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..f17159e217 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,290 @@ +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 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: + - '.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 * * *' + # On main: publish a nightly now. On a vX.Y.Z tag: retry that stable release. + workflow_dispatch: + +permissions: {} + +concurrency: + group: >- + ${{ github.event_name == 'pull_request' && format('release-pr-{0}', github.ref) + || startsWith(github.ref, 'refs/tags/') && format('release-{0}', github.ref) + || 'release-nightly' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + plan: + name: Plan + runs-on: ubuntu-latest + # A stable tag waits up to 20 minutes for its commit's CI to finish. + timeout-minutes: 25 + 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 }} + run: node scripts/release-plan.mjs + + 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: 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" + 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..7885be15f1 --- /dev/null +++ b/scripts/__tests__/release-plan.test.ts @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { isVersionOnlyCommit, 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, + rootVersion: '0.21.25-dev', + today: '20261011', + runNumber: '42', + tags: new Map([ + ['v0.21.24', 'c'.repeat(40)], + ['nightly/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/); + 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', () => { + 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('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', + 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, 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/); +}); + +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 new file mode 100644 index 0000000000..dbc909b450 --- /dev/null +++ b/scripts/__tests__/release-version.test.ts @@ -0,0 +1,59 @@ +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/); + 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', () => { + const tags = [ + 'v0.21.24', + '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), 'nightly/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/); + assert.throws(() => compareReleaseVersions('01.2.3', '0.21.25'), /release version/); +}); diff --git a/scripts/release-plan.mjs b/scripts/release-plan.mjs new file mode 100644 index 0000000000..94381661ef --- /dev/null +++ b/scripts/release-plan.mjs @@ -0,0 +1,215 @@ +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 / 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`. + */ +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 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}.`); + } + // 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); + 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 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.`); + 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 }; +} + +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 }; +} + +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 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*', 'nightly/*'], { + 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, sha) { + const { workflow_runs: runs } = await github( + env, + `actions/workflows/ci.yml/runs?head_sha=${sha}&event=push&per_page=1`, + ); + 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'; +} + +// `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 (!UNSETTLED_CI.has(conclusion) || attempt >= 40) { + return conclusion; + } + await new Promise((resolve) => setTimeout(resolve, 30_000)); + } +} + +async function isOnMain(env) { + const { status } = await github(env, `compare/main...${env.GITHUB_SHA}`); + return status === 'identical' || status === 'behind'; +} + +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; +} + +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, + 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 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, + }; +} + +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..d591cef3bb --- /dev/null +++ b/scripts/release-publish.mjs @@ -0,0 +1,70 @@ +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 (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 undefined; + } +} + +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..3e8e5160e8 --- /dev/null +++ b/scripts/release-version.mjs @@ -0,0 +1,130 @@ +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 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 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); +} + +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` 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('.'); +} + +/** + * 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 + .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); + }); + 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..02812edf98 --- /dev/null +++ b/test/ci/release-workflow.test.ts @@ -0,0 +1,55 @@ +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; + if?: string; + needs?: 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'); +}); + +// 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?.replaceAll(/\s+/g, ' ').trim()).toBe( + "!cancelled() && needs.build.result == 'success' && (needs.plan.outputs.mode == 'nightly' || needs.approve.result == 'success')", + ); +}); 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',