From ff7230f6038206305649e9ffc174b82c533cb4fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 10 Oct 2026 17:44:35 +0200 Subject: [PATCH 1/5] feat(release): publish nightly and stable releases through npm trusted publishing .github/workflows/release.yml owns every npm publish. A schedule publishes main's HEAD under the nightly dist-tag once CI passed; dispatching stable promotes the latest nightly's commit to X.Y.Z, then publishes the GitHub release, its assets, and the MCP Registry entry, and moves main to the next -dev version. Only the publish job can mint an id-token, and no npm token is involved. --- .github/actions/setup-node-pnpm/action.yml | 6 +- .github/workflows/publish-mcp-registry.yml | 35 +- .../release-android-snapshot-helper.yml | 19 +- .github/workflows/release-ios-runner.yml | 19 +- .github/workflows/release.yml | 327 ++++++++++++++++++ scripts/__tests__/release-plan.test.ts | 93 +++++ scripts/__tests__/release-version.test.ts | 56 +++ scripts/release-plan.mjs | 170 +++++++++ scripts/release-publish.mjs | 59 ++++ scripts/release-version.mjs | 122 +++++++ test/ci/release-workflow.test.ts | 42 +++ vitest.config.ts | 5 + 12 files changed, 912 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 scripts/__tests__/release-plan.test.ts create mode 100644 scripts/__tests__/release-version.test.ts create mode 100644 scripts/release-plan.mjs create mode 100644 scripts/release-publish.mjs create mode 100644 scripts/release-version.mjs create mode 100644 test/ci/release-workflow.test.ts diff --git a/.github/actions/setup-node-pnpm/action.yml b/.github/actions/setup-node-pnpm/action.yml index 0d2a472b58..cf91cd0de9 100644 --- a/.github/actions/setup-node-pnpm/action.yml +++ b/.github/actions/setup-node-pnpm/action.yml @@ -10,6 +10,10 @@ inputs: description: 'Whether to install dependencies with pnpm install --frozen-lockfile' required: false default: 'true' + cache-store: + description: 'Whether installing dependencies may restore and save the pnpm store cache' + required: false + default: 'true' cache-dependency-path: description: 'Lockfile path(s) used to key the pnpm store cache' required: false @@ -48,7 +52,7 @@ runs: # Only cache the pnpm store when this action also installs dependencies. # Jobs with install-deps: false never create the store, and on a cache # miss (any lockfile-changing PR) the post-job cache save fails the job. - cache: ${{ inputs.install-deps == 'true' && 'pnpm' || '' }} + cache: ${{ inputs.install-deps == 'true' && inputs.cache-store == 'true' && 'pnpm' || '' }} cache-dependency-path: ${{ inputs.cache-dependency-path }} # Corepack resolves the same `packageManager` field locally, so a mismatch diff --git a/.github/workflows/publish-mcp-registry.yml b/.github/workflows/publish-mcp-registry.yml index c2114a9a0b..ff3655cb82 100644 --- a/.github/workflows/publish-mcp-registry.yml +++ b/.github/workflows/publish-mcp-registry.yml @@ -1,9 +1,13 @@ name: Publish MCP Registry on: - release: - types: - - published + # release.yml calls this after publishing a stable release; dispatch reruns it for one tag. + workflow_call: + inputs: + release_tag: + description: GitHub Release tag to publish metadata for, for example v0.16.5. + required: true + type: string workflow_dispatch: inputs: release_tag: @@ -32,30 +36,21 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ github.event.inputs.checkout_ref || github.ref }} + ref: ${{ inputs.checkout_ref || github.ref }} - name: Setup Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: '24.13' - - name: Resolve release metadata - id: meta + # A released commit carries main's -dev version; stamp the tag's version into + # package.json and server.json before publishing the metadata. + - name: Stamp release version + env: + RELEASE_TAG: ${{ inputs.release_tag }} run: | - set -euo pipefail - PACKAGE_VERSION="$(node -p "JSON.parse(require('node:fs').readFileSync('package.json', 'utf8')).version")" - SERVER_VERSION="$(node -p "JSON.parse(require('node:fs').readFileSync('server.json', 'utf8')).version")" - TAG_NAME="${{ github.event.release.tag_name || github.event.inputs.release_tag }}" - VERSION="${TAG_NAME#v}" - if [ "$VERSION" != "$PACKAGE_VERSION" ]; then - echo "Release tag $TAG_NAME does not match package.json version $PACKAGE_VERSION" >&2 - exit 1 - fi - if [ "$VERSION" != "$SERVER_VERSION" ]; then - echo "Release tag $TAG_NAME does not match server.json version $SERVER_VERSION" >&2 - exit 1 - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + npm pkg set "version=${RELEASE_TAG#v}" + node scripts/sync-mcp-metadata.mjs shell: bash # This job provisions pnpm via plain actions/setup-node, not the repo's diff --git a/.github/workflows/release-android-snapshot-helper.yml b/.github/workflows/release-android-snapshot-helper.yml index 69b43a894b..1ec3c24e60 100644 --- a/.github/workflows/release-android-snapshot-helper.yml +++ b/.github/workflows/release-android-snapshot-helper.yml @@ -1,9 +1,13 @@ name: Release Android Snapshot Helper on: - release: - types: - - published + # release.yml calls this after publishing a stable release; dispatch reruns it for one tag. + workflow_call: + inputs: + release_tag: + description: GitHub Release tag to upload assets to, for example v0.13.3. + required: true + type: string workflow_dispatch: inputs: release_tag: @@ -30,7 +34,7 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ github.event.inputs.checkout_ref || github.ref }} + ref: ${{ inputs.checkout_ref || github.ref }} - name: Setup Node.js uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 @@ -60,13 +64,8 @@ jobs: id: meta run: | set -euo pipefail - PACKAGE_VERSION="$(node -p "JSON.parse(require('node:fs').readFileSync('package.json', 'utf8')).version")" - TAG_NAME="${{ github.event.release.tag_name || github.event.inputs.release_tag }}" + TAG_NAME="${{ inputs.release_tag }}" VERSION="${TAG_NAME#v}" - if [ "$VERSION" != "$PACKAGE_VERSION" ]; then - echo "Release tag $TAG_NAME does not match package.json version $PACKAGE_VERSION" >&2 - exit 1 - fi echo "tag=$TAG_NAME" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" shell: bash diff --git a/.github/workflows/release-ios-runner.yml b/.github/workflows/release-ios-runner.yml index e746e8d80b..54ef3d6110 100644 --- a/.github/workflows/release-ios-runner.yml +++ b/.github/workflows/release-ios-runner.yml @@ -1,9 +1,13 @@ name: Release iOS Runner on: - release: - types: - - published + # release.yml calls this after publishing a stable release; dispatch reruns it for one tag. + workflow_call: + inputs: + release_tag: + description: GitHub Release tag to upload assets to, for example v0.12.6. + required: true + type: string workflow_dispatch: inputs: release_tag: @@ -31,7 +35,7 @@ jobs: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - ref: ${{ github.event.inputs.checkout_ref || github.ref }} + ref: ${{ inputs.checkout_ref || github.ref }} - name: Setup toolchain uses: ./.github/actions/setup-node-pnpm @@ -42,13 +46,8 @@ jobs: id: meta run: | set -euo pipefail - PACKAGE_VERSION="$(node -p "JSON.parse(require('node:fs').readFileSync('package.json', 'utf8')).version")" - TAG_NAME="${{ github.event.release.tag_name || github.event.inputs.release_tag }}" + TAG_NAME="${{ inputs.release_tag }}" VERSION="${TAG_NAME#v}" - if [ "$VERSION" != "$PACKAGE_VERSION" ]; then - echo "Release tag $TAG_NAME does not match package.json version $PACKAGE_VERSION" >&2 - exit 1 - fi echo "tag=$TAG_NAME" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT" shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..3c574c4a39 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,327 @@ +name: Release + +# One workflow owns every npm publish, so each package trusts exactly this file +# (npm trusted publishing pins the workflow filename and the npm-publish +# environment). scripts/release-plan.mjs maps the triggering event to a mode: +# dry-run (PR), nightly (schedule or dispatch), promote (dispatch stable), or +# stable (a vX.Y.Z tag, created by promote or pushed by a maintainer). +on: + pull_request: + paths: + - '.github/workflows/release.yml' + - 'scripts/release-*.mjs' + - 'scripts/build-package-xcuitest.mjs' + - 'scripts/check-package.ts' + - 'scripts/prepare-publish-assets.mjs' + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + schedule: + # Retried every few hours: a nightly waits for green CI on main's HEAD and + # publishes at most once per UTC day. + - cron: '23 */3 * * *' + workflow_dispatch: + inputs: + channel: + description: nightly publishes main now; stable promotes the latest nightly. + required: true + default: nightly + type: choice + options: + - nightly + - stable + version: + description: Stable version override (X.Y.Z). Defaults to the version the latest nightly previews. + required: false + type: string + +permissions: {} + +concurrency: + group: >- + ${{ github.event_name == 'pull_request' && format('release-pr-{0}', github.ref) + || startsWith(github.ref, 'refs/tags/') && format('release-{0}', github.ref) + || inputs.channel == 'stable' && 'release-promote' + || 'release-nightly' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + plan: + name: Plan + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: read + contents: read + outputs: + mode: ${{ steps.plan.outputs.mode }} + version: ${{ steps.plan.outputs.version }} + dist_tag: ${{ steps.plan.outputs.dist_tag }} + commit: ${{ steps.plan.outputs.commit }} + previous_tag: ${{ steps.plan.outputs.previous_tag }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '24.13' + + - id: plan + name: Plan release + env: + GITHUB_TOKEN: ${{ github.token }} + RELEASE_CHANNEL: ${{ inputs.channel }} + RELEASE_VERSION: ${{ inputs.version }} + run: node scripts/release-plan.mjs + + promote: + name: Promote ${{ needs.plan.outputs.version }} + needs: plan + if: needs.plan.outputs.mode == 'promote' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: write + contents: write + steps: + # A run started by GITHUB_TOKEN's tag push would not trigger; dispatching + # on the tag gives the stable run a github.sha (and npm provenance) of + # exactly the promoted commit. + - name: Tag the promoted nightly and start its stable release + env: + GH_TOKEN: ${{ github.token }} + TAG: v${{ needs.plan.outputs.version }} + COMMIT: ${{ needs.plan.outputs.commit }} + run: | + set -euo pipefail + gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$COMMIT" + gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" + echo "Started the $TAG release from $COMMIT." >> "$GITHUB_STEP_SUMMARY" + + approve: + name: Approve ${{ needs.plan.outputs.version }} + needs: plan + if: needs.plan.outputs.mode == 'stable' + runs-on: ubuntu-latest + timeout-minutes: 5 + environment: release + permissions: {} + steps: + - run: echo "Approved the agent-device ${{ needs.plan.outputs.version }} release." + + build: + name: Build ${{ needs.plan.outputs.version }} + needs: plan + if: contains(fromJSON('["dry-run", "nightly", "stable"]'), needs.plan.outputs.mode) + runs-on: macos-26 + timeout-minutes: 120 + permissions: + contents: read + env: + # The helper build refuses to guess its build-tools version on CI. + AGENT_DEVICE_ANDROID_BUILD_TOOLS: 36.0.0 + VERSION: ${{ needs.plan.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + # No dependency cache: published bytes come from a clean install. + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + with: + cache-store: 'false' + + - name: Install Android helper toolchain + run: | + set -euo pipefail + SDKMANAGER="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + yes | "$SDKMANAGER" --licenses >/dev/null || true + "$SDKMANAGER" "platforms;android-36" "build-tools;$AGENT_DEVICE_ANDROID_BUILD_TOOLS" + + - name: Stamp release version + run: node scripts/release-version.mjs stamp "$VERSION" + + - name: Build and verify every public package + run: pnpm release:prepare + + - name: Upload packages + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: npm-packages + path: .tmp/release/*.tgz + include-hidden-files: true + if-no-files-found: error + retention-days: 14 + + publish: + name: Publish ${{ needs.plan.outputs.version }} to npm + needs: [plan, approve, build] + if: >- + !cancelled() && needs.build.result == 'success' + && (needs.plan.outputs.mode == 'nightly' || needs.approve.result == 'success') + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: npm-publish + permissions: + contents: read + id-token: write + steps: + - name: Checkout publisher + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout: scripts/release-publish.mjs + sparse-checkout-cone-mode: false + + # No registry-url: setup-node would write an .npmrc token placeholder that + # shadows the OIDC exchange. + - name: Setup Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 + with: + node-version: '24.13' + + - name: Download packages + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: npm-packages + path: ${{ runner.temp }}/npm-packages + + - name: Publish + env: + VERSION: ${{ needs.plan.outputs.version }} + DIST_TAG: ${{ needs.plan.outputs.dist_tag }} + run: node scripts/release-publish.mjs "$RUNNER_TEMP/npm-packages" "$VERSION" "$DIST_TAG" + + tag-nightly: + name: Tag nightly ${{ needs.plan.outputs.version }} + needs: [plan, publish] + if: needs.plan.outputs.mode == 'nightly' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Tag the published commit + env: + GH_TOKEN: ${{ github.token }} + TAG: v${{ needs.plan.outputs.version }} + COMMIT: ${{ needs.plan.outputs.commit }} + run: | + gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$COMMIT" + echo "Published $TAG from $COMMIT: npm install agent-device@nightly" >> "$GITHUB_STEP_SUMMARY" + + github-release: + name: Draft GitHub release + needs: [plan, publish] + if: needs.plan.outputs.mode == 'stable' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Draft release notes + env: + GH_TOKEN: ${{ github.token }} + TAG: v${{ needs.plan.outputs.version }} + PREVIOUS_TAG: ${{ needs.plan.outputs.previous_tag }} + run: | + set -euo pipefail + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "$TAG already has a release." + exit 0 + fi + gh release create "$TAG" --repo "$GITHUB_REPOSITORY" --verify-tag --draft \ + --title "$TAG" --generate-notes ${PREVIOUS_TAG:+--notes-start-tag "$PREVIOUS_TAG"} + + ios-runner: + name: iOS runner assets + needs: [plan, github-release] + permissions: + contents: write + uses: ./.github/workflows/release-ios-runner.yml + with: + release_tag: v${{ needs.plan.outputs.version }} + + android-snapshot-helper: + name: Android helper assets + needs: [plan, github-release] + permissions: + contents: write + uses: ./.github/workflows/release-android-snapshot-helper.yml + with: + release_tag: v${{ needs.plan.outputs.version }} + + publish-github-release: + name: Publish GitHub release + needs: [plan, ios-runner, android-snapshot-helper] + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write + steps: + - name: Publish the drafted release + env: + GH_TOKEN: ${{ github.token }} + TAG: v${{ needs.plan.outputs.version }} + run: gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest + + mcp-registry: + name: MCP Registry + needs: [plan, publish] + if: needs.plan.outputs.mode == 'stable' + permissions: + contents: read + id-token: write + uses: ./.github/workflows/publish-mcp-registry.yml + with: + release_tag: v${{ needs.plan.outputs.version }} + + mark-released: + name: Move main past ${{ needs.plan.outputs.version }} + needs: [plan, publish] + if: needs.plan.outputs.mode == 'stable' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + steps: + - name: Checkout main + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: main + + - name: Setup toolchain + uses: ./.github/actions/setup-node-pnpm + with: + install-deps: 'false' + + # main must never carry a published version. Retried because merges can + # land on main while the release runs. + - name: Commit the next development version + env: + VERSION: ${{ needs.plan.outputs.version }} + run: | + set -euo pipefail + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + for attempt in 1 2 3; do + git fetch origin main + git reset --hard origin/main + next="$(node scripts/release-version.mjs advance "$VERSION")" + if git diff --quiet; then + echo "main already builds towards $next." + exit 0 + fi + git add -u + git commit -m "chore: mark $VERSION as released" + if git push origin HEAD:main; then + exit 0 + fi + done + exit 1 diff --git a/scripts/__tests__/release-plan.test.ts b/scripts/__tests__/release-plan.test.ts new file mode 100644 index 0000000000..24101f5c71 --- /dev/null +++ b/scripts/__tests__/release-plan.test.ts @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { planRelease } from '../release-plan.mjs'; + +const MAIN = 'a'.repeat(40); +const NIGHTLY_COMMIT = 'b'.repeat(40); + +function run(overrides: Record = {}) { + return { + event: 'schedule', + ref: 'refs/heads/main', + sha: MAIN, + channel: 'nightly', + requestedVersion: '', + rootVersion: '0.21.25-dev', + today: '20261011', + runNumber: '42', + tags: new Map([ + ['v0.21.24', 'c'.repeat(40)], + ['v0.21.25-nightly.20261010.41', NIGHTLY_COMMIT], + ]), + latestStable: '0.21.24', + ciConclusion: 'success', + onMain: undefined, + ...overrides, + }; +} + +test('a pull request builds a nightly-shaped version and publishes nothing', () => { + assert.deepEqual(planRelease(run({ event: 'pull_request', ref: 'refs/pull/7/merge' })), { + mode: 'dry-run', + version: '0.21.25-nightly.20261011.42', + }); +}); + +test('a scheduled nightly publishes main once its CI passed', () => { + assert.deepEqual(planRelease(run()), { + mode: 'nightly', + version: '0.21.25-nightly.20261011.42', + distTag: 'nightly', + commit: MAIN, + }); +}); + +test('a scheduled nightly waits for new commits, a new day, and green CI', () => { + assert.match(planRelease(run({ sha: NIGHTLY_COMMIT })).reason, /already ships/); + assert.match(planRelease(run({ today: '20261010' })).reason, /shipped today/); + assert.match(planRelease(run({ ciConclusion: 'in_progress' })).reason, /is in_progress/); +}); + +test('a dispatched nightly skips the cadence checks but still requires green CI', () => { + const dispatched = run({ event: 'workflow_dispatch', sha: NIGHTLY_COMMIT, today: '20261010' }); + assert.equal(planRelease(dispatched).mode, 'nightly'); + assert.throws(() => planRelease({ ...dispatched, ciConclusion: 'failure' }), /CI on .* failure/); +}); + +test('a nightly refuses a main whose -dev version trails the published release', () => { + assert.throws(() => planRelease(run({ latestStable: '0.21.25' })), /not ahead of the published/); +}); + +test('promotion tags the latest nightly commit with the version it previewed', () => { + const promoted = run({ event: 'workflow_dispatch', channel: 'stable' }); + assert.deepEqual(planRelease(promoted), { + mode: 'promote', + version: '0.21.25', + commit: NIGHTLY_COMMIT, + nightly: 'v0.21.25-nightly.20261010.41', + }); + assert.equal(planRelease({ ...promoted, requestedVersion: '0.22.0' }).version, '0.22.0'); + assert.throws(() => planRelease({ ...promoted, requestedVersion: '0.21.23' }), /not ahead/); + assert.throws(() => planRelease({ ...promoted, requestedVersion: '0.22' }), /X\.Y\.Z/); + assert.throws(() => planRelease({ ...promoted, requestedVersion: '0.21.24' }), /already tagged/); + assert.throws(() => planRelease({ ...promoted, tags: new Map() }), /No nightly/); +}); + +test('a stable tag publishes its own commit when it is on main', () => { + const stable = run({ event: 'workflow_dispatch', ref: 'refs/tags/v0.21.25', onMain: true }); + assert.deepEqual(planRelease(stable), { + mode: 'stable', + version: '0.21.25', + distTag: 'latest', + commit: MAIN, + previousTag: 'v0.21.24', + }); + assert.equal(planRelease({ ...stable, latestStable: '0.21.25' }).mode, 'stable'); + assert.throws(() => planRelease({ ...stable, latestStable: '0.21.26' }), /older than/); + assert.throws(() => planRelease({ ...stable, onMain: false }), /not on main/); + assert.throws(() => planRelease({ ...stable, ref: 'refs/tags/v0.21.25-rc.1' }), /vX\.Y\.Z tag/); +}); + +test('releases refuse to run from any other branch', () => { + assert.throws(() => planRelease(run({ ref: 'refs/heads/feature' })), /main or a vX\.Y\.Z tag/); +}); diff --git a/scripts/__tests__/release-version.test.ts b/scripts/__tests__/release-version.test.ts new file mode 100644 index 0000000000..d1bbe216f8 --- /dev/null +++ b/scripts/__tests__/release-version.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + compareReleaseVersions, + latestNightlyTag, + nextDevelopmentVersion, + nightlyBase, + nightlyVersion, + previousReleaseTag, +} from '../release-version.mjs'; + +test('a nightly previews the release main is building towards', () => { + const version = nightlyVersion('0.21.25-dev', '20261010', 42); + assert.equal(version, '0.21.25-nightly.20261010.42'); + assert.equal(nightlyBase(version), '0.21.25'); +}); + +test('a nightly is refused unless main carries a -dev version', () => { + assert.throws(() => nightlyVersion('0.21.25', '20261010', 1), /X\.Y\.Z-dev/); + assert.throws(() => nightlyVersion('0.21.25-dev', '2026-10-10', 1), /YYYYMMDD/); + assert.throws(() => nightlyVersion('0.21.25-dev', '20261010', 0), /positive integer/); +}); + +test('the latest nightly orders by base, then date, then run number', () => { + const tags = [ + 'v0.21.24', + 'v0.21.25-nightly.20261009.9', + 'v0.21.25-nightly.20261010.10', + 'v0.21.25-nightly.20261010.12', + 'v0.21.24-nightly.20261011.13', + 'evidence/v0.21.26-nightly.20261012.14', + ]; + assert.equal(latestNightlyTag(tags), 'v0.21.25-nightly.20261010.12'); + assert.equal(latestNightlyTag(['v0.21.24']), null); +}); + +test('release notes start from the newest older stable tag', () => { + const tags = ['v0.21.9', 'v0.21.23', 'v0.21.24', 'v0.21.25-nightly.20261010.1', 'v0.22.0']; + assert.equal(previousReleaseTag(tags, '0.21.25'), 'v0.21.24'); + assert.equal(previousReleaseTag(tags, '0.21.10'), 'v0.21.9'); + assert.equal(previousReleaseTag(tags, '0.1.0'), null); +}); + +test('main moves past a release unless it already builds towards a later one', () => { + assert.equal(nextDevelopmentVersion('0.21.25-dev', '0.21.25'), '0.21.26-dev'); + assert.equal(nextDevelopmentVersion('0.21.25-dev', '0.22.0'), '0.22.1-dev'); + assert.equal(nextDevelopmentVersion('0.22.0-dev', '0.21.25'), '0.22.0-dev'); + assert.throws(() => nextDevelopmentVersion('0.21.25', '0.21.25'), /X\.Y\.Z-dev/); +}); + +test('release versions compare numerically per segment', () => { + assert.ok(compareReleaseVersions('0.21.10', '0.21.9') > 0); + assert.ok(compareReleaseVersions('1.0.0', '0.99.99') > 0); + assert.equal(compareReleaseVersions('0.21.25', '0.21.25'), 0); + assert.throws(() => compareReleaseVersions('0.21.25-dev', '0.21.25'), /release version/); +}); diff --git a/scripts/release-plan.mjs b/scripts/release-plan.mjs new file mode 100644 index 0000000000..6010846c56 --- /dev/null +++ b/scripts/release-plan.mjs @@ -0,0 +1,170 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { + compareReleaseVersions, + isReleaseVersion, + latestNightlyTag, + nightlyBase, + nightlyVersion, + previousReleaseTag, +} from './release-version.mjs'; + +/** + * Decides what one `.github/workflows/release.yml` run does, from the event that started it: + * pull_request dry-run: build and verify every package, publish nothing + * schedule / nightly nightly: publish main's HEAD under the `nightly` dist-tag + * dispatch stable on main promote: tag the latest nightly's commit and start its stable run + * vX.Y.Z tag stable: publish that commit under the `latest` dist-tag + * `tags` maps remote tag names to commits; `ciConclusion` and `onMain` describe `sha`. + */ +export function planRelease(run) { + if (run.event === 'pull_request') { + return { mode: 'dry-run', version: nightlyVersion(run.rootVersion, run.today, run.runNumber) }; + } + if (run.ref.startsWith('refs/tags/')) return planStable(run, run.ref.slice('refs/tags/'.length)); + if (run.ref !== 'refs/heads/main') { + throw new Error(`Releases run from main or a vX.Y.Z tag, not ${run.ref}.`); + } + return run.channel === 'stable' ? planPromotion(run) : planNightly(run); +} + +function planNightly(run) { + const scheduled = run.event === 'schedule'; + const latest = latestNightlyTag([...run.tags.keys()]); + if (scheduled && latest && run.tags.get(latest) === run.sha) { + return skip(`${latest} already ships ${run.sha}.`); + } + if (scheduled && latest?.includes(`-nightly.${run.today}.`)) { + return skip(`${latest} shipped today.`); + } + if (run.ciConclusion !== 'success') { + const reason = `CI on ${run.sha} is ${run.ciConclusion}; a nightly ships only a commit whose CI passed.`; + if (scheduled) return skip(reason); + throw new Error(reason); + } + const version = nightlyVersion(run.rootVersion, run.today, run.runNumber); + assertAheadOfStable(run, nightlyBase(version)); + return { mode: 'nightly', version, distTag: 'nightly', commit: run.sha }; +} + +function planPromotion(run) { + const nightly = latestNightlyTag([...run.tags.keys()]); + if (!nightly) throw new Error('No nightly has been published to promote.'); + const version = run.requestedVersion || nightlyBase(nightly.slice(1)); + if (!isReleaseVersion(version)) throw new Error(`${version} is not an X.Y.Z release version.`); + if (run.tags.has(`v${version}`)) throw new Error(`v${version} is already tagged.`); + assertAheadOfStable(run, version); + return { mode: 'promote', version, commit: run.tags.get(nightly), nightly }; +} + +function planStable(run, tag) { + const version = tag.slice(1); + if (!tag.startsWith('v') || !isReleaseVersion(version)) { + throw new Error(`Stable releases run from a vX.Y.Z tag, not ${tag}.`); + } + // An equal version is a retry: the publisher skips packages already on the registry. + if (run.latestStable && compareReleaseVersions(version, run.latestStable) < 0) { + throw new Error(`${version} is older than the published ${run.latestStable}.`); + } + if (!run.onMain) throw new Error(`${tag} is not on main.`); + const previousTag = previousReleaseTag([...run.tags.keys()], version) ?? ''; + return { mode: 'stable', version, distTag: 'latest', commit: run.sha, previousTag }; +} + +function skip(reason) { + return { mode: 'skip', reason }; +} + +function assertAheadOfStable(run, version) { + if (run.latestStable && compareReleaseVersions(version, run.latestStable) <= 0) { + throw new Error( + `${version} is not ahead of the published ${run.latestStable}. Move main's -dev version past it.`, + ); + } +} + +/** Remote tags by name, resolved to the commit each one points at. */ +function remoteTags() { + const tags = new Map(); + const output = execFileSync('git', ['ls-remote', '--tags', 'origin', 'v*'], { encoding: 'utf8' }); + for (const line of output.split('\n').filter(Boolean)) { + const [commit, name] = line.split('\t'); + const tag = name.replace(/^refs\/tags\//, ''); + if (tag.endsWith('^{}')) tags.set(tag.slice(0, -3), commit); + else if (!tags.has(tag)) tags.set(tag, commit); + } + return tags; +} + +async function ciConclusion(env) { + const { workflow_runs: runs } = await github( + env, + `actions/workflows/ci.yml/runs?head_sha=${env.GITHUB_SHA}&event=push&per_page=1`, + ); + if (runs.length === 0) return 'missing'; + return runs[0].status === 'completed' ? runs[0].conclusion : runs[0].status; +} + +async function isOnMain(env) { + const { status } = await github(env, `compare/main...${env.GITHUB_SHA}`); + return status === 'identical' || status === 'behind'; +} + +async function latestStable() { + const response = await fetch('https://registry.npmjs.org/agent-device/latest'); + if (response.status === 404) return null; + if (!response.ok) throw new Error(`npm registry lookup failed: ${response.status}`); + return (await response.json()).version; +} + +async function github(env, pathname) { + const response = await fetch(`${env.GITHUB_API_URL}/repos/${env.GITHUB_REPOSITORY}/${pathname}`, { + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${env.GITHUB_TOKEN}`, + }, + }); + if (!response.ok) throw new Error(`GitHub API ${pathname} failed: ${response.status}`); + return response.json(); +} + +async function readRun(env) { + const event = env.GITHUB_EVENT_NAME; + const ref = env.GITHUB_REF; + const onBranch = event !== 'pull_request' && ref === 'refs/heads/main'; + const onTag = ref.startsWith('refs/tags/'); + return { + event, + ref, + sha: env.GITHUB_SHA, + channel: env.RELEASE_CHANNEL || 'nightly', + requestedVersion: env.RELEASE_VERSION, + rootVersion: JSON.parse(fs.readFileSync('package.json', 'utf8')).version, + today: new Date().toISOString().slice(0, 10).replaceAll('-', ''), + runNumber: env.GITHUB_RUN_NUMBER, + tags: event === 'pull_request' ? new Map() : remoteTags(), + latestStable: onBranch || onTag ? await latestStable() : null, + ciConclusion: onBranch ? await ciConclusion(env) : undefined, + onMain: onTag ? await isOnMain(env) : undefined, + }; +} + +const OUTPUTS = { + mode: 'mode', + version: 'version', + dist_tag: 'distTag', + commit: 'commit', + previous_tag: 'previousTag', +}; + +async function main(env) { + const result = planRelease(await readRun(env)); + const lines = Object.entries(OUTPUTS).map(([output, key]) => `${output}=${result[key] ?? ''}\n`); + fs.appendFileSync(env.GITHUB_OUTPUT, lines.join('')); + const summary = result.reason ?? `${result.mode} ${result.version}`; + fs.appendFileSync(env.GITHUB_STEP_SUMMARY, `Release plan: ${summary}\n`); + process.stdout.write(`${summary}\n`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) await main(process.env); diff --git a/scripts/release-publish.mjs b/scripts/release-publish.mjs new file mode 100644 index 0000000000..c82797ddbc --- /dev/null +++ b/scripts/release-publish.mjs @@ -0,0 +1,59 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Uploads the tarballs the release build packed and verified, through npm trusted publishing. +// It runs in the only job holding `id-token: write`, so it uses nothing but Node and the npm CLI. +const [directory, version, distTag] = process.argv.slice(2); +if (!directory || !version || !distTag) { + throw new Error('Usage: node scripts/release-publish.mjs '); +} + +const [major, minor, patch] = npm(['--version']).trim().split('.').map(Number); +if (major < 11 || (major === 11 && (minor < 5 || (minor === 5 && patch < 1)))) { + throw new Error('npm trusted publishing needs npm 11.5.1 or newer.'); +} + +const tarballs = fs + .readdirSync(directory) + .filter((file) => file.endsWith('.tgz')) + .map((file) => { + const tarball = path.join(directory, file); + const manifest = JSON.parse( + execFileSync('tar', ['-xOf', tarball, 'package/package.json'], { encoding: 'utf8' }), + ); + if (!/^(agent-device|@agent-device\/[a-z0-9-]+)$/.test(manifest.name)) { + throw new Error(`${file} packs ${manifest.name}, which this release does not own.`); + } + if (manifest.version !== version || manifest.private === true) { + throw new Error(`${file} packs ${manifest.name}@${manifest.version}, not ${version}.`); + } + return { tarball, name: manifest.name }; + }) + // Plugins first: the core going live is the signal that the whole release is available. + .sort((a, b) => Number(a.name === 'agent-device') - Number(b.name === 'agent-device')); +if (!tarballs.some((entry) => entry.name === 'agent-device')) { + throw new Error(`${directory} has no agent-device tarball.`); +} + +const publishArgs = ['--tag', distTag, '--access', 'public']; +for (const { tarball } of tarballs) npm(['publish', tarball, ...publishArgs, '--dry-run']); +for (const { tarball, name } of tarballs) { + if (isPublished(name)) { + process.stdout.write(`${name}@${version} is already published; skipping.\n`); + continue; + } + npm(['publish', tarball, ...publishArgs, '--provenance'], 'inherit'); +} + +function isPublished(name) { + try { + return npm(['view', `${name}@${version}`, 'version', '--json']).trim() !== ''; + } catch { + return false; + } +} + +function npm(args, stdio = 'pipe') { + return execFileSync('npm', args, { encoding: 'utf8', stdio: ['ignore', stdio, 'inherit'] }) ?? ''; +} diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs new file mode 100644 index 0000000000..0b5e290952 --- /dev/null +++ b/scripts/release-version.mjs @@ -0,0 +1,122 @@ +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +// Version strings across the release channels. `main` carries `X.Y.Z-dev`, naming the release +// it is building towards; a nightly publishes that base as `X.Y.Z-nightly..`; a +// stable release promotes a nightly's commit to `X.Y.Z`. Registry scanners diff the tool surface +// per version string, so neither `main` nor a nightly may ever carry a published stable version. +const RELEASE = /^(\d+)\.(\d+)\.(\d+)$/; +const DEVELOPMENT = /^(\d+)\.(\d+)\.(\d+)-dev$/; +const NIGHTLY = /^(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})\.(\d+)$/; + +export function isReleaseVersion(version) { + return RELEASE.test(version); +} + +export function compareReleaseVersions(left, right) { + const a = releaseParts(left); + const b = releaseParts(right); + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; +} + +export function nightlyVersion(developmentVersion, date, runNumber) { + const match = DEVELOPMENT.exec(developmentVersion); + if (!match) throw new Error(`main must carry an X.Y.Z-dev version, got ${developmentVersion}.`); + if (!/^\d{8}$/.test(date)) throw new Error(`Nightly date must be YYYYMMDD, got ${date}.`); + if (!/^[1-9]\d*$/.test(String(runNumber))) { + throw new Error(`Nightly run number must be a positive integer, got ${runNumber}.`); + } + return `${match.slice(1, 4).join('.')}-nightly.${date}.${runNumber}`; +} + +/** The `X.Y.Z` a nightly previews: the version its promotion publishes by default. */ +export function nightlyBase(version) { + const match = NIGHTLY.exec(version); + if (!match) throw new Error(`${version} is not a nightly version.`); + return match.slice(1, 4).join('.'); +} + +/** The newest `vX.Y.Z-nightly..` tag, ordered by base, then date, then run. */ +export function latestNightlyTag(tags) { + const nightlies = tags + .map((tag) => ({ tag, match: NIGHTLY.exec(tag.replace(/^v/, '')) })) + .filter((entry) => entry.tag.startsWith('v') && entry.match); + nightlies.sort((a, b) => { + const [left, right] = [a.match, b.match].map((match) => match.slice(1).map(Number)); + return right.reduce((order, part, index) => order || part - left[index], 0); + }); + return nightlies[0]?.tag ?? null; +} + +/** The newest `vX.Y.Z` tag older than `version`, which bounds its generated release notes. */ +export function previousReleaseTag(tags, version) { + return ( + tags + .filter((tag) => tag.startsWith('v') && RELEASE.test(tag.slice(1))) + .filter((tag) => compareReleaseVersions(tag.slice(1), version) < 0) + .sort((a, b) => compareReleaseVersions(b.slice(1), a.slice(1)))[0] ?? null + ); +} + +/** + * The `-dev` version `main` moves to once `released` is published. A `main` already building + * towards a later release keeps its version; otherwise it targets the next patch. + */ +export function nextDevelopmentVersion(mainVersion, released) { + const match = DEVELOPMENT.exec(mainVersion); + if (!match) throw new Error(`main must carry an X.Y.Z-dev version, got ${mainVersion}.`); + const base = match.slice(1, 4).join('.'); + if (compareReleaseVersions(base, released) > 0) return mainVersion; + const [major, minor, patch] = releaseParts(released); + return `${major}.${minor}.${patch + 1}-dev`; +} + +function releaseParts(version) { + const match = RELEASE.exec(version); + if (!match) throw new Error(`${version} is not an X.Y.Z release version.`); + return match.slice(1, 4).map(Number); +} + +/** Writes `version` to the root, every public workspace package, and the MCP metadata. */ +function stampVersion(root, version) { + if (![RELEASE, DEVELOPMENT, NIGHTLY].some((pattern) => pattern.test(version))) { + throw new Error(`Unsupported version format: ${version}`); + } + const packagePath = path.join(root, 'package.json'); + const raw = fs.readFileSync(packagePath, 'utf8'); + const versionField = `"version": "${JSON.parse(raw).version}"`; + if (raw.split(versionField).length !== 2) { + throw new Error(`Expected exactly one ${versionField} in package.json.`); + } + fs.writeFileSync(packagePath, raw.replace(versionField, `"version": "${version}"`)); + for (const script of ['release-workspace.mjs', 'sync-mcp-metadata.mjs']) { + const args = [path.join(root, 'scripts', script)]; + if (script === 'release-workspace.mjs') args.push('sync'); + execFileSync(process.execPath, args, { cwd: root, stdio: 'inherit' }); + } +} + +function readRootVersion(root) { + return JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version; +} + +function main([command, ...args]) { + const root = process.cwd(); + if (command === 'nightly' && args.length === 2) { + process.stdout.write(`${nightlyVersion(readRootVersion(root), args[0], args[1])}\n`); + } else if (command === 'stamp' && args.length === 1) { + stampVersion(root, args[0]); + } else if (command === 'advance' && args.length === 1) { + const next = nextDevelopmentVersion(readRootVersion(root), args[0]); + if (next !== readRootVersion(root)) stampVersion(root, next); + process.stdout.write(`${next}\n`); + } else { + throw new Error( + 'Usage: node scripts/release-version.mjs |stamp |advance >', + ); + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) main(process.argv.slice(2)); diff --git a/test/ci/release-workflow.test.ts b/test/ci/release-workflow.test.ts new file mode 100644 index 0000000000..ca4be4eb67 --- /dev/null +++ b/test/ci/release-workflow.test.ts @@ -0,0 +1,42 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { expect, test } from 'vitest'; +import { parse } from 'yaml'; + +const repoRoot = path.resolve(import.meta.dirname, '../..'); +const source = fs.readFileSync(path.join(repoRoot, '.github/workflows/release.yml'), 'utf8'); + +type Job = { + environment?: string; + permissions?: Record; + steps?: Array<{ uses?: string; with?: Record }>; + uses?: string; +}; +const workflow = parse(source) as { permissions: unknown; jobs: Record }; + +// npm trusts release.yml in the npm-publish environment, so whichever job can mint an id-token +// can publish. Only the publisher and the MCP Registry call (its own OIDC login) may. +test('only the npm publisher and the MCP Registry call can mint an id-token', () => { + expect(workflow.permissions).toEqual({}); + const minting = Object.entries(workflow.jobs) + .filter(([, job]) => job.permissions?.['id-token'] === 'write') + .map(([name]) => name); + expect(minting.sort()).toEqual(['mcp-registry', 'publish']); + expect(workflow.jobs['mcp-registry']?.uses).toBe('./.github/workflows/publish-mcp-registry.yml'); + expect(workflow.jobs.publish?.environment).toBe('npm-publish'); +}); + +test('the publisher authenticates through OIDC alone', () => { + expect(source).not.toMatch(/NPM_TOKEN|NODE_AUTH_TOKEN|secrets\./); + const setupNode = workflow.jobs.publish?.steps?.find((step) => + step.uses?.startsWith('actions/setup-node@'), + ); + expect(setupNode?.with).not.toHaveProperty('registry-url'); +}); + +test('the published bytes are built without a restored dependency cache', () => { + const setup = workflow.jobs.build?.steps?.find( + (step) => step.uses === './.github/actions/setup-node-pnpm', + ); + expect(setup?.with?.['cache-store']).toBe('false'); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 7abf9d88c2..e09a5aac6b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -171,10 +171,15 @@ export default defineConfig({ // Parse-only guard on the checked-in registry entry: the npm package must declare // the fixed mcp subcommand, or registry-format launchers run the bare CLI. 'scripts/__tests__/mcp-metadata.test.ts', + // Release channel versions: nightly naming, promotion lookup, and main's -dev advance. + 'scripts/__tests__/release-version.test.ts', + 'scripts/__tests__/release-plan.test.ts', 'scripts/ios-snapshot-benchmark/*.test.ts', // Parses CI configuration only, so this action guard needs no device or subprocess lane. 'test/ci/upload-agent-device-artifacts.test.ts', 'test/ci/upload-artifact-hidden-paths.test.ts', + // npm trusts release.yml, so its token boundary is asserted on the parsed workflow. + 'test/ci/release-workflow.test.ts', // The size reporter is preserved across a base checkout; its entrypoint and imported // modules must move as one directory or the Bundle Size lane fails before measuring. 'test/ci/size-workflow.test.ts', From 0aea9c444060c01331672ce085f18f909e8d351b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 10 Oct 2026 19:04:45 +0200 Subject: [PATCH 2/5] fix(release): fail closed on registry errors, retry promotion, pin the approval gate The publisher treats only npm's E404 as "not published" and rethrows any other registry failure. Promotion reuses a tag that already points at the promoted commit, so re-running a failed dispatch succeeds. The workflow test now fails if a stable publish stops waiting for the release environment approval. --- .github/workflows/release.yml | 9 ++++++++- scripts/release-publish.mjs | 13 ++++++++++++- test/ci/release-workflow.test.ts | 13 +++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3c574c4a39..c881427e9a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,7 +98,14 @@ jobs: COMMIT: ${{ needs.plan.outputs.commit }} run: | set -euo pipefail - gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$COMMIT" + # Idempotent, so re-running a failed dispatch reuses the tag it already created. + tagged="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq .object.sha 2>/dev/null || true)" + if [ -z "$tagged" ]; then + gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$COMMIT" + elif [ "$tagged" != "$COMMIT" ]; then + echo "$TAG already points at $tagged, not $COMMIT." >&2 + exit 1 + fi gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" echo "Started the $TAG release from $COMMIT." >> "$GITHUB_STEP_SUMMARY" diff --git a/scripts/release-publish.mjs b/scripts/release-publish.mjs index c82797ddbc..d591cef3bb 100644 --- a/scripts/release-publish.mjs +++ b/scripts/release-publish.mjs @@ -49,8 +49,19 @@ for (const { tarball, name } of tarballs) { function isPublished(name) { try { return npm(['view', `${name}@${version}`, 'version', '--json']).trim() !== ''; + } catch (error) { + // npm reports a missing package or version as E404 in its JSON output; anything else is a + // registry or network failure, which must not be read as "safe to publish". + if (readErrorCode(error.stdout) === 'E404') return false; + throw error; + } +} + +function readErrorCode(output) { + try { + return JSON.parse(output).error?.code; } catch { - return false; + return undefined; } } diff --git a/test/ci/release-workflow.test.ts b/test/ci/release-workflow.test.ts index ca4be4eb67..ba7360269c 100644 --- a/test/ci/release-workflow.test.ts +++ b/test/ci/release-workflow.test.ts @@ -8,6 +8,8 @@ const source = fs.readFileSync(path.join(repoRoot, '.github/workflows/release.ym type Job = { environment?: string; + if?: string; + needs?: string[]; permissions?: Record; steps?: Array<{ uses?: string; with?: Record }>; uses?: string; @@ -40,3 +42,14 @@ test('the published bytes are built without a restored dependency cache', () => ); expect(setup?.with?.['cache-store']).toBe('false'); }); + +// The npm-publish environment cannot require a reviewer (nightlies run unattended), so a stable +// publish is held only by the approve job and its `release` environment. +test('a stable publish waits for the release environment approval', () => { + expect(workflow.jobs.approve?.environment).toBe('release'); + expect(workflow.jobs.approve?.if).toBe("needs.plan.outputs.mode == 'stable'"); + expect(workflow.jobs.publish?.needs).toContain('approve'); + expect(workflow.jobs.publish?.if).toMatch( + /needs\.plan\.outputs\.mode == 'nightly' \|\| needs\.approve\.result == 'success'/, + ); +}); From ea0388c7170779c18aa6ab39334db90b9cf33a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sat, 10 Oct 2026 19:09:43 +0200 Subject: [PATCH 3/5] feat(release): release a tagged commit on main, after its CI passed A stable release starts from a vX.Y.Z tag on main that a repository admin creates, normally by publishing a GitHub release targeting main. The stable run now also requires green CI on that commit. This replaces the promote job and the GITHUB_TOKEN tag it created. Nightly tags move to nightly/v*, outside the admin-only v* tags and the npm-publish tag policy. --- .github/workflows/release.yml | 55 +++-------------------- scripts/__tests__/release-plan.test.ts | 25 +++-------- scripts/__tests__/release-version.test.ts | 13 +++--- scripts/release-plan.mjs | 30 +++++-------- scripts/release-version.mjs | 17 ++++--- 5 files changed, 42 insertions(+), 98 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c881427e9a..a79d0fa70a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,9 @@ name: Release # One workflow owns every npm publish, so each package trusts exactly this file # (npm trusted publishing pins the workflow filename and the npm-publish # environment). scripts/release-plan.mjs maps the triggering event to a mode: -# dry-run (PR), nightly (schedule or dispatch), promote (dispatch stable), or -# stable (a vX.Y.Z tag, created by promote or pushed by a maintainer). +# dry-run (PR), nightly (schedule or dispatch on main), or stable (a vX.Y.Z tag on +# main, which only a repository admin can create, normally by publishing a GitHub +# release). on: pull_request: paths: @@ -20,20 +21,8 @@ on: # Retried every few hours: a nightly waits for green CI on main's HEAD and # publishes at most once per UTC day. - cron: '23 */3 * * *' + # On main: publish a nightly now. On a vX.Y.Z tag: retry that stable release. workflow_dispatch: - inputs: - channel: - description: nightly publishes main now; stable promotes the latest nightly. - required: true - default: nightly - type: choice - options: - - nightly - - stable - version: - description: Stable version override (X.Y.Z). Defaults to the version the latest nightly previews. - required: false - type: string permissions: {} @@ -41,7 +30,6 @@ concurrency: group: >- ${{ github.event_name == 'pull_request' && format('release-pr-{0}', github.ref) || startsWith(github.ref, 'refs/tags/') && format('release-{0}', github.ref) - || inputs.channel == 'stable' && 'release-promote' || 'release-nightly' }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} @@ -74,41 +62,8 @@ jobs: name: Plan release env: GITHUB_TOKEN: ${{ github.token }} - RELEASE_CHANNEL: ${{ inputs.channel }} - RELEASE_VERSION: ${{ inputs.version }} run: node scripts/release-plan.mjs - promote: - name: Promote ${{ needs.plan.outputs.version }} - needs: plan - if: needs.plan.outputs.mode == 'promote' - runs-on: ubuntu-latest - timeout-minutes: 5 - permissions: - actions: write - contents: write - steps: - # A run started by GITHUB_TOKEN's tag push would not trigger; dispatching - # on the tag gives the stable run a github.sha (and npm provenance) of - # exactly the promoted commit. - - name: Tag the promoted nightly and start its stable release - env: - GH_TOKEN: ${{ github.token }} - TAG: v${{ needs.plan.outputs.version }} - COMMIT: ${{ needs.plan.outputs.commit }} - run: | - set -euo pipefail - # Idempotent, so re-running a failed dispatch reuses the tag it already created. - tagged="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq .object.sha 2>/dev/null || true)" - if [ -z "$tagged" ]; then - gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$COMMIT" - elif [ "$tagged" != "$COMMIT" ]; then - echo "$TAG already points at $tagged, not $COMMIT." >&2 - exit 1 - fi - gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG" - echo "Started the $TAG release from $COMMIT." >> "$GITHUB_STEP_SUMMARY" - approve: name: Approve ${{ needs.plan.outputs.version }} needs: plan @@ -217,7 +172,7 @@ jobs: - name: Tag the published commit env: GH_TOKEN: ${{ github.token }} - TAG: v${{ needs.plan.outputs.version }} + TAG: nightly/v${{ needs.plan.outputs.version }} COMMIT: ${{ needs.plan.outputs.commit }} run: | gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$COMMIT" diff --git a/scripts/__tests__/release-plan.test.ts b/scripts/__tests__/release-plan.test.ts index 24101f5c71..4b1e6fa213 100644 --- a/scripts/__tests__/release-plan.test.ts +++ b/scripts/__tests__/release-plan.test.ts @@ -10,14 +10,12 @@ function run(overrides: Record = {}) { event: 'schedule', ref: 'refs/heads/main', sha: MAIN, - channel: 'nightly', - requestedVersion: '', rootVersion: '0.21.25-dev', today: '20261011', runNumber: '42', tags: new Map([ ['v0.21.24', 'c'.repeat(40)], - ['v0.21.25-nightly.20261010.41', NIGHTLY_COMMIT], + ['nightly/v0.21.25-nightly.20261010.41', NIGHTLY_COMMIT], ]), latestStable: '0.21.24', ciConclusion: 'success', @@ -58,22 +56,7 @@ test('a nightly refuses a main whose -dev version trails the published release', assert.throws(() => planRelease(run({ latestStable: '0.21.25' })), /not ahead of the published/); }); -test('promotion tags the latest nightly commit with the version it previewed', () => { - const promoted = run({ event: 'workflow_dispatch', channel: 'stable' }); - assert.deepEqual(planRelease(promoted), { - mode: 'promote', - version: '0.21.25', - commit: NIGHTLY_COMMIT, - nightly: 'v0.21.25-nightly.20261010.41', - }); - assert.equal(planRelease({ ...promoted, requestedVersion: '0.22.0' }).version, '0.22.0'); - assert.throws(() => planRelease({ ...promoted, requestedVersion: '0.21.23' }), /not ahead/); - assert.throws(() => planRelease({ ...promoted, requestedVersion: '0.22' }), /X\.Y\.Z/); - assert.throws(() => planRelease({ ...promoted, requestedVersion: '0.21.24' }), /already tagged/); - assert.throws(() => planRelease({ ...promoted, tags: new Map() }), /No nightly/); -}); - -test('a stable tag publishes its own commit when it is on main', () => { +test('a stable tag publishes its own commit once it is on main and its CI passed', () => { const stable = run({ event: 'workflow_dispatch', ref: 'refs/tags/v0.21.25', onMain: true }); assert.deepEqual(planRelease(stable), { mode: 'stable', @@ -85,6 +68,10 @@ test('a stable tag publishes its own commit when it is on main', () => { assert.equal(planRelease({ ...stable, latestStable: '0.21.25' }).mode, 'stable'); assert.throws(() => planRelease({ ...stable, latestStable: '0.21.26' }), /older than/); assert.throws(() => planRelease({ ...stable, onMain: false }), /not on main/); + assert.throws( + () => planRelease({ ...stable, ciConclusion: 'in_progress' }), + /run Release on v0\.21\.25/, + ); assert.throws(() => planRelease({ ...stable, ref: 'refs/tags/v0.21.25-rc.1' }), /vX\.Y\.Z tag/); }); diff --git a/scripts/__tests__/release-version.test.ts b/scripts/__tests__/release-version.test.ts index d1bbe216f8..c3f4ede373 100644 --- a/scripts/__tests__/release-version.test.ts +++ b/scripts/__tests__/release-version.test.ts @@ -24,13 +24,14 @@ test('a nightly is refused unless main carries a -dev version', () => { test('the latest nightly orders by base, then date, then run number', () => { const tags = [ 'v0.21.24', - 'v0.21.25-nightly.20261009.9', - 'v0.21.25-nightly.20261010.10', - 'v0.21.25-nightly.20261010.12', - 'v0.21.24-nightly.20261011.13', - 'evidence/v0.21.26-nightly.20261012.14', + 'nightly/v0.21.25-nightly.20261009.9', + 'nightly/v0.21.25-nightly.20261010.10', + 'nightly/v0.21.25-nightly.20261010.12', + 'nightly/v0.21.24-nightly.20261011.13', + 'v0.21.26-nightly.20261012.14', + 'evidence/v0.21.26-nightly.20261012.15', ]; - assert.equal(latestNightlyTag(tags), 'v0.21.25-nightly.20261010.12'); + assert.equal(latestNightlyTag(tags), 'nightly/v0.21.25-nightly.20261010.12'); assert.equal(latestNightlyTag(['v0.21.24']), null); }); diff --git a/scripts/release-plan.mjs b/scripts/release-plan.mjs index 6010846c56..1dff6eb355 100644 --- a/scripts/release-plan.mjs +++ b/scripts/release-plan.mjs @@ -13,8 +13,7 @@ import { /** * Decides what one `.github/workflows/release.yml` run does, from the event that started it: * pull_request dry-run: build and verify every package, publish nothing - * schedule / nightly nightly: publish main's HEAD under the `nightly` dist-tag - * dispatch stable on main promote: tag the latest nightly's commit and start its stable run + * schedule / dispatch nightly: publish main's HEAD under the `nightly` dist-tag * vX.Y.Z tag stable: publish that commit under the `latest` dist-tag * `tags` maps remote tag names to commits; `ciConclusion` and `onMain` describe `sha`. */ @@ -26,7 +25,7 @@ export function planRelease(run) { if (run.ref !== 'refs/heads/main') { throw new Error(`Releases run from main or a vX.Y.Z tag, not ${run.ref}.`); } - return run.channel === 'stable' ? planPromotion(run) : planNightly(run); + return planNightly(run); } function planNightly(run) { @@ -48,16 +47,6 @@ function planNightly(run) { return { mode: 'nightly', version, distTag: 'nightly', commit: run.sha }; } -function planPromotion(run) { - const nightly = latestNightlyTag([...run.tags.keys()]); - if (!nightly) throw new Error('No nightly has been published to promote.'); - const version = run.requestedVersion || nightlyBase(nightly.slice(1)); - if (!isReleaseVersion(version)) throw new Error(`${version} is not an X.Y.Z release version.`); - if (run.tags.has(`v${version}`)) throw new Error(`v${version} is already tagged.`); - assertAheadOfStable(run, version); - return { mode: 'promote', version, commit: run.tags.get(nightly), nightly }; -} - function planStable(run, tag) { const version = tag.slice(1); if (!tag.startsWith('v') || !isReleaseVersion(version)) { @@ -68,6 +57,11 @@ function planStable(run, tag) { throw new Error(`${version} is older than the published ${run.latestStable}.`); } if (!run.onMain) throw new Error(`${tag} is not on main.`); + if (run.ciConclusion !== 'success') { + throw new Error( + `CI on ${run.sha} is ${run.ciConclusion}. Once it passes, run Release on ${tag} to publish it.`, + ); + } const previousTag = previousReleaseTag([...run.tags.keys()], version) ?? ''; return { mode: 'stable', version, distTag: 'latest', commit: run.sha, previousTag }; } @@ -84,10 +78,12 @@ function assertAheadOfStable(run, version) { } } -/** Remote tags by name, resolved to the commit each one points at. */ +/** Remote release and nightly tags by name, resolved to the commit each one points at. */ function remoteTags() { const tags = new Map(); - const output = execFileSync('git', ['ls-remote', '--tags', 'origin', 'v*'], { encoding: 'utf8' }); + const output = execFileSync('git', ['ls-remote', '--tags', 'origin', 'v*', 'nightly/*'], { + encoding: 'utf8', + }); for (const line of output.split('\n').filter(Boolean)) { const [commit, name] = line.split('\t'); const tag = name.replace(/^refs\/tags\//, ''); @@ -138,14 +134,12 @@ async function readRun(env) { event, ref, sha: env.GITHUB_SHA, - channel: env.RELEASE_CHANNEL || 'nightly', - requestedVersion: env.RELEASE_VERSION, rootVersion: JSON.parse(fs.readFileSync('package.json', 'utf8')).version, today: new Date().toISOString().slice(0, 10).replaceAll('-', ''), runNumber: env.GITHUB_RUN_NUMBER, tags: event === 'pull_request' ? new Map() : remoteTags(), latestStable: onBranch || onTag ? await latestStable() : null, - ciConclusion: onBranch ? await ciConclusion(env) : undefined, + ciConclusion: onBranch || onTag ? await ciConclusion(env) : undefined, onMain: onTag ? await isOnMain(env) : undefined, }; } diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs index 0b5e290952..938c3eb386 100644 --- a/scripts/release-version.mjs +++ b/scripts/release-version.mjs @@ -5,7 +5,7 @@ import { pathToFileURL } from 'node:url'; // Version strings across the release channels. `main` carries `X.Y.Z-dev`, naming the release // it is building towards; a nightly publishes that base as `X.Y.Z-nightly..`; a -// stable release promotes a nightly's commit to `X.Y.Z`. Registry scanners diff the tool surface +// stable release publishes a tagged commit on `main` as `X.Y.Z`. Registry scanners diff the tool surface // per version string, so neither `main` nor a nightly may ever carry a published stable version. const RELEASE = /^(\d+)\.(\d+)\.(\d+)$/; const DEVELOPMENT = /^(\d+)\.(\d+)\.(\d+)-dev$/; @@ -31,18 +31,25 @@ export function nightlyVersion(developmentVersion, date, runNumber) { return `${match.slice(1, 4).join('.')}-nightly.${date}.${runNumber}`; } -/** The `X.Y.Z` a nightly previews: the version its promotion publishes by default. */ +/** The `X.Y.Z` release a nightly previews. */ export function nightlyBase(version) { const match = NIGHTLY.exec(version); if (!match) throw new Error(`${version} is not a nightly version.`); return match.slice(1, 4).join('.'); } -/** The newest `vX.Y.Z-nightly..` tag, ordered by base, then date, then run. */ +/** + * Nightly tags live outside `v*`, which only repository admins may create: the release workflow + * tags each nightly itself, and no nightly tag ref may deploy to the npm-publish environment. + */ +const NIGHTLY_TAG_PREFIX = 'nightly/v'; + +/** The newest `nightly/vX.Y.Z-nightly..` tag, ordered by base, then date, then run. */ export function latestNightlyTag(tags) { const nightlies = tags - .map((tag) => ({ tag, match: NIGHTLY.exec(tag.replace(/^v/, '')) })) - .filter((entry) => entry.tag.startsWith('v') && entry.match); + .filter((tag) => tag.startsWith(NIGHTLY_TAG_PREFIX)) + .map((tag) => ({ tag, match: NIGHTLY.exec(tag.slice(NIGHTLY_TAG_PREFIX.length)) })) + .filter((entry) => entry.match); nightlies.sort((a, b) => { const [left, right] = [a.match, b.match].map((match) => match.slice(1).map(Number)); return right.reduce((order, part, index) => order || part - left[index], 0); From 8f37edb9798aee150c3be5cde414009fd2111fb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 11 Oct 2026 08:45:07 +0200 Subject: [PATCH 4/5] fix(release): count the -dev bump under its parent's CI and harden the plan The workflow's -dev bump is pushed with GITHUB_TOKEN, so it never gets a CI run. A main commit with no CI run now stands on its parent's CI when it only rewrites version fields, so nightlies and stable releases keep working after a release. A stable tag waits up to 20 minutes for its commit's CI, the npm nightly dist-tag also blocks a second nightly that UTC day, version patterns reject leading zeros, and the approval test pins the publish condition exactly. --- .github/workflows/release.yml | 3 +- scripts/__tests__/release-plan.test.ts | 39 +++++++++++++- scripts/__tests__/release-version.test.ts | 2 + scripts/release-plan.mjs | 63 +++++++++++++++++++---- scripts/release-version.mjs | 7 +-- test/ci/release-workflow.test.ts | 4 +- 6 files changed, 100 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a79d0fa70a..f17159e217 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,7 +37,8 @@ jobs: plan: name: Plan runs-on: ubuntu-latest - timeout-minutes: 5 + # A stable tag waits up to 20 minutes for its commit's CI to finish. + timeout-minutes: 25 permissions: actions: read contents: read diff --git a/scripts/__tests__/release-plan.test.ts b/scripts/__tests__/release-plan.test.ts index 4b1e6fa213..7885be15f1 100644 --- a/scripts/__tests__/release-plan.test.ts +++ b/scripts/__tests__/release-plan.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; -import { planRelease } from '../release-plan.mjs'; +import { isVersionOnlyCommit, planRelease } from '../release-plan.mjs'; const MAIN = 'a'.repeat(40); const NIGHTLY_COMMIT = 'b'.repeat(40); @@ -44,6 +44,8 @@ test('a scheduled nightly waits for new commits, a new day, and green CI', () => assert.match(planRelease(run({ sha: NIGHTLY_COMMIT })).reason, /already ships/); assert.match(planRelease(run({ today: '20261010' })).reason, /shipped today/); assert.match(planRelease(run({ ciConclusion: 'in_progress' })).reason, /is in_progress/); + const published = run({ publishedNightly: '0.21.25-nightly.20261011.40' }); + assert.match(planRelease(published).reason, /20261011\.40 shipped today/); }); test('a dispatched nightly skips the cadence checks but still requires green CI', () => { @@ -78,3 +80,38 @@ test('a stable tag publishes its own commit once it is on main and its CI passed test('releases refuse to run from any other branch', () => { assert.throws(() => planRelease(run({ ref: 'refs/heads/feature' })), /main or a vX\.Y\.Z tag/); }); + +test("the workflow's -dev bump stands on its parent's CI, and nothing else does", () => { + const bump = { + parents: [{ sha: MAIN }], + files: [ + { + filename: 'package.json', + patch: '@@ -2 +2 @@\n- "version": "0.21.25",\n+ "version": "0.21.26-dev",', + }, + { + filename: 'packages/proxy/package.json', + patch: '- "version": "0.21.25",\n+ "version": "0.21.26-dev",', + }, + { + filename: 'server.json', + patch: '- "version": "0.21.25"\n+ "version": "0.21.26-dev"', + }, + ], + }; + assert.equal(isVersionOnlyCommit(bump), true); + const dependency = { + ...bump, + files: [ + { filename: 'package.json', patch: '- "undici": "7.29.0",\n+ "undici": "8.0.0",' }, + ], + }; + assert.equal(isVersionOnlyCommit(dependency), false); + const source = { ...bump, files: [...bump.files, { filename: 'src/index.ts', patch: '+x' }] }; + assert.equal(isVersionOnlyCommit(source), false); + assert.equal( + isVersionOnlyCommit({ ...bump, parents: [{ sha: MAIN }, { sha: NIGHTLY_COMMIT }] }), + false, + ); + assert.equal(isVersionOnlyCommit({ ...bump, files: [] }), false); +}); diff --git a/scripts/__tests__/release-version.test.ts b/scripts/__tests__/release-version.test.ts index c3f4ede373..dbc909b450 100644 --- a/scripts/__tests__/release-version.test.ts +++ b/scripts/__tests__/release-version.test.ts @@ -19,6 +19,7 @@ test('a nightly is refused unless main carries a -dev version', () => { assert.throws(() => nightlyVersion('0.21.25', '20261010', 1), /X\.Y\.Z-dev/); assert.throws(() => nightlyVersion('0.21.25-dev', '2026-10-10', 1), /YYYYMMDD/); assert.throws(() => nightlyVersion('0.21.25-dev', '20261010', 0), /positive integer/); + assert.throws(() => nightlyVersion('0.21.025-dev', '20261010', 1), /X\.Y\.Z-dev/); }); test('the latest nightly orders by base, then date, then run number', () => { @@ -54,4 +55,5 @@ test('release versions compare numerically per segment', () => { assert.ok(compareReleaseVersions('1.0.0', '0.99.99') > 0); assert.equal(compareReleaseVersions('0.21.25', '0.21.25'), 0); assert.throws(() => compareReleaseVersions('0.21.25-dev', '0.21.25'), /release version/); + assert.throws(() => compareReleaseVersions('01.2.3', '0.21.25'), /release version/); }); diff --git a/scripts/release-plan.mjs b/scripts/release-plan.mjs index 1dff6eb355..d87bb89088 100644 --- a/scripts/release-plan.mjs +++ b/scripts/release-plan.mjs @@ -34,9 +34,11 @@ function planNightly(run) { if (scheduled && latest && run.tags.get(latest) === run.sha) { return skip(`${latest} already ships ${run.sha}.`); } - if (scheduled && latest?.includes(`-nightly.${run.today}.`)) { - return skip(`${latest} shipped today.`); - } + // The npm dist-tag is the durable record: a nightly can publish and then fail to tag. + const shippedToday = [latest, run.publishedNightly].find((shipped) => + shipped?.includes(`-nightly.${run.today}.`), + ); + if (scheduled && shippedToday) return skip(`${shippedToday} shipped today.`); if (run.ciConclusion !== 'success') { const reason = `CI on ${run.sha} is ${run.ciConclusion}; a nightly ships only a commit whose CI passed.`; if (scheduled) return skip(reason); @@ -66,6 +68,28 @@ function planStable(run, tag) { return { mode: 'stable', version, distTag: 'latest', commit: run.sha, previousTag }; } +const VERSION_FILE = /^(packages\/[^/]+\/)?package\.json$|^server\.json$/; + +/** + * Whether a single-parent commit only rewrites `"version"` fields, as the release workflow's + * `-dev` bump does. Its push runs no workflows, so the parent's CI stands for it. + */ +export function isVersionOnlyCommit(commit) { + return ( + commit.parents.length === 1 && + commit.files.length > 0 && + commit.files.every( + (file) => + VERSION_FILE.test(file.filename) && + typeof file.patch === 'string' && + file.patch + .split('\n') + .filter((line) => /^[+-]/.test(line)) + .every((line) => /^[+-]\s*"version": "[^"]*",?$/.test(line)), + ) + ); +} + function skip(reason) { return { mode: 'skip', reason }; } @@ -93,13 +117,25 @@ function remoteTags() { return tags; } -async function ciConclusion(env) { +async function ciConclusion(env, sha) { const { workflow_runs: runs } = await github( env, - `actions/workflows/ci.yml/runs?head_sha=${env.GITHUB_SHA}&event=push&per_page=1`, + `actions/workflows/ci.yml/runs?head_sha=${sha}&event=push&per_page=1`, ); - if (runs.length === 0) return 'missing'; - return runs[0].status === 'completed' ? runs[0].conclusion : runs[0].status; + if (runs.length > 0) return runs[0].status === 'completed' ? runs[0].conclusion : runs[0].status; + const commit = await github(env, `commits/${sha}`); + return isVersionOnlyCommit(commit) ? ciConclusion(env, commit.parents[0].sha) : 'missing'; +} + +/** A tag pushed right after a merge waits, within the Plan job's budget, for that merge's CI. */ +async function settledCiConclusion(env, sha) { + for (let attempt = 0; ; attempt += 1) { + const conclusion = await ciConclusion(env, sha); + if (!['queued', 'in_progress', 'waiting', 'pending'].includes(conclusion) || attempt >= 40) { + return conclusion; + } + await new Promise((resolve) => setTimeout(resolve, 30_000)); + } } async function isOnMain(env) { @@ -107,8 +143,8 @@ async function isOnMain(env) { return status === 'identical' || status === 'behind'; } -async function latestStable() { - const response = await fetch('https://registry.npmjs.org/agent-device/latest'); +async function publishedVersion(distTag) { + const response = await fetch(`https://registry.npmjs.org/agent-device/${distTag}`); if (response.status === 404) return null; if (!response.ok) throw new Error(`npm registry lookup failed: ${response.status}`); return (await response.json()).version; @@ -138,8 +174,13 @@ async function readRun(env) { today: new Date().toISOString().slice(0, 10).replaceAll('-', ''), runNumber: env.GITHUB_RUN_NUMBER, tags: event === 'pull_request' ? new Map() : remoteTags(), - latestStable: onBranch || onTag ? await latestStable() : null, - ciConclusion: onBranch || onTag ? await ciConclusion(env) : undefined, + latestStable: onBranch || onTag ? await publishedVersion('latest') : null, + publishedNightly: onBranch ? await publishedVersion('nightly') : null, + ciConclusion: onTag + ? await settledCiConclusion(env, env.GITHUB_SHA) + : onBranch + ? await ciConclusion(env, env.GITHUB_SHA) + : undefined, onMain: onTag ? await isOnMain(env) : undefined, }; } diff --git a/scripts/release-version.mjs b/scripts/release-version.mjs index 938c3eb386..3e8e5160e8 100644 --- a/scripts/release-version.mjs +++ b/scripts/release-version.mjs @@ -7,9 +7,10 @@ import { pathToFileURL } from 'node:url'; // it is building towards; a nightly publishes that base as `X.Y.Z-nightly..`; a // stable release publishes a tagged commit on `main` as `X.Y.Z`. Registry scanners diff the tool surface // per version string, so neither `main` nor a nightly may ever carry a published stable version. -const RELEASE = /^(\d+)\.(\d+)\.(\d+)$/; -const DEVELOPMENT = /^(\d+)\.(\d+)\.(\d+)-dev$/; -const NIGHTLY = /^(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})\.(\d+)$/; +const N = String.raw`(0|[1-9]\d*)`; +const RELEASE = new RegExp(`^${N}\\.${N}\\.${N}$`); +const DEVELOPMENT = new RegExp(`^${N}\\.${N}\\.${N}-dev$`); +const NIGHTLY = new RegExp(`^${N}\\.${N}\\.${N}-nightly\\.(\\d{8})\\.([1-9]\\d*)$`); export function isReleaseVersion(version) { return RELEASE.test(version); diff --git a/test/ci/release-workflow.test.ts b/test/ci/release-workflow.test.ts index ba7360269c..02812edf98 100644 --- a/test/ci/release-workflow.test.ts +++ b/test/ci/release-workflow.test.ts @@ -49,7 +49,7 @@ test('a stable publish waits for the release environment approval', () => { expect(workflow.jobs.approve?.environment).toBe('release'); expect(workflow.jobs.approve?.if).toBe("needs.plan.outputs.mode == 'stable'"); expect(workflow.jobs.publish?.needs).toContain('approve'); - expect(workflow.jobs.publish?.if).toMatch( - /needs\.plan\.outputs\.mode == 'nightly' \|\| needs\.approve\.result == 'success'/, + expect(workflow.jobs.publish?.if?.replaceAll(/\s+/g, ' ').trim()).toBe( + "!cancelled() && needs.build.result == 'success' && (needs.plan.outputs.mode == 'nightly' || needs.approve.result == 'success')", ); }); From db9484fff629ec608cb646cd680ef7dc866f048e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 11 Oct 2026 08:57:40 +0200 Subject: [PATCH 5/5] fix(release): keep waiting while a stable tag's CI run is requested or not created yet --- scripts/release-plan.mjs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/release-plan.mjs b/scripts/release-plan.mjs index d87bb89088..94381661ef 100644 --- a/scripts/release-plan.mjs +++ b/scripts/release-plan.mjs @@ -127,11 +127,21 @@ async function ciConclusion(env, sha) { return isVersionOnlyCommit(commit) ? ciConclusion(env, commit.parents[0].sha) : 'missing'; } +// `missing` included: a tag pushed right after a merge can arrive before the merge's CI run exists. +const UNSETTLED_CI = new Set([ + 'missing', + 'requested', + 'queued', + 'waiting', + 'pending', + 'in_progress', +]); + /** A tag pushed right after a merge waits, within the Plan job's budget, for that merge's CI. */ async function settledCiConclusion(env, sha) { for (let attempt = 0; ; attempt += 1) { const conclusion = await ciConclusion(env, sha); - if (!['queued', 'in_progress', 'waiting', 'pending'].includes(conclusion) || attempt >= 40) { + if (!UNSETTLED_CI.has(conclusion) || attempt >= 40) { return conclusion; } await new Promise((resolve) => setTimeout(resolve, 30_000));