Skip to content

Commit 851bd6a

Browse files
authored
chore(release): protect latest release channels (#4825)
1 parent c471d36 commit 851bd6a

7 files changed

Lines changed: 64 additions & 61 deletions

File tree

.github/CONTRIBUTING.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ Feel free to reach out via the [graphql-js channel](https://discord.com/channels
122122

123123
## Release on NPM
124124

125-
Releases on `17.x.x` are managed by local scripts and GitHub Actions:
125+
Releases on maintained release branches are managed by local scripts and GitHub Actions:
126126

127127
```bash
128128
git switch 17.x.x
@@ -149,9 +149,15 @@ npm run release:prepare -- 17.x.x prerelease --preid rc
149149
```
150150

151151
When `--preid` is provided, the release script uses it as the npm publish tag.
152-
Without a prerelease preid, the publish tag is `latest`.
153-
154-
Push `<my_release_branch>`, open a PR from `<my_release_branch>` to `17.x.x`, wait for CI to pass, merge the PR, and then approve the GitHub Actions release workflow.
152+
Without a prerelease preid, stable releases from the latest major use the npm
153+
`latest` tag and are marked as the latest GitHub release. Stable releases from
154+
older maintained majors use an npm `latest-<major>` tag and are explicitly not
155+
marked as the latest GitHub release. Users can install those older majors with a
156+
semver specifier, for example `npm install graphql@16`.
157+
158+
Push `<my_release_branch>`, open a PR from `<my_release_branch>` to the target
159+
release branch, wait for CI to pass, merge the PR, and then approve the GitHub
160+
Actions release workflow.
155161

156162
## License
157163

.github/workflows/release.yml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ jobs:
1414
should_publish: ${{ steps.release_metadata.outputs.should_publish }}
1515
tag: ${{ steps.release_metadata.outputs.tag }}
1616
dist_tag: ${{ steps.release_metadata.outputs.dist_tag }}
17+
latest: ${{ steps.release_metadata.outputs.latest }}
1718
prerelease: ${{ steps.release_metadata.outputs.prerelease }}
1819
concurrency:
1920
group: ${{ github.workflow }}-${{ github.ref_name }}
@@ -47,6 +48,7 @@ jobs:
4748
"version=\(.version)",
4849
"tag=\(.tag)",
4950
"dist_tag=\(.distTag)",
51+
"latest=\(.latest)",
5052
"prerelease=\(.prerelease)",
5153
"package_spec=\(.packageSpec)",
5254
"should_publish=\(.shouldPublish)"
@@ -120,11 +122,7 @@ jobs:
120122
- name: Publish to npm
121123
working-directory: ./npmDist
122124
run: |
123-
if [ -n "${{ needs.check-publish.outputs.dist_tag }}" ]; then
124-
npm publish --provenance --tag "${{ needs.check-publish.outputs.dist_tag }}"
125-
else
126-
npm publish --provenance
127-
fi
125+
npm publish --provenance --tag "${{ needs.check-publish.outputs.dist_tag }}"
128126
129127
publish-jsr:
130128
name: Publish JSR package
@@ -204,8 +202,14 @@ jobs:
204202
--notes-file "${release_notes_file}" \
205203
--prerelease
206204
else
205+
latest_flag="--latest=false"
206+
if [ "${{ needs.check-publish.outputs.latest }}" = "true" ]; then
207+
latest_flag="--latest"
208+
fi
209+
207210
gh release create "${tag}" \
208211
--verify-tag \
209212
--title "${tag}" \
210-
--notes-file "${release_notes_file}"
213+
--notes-file "${release_notes_file}" \
214+
"${latest_flag}"
211215
fi

package.json

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,5 @@
7373
"prettier": "3.8.1",
7474
"typescript": "6.0.3",
7575
"typescript-eslint": "8.59.0"
76-
},
77-
"publishConfig": {
78-
"tag": "latest"
7976
}
8077
}

