diff --git a/.cline/skills/publish-extension/SKILL.md b/.cline/skills/publish-extension/SKILL.md index 22d076d496..c1e0cbd2f7 100644 --- a/.cline/skills/publish-extension/SKILL.md +++ b/.cline/skills/publish-extension/SKILL.md @@ -37,6 +37,8 @@ All three publish paths gate on tests before publishing: nightly and ab-package | python3 -c "import json,sys; v=json.load(sys.stdin)['results'][0]['extensions'][0]['versions'][0]; print(v['version'], v['lastUpdated'])" ``` + `ext-vscode-ab-package` also enforces this automatically for `publish=true` runs: a preflight job validates the version format (plain `X.Y.Z`) and hard-fails unless it exceeds the live Marketplace version, and the publish job re-checks right before publishing (the approval wait can last days — a legacy hotfix landing in between is caught). Still run the query yourself when *choosing* the version. + 2. **Check the flag BEFORE any stable combined publish.** `ext-sdk-bundle-rollout` is **shared between nightly and stable** — the loader sends only a machine id to `/decide`, no channel property, so there is no per-channel targeting. If the flag is high (nightly dogfooding) and you publish stable, stable users get the next bundle at that same percentage. Verify the effective percentage empirically (no PostHog admin needed — sample `/decide` with random ids using the key inlined in any shipped loader): ```bash @@ -62,7 +64,7 @@ All three publish paths gate on tests before publishing: nightly and ab-package 4. **Changelog lives at the repo ROOT** (`CHANGELOG.md`), on the branch being released — not `apps/vscode/CHANGELOG.md` (doesn't exist). The legacy and stable workflows hard-fail unless the first heading is exactly `## []`. -5. **Stuck concurrency groups**: `ext-vscode-ab-package` groups on the version with `cancel-in-progress: false`. A run left `waiting` on environment approval blocks every later dispatch of the same version — cancel it (`gh run cancel `) before re-dispatching. +5. **Stuck concurrency groups**: `ext-vscode-ab-package` groups on the version with `cancel-in-progress: false`. Only `publish=true` runs wait on environment approval (build-only rehearsals run ungated to completion), but a publish run left `waiting` still blocks every later dispatch of the same version — cancel it (`gh run cancel `) before re-dispatching. ## Stable release (combined A/B VSIX) — the current stable path @@ -92,14 +94,13 @@ Release prep on `main` (PR, not direct push): ```bash gh workflow run ext-vscode-ab-package.yml --ref main \ -f version= -f next-ref=main -f legacy-ref=legacy-extension -f publish=true -# publish=false builds an installable .vsix artifact without publishing, but the -# package job still requires the same Publish environment approval — an -# unapproved rehearsal sits in `waiting` and blocks that version's concurrency -# group (rule 5). +# publish=false builds an installable .vsix artifact without publishing and +# needs NO environment approval — the ungated build job uploads the artifact +# and the run completes. gh run list --workflow=ext-vscode-ab-package.yml --limit 1 ``` -Both test suites run first (no approval needed); the gated `package` job then **waits for `Publish` environment approval** (Actions → run → "Review deployments"). Both bundles build the exact revisions their test gates ran against (branch names are resolved once — commits landing on either branch mid-run or during the approval wait are not picked up); `publish=true` is additionally refused for any `next-ref` other than `main` (the bun gate only tests main — non-main next-refs are for build-only artifact rehearsals). Check what a run is waiting on: +Preflight (version format + monotonicity) and both test suites run first, then the ungated `build` job packages and uploads the VSIX; for `publish=true` the `publish` job then **waits for `Publish` environment approval** (Actions → run → "Review deployments"). Both bundles build the exact revisions their test gates ran against (branch names are resolved once — commits landing on either branch mid-run or during the approval wait are not picked up); `publish=true` is additionally refused for any `next-ref` other than `main` (the bun gate only tests main — non-main next-refs are for build-only artifact rehearsals). Check what a run is waiting on: ```bash gh api repos/cline/cline/actions/runs//pending_deployments @@ -107,7 +108,11 @@ gh api repos/cline/cline/actions/runs//pending_deployments ### Post-publish -1. Verify the marketplace serves the new version (query from rule 1) — expect minutes-to-an-hour of validation lag after "Published" appears in the logs. +1. Verify the marketplace serves the new version (query from rule 1) — expect minutes-to-an-hour of validation lag after "Published" appears in the logs. Also verify Open VSX: + + ```bash + curl -s "https://open-vsx.org/api/saoudrizwan/claude-dev" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['version'], d['timestamp'])" + ``` 2. Tag, GitHub Release (with the .vsix attached), and the Slack release-bot post happen **automatically** after a real publish (all `continue-on-error` — the publish itself already succeeded, so bookkeeping failures leave the run green). Verify they landed; the known failure is the tag push when the built commit touches `.github/workflows/**` (default token cannot create such refs — no grantable permission fixes it). Manual fallback: ```bash @@ -124,7 +129,6 @@ gh api repos/cline/cline/actions/runs//pending_deployments ### Known caveats of this path -- **Marketplace only** — no Open VSX step (both standalone workflows have one). Open VSX users stay on the last standalone version until a standalone publish or the cutover. - **`engines.vscode` unions upward** (main's floor wins, e.g. `^1.101.0` vs legacy's `^1.84.0`): users on older VS Code are never offered the combined VSIX. Fail-safe during rollout; must be resolved before 100%. - A red run can still mean a successful publish on paths that tag (see Gotchas). @@ -163,7 +167,7 @@ When the next bundle has held at 100% long enough to trust: 1. **Resolve the engines floor**: decide whether stranding VS Code < main's `engines.vscode` on the last combined version is acceptable, or lower main's floor first. 2. Bump `apps/vscode/package.json` on `main` above everything ever published; root `CHANGELOG.md` entry to match (both are enforced by the workflow). -3. Ship standalone from main: `gh workflow run ext-vscode-publish-stable.yml --ref main` — tests main, tags `v` itself, creates the GitHub release, publishes Marketplace + Open VSX (this also heals the Open VSX gap). +3. Ship standalone from main: `gh workflow run ext-vscode-publish-stable.yml --ref main` — tests main, tags `v` itself, creates the GitHub release, publishes Marketplace + Open VSX. 4. Watch the same rollout telemetry through the transition — `extension_variant` disappears from events as users leave combined builds, which is itself the adoption signal. 5. Only after the standalone version dominates: retire `legacy-extension` (keep for history), delete `ext-vscode-publish-legacy.yml` and `ext-vscode-ab-package.yml`, convert the nightly workflow back to a plain build of main, remove `apps/vscode-rollout/`, and archive the `ext-sdk-bundle-rollout` flag in PostHog (harmless to machines still on a combined VSIX: absent flag fails safe to... nothing changing until they update, but their loader treats a deleted flag as legacy — leave the flag at 100% until combined-VSIX activations flatline, then archive). 6. Update this skill: delete the combined-era sections and keep the standalone flow. @@ -174,5 +178,5 @@ When the next bundle has held at 100% long enough to trust: - `bun run package` in `apps/vscode` does not build `@cline/*` workspace deps — fresh checkouts need `bun run build:sdk` first (workflows handle this). - Job-level `if:` ref checks in workflow YAML are advisory (a dispatched branch runs its own copy of the file); the enforced boundary is each environment's deployment-branch policy in repo settings. - Marketplace PATs (`VSCE_PAT`/`OVSX_PAT`) are only mounted into publish steps; neither publish workflow has an untrusted trigger surface. -- Environment-approval runs left waiting don't time out quickly — they sit for days and (for ab-package) block their version's concurrency group. +- Environment-approval runs left waiting don't time out quickly — they sit for days and (for ab-package publish runs) block their version's concurrency group. - Local forcing for manual testing: `CLINE_BUNDLE_OVERRIDE=next|legacy` env (launch VS Code fresh from a terminal) or the `.rollout.bundleOverride` setting + reload; both report as `override` in telemetry so they don't pollute cohort data. diff --git a/.github/workflows/ext-vscode-ab-package.yml b/.github/workflows/ext-vscode-ab-package.yml index e024f92e51..8064d5170f 100644 --- a/.github/workflows/ext-vscode-ab-package.yml +++ b/.github/workflows/ext-vscode-ab-package.yml @@ -5,6 +5,11 @@ name: ext-vscode-ab-package # `legacy/` from the legacy-extension branch. Cohort selection happens at # runtime via PostHog flags; see apps/vscode-rollout/README.md for the design # and the rollout runbook. +# +# Job layout: cheap input gates (preflight) and the two bundle test suites run +# ungated; the build job packages the VSIX with no environment attached, so +# publish=false rehearsals complete without any approval; only the publish job +# — Marketplace + Open VSX + bookkeeping — waits on the `publish` environment. on: workflow_dispatch: @@ -24,7 +29,7 @@ on: default: "legacy-extension" type: string publish: - description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)" + description: "Publish to the VS Code Marketplace and Open VSX (unchecked: just build the .vsix artifact)" required: true default: false type: boolean @@ -37,18 +42,80 @@ concurrency: cancel-in-progress: false jobs: + # Input gates that need no checkout: fail in seconds — before the test + # suites, the ~20-minute build, and the environment approval — instead of + # at publish time. + preflight: + name: Validate inputs + runs-on: ubuntu-latest + steps: + # The input reaches the shell ONLY via env here (never inline + # expression interpolation, which is evaluated before bash runs and + # would allow script injection from the dispatch form). Because + # every later job `needs` preflight, passing this regex is what + # makes the plain-string `${{ inputs.version }}` interpolations + # downstream safe. + - name: Validate version format + env: + VERSION: ${{ github.event.inputs.version }} + run: | + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Error: version must be plain X.Y.Z with no leading 'v' and no suffix (got '$VERSION')." + echo "It is stamped verbatim into the union manifest and both bundle manifests." + exit 1 + fi + echo "Version format ok: $VERSION" + + # The reusable bun suite tests the dispatch revision (main), so + # publishing any other next-ref would ship an untested bundle. + # Build-only runs (publish=false) may still use arbitrary next-refs + # for artifact rehearsals. + - name: Refuse to publish an untested next-ref + if: ${{ github.event.inputs.publish == 'true' && github.event.inputs.next-ref != 'main' }} + run: | + echo "Error: publish=true requires next-ref=main — the test gate only covers main." + exit 1 + + # Marketplace versions are monotonic and cannot be unpublished: + # every publish must exceed the highest version ever published to + # the claude-dev listing FROM ANY BRANCH (combined stable or legacy + # hotfix). The publish job re-checks right before publishing — the + # environment-approval wait can last days and a legacy hotfix can + # land in between. Keep both copies of this check in sync. + - name: Verify version exceeds the live Marketplace version + if: ${{ github.event.inputs.publish == 'true' }} + env: + VERSION: ${{ github.event.inputs.version }} + run: | + LIVE=$(curl -sf --retry 3 -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \ + -H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \ + --data '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \ + | node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{process.stdout.write(JSON.parse(d).results[0].extensions[0].versions[0].version)})') + if [[ -z "$LIVE" ]]; then + echo "Error: could not resolve the live Marketplace version for saoudrizwan.claude-dev." + exit 1 + fi + node -e ' + const [next, live] = process.argv.slice(1).map((v) => v.split(".").map(Number)); + for (let i = 0; i < 3; i++) { + if (next[i] > live[i]) process.exit(0); + if (next[i] < live[i]) break; + } + console.error(`Error: version ${process.argv[1]} does not exceed the live Marketplace version ${process.argv[2]}.`); + process.exit(1); + ' "$VERSION" "$LIVE" + echo "Version ok: $VERSION exceeds live Marketplace version $LIVE" + # Gate the build/publish on BOTH bundles' own test suites, mirroring the two # standalone publish paths (nightly gates on the bun suite via the same - # reusable workflow; the legacy publish inlines the npm suite). The gated - # `package` job requests its `publish` environment approval only after both - # suites pass. + # reusable workflow; the legacy publish inlines the npm suite). # # Caveat (shared with the nightly workflow): the reusable bun suite tests the # DISPATCH revision — main's tip at dispatch, since this workflow is only - # dispatched from main — not `next-ref`. The package job therefore pins the + # dispatched from main — not `next-ref`. The build job therefore pins the # default next-ref checkout to that same revision (tested == built) and - # refuses publish=true for any other next-ref; build-only artifact runs may - # still build untested refs. + # preflight refuses publish=true for any other next-ref; build-only artifact + # runs may still build untested refs. test-next: name: Test next (SDK) bundle permissions: @@ -62,11 +129,10 @@ jobs: test-legacy: name: Test legacy bundle runs-on: ubuntu-latest - # The tested revision, exported so the package job builds EXACTLY what + # The tested revision, exported so the build job builds EXACTLY what # this suite ran against. legacy-ref is a mutable branch name and the - # package job starts much later (test phase + environment-approval wait, - # potentially days) — re-resolving the name there could pick up commits - # this gate never saw. + # build job starts later — re-resolving the name there could pick up + # commits this gate never saw. outputs: tested-sha: ${{ steps.rev.outputs.sha }} defaults: @@ -121,27 +187,11 @@ jobs: cd webview-ui npm run test:coverage - package: + build: name: Build combined (legacy + next) VSIX - needs: [test-next, test-legacy] + needs: [preflight, test-next, test-legacy] runs-on: ubuntu-latest - environment: publish - # contents: write is required by the post-publish bookkeeping (tag + - # GitHub Release), mirroring the standalone publish workflows. - permissions: - contents: write steps: - # Refuse to publish a next bundle the test-next gate did not cover. - # The reusable suite tests the dispatch revision (main), so publishing - # any other next-ref would ship an untested bundle. Build-only runs - # (publish=false) may still use arbitrary next-refs for artifact - # rehearsals. - - name: Refuse to publish an untested next-ref - if: ${{ github.event.inputs.publish == 'true' && github.event.inputs.next-ref != 'main' }} - run: | - echo "Error: publish=true requires next-ref=main — the test gate only covers main." - exit 1 - # For the default next-ref (main), pin the checkout to the exact # revision the test-next gate ran against: a moving branch name could # otherwise drift past the tested commit during the test phase. @@ -185,9 +235,12 @@ jobs: with: node-version: 22 + # --frozen-lockfile so the built bundle resolves the exact + # dependency set the test-next gate ran against (the reusable suite + # installs frozen too) — a bare install could silently re-resolve. - name: Install next workspace dependencies working-directory: next-src - run: bun install + run: bun install --frozen-lockfile # @cline/* are local workspace symlinks to source packages; apps/vscode's # `package` script does NOT build them, so without this the esbuild step @@ -196,6 +249,17 @@ jobs: working-directory: next-src run: bun run build:sdk + - name: Assert better-sqlite3 native binary present + working-directory: next-src/apps/vscode + run: | + NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node" + if [ ! -f "$NODE_FILE" ]; then + echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE" + echo "(bun trustedDependencies postinstall likely did not run)" + exit 1 + fi + echo "Found better-sqlite3 native binary: $NODE_FILE" + # Stamp the combined version into each bundle's package.json AFTER # install and BEFORE its build: the About tab and telemetry # extension_version read the bundle's own manifest, so without this @@ -312,32 +376,111 @@ jobs: path: staging/claude-dev-${{ github.event.inputs.version }}.vsix if-no-files-found: error + publish: + name: Publish to Marketplace and Open VSX + needs: build + if: ${{ github.event.inputs.publish == 'true' }} + runs-on: ubuntu-latest + environment: publish + # contents: write is required by the post-publish bookkeeping (tag + + # GitHub Release), mirroring the standalone publish workflows. + permissions: + contents: write + steps: + # The built next revision: preflight refused publish=true for any + # next-ref other than main, and the build job pinned main to the + # dispatch SHA — so github.sha IS the published commit. Used for the + # changelog, the release tag, and the previous-tag lookup. + - uses: actions/checkout@v4 + with: + ref: ${{ github.sha }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Download VSIX artifact + uses: actions/download-artifact@v4 + with: + name: claude-dev-${{ github.event.inputs.version }} + path: staging + + - name: Install Publishing Tools + run: npm install -g @vscode/vsce ovsx + + # Re-check monotonicity at the last moment: the environment-approval + # wait can last days, and a legacy hotfix published in the meantime + # would otherwise be silently superseded by this older code line. + # Keep in sync with the preflight copy of this check. + - name: Re-verify version exceeds the live Marketplace version + env: + VERSION: ${{ github.event.inputs.version }} + run: | + LIVE=$(curl -sf --retry 3 -X POST "https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" \ + -H "Content-Type: application/json" -H "Accept: application/json;api-version=3.0-preview.1" \ + --data '{"filters":[{"criteria":[{"filterType":7,"value":"saoudrizwan.claude-dev"}]}],"flags":16}' \ + | node -e 'let d="";process.stdin.on("data",c=>d+=c);process.stdin.on("end",()=>{process.stdout.write(JSON.parse(d).results[0].extensions[0].versions[0].version)})') + if [[ -z "$LIVE" ]]; then + echo "Error: could not resolve the live Marketplace version for saoudrizwan.claude-dev." + exit 1 + fi + node -e ' + const [next, live] = process.argv.slice(1).map((v) => v.split(".").map(Number)); + for (let i = 0; i < 3; i++) { + if (next[i] > live[i]) process.exit(0); + if (next[i] < live[i]) break; + } + console.error(`Error: version ${process.argv[1]} does not exceed the live Marketplace version ${process.argv[2]}.`); + process.exit(1); + ' "$VERSION" "$LIVE" + echo "Version ok: $VERSION exceeds live Marketplace version $LIVE" + + # Both PATs are verified BEFORE the first irreversible publish so a + # missing Open VSX token can't strand us half-published. The two + # registries are separate steps: if Open VSX fails after the + # Marketplace accepted the VSIX, the run goes red (so the operator + # notices Open VSX lagged) but the bookkeeping below still runs — + # it is keyed off the Marketplace outcome, which is what "shipped" + # means for this listing. - name: Publish to Marketplace - if: ${{ github.event.inputs.publish == 'true' }} + id: publish_marketplace working-directory: staging env: VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} run: | if [[ -z "$VSCE_PAT" ]]; then echo "Error: VSCE_PAT is required to publish." exit 1 fi + if [[ -z "$OVSX_PAT" ]]; then + echo "Error: OVSX_PAT is required to publish to Open VSX." + exit 1 + fi vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix" + - name: Publish to Open VSX + working-directory: staging + env: + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: npx ovsx publish --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix" --pat "$OVSX_PAT" + # ---- Post-publish bookkeeping (tag / GitHub Release / Slack) ---- # Mirrors the standalone publish workflows. Every step here is - # continue-on-error: the Marketplace publish above already happened, - # and a red run after a successful publish is exactly the confusion - # the nightly workflow taught us to avoid (tag pushes fail whenever - # the built commit touches .github/workflows/** — no grantable - # permission fixes that; push the tag manually in that case, see - # the publish-extension skill). + # continue-on-error, and gated on the MARKETPLACE outcome rather + # than plain step ordering: the Marketplace publish already + # happened, so bookkeeping must still run when only the Open VSX + # step failed, and a red run after a successful publish is exactly + # the confusion the nightly workflow taught us to avoid (tag pushes + # fail whenever the built commit touches .github/workflows/** — no + # grantable permission fixes that; push the tag manually in that + # case, see the publish-extension skill). - name: Extract changelog entry id: changelog - if: ${{ github.event.inputs.publish == 'true' }} + if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }} continue-on-error: true - working-directory: next-src run: | CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md) { @@ -348,9 +491,8 @@ jobs: - name: Resolve previous release tag id: prev_tag - if: ${{ github.event.inputs.publish == 'true' }} + if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }} continue-on-error: true - working-directory: next-src run: | # ls-remote needs no local tag objects; take the highest v* tag # below the one being released. @@ -362,9 +504,8 @@ jobs: echo "prev_tag=$PREV" >> "$GITHUB_OUTPUT" - name: Create and push release tag - if: ${{ github.event.inputs.publish == 'true' }} + if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }} continue-on-error: true - working-directory: next-src run: | TAG="v${{ github.event.inputs.version }}" git tag "$TAG" HEAD @@ -372,7 +513,7 @@ jobs: echo "Pushed $TAG at $(git rev-parse HEAD)" - name: Create GitHub Release - if: ${{ github.event.inputs.publish == 'true' }} + if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }} continue-on-error: true uses: softprops/action-gh-release@v1 with: @@ -386,7 +527,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Post release to Slack - if: ${{ github.event.inputs.publish == 'true' }} + if: ${{ !cancelled() && steps.publish_marketplace.outcome == 'success' }} continue-on-error: true uses: slackapi/slack-github-action@v3.0.1 with: