Skip to content

Commit 9c6ef86

Browse files
committed
feat(release): release a tagged commit on main, after its CI passed
A stable release starts from a vX.Y.Z tag on main that a repository admin creates, normally by publishing a GitHub release targeting main. The stable run now also requires green CI on that commit. This replaces the promote job and the GITHUB_TOKEN tag it created. Nightly tags move to nightly/v*, outside the admin-only v* tags and the npm-publish tag policy.
1 parent 3d4acc1 commit 9c6ef86

5 files changed

Lines changed: 42 additions & 98 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 5 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ name: Release
33
# One workflow owns every npm publish, so each package trusts exactly this file
44
# (npm trusted publishing pins the workflow filename and the npm-publish
55
# environment). scripts/release-plan.mjs maps the triggering event to a mode:
6-
# dry-run (PR), nightly (schedule or dispatch), promote (dispatch stable), or
7-
# stable (a vX.Y.Z tag, created by promote or pushed by a maintainer).
6+
# dry-run (PR), nightly (schedule or dispatch on main), or stable (a vX.Y.Z tag on
7+
# main, which only a repository admin can create, normally by publishing a GitHub
8+
# release).
89
on:
910
pull_request:
1011
paths:
@@ -20,28 +21,15 @@ on:
2021
# Retried every few hours: a nightly waits for green CI on main's HEAD and
2122
# publishes at most once per UTC day.
2223
- cron: '23 */3 * * *'
24+
# On main: publish a nightly now. On a vX.Y.Z tag: retry that stable release.
2325
workflow_dispatch:
24-
inputs:
25-
channel:
26-
description: nightly publishes main now; stable promotes the latest nightly.
27-
required: true
28-
default: nightly
29-
type: choice
30-
options:
31-
- nightly
32-
- stable
33-
version:
34-
description: Stable version override (X.Y.Z). Defaults to the version the latest nightly previews.
35-
required: false
36-
type: string
3726

3827
permissions: {}
3928

4029
concurrency:
4130
group: >-
4231
${{ github.event_name == 'pull_request' && format('release-pr-{0}', github.ref)
4332
|| startsWith(github.ref, 'refs/tags/') && format('release-{0}', github.ref)
44-
|| inputs.channel == 'stable' && 'release-promote'
4533
|| 'release-nightly' }}
4634
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
4735

@@ -74,41 +62,8 @@ jobs:
7462
name: Plan release
7563
env:
7664
GITHUB_TOKEN: ${{ github.token }}
77-
RELEASE_CHANNEL: ${{ inputs.channel }}
78-
RELEASE_VERSION: ${{ inputs.version }}
7965
run: node scripts/release-plan.mjs
8066

