Skip to content

Commit 2a087b3

Browse files
authored
internal: improve release workflow (#4606)
- convert release-metadata.ts output to JSON - allow release-metadata to scroll back to release commit body for changelog - move jq parsing of commit body into release-metadata.ts - split github release and npm publish into separate steps for more granular permissions - publish with explicit dist-tag - remove dry-run!
1 parent 69707ad commit 2a087b3

File tree

4 files changed

+124
-42
lines changed

4 files changed

+124
-42
lines changed

.github/workflows/release.yml

Lines changed: 59 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ jobs:
1313
outputs:
1414
should_publish: ${{ steps.release_metadata.outputs.should_publish }}
1515
tag: ${{ steps.release_metadata.outputs.tag }}
16+
dist_tag: ${{ steps.release_metadata.outputs.dist_tag }}
17+
prerelease: ${{ steps.release_metadata.outputs.prerelease }}
1618
tarball_name: ${{ steps.release_metadata.outputs.tarball_name }}
1719
concurrency:
1820
group: ${{ github.workflow }}-${{ github.ref_name }}
@@ -23,6 +25,10 @@ jobs:
2325
- name: Checkout repo
2426
uses: actions/checkout@v4
2527
with:
28+
# Keep checkout fast: we should only need to scroll back a few
29+
# commits for release notes. If the release commit is older than
30+
# this depth, release:metadata will emit empty release notes.
31+
fetch-depth: 10
2632
persist-credentials: false
2733

2834
- name: Setup Node.js
@@ -37,7 +43,17 @@ jobs:
3743
- name: Read release metadata
3844
id: release_metadata
3945
run: |
40-
npm run --silent release:metadata >> "${GITHUB_OUTPUT}"
46+
release_metadata_json="$(npm run --silent release:metadata)"
47+
jq -r '
48+
"version=\(.version)",
49+
"tag=\(.tag)",
50+
"dist_tag=\(.distTag)",
51+
"prerelease=\(.prerelease)",
52+
"package_spec=\(.packageSpec)",
53+
"tarball_name=\(.tarballName)",
54+
"should_publish=\(.shouldPublish)"
55+
' <<< "${release_metadata_json}" >> "${GITHUB_OUTPUT}"
56+
jq -r '.releaseNotes' <<< "${release_metadata_json}" > ./release-notes.md
4157
4258
- name: Log publish decision
4359
run: |
@@ -62,22 +78,23 @@ jobs:
6278
name: npmDist-tarball
6379
path: ./${{ steps.release_metadata.outputs.tarball_name }}
6480

65-
publish-release:
66-
name: Publish, tag, and create release
81+
- name: Upload release notes
82+
if: steps.release_metadata.outputs.should_publish == 'true'
83+
uses: actions/upload-artifact@v4
84+
with:
85+
name: release-notes
86+
path: ./release-notes.md
87+
88+
publish-npm:
89+
name: Publish npm package
6790
needs: check-publish
6891
# Keep this guard on every job for defense-in-depth in case job dependencies are refactored.
6992
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' }}
7093
runs-on: ubuntu-latest
7194
environment: release
7295
permissions:
73-
contents: write # for creating/pushing tag and creating GitHub release
7496
id-token: write # for npm trusted publishing via OIDC
7597
steps:
76-
- name: Checkout repo
77-
uses: actions/checkout@v4
78-
with:
79-
persist-credentials: false
80-
8198
- name: Setup Node.js
8299
uses: actions/setup-node@v4
83100
with:
@@ -89,34 +106,50 @@ jobs:
89106
name: npmDist-tarball
90107
path: ./artifacts
91108

92-
- name: Verify package tarball is present
109+
- name: Dry-run npm publish
93110
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
111+
if [ -n "${{ needs.check-publish.outputs.dist_tag }}" ]; then
112+
npm publish --provenance --tag "${{ needs.check-publish.outputs.dist_tag }}" "./artifacts/${{ needs.check-publish.outputs.tarball_name }}"
113+
else
114+
npm publish --provenance "./artifacts/${{ needs.check-publish.outputs.tarball_name }}"
98115
fi
99116
100-
- name: Create release if needed
117+
create-release:
118+
name: Create release
119+
needs: check-publish
120+
# Keep this guard on every job for defense-in-depth in case job dependencies are refactored.
121+
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' }}
122+
runs-on: ubuntu-latest
123+
environment: release
124+
permissions:
125+
contents: write # for creating GitHub release
126+
steps:
127+
- name: Download release notes
128+
uses: actions/download-artifact@v4
129+
with:
130+
name: release-notes
131+
path: ./artifacts
132+
133+
- name: Create release
101134
env:
102135
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
103136
run: |
104-
release_notes_file="./artifacts/release-notes.md"
105-
gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}" \
106-
--jq '.commit.message | split("\n")[1:] | join("\n") | ltrimstr("\n")' \
107-
> "${release_notes_file}"
108-
if [ ! -s "${release_notes_file}" ]; then
109-
printf '## Release %s\n' "${{ needs.check-publish.outputs.tag }}" > "${release_notes_file}"
137+
if gh release view "${{ needs.check-publish.outputs.tag }}" > /dev/null 2>&1; then
138+
echo "GitHub release ${{ needs.check-publish.outputs.tag }} already exists. Skipping release creation."
139+
exit 0
110140
fi
111141
112-
if gh release view "${{ needs.check-publish.outputs.tag }}" > /dev/null 2>&1; then
113-
echo "GitHub release ${{ needs.check-publish.outputs.tag }} already exists."
142+
release_notes_file="./artifacts/release-notes.md"
143+
144+
if [ "${{ needs.check-publish.outputs.prerelease }}" = "true" ]; then
145+
gh release create "${{ needs.check-publish.outputs.tag }}" \
146+
--target "${GITHUB_SHA}" \
147+
--title "${{ needs.check-publish.outputs.tag }}" \
148+
--notes-file "${release_notes_file}" \
149+
--prerelease
114150
else
115151
gh release create "${{ needs.check-publish.outputs.tag }}" \
116152
--target "${GITHUB_SHA}" \
117153
--title "${{ needs.check-publish.outputs.tag }}" \
118154
--notes-file "${release_notes_file}"
119155
fi
120-
121-
- name: Dry-run npm publish
122-
run: npm publish --provenance --dry-run "./artifacts/${{ needs.check-publish.outputs.tarball_name }}"

