Skip to content

Commit 8f37edb

Browse files
committed
fix(release): count the -dev bump under its parent's CI and harden the plan
The workflow's -dev bump is pushed with GITHUB_TOKEN, so it never gets a CI run. A main commit with no CI run now stands on its parent's CI when it only rewrites version fields, so nightlies and stable releases keep working after a release. A stable tag waits up to 20 minutes for its commit's CI, the npm nightly dist-tag also blocks a second nightly that UTC day, version patterns reject leading zeros, and the approval test pins the publish condition exactly.
1 parent ea0388c commit 8f37edb

6 files changed

Lines changed: 100 additions & 18 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,8 @@ jobs:
3737
plan:
3838
name: Plan
3939
runs-on: ubuntu-latest
40-
timeout-minutes: 5
40+
# A stable tag waits up to 20 minutes for its commit's CI to finish.
41+
timeout-minutes: 25
4142
permissions:
4243
actions: read
4344
contents: read

‎scripts/__tests__/release-plan.test.ts‎

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import assert from 'node:assert/strict';
22
import { test } from 'vitest';
3-
import { planRelease } from '../release-plan.mjs';
3+
import { isVersionOnlyCommit, planRelease } from '../release-plan.mjs';
44

55
const MAIN = 'a'.repeat(40);
66
const NIGHTLY_COMMIT = 'b'.repeat(40);
@@ -44,6 +44,8 @@ test('a scheduled nightly waits for new commits, a new day, and green CI', () =>
4444
assert.match(planRelease(run({ sha: NIGHTLY_COMMIT })).reason, /already ships/);
4545
assert.match(planRelease(run({ today: '20261010' })).reason, /shipped today/);
4646
assert.match(planRelease(run({ ciConclusion: 'in_progress' })).reason, /is in_progress/);
47+
const published = run({ publishedNightly: '0.21.25-nightly.20261011.40' });
48+
assert.match(planRelease(published).reason, /20261011\.40 shipped today/);
4749
});
4850