81-
promote:
82-
name: Promote ${{ needs.plan.outputs.version }}
83-
needs: plan
84-
if: needs.plan.outputs.mode == 'promote'
85-
runs-on: ubuntu-latest
86-
timeout-minutes: 5
87-
permissions:
88-
actions: write
89-
contents: write
90-
steps:
91-
# A run started by GITHUB_TOKEN's tag push would not trigger; dispatching
92-
# on the tag gives the stable run a github.sha (and npm provenance) of
93-
# exactly the promoted commit.
94-
- name: Tag the promoted nightly and start its stable release
95-
env:
96-
GH_TOKEN: ${{ github.token }}
97-
TAG: v${{ needs.plan.outputs.version }}
98-
COMMIT: ${{ needs.plan.outputs.commit }}
99-
run: |
100-
set -euo pipefail
101-
# Idempotent, so re-running a failed dispatch reuses the tag it already created.
102-
tagged="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --jq .object.sha 2>/dev/null || true)"
103-
if [ -z "$tagged" ]; then
104-
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$COMMIT"
105-
elif [ "$tagged" != "$COMMIT" ]; then
106-
echo "$TAG already points at $tagged, not $COMMIT." >&2
107-
exit 1
108-
fi
109-
gh workflow run release.yml --repo "$GITHUB_REPOSITORY" --ref "$TAG"
110-
echo "Started the $TAG release from $COMMIT." >> "$GITHUB_STEP_SUMMARY"
111-
11267
approve:
11368
name: Approve ${{ needs.plan.outputs.version }}
11469
needs: plan
@@ -217,7 +172,7 @@ jobs:
217172
- name: Tag the published commit
218173
env:
219174
GH_TOKEN: ${{ github.token }}
220-
TAG: v${{ needs.plan.outputs.version }}
175+
TAG: nightly/v${{ needs.plan.outputs.version }}
221176
COMMIT: ${{ needs.plan.outputs.commit }}
222177
run: |
223178
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$COMMIT"

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

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,12 @@ function run(overrides: Record<string, unknown> = {}) {
1010
event: 'schedule',
1111
ref: 'refs/heads/main',
1212
sha: MAIN,
13-
channel: 'nightly',
14-
requestedVersion: '',
1513
rootVersion: '0.21.25-dev',
1614
today: '20261011',
1715
runNumber: '42',
1816
tags: new Map([
1917
['v0.21.24', 'c'.repeat(40)],
20-
['v0.21.25-nightly.20261010.41', NIGHTLY_COMMIT],
18+
['nightly/v0.21.25-nightly.20261010.41', NIGHTLY_COMMIT],
2119
]),
2220
latestStable: '0.21.24',
2321
ciConclusion: 'success',
@@ -58,22 +56,7 @@ test('a nightly refuses a main whose -dev version trails the published release',
5856
assert.throws(() => planRelease(run({ latestStable: '0.21.25' })), /not ahead of the published/);
5957
});
6058