resources/gen-changelog.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { git, readPackageJSON } from './utils.js';
1+
import { git, readPackageJSON, readPackageJSONAtRef } from './utils.js';
22

33
const packageJSON = readPackageJSON();
44
const labelsConfig: { [label: string]: { section: string; fold?: boolean } } = {
@@ -70,11 +70,6 @@ function parseFromRevArg(rawArgs: ReadonlyArray<string>): string | null {
7070
);
7171
}
7272

73-
function readVersionFromPackageJSONAtRef(ref: string): string {
74-
const packageJSONAtRef = git().catFile('blob', `${ref}:package.json`);
75-
return JSON.parse(packageJSONAtRef).version;
76-
}
77-
7873
function resolveChangelogRangeConfig(
7974
workingTreeVersion: string,
8075
fromRev: string | null,
@@ -98,7 +93,7 @@ function resolveChangelogRangeConfig(
9893
};
9994
}
10095

101-
const headVersion = readVersionFromPackageJSONAtRef('HEAD');
96+
const headVersion = readPackageJSONAtRef('HEAD').version;
10297
const headReleaseTag = `v${headVersion}`;
10398

10499
// Supported scenario 2: release preparation started
@@ -116,7 +111,7 @@ function resolveChangelogRangeConfig(
116111
// - release preparation committed
117112
// - working-tree version tag equal to HEAD version tag, both not yet created
118113
// - HEAD~1 version tag exists
119-
const parentVersion = readVersionFromPackageJSONAtRef('HEAD~1');
114+
const parentVersion = readPackageJSONAtRef('HEAD~1').version;
120115
const parentTag = `v${parentVersion}`;
121116
const parentTagExists = git().tagExists(parentTag);
122117
if (workingTreeReleaseTag === headReleaseTag && parentTagExists) {

resources/release-metadata.ts

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,32 @@
1-
import { npm, readPackageJSON } from './utils.js';
1+
import { git, npm, readPackageJSON, readPackageJSONAtRef } from './utils.js';
2+
3+
interface ReleaseMetadata {
4+
version: string;
5+
tag: string;
6+
distTag: string;
7+
prerelease: boolean;
8+
releaseNotes: string;
9+
packageSpec: string;
10+
tarballName: string;
11+
shouldPublish: boolean;
12+
}
213

314
try {
415
const packageJSON = readPackageJSON();
5-
const { version } = packageJSON;
16+
const { version, publishConfig } = packageJSON;
617

718
if (typeof version !== 'string' || version === '') {
819
throw new Error('package.json is missing a valid "version" field.');
920
}
1021

1122
const tag = `v${version}`;
23+
const distTag = publishConfig?.tag ?? '';
24+
const prerelease = distTag === 'alpha';
25+
const releaseCommitSha = findReleaseCommitSha(version);
26+
const releaseNotes =
27+
releaseCommitSha == null
28+
? ''
29+
: git().log('-1', '--format=%b', releaseCommitSha).trim();
1230
const packageSpec = `graphql@${version}`;
1331
const tarballName = `graphql-${version}.tgz`;
1432

@@ -17,15 +35,46 @@ try {
1735
const versions = Array.isArray(parsedVersions)
1836
? parsedVersions
1937
: [parsedVersions];
20-
const shouldPublish = versions.includes(version) ? 'false' : 'true';
38+
const shouldPublish = !versions.includes(version);
39+
const releaseMetadata: ReleaseMetadata = {
40+
version,
41+
tag,
42+
distTag,
43+
prerelease,
44+
releaseNotes,
45+
packageSpec,
46+
tarballName,
47+
shouldPublish,
48+
};
2149

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`);
50+
process.stdout.write(JSON.stringify(releaseMetadata) + '\n');
2751
} catch (error) {
2852
const message = error instanceof Error ? error.message : String(error);
2953
process.stderr.write(message + '\n');
3054
process.exit(1);
3155
}
56+
57+
function findReleaseCommitSha(version: string): string | null {
58+
const commitsTouchingPackageJSON = git().revList(
59+
'--first-parent',
60+
'--reverse',
61+
'HEAD',
62+
'--',
63+
'package.json',
64+
);
65+
66+
let previousVersion: string | null = null;
67+
for (const commit of commitsTouchingPackageJSON) {
68+
const versionAtCommit = readPackageJSONAtRef(commit).version;
69+
if (versionAtCommit === version && previousVersion !== version) {
70+
return commit;
71+
}
72+
previousVersion = versionAtCommit;
73+
}
74+
75+
process.stderr.write(
76+
`Warning: Unable to find commit introducing version ${version} in fetched history. ` +
77+
`Release notes will be empty for this run.\n`,
78+
);
79+
return null;
80+
}

resources/utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,11 @@ export function readPackageJSON(
300300
return JSON.parse(fs.readFileSync(filepath, 'utf-8'));
301301
}
302302

303+
export function readPackageJSONAtRef(ref: string): PackageJSON {
304+
const packageJSONAtRef = git().catFile('blob', `${ref}:package.json`);
305+
return JSON.parse(packageJSONAtRef);
306+
}
307+
303308
export function readTSConfig(overrides?: any): ts.CompilerOptions {
304309
const tsConfigPath = localRepoPath('tsconfig.json');
305310

0 commit comments

Comments
 (0)