4951
test('a dispatched nightly skips the cadence checks but still requires green CI', () => {
@@ -78,3 +80,38 @@ test('a stable tag publishes its own commit once it is on main and its CI passed
7880
test('releases refuse to run from any other branch', () => {
7981
assert.throws(() => planRelease(run({ ref: 'refs/heads/feature' })), /main or a vX\.Y\.Z tag/);
8082
});
83+
84+
test("the workflow's -dev bump stands on its parent's CI, and nothing else does", () => {
85+
const bump = {
86+
parents: [{ sha: MAIN }],
87+
files: [
88+
{
89+
filename: 'package.json',
90+
patch: '@@ -2 +2 @@\n- "version": "0.21.25",\n+ "version": "0.21.26-dev",',
91+
},
92+
{
93+
filename: 'packages/proxy/package.json',
94+
patch: '- "version": "0.21.25",\n+ "version": "0.21.26-dev",',
95+
},
96+
{
97+
filename: 'server.json',
98+
patch: '- "version": "0.21.25"\n+ "version": "0.21.26-dev"',
99+
},
100+
],
101+
};
102+
assert.equal(isVersionOnlyCommit(bump), true);
103+
const dependency = {
104+
...bump,
105+
files: [
106+
{ filename: 'package.json', patch: '- "undici": "7.29.0",\n+ "undici": "8.0.0",' },
107+
],
108+
};
109+
assert.equal(isVersionOnlyCommit(dependency), false);
110+
const source = { ...bump, files: [...bump.files, { filename: 'src/index.ts', patch: '+x' }] };
111+
assert.equal(isVersionOnlyCommit(source), false);
112+
assert.equal(
113+
isVersionOnlyCommit({ ...bump, parents: [{ sha: MAIN }, { sha: NIGHTLY_COMMIT }] }),
114+
false,
115+
);
116+
assert.equal(isVersionOnlyCommit({ ...bump, files: [] }), false);
117+
});

‎scripts/__tests__/release-version.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ test('a nightly is refused unless main carries a -dev version', () => {
1919
assert.throws(() => nightlyVersion('0.21.25', '20261010', 1), /X\.Y\.Z-dev/);
2020
assert.throws(() => nightlyVersion('0.21.25-dev', '2026-10-10', 1), /YYYYMMDD/);
2121
assert.throws(() => nightlyVersion('0.21.25-dev', '20261010', 0), /positive integer/);
22+
assert.throws(() => nightlyVersion('0.21.025-dev', '20261010', 1), /X\.Y\.Z-dev/);
2223
});
2324

2425
test('the latest nightly orders by base, then date, then run number', () => {
@@ -54,4 +55,5 @@ test('release versions compare numerically per segment', () => {
5455
assert.ok(compareReleaseVersions('1.0.0', '0.99.99') > 0);
5556
assert.equal(compareReleaseVersions('0.21.25', '0.21.25'), 0);
5657
assert.throws(() => compareReleaseVersions('0.21.25-dev', '0.21.25'), /release version/);
58+
assert.throws(() => compareReleaseVersions('01.2.3', '0.21.25'), /release version/);
5759
});

‎scripts/release-plan.mjs‎

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,11 @@ function planNightly(run) {
3434
if (scheduled && latest && run.tags.get(latest) === run.sha) {
3535
return skip(`${latest} already ships ${run.sha}.`);
3636
}
37-
if (scheduled && latest?.includes(`-nightly.${run.today}.`)) {
38-
return skip(`${latest} shipped today.`);
39-
}
37+
// The npm dist-tag is the durable record: a nightly can publish and then fail to tag.
38+
const shippedToday = [latest, run.publishedNightly].find((shipped) =>
39+
shipped?.includes(`-nightly.${run.today}.`),
40+
);
41+
if (scheduled && shippedToday) return skip(`${shippedToday} shipped today.`);
4042
if (run.ciConclusion !== 'success') {
4143
const reason = `CI on ${run.sha} is ${run.ciConclusion}; a nightly ships only a commit whose CI passed.`;
4244
if (scheduled) return skip(reason);
@@ -66,6 +68,28 @@ function planStable(run, tag) {
6668
return { mode: 'stable', version, distTag: 'latest', commit: run.sha, previousTag };
6769
}
6870

71+
const VERSION_FILE = /^(packages\/[^/]+\/)?package\.json$|^server\.json$/;
72+
73+
/**
74+
* Whether a single-parent commit only rewrites `"version"` fields, as the release workflow's
75+
* `-dev` bump does. Its push runs no workflows, so the parent's CI stands for it.
76+
*/
77+
export function isVersionOnlyCommit(commit) {
78+
return (
79+
commit.parents.length === 1 &&
80+
commit.files.length > 0 &&
81+
commit.files.every(
82+
(file) =>
83+
VERSION_FILE.test(file.filename) &&
84+
typeof file.patch === 'string' &&
85+
file.patch
86+
.split('\n')
87+
.filter((line) => /^[+-]/.test(line))
88+
.every((line) => /^[+-]\s*"version": "[^"]*",?$/.test(line)),
89+
)
90+
);
91+
}
92+
6993
function skip(reason) {
7094
return { mode: 'skip', reason };
7195
}
@@ -93,22 +117,34 @@ function remoteTags() {
93117
return tags;
94118
}
95119

96-
async function ciConclusion(env) {
120+
async function ciConclusion(env, sha) {
97121
const { workflow_runs: runs } = await github(
98122
env,
99-
`actions/workflows/ci.yml/runs?head_sha=${env.GITHUB_SHA}&event=push&per_page=1`,
123+
`actions/workflows/ci.yml/runs?head_sha=${sha}&event=push&per_page=1`,
100124
);
101-
if (runs.length === 0) return 'missing';
102-
return runs[0].status === 'completed' ? runs[0].conclusion : runs[0].status;
125+
if (runs.length > 0) return runs[0].status === 'completed' ? runs[0].conclusion : runs[0].status;
126+
const commit = await github(env, `commits/${sha}`);
127+
return isVersionOnlyCommit(commit) ? ciConclusion(env, commit.parents[0].sha) : 'missing';
128+
}
129+
130+
/** A tag pushed right after a merge waits, within the Plan job's budget, for that merge's CI. */
131+
async function settledCiConclusion(env, sha) {
132+
for (let attempt = 0; ; attempt += 1) {
133+
const conclusion = await ciConclusion(env, sha);
134+
if (!['queued', 'in_progress', 'waiting', 'pending'].includes(conclusion) || attempt >= 40) {
135+
return conclusion;
136+
}
137+
await new Promise((resolve) => setTimeout(resolve, 30_000));
138+
}
103139
}
104140

105141
async function isOnMain(env) {
106142
const { status } = await github(env, `compare/main...${env.GITHUB_SHA}`);
107143
return status === 'identical' || status === 'behind';
108144
}
109145

110-
async function latestStable() {
111-
const response = await fetch('https://registry.npmjs.org/agent-device/latest');
146+
async function publishedVersion(distTag) {
147+
const response = await fetch(`https://registry.npmjs.org/agent-device/${distTag}`);
112148
if (response.status === 404) return null;
113149
if (!response.ok) throw new Error(`npm registry lookup failed: ${response.status}`);
114150
return (await response.json()).version;
@@ -138,8 +174,13 @@ async function readRun(env) {
138174
today: new Date().toISOString().slice(0, 10).replaceAll('-', ''),
139175
runNumber: env.GITHUB_RUN_NUMBER,
140176
tags: event === 'pull_request' ? new Map() : remoteTags(),
141-
latestStable: onBranch || onTag ? await latestStable() : null,
142-
ciConclusion: onBranch || onTag ? await ciConclusion(env) : undefined,
177+
latestStable: onBranch || onTag ? await publishedVersion('latest') : null,
178+
publishedNightly: onBranch ? await publishedVersion('nightly') : null,
179+
ciConclusion: onTag
180+
? await settledCiConclusion(env, env.GITHUB_SHA)
181+
: onBranch
182+
? await ciConclusion(env, env.GITHUB_SHA)
183+
: undefined,
143184
onMain: onTag ? await isOnMain(env) : undefined,
144185
};
145186
}

‎scripts/release-version.mjs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import { pathToFileURL } from 'node:url';
77
// it is building towards; a nightly publishes that base as `X.Y.Z-nightly.<YYYYMMDD>.<run>`; a
88
// stable release publishes a tagged commit on `main` as `X.Y.Z`. Registry scanners diff the tool surface
99
// per version string, so neither `main` nor a nightly may ever carry a published stable version.
10-
const RELEASE = /^(\d+)\.(\d+)\.(\d+)$/;
11-
const DEVELOPMENT = /^(\d+)\.(\d+)\.(\d+)-dev$/;
12-
const NIGHTLY = /^(\d+)\.(\d+)\.(\d+)-nightly\.(\d{8})\.(\d+)$/;
10+
const N = String.raw`(0|[1-9]\d*)`;
11+
const RELEASE = new RegExp(`^${N}\\.${N}\\.${N}$`);
12+
const DEVELOPMENT = new RegExp(`^${N}\\.${N}\\.${N}-dev$`);
13+
const NIGHTLY = new RegExp(`^${N}\\.${N}\\.${N}-nightly\\.(\\d{8})\\.([1-9]\\d*)$`);
1314

1415
export function isReleaseVersion(version) {
1516
return RELEASE.test(version);

‎test/ci/release-workflow.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ test('a stable publish waits for the release environment approval', () => {
4949
expect(workflow.jobs.approve?.environment).toBe('release');
5050
expect(workflow.jobs.approve?.if).toBe("needs.plan.outputs.mode == 'stable'");
5151
expect(workflow.jobs.publish?.needs).toContain('approve');
52-
expect(workflow.jobs.publish?.if).toMatch(
53-
/needs\.plan\.outputs\.mode == 'nightly' \|\| needs\.approve\.result == 'success'/,
52+
expect(workflow.jobs.publish?.if?.replaceAll(/\s+/g, ' ').trim()).toBe(
53+
"!cancelled() && needs.build.result == 'success' && (needs.plan.outputs.mode == 'nightly' || needs.approve.result == 'success')",
5454
);
5555
});

0 commit comments

Comments
 (0)