From cd30bdfc7cdfd2cdf4fe476be443c37acacb685c Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Tue, 18 Aug 2026 14:45:40 +0800 Subject: [PATCH 1/3] ci(release): add protected npm staged publishing Split CLI publication into an OIDC stage operation and a separately verified finalization. Bind release artifacts to the exact workflow attempt, then require registry byte, tag, signature, and provenance checks before creating the GitHub release. Generated-by: OpenAI Codex --- .github/workflows/cli-package-validation.yml | 14 +- .github/workflows/release-cli-finalize.yml | 167 +++++++ .github/workflows/release-cli-stage.yml | 135 ++++++ CONTRIBUTING.md | 2 +- CONTRIBUTING.zh-CN.md | 2 +- package.json | 4 +- scripts/release-cli-publication.mjs | 452 +++++++++++++++++++ scripts/release-cli-publication.test.mjs | 306 +++++++++++++ 8 files changed, 1072 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/release-cli-finalize.yml create mode 100644 .github/workflows/release-cli-stage.yml create mode 100644 scripts/release-cli-publication.mjs create mode 100644 scripts/release-cli-publication.test.mjs diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index 60904b03e8..3dcaf6091a 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -5,6 +5,7 @@ on: branches: [main] paths: - '.github/workflows/cli-package-validation.yml' + - '.github/workflows/release-cli-*.yml' - '.gitattributes' - '.npmrc' - 'LICENSE' @@ -32,6 +33,7 @@ on: branches: [main] paths: - '.github/workflows/cli-package-validation.yml' + - '.github/workflows/release-cli-*.yml' - '.gitattributes' - '.npmrc' - 'LICENSE' @@ -79,13 +81,13 @@ jobs: node-version: '22.19.0' cache: npm - name: Select the release npm toolchain - run: npm install --global --no-audit --no-fund npm@11.12.1 + run: npm install --global --no-audit --no-fund npm@11.19.0 - name: Build the release tarball once run: npm run release:cli:pack - name: Upload the immutable release candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: cli-release-candidate + name: cli-release-candidate-${{ github.run_attempt }} path: | packages/cli/release/*.tgz packages/cli/release/*.tgz.sha256 @@ -130,7 +132,7 @@ jobs: with: node-version: ${{ matrix.node }} - name: Select the release npm toolchain - run: npm install --global --no-audit --no-fund npm@11.12.1 + run: npm install --global --no-audit --no-fund npm@11.19.0 - name: Assert the runner architecture env: EXPECTED_PLATFORM: ${{ matrix.platform }} @@ -140,7 +142,7 @@ jobs: - name: Download the release candidate uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: cli-release-candidate + name: cli-release-candidate-${{ github.run_attempt }} path: packages/cli/release - name: Validate the installed tarball run: node scripts/smoke-release-cli-package.mjs @@ -161,7 +163,7 @@ jobs: with: python-version: '3.12' - name: Select the release npm toolchain - run: npm install --global --no-audit --no-fund npm@11.12.1 + run: npm install --global --no-audit --no-fund npm@11.19.0 - name: Install pinned Eval frameworks run: | python -m venv "$RUNNER_TEMP/maka-harbor" @@ -173,7 +175,7 @@ jobs: - name: Download the release candidate uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: cli-release-candidate + name: cli-release-candidate-${{ github.run_attempt }} path: packages/cli/release - name: Validate real Harbor and Pier cells run: npm run release:cli:eval diff --git a/.github/workflows/release-cli-finalize.yml b/.github/workflows/release-cli-finalize.yml new file mode 100644 index 0000000000..9b5cac68b0 --- /dev/null +++ b/.github/workflows/release-cli-finalize.yml @@ -0,0 +1,167 @@ +name: Finalize CLI npm release + +on: + workflow_dispatch: + inputs: + stage_run_id: + description: Successful Stage CLI npm release workflow run ID + required: true + type: string + version: + description: Exact staged maka-agent version + required: true + type: string + +permissions: + actions: read + contents: read + +concurrency: + group: cli-npm-finalize + cancel-in-progress: false + +jobs: + authorize: + name: Require main + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Reject non-main dispatches + env: + RELEASE_REF: ${{ github.ref }} + run: | + if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then + echo "CLI releases must be dispatched from main; found $RELEASE_REF" >&2 + exit 1 + fi + + inspect: + name: Verify the public npm release + needs: authorize + runs-on: ubuntu-24.04 + timeout-minutes: 20 + outputs: + git_tag: ${{ steps.release.outputs.git_tag }} + source_sha: ${{ steps.release.outputs.source_sha }} + tarball: ${{ steps.registry.outputs.tarball }} + version: ${{ steps.release.outputs.version }} + steps: + - name: Load the exact stage workflow run + id: stage-run + env: + GH_TOKEN: ${{ github.token }} + STAGE_RUN_ID: ${{ inputs.stage_run_id }} + run: | + if [[ ! "$STAGE_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then + echo "Stage workflow run ID must be a positive integer" >&2 + exit 1 + fi + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$STAGE_RUN_ID" > "$RUNNER_TEMP/stage-run.json" + node -e ' + const fs = require("node:fs"); + const run = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if (!/^[0-9a-f]{40}$/.test(run.head_sha)) throw new Error("Stage run has no valid source SHA"); + fs.appendFileSync(process.env.GITHUB_OUTPUT, "source_sha=" + run.head_sha + "\n"); + if (!Number.isSafeInteger(run.run_attempt) || run.run_attempt < 1) throw new Error("Stage run has no valid attempt"); + fs.appendFileSync(process.env.GITHUB_OUTPUT, "run_attempt=" + run.run_attempt + "\n"); + ' "$RUNNER_TEMP/stage-run.json" + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.stage-run.outputs.source_sha }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + package-manager-cache: false + - name: Select the release npm toolchain + run: npm install --global --no-audit --no-fund npm@11.19.0 + - name: Download the exact staged candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-staged-release-${{ steps.stage-run.outputs.run_attempt }} + path: packages/cli/release + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ inputs.stage_run_id }} + - name: Verify the stage run and release record + id: release + env: + EXPECTED_VERSION: ${{ inputs.version }} + run: | + node scripts/release-cli-publication.mjs validate-stage-run \ + packages/cli/release \ + "$RUNNER_TEMP/stage-run.json" \ + "$EXPECTED_VERSION" \ + "$GITHUB_OUTPUT" + - name: Fetch and verify the public registry bytes + id: registry + run: | + node scripts/release-cli-publication.mjs fetch-registry \ + packages/cli/release \ + "$RUNNER_TEMP/registry-release" \ + "$GITHUB_OUTPUT" + - name: Validate the registry tarball as installed CLI + env: + RELEASE_TARBALL: ${{ steps.registry.outputs.tarball }} + run: node scripts/smoke-release-cli-package.mjs "$RELEASE_TARBALL" + - name: Verify npm signatures and provenance + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: | + mkdir "$RUNNER_TEMP/signature-audit" + cd "$RUNNER_TEMP/signature-audit" + npm init --yes + npm install --ignore-scripts --no-audit --no-fund --save-exact "maka-agent@$RELEASE_VERSION" + npm audit signatures --json --include-attestations > audit.json + node "$GITHUB_WORKSPACE/scripts/release-cli-publication.mjs" validate-audit \ + "$GITHUB_WORKSPACE/packages/cli/release" \ + "$RUNNER_TEMP/signature-audit/audit.json" + - name: Preserve the verified public release + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cli-public-release-${{ github.run_attempt }} + path: ${{ runner.temp }}/registry-release + if-no-files-found: error + compression-level: 0 + retention-days: 30 + + publish: + name: Create the GitHub CLI release + needs: inspect + runs-on: ubuntu-24.04 + timeout-minutes: 10 + environment: + name: npm-release + url: https://github.com/maka-agent/maka-agent/releases/tag/${{ needs.inspect.outputs.git_tag }} + permissions: + contents: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.inspect.outputs.source_sha }} + persist-credentials: false + - name: Download the verified public release + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-public-release-${{ github.run_attempt }} + path: ${{ runner.temp }}/registry-release + - name: Create the Git tag and GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_DIRECTORY: ${{ runner.temp }}/registry-release + RELEASE_SHA: ${{ needs.inspect.outputs.source_sha }} + RELEASE_TAG: ${{ needs.inspect.outputs.git_tag }} + RELEASE_TARBALL: ${{ needs.inspect.outputs.tarball }} + RELEASE_VERSION: ${{ needs.inspect.outputs.version }} + run: | + tarball_name=$(basename "$RELEASE_TARBALL") + gh release create "$RELEASE_TAG" \ + "$RELEASE_DIRECTORY/$tarball_name" \ + "$RELEASE_DIRECTORY/$tarball_name.sha256" \ + "$RELEASE_DIRECTORY/$tarball_name.files.json" \ + "$RELEASE_DIRECTORY/release.json" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$RELEASE_SHA" \ + --title "Maka CLI $RELEASE_VERSION" \ + --notes-file "$RELEASE_DIRECTORY/release-notes.md" diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml new file mode 100644 index 0000000000..c1e38db929 --- /dev/null +++ b/.github/workflows/release-cli-stage.yml @@ -0,0 +1,135 @@ +name: Stage CLI npm release + +on: + workflow_dispatch: + inputs: + version: + description: Exact maka-agent version from packages/cli/package.json + required: true + type: string + +permissions: + contents: read + +concurrency: + group: cli-npm-stage + cancel-in-progress: false + +jobs: + authorize: + name: Require main + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - name: Reject non-main dispatches + env: + RELEASE_REF: ${{ github.ref }} + run: | + if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then + echo "CLI releases must be dispatched from main; found $RELEASE_REF" >&2 + exit 1 + fi + + validate: + name: Validate immutable candidate + needs: authorize + uses: ./.github/workflows/cli-package-validation.yml + + stage: + name: Stage maka-agent on npm + needs: validate + runs-on: ubuntu-24.04 + timeout-minutes: 15 + environment: + name: npm-release + url: https://www.npmjs.com/package/maka-agent + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '22.19.0' + registry-url: https://registry.npmjs.org + package-manager-cache: false + - name: Select the staged-publishing npm toolchain + run: npm install --global --no-audit --no-fund npm@11.19.0 + - name: Download the validated release candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: cli-release-candidate-${{ github.run_attempt }} + path: packages/cli/release + - name: Bind the candidate to this workflow run + id: release + env: + EXPECTED_VERSION: ${{ inputs.version }} + RELEASE_REPOSITORY: ${{ github.repository }} + RELEASE_RUN_ID: ${{ github.run_id }} + RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }} + RELEASE_SHA: ${{ github.sha }} + RELEASE_WORKFLOW: .github/workflows/release-cli-stage.yml + run: | + node scripts/release-cli-publication.mjs prepare-stage \ + packages/cli/release \ + "$EXPECTED_VERSION" \ + "$RELEASE_SHA" \ + "$RELEASE_RUN_ID" \ + "$RELEASE_RUN_ATTEMPT" \ + "$RELEASE_REPOSITORY" \ + "$RELEASE_WORKFLOW" \ + "$GITHUB_OUTPUT" + - name: Require an unused npm version + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + run: node scripts/release-cli-publication.mjs assert-vacant "$RELEASE_VERSION" + - name: Require an unused Git tag + env: + RELEASE_TAG: ${{ steps.release.outputs.git_tag }} + run: | + if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG"; then + echo "Git tag $RELEASE_TAG already exists" >&2 + exit 1 + else + status=$? + if [[ $status -ne 2 ]]; then + echo "Could not confirm that Git tag $RELEASE_TAG is unused" >&2 + exit "$status" + fi + fi + - name: Preserve the exact staged candidate + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: cli-staged-release-${{ github.run_attempt }} + path: | + packages/cli/release/*.tgz + packages/cli/release/*.tgz.sha256 + packages/cli/release/*.tgz.files.json + packages/cli/release/release.json + if-no-files-found: error + compression-level: 0 + retention-days: 30 + - name: Submit the candidate to npm staging + env: + RELEASE_DIST_TAG: ${{ steps.release.outputs.dist_tag }} + RELEASE_TARBALL: ${{ steps.release.outputs.tarball }} + run: >- + npm stage publish "$RELEASE_TARBALL" + --tag "$RELEASE_DIST_TAG" + --registry https://registry.npmjs.org/ + - name: Record the manual approval step + env: + RELEASE_VERSION: ${{ steps.release.outputs.version }} + RELEASE_RUN_ID: ${{ github.run_id }} + run: | + { + echo "## maka-agent@$RELEASE_VERSION staged" + echo + echo "Review and approve the staged package with 2FA on npmjs.com." + echo "After it becomes public, run **Finalize CLI npm release** with:" + echo + echo "- stage run ID: \`$RELEASE_RUN_ID\`" + echo "- version: \`$RELEASE_VERSION\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b1c77e7ca5..e64a2ceeff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,7 @@ By contributing you agree that your contributions are licensed under the [Apache | Requirement | Value | | --- | --- | | Node | `>=22.19.0` (`engines`, root `package.json`) | -| npm | `11.12.1` (`packageManager`) | +| npm | `11.19.0` (`packageManager`) | | Platform | macOS Apple Silicon for desktop work. Releases also ship an unsigned Windows x64 build and CI runs a non-blocking `windows_baseline` job, but Windows and Linux are not supported targets yet | ```sh diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md index ea7e6306f6..2ecfee6f9c 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -75,7 +75,7 @@ Generated-by: | 要求 | 值 | | --- | --- | | Node | `>=22.19.0`(根 `package.json` 的 `engines`) | -| npm | `11.12.1`(`packageManager`) | +| npm | `11.19.0`(`packageManager`) | | 平台 | 桌面端开发需要 macOS Apple Silicon。发版也会产出未签名的 Windows x64 构建,CI 有非阻塞的 `windows_baseline` job,但 Windows 和 Linux 目前还不是受支持的目标平台 | ```sh diff --git a/package.json b/package.json index e9dd8f6d26..d081a0e455 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "engines": { "node": ">=22.19.0" }, - "packageManager": "npm@11.12.1", + "packageManager": "npm@11.19.0", "type": "module", "workspaces": [ "packages/code-mode", @@ -47,7 +47,7 @@ "release:cli:eval": "node scripts/release-cli-eval-package.mjs", "generate:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs", "check:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && node --test scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && node --test scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", "package:windows-x64": "node scripts/package-windows-x64.mjs", diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs new file mode 100644 index 0000000000..cbc4cd7896 --- /dev/null +++ b/scripts/release-cli-publication.mjs @@ -0,0 +1,452 @@ +import { appendFileSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { basename, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createHash } from 'node:crypto'; +import { CLI_RELEASE_ARTIFACT_LIMITS } from './release-cli-artifact-policy.mjs'; + +const PACKAGE_NAME = 'maka-agent'; +const REGISTRY_ORIGIN = 'https://registry.npmjs.org'; +const REPOSITORY = 'maka-agent/maka-agent'; +const STAGE_WORKFLOW_PATH = '.github/workflows/release-cli-stage.yml'; +const RELEASE_RECORD_KEYS = [ + 'schemaVersion', + 'packageName', + 'version', + 'distTag', + 'gitTag', + 'tarball', + 'sha256', + 'checksum', + 'inventory', + 'source', +]; + +export function parseCliReleaseVersion(version) { + if (typeof version !== 'string') throw new Error('Expected a valid CLI release version'); + const match = + /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/u.exec( + version, + ); + if (!match) throw new Error(`Expected a valid CLI release version; found ${version}`); + const prerelease = match[4]; + if ( + prerelease + ?.split('.') + .some( + (identifier) => /^\d+$/u.test(identifier) && identifier.length > 1 && identifier[0] === '0', + ) + ) { + throw new Error(`Expected a valid CLI release version; found ${version}`); + } + return { + version, + distTag: prerelease ? 'next' : 'latest', + gitTag: `cli-v${version}`, + tarball: `${PACKAGE_NAME}-${version}.tgz`, + }; +} + +export function prepareStageRelease({ + repoRoot, + releaseDirectory, + expectedVersion, + sourceSha, + runId, + runAttempt, + repository, + workflowPath, +}) { + const cliManifest = readJson(join(repoRoot, 'packages/cli/package.json'), 'CLI manifest'); + if (cliManifest.name !== PACKAGE_NAME) { + throw new Error(`CLI package name must be ${PACKAGE_NAME}`); + } + const identity = parseCliReleaseVersion(cliManifest.version); + if (expectedVersion !== identity.version) { + throw new Error( + `Release version confirmation ${expectedVersion} does not match ${identity.version}`, + ); + } + validateSourceIdentity({ sourceSha, runId, runAttempt, repository, workflowPath }); + const candidate = validateCandidateFiles(releaseDirectory, identity); + const record = { + schemaVersion: 1, + packageName: PACKAGE_NAME, + ...identity, + sha256: candidate.sha256, + checksum: `${identity.tarball}.sha256`, + inventory: `${identity.tarball}.files.json`, + source: { + repository, + workflow: workflowPath, + commit: sourceSha, + runId, + runAttempt, + }, + }; + writeFileSync(join(releaseDirectory, 'release.json'), `${JSON.stringify(record, null, 2)}\n`, { + flag: 'wx', + mode: 0o644, + }); + return { record, tarballPath: candidate.tarballPath }; +} + +export async function assertPublicVersionVacant({ version, fetchImpl = fetch }) { + parseCliReleaseVersion(version); + const response = await fetchImpl( + `${REGISTRY_ORIGIN}/${PACKAGE_NAME}/${encodeURIComponent(version)}`, + { redirect: 'error' }, + ); + if (response.status === 404) return; + if (response.ok) throw new Error(`${PACKAGE_NAME}@${version} already exists on npm`); + throw new Error(`Registry version availability check failed with status ${response.status}`); +} + +export function validateStageRun({ releaseDirectory, expectedVersion, run }) { + const record = loadReleaseRecord(releaseDirectory); + if (expectedVersion !== record.version) { + throw new Error( + `Finalization version confirmation ${expectedVersion} does not match ${record.version}`, + ); + } + if ( + !run || + String(run.id) !== record.source.runId || + String(run.run_attempt) !== record.source.runAttempt || + run.path !== record.source.workflow || + run.event !== 'workflow_dispatch' || + run.head_branch !== 'main' || + run.head_sha !== record.source.commit || + run.conclusion !== 'success' || + run.head_repository?.full_name !== record.source.repository + ) { + throw new Error( + 'Release record does not belong to the exact successful main stage workflow run', + ); + } + return record; +} + +export async function fetchRegistryRelease({ + releaseDirectory, + registryDirectory, + fetchImpl = fetch, +}) { + const record = loadReleaseRecord(releaseDirectory); + const versionUrl = `${REGISTRY_ORIGIN}/${PACKAGE_NAME}/${encodeURIComponent(record.version)}`; + const metadata = await fetchJson(fetchImpl, versionUrl, 'package version metadata'); + if (metadata.name !== PACKAGE_NAME || metadata.version !== record.version) { + throw new Error('Registry package identity does not match the staged release'); + } + const tags = await fetchJson(fetchImpl, `${REGISTRY_ORIGIN}/${PACKAGE_NAME}`, 'package metadata'); + if (tags['dist-tags']?.[record.distTag] !== record.version) { + throw new Error(`Registry dist-tag ${record.distTag} does not point to ${record.version}`); + } + const tarballUrl = parseRegistryTarballUrl(metadata.dist?.tarball, record.tarball); + const response = await fetchImpl(tarballUrl, { redirect: 'error' }); + if (!response.ok) { + throw new Error(`Registry tarball request failed with status ${response.status}`); + } + const bytes = await readBoundedBytes( + response, + CLI_RELEASE_ARTIFACT_LIMITS.compressedBytes, + 'Registry tarball exceeds the reviewed compressed size limit', + ); + const sha256 = digest('sha256', bytes, 'hex'); + if (sha256 !== record.sha256) { + throw new Error('Registry tarball does not match the staged release checksum'); + } + if (metadata.dist?.integrity !== `sha512-${digest('sha512', bytes, 'base64')}`) { + throw new Error('Registry tarball does not match its published integrity'); + } + if (metadata.dist?.shasum !== digest('sha1', bytes, 'hex')) { + throw new Error('Registry tarball does not match its published shasum'); + } + + mkdirSync(registryDirectory, { recursive: true, mode: 0o755 }); + const tarballPath = join(registryDirectory, record.tarball); + writeFileSync(tarballPath, bytes, { flag: 'wx', mode: 0o644 }); + for (const name of [record.checksum, record.inventory, 'release.json']) { + copyFileSync(join(releaseDirectory, name), join(registryDirectory, name)); + } + writeFileSync(join(registryDirectory, 'release-notes.md'), releaseNotes(record), { + flag: 'wx', + mode: 0o644, + }); + return { ...record, tarballPath, sha256 }; +} + +export function validateSignatureAudit({ releaseDirectory, audit }) { + const record = loadReleaseRecord(releaseDirectory); + if (!Array.isArray(audit?.invalid) || !Array.isArray(audit?.missing)) { + throw new Error('npm signature audit did not return its bounded result arrays'); + } + if (audit.invalid.length > 0 || audit.missing.length > 0) { + throw new Error('npm signature audit found invalid or missing signatures'); + } + const verified = Array.isArray(audit.verified) ? audit.verified : []; + const own = verified.find( + (entry) => entry?.name === PACKAGE_NAME && entry.version === record.version, + ); + if (!own?.attestations?.provenance) { + throw new Error( + `npm signature audit did not include verified provenance for ${record.version}`, + ); + } + return record; +} + +function loadReleaseRecord(releaseDirectory) { + const record = readJson(join(releaseDirectory, 'release.json'), 'release record'); + exactKeys(record, RELEASE_RECORD_KEYS, 'release record'); + if (record.schemaVersion !== 1 || record.packageName !== PACKAGE_NAME) { + throw new Error('Unsupported CLI release record'); + } + const identity = parseCliReleaseVersion(record.version); + for (const key of ['distTag', 'gitTag', 'tarball']) { + if (record[key] !== identity[key]) throw new Error(`Release record ${key} is inconsistent`); + } + if (!/^[0-9a-f]{64}$/u.test(record.sha256)) { + throw new Error('Release record sha256 is invalid'); + } + if ( + record.checksum !== `${identity.tarball}.sha256` || + record.inventory !== `${identity.tarball}.files.json` + ) { + throw new Error('Release record sidecar names are inconsistent'); + } + exactKeys( + record.source, + ['repository', 'workflow', 'commit', 'runId', 'runAttempt'], + 'release source', + ); + validateSourceIdentity({ + sourceSha: record.source.commit, + runId: record.source.runId, + runAttempt: record.source.runAttempt, + repository: record.source.repository, + workflowPath: record.source.workflow, + }); + const candidate = validateCandidateFiles(releaseDirectory, identity); + if (candidate.sha256 !== record.sha256) { + throw new Error('Release record checksum does not match the candidate'); + } + return record; +} + +function validateCandidateFiles(releaseDirectory, identity) { + const tarballPath = join(releaseDirectory, identity.tarball); + const bytes = readFileSync(tarballPath); + if (bytes.length > CLI_RELEASE_ARTIFACT_LIMITS.compressedBytes) { + throw new Error('CLI release candidate exceeds the reviewed compressed size limit'); + } + const checksum = readFileSync(`${tarballPath}.sha256`, 'utf8'); + const match = /^([0-9a-f]{64}) {2}([^\r\n]+)\r?\n?$/u.exec(checksum); + if (!match || match[2] !== identity.tarball) { + throw new Error('CLI release candidate checksum sidecar is malformed'); + } + const sha256 = digest('sha256', bytes, 'hex'); + if (match[1] !== sha256) { + throw new Error('CLI release candidate checksum does not match'); + } + const inventory = readJson(`${tarballPath}.files.json`, 'CLI release file inventory'); + if (!Array.isArray(inventory)) throw new Error('CLI release file inventory must be an array'); + return { tarballPath, sha256 }; +} + +function validateSourceIdentity({ sourceSha, runId, runAttempt, repository, workflowPath }) { + if (!/^[0-9a-f]{40}$/u.test(sourceSha)) throw new Error('Release source SHA is invalid'); + if (!/^[1-9]\d*$/u.test(runId)) throw new Error('Release workflow run ID is invalid'); + if (!/^[1-9]\d*$/u.test(runAttempt)) throw new Error('Release workflow run attempt is invalid'); + if (repository !== REPOSITORY) throw new Error(`Release repository must be ${REPOSITORY}`); + if (workflowPath !== STAGE_WORKFLOW_PATH) { + throw new Error(`Release workflow must be ${STAGE_WORKFLOW_PATH}`); + } +} + +async function fetchJson(fetchImpl, url, label) { + const response = await fetchImpl(url, { + headers: { accept: 'application/vnd.npm.install-v1+json' }, + redirect: 'error', + }); + if (!response.ok) + throw new Error(`Registry ${label} request failed with status ${response.status}`); + const bytes = await readBoundedBytes( + response, + 4 * 1024 * 1024, + `Registry ${label} exceeds the bounded response size`, + ); + try { + return JSON.parse(bytes.toString('utf8')); + } catch (error) { + throw new Error(`Registry ${label} is not valid JSON`, { cause: error }); + } +} + +async function readBoundedBytes(response, limit, errorMessage) { + const contentLength = response.headers.get('content-length'); + if (contentLength !== null && Number(contentLength) > limit) { + throw new Error(errorMessage); + } + if (!response.body) return Buffer.alloc(0); + + const reader = response.body.getReader(); + const chunks = []; + let total = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > limit) { + await reader.cancel(); + throw new Error(errorMessage); + } + chunks.push(Buffer.from(value)); + } + } finally { + reader.releaseLock(); + } + return Buffer.concat(chunks, total); +} + +function parseRegistryTarballUrl(value, expectedName) { + if (typeof value !== 'string' || !URL.canParse(value)) { + throw new Error('Registry package metadata has no valid tarball URL'); + } + const url = new URL(value); + if ( + url.origin !== REGISTRY_ORIGIN || + url.username || + url.password || + basename(url.pathname) !== expectedName + ) { + throw new Error('Registry package metadata points outside the npm registry release path'); + } + return url.href; +} + +function releaseNotes(record) { + const install = record.distTag === 'next' ? `${PACKAGE_NAME}@next` : PACKAGE_NAME; + return `Maka CLI ${record.version}\n\nInstall with:\n\n\`\`\`sh\nnpm install --global ${install}\n\`\`\`\n\nSource commit: ${record.source.commit}\nStage workflow run: https://github.com/${record.source.repository}/actions/runs/${record.source.runId}\nSHA-256: \`${record.sha256}\`\n`; +} + +function exactKeys(value, keys, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} must be an object`); + } + const actual = Object.keys(value).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new Error(`${label} fields are invalid`); + } +} + +function readJson(path, label) { + try { + return JSON.parse(readFileSync(path, 'utf8')); + } catch (error) { + throw new Error(`${label} is unavailable or invalid`, { cause: error }); + } +} + +function digest(algorithm, bytes, encoding) { + return createHash(algorithm).update(bytes).digest(encoding); +} + +function appendOutputs(path, values) { + for (const [name, value] of Object.entries(values)) { + const text = String(value); + if (!/^[a-z_]+$/u.test(name) || /[\r\n]/u.test(text)) { + throw new Error('Unsafe GitHub Actions output'); + } + appendFileSync(path, `${name}=${text}\n`, 'utf8'); + } +} + +async function main() { + const [command, ...args] = process.argv.slice(2); + if (command === 'prepare-stage' && args.length === 8) { + const [ + releaseDirectory, + expectedVersion, + sourceSha, + runId, + runAttempt, + repository, + workflowPath, + output, + ] = args; + const result = prepareStageRelease({ + repoRoot: resolve(import.meta.dirname, '..'), + releaseDirectory: resolve(releaseDirectory), + expectedVersion, + sourceSha, + runId, + runAttempt, + repository, + workflowPath, + }); + appendOutputs(output, { + version: result.record.version, + dist_tag: result.record.distTag, + git_tag: result.record.gitTag, + tarball: result.tarballPath, + sha256: result.record.sha256, + }); + return; + } + if (command === 'validate-stage-run' && args.length === 4) { + const [releaseDirectory, runPath, expectedVersion, output] = args; + const record = validateStageRun({ + releaseDirectory: resolve(releaseDirectory), + expectedVersion, + run: readJson(resolve(runPath), 'stage workflow run'), + }); + appendOutputs(output, { + version: record.version, + dist_tag: record.distTag, + git_tag: record.gitTag, + source_sha: record.source.commit, + stage_run_id: record.source.runId, + stage_run_attempt: record.source.runAttempt, + tarball: record.tarball, + sha256: record.sha256, + }); + return; + } + if (command === 'assert-vacant' && args.length === 1) { + await assertPublicVersionVacant({ version: args[0] }); + return; + } + if (command === 'fetch-registry' && args.length === 3) { + const [releaseDirectory, registryDirectory, output] = args; + const result = await fetchRegistryRelease({ + releaseDirectory: resolve(releaseDirectory), + registryDirectory: resolve(registryDirectory), + }); + appendOutputs(output, { + version: result.version, + dist_tag: result.distTag, + git_tag: result.gitTag, + source_sha: result.source.commit, + tarball: result.tarballPath, + sha256: result.sha256, + }); + return; + } + if (command === 'validate-audit' && args.length === 2) { + const [releaseDirectory, auditPath] = args; + validateSignatureAudit({ + releaseDirectory: resolve(releaseDirectory), + audit: readJson(resolve(auditPath), 'npm signature audit'), + }); + return; + } + throw new Error( + `Usage: release-cli-publication.mjs ...`, + ); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + await main(); +} diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs new file mode 100644 index 0000000000..a25d469f3a --- /dev/null +++ b/scripts/release-cli-publication.test.mjs @@ -0,0 +1,306 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { + assertPublicVersionVacant, + fetchRegistryRelease, + parseCliReleaseVersion, + prepareStageRelease, + validateSignatureAudit, + validateStageRun, +} from './release-cli-publication.mjs'; + +const SOURCE_SHA = 'a'.repeat(40); +const WORKFLOW_PATH = '.github/workflows/release-cli-stage.yml'; + +test('release versions map prereleases and stable versions to distinct channels', () => { + assert.deepEqual(parseCliReleaseVersion('0.1.0-beta.1'), { + version: '0.1.0-beta.1', + distTag: 'next', + gitTag: 'cli-v0.1.0-beta.1', + tarball: 'maka-agent-0.1.0-beta.1.tgz', + }); + assert.equal(parseCliReleaseVersion('0.1.0').distTag, 'latest'); + for (const version of ['01.0.0', '0.1', '0.1.0+local', '0.1.0-beta..1', '../0.1.0']) { + assert.throws(() => parseCliReleaseVersion(version), /valid CLI release version/u); + } +}); + +test('stage records bind the checked candidate to one source workflow run', () => { + const fixture = createCandidate(); + const prepared = prepareStageRelease({ + repoRoot: fixture.root, + releaseDirectory: fixture.releaseDirectory, + expectedVersion: fixture.version, + sourceSha: SOURCE_SHA, + runId: '321', + runAttempt: '1', + repository: 'maka-agent/maka-agent', + workflowPath: WORKFLOW_PATH, + }); + + assert.equal(prepared.record.sha256, fixture.sha256); + assert.equal(prepared.record.source.commit, SOURCE_SHA); + assert.equal(prepared.record.source.runId, '321'); + assert.equal(prepared.record.source.runAttempt, '1'); + assert.deepEqual( + JSON.parse(readFileSync(join(fixture.releaseDirectory, 'release.json'), 'utf8')), + prepared.record, + ); +}); + +test('stage preparation rejects confirmation and checksum drift', () => { + const fixture = createCandidate(); + assert.throws( + () => + prepareStageRelease({ + repoRoot: fixture.root, + releaseDirectory: fixture.releaseDirectory, + expectedVersion: '0.1.0-beta.2', + sourceSha: SOURCE_SHA, + runId: '321', + runAttempt: '1', + repository: 'maka-agent/maka-agent', + workflowPath: WORKFLOW_PATH, + }), + /confirmation/u, + ); + + writeFileSync(`${fixture.tarballPath}.sha256`, `${'0'.repeat(64)} ${fixture.tarball}\n`); + assert.throws( + () => + prepareStageRelease({ + repoRoot: fixture.root, + releaseDirectory: fixture.releaseDirectory, + expectedVersion: fixture.version, + sourceSha: SOURCE_SHA, + runId: '321', + runAttempt: '1', + repository: 'maka-agent/maka-agent', + workflowPath: WORKFLOW_PATH, + }), + /checksum does not match/u, + ); +}); + +test('staging refuses an existing public version and fails closed on registry errors', async () => { + await assert.doesNotReject( + assertPublicVersionVacant({ + version: '0.1.0-beta.1', + fetchImpl: async () => new Response('not found', { status: 404 }), + }), + ); + await assert.rejects( + assertPublicVersionVacant({ + version: '0.1.0-beta.1', + fetchImpl: async () => Response.json({ name: 'maka-agent', version: '0.1.0-beta.1' }), + }), + /already exists/u, + ); + await assert.rejects( + assertPublicVersionVacant({ + version: '0.1.0-beta.1', + fetchImpl: async () => new Response('unavailable', { status: 503 }), + }), + /status 503/u, + ); +}); + +test('finalization accepts only the exact successful main stage run', () => { + const fixture = createPreparedCandidate(); + const run = { + id: 321, + run_attempt: 1, + path: WORKFLOW_PATH, + event: 'workflow_dispatch', + head_branch: 'main', + head_sha: SOURCE_SHA, + conclusion: 'success', + head_repository: { full_name: 'maka-agent/maka-agent' }, + }; + + assert.equal( + validateStageRun({ + releaseDirectory: fixture.releaseDirectory, + expectedVersion: fixture.version, + run, + }).source.commit, + SOURCE_SHA, + ); + + for (const drift of [ + { path: '.github/workflows/other.yml' }, + { event: 'pull_request' }, + { head_branch: 'feature' }, + { conclusion: 'failure' }, + { head_sha: 'b'.repeat(40) }, + { run_attempt: 2 }, + ]) { + assert.throws( + () => + validateStageRun({ + releaseDirectory: fixture.releaseDirectory, + expectedVersion: fixture.version, + run: { ...run, ...drift }, + }), + /stage workflow run/u, + ); + } +}); + +test('registry finalization requires the exact staged bytes and dist-tag', async () => { + const fixture = createPreparedCandidate(); + const registryDirectory = mkdtempSync(join(tmpdir(), 'maka-cli-registry-release-')); + const fetchImpl = registryFetch({ fixture }); + + const result = await fetchRegistryRelease({ + releaseDirectory: fixture.releaseDirectory, + registryDirectory, + fetchImpl, + }); + + assert.equal(result.sha256, fixture.sha256); + assert.deepEqual(readFileSync(result.tarballPath), fixture.bytes); + assert.deepEqual( + readFileSync(`${result.tarballPath}.files.json`), + readFileSync(`${fixture.tarballPath}.files.json`), + ); + + await assert.rejects( + fetchRegistryRelease({ + releaseDirectory: fixture.releaseDirectory, + registryDirectory: mkdtempSync(join(tmpdir(), 'maka-cli-registry-drift-')), + fetchImpl: registryFetch({ fixture, bytes: Buffer.from('different release') }), + }), + /Registry tarball does not match/u, + ); +}); + +test('registry downloads stop reading as soon as the tarball exceeds its bound', async () => { + const fixture = createPreparedCandidate(); + const fallback = registryFetch({ fixture }); + const tarballUrl = `https://registry.npmjs.org/maka-agent/-/${fixture.tarball}`; + let pulls = 0; + const fetchImpl = async (input) => { + if (String(input) !== tarballUrl) return fallback(input); + return new Response( + new ReadableStream({ + pull(controller) { + pulls += 1; + if (pulls > 30) return controller.close(); + controller.enqueue(new Uint8Array(1024 * 1024)); + }, + }), + ); + }; + + await assert.rejects( + fetchRegistryRelease({ + releaseDirectory: fixture.releaseDirectory, + registryDirectory: mkdtempSync(join(tmpdir(), 'maka-cli-registry-oversized-')), + fetchImpl, + }), + /exceeds the reviewed compressed size limit/u, + ); + assert.ok(pulls < 30, `expected an early bounded read, consumed ${pulls} chunks`); +}); + +test('signature audit must contain Maka provenance for the finalized version', () => { + const fixture = createPreparedCandidate(); + const verified = { + invalid: [], + missing: [], + verified: [ + { + name: 'maka-agent', + version: fixture.version, + attestations: { provenance: { predicateType: 'https://slsa.dev/provenance/v1' } }, + }, + ], + }; + assert.doesNotThrow(() => + validateSignatureAudit({ + releaseDirectory: fixture.releaseDirectory, + audit: verified, + }), + ); + assert.throws( + () => + validateSignatureAudit({ + releaseDirectory: fixture.releaseDirectory, + audit: { ...verified, verified: [] }, + }), + /verified provenance/u, + ); + assert.throws( + () => + validateSignatureAudit({ + releaseDirectory: fixture.releaseDirectory, + audit: { ...verified, invalid: [{ name: 'dependency' }] }, + }), + /invalid or missing signatures/u, + ); +}); + +function createPreparedCandidate() { + const fixture = createCandidate(); + prepareStageRelease({ + repoRoot: fixture.root, + releaseDirectory: fixture.releaseDirectory, + expectedVersion: fixture.version, + sourceSha: SOURCE_SHA, + runId: '321', + runAttempt: '1', + repository: 'maka-agent/maka-agent', + workflowPath: WORKFLOW_PATH, + }); + return fixture; +} + +function createCandidate() { + const root = mkdtempSync(join(tmpdir(), 'maka-cli-publication-')); + const releaseDirectory = join(root, 'packages/cli/release'); + const version = '0.1.0-beta.1'; + const tarball = `maka-agent-${version}.tgz`; + const tarballPath = join(releaseDirectory, tarball); + const bytes = Buffer.from('immutable cli tarball'); + const sha256 = digest('sha256', bytes, 'hex'); + mkdirSync(releaseDirectory, { recursive: true }); + writeFileSync(join(root, 'package.json'), '{"packageManager":"npm@11.19.0"}\n'); + writeFileSync( + join(root, 'packages/cli/package.json'), + `${JSON.stringify({ name: 'maka-agent', version })}\n`, + ); + writeFileSync(tarballPath, bytes); + writeFileSync(`${tarballPath}.sha256`, `${sha256} ${tarball}\n`); + writeFileSync(`${tarballPath}.files.json`, '[{"path":"dist/cli.js","size":1}]\n'); + return { root, releaseDirectory, version, tarball, tarballPath, bytes, sha256 }; +} + +function registryFetch({ fixture, bytes = fixture.bytes }) { + const integrity = `sha512-${digest('sha512', bytes, 'base64')}`; + const shasum = digest('sha1', bytes, 'hex'); + const tarballUrl = `https://registry.npmjs.org/maka-agent/-/${fixture.tarball}`; + return async (input) => { + const url = String(input); + if (url === `https://registry.npmjs.org/maka-agent/${fixture.version}`) { + return Response.json({ + name: 'maka-agent', + version: fixture.version, + dist: { tarball: tarballUrl, integrity, shasum }, + }); + } + if (url === 'https://registry.npmjs.org/maka-agent') { + return Response.json({ 'dist-tags': { next: fixture.version } }); + } + if (url === tarballUrl) return new Response(bytes); + return new Response('not found', { status: 404 }); + }; +} + +function digest(algorithm, bytes, encoding) { + return createHash(algorithm).update(bytes).digest(encoding); +} From 18826ba77d92142b1ae433f0ec20fb84e20a1370 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Tue, 18 Aug 2026 15:25:06 +0800 Subject: [PATCH 2/3] fix(release): make staged publishing recoverable Make artifact producers authoritative across partial reruns, bind finalization to an explicit workflow attempt, and validate the stage source before executing it. Finalization now audits only the public package edge, owns exact tag creation, and preserves Desktop as the repository Latest release. Generated-by: OpenAI Codex --- .github/workflows/cli-package-validation.yml | 17 ++- .github/workflows/release-cli-finalize.yml | 105 ++++++++++++------- .github/workflows/release-cli-stage.yml | 24 +---- package.json | 2 +- scripts/release-cli-publication.mjs | 58 +++++----- scripts/release-cli-publication.test.mjs | 81 +++++++++----- scripts/release-cli-workflow-policy.test.mjs | 90 ++++++++++++++++ 7 files changed, 265 insertions(+), 112 deletions(-) create mode 100644 scripts/release-cli-workflow-policy.test.mjs diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index 3dcaf6091a..6eae6d16f0 100644 --- a/.github/workflows/cli-package-validation.yml +++ b/.github/workflows/cli-package-validation.yml @@ -58,6 +58,10 @@ on: - 'scripts/smoke-release-cli-package.mjs' - 'tsconfig*.json' workflow_call: + outputs: + release_candidate_artifact_id: + description: Immutable artifact produced by the build job + value: ${{ jobs.build.outputs.release_candidate_artifact_id }} workflow_dispatch: permissions: @@ -72,6 +76,8 @@ jobs: name: Build immutable tarball runs-on: ubuntu-24.04 timeout-minutes: 60 + outputs: + release_candidate_artifact_id: ${{ steps.release-candidate.outputs.artifact-id }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -81,10 +87,11 @@ jobs: node-version: '22.19.0' cache: npm - name: Select the release npm toolchain - run: npm install --global --no-audit --no-fund npm@11.19.0 + run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" - name: Build the release tarball once run: npm run release:cli:pack - name: Upload the immutable release candidate + id: release-candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cli-release-candidate-${{ github.run_attempt }} @@ -132,7 +139,7 @@ jobs: with: node-version: ${{ matrix.node }} - name: Select the release npm toolchain - run: npm install --global --no-audit --no-fund npm@11.19.0 + run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" - name: Assert the runner architecture env: EXPECTED_PLATFORM: ${{ matrix.platform }} @@ -142,7 +149,7 @@ jobs: - name: Download the release candidate uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: cli-release-candidate-${{ github.run_attempt }} + artifact-ids: ${{ needs.build.outputs.release_candidate_artifact_id }} path: packages/cli/release - name: Validate the installed tarball run: node scripts/smoke-release-cli-package.mjs @@ -163,7 +170,7 @@ jobs: with: python-version: '3.12' - name: Select the release npm toolchain - run: npm install --global --no-audit --no-fund npm@11.19.0 + run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" - name: Install pinned Eval frameworks run: | python -m venv "$RUNNER_TEMP/maka-harbor" @@ -175,7 +182,7 @@ jobs: - name: Download the release candidate uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: cli-release-candidate-${{ github.run_attempt }} + artifact-ids: ${{ needs.build.outputs.release_candidate_artifact_id }} path: packages/cli/release - name: Validate real Harbor and Pier cells run: npm run release:cli:eval diff --git a/.github/workflows/release-cli-finalize.yml b/.github/workflows/release-cli-finalize.yml index 9b5cac68b0..8a000b1140 100644 --- a/.github/workflows/release-cli-finalize.yml +++ b/.github/workflows/release-cli-finalize.yml @@ -7,6 +7,10 @@ on: description: Successful Stage CLI npm release workflow run ID required: true type: string + stage_run_attempt: + description: Successful Stage CLI npm release workflow run attempt + required: true + type: string version: description: Exact staged maka-agent version required: true @@ -21,49 +25,58 @@ concurrency: cancel-in-progress: false jobs: - authorize: - name: Require main - runs-on: ubuntu-24.04 - timeout-minutes: 5 - steps: - - name: Reject non-main dispatches - env: - RELEASE_REF: ${{ github.ref }} - run: | - if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then - echo "CLI releases must be dispatched from main; found $RELEASE_REF" >&2 - exit 1 - fi - inspect: name: Verify the public npm release - needs: authorize runs-on: ubuntu-24.04 timeout-minutes: 20 outputs: + dist_tag: ${{ steps.release.outputs.dist_tag }} git_tag: ${{ steps.release.outputs.git_tag }} + public_release_artifact_id: ${{ steps.public-release.outputs.artifact-id }} source_sha: ${{ steps.release.outputs.source_sha }} tarball: ${{ steps.registry.outputs.tarball }} version: ${{ steps.release.outputs.version }} steps: + - name: Require main + env: + RELEASE_REF: ${{ github.ref }} + run: | + if [[ "$RELEASE_REF" != "refs/heads/main" ]]; then + echo "CLI releases must be dispatched from main; found $RELEASE_REF" >&2 + exit 1 + fi - name: Load the exact stage workflow run id: stage-run env: GH_TOKEN: ${{ github.token }} STAGE_RUN_ID: ${{ inputs.stage_run_id }} + STAGE_RUN_ATTEMPT: ${{ inputs.stage_run_attempt }} run: | if [[ ! "$STAGE_RUN_ID" =~ ^[1-9][0-9]*$ ]]; then echo "Stage workflow run ID must be a positive integer" >&2 exit 1 fi - gh api "repos/$GITHUB_REPOSITORY/actions/runs/$STAGE_RUN_ID" > "$RUNNER_TEMP/stage-run.json" + if [[ ! "$STAGE_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]; then + echo "Stage workflow run attempt must be a positive integer" >&2 + exit 1 + fi + gh api "repos/$GITHUB_REPOSITORY/actions/runs/$STAGE_RUN_ID/attempts/$STAGE_RUN_ATTEMPT" > "$RUNNER_TEMP/stage-run.json" node -e ' const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + if ( + String(run.id) !== process.env.STAGE_RUN_ID || + String(run.run_attempt) !== process.env.STAGE_RUN_ATTEMPT || + run.path !== ".github/workflows/release-cli-stage.yml" || + run.event !== "workflow_dispatch" || + run.head_branch !== "main" || + run.conclusion !== "success" || + run.head_repository?.full_name !== process.env.GITHUB_REPOSITORY + ) { + throw new Error("Stage run is not an exact successful main CLI stage attempt"); + } if (!/^[0-9a-f]{40}$/.test(run.head_sha)) throw new Error("Stage run has no valid source SHA"); fs.appendFileSync(process.env.GITHUB_OUTPUT, "source_sha=" + run.head_sha + "\n"); - if (!Number.isSafeInteger(run.run_attempt) || run.run_attempt < 1) throw new Error("Stage run has no valid attempt"); - fs.appendFileSync(process.env.GITHUB_OUTPUT, "run_attempt=" + run.run_attempt + "\n"); ' "$RUNNER_TEMP/stage-run.json" - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -72,14 +85,13 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '24' - registry-url: https://registry.npmjs.org package-manager-cache: false - name: Select the release npm toolchain - run: npm install --global --no-audit --no-fund npm@11.19.0 + run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" - name: Download the exact staged candidate uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: cli-staged-release-${{ steps.stage-run.outputs.run_attempt }} + name: cli-staged-release-${{ inputs.stage_run_attempt }} path: packages/cli/release github-token: ${{ github.token }} repository: ${{ github.repository }} @@ -101,23 +113,18 @@ jobs: packages/cli/release \ "$RUNNER_TEMP/registry-release" \ "$GITHUB_OUTPUT" - - name: Validate the registry tarball as installed CLI - env: - RELEASE_TARBALL: ${{ steps.registry.outputs.tarball }} - run: node scripts/smoke-release-cli-package.mjs "$RELEASE_TARBALL" - name: Verify npm signatures and provenance - env: - RELEASE_VERSION: ${{ steps.release.outputs.version }} run: | - mkdir "$RUNNER_TEMP/signature-audit" + node scripts/release-cli-publication.mjs prepare-audit \ + packages/cli/release \ + "$RUNNER_TEMP/signature-audit" cd "$RUNNER_TEMP/signature-audit" - npm init --yes - npm install --ignore-scripts --no-audit --no-fund --save-exact "maka-agent@$RELEASE_VERSION" npm audit signatures --json --include-attestations > audit.json node "$GITHUB_WORKSPACE/scripts/release-cli-publication.mjs" validate-audit \ "$GITHUB_WORKSPACE/packages/cli/release" \ "$RUNNER_TEMP/signature-audit/audit.json" - name: Preserve the verified public release + id: public-release uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: cli-public-release-${{ github.run_attempt }} @@ -137,24 +144,49 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.inspect.outputs.source_sha }} - persist-credentials: false - name: Download the verified public release uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: cli-public-release-${{ github.run_attempt }} + artifact-ids: ${{ needs.inspect.outputs.public_release_artifact_id }} path: ${{ runner.temp }}/registry-release - name: Create the Git tag and GitHub Release env: GH_TOKEN: ${{ github.token }} + RELEASE_DIST_TAG: ${{ needs.inspect.outputs.dist_tag }} RELEASE_DIRECTORY: ${{ runner.temp }}/registry-release RELEASE_SHA: ${{ needs.inspect.outputs.source_sha }} RELEASE_TAG: ${{ needs.inspect.outputs.git_tag }} RELEASE_TARBALL: ${{ needs.inspect.outputs.tarball }} RELEASE_VERSION: ${{ needs.inspect.outputs.version }} run: | + tag_json="$RUNNER_TEMP/release-tag.json" + tag_ref="repos/$GITHUB_REPOSITORY/git/ref/tags/$RELEASE_TAG" + if ! gh api "$tag_ref" > "$tag_json" 2>/dev/null; then + if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + -f ref="refs/tags/$RELEASE_TAG" \ + -f sha="$RELEASE_SHA" > "$tag_json"; then + gh api "$tag_ref" > "$tag_json" + fi + fi + TAG_JSON="$tag_json" node -e ' + const fs = require("node:fs"); + const tag = JSON.parse(fs.readFileSync(process.env.TAG_JSON, "utf8")); + if ( + tag.ref !== "refs/tags/" + process.env.RELEASE_TAG || + tag.object?.type !== "commit" || + tag.object.sha !== process.env.RELEASE_SHA + ) { + throw new Error("Git tag does not point to the verified CLI release commit"); + } + ' + + release_flags=(--latest=false) + if [[ "$RELEASE_DIST_TAG" == "next" ]]; then + release_flags+=(--prerelease) + elif [[ "$RELEASE_DIST_TAG" != "latest" ]]; then + echo "Unsupported CLI release dist-tag: $RELEASE_DIST_TAG" >&2 + exit 1 + fi tarball_name=$(basename "$RELEASE_TARBALL") gh release create "$RELEASE_TAG" \ "$RELEASE_DIRECTORY/$tarball_name" \ @@ -162,6 +194,7 @@ jobs: "$RELEASE_DIRECTORY/$tarball_name.files.json" \ "$RELEASE_DIRECTORY/release.json" \ --repo "$GITHUB_REPOSITORY" \ - --target "$RELEASE_SHA" \ + --verify-tag \ + "${release_flags[@]}" \ --title "Maka CLI $RELEASE_VERSION" \ --notes-file "$RELEASE_DIRECTORY/release-notes.md" diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index c1e38db929..05539e9036 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -56,11 +56,11 @@ jobs: registry-url: https://registry.npmjs.org package-manager-cache: false - name: Select the staged-publishing npm toolchain - run: npm install --global --no-audit --no-fund npm@11.19.0 + run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" - name: Download the validated release candidate uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: cli-release-candidate-${{ github.run_attempt }} + artifact-ids: ${{ needs.validate.outputs.release_candidate_artifact_id }} path: packages/cli/release - name: Bind the candidate to this workflow run id: release @@ -81,24 +81,6 @@ jobs: "$RELEASE_REPOSITORY" \ "$RELEASE_WORKFLOW" \ "$GITHUB_OUTPUT" - - name: Require an unused npm version - env: - RELEASE_VERSION: ${{ steps.release.outputs.version }} - run: node scripts/release-cli-publication.mjs assert-vacant "$RELEASE_VERSION" - - name: Require an unused Git tag - env: - RELEASE_TAG: ${{ steps.release.outputs.git_tag }} - run: | - if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_TAG"; then - echo "Git tag $RELEASE_TAG already exists" >&2 - exit 1 - else - status=$? - if [[ $status -ne 2 ]]; then - echo "Could not confirm that Git tag $RELEASE_TAG is unused" >&2 - exit "$status" - fi - fi - name: Preserve the exact staged candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -123,6 +105,7 @@ jobs: env: RELEASE_VERSION: ${{ steps.release.outputs.version }} RELEASE_RUN_ID: ${{ github.run_id }} + RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }} run: | { echo "## maka-agent@$RELEASE_VERSION staged" @@ -131,5 +114,6 @@ jobs: echo "After it becomes public, run **Finalize CLI npm release** with:" echo echo "- stage run ID: \`$RELEASE_RUN_ID\`" + echo "- stage run attempt: \`$RELEASE_RUN_ATTEMPT\`" echo "- version: \`$RELEASE_VERSION\`" } >> "$GITHUB_STEP_SUMMARY" diff --git a/package.json b/package.json index d081a0e455..aef6f34f20 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "release:cli:eval": "node scripts/release-cli-eval-package.mjs", "generate:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs", "check:windows-cargo-notices": "node scripts/generate-windows-cargo-notices.mjs --check", - "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && node --test scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs", + "check:release": "npm run check:stale && npm run check:third-party-notices && npm run check:cli-third-party-notices && node --test scripts/release-cli-file-policy.test.mjs scripts/release-cli-artifact-policy.test.mjs scripts/release-cli-eval-support.test.mjs scripts/release-cli-publication.test.mjs scripts/release-cli-workflow-policy.test.mjs", "package:macos-arm64": "node scripts/package-macos-arm64.mjs", "verify:macos-arm64": "node scripts/verify-macos-arm64-dmg.mjs", "package:windows-x64": "node scripts/package-windows-x64.mjs", diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index cbc4cd7896..d693071b33 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -90,17 +90,6 @@ export function prepareStageRelease({ return { record, tarballPath: candidate.tarballPath }; } -export async function assertPublicVersionVacant({ version, fetchImpl = fetch }) { - parseCliReleaseVersion(version); - const response = await fetchImpl( - `${REGISTRY_ORIGIN}/${PACKAGE_NAME}/${encodeURIComponent(version)}`, - { redirect: 'error' }, - ); - if (response.status === 404) return; - if (response.ok) throw new Error(`${PACKAGE_NAME}@${version} already exists on npm`); - throw new Error(`Registry version availability check failed with status ${response.status}`); -} - export function validateStageRun({ releaseDirectory, expectedVersion, run }) { const record = loadReleaseRecord(releaseDirectory); if (expectedVersion !== record.version) { @@ -195,6 +184,27 @@ export function validateSignatureAudit({ releaseDirectory, audit }) { return record; } +export function prepareSignatureAuditTree({ releaseDirectory, auditDirectory }) { + const record = loadReleaseRecord(releaseDirectory); + const packageDirectory = join(auditDirectory, 'node_modules', PACKAGE_NAME); + mkdirSync(packageDirectory, { recursive: true, mode: 0o755 }); + writeJson( + join(auditDirectory, 'package.json'), + { + name: 'maka-cli-signature-audit', + private: true, + dependencies: { [PACKAGE_NAME]: record.version }, + }, + 0o644, + ); + writeJson( + join(packageDirectory, 'package.json'), + { name: PACKAGE_NAME, version: record.version }, + 0o644, + ); + return record; +} + function loadReleaseRecord(releaseDirectory) { const record = readJson(join(releaseDirectory, 'release.json'), 'release record'); exactKeys(record, RELEASE_RECORD_KEYS, 'release record'); @@ -327,7 +337,7 @@ function parseRegistryTarballUrl(value, expectedName) { function releaseNotes(record) { const install = record.distTag === 'next' ? `${PACKAGE_NAME}@next` : PACKAGE_NAME; - return `Maka CLI ${record.version}\n\nInstall with:\n\n\`\`\`sh\nnpm install --global ${install}\n\`\`\`\n\nSource commit: ${record.source.commit}\nStage workflow run: https://github.com/${record.source.repository}/actions/runs/${record.source.runId}\nSHA-256: \`${record.sha256}\`\n`; + return `Maka CLI ${record.version}\n\nInstall with:\n\n\`\`\`sh\nnpm install --global ${install}\n\`\`\`\n\nSource commit: ${record.source.commit}\nStage workflow run: https://github.com/${record.source.repository}/actions/runs/${record.source.runId} (attempt ${record.source.runAttempt})\nSHA-256: \`${record.sha256}\`\n`; } function exactKeys(value, keys, label) { @@ -349,6 +359,10 @@ function readJson(path, label) { } } +function writeJson(path, value, mode) { + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode }); +} + function digest(algorithm, bytes, encoding) { return createHash(algorithm).update(bytes).digest(encoding); } @@ -391,7 +405,6 @@ async function main() { dist_tag: result.record.distTag, git_tag: result.record.gitTag, tarball: result.tarballPath, - sha256: result.record.sha256, }); return; } @@ -407,15 +420,15 @@ async function main() { dist_tag: record.distTag, git_tag: record.gitTag, source_sha: record.source.commit, - stage_run_id: record.source.runId, - stage_run_attempt: record.source.runAttempt, - tarball: record.tarball, - sha256: record.sha256, }); return; } - if (command === 'assert-vacant' && args.length === 1) { - await assertPublicVersionVacant({ version: args[0] }); + if (command === 'prepare-audit' && args.length === 2) { + const [releaseDirectory, auditDirectory] = args; + prepareSignatureAuditTree({ + releaseDirectory: resolve(releaseDirectory), + auditDirectory: resolve(auditDirectory), + }); return; } if (command === 'fetch-registry' && args.length === 3) { @@ -425,12 +438,7 @@ async function main() { registryDirectory: resolve(registryDirectory), }); appendOutputs(output, { - version: result.version, - dist_tag: result.distTag, - git_tag: result.gitTag, - source_sha: result.source.commit, tarball: result.tarballPath, - sha256: result.sha256, }); return; } @@ -443,7 +451,7 @@ async function main() { return; } throw new Error( - `Usage: release-cli-publication.mjs ...`, + `Usage: release-cli-publication.mjs ...`, ); } diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index a25d469f3a..0c20949c5c 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -1,13 +1,14 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import test from 'node:test'; import { - assertPublicVersionVacant, fetchRegistryRelease, parseCliReleaseVersion, + prepareSignatureAuditTree, prepareStageRelease, validateSignatureAudit, validateStageRun, @@ -86,29 +87,6 @@ test('stage preparation rejects confirmation and checksum drift', () => { ); }); -test('staging refuses an existing public version and fails closed on registry errors', async () => { - await assert.doesNotReject( - assertPublicVersionVacant({ - version: '0.1.0-beta.1', - fetchImpl: async () => new Response('not found', { status: 404 }), - }), - ); - await assert.rejects( - assertPublicVersionVacant({ - version: '0.1.0-beta.1', - fetchImpl: async () => Response.json({ name: 'maka-agent', version: '0.1.0-beta.1' }), - }), - /already exists/u, - ); - await assert.rejects( - assertPublicVersionVacant({ - version: '0.1.0-beta.1', - fetchImpl: async () => new Response('unavailable', { status: 503 }), - }), - /status 503/u, - ); -}); - test('finalization accepts only the exact successful main stage run', () => { const fixture = createPreparedCandidate(); const run = { @@ -168,6 +146,10 @@ test('registry finalization requires the exact staged bytes and dist-tag', async readFileSync(`${result.tarballPath}.files.json`), readFileSync(`${fixture.tarballPath}.files.json`), ); + assert.match( + readFileSync(join(registryDirectory, 'release-notes.md'), 'utf8'), + /Stage workflow run: .* \(attempt 1\)/u, + ); await assert.rejects( fetchRegistryRelease({ @@ -245,6 +227,55 @@ test('signature audit must contain Maka provenance for the finalized version', ( ); }); +test('signature audit tree exposes only the top-level registry package', () => { + const fixture = createPreparedCandidate(); + const auditDirectory = mkdtempSync(join(tmpdir(), 'maka-cli-signature-audit-')); + + prepareSignatureAuditTree({ + releaseDirectory: fixture.releaseDirectory, + auditDirectory, + }); + + assert.deepEqual(JSON.parse(readFileSync(join(auditDirectory, 'package.json'), 'utf8')), { + name: 'maka-cli-signature-audit', + private: true, + dependencies: { 'maka-agent': fixture.version }, + }); + assert.deepEqual( + JSON.parse(readFileSync(join(auditDirectory, 'node_modules/maka-agent/package.json'), 'utf8')), + { name: 'maka-agent', version: fixture.version }, + ); +}); + +test('prepare-stage CLI emits only consumed GitHub Actions outputs', () => { + const fixture = createCandidate(); + const output = join(fixture.root, 'github-output.txt'); + const result = spawnSync( + process.execPath, + [ + resolve(import.meta.dirname, 'release-cli-publication.mjs'), + 'prepare-stage', + fixture.releaseDirectory, + fixture.version, + SOURCE_SHA, + '321', + '1', + 'maka-agent/maka-agent', + WORKFLOW_PATH, + output, + ], + { encoding: 'utf8' }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(readFileSync(output, 'utf8').trim().split('\n'), [ + `version=${fixture.version}`, + 'dist_tag=next', + `git_tag=cli-v${fixture.version}`, + `tarball=${fixture.tarballPath}`, + ]); +}); + function createPreparedCandidate() { const fixture = createCandidate(); prepareStageRelease({ diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs new file mode 100644 index 0000000000..45ec4e68be --- /dev/null +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +const workflows = resolve(import.meta.dirname, '../.github/workflows'); + +test('validation consumers download the artifact produced by the build job', () => { + const workflow = readWorkflow('cli-package-validation.yml'); + assert.match( + workflow, + /workflow_call:\n\s+outputs:\n\s+release_candidate_artifact_id:[\s\S]*?value: \$\{\{ jobs\.build\.outputs\.release_candidate_artifact_id \}\}/u, + ); + assert.match( + workflow, + /release_candidate_artifact_id: \$\{\{ steps\.release-candidate\.outputs\.artifact-id \}\}/u, + ); + assert.equal( + occurrences(workflow, 'artifact-ids: ${{ needs.build.outputs.release_candidate_artifact_id }}'), + 2, + ); +}); + +test('stage consumes the reusable validation artifact identity', () => { + const workflow = readWorkflow('release-cli-stage.yml'); + assert.match( + workflow, + /artifact-ids: \$\{\{ needs\.validate\.outputs\.release_candidate_artifact_id \}\}/u, + ); + assert.doesNotMatch(workflow, /assert-vacant|git ls-remote/u); + assert.match(workflow, /RELEASE_RUN_ATTEMPT/u); +}); + +test('finalize selects and validates one exact stage attempt before checkout', () => { + const workflow = readWorkflow('release-cli-finalize.yml'); + assert.match(workflow, /stage_run_attempt:[\s\S]*?required: true/u); + const loadIndex = workflow.indexOf('id: stage-run'); + const checkoutIndex = workflow.indexOf('uses: actions/checkout@'); + assert.ok(loadIndex >= 0 && checkoutIndex > loadIndex); + assert.match(workflow, /actions\/runs\/\$STAGE_RUN_ID\/attempts\/\$STAGE_RUN_ATTEMPT/u); + for (const field of [ + 'run.id', + 'run.run_attempt', + 'run.path', + 'run.event', + 'run.head_branch', + 'run.head_sha', + 'run.conclusion', + 'run.head_repository?.full_name', + ]) { + assert.ok(workflow.includes(field), `missing pre-check for ${field}`); + } +}); + +test('finalize propagates verified artifacts and creates a non-latest exact-tag release', () => { + const workflow = readWorkflow('release-cli-finalize.yml'); + assert.match( + workflow, + /public_release_artifact_id: \$\{\{ steps\.public-release\.outputs\.artifact-id \}\}/u, + ); + const publish = workflow.slice(workflow.indexOf('\n publish:')); + assert.match( + publish, + /artifact-ids: \$\{\{ needs\.inspect\.outputs\.public_release_artifact_id \}\}/u, + ); + assert.match(publish, /--verify-tag/u); + assert.match(publish, /--prerelease/u); + assert.match(publish, /--latest=false/u); + assert.doesNotMatch(publish, /actions\/checkout@/u); +}); + +test('release workflows select npm from the root packageManager authority', () => { + for (const name of [ + 'cli-package-validation.yml', + 'release-cli-stage.yml', + 'release-cli-finalize.yml', + ]) { + const workflow = readWorkflow(name); + assert.doesNotMatch(workflow, /npm@11\.19\.0/u); + assert.match(workflow, /packageManager/u); + } +}); + +function readWorkflow(name) { + return readFileSync(resolve(workflows, name), 'utf8'); +} + +function occurrences(value, needle) { + return value.split(needle).length - 1; +} From aef007481325130b7d665b539ca4c97f559e28e8 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Tue, 18 Aug 2026 15:48:04 +0800 Subject: [PATCH 3/3] fix(release): close staged publication boundaries Require provenance before npm accepts the staged version, keep staging as the final business step, and run finalization with the current verifier while treating the staged SHA only as release data. Pass canonical artifact identity across jobs and test the observable workflow contracts instead of deleted implementation details. Generated-by: OpenAI Codex --- .github/workflows/release-cli-finalize.yml | 20 ++++---- .github/workflows/release-cli-stage.yml | 25 +++++----- scripts/release-cli-publication.mjs | 10 ++-- scripts/release-cli-publication.test.mjs | 41 ++++++++++++++++ scripts/release-cli-workflow-policy.test.mjs | 50 ++++++++++++++++---- 5 files changed, 107 insertions(+), 39 deletions(-) diff --git a/.github/workflows/release-cli-finalize.yml b/.github/workflows/release-cli-finalize.yml index 8a000b1140..c1a91234d8 100644 --- a/.github/workflows/release-cli-finalize.yml +++ b/.github/workflows/release-cli-finalize.yml @@ -34,7 +34,7 @@ jobs: git_tag: ${{ steps.release.outputs.git_tag }} public_release_artifact_id: ${{ steps.public-release.outputs.artifact-id }} source_sha: ${{ steps.release.outputs.source_sha }} - tarball: ${{ steps.registry.outputs.tarball }} + tarball: ${{ steps.release.outputs.tarball }} version: ${{ steps.release.outputs.version }} steps: - name: Require main @@ -78,9 +78,10 @@ jobs: if (!/^[0-9a-f]{40}$/.test(run.head_sha)) throw new Error("Stage run has no valid source SHA"); fs.appendFileSync(process.env.GITHUB_OUTPUT, "source_sha=" + run.head_sha + "\n"); ' "$RUNNER_TEMP/stage-run.json" - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Check out the current release verifier + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ steps.stage-run.outputs.source_sha }} + ref: ${{ github.sha }} persist-credentials: false - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -107,12 +108,10 @@ jobs: "$EXPECTED_VERSION" \ "$GITHUB_OUTPUT" - name: Fetch and verify the public registry bytes - id: registry run: | node scripts/release-cli-publication.mjs fetch-registry \ packages/cli/release \ - "$RUNNER_TEMP/registry-release" \ - "$GITHUB_OUTPUT" + "$RUNNER_TEMP/registry-release" - name: Verify npm signatures and provenance run: | node scripts/release-cli-publication.mjs prepare-audit \ @@ -156,7 +155,7 @@ jobs: RELEASE_DIRECTORY: ${{ runner.temp }}/registry-release RELEASE_SHA: ${{ needs.inspect.outputs.source_sha }} RELEASE_TAG: ${{ needs.inspect.outputs.git_tag }} - RELEASE_TARBALL: ${{ needs.inspect.outputs.tarball }} + RELEASE_TARBALL_NAME: ${{ needs.inspect.outputs.tarball }} RELEASE_VERSION: ${{ needs.inspect.outputs.version }} run: | tag_json="$RUNNER_TEMP/release-tag.json" @@ -187,11 +186,10 @@ jobs: echo "Unsupported CLI release dist-tag: $RELEASE_DIST_TAG" >&2 exit 1 fi - tarball_name=$(basename "$RELEASE_TARBALL") gh release create "$RELEASE_TAG" \ - "$RELEASE_DIRECTORY/$tarball_name" \ - "$RELEASE_DIRECTORY/$tarball_name.sha256" \ - "$RELEASE_DIRECTORY/$tarball_name.files.json" \ + "$RELEASE_DIRECTORY/$RELEASE_TARBALL_NAME" \ + "$RELEASE_DIRECTORY/$RELEASE_TARBALL_NAME.sha256" \ + "$RELEASE_DIRECTORY/$RELEASE_TARBALL_NAME.files.json" \ "$RELEASE_DIRECTORY/release.json" \ --repo "$GITHUB_REPOSITORY" \ --verify-tag \ diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index 05539e9036..62f0ca095e 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -93,27 +93,28 @@ jobs: if-no-files-found: error compression-level: 0 retention-days: 30 - - name: Submit the candidate to npm staging - env: - RELEASE_DIST_TAG: ${{ steps.release.outputs.dist_tag }} - RELEASE_TARBALL: ${{ steps.release.outputs.tarball }} - run: >- - npm stage publish "$RELEASE_TARBALL" - --tag "$RELEASE_DIST_TAG" - --registry https://registry.npmjs.org/ - - name: Record the manual approval step + - name: Record the post-staging approval step env: RELEASE_VERSION: ${{ steps.release.outputs.version }} RELEASE_RUN_ID: ${{ github.run_id }} RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }} run: | { - echo "## maka-agent@$RELEASE_VERSION staged" + echo "## maka-agent@$RELEASE_VERSION staging" echo - echo "Review and approve the staged package with 2FA on npmjs.com." - echo "After it becomes public, run **Finalize CLI npm release** with:" + echo "After this workflow succeeds, review and approve the staged package with 2FA on npmjs.com." + echo "After the package becomes public, run **Finalize CLI npm release** with:" echo echo "- stage run ID: \`$RELEASE_RUN_ID\`" echo "- stage run attempt: \`$RELEASE_RUN_ATTEMPT\`" echo "- version: \`$RELEASE_VERSION\`" } >> "$GITHUB_STEP_SUMMARY" + - name: Submit the candidate to npm staging + env: + RELEASE_DIST_TAG: ${{ steps.release.outputs.dist_tag }} + RELEASE_TARBALL: ${{ steps.release.outputs.tarball }} + run: >- + npm stage publish "$RELEASE_TARBALL" + --tag "$RELEASE_DIST_TAG" + --registry https://registry.npmjs.org/ + --provenance diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index d693071b33..ede7e9a640 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -420,6 +420,7 @@ async function main() { dist_tag: record.distTag, git_tag: record.gitTag, source_sha: record.source.commit, + tarball: record.tarball, }); return; } @@ -431,15 +432,12 @@ async function main() { }); return; } - if (command === 'fetch-registry' && args.length === 3) { - const [releaseDirectory, registryDirectory, output] = args; - const result = await fetchRegistryRelease({ + if (command === 'fetch-registry' && args.length === 2) { + const [releaseDirectory, registryDirectory] = args; + await fetchRegistryRelease({ releaseDirectory: resolve(releaseDirectory), registryDirectory: resolve(registryDirectory), }); - appendOutputs(output, { - tarball: result.tarballPath, - }); return; } if (command === 'validate-audit' && args.length === 2) { diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index 0c20949c5c..7eaa1168d9 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -276,6 +276,47 @@ test('prepare-stage CLI emits only consumed GitHub Actions outputs', () => { ]); }); +test('validate-stage-run CLI emits the canonical cross-job release identity', () => { + const fixture = createPreparedCandidate(); + const runPath = join(fixture.root, 'stage-run.json'); + const output = join(fixture.root, 'github-output.txt'); + writeFileSync( + runPath, + JSON.stringify({ + id: 321, + run_attempt: 1, + path: WORKFLOW_PATH, + event: 'workflow_dispatch', + head_branch: 'main', + head_sha: SOURCE_SHA, + conclusion: 'success', + head_repository: { full_name: 'maka-agent/maka-agent' }, + }), + ); + + const result = spawnSync( + process.execPath, + [ + resolve(import.meta.dirname, 'release-cli-publication.mjs'), + 'validate-stage-run', + fixture.releaseDirectory, + runPath, + fixture.version, + output, + ], + { encoding: 'utf8' }, + ); + + assert.equal(result.status, 0, result.stderr); + assert.deepEqual(readFileSync(output, 'utf8').trim().split('\n'), [ + `version=${fixture.version}`, + 'dist_tag=next', + `git_tag=cli-v${fixture.version}`, + `source_sha=${SOURCE_SHA}`, + `tarball=${fixture.tarball}`, + ]); +}); + function createPreparedCandidate() { const fixture = createCandidate(); prepareStageRelease({ diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index 45ec4e68be..349e917b30 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -15,24 +15,36 @@ test('validation consumers download the artifact produced by the build job', () workflow, /release_candidate_artifact_id: \$\{\{ steps\.release-candidate\.outputs\.artifact-id \}\}/u, ); - assert.equal( - occurrences(workflow, 'artifact-ids: ${{ needs.build.outputs.release_candidate_artifact_id }}'), - 2, + const downloads = workflowSteps(workflow).filter((step) => + step.includes('uses: actions/download-artifact@'), ); + assert.ok(downloads.length > 0); + for (const step of downloads) { + assert.match( + step, + /artifact-ids: \$\{\{ needs\.build\.outputs\.release_candidate_artifact_id \}\}/u, + ); + } }); -test('stage consumes the reusable validation artifact identity', () => { +test('stage consumes the validated artifact and makes provenance staging the final step', () => { const workflow = readWorkflow('release-cli-stage.yml'); + const steps = workflowSteps(workflow); + const download = namedStep(steps, 'Download the validated release candidate'); assert.match( - workflow, + download, /artifact-ids: \$\{\{ needs\.validate\.outputs\.release_candidate_artifact_id \}\}/u, ); - assert.doesNotMatch(workflow, /assert-vacant|git ls-remote/u); assert.match(workflow, /RELEASE_RUN_ATTEMPT/u); + const submit = namedStep(steps, 'Submit the candidate to npm staging'); + assert.equal(steps.at(-1), submit); + assert.match(submit, /npm stage publish/u); + assert.match(submit, /--provenance/u); }); -test('finalize selects and validates one exact stage attempt before checkout', () => { +test('finalize validates one exact stage attempt before running the current verifier', () => { const workflow = readWorkflow('release-cli-finalize.yml'); + const steps = workflowSteps(workflow); assert.match(workflow, /stage_run_attempt:[\s\S]*?required: true/u); const loadIndex = workflow.indexOf('id: stage-run'); const checkoutIndex = workflow.indexOf('uses: actions/checkout@'); @@ -50,6 +62,9 @@ test('finalize selects and validates one exact stage attempt before checkout', ( ]) { assert.ok(workflow.includes(field), `missing pre-check for ${field}`); } + const checkout = namedStep(steps, 'Check out the current release verifier'); + assert.match(checkout, /ref: \$\{\{ github\.sha \}\}/u); + assert.doesNotMatch(checkout, /steps\.stage-run\.outputs\.source_sha/u); }); test('finalize propagates verified artifacts and creates a non-latest exact-tag release', () => { @@ -58,6 +73,8 @@ test('finalize propagates verified artifacts and creates a non-latest exact-tag workflow, /public_release_artifact_id: \$\{\{ steps\.public-release\.outputs\.artifact-id \}\}/u, ); + assert.match(workflow, /tarball: \$\{\{ steps\.release\.outputs\.tarball \}\}/u); + assert.doesNotMatch(workflow, /steps\.registry\.outputs\.tarball/u); const publish = workflow.slice(workflow.indexOf('\n publish:')); assert.match( publish, @@ -77,7 +94,13 @@ test('release workflows select npm from the root packageManager authority', () = ]) { const workflow = readWorkflow(name); assert.doesNotMatch(workflow, /npm@11\.19\.0/u); - assert.match(workflow, /packageManager/u); + const selectors = workflowSteps(workflow).filter((step) => + /name: Select the .*npm toolchain/u.test(step), + ); + assert.ok(selectors.length > 0, `${name} has no npm toolchain selector`); + for (const step of selectors) { + assert.match(step, /require\("\.\/package\.json"\)\.packageManager/u); + } } }); @@ -85,6 +108,13 @@ function readWorkflow(name) { return readFileSync(resolve(workflows, name), 'utf8'); } -function occurrences(value, needle) { - return value.split(needle).length - 1; +function workflowSteps(workflow) { + const starts = [...workflow.matchAll(/^ - (?=name:|uses:)/gmu)].map((match) => match.index); + return starts.map((start, index) => workflow.slice(start, starts[index + 1])); +} + +function namedStep(steps, name) { + const step = steps.find((candidate) => candidate.startsWith(` - name: ${name}\n`)); + assert.ok(step, `missing workflow step: ${name}`); + return step; }