ci(vscode): publish combined stable VSIX to Open VSX and harden ab-package gates (#12805)

* ci(vscode): publish combined stable VSIX to Open VSX and harden ab-package gates

* ci(vscode): address greptile review — env-routed version input, bookkeeping survives Open VSX failure
This commit is contained in:
Saoud Rizwan
2026-07-31 20:09:09 -07:00
committed by GitHub
parent a063317218
commit b0ee2cc80a
2 changed files with 200 additions and 55 deletions
+186 -45
View File
@@ -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: