Skip to content

Commit 78e9601

Browse files
committed
feat(release): Support immutable retry tags.
1 parent 3d66ab3 commit 78e9601

4 files changed

Lines changed: 142 additions & 4 deletions

File tree

‎.github/scripts/release-policy.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,16 @@ export function releasePolicy(
1919
tag: string,
2020
packages: ReadonlyArray<{ name: string; version: string }>,
2121
): { version: string; npmTag: string } {
22+
// Retry metadata identifies a Git release attempt, not a new npm version.
23+
// Keep real prereleases in the captured version and in channel selection.
2224
const match =
23-
/^v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?)$/.exec(
25+
/^v((?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?)(?:\+retry\.[1-9]\d*)?$/.exec(
2426
tag,
2527
);
2628
if (!match || match[0] !== tag || match[2]?.split('.').some((part) => /^0\d+$/.test(part))) {
27-
throw new Error('Release tags must be v followed by an exact semantic version.');
29+
throw new Error(
30+
'Release tags must be v followed by an exact semantic version, optionally ending in +retry.N (N is a positive integer).',
31+
);
2832
}
2933
const version = match[1];
3034
if (UNAVAILABLE_VOID_VERSIONS.has(version)) {
@@ -35,7 +39,9 @@ export function releasePolicy(
3539
}
3640
for (const pkg of packages) {
3741
if (pkg.version !== version) {
38-
throw new Error(`${pkg.name}@${pkg.version} does not match release ${tag}.`);
42+
throw new Error(
43+
`${pkg.name}@${pkg.version} does not match release ${tag}. Retry tags use +retry.N; the base version must still match every package.`,
44+
);
3945
}
4046
}
4147
const prefix = match[2]?.split('.')[0];

‎.github/workflows/publish.yml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,31 @@ on:
88
permissions: {}
99

1010
jobs:
11+
validate-release:
12+
name: Validate release tag
13+
runs-on: ubuntu-latest
14+
permissions:
15+
contents: read
16+
steps:
17+
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
18+
with:
19+
persist-credentials: false
20+
- uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0
21+
with:
22+
node-version-file: .node-version
23+
run-install: false
24+
cache: false
25+
- name: Validate package versions and release channel
26+
run: node .github/scripts/release-policy.ts
27+
1128
ci:
29+
needs: validate-release
1230
uses: ./.github/workflows/ci.yml
1331
permissions:
1432
contents: read
1533

1634
platform-ci:
35+
needs: validate-release
1736
uses: ./.github/workflows/platform-ci.yml
1837
with:
1938
require-schema-compat: true
@@ -23,6 +42,7 @@ jobs:
2342

2443
prepublish-test:
2544
name: Prepublish Test
45+
needs: validate-release
2646
uses: ./.github/workflows/prepublish-test.yml
2747
with:
2848
suite: full

‎docs/guide/platform-development.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,4 +232,33 @@ Publishing requires both SDK CI and platform CI, including the platform unit and
232232
API integration suites. Release tags also run the Windows SDK checks; a passing
233233
SDK-only build cannot publish a changed control plane.
234234

235+
### Retrying a Release
236+
237+
To retry a failed release without moving an existing tag, add `+retry.N` to a
238+
new Git tag, with `N` starting at `1`. Keep the package versions unchanged:
239+
240+
| Git tag | Package version | npm channel |
241+
| ------------------------ | --------------- | ----------- |
242+
| `v0.21.0` | `0.21.0` | `latest` |
243+
| `v0.21.0+retry.1` | `0.21.0` | `latest` |
244+
| `v0.21.0-beta.1+retry.2` | `0.21.0-beta.1` | `beta` |
245+
246+
For example, when the packages are at `0.21.0`, commit the release fix and tag
247+
that commit:
248+
249+
```sh
250+
git tag -a 'v0.21.0+retry.1' -m 'Retry 0.21.0 publication.'
251+
git push origin 'refs/tags/v0.21.0+retry.1'
252+
```
253+
254+
The retry suffix belongs only in the Git tag, not in `package.json`. A `-1`
255+
suffix is a distinct prerelease version, not a retry. Tag and package versions
256+
are checked before dependency installation and the full CI jobs; retries still
257+
run the normal release checks.
258+
259+
Retries publish only package versions that are still missing from npm. They
260+
cannot replace an already-published version. If an earlier attempt partially
261+
published the release and you changed its package contents, bump the version
262+
instead of combining different contents under the same version.
263+
235264
For implementation history, use the design archive at `platform/meta/design-docs/README.md`. Its proposals explain earlier decisions; the source and current guides define the supported behavior.

‎packages/void/test/unit/release-policy.test.ts‎

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,78 @@ describe('public package release policy', () => {
1818
).toEqual({ version, npmTag });
1919
});
2020

21+
it.each([
22+
['v0.21.0+retry.1', '0.21.0', 'latest'],
23+
['v0.21.0+retry.27', '0.21.0', 'latest'],
24+
['v0.21.0-beta.1+retry.2', '0.21.0-beta.1', 'beta'],
25+
['v0.21.0-rc.2+retry.1', '0.21.0-rc.2', 'rc'],
26+
['v0.21.0-1+retry.1', '0.21.0-1', 'next'],
27+
['v0.21.0-latest.1+retry.1', '0.21.0-latest.1', 'next'],
28+
])('treats %s as an attempt to publish %s to %s', (tag, version, npmTag) => {
29+
expect(
30+
releasePolicy(tag, [
31+
{ name: 'void', version },
32+
{ name: '@void/platform', version },
33+
]),
34+
).toEqual({ version, npmTag });
35+
});
36+
37+
it.each([
38+
'v0.21.0+retry',
39+
'v0.21.0+retry.',
40+
'v0.21.0+retry.0',
41+
'v0.21.0+retry.01',
42+
'v0.21.0+retry.-1',
43+
'v0.21.0+retry.1.extra',
44+
'v0.21.0+retry.1+retry.2',
45+
'v0.21.0+Retry.1',
46+
'v0.21.0+build.1',
47+
'v0.21.0-beta.01+retry.1',
48+
'v0.21.0+retry.1\n',
49+
'v0.21.0+retry.1\nversion=other',
50+
])('rejects malformed or unrecognized retry metadata: %s', (tag) => {
51+
expect(() => releasePolicy(tag, [])).toThrow('optionally ending in +retry.N');
52+
});
53+
54+
it('keeps numeric prereleases distinct from stable release retries', () => {
55+
expect(() => releasePolicy('v0.21.0-1', [{ name: 'void', version: '0.21.0' }])).toThrow(
56+
'Retry tags use +retry.N',
57+
);
58+
});
59+
60+
it.each(['0.21.1', '0.21.0+retry.1'])(
61+
'requires retry tags to match the plain package version (%s)',
62+
(version) => {
63+
expect(() => releasePolicy('v0.21.0+retry.1', [{ name: 'void', version }])).toThrow(
64+
'does not match release',
65+
);
66+
},
67+
);
68+
69+
it('checks every package on a retry, not just the CLI', () => {
70+
expect(() =>
71+
releasePolicy('v0.21.0+retry.1', [
72+
{ name: 'void', version: '0.21.0' },
73+
{ name: '@void/platform', version: '0.20.0' },
74+
]),
75+
).toThrow('@void/platform@0.20.0 does not match');
76+
});
77+
2178
it.each(['v0.1.0', 'v3.0.1'])('rejects the unavailable version %s', (tag) => {
2279
expect(() => releasePolicy(tag, [{ name: 'void', version: tag.slice(1) }])).toThrow(
2380
'cannot be reused',
2481
);
2582
});
2683

84+
it.each(['v0.1.0+retry.1', 'v3.0.1+retry.2'])(
85+
'rejects retrying an unavailable version: %s',
86+
(tag) => {
87+
expect(() =>
88+
releasePolicy(tag, [{ name: 'void', version: tag.slice(1).split('+')[0]! }]),
89+
).toThrow('cannot be reused');
90+
},
91+
);
92+
2793
it.each(['latest', 'v01.2.3', 'v1.2.3-beta.01', 'v1.2.3\nother=tag', 'v1.2.3\n', 'v1.2.3+build'])(
2894
'rejects malformed release tag %s',
2995
(tag) => {
@@ -46,7 +112,23 @@ describe('trusted publishing workflow', () => {
46112
new URL('../../../../.github/workflows/publish.yml', import.meta.url),
47113
'utf8',
48114
);
49-
const publishJob = workflow.split('\n publish:\n')[1]!;
115+
function job(name: string): string {
116+
const parts = workflow.split(`\n ${name}:\n`);
117+
expect(parts).toHaveLength(2);
118+
return parts[1]!.split(/\n {2}[\w-]+:\n/)[0]!;
119+
}
120+
const publishJob = job('publish');
121+
122+
it('rejects invalid tags before installing dependencies or running CI', () => {
123+
const validation = job('validate-release');
124+
expect(validation).toContain('node .github/scripts/release-policy.ts');
125+
expect(validation).toContain('run-install: false');
126+
expect(validation).toContain('cache: false');
127+
expect(validation).not.toContain('id-token: write');
128+
for (const name of ['ci', 'platform-ci', 'prepublish-test']) {
129+
expect(job(name)).toContain(' needs: validate-release\n');
130+
}
131+
});
50132

51133
it('grants OIDC permission to the GitHub-hosted release job', () => {
52134
expect(publishJob).toBeDefined();
@@ -61,6 +143,7 @@ describe('trusted publishing workflow', () => {
61143
expect(publishJob).toContain(
62144
'vp pm publish -r --access public --tag "$RELEASE_NPM_TAG" --no-git-checks',
63145
);
146+
expect(publishJob).not.toContain('--force');
64147
});
65148

66149
it('keeps CI gates and validated stable or prerelease channel selection', () => {

0 commit comments

Comments
 (0)