From 3bfe7375f64c445e166fa02ce3a89a188554c249 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 4 Oct 2026 07:49:18 +0300 Subject: [PATCH] Gate CLI main nightlies and stable packages on exact accepted SDK releases --- .github/workflows/ci.yml | 2 +- .github/workflows/publish-dev.yml | 163 ++++++++++++++---------------- .gitignore | 2 + README.md | 4 + RELEASING.md | 37 +++++++ eslint.config.js | 8 +- scripts/prepare-release.mjs | 114 +++++++++++++++++++++ scripts/publish-release.mjs | 115 +++++++++++++++++++++ scripts/release-evidence.mjs | 102 +++++++++++++++++++ scripts/release-evidence.test.mjs | 63 ++++++++++++ 10 files changed, 523 insertions(+), 87 deletions(-) create mode 100644 RELEASING.md create mode 100644 scripts/prepare-release.mjs create mode 100644 scripts/publish-release.mjs create mode 100644 scripts/release-evidence.mjs create mode 100644 scripts/release-evidence.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 144b7c9..2bb485e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: pull_request: push: branches: - - dev - main permissions: @@ -29,3 +28,4 @@ jobs: cache: npm - run: npm ci - run: npm run verify + - run: node --test scripts/release-evidence.test.mjs diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index 8e8b9d4..993b2f3 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -1,122 +1,115 @@ -name: Publish CLI dev prerelease - +name: Publish accepted CLI packages on: + schedule: + - cron: '30 2 * * *' workflow_dispatch: inputs: - commit: - description: Full commit SHA at the current dev head - required: true - type: string + channel: + type: choice + description: Package channel from main + options: [nightly, stable] + default: nightly version: - description: Exact prerelease version in package.json - required: true type: string - + description: Exact x.y.z for stable; leave empty for nightly permissions: contents: read - concurrency: - group: cli-npm-dev + group: cli-publication cancel-in-progress: false - jobs: package: - if: github.ref == 'refs/heads/dev' + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest outputs: - tarball: ${{ steps.pack.outputs.tarball }} - sha256: ${{ steps.pack.outputs.sha256 }} + publish: ${{ steps.gate.outputs.publish }} + version: ${{ steps.gate.outputs.version }} + sdk: ${{ steps.gate.outputs.sdk }} + env: + RELEASE_CHANNEL: ${{ inputs.channel || 'nightly' }} + STABLE_VERSION: ${{ inputs.version }} + RELEASE_OUTPUT: .release/packs steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: - ref: ${{ inputs.commit }} - fetch-depth: 0 persist-credentials: false - - name: Check immutable release source - env: - RELEASE_COMMIT: ${{ inputs.commit }} - RELEASE_VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - [[ "$RELEASE_COMMIT" =~ ^[0-9a-f]{40}$ ]] - git fetch origin dev - test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT" - test "$(git rev-parse origin/dev)" = "$RELEASE_COMMIT" - test -z "$(git status --porcelain)" - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 with: - node-version: 24.15.0 + node-version: 24 registry-url: https://registry.npmjs.org/ package-manager-cache: false - - name: Check release manifest and lockfile + - name: Require an exact published SDK with matching API acceptance + id: gate + run: node scripts/prepare-release.mjs + - if: steps.gate.outputs.publish == 'true' + run: | + npm ci + npm run verify + node --test scripts/release-evidence.test.mjs + - if: steps.gate.outputs.publish == 'true' + name: Pack and smoke-test the accepted CLI and exact SDK env: - RELEASE_VERSION: ${{ inputs.version }} - run: node scripts/check-prerelease.mjs "$RELEASE_VERSION" - - name: Verify published SDK artifact + RELEASE_VERSION: ${{ steps.gate.outputs.version }} + SDK_VERSION: ${{ steps.gate.outputs.sdk }} run: | - set -euo pipefail + npm version "$RELEASE_VERSION" --no-git-tag-version --ignore-scripts --allow-same-version + npm run build + cp "$RELEASE_OUTPUT/release-contract.json" dist/release-contract.json + npm pack --ignore-scripts --json --pack-destination "$RELEASE_OUTPUT" > "$RELEASE_OUTPUT/pack.json" + mkdir -p .release/smoke + npm install --prefix .release/smoke --ignore-scripts "$(pwd)/$RELEASE_OUTPUT/polymorfa-cli-$RELEASE_VERSION.tgz" + .release/smoke/node_modules/.bin/polymorfa version --json > .release/smoke/version.json node --input-type=module <<'NODE' - import {execFileSync} from 'node:child_process' - import {readFileSync} from 'node:fs' - const lock = JSON.parse(readFileSync('package-lock.json')) - const sdk = lock.packages['node_modules/@polymorfa/sdk'] - const actual = JSON.parse(execFileSync('npm', ['view', `@polymorfa/sdk@${sdk.version}`, 'dist.integrity', '--json'], {encoding: 'utf8'})) - if (actual !== sdk.integrity) throw new Error('Published SDK integrity differs from the lockfile') + import assert from 'node:assert/strict'; + import {readFileSync} from 'node:fs'; + const versions = JSON.parse(readFileSync('.release/smoke/version.json')); + assert.equal(versions.cli, process.env.RELEASE_VERSION); + assert.equal(versions.sdk, process.env.SDK_VERSION); NODE - - run: npm ci - - run: npm run verify - - name: Pack and smoke test the release artifact - id: pack - env: - RELEASE_VERSION: ${{ inputs.version }} - run: | - set -euo pipefail - npm pack --ignore-scripts --json > pack.json - tarball="$(node --input-type=module -e "import {readFileSync} from 'node:fs'; console.log(JSON.parse(readFileSync('pack.json'))[0].filename)")" - test "$tarball" = "polymorfa-cli-$RELEASE_VERSION.tgz" - mkdir -p "$RUNNER_TEMP/cli-smoke" - npm install --prefix "$RUNNER_TEMP/cli-smoke" --ignore-scripts "$(pwd)/$tarball" - "$RUNNER_TEMP/cli-smoke/node_modules/.bin/polymorfa" version --json > "$RUNNER_TEMP/cli-smoke/version.json" - node --input-type=module -e "import {readFileSync} from 'node:fs'; const v=JSON.parse(readFileSync(process.argv[1])); if(v.cli!==process.argv[2] || v.sdk!==process.argv[3]) process.exit(1)" "$RUNNER_TEMP/cli-smoke/version.json" "$RELEASE_VERSION" "$(node -p "require('./package.json').dependencies['@polymorfa/sdk']")" - echo "tarball=$tarball" >> "$GITHUB_OUTPUT" - echo "sha256=$(sha256sum "$tarball" | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT" - - uses: actions/upload-artifact@v4 + - if: steps.gate.outputs.publish == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: cli-prerelease - path: ${{ steps.pack.outputs.tarball }} + name: accepted-cli-packs + include-hidden-files: true + path: .release/packs if-no-files-found: error - retention-days: 7 - + retention-days: 90 publish: needs: package + if: needs.package.outputs.publish == 'true' runs-on: ubuntu-latest - environment: npm-prerelease + environment: ${{ inputs.channel == 'stable' && 'npm-stable' || 'npm-prerelease' }} permissions: contents: read id-token: write + env: + RELEASE_CHANNEL: ${{ inputs.channel || 'nightly' }} + RELEASE_OUTPUT: .release/packs steps: - - uses: actions/setup-node@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 with: - node-version: 24.15.0 + persist-credentials: false + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 24 registry-url: https://registry.npmjs.org/ package-manager-cache: false - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c with: - name: cli-prerelease - - name: Publish the verified tarball to the dev tag + name: accepted-cli-packs + include-hidden-files: true + path: .release/packs + - run: npm install --global npm@11.6.2 + - name: Publish exact verified artifact and verify registry integrity env: - RELEASE_VERSION: ${{ inputs.version }} - EXPECTED_TARBALL: ${{ needs.package.outputs.tarball }} - EXPECTED_SHA256: ${{ needs.package.outputs.sha256 }} - run: | - set -euo pipefail - test "$EXPECTED_TARBALL" = "polymorfa-cli-$RELEASE_VERSION.tgz" - echo "$EXPECTED_SHA256 $EXPECTED_TARBALL" | sha256sum --check - - node -e "const v=process.versions.node.split('.').map(Number); if(v[0]<22 || (v[0]===22 && v[1]<14)) process.exit(1)" - node -e "const v=process.argv[1].split('.').map(Number); if(v[0]<11 || (v[0]===11 && (v[1]<5 || (v[1]===5 && v[2]<1)))) process.exit(1)" "$(npm --version)" - npm view "@polymorfa/cli" version --json > /dev/null - if npm view "@polymorfa/cli@$RELEASE_VERSION" version --json > /dev/null 2>&1; then - echo "Version already exists in npm" >&2 - exit 1 - fi - npm publish "$EXPECTED_TARBALL" --access public --tag dev --provenance=false --ignore-scripts + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: node scripts/publish-release.mjs + - name: Save release stage evidence for the later Linear bot + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: cli-publication-receipts + path: | + .release/packs/release-contract.json + .release/packs/stage-events.json + if-no-files-found: error + retention-days: 90 diff --git a/.gitignore b/.gitignore index 5f95008..1678d67 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,5 @@ coverage/ *.tgz .sdk-source/ .DS_Store + +.release/ diff --git a/README.md b/README.md index 34499ef..da726c3 100644 --- a/README.md +++ b/README.md @@ -2565,3 +2565,7 @@ Disabled Analytics returns no business aggregates. These commands use the pinned SDK's `analytics.get` and `analytics.metrics` methods and shared response types. A CLI release containing these commands and an API deployment exposing Analytics are separate requirements. + +## Package release workflow + +CLI development and release PRs target `main`. Nightly packages use the `dev` npm tag after matching staging API acceptance; stable packages follow production acceptance. Each package pins an exact accepted SDK version. See [the release guide](RELEASING.md). diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..b4733a3 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,37 @@ +# CLI releases from main + +All CLI PRs target `main`. The preserved `dev` branch is no longer a publication +trigger. The daily nightly runs at 02:30 UTC and uses npm's `dev` tag. Stable +releases require an explicit `x.y.z` and the protected `npm-stable` environment. +The workflow remains `publish-dev.yml` to preserve its publisher identity. + +The SDK publishes first. A CLI build selects an exact SDK version from the +matching package channel, downloads its registry artifact and verifies its +integrity. Its embedded contract receipt must prove matching staging acceptance +for a nightly or production acceptance for stable. Missing SDK provenance, +failed scenarios, different source/spec/environment or an unavailable SDK block +publication. Nightly timestamps never advance `Polymorfa-Version`. + +The build pins that exact SDK version in its disposable manifest and lockfile, +runs verification, then writes the package version. The packed CLI is installed +into a clean consumer and its `version --json` output must match both versions. +The tarball includes `dist/release-contract.json` binding CLI, SDK and API +revisions. A repeated version must have matching registry integrity. An +unchanged CLI source plus the same exact SDK skips the next nightly. + +Publication emits feature-to-ticket stage receipts for a later Linear bot. A +successful nightly does not mean production availability or authorize closing +feature tickets. Feature definitions remain in Notion; engineering issues stay +in Linear. The monorepo feature registry owns their links. + +Keep the existing `npm-prerelease` credentials/trusted publishing and configure +`npm-stable` for approved main releases. The private CLI repository retains +`provenance: false` for npm's public source attestation; the embedded acceptance +receipt still supplies contract evidence. Use the existing token fallback when +trusted publishing is unavailable. Do not expose source credentials in packages. + +Run `npm ci`, `npm run verify`, `node --test scripts/release-evidence.test.mjs` +and Actionlint before merge. These checks prove local packaging behavior; hosted +acceptance and npm publication remain separate gates. Roll back an affected +channel to a previously verified CLI and SDK pair through an authorized release +operation. Never replace a published version. diff --git a/eslint.config.js b/eslint.config.js index e720c41..402cfd8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,13 @@ import tseslint from 'typescript-eslint' export default tseslint.config( { - ignores: ['.sdk-source/**', 'coverage/**', 'dist/**', 'node_modules/**'], + ignores: [ + '.sdk-source/**', + '.release/**', + 'coverage/**', + 'dist/**', + 'node_modules/**', + ], }, { linterOptions: {reportUnusedDisableDirectives: 'error'}, diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs new file mode 100644 index 0000000..271320d --- /dev/null +++ b/scripts/prepare-release.mjs @@ -0,0 +1,114 @@ +import console from 'node:console' +import process from 'node:process' +import assert from 'node:assert/strict' +import {createHash} from 'node:crypto' +import {execFileSync} from 'node:child_process' +import {mkdirSync, readFileSync, writeFileSync} from 'node:fs' +import {join, resolve} from 'node:path' +import {registryEvidence, validateSdk} from './release-evidence.mjs' + +try { + assert.equal( + process.env.GITHUB_REF, + 'refs/heads/main', + 'CLI releases run only from main', + ) + const channel = process.env.RELEASE_CHANNEL + assert.ok(['nightly', 'stable'].includes(channel)) + const tag = channel === 'stable' ? 'latest' : 'dev' + const output = resolve(process.env.RELEASE_OUTPUT) + mkdirSync(output, {recursive: true, mode: 0o700}) + const sdk = await registryEvidence('@polymorfa/sdk', tag, output) + assert.ok( + sdk?.provenance, + 'SDK publication with matching API acceptance must finish first', + ) + validateSdk(sdk.provenance, sdk.version, channel) + const files = execFileSync('git', [ + 'ls-files', + '-z', + 'src', + 'bin', + 'scripts', + 'skills', + 'package.json', + 'package-lock.json', + ]) + .toString() + .split('\0') + .filter(Boolean) + .sort() + const digest = createHash('sha256') + for (const file of files) + digest + .update(file + '\0') + .update(readFileSync(file)) + .update('\0') + digest.update(sdk.version).update(sdk.integrity) + const fingerprint = digest.digest('hex') + const previous = await registryEvidence('@polymorfa/cli', tag, output) + if ( + channel === 'nightly' && + previous?.provenance?.fingerprint === fingerprint + ) { + writeFileSync(process.env.GITHUB_OUTPUT, 'publish=false\n', {flag: 'a'}) + console.log('CLI and its exact SDK are already published; skipping nightly') + } else { + const pkg = JSON.parse(readFileSync('package.json')) + const base = pkg.version.split('-')[0] + const version = + channel === 'stable' + ? process.env.STABLE_VERSION + : `${base}-dev.${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}` + assert.match( + version ?? '', + channel === 'stable' + ? /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ + : /^\d+\.\d+\.\d+-dev\.\d+$/, + ) + if (previous?.version === version) + assert.equal( + previous.provenance?.fingerprint, + fingerprint, + 'CLI version belongs to different source', + ) + const sourceSha = execFileSync('git', ['rev-parse', 'HEAD'], { + encoding: 'utf8', + }).trim() + const provenance = { + schemaVersion: 1, + repository: 'polymorfa/cli', + sourceSha, + fingerprint, + channel, + packageVersion: version, + sdkVersion: sdk.version, + sdkIntegrity: sdk.integrity, + sdk: sdk.provenance, + } + writeFileSync( + join(output, 'release-contract.json'), + JSON.stringify(provenance, null, 2) + '\n', + ) + // Exact accepted SDK selection is a package build input; branches remain main. + execFileSync( + 'npm', + [ + 'install', + '--package-lock-only', + '--ignore-scripts', + '--save-exact', + `@polymorfa/sdk@${sdk.version}`, + ], + {stdio: 'inherit'}, + ) + writeFileSync( + process.env.GITHUB_OUTPUT, + `publish=true\nversion=${version}\nsdk=${sdk.version}\n`, + {flag: 'a'}, + ) + } +} catch (error) { + console.error(error.message) + process.exitCode = 1 +} diff --git a/scripts/publish-release.mjs b/scripts/publish-release.mjs new file mode 100644 index 0000000..5933c27 --- /dev/null +++ b/scripts/publish-release.mjs @@ -0,0 +1,115 @@ +import process from 'node:process' +import {setTimeout as delay} from 'node:timers/promises' +import assert from 'node:assert/strict' +import {createHash} from 'node:crypto' +import {execFileSync} from 'node:child_process' +import {readFileSync, writeFileSync} from 'node:fs' +import {join, resolve} from 'node:path' +import {registryEvidence, validateSdk} from './release-evidence.mjs' + +const output = resolve(process.env.RELEASE_OUTPUT) +const provenance = JSON.parse( + readFileSync(join(output, 'release-contract.json')), +) +assert.equal(provenance.repository, 'polymorfa/cli') +assert.equal(provenance.sourceSha, process.env.GITHUB_SHA) +assert.equal(provenance.channel, process.env.RELEASE_CHANNEL) +validateSdk(provenance.sdk, provenance.sdkVersion, provenance.channel) +const version = provenance.packageVersion +assert.match(version, /^\d+\.\d+\.\d+(?:-dev\.\d+)?$/) +const tarball = join(output, `polymorfa-cli-${version}.tgz`) +const integrity = `sha512-${createHash('sha512').update(readFileSync(tarball)).digest('base64')}` +assert.equal( + integrity, + JSON.parse(readFileSync(join(output, 'pack.json')))[0].integrity, +) +assert.deepEqual( + JSON.parse( + execFileSync( + 'tar', + ['-xzOf', tarball, 'package/dist/release-contract.json'], + {encoding: 'utf8'}, + ), + ), + provenance, +) +const packed = JSON.parse( + execFileSync('tar', ['-xzOf', tarball, 'package/package.json'], { + encoding: 'utf8', + }), +) +assert.equal(packed.name, '@polymorfa/cli') +assert.equal(packed.version, version) +assert.equal(packed.dependencies['@polymorfa/sdk'], provenance.sdkVersion) +const sdk = await registryEvidence( + '@polymorfa/sdk', + provenance.sdkVersion, + output, +) +assert.equal(sdk.integrity, provenance.sdkIntegrity) +assert.deepEqual(sdk.provenance, provenance.sdk) +const tag = provenance.channel === 'stable' ? 'latest' : 'dev' +let published = await registryEvidence('@polymorfa/cli', version, output) +if (!published) { + execFileSync( + 'npm', + [ + 'publish', + tarball, + '--tag', + tag, + '--access', + 'public', + '--provenance=false', + ], + {stdio: 'inherit'}, + ) + for (let attempt = 0; attempt < 12; attempt++) { + published = await registryEvidence('@polymorfa/cli', version, output) + if (published) break + await delay(5000) + } +} +assert.equal( + published?.integrity, + integrity, + 'Existing CLI package has different contents', +) +assert.equal( + (await registryEvidence('@polymorfa/cli', tag, output))?.version, + version, + 'CLI dist-tag requires repair before recording completion', +) +const receipt = provenance.sdk.acceptance +const events = (receipt.featureLinks ?? []).map((feature) => { + const stage = + provenance.channel === 'stable' ? 'stable_published' : 'nightly_published' + const identity = { + featureId: feature.id, + deliveryLevel: 'cli', + stage, + sourceSha: receipt.sourceSha, + apiVersion: receipt.apiVersion, + packageVersions: { + '@polymorfa/cli': version, + '@polymorfa/sdk': provenance.sdkVersion, + }, + } + return { + schemaVersion: 1, + eventId: createHash('sha256') + .update(JSON.stringify(identity)) + .digest('hex'), + ...identity, + notionUrl: feature.notionUrl, + linearIssueIds: feature.linearIssueIds, + trackingStatus: feature.trackingStatus, + environment: receipt.environment, + evidenceUrl: receipt.runUrl, + occurredAt: new Date().toISOString(), + } +}) +writeFileSync( + join(output, 'stage-events.json'), + JSON.stringify(events, null, 2) + '\n', +) diff --git a/scripts/release-evidence.mjs b/scripts/release-evidence.mjs new file mode 100644 index 0000000..dc5890a --- /dev/null +++ b/scripts/release-evidence.mjs @@ -0,0 +1,102 @@ +import {Buffer} from 'node:buffer' +import {URL} from 'node:url' +import assert from 'node:assert/strict' +import {createHash} from 'node:crypto' +import {execFileSync} from 'node:child_process' +import {writeFileSync} from 'node:fs' +import {join} from 'node:path' + +const {fetch, AbortSignal} = globalThis + +export function validateSdk(provenance, version, channel) { + assert.equal(provenance.schemaVersion, 1) + assert.equal(provenance.repository, 'polymorfa/sdks') + assert.match(provenance.sourceSha, /^[a-f0-9]{40}$/) + assert.match(provenance.fingerprint, /^[a-f0-9]{64}$/) + assert.equal(provenance.packageVersion, version) + assert.equal(provenance.channel, channel) + assert.match( + version, + channel === 'stable' + ? /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/ + : /^\d+\.\d+\.\d+-dev\.\d+$/, + ) + const receipt = provenance.acceptance + assert.equal(receipt.schemaVersion, 1) + assert.equal(receipt.repository, 'polymorfa/polymorfa') + assert.equal( + receipt.environment, + channel === 'stable' ? 'production' : 'staging', + ) + assert.match(receipt.sourceSha, /^[a-f0-9]{40}$/) + assert.match(receipt.apiVersion, /^\d{4}-\d{2}-\d{2}$/) + assert.equal(receipt.result, 'passed') + assert.ok( + receipt.scenarios?.length && + receipt.scenarios.every((s) => s.result === 'passed'), + ) + assert.equal(receipt.deployment?.commit, receipt.sourceSha) + assert.equal(receipt.deployment?.status, 'finished') + assert.match( + receipt.runUrl, + /^https:\/\/github\.com\/polymorfa\/polymorfa\/actions\/runs\/\d+$/, + ) + assert.ok(Number.isFinite(Date.parse(receipt.acceptedAt))) + assert.deepEqual(Object.keys(receipt.contracts).sort(), [ + 'messaging', + 'platform', + ]) + for (const [name, spec] of Object.entries(receipt.contracts)) { + assert.equal( + spec.sourcePath, + name === 'messaging' + ? 'apps/api/docs/openapi.json' + : 'apps/api/docs/openapi.management.json', + ) + assert.match(spec.sha256, /^[a-f0-9]{64}$/) + } + return provenance +} +export async function registryEvidence(name, tag, output) { + assert.ok(['@polymorfa/sdk', '@polymorfa/cli'].includes(name)) + const response = await fetch( + `https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(tag)}`, + {signal: AbortSignal.timeout(30_000), redirect: 'error'}, + ) + if (response.status === 404) return null + assert.equal(response.status, 200, 'Registry release metadata unavailable') + const pkg = await response.json() + const url = new URL(pkg.dist.tarball) + assert.equal(url.origin, 'https://registry.npmjs.org') + assert.ok(!url.username && !url.password && !url.search && !url.hash) + const tar = await fetch(url, { + signal: AbortSignal.timeout(30_000), + redirect: 'error', + }) + assert.equal(tar.status, 200) + const bytes = Buffer.from(await tar.arrayBuffer()) + assert.ok(bytes.length < 32 * 1024 * 1024) + assert.equal( + `sha512-${createHash('sha512').update(bytes).digest('base64')}`, + pkg.dist.integrity, + ) + const archive = join( + output, + name === '@polymorfa/sdk' ? 'accepted-sdk.tgz' : 'published-cli.tgz', + ) + writeFileSync(archive, bytes, {mode: 0o600}) + const entries = execFileSync('tar', ['-tzf', archive], { + encoding: 'utf8', + }).split('\n') + const provenance = entries.includes('package/dist/release-contract.json') + ? JSON.parse( + execFileSync( + 'tar', + ['-xzOf', archive, 'package/dist/release-contract.json'], + {encoding: 'utf8', maxBuffer: 2 * 1024 * 1024}, + ), + ) + : null + if (provenance) assert.equal(provenance.packageVersion, pkg.version) + return {version: pkg.version, integrity: pkg.dist.integrity, provenance} +} diff --git a/scripts/release-evidence.test.mjs b/scripts/release-evidence.test.mjs new file mode 100644 index 0000000..12b863c --- /dev/null +++ b/scripts/release-evidence.test.mjs @@ -0,0 +1,63 @@ +import {test} from 'node:test' +import assert from 'node:assert/strict' +import {validateSdk} from './release-evidence.mjs' +const sdk = { + schemaVersion: 1, + repository: 'polymorfa/sdks', + sourceSha: 'a'.repeat(40), + fingerprint: 'b'.repeat(64), + channel: 'nightly', + packageVersion: '0.1.0-dev.20261004020000', + acceptance: { + schemaVersion: 1, + repository: 'polymorfa/polymorfa', + sourceSha: 'c'.repeat(40), + apiVersion: '2026-09-22', + environment: 'staging', + result: 'passed', + scenarios: [{result: 'passed'}], + deployment: {commit: 'c'.repeat(40), status: 'finished'}, + runUrl: 'https://github.com/polymorfa/polymorfa/actions/runs/123', + acceptedAt: '2026-10-04T02:00:00Z', + contracts: { + messaging: { + sourcePath: 'apps/api/docs/openapi.json', + sha256: 'd'.repeat(64), + }, + platform: { + sourcePath: 'apps/api/docs/openapi.management.json', + sha256: 'e'.repeat(64), + }, + }, + }, +} +test('CLI pins an exact SDK with the matching environment acceptance', () => { + assert.equal(validateSdk(sdk, sdk.packageVersion, 'nightly'), sdk) + assert.throws(() => validateSdk(sdk, '0.1.0-dev.1', 'nightly')) + assert.throws(() => validateSdk(sdk, sdk.packageVersion, 'stable')) + const production = { + ...sdk, + channel: 'stable', + packageVersion: '0.1.0', + acceptance: {...sdk.acceptance, environment: 'production'}, + } + assert.equal(validateSdk(production, '0.1.0', 'stable'), production) + assert.throws(() => + validateSdk({...production, acceptance: sdk.acceptance}, '0.1.0', 'stable'), + ) +}) +test('an installed SDK or healthy API without native route acceptance is insufficient', () => { + for (const delta of [ + {result: 'failed'}, + {scenarios: []}, + {deployment: {commit: 'f'.repeat(40), status: 'finished'}}, + {contracts: {}}, + ]) + assert.throws(() => + validateSdk( + {...sdk, acceptance: {...sdk.acceptance, ...delta}}, + sdk.packageVersion, + 'nightly', + ), + ) +})