resources/build-npm.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import type { PlatformConditionalExports } from './utils.ts';
1414
import {
1515
buildCJSDevModeStub,
1616
buildESMDevModeStub,
17-
getPublishConfigTag,
1817
prettify,
1918
readPackageJSON,
2019
readTSConfig,
@@ -66,15 +65,6 @@ async function buildPackage(outDir: string): Promise<void> {
6665
'*': { '*': [notSupportedTSVersionFile] },
6766
};
6867

69-
// TODO: move to integration tests
70-
const { version } = packageJSON;
71-
const publishTag = getPublishConfigTag(version);
72-
assert.equal(
73-
packageJSON.publishConfig?.tag,
74-
publishTag,
75-
'Publish tag and version tag should match!',
76-
);
77-
7868
delete packageJSON.type;
7969
packageJSON.main = 'index.js';
8070
packageJSON.module = 'index.mjs';

resources/release-metadata.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import assert from 'node:assert';
2-
31
import {
4-
getPublishConfigTag,
2+
getReleaseDistTag,
53
git,
4+
isLatestReleaseVersion,
65
isPrereleaseVersion,
76
npm,
87
readPackageJSON,
@@ -13,6 +12,7 @@ interface ReleaseMetadata {
1312
version: string;
1413
tag: string;
1514
distTag: string;
15+
latest: boolean;
1616
prerelease: boolean;
1717
releaseNotes: string;
1818
packageSpec: string;
@@ -22,19 +22,16 @@ interface ReleaseMetadata {
2222

2323
try {
2424
const packageJSON = readPackageJSON();
25-
const { version, publishConfig } = packageJSON;
25+
const { version } = packageJSON;
2626

2727
if (typeof version !== 'string' || version === '') {
2828
throw new Error('package.json is missing a valid "version" field.');
2929
}
3030

3131
const tag = `v${version}`;
32-
const distTag = getPublishConfigTag(version);
33-
assert.equal(
34-
publishConfig?.tag,
35-
distTag,
36-
'Publish tag and version tag should match!',
37-
);
32+
const latestVersion = npm().view('graphql', 'dist-tags.latest');
33+
const distTag = getReleaseDistTag(version, latestVersion);
34+
const latest = isLatestReleaseVersion(version, latestVersion);
3835
const prerelease = isPrereleaseVersion(version);
3936
const releaseCommitSha = findReleaseCommitSha(version);
4037
const releaseNotes =
@@ -54,6 +51,7 @@ try {
5451
version,
5552
tag,
5653
distTag,
54+
latest,
5755
prerelease,
5856
releaseNotes,
5957
packageSpec,

resources/release-prepare.ts

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
import fs from 'node:fs';
2-
3-
import { getPublishConfigTag, git, npm, readPackageJSON } from './utils.ts';
1+
import { git, npm, readPackageJSON } from './utils.ts';
42

53
let args: ParsedArgs;
64
try {
@@ -17,9 +15,6 @@ npm().ci('--ignore-scripts');
1715
console.log('Bumping package version without creating a tag...');
1816
npm().version(...args.npmVersionArgs, '--no-git-tag-version');
1917

20-
console.log('Updating publishConfig.tag...');
21-
updatePublishConfigTag();
22-
2318
console.log('Updating src/version.ts...');
2419
npm().exec(
2520
'--',
@@ -101,7 +96,10 @@ function validateBranchState(releaseBranch: string): void {
10196
throw new Error(
10297
'Git is in detached HEAD state (not on a local branch). ' +
10398
'Switch to a local branch based on the release branch first, for example:\n' +
104-
` git switch -c release-${releaseBranch.replace(/[^a-zA-Z0-9._-]/g, '-')} ${releaseBranch}`,
99+
` git switch -c release-${releaseBranch.replace(
100+
/[^a-zA-Z0-9._-]/g,
101+
'-',
102+
)} ${releaseBranch}`,
105103
);
106104
}
107105
if (checkedBranch === releaseBranch) {
@@ -200,15 +198,6 @@ function validateBranchState(releaseBranch: string): void {
200198
}
201199
}
202200

203-
function updatePublishConfigTag(): void {
204-
const packageJSON = readPackageJSON();
205-
packageJSON.publishConfig = {
206-
...packageJSON.publishConfig,
207-
tag: getPublishConfigTag(packageJSON.version),
208-
};
209-
fs.writeFileSync('package.json', JSON.stringify(packageJSON, null, 2) + '\n');
210-
}
211-
212201
function throwUsage(message: string): never {
213202
throw new Error(
214203
`${message}\n` +

resources/utils.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -280,29 +280,49 @@ export function buildESMDevModeStub(
280280
].join('\n');
281281
}
282282

283-
export function getPublishConfigTag(version: string): string {
284-
return getPrereleaseDistTag(version) ?? 'latest';
283+
export function getReleaseDistTag(
284+
version: string,
285+
latestVersion: string,
286+
): string {
287+
const { major, prerelease } = parseSemVer(version);
288+
if (prerelease != null) {
289+
return prerelease.split('.')[0];
290+
}
291+
292+
return major >= parseSemVer(latestVersion).major
293+
? 'latest'
294+
: `latest-${major}`;
295+
}
296+
297+
export function isLatestReleaseVersion(
298+
version: string,
299+
latestVersion: string,
300+
): boolean {
301+
const { major, prerelease } = parseSemVer(version);
302+
return prerelease == null && major >= parseSemVer(latestVersion).major;
285303
}
286304

287305
export function isPrereleaseVersion(version: string): boolean {
288-
return getPrereleaseDistTag(version) != null;
306+
return parseSemVer(version).prerelease != null;
289307
}
290308

291-
function getPrereleaseDistTag(version: string): string | null {
309+
function parseSemVer(version: string): {
310+
major: number;
311+
prerelease: string | null;
312+
} {
292313
const versionMatch =
293-
/^\d+\.\d+\.\d+(?:-(?<prerelease>[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
314+
/^(?<major>\d+)\.\d+\.\d+(?:-(?<prerelease>[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
294315
version,
295316
);
296317
if (versionMatch?.groups == null) {
297318
throw new Error('Version does not match semver spec: ' + version);
298319
}
299320

300-
const { prerelease } = versionMatch.groups;
301-
if (prerelease == null) {
302-
return null;
303-
}
304-
305-
return prerelease.split('.')[0];
321+
const { major, prerelease } = versionMatch.groups;
322+
return {
323+
major: Number(major),
324+
prerelease: prerelease ?? null,
325+
};
306326
}
307327

308328
interface PackageJSON {
@@ -317,7 +337,6 @@ interface PackageJSON {
317337
types?: string;
318338
typesVersions: { [ranges: string]: { [path: string]: Array<string> } };
319339
devDependencies?: { [name: string]: string };
320-
publishConfig: { tag: string };
321340

322341
// TODO: remove after we drop CJS support
323342
main?: string;

0 commit comments

Comments
 (0)