61-
test('promotion tags the latest nightly commit with the version it previewed', () => {
62-
const promoted = run({ event: 'workflow_dispatch', channel: 'stable' });
63-
assert.deepEqual(planRelease(promoted), {
64-
mode: 'promote',
65-
version: '0.21.25',
66-
commit: NIGHTLY_COMMIT,
67-
nightly: 'v0.21.25-nightly.20261010.41',
68-
});
69-
assert.equal(planRelease({ ...promoted, requestedVersion: '0.22.0' }).version, '0.22.0');
70-
assert.throws(() => planRelease({ ...promoted, requestedVersion: '0.21.23' }), /not ahead/);
71-
assert.throws(() => planRelease({ ...promoted, requestedVersion: '0.22' }), /X\.Y\.Z/);
72-
assert.throws(() => planRelease({ ...promoted, requestedVersion: '0.21.24' }), /already tagged/);
73-
assert.throws(() => planRelease({ ...promoted, tags: new Map() }), /No nightly/);
74-
});
75-
76-
test('a stable tag publishes its own commit when it is on main', () => {
59+
test('a stable tag publishes its own commit once it is on main and its CI passed', () => {
7760
const stable = run({ event: 'workflow_dispatch', ref: 'refs/tags/v0.21.25', onMain: true });
7861
assert.deepEqual(planRelease(stable), {
7962
mode: 'stable',
@@ -85,6 +68,10 @@ test('a stable tag publishes its own commit when it is on main', () => {
8568
assert.equal(planRelease({ ...stable, latestStable: '0.21.25' }).mode, 'stable');
8669
assert.throws(() => planRelease({ ...stable, latestStable: '0.21.26' }), /older than/);
8770
assert.throws(() => planRelease({ ...stable, onMain: false }), /not on main/);
71+
assert.throws(
72+
() => planRelease({ ...stable, ciConclusion: 'in_progress' }),
73+
/run Release on v0\.21\.25/,
74+
);
8875
assert.throws(() => planRelease({ ...stable, ref: 'refs/tags/v0.21.25-rc.1' }), /vX\.Y\.Z tag/);
8976
});
9077

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

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,14 @@ test('a nightly is refused unless main carries a -dev version', () => {
2424
test('the latest nightly orders by base, then date, then run number', () => {
2525
const tags = [
2626
'v0.21.24',
27-
'v0.21.25-nightly.20261009.9',
28-
'v0.21.25-nightly.20261010.10',
29-
'v0.21.25-nightly.20261010.12',
30-
'v0.21.24-nightly.20261011.13',
31-
'evidence/v0.21.26-nightly.20261012.14',
27+
'nightly/v0.21.25-nightly.20261009.9',
28+
'nightly/v0.21.25-nightly.20261010.10',
29+
'nightly/v0.21.25-nightly.20261010.12',
30+
'nightly/v0.21.24-nightly.20261011.13',
31+
'v0.21.26-nightly.20261012.14',
32+
'evidence/v0.21.26-nightly.20261012.15',
3233
];
33-
assert.equal(latestNightlyTag(tags), 'v0.21.25-nightly.20261010.12');
34+
assert.equal(latestNightlyTag(tags), 'nightly/v0.21.25-nightly.20261010.12');
3435
assert.equal(latestNightlyTag(['v0.21.24']), null);
3536
});
3637

‎scripts/release-plan.mjs‎

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@ import {
1313
/**
1414
* Decides what one `.github/workflows/release.yml` run does, from the event that started it:
1515
* pull_request dry-run: build and verify every package, publish nothing
16-
* schedule / nightly nightly: publish main's HEAD under the `nightly` dist-tag
17-
* dispatch stable on main promote: tag the latest nightly's commit and start its stable run
16+
* schedule / dispatch nightly: publish main's HEAD under the `nightly` dist-tag
1817
* vX.Y.Z tag stable: publish that commit under the `latest` dist-tag
1918
* `tags` maps remote tag names to commits; `ciConclusion` and `onMain` describe `sha`.
2019
*/
@@ -26,7 +25,7 @@ export function planRelease(run) {
2625
if (run.ref !== 'refs/heads/main') {
2726
throw new Error(`Releases run from main or a vX.Y.Z tag, not ${run.ref}.`);
2827
}
29-
return run.channel === 'stable' ? planPromotion(run) : planNightly(run);
28+
return planNightly(run);
3029
}
3130

3231
function planNightly(run) {
@@ -48,16 +47,6 @@ function planNightly(run) {
4847
return { mode: 'nightly', version, distTag: 'nightly', commit: run.sha };
4948
}
5049

51-
function planPromotion(run) {
52-
const nightly = latestNightlyTag([...run.tags.keys()]);
53-
if (!nightly) throw new Error('No nightly has been published to promote.');
54-
const version = run.requestedVersion || nightlyBase(nightly.slice(1));
55-
if (!isReleaseVersion(version)) throw new Error(`${version} is not an X.Y.Z release version.`);
56-
if (run.tags.has(`v${version}`)) throw new Error(`v${version} is already tagged.`);
57-
assertAheadOfStable(run, version);
58-
return { mode: 'promote', version, commit: run.tags.get(nightly), nightly };
59-
}
60-
6150
function planStable(run, tag) {
6251
const version = tag.slice(1);
6352
if (!tag.startsWith('v') || !isReleaseVersion(version)) {
@@ -68,6 +57,11 @@ function planStable(run, tag) {
6857
throw new Error(`${version} is older than the published ${run.latestStable}.`);
6958
}
7059
if (!run.onMain) throw new Error(`${tag} is not on main.`);
60+
if (run.ciConclusion !== 'success') {
61+
throw new Error(
62+
`CI on ${run.sha} is ${run.ciConclusion}. Once it passes, run Release on ${tag} to publish it.`,
63+
);
64+
}
7165
const previousTag = previousReleaseTag([...run.tags.keys()], version) ?? '';
7266
return { mode: 'stable', version, distTag: 'latest', commit: run.sha, previousTag };
7367
}
@@ -84,10 +78,12 @@ function assertAheadOfStable(run, version) {
8478
}
8579
}
8680

87-
/** Remote tags by name, resolved to the commit each one points at. */
81+
/** Remote release and nightly tags by name, resolved to the commit each one points at. */
8882
function remoteTags() {
8983
const tags = new Map();
90-
const output = execFileSync('git', ['ls-remote', '--tags', 'origin', 'v*'], { encoding: 'utf8' });
84+
const output = execFileSync('git', ['ls-remote', '--tags', 'origin', 'v*', 'nightly/*'], {
85+
encoding: 'utf8',
86+
});
9187
for (const line of output.split('\n').filter(Boolean)) {
9288
const [commit, name] = line.split('\t');
9389
const tag = name.replace(/^refs\/tags\//, '');
@@ -138,14 +134,12 @@ async function readRun(env) {
138134
event,
139135
ref,
140136
sha: env.GITHUB_SHA,
141-
channel: env.RELEASE_CHANNEL || 'nightly',
142-
requestedVersion: env.RELEASE_VERSION,
143137
rootVersion: JSON.parse(fs.readFileSync('package.json', 'utf8')).version,
144138
today: new Date().toISOString().slice(0, 10).replaceAll('-', ''),
145139
runNumber: env.GITHUB_RUN_NUMBER,
146140
tags: event === 'pull_request' ? new Map() : remoteTags(),
147141
latestStable: onBranch || onTag ? await latestStable() : null,
148-
ciConclusion: onBranch ? await ciConclusion(env) : undefined,
142+
ciConclusion: onBranch || onTag ? await ciConclusion(env) : undefined,
149143
onMain: onTag ? await isOnMain(env) : undefined,
150144
};
151145
}

‎scripts/release-version.mjs‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { pathToFileURL } from 'node:url';
55

66
// Version strings across the release channels. `main` carries `X.Y.Z-dev`, naming the release
77
// it is building towards; a nightly publishes that base as `X.Y.Z-nightly.<YYYYMMDD>.<run>`; a
8-
// stable release promotes a nightly's commit to `X.Y.Z`. Registry scanners diff the tool surface
8+
// 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.
1010
const RELEASE = /^(\d+)\.(\d+)\.(\d+)$/;
1111
const DEVELOPMENT = /^(\d+)\.(\d+)\.(\d+)-dev$/;
@@ -31,18 +31,25 @@ export function nightlyVersion(developmentVersion, date, runNumber) {
3131
return `${match.slice(1, 4).join('.')}-nightly.${date}.${runNumber}`;
3232
}
3333

34-
/** The `X.Y.Z` a nightly previews: the version its promotion publishes by default. */
34+
/** The `X.Y.Z` release a nightly previews. */
3535
export function nightlyBase(version) {
3636
const match = NIGHTLY.exec(version);
3737
if (!match) throw new Error(`${version} is not a nightly version.`);
3838
return match.slice(1, 4).join('.');
3939
}
4040

41-
/** The newest `vX.Y.Z-nightly.<date>.<run>` tag, ordered by base, then date, then run. */
41+
/**
42+
* Nightly tags live outside `v*`, which only repository admins may create: the release workflow
43+
* tags each nightly itself, and no nightly tag ref may deploy to the npm-publish environment.
44+
*/
45+
const NIGHTLY_TAG_PREFIX = 'nightly/v';
46+
47+
/** The newest `nightly/vX.Y.Z-nightly.<date>.<run>` tag, ordered by base, then date, then run. */
4248
export function latestNightlyTag(tags) {
4349
const nightlies = tags
44-
.map((tag) => ({ tag, match: NIGHTLY.exec(tag.replace(/^v/, '')) }))
45-
.filter((entry) => entry.tag.startsWith('v') && entry.match);
50+
.filter((tag) => tag.startsWith(NIGHTLY_TAG_PREFIX))
51+
.map((tag) => ({ tag, match: NIGHTLY.exec(tag.slice(NIGHTLY_TAG_PREFIX.length)) }))
52+
.filter((entry) => entry.match);
4653
nightlies.sort((a, b) => {
4754
const [left, right] = [a.match, b.match].map((match) => match.slice(1).map(Number));
4855
return right.reduce((order, part, index) => order || part - left[index], 0);

0 commit comments

Comments
 (0)