diff --git a/.github/workflows/cli-package-validation.yml b/.github/workflows/cli-package-validation.yml index 60904b03e8..6eae6d16f0 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' @@ -56,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: @@ -70,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: @@ -79,13 +87,14 @@ 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 "$(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 + name: cli-release-candidate-${{ github.run_attempt }} path: | packages/cli/release/*.tgz packages/cli/release/*.tgz.sha256 @@ -130,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.12.1 + run: npm install --global --no-audit --no-fund "$(node -p 'require("./package.json").packageManager')" - name: Assert the runner architecture env: EXPECTED_PLATFORM: ${{ matrix.platform }} @@ -140,7 +149,7 @@ jobs: - name: Download the release candidate uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: cli-release-candidate + 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 @@ -161,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.12.1 + 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" @@ -173,7 +182,7 @@ jobs: - name: Download the release candidate uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: cli-release-candidate + 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 new file mode 100644 index 0000000000..c1a91234d8 --- /dev/null +++ b/.github/workflows/release-cli-finalize.yml @@ -0,0 +1,198 @@ +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 + 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 + type: string + +permissions: + actions: read + contents: read + +concurrency: + group: cli-npm-finalize + cancel-in-progress: false + +jobs: + inspect: + name: Verify the public npm release + 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.release.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 + 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"); + ' "$RUNNER_TEMP/stage-run.json" + - name: Check out the current release verifier + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + package-manager-cache: false + - name: Select the release npm toolchain + 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-${{ inputs.stage_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 + run: | + node scripts/release-cli-publication.mjs fetch-registry \ + packages/cli/release \ + "$RUNNER_TEMP/registry-release" + - name: Verify npm signatures and provenance + run: | + node scripts/release-cli-publication.mjs prepare-audit \ + packages/cli/release \ + "$RUNNER_TEMP/signature-audit" + cd "$RUNNER_TEMP/signature-audit" + 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 }} + 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: + - name: Download the verified public release + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + 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_NAME: ${{ 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 + gh release create "$RELEASE_TAG" \ + "$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 \ + "${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 new file mode 100644 index 0000000000..62f0ca095e --- /dev/null +++ b/.github/workflows/release-cli-stage.yml @@ -0,0 +1,120 @@ +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 "$(node -p 'require("./package.json").packageManager')" + - name: Download the validated release candidate + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ needs.validate.outputs.release_candidate_artifact_id }} + 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: 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: 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 staging" + echo + 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/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..aef6f34f20 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 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 new file mode 100644 index 0000000000..ede7e9a640 --- /dev/null +++ b/scripts/release-cli-publication.mjs @@ -0,0 +1,458 @@ +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 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; +} + +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'); + 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} (attempt ${record.source.runAttempt})\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 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); +} + +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, + }); + 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, + tarball: record.tarball, + }); + return; + } + 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 === 2) { + const [releaseDirectory, registryDirectory] = args; + await fetchRegistryRelease({ + releaseDirectory: resolve(releaseDirectory), + registryDirectory: resolve(registryDirectory), + }); + 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..7eaa1168d9 --- /dev/null +++ b/scripts/release-cli-publication.test.mjs @@ -0,0 +1,378 @@ +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, resolve } from 'node:path'; +import test from 'node:test'; +import { + fetchRegistryRelease, + parseCliReleaseVersion, + prepareSignatureAuditTree, + 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('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`), + ); + assert.match( + readFileSync(join(registryDirectory, 'release-notes.md'), 'utf8'), + /Stage workflow run: .* \(attempt 1\)/u, + ); + + 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, + ); +}); + +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}`, + ]); +}); + +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({ + 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); +} diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs new file mode 100644 index 0000000000..349e917b30 --- /dev/null +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -0,0 +1,120 @@ +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, + ); + 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 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( + download, + /artifact-ids: \$\{\{ needs\.validate\.outputs\.release_candidate_artifact_id \}\}/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 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@'); + 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}`); + } + 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', () => { + const workflow = readWorkflow('release-cli-finalize.yml'); + assert.match( + 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, + /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); + 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); + } + } +}); + +function readWorkflow(name) { + return readFileSync(resolve(workflows, name), 'utf8'); +} + +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; +}