From dfb5abb67aebfbbf791fe16990d6c336ae427b88 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Tue, 18 Aug 2026 18:29:48 +0800 Subject: [PATCH] fix(release): keep npm channels monotonic Stable publication advances latest but cannot update next through trusted publishing. Require next to remain at or ahead of latest, and document the interactive owner step without adding a registry token to Actions. Use the full JSON representation for single-version registry metadata so Finalize does not fail with npm's 406 response to the abbreviated packument media type. Generated-by: Codex --- .github/workflows/release-cli-stage.yml | 7 ++ docs/cli-npm-release.md | 26 +++- docs/cli-npm-release.zh-CN.md | 25 +++- scripts/release-cli-publication.mjs | 118 +++++++++++++++---- scripts/release-cli-publication.test.mjs | 50 +++++++- scripts/release-cli-workflow-policy.test.mjs | 3 + 6 files changed, 197 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release-cli-stage.yml b/.github/workflows/release-cli-stage.yml index 62f0ca095e..7e09cc1a45 100644 --- a/.github/workflows/release-cli-stage.yml +++ b/.github/workflows/release-cli-stage.yml @@ -95,6 +95,7 @@ jobs: retention-days: 30 - name: Record the post-staging approval step env: + RELEASE_DIST_TAG: ${{ steps.release.outputs.dist_tag }} RELEASE_VERSION: ${{ steps.release.outputs.version }} RELEASE_RUN_ID: ${{ github.run_id }} RELEASE_RUN_ATTEMPT: ${{ github.run_attempt }} @@ -108,6 +109,12 @@ jobs: echo "- stage run ID: \`$RELEASE_RUN_ID\`" echo "- stage run attempt: \`$RELEASE_RUN_ATTEMPT\`" echo "- version: \`$RELEASE_VERSION\`" + if [[ "$RELEASE_DIST_TAG" == "latest" ]]; then + echo + echo "Before finalizing this stable release, inspect npm dist-tags. If \`next\` is older than \`latest\`, authenticate interactively with npm and run:" + echo + echo "\`npm dist-tag add \"maka-agent@$RELEASE_VERSION\" next --registry https://registry.npmjs.org/\`" + fi } >> "$GITHUB_STEP_SUMMARY" - name: Submit the candidate to npm staging env: diff --git a/docs/cli-npm-release.md b/docs/cli-npm-release.md index a0f50fbb4b..37964e6833 100644 --- a/docs/cli-npm-release.md +++ b/docs/cli-npm-release.md @@ -9,7 +9,9 @@ validated by the Stage workflow. ## Release invariants - Dispatch release workflows only from `main`. -- Publish prereleases under `next` and stable versions under `latest`. +- Publish prereleases under `next` and stable versions under `latest`. `next` must never resolve to + a version older than `latest`; when no newer prerelease exists, both tags point to the stable + version. - Use the Git tag `cli-v`; CLI releases never replace the Desktop GitHub Latest release. - Do not run `npm publish`. GitHub Actions may only run `npm stage publish`; a human package maintainer approves the staged package with npm 2FA. @@ -118,14 +120,32 @@ npm stage approve "$stage_id" --registry https://registry.npmjs.org/ The same review and approval can be performed from the package's **Staged Packages** page on npmjs.com. +For a stable release, inspect the public tags after approval: + +```sh +version=0.1.0 +npm view maka-agent dist-tags --json --registry https://registry.npmjs.org/ +``` + +If `next` is absent or older than `latest`, authenticate interactively as an npm package owner and +advance it to the new stable version before running Finalize: + +```sh +npm dist-tag add "maka-agent@$version" next --registry https://registry.npmjs.org/ +``` + +Do not change `next` when it already points to a newer version such as `0.2.0-beta.1`. This step is +intentionally manual: npm Trusted Publishing authenticates `npm publish` and `npm stage publish`, +not dist-tag mutations, and the release workflows must not gain a long-lived npm token. + ## Finalize the public release After npm reports the version as public: 1. Open **Actions → Finalize CLI npm release → Run workflow** on `main`. 2. Enter the successful Stage run ID, its exact run attempt, and the version. -3. Let the inspection job verify the public tarball bytes, checksum, inventory, dist-tag, npm - signature, and Trusted Publishing provenance. +3. Let the inspection job verify the public tarball bytes, checksum, inventory, npm signature, + Trusted Publishing provenance, the release dist-tag, and that `next` is not older than `latest`. 4. Review and approve the `npm-release` Environment deployment for the Git tag and GitHub Release. 5. Confirm the workflow created `cli-v` at the Stage source commit. A prerelease must be marked prerelease; no CLI release may become the repository's GitHub Latest release. diff --git a/docs/cli-npm-release.zh-CN.md b/docs/cli-npm-release.zh-CN.md index f12d03c85b..b0ba7425f1 100644 --- a/docs/cli-npm-release.zh-CN.md +++ b/docs/cli-npm-release.zh-CN.md @@ -8,7 +8,8 @@ ## 发布不变量 - 只从 `main` dispatch 发布 workflow; -- 预发布版本使用 `next`,稳定版本使用 `latest`; +- 预发布版本使用 `next`,稳定版本使用 `latest`;`next` 不得指向比 `latest` 更旧的版本;没有 + 更新的预发布版本时,两个 tag 都指向稳定版; - Git tag 使用 `cli-v`;CLI release 不得替换 Desktop 的 GitHub Latest release; - 不运行 `npm publish`。GitHub Actions 只能运行 `npm stage publish`,由人工 package maintainer 使用 npm 2FA 批准 staged package; @@ -114,14 +115,32 @@ npm stage approve "$stage_id" --registry https://registry.npmjs.org/ 也可以在 npmjs.com package 的 **Staged Packages** 页面完成相同的检查和批准。 +稳定版获得批准后,检查公共 dist-tags: + +```sh +version=0.1.0 +npm view maka-agent dist-tags --json --registry https://registry.npmjs.org/ +``` + +如果 `next` 不存在或比 `latest` 更旧,使用 npm package owner 身份进行交互式认证,并在运行 +Finalize 前将其推进到新的稳定版: + +```sh +npm dist-tag add "maka-agent@$version" next --registry https://registry.npmjs.org/ +``` + +如果 `next` 已经指向 `0.2.0-beta.1` 之类的更新版本,则不要修改。此步骤有意保留为人工操作: +npm Trusted Publishing 只认证 `npm publish` 和 `npm stage publish`,不认证 dist-tag 变更,而 +release workflow 不得获得长期 npm token。 + ## Finalize 公共发布 npm 显示该版本已经公开后: 1. 在 `main` 上打开 **Actions → Finalize CLI npm release → Run workflow**; 2. 输入成功 Stage 的 run ID、精确 run attempt 和 version; -3. 让 inspection job 验证公共 tarball 字节、checksum、inventory、dist-tag、npm signature 和 - Trusted Publishing provenance; +3. 让 inspection job 验证公共 tarball 字节、checksum、inventory、npm signature、Trusted + Publishing provenance、发布 dist-tag,并确认 `next` 不比 `latest` 更旧; 4. 审查并批准用于 Git tag 和 GitHub Release 的 `npm-release` Environment deployment; 5. 确认 workflow 在 Stage source commit 上创建了 `cli-v`。预发布版本必须标记为 prerelease;任何 CLI release 都不得成为仓库的 GitHub Latest release。 diff --git a/scripts/release-cli-publication.mjs b/scripts/release-cli-publication.mjs index c102f6ad63..e1f9c4f694 100644 --- a/scripts/release-cli-publication.mjs +++ b/scripts/release-cli-publication.mjs @@ -22,30 +22,35 @@ const RELEASE_RECORD_KEYS = [ ]; 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}`); - } + const { prerelease } = parseReleaseSemver(version); return { version, - distTag: prerelease ? 'next' : 'latest', + distTag: prerelease.length > 0 ? 'next' : 'latest', gitTag: `cli-v${version}`, tarball: `${PACKAGE_NAME}-${version}.tgz`, }; } +export function validateRegistryChannels({ releaseVersion, releaseDistTag, distTags }) { + if (!distTags || typeof distTags !== 'object' || Array.isArray(distTags)) { + throw new Error('Registry package metadata has no valid dist-tags'); + } + if (distTags[releaseDistTag] !== releaseVersion) { + throw new Error(`Registry dist-tag ${releaseDistTag} does not point to ${releaseVersion}`); + } + + const latest = distTags.latest; + const next = distTags.next; + if (releaseDistTag === 'latest' && typeof next !== 'string') { + throw channelLagError({ releaseVersion, releaseDistTag, latest, next }); + } + if (typeof latest === 'string' && typeof next === 'string') { + if (compareReleaseSemver(next, latest) < 0) { + throw channelLagError({ releaseVersion, releaseDistTag, latest, next }); + } + } +} + export function prepareStageRelease({ repoRoot, releaseDirectory, @@ -122,14 +127,21 @@ export async function fetchRegistryRelease({ }) { const record = loadReleaseRecord(releaseDirectory); const versionUrl = `${REGISTRY_ORIGIN}/${PACKAGE_NAME}/${encodeURIComponent(record.version)}`; - const metadata = await fetchJson(fetchImpl, versionUrl, 'package version metadata'); + const metadata = await fetchJson( + fetchImpl, + versionUrl, + 'package version metadata', + 'application/json', + ); 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}`); - } + validateRegistryChannels({ + releaseVersion: record.version, + releaseDistTag: record.distTag, + distTags: tags['dist-tags'], + }); const tarballUrl = parseRegistryTarballUrl(metadata.dist?.tarball, record.tarball); const response = await fetchImpl(tarballUrl, { redirect: 'error' }); if (!response.ok) { @@ -304,9 +316,69 @@ function validateSourceIdentity({ sourceSha, runId, runAttempt, repository, work } } -async function fetchJson(fetchImpl, url, label) { +function parseReleaseSemver(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]?.split('.') ?? []; + if ( + prerelease.some( + (identifier) => /^\d+$/u.test(identifier) && identifier.length > 1 && identifier[0] === '0', + ) + ) { + throw new Error(`Expected a valid CLI release version; found ${version}`); + } + return { + core: [BigInt(match[1]), BigInt(match[2]), BigInt(match[3])], + prerelease, + }; +} + +function compareReleaseSemver(left, right) { + const a = parseReleaseSemver(left); + const b = parseReleaseSemver(right); + for (let index = 0; index < a.core.length; index += 1) { + if (a.core[index] < b.core[index]) return -1; + if (a.core[index] > b.core[index]) return 1; + } + if (a.prerelease.length === 0) return b.prerelease.length === 0 ? 0 : 1; + if (b.prerelease.length === 0) return -1; + for (let index = 0; index < Math.max(a.prerelease.length, b.prerelease.length); index += 1) { + const leftIdentifier = a.prerelease[index]; + const rightIdentifier = b.prerelease[index]; + if (leftIdentifier === undefined) return -1; + if (rightIdentifier === undefined) return 1; + if (leftIdentifier === rightIdentifier) continue; + const leftNumeric = /^\d+$/u.test(leftIdentifier); + const rightNumeric = /^\d+$/u.test(rightIdentifier); + if (leftNumeric && rightNumeric) { + return BigInt(leftIdentifier) < BigInt(rightIdentifier) ? -1 : 1; + } + if (leftNumeric) return -1; + if (rightNumeric) return 1; + return leftIdentifier < rightIdentifier ? -1 : 1; + } + return 0; +} + +function channelLagError({ releaseVersion, releaseDistTag, latest, next }) { + const current = typeof next === 'string' ? next : 'missing'; + if (releaseDistTag === 'next') { + return new Error( + `Registry next dist-tag (${current}) is behind latest (${latest}); prerelease ${releaseVersion} cannot advance the next channel`, + ); + } + return new Error( + `Registry next dist-tag (${current}) is behind the latest release. Before finalizing, authenticate interactively with npm and run: npm dist-tag add "${PACKAGE_NAME}@${releaseVersion}" next --registry ${REGISTRY_ORIGIN}/`, + ); +} + +async function fetchJson(fetchImpl, url, label, accept = 'application/vnd.npm.install-v1+json') { const response = await fetchImpl(url, { - headers: { accept: 'application/vnd.npm.install-v1+json' }, + headers: { accept }, redirect: 'error', }); if (!response.ok) diff --git a/scripts/release-cli-publication.test.mjs b/scripts/release-cli-publication.test.mjs index 17e5b090f4..c0af65092e 100644 --- a/scripts/release-cli-publication.test.mjs +++ b/scripts/release-cli-publication.test.mjs @@ -11,6 +11,7 @@ import { prepareSignatureAuditTree, prepareStageRelease, validateGitHubRelease, + validateRegistryChannels, validateSignatureAudit, validateStageRun, } from './release-cli-publication.mjs'; @@ -31,6 +32,47 @@ test('release versions map prereleases and stable versions to distinct channels' } }); +test('release channels never leave next behind latest', () => { + for (const next of ['0.1.0', '0.2.0-beta.1']) { + assert.doesNotThrow(() => + validateRegistryChannels({ + releaseVersion: '0.1.0', + releaseDistTag: 'latest', + distTags: { latest: '0.1.0', next }, + }), + ); + } + + for (const next of [undefined, '0.1.0-beta.1']) { + assert.throws( + () => + validateRegistryChannels({ + releaseVersion: '0.1.0', + releaseDistTag: 'latest', + distTags: { latest: '0.1.0', ...(next ? { next } : {}) }, + }), + /npm dist-tag add "maka-agent@0\.1\.0" next/u, + ); + } + + assert.doesNotThrow(() => + validateRegistryChannels({ + releaseVersion: '0.2.0-beta.1', + releaseDistTag: 'next', + distTags: { latest: '0.1.0', next: '0.2.0-beta.1' }, + }), + ); + assert.throws( + () => + validateRegistryChannels({ + releaseVersion: '0.1.0-beta.2', + releaseDistTag: 'next', + distTags: { latest: '0.1.0', next: '0.1.0-beta.2' }, + }), + /cannot advance the next channel/u, + ); +}); + test('stage records bind the checked candidate to one source workflow run', () => { const fixture = createCandidate(); const prepared = prepareStageRelease({ @@ -167,8 +209,8 @@ test('registry downloads stop reading as soon as the tarball exceeds its bound', 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); + const fetchImpl = async (input, options) => { + if (String(input) !== tarballUrl) return fallback(input, options); return new Response( new ReadableStream({ pull(controller) { @@ -393,9 +435,10 @@ 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) => { + return async (input, options = {}) => { const url = String(input); if (url === `https://registry.npmjs.org/maka-agent/${fixture.version}`) { + assert.equal(options.headers?.accept, 'application/json'); return Response.json({ name: 'maka-agent', version: fixture.version, @@ -403,6 +446,7 @@ function registryFetch({ fixture, bytes = fixture.bytes }) { }); } if (url === 'https://registry.npmjs.org/maka-agent') { + assert.equal(options.headers?.accept, 'application/vnd.npm.install-v1+json'); return Response.json({ 'dist-tags': { next: fixture.version } }); } if (url === tarballUrl) return new Response(bytes); diff --git a/scripts/release-cli-workflow-policy.test.mjs b/scripts/release-cli-workflow-policy.test.mjs index 19b3882e54..a0aa552a66 100644 --- a/scripts/release-cli-workflow-policy.test.mjs +++ b/scripts/release-cli-workflow-policy.test.mjs @@ -36,6 +36,9 @@ test('stage consumes the validated artifact and makes provenance staging the fin /artifact-ids: \$\{\{ needs\.validate\.outputs\.release_candidate_artifact_id \}\}/u, ); assert.match(workflow, /RELEASE_RUN_ATTEMPT/u); + const guidance = namedStep(steps, 'Record the post-staging approval step'); + assert.match(guidance, /if \[\[ "\$RELEASE_DIST_TAG" == "latest" \]\]/u); + assert.match(guidance, /npm dist-tag add/u); const submit = namedStep(steps, 'Submit the candidate to npm staging'); assert.equal(steps.at(-1), submit); assert.match(submit, /npm stage publish/u);