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);