Skip to content

Commit c767858

Browse files
authored
chore(release): protect latest release channels from v16 (#4826)
1 parent dca5b4d commit c767858

6 files changed

Lines changed: 73 additions & 34 deletions

File tree

.github/CONTRIBUTING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ export GH_TOKEN=<token> # required to build changelog via GitHub API requests
8585
npm run release:prepare -- 16.x.x patch
8686
```
8787

88+
Stable `16.x.x` releases publish to npm with the `latest-16` tag so they do
89+
not replace the package `latest` tag, and their GitHub releases are explicitly
90+
not marked as the latest release. Users can install this line with a semver
91+
specifier, for example `npm install graphql@16`.
92+
8893
Push `<my_release_branch>`, open a PR from `<my_release_branch>` to `16.x.x`,
8994
wait for CI to pass, merge the PR, and then approve the GitHub Actions release
9095
workflow.

.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
tarball_name: ${{ steps.release_metadata.outputs.tarball_name }}
1920
concurrency:
@@ -48,6 +49,7 @@ jobs:
4849
"version=\(.version)",
4950
"tag=\(.tag)",
5051
"dist_tag=\(.distTag)",
52+
"latest=\(.latest)",
5153
"prerelease=\(.prerelease)",
5254
"package_spec=\(.packageSpec)",
5355
"tarball_name=\(.tarballName)",
@@ -111,11 +113,7 @@ jobs:
111113

112114
- name: Publish npm package
113115
run: |
114-
if [ -n "${{ needs.check-publish.outputs.dist_tag }}" ]; then
115-
npm publish --provenance --tag "${{ needs.check-publish.outputs.dist_tag }}" "./artifacts/${{ needs.check-publish.outputs.tarball_name }}"
116-
else
117-
npm publish --provenance "./artifacts/${{ needs.check-publish.outputs.tarball_name }}"
118-
fi
116+
npm publish --provenance --tag "${{ needs.check-publish.outputs.dist_tag }}" "./artifacts/${{ needs.check-publish.outputs.tarball_name }}"
119117
120118
create-release:
121119
name: Create release
@@ -169,8 +167,14 @@ jobs:
169167
--notes-file "${release_notes_file}" \
170168
--prerelease
171169
else
170+
latest_flag="--latest=false"
171+
if [ "${{ needs.check-publish.outputs.latest }}" = "true" ]; then
172+
latest_flag="--latest"
173+
fi
174+
172175
gh release create "${tag}" \
173176
--verify-tag \
174177
--title "${tag}" \
175-
--notes-file "${release_notes_file}"
178+
--notes-file "${release_notes_file}" \
179+
"${latest_flag}"
176180
fi

package.json

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

resources/build-npm.js

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -107,33 +107,11 @@ function buildPackageJSON() {
107107
delete packageJSON.scripts;
108108
delete packageJSON.devDependencies;
109109

110-
// TODO: move to integration tests
111-
const publishTag = packageJSON.publishConfig?.tag;
112-
assert(publishTag != null, 'Should have packageJSON.publishConfig defined!');
113-
114110
const { version } = packageJSON;
115111
const versionMatch = /^\d+\.\d+\.\d+-?(?<preReleaseTag>.*)?$/.exec(version);
116112
if (!versionMatch) {
117113
throw new Error('Version does not match semver spec: ' + version);
118114
}
119115

120-
const { preReleaseTag } = versionMatch.groups;
121-
122-
if (preReleaseTag != null) {
123-
const splittedTag = preReleaseTag.split('.');
124-
// Note: `experimental-*` take precedence over `alpha`, `beta` or `rc`.
125-
const versionTag = splittedTag[2] ?? splittedTag[0];
126-
assert(
127-
['alpha', 'beta', 'rc'].includes(versionTag) ||
128-
versionTag.startsWith('experimental-'),
129-
`"${versionTag}" tag is not supported.`,
130-
);
131-
assert.equal(
132-
versionTag,
133-
publishTag,
134-
'Publish tag and version tag should match!',
135-
);
136-
}
137-
138116
return packageJSON;
139117
}

resources/release-metadata.js

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,31 @@
11
'use strict';
22

33
const {
4+
getReleaseDistTag,
5+
isLatestReleaseVersion,
6+
isPrereleaseVersion,
47
readPackageJSON,
58
readPackageJSONAtRef,
69
spawnOutput,
710
} = require('./utils.js');
811

912
try {
1013
const packageJSON = readPackageJSON();
11-
const { version, publishConfig } = packageJSON;
14+
const { version } = packageJSON;
1215

1316
if (typeof version !== 'string' || version === '') {
1417
throw new Error('package.json is missing a valid "version" field.');
1518
}
1619

1720
const tag = `v${version}`;
18-
const distTag = publishConfig?.tag ?? '';
19-
const prerelease = distTag === 'alpha';
21+
const latestVersion = spawnOutput('npm', [
22+
'view',
23+
'graphql',
24+
'dist-tags.latest',
25+
]);
26+
const distTag = getReleaseDistTag(version, latestVersion);
27+
const latest = isLatestReleaseVersion(version, latestVersion);
28+
const prerelease = isPrereleaseVersion(version);
2029
const releaseCommitSha = findReleaseCommitSha(version);
2130
const releaseNotes =
2231
releaseCommitSha == null
@@ -45,6 +54,7 @@ try {
4554
version,
4655
tag,
4756
distTag,
57+
latest,
4858
prerelease,
4959
releaseNotes,
5060
packageSpec,

resources/utils.js

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,48 @@ function readPackageJSONAtRef(ref) {
137137
return JSON.parse(packageJSONAtRef);
138138
}
139139

140+
function getReleaseDistTag(version, latestVersion) {
141+
const { major, prerelease } = parseSemVer(version);
142+
if (prerelease != null) {
143+
return getPrereleaseDistTag(prerelease);
144+
}
145+
146+
return major >= parseSemVer(latestVersion).major
147+
? 'latest'
148+
: `latest-${major}`;
149+
}
150+
151+
function isLatestReleaseVersion(version, latestVersion) {
152+
const { major, prerelease } = parseSemVer(version);
153+
return prerelease == null && major >= parseSemVer(latestVersion).major;
154+
}
155+
156+
function isPrereleaseVersion(version) {
157+
return parseSemVer(version).prerelease != null;
158+
}
159+
160+
function parseSemVer(version) {
161+
const versionMatch =
162+
/^(?<major>\d+)\.\d+\.\d+(?:-(?<prerelease>[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(
163+
version,
164+
);
165+
if (versionMatch?.groups == null) {
166+
throw new Error('Version does not match semver spec: ' + version);
167+
}
168+
169+
const { major, prerelease } = versionMatch.groups;
170+
return {
171+
major: Number(major),
172+
prerelease: prerelease ?? null,
173+
};
174+
}
175+
176+
function getPrereleaseDistTag(prerelease) {
177+
const splittedTag = prerelease.split('.');
178+
// Note: `experimental-*` take precedence over `alpha`, `beta` or `rc`.
179+
return splittedTag[2] ?? splittedTag[0];
180+
}
181+
140182
function tagExists(tag) {
141183
const result = childProcess.spawnSync(
142184
'git',
@@ -148,6 +190,9 @@ function tagExists(tag) {
148190

149191
module.exports = {
150192
exec,
193+
getReleaseDistTag,
194+
isLatestReleaseVersion,
195+
isPrereleaseVersion,
151196
spawn,
152197
spawnOutput,
153198
readdirRecursive,

0 commit comments

Comments
 (0)