Skip to content

Commit 5b9e7cb

Browse files
committed
internal: add 17.x.x publish automation
1 parent 1740c2e commit 5b9e7cb

File tree

6 files changed

+330
-20
lines changed

6 files changed

+330
-20
lines changed

.github/CONTRIBUTING.md

Lines changed: 10 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -86,28 +86,18 @@ Feel free to reach out via the [graphql-js channel](https://discord.com/channels
8686

8787
## Release on NPM
8888

89-
_Only core contributors may release to NPM._
90-
91-
To release a new version on NPM, first ensure all tests pass with `npm test`,
92-
then use `npm version patch|minor|major` in order to increment the version in
93-
package.json and tag and commit a release. Then `git push && git push --tags`
94-
to sync this change with source control. Then `npm publish npmDist` to actually
95-
publish the release to NPM.
96-
Once published, add [release notes](https://github.com/graphql/graphql-js/releases).
97-
Use [semver](https://semver.org/) to determine which version part to increment.
98-
99-
Example for a patch release:
100-
101-
```sh
102-
npm ci
103-
npm test
104-
npm version patch --ignore-scripts=false
105-
git push --follow-tags
106-
cd npmDist && npm publish
107-
npm run changelog
89+
Releases on `17.x.x` are managed by local scripts and GitHub Actions:
90+
91+
```bash
92+
git switch 17.x.x
93+
git switch -c <my_release_branch>
94+
export GH_TOKEN=<token> # required to build changelog via GitHub API requests
95+
npm run release:prepare -- 17.x.x patch
10896
```
10997

110-
Then upload the changelog to [https://github.com/graphql/graphql-js/releases](https://github.com/graphql/graphql-js/releases).
98+
Push `<my_release_branch>`, open a PR from `<my_release_branch>` to `17.x.x`, wait for CI to pass, and merge.
99+
100+
After merge, GitHub Actions runs the publish flow automatically. It is currently configured as a dry-run for testing.
111101

112102
## License
113103

.github/workflows/release.yml

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
name: Release
2+
on:
3+
push:
4+
branches:
5+
- 17.x.x
6+
permissions: {}
7+
jobs:
8+
check-publish:
9+
name: Check for publish need and prepare artifacts
10+
# Keep this guard on every job for defense-in-depth in case job dependencies are refactored.
11+
if: ${{ !github.event.repository.fork && github.repository == 'graphql/graphql-js' && github.ref_name == '17.x.x' }}
12+
runs-on: ubuntu-latest
13+
outputs:
14+
should_publish: ${{ steps.release_metadata.outputs.should_publish }}
15+
tag: ${{ steps.release_metadata.outputs.tag }}
16+
tarball_name: ${{ steps.release_metadata.outputs.tarball_name }}
17+
concurrency:
18+
group: ${{ github.workflow }}-${{ github.ref_name }}
19+
cancel-in-progress: true
20+
permissions:
21+
contents: read # for actions/checkout
22+
steps:
23+
- name: Checkout repo
24+
uses: actions/checkout@v4
25+
with:
26+
persist-credentials: false
27+
28+
- name: Setup Node.js
29+
uses: actions/setup-node@v4
30+
with:
31+
cache: npm
32+
node-version-file: '.node-version'
33+
34+
- name: Install Dependencies
35+
run: npm ci --ignore-scripts
36+
37+
- name: Read release metadata
38+
id: release_metadata
39+
run: |
40+
npm run --silent release:metadata >> "${GITHUB_OUTPUT}"
41+
42+
- name: Log publish decision
43+
run: |
44+
if [ "${{ steps.release_metadata.outputs.should_publish }}" = "true" ]; then
45+
echo "${{ steps.release_metadata.outputs.package_spec }} is not published yet."
46+
else
47+
echo "${{ steps.release_metadata.outputs.package_spec }} is already published."
48+
fi
49+
50+
- name: Build NPM package
51+
if: steps.release_metadata.outputs.should_publish == 'true'
52+
run: npm run build:npm
53+
54+
- name: Pack npmDist package
55+
if: steps.release_metadata.outputs.should_publish == 'true'
56+
run: npm pack ./npmDist --pack-destination . > /dev/null
57+
58+
- name: Upload npm package tarball
59+
if: steps.release_metadata.outputs.should_publish == 'true'
60+
uses: actions/upload-artifact@v4
61+
with:
62+
name: npmDist-tarball
63+
path: ./${{ steps.release_metadata.outputs.tarball_name }}
64+
65+
publish-release:
66+
name: Publish, tag, and create release
67+
needs: check-publish
68+
# Keep this guard on every job for defense-in-depth in case job dependencies are refactored.
69+
if: ${{ !github.event.repository.fork && github.repository == 'graphql/graphql-js' && github.ref_name == '17.x.x' && needs.check-publish.outputs.should_publish == 'true' && needs.check-publish.result == 'success' }}
70+
runs-on: ubuntu-latest
71+
environment: release
72+
permissions:
73+
contents: write # for creating/pushing tag and creating GitHub release
74+
id-token: write # for npm trusted publishing via OIDC
75+
steps:
76+
- name: Checkout repo
77+
uses: actions/checkout@v4
78+
with:
79+
persist-credentials: false
80+
81+
- name: Setup Node.js
82+
uses: actions/setup-node@v4
83+
with:
84+
node-version-file: '.node-version'
85+
86+
- name: Download npmDist package
87+
uses: actions/download-artifact@v4
88+
with:
89+
name: npmDist-tarball
90+
path: ./artifacts
91+
92+
- name: Verify package tarball is present
93+
run: |
94+
tarball="./artifacts/${{ needs.check-publish.outputs.tarball_name }}"
95+
if [ ! -f "${tarball}" ]; then
96+
echo "::error::Expected package tarball ${tarball} is missing."
97+
exit 1
98+
fi
99+
100+
- name: Create release if needed
101+
env:
102+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
103+
run: |
104+
release_notes_file="./artifacts/release-notes.md"
105+
gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}" --jq '.commit.message' > "${release_notes_file}"
106+
if [ ! -s "${release_notes_file}" ]; then
107+
printf '## Release %s\n' "${{ needs.check-publish.outputs.tag }}" > "${release_notes_file}"
108+
fi
109+
110+
if gh release view "${{ needs.check-publish.outputs.tag }}" > /dev/null 2>&1; then
111+
echo "GitHub release ${{ needs.check-publish.outputs.tag }} already exists."
112+
else
113+
gh release create "${{ needs.check-publish.outputs.tag }}" \
114+
--target "${GITHUB_SHA}" \
115+
--title "${{ needs.check-publish.outputs.tag }}" \
116+
--notes-file "${release_notes_file}"
117+
fi
118+
119+
- name: Dry-run npm publish
120+
run: npm publish --provenance --dry-run "./artifacts/${{ needs.check-publish.outputs.tarball_name }}"

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@
3333
"version": "node --import ./resources/register-ts-node.js resources/gen-version.ts && npm test && git add src/version.ts",
3434
"fuzzonly": "mocha --full-trace src/**/__tests__/**/*-fuzz.ts",
3535
"changelog": "node --import ./resources/register-ts-node.js resources/gen-changelog.ts",
36+
"release:prepare": "node --import ./resources/register-ts-node.js resources/release-prepare.ts",
37+
"release:metadata": "node --import ./resources/register-ts-node.js resources/release-metadata.ts",
3638
"benchmark": "node --import ./resources/register-ts-node.js resources/benchmark.ts",
3739
"test": "npm run lint && npm run check && npm run testonly:cover && npm run prettier:check && npm run check:spelling && npm run check:integrations",
3840
"lint": "eslint --cache --max-warnings 0 .",

resources/release-metadata.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { npm, readPackageJSON } from './utils.js';
2+
3+
try {
4+
const packageJSON = readPackageJSON();
5+
const { version } = packageJSON;
6+
7+
if (typeof version !== 'string' || version === '') {
8+
throw new Error('package.json is missing a valid "version" field.');
9+
}
10+
11+
const tag = `v${version}`;
12+
const packageSpec = `graphql@${version}`;
13+
const tarballName = `graphql-${version}.tgz`;
14+
15+
const versionsJSON = npm().view('graphql', 'versions', '--json');
16+
const parsedVersions = JSON.parse(versionsJSON) as unknown;
17+
const versions = Array.isArray(parsedVersions)
18+
? parsedVersions
19+
: [parsedVersions];
20+
const shouldPublish = versions.includes(version) ? 'false' : 'true';
21+
22+
process.stdout.write(`version=${version}\n`);
23+
process.stdout.write(`tag=${tag}\n`);
24+
process.stdout.write(`package_spec=${packageSpec}\n`);
25+
process.stdout.write(`tarball_name=${tarballName}\n`);
26+
process.stdout.write(`should_publish=${shouldPublish}\n`);
27+
} catch (error) {
28+
const message = error instanceof Error ? error.message : String(error);
29+
process.stderr.write(message + '\n');
30+
process.exit(1);
31+
}

resources/release-prepare.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { git, npm, readPackageJSON } from './utils.js';
2+
3+
let args: ParsedArgs;
4+
try {
5+
args = parseArgs(process.argv.slice(2));
6+
ensureGitHubToken();
7+
validateBranchState(args.releaseBranch);
8+
} catch (error) {
9+
console.error(error instanceof Error ? error.message : String(error));
10+
process.exit(1);
11+
}
12+
13+
console.log('Running npm version without creating a tag...');
14+
npm().version(...args.npmVersionArgs, '--no-git-tag-version');
15+
16+
const { version } = readPackageJSON();
17+
console.log(`Generating changelog for v${version}...`);
18+
const releaseChangelog = npm().runOutput('--silent', 'changelog');
19+
20+
console.log('Creating release commit...');
21+
git().add('package.json', 'package-lock.json', 'src/version.ts');
22+
git().commit('-m', releaseChangelog);
23+
24+
const currentBranch = git({ quiet: true }).revParse('--abbrev-ref', 'HEAD');
25+
26+
console.log('');
27+
console.log(`Release commit created for v${version}.`);
28+
console.log(
29+
`Next steps: push "${currentBranch}", open a PR to "${args.releaseBranch}", wait for CI, then merge.`,
30+
);
31+
32+
interface ParsedArgs {
33+
releaseBranch: string;
34+
npmVersionArgs: Array<string>;
35+
}
36+
37+
function parseArgs(rawArgs: ReadonlyArray<string>): ParsedArgs {
38+
const releaseBranch = rawArgs[0];
39+
if (releaseBranch == null || releaseBranch.trim() === '') {
40+
throwUsage('Missing required release branch as the first argument.');
41+
}
42+
43+
const npmVersionArgs = rawArgs.slice(1);
44+
if (npmVersionArgs.length === 0) {
45+
throwUsage(
46+
'Missing npm version arguments (e.g. patch, major, prerelease --preid alpha).',
47+
);
48+
}
49+
50+
return {
51+
releaseBranch,
52+
npmVersionArgs,
53+
};
54+
}
55+
56+
function validateBranchState(releaseBranch: string): void {
57+
const checkedBranch = git({ quiet: true }).revParse('--abbrev-ref', 'HEAD');
58+
if (checkedBranch === 'HEAD') {
59+
throw new Error(
60+
'Git is in detached HEAD state (not on a local branch). ' +
61+
'Switch to a local branch based on the release branch first, for example:\n' +
62+
` git switch -c release-${releaseBranch.replace(/[^a-zA-Z0-9._-]/g, '-')} ${releaseBranch}`,
63+
);
64+
}
65+
if (checkedBranch === releaseBranch) {
66+
throw new Error(
67+
`Release prepare must not run on "${releaseBranch}". Create a local release branch first.`,
68+
);
69+
}
70+
71+
const status = git().status('--porcelain').trim();
72+
if (status !== '') {
73+
throw new Error(
74+
'Working directory must be clean before running release:prepare.',
75+
);
76+
}
77+
78+
let releaseBranchHead: string;
79+
try {
80+
releaseBranchHead = git({ quiet: true }).revParse(releaseBranch);
81+
} catch {
82+
throw new Error(
83+
`Release branch "${releaseBranch}" does not exist locally.`,
84+
);
85+
}
86+
87+
let releaseBranchUpstream: string;
88+
try {
89+
releaseBranchUpstream = git({ quiet: true }).revParse(
90+
'--abbrev-ref',
91+
`${releaseBranch}@{upstream}`,
92+
);
93+
} catch {
94+
throw new Error(
95+
`Release branch "${releaseBranch}" does not track a remote branch. ` +
96+
'Set one first (for example: git branch --set-upstream-to ' +
97+
`<remote>/${releaseBranch} ${releaseBranch}).`,
98+
);
99+
}
100+
101+
const upstreamRemote = releaseBranchUpstream.split('/')[0];
102+
try {
103+
git().fetch('--quiet', upstreamRemote, releaseBranch);
104+
} catch {
105+
throw new Error(
106+
`Failed to fetch "${releaseBranchUpstream}". ` +
107+
'Verify network access and git remote configuration, then retry.',
108+
);
109+
}
110+
111+
const upstreamReleaseBranchHead = git({ quiet: true }).revParse(
112+
`${releaseBranch}@{upstream}`,
113+
);
114+
if (releaseBranchHead !== upstreamReleaseBranchHead) {
115+
throw new Error(
116+
`Local "${releaseBranch}" is not up to date with "${releaseBranchUpstream}". ` +
117+
`Update it first (for example: git switch ${releaseBranch} && git pull --ff-only).`,
118+
);
119+
}
120+
121+
const currentHead = git({ quiet: true }).revParse('HEAD');
122+
if (currentHead !== releaseBranchHead) {
123+
throw new Error(
124+
`Current branch "${checkedBranch}" must match "${releaseBranch}" before preparing a release.`,
125+
);
126+
}
127+
}
128+
129+
function ensureGitHubToken(): void {
130+
if (process.env.GH_TOKEN == null || process.env.GH_TOKEN.trim() === '') {
131+
throw new Error(
132+
'Missing GH_TOKEN environment variable.\n' +
133+
'Example: GH_TOKEN=<token> npm run release:prepare -- 17.x.x patch',
134+
);
135+
}
136+
}
137+
138+
function throwUsage(message: string): never {
139+
throw new Error(
140+
`${message}\n` +
141+
'Usage: npm run release:prepare -- <release-branch> <npm version args>\n' +
142+
'Examples:\n' +
143+
' npm run release:prepare -- 17.x.x patch\n' +
144+
' npm run release:prepare -- 17.x.x prerelease --preid alpha',
145+
);
146+
}

0 commit comments

Comments
 (0)