Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/release-cli-stage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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:"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Missing next tag omitted 🐞 Bug ≡ Correctness

Fix-now: The Stage summary tells maintainers to advance next only when it is older than latest,
omitting the absent-tag case that deterministically causes Finalize to fail. This contradicts both
the validator and the newly added runbook instructions.
Agent Prompt
## Issue description
The generated stable-release guidance only instructs maintainers to update `next` when it is older than `latest`. Finalize also rejects a missing `next` tag, so the workflow summary must cover both conditions.

## Issue Context
The English and Chinese runbooks already use the correct “absent or older” wording. Reuse that wording rather than introducing new behavior, state, or configuration.

## Fix Focus Areas
- .github/workflows/release-cli-stage.yml[112-116]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

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:
Expand Down
26 changes: 23 additions & 3 deletions docs/cli-npm-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<version>`; 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.
Expand Down Expand Up @@ -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<version>` at the Stage source commit. A prerelease must be
marked prerelease; no CLI release may become the repository's GitHub Latest release.
Expand Down
25 changes: 22 additions & 3 deletions docs/cli-npm-release.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
## 发布不变量

- 只从 `main` dispatch 发布 workflow;
- 预发布版本使用 `next`,稳定版本使用 `latest`;
- 预发布版本使用 `next`,稳定版本使用 `latest`;`next` 不得指向比 `latest` 更旧的版本;没有
更新的预发布版本时,两个 tag 都指向稳定版;
- Git tag 使用 `cli-v<version>`;CLI release 不得替换 Desktop 的 GitHub Latest release;
- 不运行 `npm publish`。GitHub Actions 只能运行 `npm stage publish`,由人工 package
maintainer 使用 npm 2FA 批准 staged package;
Expand Down Expand Up @@ -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<version>`。预发布版本必须标记为
prerelease;任何 CLI release 都不得成为仓库的 GitHub Latest release。
Expand Down
118 changes: 95 additions & 23 deletions scripts/release-cli-publication.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
50 changes: 47 additions & 3 deletions scripts/release-cli-publication.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
prepareSignatureAuditTree,
prepareStageRelease,
validateGitHubRelease,
validateRegistryChannels,
validateSignatureAudit,
validateStageRun,
} from './release-cli-publication.mjs';
Expand All @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -393,16 +435,18 @@ 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,
dist: { tarball: tarballUrl, integrity, shasum },
});
}
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);
Expand Down
3 changes: 3 additions & 0 deletions scripts/release-cli-workflow-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading