diff --git a/.gitattributes b/.gitattributes index 9f43f6095a58..8e1fcf5679d6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,11 @@ packages/core/migration/**/snapshot.json linguist-generated packages/core/src/database/migration.gen.ts linguist-generated packages/core/src/models-dev/snapshot.txt linguist-generated +packages/core/src/database/schema.gen.ts linguist-generated +packages/desktop/src/main/storage/migration.gen.ts linguist-generated +packages/protocol/openapi.json linguist-generated +packages/client/src/promise/generated/** linguist-generated +packages/client/src/effect/generated/** linguist-generated +packages/client/src/effect/api/** linguist-generated packages/core/src/**/*.txt text eol=lf packages/httpapi-codegen/test/generated/*.ts text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c6dfb0ebda4e..56a232e101da 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,7 @@ # web + desktop packages packages/app/ @Hona @Brendonovich packages/desktop/ @Hona @Brendonovich + +# CI and releases. Release jobs that hold secrets run only .github/workflows/release.yml and .github/release/, +# which makes this path the release security boundary. +/.github/ @anomalyco/releasers diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 6d8bb42a21dc..57ad2d56a884 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -55,7 +55,10 @@ runs: uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 with: path: ${{ steps.cache.outputs.dir }} - key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.cache.outputs.version }}-${{ hashFiles('bun.lock', 'patches/**') }} + # Each dev merge changes beta's bun.lock. Beta falls back to the newest cache for the same Bun version + # (Bun's cache stores every package version separately) instead of installing everything from npm. + restore-keys: ${{ (github.ref_name == 'beta' && format('{0}-{1}-bun-{2}-', runner.os, runner.arch, steps.cache.outputs.version)) || '' }} - name: Install setuptools for distutils compatibility run: python3 -m pip install setuptools || pip install setuptools || true diff --git a/.github/hygiene-allowlist.txt b/.github/hygiene-allowlist.txt index e4f6ef26235f..d72599b5c343 100644 --- a/.github/hygiene-allowlist.txt +++ b/.github/hygiene-allowlist.txt @@ -8,14 +8,7 @@ pii:*.stories.* pii:*/test/* pii:*/e2e/* pii:*fixture* -pii:packages/app/src/home/projects/view.tsx -pii:packages/app/src/home/sessions/controller.tsx -pii:packages/app/src/settings/workspaces/project-options.tsx -pii:packages/app/src/settings/workspaces/projects.tsx -pii:packages/app/src/shell/routes/routes.tsx pii:packages/core/drizzle.config.ts -pii:packages/desktop/scripts/profile-by-source.ts -pii:packages/storybook/.storybook/mocks/gui-extensions/ssh.tsx pii:packages/tui/src/feature-plugins/system/storybook/session-location-missing.tsx pii:packages/web/src/content/docs/ar/permissions.mdx pii:packages/web/src/content/docs/bs/permissions.mdx @@ -42,3 +35,14 @@ pii:services/www/src/docs/content/cli/config.mdx pii:services/www/src/docs/content/plugins.mdx # Synthetic home directory in upstream documentation examples. text:/home/me +# `@/home/` module aliases. Scoped to the files that import them. +pii:packages/app/src/home/projects/view.tsx +pii:packages/app/src/home/sessions/controller.tsx +pii:packages/app/src/session/session-identity-header.tsx +pii:packages/app/src/settings/workspaces/project-options.tsx +pii:packages/app/src/settings/workspaces/projects.tsx +pii:packages/app/src/shell/routes/routes.tsx +pii:packages/app/src/shell/shell.tsx +pii:packages/app/src/shell/titlebar/inbox-model.ts +pii:packages/app/src/shell/titlebar/recently-closed-tabs-menu.tsx +pii:packages/stories/mocks/gui-plugins/ssh.tsx diff --git a/.github/nix-hashes/publish.sh b/.github/nix-hashes/publish.sh new file mode 100644 index 000000000000..2a3d38f6378e --- /dev/null +++ b/.github/nix-hashes/publish.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# publish.sh : run from a checkout of dev. Puts on bot/nix-hashes as one commit on top of dev, +# opens or updates the pull request into dev, dispatches its required checks and enables auto-merge. Closes the pull +# request instead when dev already has these hashes. Uses git, gh and jq only; never runs repository code. +set -euo pipefail + +here=$(cd "$(dirname "$0")" && pwd) +new=$(realpath "$1") +branch=bot/nix-hashes +file=nix/hashes.json +title="chore: update nix node_modules hashes" +: "${GH_REPO:?}" "${GITHUB_SHA:?}" +git=(git -c core.hooksPath=/dev/null -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com') + +bash "$here/validate.sh" "$new" + +"${git[@]}" fetch --no-tags --depth=1 origin "+refs/heads/dev:refs/remotes/origin/dev" "$GITHUB_SHA" + +# A newer push that changed the inputs has its own run, which publishes hashes for it. Mirrors on.push.paths. +if ! git diff --quiet "$GITHUB_SHA" origin/dev -- bun.lock package.json ':(glob)packages/*/package.json' \ + ':(glob)services/*/package.json' flake.lock nix/node_modules.nix nix/scripts patches; then + echo "::notice::dev changed the hash inputs since $GITHUB_SHA; leaving it to the newer run" + exit 0 +fi + +# Only a same-repository pull request is the bot's; a fork can use the same branch name. +find_pr() { + gh pr list --repo "$GH_REPO" --base dev --head "$branch" --state open --json number,isCrossRepository \ + --jq 'map(select(.isCrossRepository | not)) | .[0].number // empty' +} +pr=$(find_pr) + +"${git[@]}" checkout -B "$branch" origin/dev +if cmp -s "$new" "$file"; then + echo "dev already has these hashes" + if [ -n "$pr" ]; then + gh pr close "$pr" --repo "$GH_REPO" --delete-branch --comment "dev already has these hashes." + fi + exit 0 +fi + +cp "$new" "$file" +bash "$here/validate.sh" "$file" +"${git[@]}" add -- "$file" +"${git[@]}" commit --no-verify -m "$title" +[ "$(git diff --name-only origin/dev HEAD)" = "$file" ] || { + echo "::error::the commit changes more than $file" >&2 + exit 1 +} +"${git[@]}" push --no-verify --force origin "HEAD:refs/heads/$branch" + +if [ -z "$pr" ]; then + gh pr create --repo "$GH_REPO" --base dev --head "$branch" --title "$title" \ + --body "Updates \`$file\` to the node_modules hashes computed for $GITHUB_SHA by bot-nix-hashes. check-bot-scope keeps this branch to that file." + pr=$(find_pr) +fi + +# Pushes and pull requests made with GITHUB_TOKEN start no workflows; dispatches do. These produce the checks dev +# requires on the new head. +for workflow in check.yml check-test.yml check-bot-scope.yml; do + gh workflow run "$workflow" --repo "$GH_REPO" --ref "$branch" +done + +gh pr merge "$pr" --repo "$GH_REPO" --auto --squash +echo "### Nix hashes" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" +echo "Updated pull request #$pr" >> "${GITHUB_STEP_SUMMARY:-/dev/null}" diff --git a/.github/nix-hashes/validate.sh b/.github/nix-hashes/validate.sh new file mode 100644 index 000000000000..59a7abf1dd0a --- /dev/null +++ b/.github/nix-hashes/validate.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# validate.sh : fails unless has exactly the shape of nix/hashes.json, so the hashes bot can only ever publish +# three SRI hashes. Used by bot-nix-hashes.yml before it commits and by check-bot-scope.yml on the bot's pull request. +# Adding a system to the flake means adding it to SYSTEMS here. +set -euo pipefail + +file=${1:?usage: validate.sh } +SYSTEMS='["aarch64-darwin","aarch64-linux","x86_64-linux"]' + +fail() { + echo "::error::$file: $*" >&2 + exit 1 +} + +if [ ! -f "$file" ] || [ -L "$file" ]; then fail "not a regular file"; fi +[ "$(wc -c < "$file")" -lt 4096 ] || fail "4096 bytes or larger" +jq -se 'length == 1' "$file" > /dev/null 2>&1 || fail "not a single JSON value" +jq -e --argjson systems "$SYSTEMS" ' + type == "object" + and keys == ["nodeModules"] + and (.nodeModules | type == "object") + and (.nodeModules | keys == $systems) + and (.nodeModules | all(.[]; type == "string" and test("\\Asha256-[A-Za-z0-9+/]{43}=\\z"))) +' "$file" > /dev/null || fail "expected {\"nodeModules\": {<$SYSTEMS>: \"sha256-<44 base64 chars>\"}}" +# The canonical form rules out duplicate keys and anything else jq would drop on parse. +jq . "$file" | cmp -s - "$file" || fail "not formatted as jq . prints it" +echo "$file: valid" diff --git a/.github/publish-python-sdk.yml b/.github/publish-python-sdk.yml deleted file mode 100644 index 151ecb9944b6..000000000000 --- a/.github/publish-python-sdk.yml +++ /dev/null @@ -1,71 +0,0 @@ -# -# This file is intentionally in the wrong dir, will move and add later.... -# - -# name: publish-python-sdk - -# on: -# release: -# types: [published] -# workflow_dispatch: - -# jobs: -# publish: -# runs-on: ubuntu-latest -# permissions: -# contents: read -# steps: -# - name: Checkout repository -# uses: actions/checkout@v4 - -# - name: Setup Bun -# uses: oven-sh/setup-bun@v1 -# with: -# bun-version: 1.2.21 - -# - name: Install dependencies (JS/Bun) -# run: bun install - -# - name: Install uv -# shell: bash -# run: curl -LsSf https://astral.sh/uv/install.sh | sh - -# - name: Generate Python SDK from OpenAPI (CLI) -# shell: bash -# run: | -# ~/.local/bin/uv run --project packages/sdk/python python packages/sdk/python/scripts/generate.py --source cli - -# - name: Sync Python dependencies -# shell: bash -# run: | -# ~/.local/bin/uv sync --dev --project packages/sdk/python - -# - name: Set version from release tag -# shell: bash -# run: | -# TAG="${GITHUB_REF_NAME:-}" -# if [ -z "$TAG" ]; then -# TAG="$(git describe --tags --abbrev=0 || echo 0.0.0)" -# fi -# echo "Using version: $TAG" -# VERSION="$TAG" ~/.local/bin/uv run --project packages/sdk/python python - <<'PY' -# import os, re, pathlib -# root = pathlib.Path('packages/sdk/python') -# pt = (root / 'pyproject.toml').read_text() -# version = os.environ.get('VERSION','0.0.0').lstrip('v') -# pt = re.sub(r'(?m)^(version\s*=\s*")[^"]+("\s*)$', f"\\1{version}\\2", pt) -# (root / 'pyproject.toml').write_text(pt) -# # Also update generator config override for consistency -# cfgp = root / 'openapi-python-client.yaml' -# if cfgp.exists(): -# cfg = cfgp.read_text() -# cfg = re.sub(r'(?m)^(package_version_override:\s*)\S+$', f"\\1{version}", cfg) -# cfgp.write_text(cfg) -# PY - -# - name: Build and publish to PyPI -# env: -# PYPI_TOKEN: ${{ secrets.PYPI_API_TOKEN }} -# shell: bash -# run: | -# ~/.local/bin/uv run --project packages/sdk/python python packages/sdk/python/scripts/publish.py diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 393bf90518e9..a323ca5a6b33 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -17,6 +17,13 @@ Please provide a description of the issue, the changes you made to fix it, and w ### How did you verify your code works? +### Visual + +_Optional, for user-facing changes: a small looping animated SVG (240×72) of the change, shown in the desktop app's "What's new in beta". Replace the block below or delete this section._ + +```svg +``` + ### Screenshots / recordings _If this is a UI change, please include a screenshot or recording._ diff --git a/.github/release/download/action.yml b/.github/release/download/action.yml new file mode 100644 index 000000000000..de159d799b17 --- /dev/null +++ b/.github/release/download/action.yml @@ -0,0 +1,21 @@ +name: Download release artifacts +description: Downloads artifacts into $RUNNER_TEMP/in/ and verifies each against its SHA256SUMS. +inputs: + pattern: + description: Artifact name pattern + required: true + digests: + description: Space-separated name=digest pairs for artifacts made by sign jobs, from their job outputs + required: false + default: "" +runs: + using: composite + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + pattern: ${{ inputs.pattern }} + path: ${{ runner.temp }}/in + - shell: bash + run: bash "${GITHUB_ACTION_PATH//\\//}/../verify-artifacts.sh" "$RUNNER_TEMP/in" + env: + OPENCODE_DIGESTS: ${{ inputs.digests }} diff --git a/packages/cli/script/entitlements.plist b/.github/release/entitlements/cli.plist similarity index 100% rename from packages/cli/script/entitlements.plist rename to .github/release/entitlements/cli.plist diff --git a/packages/desktop/resources/entitlements.cli.plist b/.github/release/entitlements/desktop-cli.plist similarity index 100% rename from packages/desktop/resources/entitlements.cli.plist rename to .github/release/entitlements/desktop-cli.plist diff --git a/packages/desktop/resources/entitlements.plist b/.github/release/entitlements/desktop.plist similarity index 100% rename from packages/desktop/resources/entitlements.plist rename to .github/release/entitlements/desktop.plist diff --git a/.github/release/lib.sh b/.github/release/lib.sh new file mode 100644 index 000000000000..76ec08e6f308 --- /dev/null +++ b/.github/release/lib.sh @@ -0,0 +1,110 @@ +# shellcheck shell=bash +# Shared helpers for the release scripts. Sourced, never executed. +# +# These scripts run in sign and publish jobs that hold secrets. They must never execute repository code +# or artifact contents: artifacts are data, and every value that names a destination comes from the +# environment the workflow sets (OPENCODE_VERSION, OPENCODE_CHANNEL, OPENCODE_DRY_RUN, ...). + +set -euo pipefail + +# shellcheck disable=SC2034 # used by the scripts that source this file +release_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + +die() { + echo "::error::$*" >&2 + exit 1 +} + +dry_run() { + [ "${OPENCODE_DRY_RUN:-false}" = true ] +} + +sha256() { + if command -v sha256sum > /dev/null; then sha256sum "$@"; else shasum -a 256 "$@"; fi +} + +# Windows runners pass D:\a\_temp style paths; Git Bash tools want /d/a/_temp. +posix() { + if command -v cygpath > /dev/null; then cygpath -u "$1"; else printf '%s' "$1"; fi +} + +size() { + wc -c < "$1" | tr -d ' ' +} + +# Uploads files to opencode.ai/files/bin// and prints the update-server file map. +upload_files() { + local credentials=false + for file in "$@"; do + test -s "$file" || die "Missing release file: $file" + local name type + name=$(basename "$file") + case "$name" in + *.tar.gz) type=application/gzip ;; + *.zip) type=application/zip ;; + *) type=application/octet-stream ;; + esac + if dry_run; then + echo "dry-run upload: bin/$OPENCODE_VERSION/$name" >&2 + else + if [ "$credentials" = false ]; then + r2_credentials + credentials=true + fi + aws s3 cp "$file" "s3://opencode-production-files/bin/$OPENCODE_VERSION/$name" --only-show-errors \ + --endpoint-url "https://$CLOUDFLARE_ACCOUNT_ID.r2.cloudflarestorage.com" \ + --content-type "$type" --content-disposition "attachment; filename=\"$name\"" \ + --cache-control "public, max-age=31536000, immutable" >&2 + fi + jq -n --arg name "$name" --arg url "https://opencode.ai/files/bin/$OPENCODE_VERSION/$name" \ + --arg sha256 "$(sha256 "$file" | cut -c1-64)" --argjson size "$(size "$file")" \ + '{($name): {url: $url, sha256: $sha256, size: $size}}' + done | jq -s 'add' +} + +# R2 accepts the API token ID as the access key and SHA-256(token) as the secret. +r2_credentials() { + local id + id=$(curl -fsS -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \ + "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/tokens/verify" | jq -er .result.id) + export AWS_ACCESS_KEY_ID="$id" + AWS_SECRET_ACCESS_KEY=$(printf '%s' "$CLOUDFLARE_API_TOKEN" | sha256 | cut -c1-64) + export AWS_SECRET_ACCESS_KEY + export AWS_DEFAULT_REGION=auto AWS_REQUEST_CHECKSUM_CALCULATION=when_required AWS_RESPONSE_CHECKSUM_VALIDATION=when_required +} + +# Registers one artifact with the update server: publish_update . +publish_update() { + local body status token attempt + body=$(jq -n --arg channel "$OPENCODE_CHANNEL" --arg version "$OPENCODE_VERSION" --arg name "$1" --arg distribution "$2" \ + --argjson metadata "$3" '{channel: $channel, name: $name, distribution: $distribution, version: $version, metadata: $metadata}') + echo "$body" | jq . + if dry_run; then return; fi + token=$(curl -fsS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://update.opencode.ai" | jq -er .value) + for attempt in 1 2 3; do + status=$(curl -sS -o "$RUNNER_TEMP/update-response" -w '%{http_code}' -X POST https://opencode.ai/update/api/publish \ + -H "Authorization: Bearer $token" -H "Content-Type: application/json" --data "$body") + if [ "$status" -lt 300 ]; then return; fi + cat "$RUNNER_TEMP/update-response" + # D1 can commit the upsert and still time out. Replaying the same artifact is safe. + if [ "$status" -lt 500 ] || [ "$attempt" = 3 ]; then die "Update server returned $status"; fi + sleep "$attempt" + done +} + +# Clones a repository, lets a render script fill it, then commits and pushes any change: +# push_git [args...]. Dry runs render into an empty directory. +push_git() { + local url=$1 message=$2 dir + shift 2 + dir=$(mktemp -d) + if ! dry_run; then git clone -q "$url" "$dir"; fi + "$@" "$dir" + if dry_run; then return; fi + git -C "$dir" add -A + if git -C "$dir" diff --cached --quiet; then return; fi + git -C "$dir" -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit -q -m "$message" + git -C "$dir" push -q origin HEAD:master +} diff --git a/.github/release/plan-release.sh b/.github/release/plan-release.sh new file mode 100755 index 000000000000..01ed6f3a4c8e --- /dev/null +++ b/.github/release/plan-release.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# plan-release.sh: decides version, channel and npm dist-tag from the event, ref and dispatch inputs +# (INPUT_BUMP, INPUT_VERSION) only, and writes them to $GITHUB_OUTPUT. Nothing here reads repository files. +# +# push or dispatch on dev -> dev 0.0.0-dev- npm tag dev, desktop +# dispatch on dev with bump/version -> latest x.y.z npm tag latest, releasers only +# push or dispatch on beta -> beta 0.0.0-beta- desktop only +# pull_request -> pr 0.0.0-pr- everything, dry run +# +# Releasers are actors with the admin or maintain role on the repository (the releasers team has maintain). +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +versioned=false +if [ -n "${INPUT_BUMP:-}" ] || [ -n "${INPUT_VERSION:-}" ]; then versioned=true; fi +case "$GITHUB_EVENT_NAME:$GITHUB_REF:$versioned" in + push:refs/heads/dev:false | workflow_dispatch:refs/heads/dev:false) channel="dev" ;; + workflow_dispatch:refs/heads/dev:true) channel="latest" ;; + push:refs/heads/beta:false | workflow_dispatch:refs/heads/beta:false) channel="beta" ;; + pull_request:*:false) channel="pr" ;; + *) die "$GITHUB_EVENT_NAME on $GITHUB_REF cannot publish (versioned: $versioned)" ;; +esac + +if [ "$channel" = latest ]; then + actor=${GITHUB_TRIGGERING_ACTOR:-${GITHUB_ACTOR:-}} + [ -n "$actor" ] || die "Stable releases can only be started by releasers; the triggering actor is unknown" + role=$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$actor/permission" --jq .role_name) || role="" + case "$role" in + admin | maintain) ;; + *) die "Stable releases can only be started by releasers (admin or maintain on the repo); $actor has ${role:-no readable role}" ;; + esac +fi + +if [ "$channel" = latest ] && [ -n "${INPUT_VERSION:-}" ]; then + version=$INPUT_VERSION +elif [ "$channel" = latest ]; then + current=$(curl -fsS https://registry.npmjs.org/-/package/@opencode%2fcli/dist-tags | jq -r .latest) + [[ "$current" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || die "Unexpected latest version: $current" + major=${BASH_REMATCH[1]} minor=${BASH_REMATCH[2]} patch=${BASH_REMATCH[3]} + case "$INPUT_BUMP" in + major) version="$((major + 1)).0.0" ;; + minor) version="$major.$((minor + 1)).0" ;; + *) version="$major.$minor.$((patch + 1))" ;; + esac + if [ "$major" -lt 2 ]; then version=2.0.0; fi +else + version="0.0.0-$channel-$GITHUB_RUN_NUMBER" + if [ "$GITHUB_RUN_ATTEMPT" != 1 ]; then version="$version.$GITHUB_RUN_ATTEMPT"; fi +fi + +if [ "$channel" = latest ]; then + if ! [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || [[ "$version" == 0.0.0-* ]]; then + die "Invalid release version: $version" + fi + status=$(curl -sS -o /dev/null -w '%{http_code}' "https://registry.npmjs.org/@opencode%2fcli/$version") + [ "$status" != 200 ] || die "@opencode/cli@$version is already published" +fi + +# What each channel builds and publishes. +case "$channel" in + latest) npm_tag=latest cli=true desktop=true node=false release=true ;; + dev) npm_tag=dev cli=true desktop=true node=true release=false ;; + beta) npm_tag="" cli=false desktop=true node=false release=false ;; + pr) npm_tag=pr cli=true desktop=true node=true release=true ;; +esac +dry_run=false +if [ "$channel" = pr ]; then dry_run=true; fi + +{ + echo "version=$version" + echo "channel=$channel" + echo "npm_tag=$npm_tag" + echo "dry_run=$dry_run" + echo "cli=$cli" + echo "desktop=$desktop" + echo "node=$node" + echo "release=$release" +} | tee -a "$GITHUB_OUTPUT" diff --git a/.github/release/publish-aur.sh b/.github/release/publish-aur.sh new file mode 100755 index 000000000000..771170d0966f --- /dev/null +++ b/.github/release/publish-aur.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# publish-aur.sh : updates opencode-beta on the AUR over SSH (AUR_KEY, pinned host key) and +# registers the aur update artifact. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +if ! dry_run; then + mkdir -p ~/.ssh + printf '%s\n' "$AUR_KEY" > ~/.ssh/aur + chmod 600 ~/.ssh/aur + echo "aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN" > ~/.ssh/known_hosts + export GIT_SSH_COMMAND="ssh -i $HOME/.ssh/aur -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" +fi +push_git ssh://aur@aur.archlinux.org/opencode-beta.git "chore: update opencode-beta to ${OPENCODE_VERSION//-/.}" \ + bash "$release_dir/render-aur.sh" "$1" +publish_update cli aur '{"package": "opencode-beta"}' diff --git a/.github/release/publish-cli.sh b/.github/release/publish-cli.sh new file mode 100755 index 000000000000..22c2bb53d500 --- /dev/null +++ b/.github/release/publish-cli.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# publish-cli.sh : uploads the standalone CLI +# archives to opencode.ai/files and registers the cli (and, off latest, cli-node) update artifacts. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +archives=$1 macos=$2 windows=$3 +cli=() +for target in linux-arm64 linux-x64 linux-x64-baseline linux-arm64-musl linux-x64-musl linux-x64-baseline-musl; do cli+=("$archives/opencode-$target.tar.gz"); done +for target in darwin-arm64 darwin-x64 darwin-x64-baseline; do cli+=("$macos/opencode-$target.zip"); done +for target in windows-arm64 windows-x64 windows-x64-baseline; do cli+=("$windows/opencode-$target.zip"); done + +files=$(upload_files "${cli[@]}") +publish_update cli npm '{"package": "@opencode/cli"}' +publish_update cli opencode "$(jq -n --argjson files "$files" '{files: $files}')" + +if [ "$OPENCODE_CHANNEL" != latest ]; then + node=() + for target in linux-arm64 linux-x64; do node+=("$archives/opencode2-node-$target.tar.gz"); done + for target in darwin-arm64 windows-arm64 windows-x64; do node+=("$archives/opencode2-node-$target.zip"); done + files=$(upload_files "${node[@]}") + publish_update cli-node npm '{"package": "@opencode/cli-node"}' + publish_update cli-node opencode "$(jq -n --argjson files "$files" '{files: $files}')" +fi diff --git a/.github/release/publish-desktop.sh b/.github/release/publish-desktop.sh new file mode 100755 index 000000000000..3525f7b66e06 --- /dev/null +++ b/.github/release/publish-desktop.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# publish-desktop.sh +# : uploads the desktop builds to opencode.ai/files and registers the desktop update +# artifact. The electron-updater feeds (latest*.yml) are computed here from the final signed files. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +macos=$1 windows=$2 linux_x64=$3 linux_arm64=$4 +win=("$windows/opencode-desktop-win-arm64.exe" "$windows/opencode-desktop-win-x64.exe") +mac=("$macos/opencode-desktop-mac-arm64.zip" "$macos/opencode-desktop-mac-arm64.dmg" "$macos/opencode-desktop-mac-x64.zip" "$macos/opencode-desktop-mac-x64.dmg") +linux=("$linux_x64/opencode-desktop-linux-x86_64.AppImage" "$linux_x64/opencode-desktop-linux-amd64.deb" "$linux_x64/opencode-desktop-linux-x86_64.rpm") +linux_arm=("$linux_arm64/opencode-desktop-linux-arm64.AppImage" "$linux_arm64/opencode-desktop-linux-arm64.deb" "$linux_arm64/opencode-desktop-linux-aarch64.rpm") +# Dev and beta skip the .app.tar.gz, which only the retired Tauri updater feed read. +bundles=() +case "$OPENCODE_CHANNEL" in + dev | beta) ;; + *) bundles=("$macos/opencode-desktop-mac-arm64.app.tar.gz" "$macos/opencode-desktop-mac-x64.app.tar.gz") ;; +esac + +feed() { + for file in "$@"; do + jq -n --arg url "https://opencode.ai/files/bin/$OPENCODE_VERSION/$(basename "$file")" \ + --arg sha512 "$(openssl dgst -sha512 -binary "$file" | base64 | tr -d '\n')" --argjson size "$(size "$file")" \ + '{url: $url, sha512: $sha512, size: $size}' + done | jq -s --arg date "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" '{files: ., releaseDate: $date}' +} + +files=$(upload_files "${win[@]}" "${mac[@]}" "${linux[@]}" "${linux_arm[@]}" "${bundles[@]}") +manifests=$(jq -n --argjson win "$(feed "${win[@]}")" --argjson mac "$(feed "${mac[@]}")" \ + --argjson linux "$(feed "${linux[@]}")" --argjson linux_arm64 "$(feed "${linux_arm[@]}")" \ + '{"desktop.yml": $win, "desktop-mac.yml": $mac, "desktop-linux.yml": $linux, "desktop-linux-arm64.yml": $linux_arm64}') +publish_update desktop opencode "$(jq -n --argjson files "$files" --argjson manifests "$manifests" '{files: $files, manifests: $manifests}')" diff --git a/.github/release/publish-docker.sh b/.github/release/publish-docker.sh new file mode 100755 index 000000000000..e899731a5036 --- /dev/null +++ b/.github/release/publish-docker.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# publish-docker.sh : pushes the multi-arch OCI image to ghcr.io/anomalyco/opencode: +# without building anything (GH_TOKEN with packages: write). +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +image="$1/opencode-image.tar" +skopeo inspect --raw "oci-archive:$image" | jq . +if dry_run; then exit 0; fi +printf '%s' "$GH_TOKEN" | skopeo login ghcr.io --username "$GITHUB_ACTOR" --password-stdin +skopeo copy --all "oci-archive:$image" "docker://ghcr.io/anomalyco/opencode:$OPENCODE_VERSION" diff --git a/.github/release/publish-github-release.sh b/.github/release/publish-github-release.sh new file mode 100755 index 000000000000..ae3776d4e0bc --- /dev/null +++ b/.github/release/publish-github-release.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# publish-github-release.sh ...: attaches the CLI archives and desktop builds in the given artifact dirs, plus +# a SHA256SUMS, to the v GitHub release with OPENCODE_RELEASE_NOTES. Immutable releases lock assets once +# published, so they go to a draft first. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +assets=$(mktemp -d) +for dir in "$@"; do + find "$dir" -maxdepth 1 -type f \( -name 'opencode-*.zip' -o -name 'opencode-*.tar.gz' -o -name 'opencode-desktop-*' \) \ + ! -name '*.tgz' ! -name '*.app.tar' -exec cp {} "$assets/" \; +done +(cd "$assets" && sha256 -- * > SHA256SUMS && cat SHA256SUMS) +printf '%s\n' "${OPENCODE_RELEASE_NOTES:-}" > "$assets.md" +if dry_run; then exit 0; fi + +tag="v$OPENCODE_VERSION" +# OpenCode 1 update paths (CLI upgrade, electron-updater, Scoop) follow GitHub's Latest release, which must stay on 1.x. +gh release view "$tag" > /dev/null 2>&1 || + gh release create "$tag" --draft --verify-tag --latest=false --title "$tag" --notes-file "$assets.md" +gh release upload "$tag" "$assets"/* --clobber +gh release edit "$tag" --draft=false --latest=false diff --git a/.github/release/publish-homebrew.sh b/.github/release/publish-homebrew.sh new file mode 100755 index 000000000000..460275753f85 --- /dev/null +++ b/.github/release/publish-homebrew.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# publish-homebrew.sh : updates opencode-v2 in anomalyco/homebrew-tap over SSH +# with its write deploy key (HOMEBREW_TAP_DEPLOY_KEY) and registers the homebrew update artifact. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +if ! dry_run; then + test -n "${HOMEBREW_TAP_DEPLOY_KEY:-}" || die "Missing HOMEBREW_TAP_DEPLOY_KEY" + ssh_dir=$(mktemp -d) + printf '%s\n' "$HOMEBREW_TAP_DEPLOY_KEY" > "$ssh_dir/key" + chmod 600 "$ssh_dir/key" + echo "github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl" > "$ssh_dir/known_hosts" + export GIT_SSH_COMMAND="ssh -i $ssh_dir/key -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$ssh_dir/known_hosts" +fi + +push_git "git@github.com:anomalyco/homebrew-tap.git" \ + "chore: update opencode-v2 to $OPENCODE_VERSION" bash "$release_dir/render-homebrew.sh" "$1" "$2" +publish_update cli homebrew '{"package": "anomalyco/tap/opencode-v2"}' diff --git a/.github/release/publish-npm.sh b/.github/release/publish-npm.sh new file mode 100755 index 000000000000..883ae79803a9 --- /dev/null +++ b/.github/release/publish-npm.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# publish-npm.sh ...: publishes every release tarball to npm with OPENCODE_NPM_TAG. +# Names, the version and the package set come from this script, never from the tarballs: each tarball's +# package.json is read as data and must match. macOS and Windows CLI packages must come from a signed dir. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +npm_dir=$1 +shift +targets="linux-arm64 linux-x64 linux-x64-baseline linux-arm64-musl linux-x64-musl linux-x64-baseline-musl darwin-arm64 darwin-x64 darwin-x64-baseline windows-arm64 windows-x64 windows-x64-baseline" +expected="@opencode/cli" +for target in $targets; do expected="$expected @opencode/cli-$target"; done +for lib in schema codemode theme ai util protocol client plugin plugin-browser core simulation server sdk ui; do expected="$expected @opencode/$lib"; done +if [ "$OPENCODE_CHANNEL" != latest ]; then + expected="$expected @opencode/cli-node" + for target in linux-arm64 linux-x64 darwin-arm64 windows-arm64 windows-x64; do expected="$expected @opencode/cli-node-$target"; done +fi + +if ! dry_run; then + for file in "$npm_dir"/*.tgz; do + gh attestation verify "$file" --repo anomalyco/opencode --signer-workflow anomalyco/opencode/.github/workflows/release.yml > /dev/null + done +fi + +shopt -s nullglob +files=() +for dir in "$@"; do files+=("$dir"/*.tgz); done +files+=("$npm_dir"/*.tgz) +list=$(mktemp) +for file in "${files[@]}"; do + IFS=$'\t' read -r name version config install < <(tar -xzOf "$file" package/package.json | + jq -r '[.name, .version, ((.publishConfig // {}) | keys - ["access"] | length > 0), + ((.scripts // {}) | (has("preinstall") or has("install") or has("postinstall")))] | @tsv') + # shellcheck disable=SC2086 # $expected is a word list + printf '%s\n' $expected | grep -qxF -- "$name" || die "Unexpected package $name in $file" + # The signed copy of a macOS or Windows CLI package replaces the unsigned one from the build. + if cut -f1 "$list" | grep -qxF -- "$name"; then continue; fi + case "$name:$file" in + @opencode/cli-darwin-*:"$npm_dir"/* | @opencode/cli-windows-*:"$npm_dir"/*) die "$name is not signed" ;; + esac + [ "$version" = "$OPENCODE_VERSION" ] || die "$name has version $version, expected $OPENCODE_VERSION" + [ "$config" = false ] || die "$name sets publishConfig beyond access" + [ "$install" = false ] || [ "$name" = @opencode/cli ] || [ "$name" = @opencode/cli-node ] || die "$name has an install script" + printf '%s\t%s\n' "$name" "$file" >> "$list" +done +for name in $expected; do cut -f1 "$list" | grep -qxF -- "$name" || die "Missing package $name"; done + +# The CLI wrappers resolve their platform packages, so they publish last. +order=$(mktemp) +{ grep -vP '^@opencode/cli(-node)?\t' "$list" || true; grep -P '^@opencode/cli(-node)?\t' "$list"; } > "$order" +cat "$order" + +# A clean directory and config, so nothing from an artifact can redirect the registry. +printf 'registry=https://registry.npmjs.org/\n' > "$RUNNER_TEMP/npmrc" +export NPM_CONFIG_USERCONFIG="$RUNNER_TEMP/npmrc" +cd "$(mktemp -d)" || die "cannot create a clean publish directory" +flags=(--tag "$OPENCODE_NPM_TAG" --access public --ignore-scripts) +if dry_run; then flags+=(--dry-run); else flags+=(--provenance); fi +while IFS=$'\t' read -r name file; do + if ! dry_run && npm view "$name@$OPENCODE_VERSION" version > /dev/null 2>&1; then + echo "already published $name@$OPENCODE_VERSION" + continue + fi + npm publish "$file" "${flags[@]}" +done < "$order" diff --git a/.github/release/publish-version-pr.sh b/.github/release/publish-version-pr.sh new file mode 100755 index 000000000000..1b3db4541f6f --- /dev/null +++ b/.github/release/publish-version-pr.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# publish-version-pr.sh : opens the pull request that brings the release commit (versions and changelog) back to dev. +# Pushing to dev is blocked by rulesets, so this never merges. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +branch="release-v$OPENCODE_VERSION" +gh api "repos/$GH_REPO/git/refs" -f "ref=refs/heads/$branch" -f "sha=$1" > /dev/null || + gh api "repos/$GH_REPO/git/refs/heads/$branch" > /dev/null +body="Brings the versions and changelog of the published \`v$OPENCODE_VERSION\` release back to \`dev\`. The \`v$OPENCODE_VERSION\` tag points at this commit." +gh pr create --base dev --head "$branch" --title "release: v$OPENCODE_VERSION" --body "$body" || + echo "::warning::Could not open the release PR. Open it by hand: gh pr create --base dev --head $branch" diff --git a/.github/release/render-aur.sh b/.github/release/render-aur.sh new file mode 100755 index 000000000000..8317fd603b28 --- /dev/null +++ b/.github/release/render-aur.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# render-aur.sh : writes PKGBUILD, .SRCINFO and the opencode2 shim for opencode-beta. +# The package installs the published npm tarballs, so their hashes come from the build-npm artifact. +# LICENSE comes from the AUR repository itself. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +npm=$1 aur=$2 +name=opencode-beta +pkgver="${OPENCODE_VERSION//-/.}" +if dry_run && [ ! -f "$aur/LICENSE" ]; then printf 'MIT License\n' > "$aur/LICENSE"; fi +test -s "$aur/LICENSE" || die "The AUR repository has no LICENSE" +printf '#!/bin/sh\nexec "$(dirname "$0")/opencode" "$@"\n' > "$aur/opencode2" +sum() { sha256 "$1" | cut -c1-64; } +x64=$(sum "$npm/opencode-cli-linux-x64-baseline-$OPENCODE_VERSION.tgz") +arm64=$(sum "$npm/opencode-cli-linux-arm64-$OPENCODE_VERSION.tgz") +x64_url="https://registry.npmjs.org/@opencode/cli-linux-x64-baseline/-/cli-linux-x64-baseline-$OPENCODE_VERSION.tgz" +arm64_url="https://registry.npmjs.org/@opencode/cli-linux-arm64/-/cli-linux-arm64-$OPENCODE_VERSION.tgz" +license=$(sum "$aur/LICENSE") +shim=$(sum "$aur/opencode2") + +# Stripping a compiled Bun executable can damage its embedded application. +cat > "$aur/PKGBUILD" < +pkgname=$name +pkgver=$pkgver +pkgrel=1 +pkgdesc='OpenCode beta - the AI coding agent for the terminal' +url='https://github.com/anomalyco/opencode' +arch=('x86_64' 'aarch64') +license=('MIT') +depends=('glibc' 'gcc-libs' 'ripgrep') +provides=('opencode' 'opencode2') +conflicts=('opencode' 'opencode2') +options=('!strip' '!debug') +source=('LICENSE' 'opencode2') +sha256sums=('$license' '$shim') +source_x86_64=('$name-$pkgver-x86_64.tgz::$x64_url') +sha256sums_x86_64=('$x64') +source_aarch64=('$name-$pkgver-aarch64.tgz::$arm64_url') +sha256sums_aarch64=('$arm64') + +package() { + install -Dm755 "\$srcdir/package/bin/opencode" "\$pkgdir/usr/bin/opencode" + install -Dm755 "\$srcdir/opencode2" "\$pkgdir/usr/bin/opencode2" + install -Dm644 "\$srcdir/LICENSE" "\$pkgdir/usr/share/licenses/\$pkgname/LICENSE" +} +PKGBUILD + +# What makepkg --printsrcinfo prints for the PKGBUILD above. +t=$'\t' +cat > "$aur/.SRCINFO" < : writes Formula opencode-v2.rb with the +# hashes of the signed macOS and the Linux archives. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +macos=$1 archives=$2 tap=$3 +sum() { sha256 "$1" | cut -c1-64; } +base="https://opencode.ai/files/bin/$OPENCODE_VERSION" +cat > "$tap/opencode-v2.rb" < : writes the WinGet manifests for +# Anomaly.OpenCode and Anomaly.OpenCode.Desktop. Submission to microsoft/winget-pkgs is paused until the packages +# are accepted, so the workflow only uploads them. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +cli=$1 desktop=$2 out=$3 +sum() { sha256 "$1" | cut -c1-64 | tr a-f A-F; } +base="https://opencode.ai/files/bin/$OPENCODE_VERSION" +date=$(date -u +%F) +header() { + printf '# yaml-language-server: $schema=https://aka.ms/winget-manifest.%s.1.12.0.schema.json\n\nPackageIdentifier: %s\nPackageVersion: %s\n' \ + "$2" "$1" "$OPENCODE_VERSION" +} +footer() { printf 'ManifestType: %s\nManifestVersion: 1.12.0\n' "$1"; } +manifests() { + local id=$1 name=$2 description=$3 moniker=$4 + shift 4 + mkdir -p "$out/$id" + { header "$id" version; echo "DefaultLocale: en-US"; footer version; } > "$out/$id/$id.yaml" + { header "$id" installer; printf '%s\n' "$@" "ReleaseDate: $date"; footer installer; } > "$out/$id/$id.installer.yaml" + { + header "$id" defaultLocale + printf '%s\n' "PackageLocale: en-US" "Publisher: OpenCode" "PublisherUrl: https://anoma.ly/" \ + "PublisherSupportUrl: https://github.com/anomalyco/opencode/issues" "PrivacyUrl: https://opencode.ai/legal/privacy-policy" \ + "PackageName: $name" "PackageUrl: https://opencode.ai/" "License: MIT" "LicenseUrl: https://github.com/anomalyco/opencode/blob/HEAD/LICENSE" \ + "ShortDescription: $description" "Moniker: $moniker" "Tags:" "- ai" "- coding-agent" "- developer-tools" \ + "Documentations:" "- DocumentLabel: Docs" " DocumentUrl: https://opencode.ai/v2/docs" + footer defaultLocale + } > "$out/$id/$id.locale.en-US.yaml" +} + +manifests Anomaly.OpenCode "OpenCode CLI" "The open source AI coding agent for the terminal." opencode \ + "InstallerType: zip" "NestedInstallerType: portable" "NestedInstallerFiles:" \ + "- RelativeFilePath: opencode.exe" " PortableCommandAlias: opencode" "Commands:" "- opencode" "Installers:" \ + "- Architecture: x64" " InstallerUrl: $base/opencode-windows-x64-baseline.zip" " InstallerSha256: $(sum "$cli/opencode-windows-x64-baseline.zip")" \ + "- Architecture: arm64" " InstallerUrl: $base/opencode-windows-arm64.zip" " InstallerSha256: $(sum "$cli/opencode-windows-arm64.zip")" + +# The display name and uninstall key match what the NSIS installer registers for ai.opencode.desktop. +manifests Anomaly.OpenCode.Desktop "OpenCode" "The open source AI coding agent as a desktop app." opencode-desktop \ + "InstallerType: nullsoft" "Scope: user" "UpgradeBehavior: install" "ProductCode: d074f30d-5f88-5885-b075-be1348cc7676" \ + "Protocols:" "- opencode" "Installers:" \ + "- Architecture: x64" " InstallerUrl: $base/opencode-desktop-win-x64.exe" " InstallerSha256: $(sum "$desktop/opencode-desktop-win-x64.exe")" \ + "- Architecture: arm64" " InstallerUrl: $base/opencode-desktop-win-arm64.exe" " InstallerSha256: $(sum "$desktop/opencode-desktop-win-arm64.exe")" +tail -n +1 "$out"/*/* diff --git a/.github/release/repack-windows.sh b/.github/release/repack-windows.sh new file mode 100755 index 000000000000..c660a55ca222 --- /dev/null +++ b/.github/release/repack-windows.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# repack-windows.sh : checks every signature in , then writes the signed CLI back +# into npm tarballs and opencode-.zip archives in . +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +work=$(posix "$1") out=$(posix "$2") +bash "$release_dir/verify-signatures-windows.sh" "$work" +mkdir -p "$out" +if [ ! -d "$work/cli" ]; then exit 0; fi +for target in windows-arm64 windows-x64 windows-x64-baseline; do + tar -czf "$out/opencode-cli-$target-$OPENCODE_VERSION.tgz" -C "$work/cli/$target" package + powershell -NoProfile -Command "Compress-Archive -Path '$(cygpath -w "$work/cli/$target/package/bin/opencode.exe")' -DestinationPath '$(cygpath -w "$out/opencode-$target.zip")'" +done diff --git a/.github/release/sign-cli-macos.sh b/.github/release/sign-cli-macos.sh new file mode 100755 index 000000000000..134b01c86914 --- /dev/null +++ b/.github/release/sign-cli-macos.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# sign-cli-macos.sh : signs the three macOS CLI binaries inside their npm tarballs with +# the Developer ID in build.keychain, then writes the re-packed tarballs and the opencode-.zip archives. +# Dry runs sign ad-hoc. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +in=$1 out=$2 +mkdir -p "$out" +identity=- timestamp=--timestamp=none +if ! dry_run; then + identity=$(security find-identity -v -p codesigning build.keychain | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1) + [ -n "$identity" ] || die "Developer ID Application identity not found" + timestamp=--timestamp +fi + +export COPYFILE_DISABLE=1 +for target in darwin-arm64 darwin-x64 darwin-x64-baseline; do + work=$(mktemp -d) + tar -xzf "$in/opencode-cli-$target-$OPENCODE_VERSION.tgz" -C "$work" + bin="$work/package/bin/opencode" + file -b "$bin" | grep -q '^Mach-O' || die "$target is not a Mach-O binary" + chmod 755 "$bin" + codesign --force "$timestamp" --options runtime --entitlements "$release_dir/entitlements/cli.plist" --sign "$identity" "$bin" + codesign --verify --strict --verbose=2 "$bin" + tar --no-mac-metadata -czf "$out/opencode-cli-$target-$OPENCODE_VERSION.tgz" -C "$work" package + touch -t 198001010000 "$bin" + (cd "$work/package/bin" && zip -X -q "$out/opencode-$target.zip" opencode) +done diff --git a/.github/release/sign-desktop-macos.sh b/.github/release/sign-desktop-macos.sh new file mode 100755 index 000000000000..143f25ac90fd --- /dev/null +++ b/.github/release/sign-desktop-macos.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# sign-desktop-macos.sh ...: signs each unsigned .app inside-out with hardened runtime, +# notarizes them in one submission, staples them, and only then builds opencode-desktop-mac-.{zip,dmg,app.tar.gz} +# from the stapled apps. Dry runs sign ad-hoc and skip notarization. Dev and beta trade larger files for faster +# packaging (zip and dmg at zlib level 1) and skip the .app.tar.gz, which only the retired Tauri updater feed read. +# Notarization reads APPLE_API_KEY_CONTENT, APPLE_API_KEY_ID and APPLE_API_ISSUER. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +out=$1 +shift +mkdir -p "$out" +apps=$(mktemp -d) +identity=- timestamp=--timestamp=none +zip_level=() dmg_level=() preview=false +case "$OPENCODE_CHANNEL" in dev | beta) preview=true ;; esac +if [ "$preview" = true ]; then zip_level=(--zlibCompressionLevel 1) dmg_level=(-imagekey zlib-level=1); fi +if ! dry_run; then + identity=$(security find-identity -v -p codesigning build.keychain | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1) + [ -n "$identity" ] || die "Developer ID Application identity not found" + timestamp=--timestamp +fi +sign() { + codesign --force "$timestamp" --options runtime --entitlements "$2" --sign "$identity" "$1" +} +bundle() { + find "$apps/$1" -mindepth 1 -maxdepth 1 -name '*.app' -type d +} + +for tarball in "$@"; do + arch=$(basename "$tarball" .app.tar) + arch=${arch#opencode-desktop-mac-} + [ "$arch" = arm64 ] || [ "$arch" = x64 ] || die "Unexpected app tarball: $tarball" + mkdir -p "$apps/$arch" + tar -xf "$tarball" -C "$apps/$arch" + if [ "$(find "$apps/$arch" -mindepth 1 -maxdepth 1 | wc -l | tr -d ' ')" != 1 ] || [ -z "$(bundle "$arch")" ]; then + die "Expected exactly one .app bundle in $tarball" + fi + app=$(bundle "$arch") + + # Every nested Mach-O file and bundle, deepest first, then the app itself. + list=$(mktemp) + { + find "$app/Contents" -type f -print0 | while IFS= read -r -d '' file; do + case "$(file -b --mime-type "$file")" in application/x-mach-binary*) printf '%s\n' "$file" ;; esac + done + find "$app/Contents" -type d \( -name '*.app' -o -name '*.framework' \) -print + } | awk -F/ '{ print NF "\t" $0 }' | sort -t "$(printf '\t')" -k1,1nr | cut -f2- > "$list" + echo "Signing $(wc -l < "$list" | tr -d ' ') items in $app" + while IFS= read -r item; do + if [ "$item" = "$app/Contents/Resources/opencode-cli" ]; then + # The bundled Bun CLI loads bun-pty's native library; Electron and its helpers do not need this exception. + sign "$item" "$release_dir/entitlements/desktop-cli.plist" + else + sign "$item" "$release_dir/entitlements/desktop.plist" + fi + done < "$list" + sign "$app" "$release_dir/entitlements/desktop.plist" + codesign --verify --deep --strict --verbose=2 "$app" +done + +if ! dry_run; then + key=$(mktemp) + printf '%s\n' "$APPLE_API_KEY_CONTENT" > "$key" + ditto -c -k --sequesterRsrc "$apps" "$apps.zip" + xcrun notarytool submit "$apps.zip" --key "$key" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER" \ + --wait --timeout 60m --output-format json > "$apps.json" + rm -f "$key" + cat "$apps.json" + [ "$(jq -r .status "$apps.json")" = Accepted ] || die "Notarization was not accepted" +fi + +for dir in "$apps"/*; do + arch=$(basename "$dir") + app=$(bundle "$arch") + name=$(basename "$app") + if ! dry_run; then + xcrun stapler staple "$app" + xcrun stapler validate "$app" + spctl --assess --type execute --verbose=2 "$app" + fi + ditto -c -k ${zip_level[@]+"${zip_level[@]}"} --sequesterRsrc --keepParent "$app" "$out/opencode-desktop-mac-$arch.zip" + staging=$(mktemp -d) + ditto "$app" "$staging/$name" + ln -s /Applications "$staging/Applications" + hdiutil create -volname "${name%.app}" -srcfolder "$staging" -fs HFS+ -format UDZO ${dmg_level[@]+"${dmg_level[@]}"} -ov "$out/opencode-desktop-mac-$arch.dmg" + if [ "$preview" = true ]; then continue; fi + COPYFILE_DISABLE=1 tar --no-mac-metadata -czf "$out/opencode-desktop-mac-$arch.app.tar.gz" -C "$dir" "$name" +done diff --git a/.github/release/tag-release.sh b/.github/release/tag-release.sh new file mode 100755 index 000000000000..1ef6fa2a8977 --- /dev/null +++ b/.github/release/tag-release.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# tag-release.sh: in a checkout of the dispatched dev commit, commits the release versions and changelog +# (OPENCODE_RELEASE_NOTES) and pushes only the v tag at it. Writes sha= to $GITHUB_OUTPUT. +# Runs git only, with hooks disabled; no repository code. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +tag="v$OPENCODE_VERSION" +git ls-files -z -- '*package.json' bun.lock | xargs -0 sed -i -E "s/\"version\": \"[^\"]+\"/\"version\": \"$OPENCODE_VERSION\"/g" + +if [ -n "${OPENCODE_RELEASE_NOTES:-}" ] && ! grep -qF -x "## $tag" CHANGELOG.md && ! grep -qF "## $tag " CHANGELOG.md; then + notes=$(mktemp) + printf '%s\n' "$OPENCODE_RELEASE_NOTES" > "$notes" + awk -v entry="## $tag — $(date -u +%F)" -v notes="$notes" ' + function emit() { print entry; print ""; while ((getline line < notes) > 0) print line; print ""; done = 1 } + !done && /^## / { emit() } + { print } + END { if (!done) { print ""; emit() } } + ' CHANGELOG.md > "$notes.md" + mv "$notes.md" CHANGELOG.md +fi + +if git diff --name-only | grep -vE '(^|/)package\.json$|^bun\.lock$|^CHANGELOG\.md$'; then + die "The release commit may only change versions and the changelog" +fi + +git -c core.hooksPath=/dev/null \ + -c user.name="github-actions[bot]" \ + -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit --no-verify -q -am "release: $tag" + +# A rerun reuses the tag when it holds the same tree. +existing=$(git ls-remote origin "refs/tags/$tag" | cut -f1) +if [ -n "$existing" ]; then + git fetch -q --depth=1 origin "refs/tags/$tag" + [ "$(git rev-parse "$existing^{tree}")" = "$(git rev-parse "HEAD^{tree}")" ] || die "Tag $tag already exists with different contents" + echo "sha=$existing" >> "$GITHUB_OUTPUT" + exit 0 +fi +git push -q origin "HEAD:refs/tags/$tag" +echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" diff --git a/.github/release/unpack-windows.sh b/.github/release/unpack-windows.sh new file mode 100755 index 000000000000..1af06d87ea48 --- /dev/null +++ b/.github/release/unpack-windows.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# unpack-windows.sh : lays out what the signing action signs in : +# cli//package from the Windows CLI npm tarballs, and desktop-/ from the unpacked desktop apps. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +in=$(posix "$1") work=$(posix "$2") +mkdir -p "$work" +if [ -d "$in/build-npm" ]; then + for target in windows-arm64 windows-x64 windows-x64-baseline; do + mkdir -p "$work/cli/$target" + tar -xzf "$in/build-npm/opencode-cli-$target-$OPENCODE_VERSION.tgz" -C "$work/cli/$target" + test -s "$work/cli/$target/package/bin/opencode.exe" || die "Missing opencode.exe for $target" + done +fi +for target in windows-arm64 windows-x64; do + if [ -d "$in/build-desktop-$target" ]; then mkdir -p "$work/desktop-$target" && cp -R "$in/build-desktop-$target/app" "$work/desktop-$target/app"; fi +done diff --git a/.github/release/upload/action.yml b/.github/release/upload/action.yml new file mode 100644 index 000000000000..bd03680f3a19 --- /dev/null +++ b/.github/release/upload/action.yml @@ -0,0 +1,26 @@ +name: Upload a release artifact +description: Writes SHA256SUMS for a directory, uploads it, and outputs the manifest digest. +inputs: + name: + description: Artifact name, - + required: true + path: + description: Directory to upload + required: true +outputs: + digest: + description: SHA-256 of the artifact's SHA256SUMS + value: ${{ steps.checksum.outputs.digest }} +runs: + using: composite + steps: + - id: checksum + shell: bash + run: echo "digest=$(bash "${GITHUB_ACTION_PATH//\\//}/../write-checksums.sh" "$UPLOAD_PATH")" >> "$GITHUB_OUTPUT" + env: + UPLOAD_PATH: ${{ inputs.path }} + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: ${{ inputs.name }} + path: ${{ inputs.path }} + if-no-files-found: error diff --git a/.github/release/verify-artifacts.sh b/.github/release/verify-artifacts.sh new file mode 100755 index 000000000000..644a2ade8049 --- /dev/null +++ b/.github/release/verify-artifacts.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# verify-artifacts.sh : checks every artifact downloaded into / against its SHA256SUMS and that it +# holds exactly the listed files. Artifacts made by sign jobs (sign-*) must also match the manifest digest their +# job reported (OPENCODE_DIGESTS="name=digest ..."), so an artifact replaced after that job finished is rejected. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +root=$(posix "$1") +for dir in "$root"/*/; do + name=$(basename "$dir") + digest="" + for pair in ${OPENCODE_DIGESTS:-}; do + if [ "${pair%%=*}" = "$name" ]; then digest=${pair#*=}; fi + done + case "$name" in sign-*) [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || die "Missing digest for $name" ;; esac + ( + cd "$dir" || die "cannot enter $dir" + if [ -n "$digest" ] && [ "$(sha256 SHA256SUMS | cut -c1-64)" != "$digest" ]; then die "SHA256SUMS digest mismatch in $name"; fi + sha256 -c SHA256SUMS > /dev/null || die "Checksum mismatch in $name" + diff <(find . -type f ! -name SHA256SUMS | sed 's|^\./||' | LC_ALL=C sort) <(cut -c67- SHA256SUMS | LC_ALL=C sort) || + die "Unexpected files in $name" + ) + echo "verified $name${digest:+ ($digest)}" +done diff --git a/.github/release/verify-signatures-windows.sh b/.github/release/verify-signatures-windows.sh new file mode 100755 index 000000000000..d1d9c22d55b9 --- /dev/null +++ b/.github/release/verify-signatures-windows.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# verify-signatures-windows.sh : fails unless every .exe and .node under has a valid Authenticode signature. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +if dry_run; then + echo "dry-run: not verifying signatures in $1" + exit 0 +fi +powershell -NoProfile -Command " + \$ErrorActionPreference = 'Stop' + \$files = Get-ChildItem '$(cygpath -w "$(posix "$1")")' -Recurse -Include *.exe, *.node + if (-not \$files) { throw 'No signed files found' } + foreach (\$file in \$files) { + \$sig = Get-AuthenticodeSignature \$file.FullName + if (\$sig.Status -ne 'Valid') { throw \"Invalid signature for \$(\$file.FullName): \$(\$sig.Status)\" } + } + Write-Output \"verified \$(\$files.Count) signatures\" +" diff --git a/.github/release/write-checksums.sh b/.github/release/write-checksums.sh new file mode 100755 index 000000000000..b1b355513c1f --- /dev/null +++ b/.github/release/write-checksums.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# write-checksums.sh : writes /SHA256SUMS for every file under and prints its digest. +# Every artifact carries this manifest; trusted jobs pass the digest on through job outputs. +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "$(dirname "$0")/lib.sh" + +cd "$(posix "$1")" || die "cannot enter checksum directory" +manifest=$(mktemp) +find . -type f ! -name SHA256SUMS | sed 's|^\./||' | LC_ALL=C sort | while IFS= read -r file; do sha256 "$file"; done > "$manifest" +mv "$manifest" SHA256SUMS +cat SHA256SUMS >&2 +sha256 SHA256SUMS | cut -c1-64 diff --git a/.opencode/agent/triage.md b/.opencode/agent/triage.md deleted file mode 100644 index 11c4c816cfae..000000000000 --- a/.opencode/agent/triage.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -mode: primary -hidden: true -model: opencode/gpt-5.4-mini -color: "#44BA81" -tools: - "*": false - "github-triage": true ---- - -You are a triage agent responsible for triaging github issues. - -Use your github-triage tool to triage issues. - -This file is the source of truth for ownership/routing rules. - -Assign issues by choosing the team with the strongest overlap. The github-triage tool will assign a random member from that team. - -Do not add labels to issues. Only assign an owner. - -When calling github-triage, pass one of these team values: tui, desktop_web, core, inference, windows. - -## Teams - -### TUI - -Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance. - -### Desktop / Web - -Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems. - -### Core - -Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, documentation, and larger architectural features. - -### Inference - -OpenCode Zen, OpenCode Go, and billing issues. - -### Windows - -Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows. diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index b0f7d59447db..4506cf49fefa 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -14,7 +14,6 @@ }, "mcp": {}, "tools": { - "github-triage": false, "github-pr-search": false, }, } diff --git a/.opencode/skills/app-e2e-tests/SKILL.md b/.opencode/skills/app-e2e-tests/SKILL.md index 3a1f984afc53..2630c24a58fc 100644 --- a/.opencode/skills/app-e2e-tests/SKILL.md +++ b/.opencode/skills/app-e2e-tests/SKILL.md @@ -1,17 +1,17 @@ --- name: app-e2e-tests -description: Use when writing, changing, reviewing, or debugging Playwright e2e tests in packages/app/e2e (regression keepers and user stories). Covers the mocked opencode server harness and the Bun static server, per-test isolation, page-load and route cost, the suite-wide service-worker block, races with first mount, hover, measurement, clock, dialogs and shortcuts, projects, shards and worker scheduling, rehearsing Windows timing locally, and diagnosing a CI flake from its artifacts. Not for Storybook component tests or e2e/performance benchmarks. +description: Use when writing, changing, reviewing, or debugging Playwright e2e tests in packages/app/e2e (regression keepers and user stories). Covers the mocked opencode server harness and the Bun static server, per-test isolation, page-load and route cost, the suite-wide service-worker block, races with first mount, hover, measurement, clock, dialogs and shortcuts, projects, shards and worker scheduling, rehearsing Windows timing locally, and diagnosing a CI flake from its artifacts. Not for component tests (stories mounted on packages/stories) or e2e/performance benchmarks. --- # App e2e tests -Read `packages/app/e2e/AGENTS.md` and the Tests section of `packages/app/AGENTS.md` first. They define what to test, which locators to use, and how to assert. `packages/app/README.md` (E2E Testing) documents run modes and env vars. This skill covers what makes these tests slow or flaky in CI, which runs the suite as two shards per OS on 4-vCPU runners (`.github/workflows/test.yml:207-229`). Most races below were found on Windows. +Read `packages/app/e2e/AGENTS.md` and the Tests section of `packages/app/AGENTS.md` first. They define what to test, which locators to use, and how to assert. `packages/app/README.md` (E2E Testing) documents run modes and env vars. This skill covers what makes these tests slow or flaky in CI, which runs the suite as two shards per OS on 4-vCPU runners (`.github/workflows/check-test.yml:217-240`). Most races below were found on Windows. All paths below are relative to `packages/app` unless they start with `packages/`, `services/` or `.github/`. ## What a test runs against -- **No opencode backend.** Each shard leg runs `bun --cwd packages/app test:e2e:built --shard=/2` (`test.yml:274-279`). It builds once and starts one Bun static server, `e2e/utils/serve.ts`, on `127.0.0.1:${PLAYWRIGHT_PORT ?? 3000}`. All 5 workers share it (`playwright.config.ts:23-27`). +- **No opencode backend.** Each shard leg runs `bun --cwd packages/app test:e2e:built --shard=/2` (`check-test.yml:287-292`). It builds once and starts one Bun static server, `e2e/utils/serve.ts`, on `127.0.0.1:${PLAYWRIGHT_PORT ?? 3000}`. All 5 workers share it (`playwright.config.ts:23-27`). - `serve.ts` keeps vite preview's SPA fallback rules. Do not move built e2e back to vite preview. On case-insensitive file systems it synchronously reads the whole `dist/_assets` directory (about 3,100 files) for every asset request, so every worker's page loads wait in line. The `e2e/performance` configs still use it to keep their baselines comparable. - **The API is an in-memory mock per page,** running in the test worker. `mockOpenCodeServer` (`e2e/utils/mock-server.ts:202`): - answers `page.route("**/api/**")` with an Effect HttpApi handler; @@ -39,7 +39,7 @@ All paths below are relative to `packages/app` unless they start with `packages/ - **Navigate once.** Every `goto` or `reload` is a cold load of the production bundle: about 150 requests per session open, about 125 of them static files. Each test has a new context, and any `page.route` disables the HTTP cache. Reach later states through the UI or through pushed events. Reload only when surviving a reload is the contract. - **Service workers are already blocked suite-wide** (`serviceWorkers: "block"`, `playwright.config.ts:66-67`). Do not add a per-file block; the 11 in `e2e/regression` are redundant. - - Production registers `/sw.js` on load (`src/entry.tsx:54-55`) and precaches about 1,600 files (`globPatterns: ["**/*"]`, `vite.pwa.ts:34`). `e2e/service-worker/` covers it under its own config. The main config ignores that directory (`playwright.config.ts:33-36`), and CI runs it after shard 2 (`test.yml:281-285`). + - Production registers `/sw.js` on load (`src/entry.tsx:54-55`) and precaches about 1,600 files (`globPatterns: ["**/*"]`, `vite.pwa.ts:34`). `e2e/service-worker/` covers it under its own config. The main config ignores that directory (`playwright.config.ts:33-36`), and CI runs it after shard 2 (`check-test.yml:294-298`). - Opt back in with `test.use({ serviceWorkers: "allow" })` only when the worker is the contract. It never controls the first load (`clientsClaim: false`, `vite.pwa.ts:26-28`). After a reload it can control the page, and page routes may then miss its requests. - Every built-mode page load logs Playwright's block warning. Console collectors must ignore it, as `captureConsoleWarnings` does (`e2e/utils/command-palette.ts:15-26`). - **Match new routes with a RegExp or a query-aware glob, not a function.** @@ -77,7 +77,7 @@ Most of these rarely reproduce on an idle local machine. - **Use the harness's signals.** `holdRoute(...).arrived` resolves when the request reaches the route, and `transport.send` resolves after delivery. `setupTimeline(...).waitForPart` resolves once a part renders (`timeline.ts:328`). - **Decide which side is wrong.** Three of eight investigated e2e flakes were product bugs, each since fixed in the product: - a stale scroll offset restored when a scroll settles (`src/session/timeline/observe-element-offset.ts`); - - a video double-click that also entered native fullscreen (`packages/gui-extensions/src/file/artifact-view.tsx:617`); + - a video double-click that also entered native fullscreen (`packages/gui-plugins/src/file/artifact-view.tsx:612`); - shortcuts dropped while a dialog closed. If a user could hit the interleaving, fix the product and keep the assertion. @@ -89,12 +89,12 @@ Most of these rarely reproduce on an idle local machine. - A rename that the regex misses quietly puts the file back in `chromium` as that serial group. - `--project chromium`, UI mode and the VS Code extension's default project skip the file. - **In `chromium`, files are the scheduling unit.** `fullyParallel` is off unless `PLAYWRIGHT_FULLY_PARALLEL=1` (`:42`), so a file's tests run in order on one worker. A long file sets a floor and leaves an idle tail. New regressions go into the area's keeper file (`e2e/AGENTS.md`), so keep each case cheap. -- **Shards split whole files by test count, not duration** (`test.yml:219-222`). The touch project queues last, so its tests always fall in shard 2. To reproduce a leg's schedule, pass the same `--shard`. +- **Shards split whole files by test count, not duration** (`check-test.yml:229-232`). The touch project queues last, so its tests always fall in shard 2. To reproduce a leg's schedule, pass the same `--shard`. - **Worker-scoped options fork a worker.** `launchOptions`, `channel`, `headless`, `video`, `trace`, `screenshot` and `defaultBrowserType` give a file its own worker hash. - Playwright groups a project's files by worker hash in order of first appearance, so such a file runs after every default file in its project. - Use test-scoped options instead: `viewport`, `deviceScaleFactor`, `hasTouch`, `isMobile`, `userAgent`, `colorScheme`, `serviceWorkers`, `locale`. - Use `launchOptions` only when a Chromium flag is the contract, with a comment (`patch-border.spec.ts:5-9`). -- **Never spread `devices[...]` into `test.use`.** iPhone entries set `defaultBrowserType: "webkit"`, which forks a worker and needs a browser that CI never installs (`test.yml:269-272`). Copy the fields you need, as `mobile-timeline-scroll.spec.ts:23-28` does. +- **Never spread `devices[...]` into `test.use`.** iPhone entries set `defaultBrowserType: "webkit"`, which forks a worker and needs a browser that CI never installs (`check-test.yml:282-285`). Copy the fields you need, as `mobile-timeline-scroll.spec.ts:23-28` does. - **Retries are not free.** CI retries twice (`playwright.config.ts:44`). Each failure restarts the worker's browser and costs 10-30 s of cleanup on Windows. The first retry then reruns with a trace and a video (`:62-65`). - **Timeouts are not a fix.** The limits are 60 s per test and 10 s per assertion (`playwright.config.ts:38-41`). `APP_READY_TIMEOUT` (30 s, `e2e/utils/waits.ts:4`) covers only first readiness. - When one wait is genuinely long, scope the timeout to that assertion and say what takes the time. `settings-wsl.spec.ts:268-276` does this with `{ timeout: 20_000 }` for a 10 s health check. @@ -122,11 +122,11 @@ Run from `packages/app`: ## When CI flakes -- **Find it.** e2e runs on pull requests that affect `@opencode/app` or `@opencode/storybook`, including their workspace dependencies, and on every manual dispatch (`.github/workflows/test.yml:42-54`). It never runs on dev (`:28`). +- **Find it.** e2e runs on pull requests that affect `@opencode/app` or `@opencode/stories`, including their workspace dependencies, and on every manual dispatch (`.github/workflows/check-test.yml:50-62`). It never runs on dev (`:28`). - Each OS has the legs `app-1` and `app-2` (the shards), `session-ui-components` and `app-components`. - - The required `e2e (linux)` and `e2e (windows)` checks only aggregate those legs (`:316-335`). + - The required `e2e (linux)` and `e2e (windows)` checks only aggregate those legs (`:329-352`). - A pass on retry leaves a leg green. Check each Windows shard report's Flaky list, and make sure your new tests are not on it. -- **Download:** `gh run download -n playwright-windows-app-2-1 -D `. Artifacts are named `playwright---` (`test.yml:301-314`). App e2e results are in `/app/e2e/`. +- **Download:** `gh run download -n playwright-windows-app-2-1 -D `. Artifacts are named `playwright---` (`check-test.yml:314-327`). App e2e results are in `/app/e2e/`. - **In CI, the failing first attempt has no trace and no video** (`playwright.config.ts:62-65`). - `test-results/-/` holds `error-context.md` (the aria snapshot at the failure) and `test-failed-1.png`. - `--retry1/` holds `trace.zip` and `video.webm` whether the retry passed or failed, plus failure files only if it failed. A second retry records neither. diff --git a/.opencode/skills/component-tests/SKILL.md b/.opencode/skills/component-tests/SKILL.md index 77c9bfe1c0cb..530a61668567 100644 --- a/.opencode/skills/component-tests/SKILL.md +++ b/.opencode/skills/component-tests/SKILL.md @@ -1,66 +1,65 @@ --- name: component-tests -description: Use when writing, changing, debugging, or reviewing Storybook-based Playwright component tests. That covers specs in packages/app/component-tests or packages/session-ui/component-tests, `*.fixture.tsx` files there or beside source (such as packages/gui-extensions/src/browser/panel.fixture.tsx), the stories they mount, and the shared harness in packages/storybook (playwright/config.ts, playwright/story.ts, .storybook/main.ts, preview.tsx, mocks). Also use when a component test is flaky, passed on retry, or fails with "Execution context was destroyed", "Failed to fetch dynamically imported module", or `#storybook-root` hidden. Covers mount args, source modules imported by URL, Vite dependency pre-bundling and reloads, cold vs warm Storybook, mobile drawers, geometry, and checks before pushing. +description: Use when writing, changing, debugging, or reviewing the Playwright component tests that mount stories. That covers specs in packages/app/component-tests or packages/session-ui/component-tests, `*.fixture.tsx` files there or beside source (such as packages/gui-plugins/src/browser/panel.fixture.tsx), the stories they mount, and the shared harness in packages/stories (playwright/config.ts, playwright/story.ts, vite.config.ts, src/frame.tsx, src/index-plugin.ts, mocks). Also use when a component test is flaky, passed on retry, or fails with "Execution context was destroyed", "Failed to fetch dynamically imported module", or `#story-root` hidden. Covers mount args, source modules imported by URL, Vite dependency pre-bundling and reloads, a cold vs warm story server, mobile drawers, geometry, and checks before pushing. --- # Component tests -Each package suite starts one Storybook/Vite dev server, and every worker shares it (`packages/storybook/playwright/config.ts:17-24`; CI uses 2 workers, `config.ts:15`). With `fullyParallel` (`config.ts:12`), the tests of one file run on several workers in any order. There is no backend: server, SDK and sync modules are in-page mocks. Every test gets a fresh context, and `mount` is a full iframe navigation (`packages/storybook/playwright/story.ts:37`). Slow or flaky component tests in CI have come from five sources: the shared server's dependency optimizer, module resolution that depended on the install layout, a cold first story, a product race (the mobile drawer), or an action that ran before the UI reached the state it needed. +Each package suite starts one story server, a plain Vite dev server for `packages/stories`, and every worker shares it (`packages/stories/playwright/config.ts:17-26`; CI uses 2 workers, `config.ts:15`). With `fullyParallel` (`config.ts:12`), the tests of one file run on several workers in any order. There is no backend: server, SDK and sync modules are in-page mocks. Every test gets a fresh context, and `mount` is a full page navigation to the frame (`packages/stories/playwright/story.ts:37`). Slow or flaky component tests in CI have come from five sources: the shared server's dependency optimizer, module resolution that depended on the install layout, a cold first story, a product race (the mobile drawer), or an action that ran before the UI reached the state it needed. Read these first rather than repeating them: -- `packages/storybook/playwright/README.md` covers layout, run commands and "Adding a test". +- `packages/stories/playwright/README.md` covers layout, run commands and "Adding a test". - `packages/app/AGENTS.md` "Tests" decides whether a test should exist, when theme or direction cases are allowed, and that a regression test must fail before the fix. - `packages/app/e2e/AGENTS.md` "Test Hygiene" applies here unchanged. ## Stories and `mount` -- **Location.** Put the story next to the production component under `src/` of `ui`, `session-ui`, `app` or `gui-extensions`. Storybook indexes only the `stories` globs in `packages/storybook/.storybook/main.ts:44-49`. -- **Story ID.** It is the meta `id` if one is set (`packages/session-ui/src/components/session-review.stories.tsx:25`). Otherwise it is the kebab-cased `title`, then `--`, then the kebab-cased export. Check it at `http://127.0.0.1:6006/?path=/story/`. A wrong ID never renders, so `mount` waits out its full 30 s root wait (`story.ts:39`) and then fails. -- **URL args can be dropped.** `mount(id, { args, globals })` writes them into the URL (`story.ts:20-36`). Storybook drops an arg when the story declares no `args`/`argTypes` entry for its key. It also drops one whose value is not `[A-Za-z0-9 _-]*`, a number, a hex color or `rgb()`/`hsl()`, which rules out paths, URLs and file names with dots. The only trace is the browser console warning "Omitted potentially unsafe URL args.", and the test then passes on the default scenario. +- **Location.** Put the story next to the production component under `src/` of `ui`, `session-ui`, `app` or `gui-plugins`. The story server indexes `*.stories.tsx` files in those four trees (`packages/stories/vite.config.ts`, `src/index-plugin.ts`), reading each file's `export default { title, id }` and its `export const` stories from source. +- **Story ID.** It is the meta `id` if one is set (`packages/session-ui/src/components/session-review.stories.tsx:25`). Otherwise it is the kebab-cased `title`, then `--`, then the kebab-cased export. Check it at `http://127.0.0.1:6006/?id=` on a running story server (`bun --cwd packages/stories dev`). A wrong ID renders nothing (the page throws `No story has the id …`), so `mount` waits out its full 30 s root wait (`story.ts:39`) and then fails. +- **URL args.** `mount(id, { args, globals })` writes them into the URL as `key:value` pairs (`story.ts:20-36`). The frame reads every one: a value becomes a boolean or number when the story's own default for that key is one, `!true`/`!false`/`!null`/`!undefined` keep Storybook's meaning, and anything else stays text. - Give every arg a typed default. With `args: { split: false }` (`session-review.stories.tsx:129`), `split:false` arrives as a boolean. - - Pick punctuated values inside the story, from a `scenario`-style arg. -- **Globals** are `theme`, `direction` and `locale` (`packages/storybook/.storybook/preview.tsx:106-122`). `mount` also sets `a11y.manual` (`story.ts:29-36`); caller globals override it. -- **Portaled UI.** `mount` returns `#storybook-root`. Dialogs, menus and drawers render outside it, so locate them from `page` (`packages/app/component-tests/composer.spec.ts:101-107`). -- **Server state** comes from the `resolve.alias` list in `viteFinal` (`main.ts:72-94`), which points app server, SDK and sync modules at `packages/storybook/.storybook/mocks/app/**`. Extend a mock rather than adding a server. Restore mock state in `onCleanup` (`mockProviderAuth`, `packages/app/src/providers/connect/dialog.stories.tsx:23`) so that switching stories in the Storybook UI does not inherit it. Tests reset it on every mount. + - A value containing `;` would split the pair list, so pick such values inside the story, from a `scenario`-style arg. +- **Globals** are `theme` (default `light`), `direction` and `locale` (`packages/stories/src/frame.tsx`). +- **Portaled UI.** `mount` returns `#story-root`. Dialogs, menus and drawers render outside it, so locate them from `page` (`packages/app/component-tests/composer.spec.ts:101-107`). +- **Server state** comes from the `resolve.alias` list in `packages/stories/vite.config.ts`, which points app server, SDK and sync modules at `packages/stories/mocks/app/**`. Extend a mock rather than adding a server. The app's components lab renders the same stories inside the real app instead, so a story must not depend on a mock-only export. Tests reset mock state on every mount. - **Requests the mocks do not answer.** Route them with `page.route`. That includes server endpoints such as `/api/fs/read` (`packages/session-ui/component-tests/read-image.spec.ts:20`) and external URLs such as favicons (`packages/session-ui/component-tests/markdown.spec.ts:217`). ## Fixtures imported by URL Use these when the contract is not visible in a story, such as construction counts, unmount races or cache state. -- **Fixture file.** Put a `*.fixture.tsx` file in `component-tests/` or beside the source it drives (`packages/gui-extensions/src/browser/panel.fixture.tsx`). It exports a `mountX()` that renders into a host with a `data-testid` (`packages/session-ui/component-tests/basic-tool.fixture.tsx:7-46`). Never end the name in `.spec` or `.test`. +- **Fixture file.** Put a `*.fixture.tsx` file in `component-tests/` or beside the source it drives (`packages/gui-plugins/src/browser/panel.fixture.tsx`). It exports a `mountX()` that renders into a host with a `data-testid` (`packages/session-ui/component-tests/basic-tool.fixture.tsx:7-46`). Never end the name in `.spec` or `.test`. - **Mount it in three steps.** 1. Mount a light story first, so that the app styles load. 2. Import the fixture and call it inside `page.evaluate`. 3. Locate it with `page.getByTestId` (`packages/session-ui/component-tests/basic-tool.spec.ts:30-36`). - It renders outside the preview decorators, so it must supply its own providers (`packages/app/component-tests/browser-pane-restore.spec.ts:69-83`). + It renders outside the frame's decorators, so it must supply its own providers (`packages/app/component-tests/browser-pane-restore.spec.ts:69-83`). - **Module URLs.** Build each one with `sourceURL(new URL("", import.meta.url))` (`story.ts:10`), written as a string literal in the spec (`browser-pane-restore.spec.ts:6-24`). Any other spelling either fails to load or loads a second module instance whose contexts the page never provides; a hand-written `/@fs//...` is the documented second-instance case (`story.ts:6-9`). The literal also matters for pre-bundling (next section). -- **Aliases.** A module imported by URL is the real file. App code that reaches the same module through a `main.ts` alias gets the mock (`browser-pane-restore.spec.ts:95-96`). Decide which of the two the contract needs. +- **Aliases.** A module imported by URL is the real file. App code that reaches the same module through a `vite.config.ts` alias gets the mock (`browser-pane-restore.spec.ts:95-96`). Decide which of the two the contract needs. - **Spec imports.** Import source modules into the spec with `import type` only (`browser-pane-restore.spec.ts:2-4`). A value import runs browser code in the Node runner. -- **Async order.** Let the test decide the order through deferreds the fixture exposes (`mountExtensionHost`, `packages/app/component-tests/extension-host.fixture.tsx:135`). Await the outcome they lead to (`extension-host.spec.ts:46-77`), or poll in-page state with `until` (`extension-host.fixture.tsx:124-132`). Do not copy the fixed in-page `setTimeout` waits that other cases in `extension-host.spec.ts` (`:38`, `:88-90`, `:108-119`) use to prove a negative. +- **Async order.** Let the test decide the order through deferreds the fixture exposes (`mountPluginHost`, `packages/app/component-tests/plugin-host.fixture.tsx:151`). Await the outcome they lead to (`plugin-host.spec.ts:38-69`), or poll in-page state with `until` (`plugin-host.fixture.tsx:141-148`). Do not copy the fixed in-page `setTimeout` waits that other cases in `plugin-host.spec.ts` (`:30`, `:80-82`, `:100-111`) use to prove a negative. ## Vite pre-bundling and reloads -`viteFinal` drops `storybook:optimize-deps-plugin` (`main.ts:105-108`) and sets `optimizeDeps` itself (`main.ts:56-67`). Its `entries` are every `*.stories.tsx` and `*.worker.ts` under the four `src/` trees, every module a component spec names by a `./` or `../` string literal ending in `.ts`/`.tsx` (collected at `main.ts:15-29`), and `preview.tsx`; `include` adds the Storybook preview runtime. Vite pre-bundles their bare imports before the first page loads. A dependency the scan misses is found on first import instead. If that changes chunks already served, Vite logs `✨ optimized dependencies changed. reloading` and sends `full-reload` with path `*` to every page in every worker, and requests with a stale `?v=` hash get a 504 "Outdated Optimize Dep". +`packages/stories/vite.config.ts` sets `optimizeDeps.entries`: every `*.stories.tsx` and `*.worker.ts` under the four `src/` trees, every module a component spec names by a `./` or `../` string literal ending in `.ts`/`.tsx` (collected at the top of the file), and the frame's `index.html`. Vite pre-bundles their bare imports before the first page loads. A dependency the scan misses is found on first import instead. If that changes chunks already served, Vite logs `✨ optimized dependencies changed. reloading` and sends `full-reload` with path `*` to every page in every worker, and requests with a stale `?v=` hash get a 504 "Outdated Optimize Dep". -- **The bar is zero reloads.** A cold run of either suite prints neither `new dependencies optimized` nor `optimized dependencies changed`; neither did the Linux and Windows CI legs. Dropping `preview.tsx` and `include` from `main.ts` brings one reload back, so these lines are the signal to watch. -- **Keep new code reachable by the scan.** A module path built at runtime, in a template literal, or without its extension is not collected, and neither is a spec named other than `*.spec.ts`. Name stories `*.stories.tsx`: Storybook indexes other extensions, but the scan reads only that one. Name workers `*.worker.ts`, since the scan does not follow `new Worker(new URL(…))`. Add an `optimizeDeps.include` entry only for a dependency the scan cannot reach. Vite names it in `✨ new dependencies optimized: …`, and `DEBUG=vite:deps` replaces that line with scan and discovery detail. +- **The bar is zero reloads.** A cold run of either suite should print neither `new dependencies optimized` nor `optimized dependencies changed`, so these lines are the signal to watch. +- **Keep new code reachable by the scan.** A module path built at runtime, in a template literal, or without its extension is not collected, and neither is a spec named other than `*.spec.ts`. Name stories `*.stories.tsx`: the story index and the scan read only that extension. Name workers `*.worker.ts`, since the scan does not follow `new Worker(new URL(…))`. Add an `optimizeDeps.include` entry only for a dependency the scan cannot reach. Vite names it in `✨ new dependencies optimized: …`, and `DEBUG=vite:deps` replaces that line with scan and discovery detail. - **Symptoms.** "page.evaluate: Execution context was destroyed", "Failed to fetch dynamically imported module …/@fs/…", or fixture state that is gone right after `mount` ("Registrations: 1" not found, an empty tab strip). - **Only `mount()` survives a reload,** because it navigates back to the same URL. A reload loses everything set up after it: `page.evaluate` imports, mounted fixtures, state from earlier clicks, and the elements your locators pointed at. Another worker's discovery can trigger it at any moment. - **Fix reloads in the scan, never with retries, timeouts or import order.** The import-everything-before-mount `Promise.all` in `browser-pane-restore.spec.ts:43-58` (comment at `:56`) only narrowed the window. Before the entries existed, that file's `:123` and `:150` were retried in 31 CI jobs each, and failed attempts cost about 39-47 s (Linux) and 34 s (Windows) of worker time per run. -- **Clear the cache whenever entries matter.** Vite's `getConfigHash` covers `optimizeDeps.include`, `resolve` and plugin names, but not `entries`, and a valid `_metadata.json` skips the scan. A warm cache therefore never scans a new story or fixture, so it shows neither the reloads nor the absence of reloads that CI's cold scan would. Delete `packages/storybook/node_modules/.cache` before judging. -- **Resolve through package resolution, not paths into `node_modules`.** `main.ts:69-71` dedupes `@tanstack/solid-query` instead of aliasing it into `app/node_modules`, a path that exists only under bun's isolated linker. While Windows CI installed hoisted, that alias made every app story fail there, and 23 of 30 runs hit the step's 15-minute cap. CI now runs the same `bun install --frozen-lockfile` on every OS (`.github/actions/setup-bun/action.yml:66-68`), so such an alias would pass CI and still break a hoisted local install. +- **Clear the cache whenever entries matter.** Vite's `getConfigHash` covers `optimizeDeps.include`, `resolve` and plugin names, but not `entries`, and a valid `_metadata.json` skips the scan. A warm cache therefore never scans a new story or fixture, so it shows neither the reloads nor the absence of reloads that CI's cold scan would. Delete `packages/stories/node_modules/.vite` before judging. +- **Resolve through package resolution, not paths into `node_modules`.** `vite.config.ts` dedupes `@tanstack/solid-query` instead of aliasing it into `app/node_modules`, a path that exists only under bun's isolated linker. While Windows CI installed hoisted, that alias made every app story fail there, and 23 of 30 runs hit the step's 15-minute cap. CI now runs the same `bun install --frozen-lockfile` on every OS (`.github/actions/setup-bun/action.yml:66-68`), so such an alias would pass CI and still break a hoisted local install. ## Cold vs warm, and what a mount costs -- **CI is always cold.** Each suite runs in its own leg on a fresh runner (`test.yml:228-229`), so every leg starts Storybook with no cache and runs the full scan. +- **CI is always cold.** Each suite runs in its own leg on a fresh runner (`check-test.yml:239-240`), so every leg starts the story server with no cache and runs the full scan. - **Do not count the first test.** In one CI run of this harness, the first test per worker took 5.7-7.0 s on Linux and 14-15 s on Windows. Warm tests had a median of 1.5 s (app) and 2.0 s (session-ui) on Linux and 2.4-2.6 s on Windows. A warm `mount` (navigation plus root wait) had a median of 1.0 s (app) and 1.5 s (session-ui) on Linux and 1.7-1.9 s on Windows, which is most of a typical test. Judge cost by warm attempts. -- **Local reuse.** Playwright reuses whatever answers on the port (`config.ts:20`; default 6006, `config.ts:3`). That hides cold cost and reloads, and with several worktrees another checkout's Storybook can serve your tests its own code. Set `PLAYWRIGHT_STORYBOOK_PORT` or `PLAYWRIGHT_STORYBOOK_URL` (`config.ts:3-4`). On Windows pick a port below 49152: the dynamic range starts there, and Hyper-V and WSL reserve blocks inside it (`netsh int ipv4 show excludedportrange protocol=tcp`). Two Storybooks in one checkout share, and race on, `packages/storybook/node_modules/.cache/storybook///sb-vite`, so a separate port is not enough: use a separate worktree. +- **Local reuse.** Playwright reuses whatever answers on the port (`config.ts:20`; default 6006, `config.ts:3`). That hides cold cost and reloads, and with several worktrees another checkout's story server can serve your tests its own code. Set `PLAYWRIGHT_STORIES_PORT` or `PLAYWRIGHT_STORIES_URL` (`config.ts:3-4`). On Windows pick a port below 49152: the dynamic range starts there, and Hyper-V and WSL reserve blocks inside it (`netsh int ipv4 show excludedportrange protocol=tcp`). Two story servers in one checkout share, and race on, `packages/stories/node_modules/.vite`, so a separate port is not enough: use a separate worktree. - **Keep mounts few.** Put one scenario in each test, and resize in place with `page.setViewportSize` (`composer.spec.ts:28-29`) instead of mounting again. - - `a11y.manual` makes addon-a11y skip its axe run after each render, so changing `a11y.test` in `preview.tsx:133-135` is not enforced by these tests. Assert accessibility in the spec when it is the contract. - CI records video only on the first retry, next to the trace; local runs keep `retain-on-failure` (`config.ts:27-30`). ## Mobile, touch and drawers @@ -77,7 +76,7 @@ Use these when the contract is not visible in a story, such as construction coun ## Geometry, platforms, known failures - **Fonts.** Inter's `@font-face` has no `font-display` (`packages/app/src/index.css:13-18`), so a story can lay out with fallback metrics and then shift. Windows CI saw 6.5 px in the composer overflow measurement (`composer.spec.ts:93-95`), which now waits first (`:25-26`). Before measuring, run `await page.evaluate(() => document.fonts.ready.then(() => undefined))` (`markdown.spec.ts:247`). The `.then` keeps the `FontFaceSet` from being returned to Node. -- **Page scroll.** Outside fullscreen, the preview frame is `calc(100vh - 2rem)` to fit Storybook's 1rem padding (`preview.tsx:82-84`). With `100vh`, every such story scrolled by 32 px, and any scroll-into-view shifted boxes (`composer.spec.ts:116` saw y go from 300 to 268). Do not give a story root `100vh`. A story taller than the viewport still scrolls, so measure relative to a container captured at the same moment, or use `parameters: { layout: "fullscreen" }`. +- **Page scroll.** Outside fullscreen, the frame's wrapper is `calc(100vh - 2rem)` to fit the page's 1rem padding (`packages/stories/src/frame.tsx`). With `100vh`, every such story scrolled by 32 px, and any scroll-into-view shifted boxes (`composer.spec.ts:116` saw y go from 300 to 268). Do not give a story root `100vh`. A story taller than the viewport still scrolls, so measure relative to a container captured at the same moment, or use `parameters: { layout: "fullscreen" }`. - **Shortcuts.** Write them as `ControlOrMeta+…` (`browser-pane-restore.spec.ts:134`). Derive expected labels from `navigator.platform` (`packages/app/component-tests/session-question-dock.spec.ts:121`). - **Known local failure.** On macOS, `browser-pane-restore.spec.ts:123` fails after `ControlOrMeta+w` (Meta+W, `:134-135`), while CI (Control+W) passes. It is not your regression. - **`story.fail`.** It waits out the full 10 s expect timeout on every run: `packages/session-ui/component-tests/file-tool.spec.ts:49-50` took 12.6 s on Linux CI and 14.8 s on Windows CI. Fix the product instead, or make the expected failure an immediate check once the state has settled. @@ -89,11 +88,11 @@ From the owning package (`packages/session-ui` or `packages/app`): 1. **Repeat** at CI's worker count. Locally retries are already 0 (`config.ts:14`). Every attempt must pass: `bun run test:components -- component-tests/.spec.ts --workers=2 --repeat-each=10`. 2. **Read durations** in `bunx playwright show-report component-tests/playwright-report`. Compare warm tests with the CI medians above. Anything much slower needs a reason in the work the test does. 3. **Rehearse load.** Late font swaps and focus restored after a menu closes (`composer.spec.ts:114-115`) race the test under load. In a throwaway copy of the spec, throttle before `mount` with `const cdp = await page.context().newCDPSession(page)` and then `await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 })`. Alternatively, repeat while the other suite runs. -4. **Run each suite cold** whenever you add a story, fixture, dependency, worker or lazy import, or change `packages/storybook`. From the repo root, once per suite, as CI runs each in its own leg. Playwright pipes Storybook's output into its own (`config.ts:22-23`), and a free port makes it start a fresh server: +4. **Run each suite cold** whenever you add a story, fixture, dependency, worker or lazy import, or change `packages/stories`. From the repo root, once per suite, as CI runs each in its own leg. Playwright pipes the story server's output into its own (`config.ts:22-23`), and a free port makes it start a fresh server: ```sh - rm -rf packages/storybook/node_modules/.cache - PLAYWRIGHT_STORYBOOK_PORT=6216 bun run --cwd packages/app test:components -- --workers=2 2>&1 | tee /tmp/app-components.log + rm -rf packages/stories/node_modules/.vite + PLAYWRIGHT_STORIES_PORT=6216 bun run --cwd packages/app test:components -- --workers=2 2>&1 | tee /tmp/app-components.log grep -E "new dependencies optimized|optimized dependencies changed" /tmp/app-components.log ``` @@ -102,16 +101,16 @@ From the owning package (`packages/session-ui` or `packages/app`): 5. **Windows.** Check platform keys and labels and font-dependent geometry. The Windows component legs are the authoritative check. - Do not skip on Windows, and do not raise `config.ts` timeouts, retries or the `mount` wait. - If one test does genuinely expensive work, call `story.setTimeout(...)` in that test, with a comment that names the work. -6. **Confirm CI ran the component legs.** E2E never runs on pushes to dev (`test.yml:28`), so no post-merge run catches a regression. - - The `affected` gate filters on `@opencode/app` and `@opencode/storybook` (`test.yml:52-53`). `ui`, `session-ui` and `gui-extensions` count through app's dependencies. `workflow_dispatch` forces the legs on (`test.yml:48-51`). - - `test.yml` is listed in `globalDependencies` (`turbo.json:8`), so editing it marks every package affected. - - The required `e2e (linux)` and `e2e (windows)` checks also pass when no leg was needed (`test.yml:316-335`). Look for `e2e (, session-ui-components)` and `e2e (, app-components)`. +6. **Confirm CI ran the component legs.** E2E never runs on pushes to dev (`check-test.yml:28`), so no post-merge run catches a regression. + - The `affected` gate filters on `@opencode/app` and `@opencode/stories` (`check-test.yml:60-61`). `ui`, `session-ui` and `gui-plugins` count through app's dependencies. `workflow_dispatch` forces the legs on (`check-test.yml:56-59`). + - `check-test.yml` is listed in `globalDependencies` (`turbo.json:8`), so editing it marks every package affected. + - The required `e2e (linux)` and `e2e (windows)` checks also pass when no leg was needed (`check-test.yml:329-352`). Look for `e2e (, session-ui-components)` and `e2e (, app-components)`. - If CI skipped them, report your local cold run in the PR. ## When CI flakes -- **Download the artifact.** Run `gh run download -n playwright---components- -D ` (`test.yml:301-314`). Results are in `/session-ui/component-tests/` or `/app/component-tests/`. Only the first retry has a trace and, in CI, a video: `test-results/-components-retry1/`. -- **Find reloads.** Storybook's output reaches the job log with a `[WebServer]` prefix, so grep the log for `optimized dependencies changed`. In the retry trace, a reload shows up as any of: - - a second `iframe.html` load and a second `[vite] connecting...`; +- **Download the artifact.** Run `gh run download -n playwright---components- -D ` (`check-test.yml:314-327`). Results are in `/session-ui/component-tests/` or `/app/component-tests/`. Only the first retry has a trace and, in CI, a video: `test-results/-components-retry1/`. +- **Find reloads.** The story server's output reaches the job log with a `[WebServer]` prefix, so grep the log for `optimized dependencies changed`. In the retry trace, a reload shows up as any of: + - a second frame (`/?id=…`) load and a second `[vite] connecting...`; - the deps `?v=` hash changing between requests; - 504 "Outdated Optimize Dep" on `deps/*.js`. diff --git a/.opencode/skills/opencode-dev/SKILL.md b/.opencode/skills/opencode-dev/SKILL.md index 5d53c6b2921f..31cb2a3c5a24 100644 --- a/.opencode/skills/opencode-dev/SKILL.md +++ b/.opencode/skills/opencode-dev/SKILL.md @@ -132,7 +132,7 @@ opencode debug heap-dump It discovers the already-running service (never starts or replaces one), calls `POST /api/debug/heap-dump`, waits until the file is complete, and prints its server-local path. Pass `--server ` (with `OPENCODE_PASSWORD`) for a server started with `serve`, and `--directory ` to write somewhere other than the server's log directory (the server creates it; a server that predates the option ignores it and the CLI warns). A 404 means the running server predates the endpoint. -For desktop memory, run **Capture heap snapshots** from the desktop command palette (`packages/gui-extensions/src/debug/main.ts`). It writes `~/Downloads/opencode-heap-