From 91fa60e146eef5cc929abe70fed5c4a64826057f Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Mon, 2 Mar 2026 16:34:41 +0200 Subject: [PATCH] internal: improve release workflow - 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! --- .github/workflows/release.yml | 85 ++++++++++++++++++++++++----------- resources/gen-changelog.ts | 11 ++--- resources/release-metadata.ts | 65 +++++++++++++++++++++++---- resources/utils.ts | 5 +++ 4 files changed, 124 insertions(+), 42 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b214004d7..e6bdc9cd3b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,8 @@ jobs: outputs: should_publish: ${{ steps.release_metadata.outputs.should_publish }} tag: ${{ steps.release_metadata.outputs.tag }} + dist_tag: ${{ steps.release_metadata.outputs.dist_tag }} + prerelease: ${{ steps.release_metadata.outputs.prerelease }} tarball_name: ${{ steps.release_metadata.outputs.tarball_name }} concurrency: group: ${{ github.workflow }}-${{ github.ref_name }} @@ -23,6 +25,10 @@ jobs: - name: Checkout repo uses: actions/checkout@v4 with: + # Keep checkout fast: we should only need to scroll back a few + # commits for release notes. If the release commit is older than + # this depth, release:metadata will emit empty release notes. + fetch-depth: 10 persist-credentials: false - name: Setup Node.js @@ -37,7 +43,17 @@ jobs: - name: Read release metadata id: release_metadata run: | - npm run --silent release:metadata >> "${GITHUB_OUTPUT}" + release_metadata_json="$(npm run --silent release:metadata)" + jq -r ' + "version=\(.version)", + "tag=\(.tag)", + "dist_tag=\(.distTag)", + "prerelease=\(.prerelease)", + "package_spec=\(.packageSpec)", + "tarball_name=\(.tarballName)", + "should_publish=\(.shouldPublish)" + ' <<< "${release_metadata_json}" >> "${GITHUB_OUTPUT}" + jq -r '.releaseNotes' <<< "${release_metadata_json}" > ./release-notes.md - name: Log publish decision run: | @@ -62,22 +78,23 @@ jobs: name: npmDist-tarball path: ./${{ steps.release_metadata.outputs.tarball_name }} - publish-release: - name: Publish, tag, and create release + - name: Upload release notes + if: steps.release_metadata.outputs.should_publish == 'true' + uses: actions/upload-artifact@v4 + with: + name: release-notes + path: ./release-notes.md + + publish-npm: + name: Publish npm package needs: check-publish # Keep this guard on every job for defense-in-depth in case job dependencies are refactored. 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' }} runs-on: ubuntu-latest environment: release permissions: - contents: write # for creating/pushing tag and creating GitHub release id-token: write # for npm trusted publishing via OIDC steps: - - name: Checkout repo - uses: actions/checkout@v4 - with: - persist-credentials: false - - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -89,34 +106,50 @@ jobs: name: npmDist-tarball path: ./artifacts - - name: Verify package tarball is present + - name: Dry-run npm publish run: | - tarball="./artifacts/${{ needs.check-publish.outputs.tarball_name }}" - if [ ! -f "${tarball}" ]; then - echo "::error::Expected package tarball ${tarball} is missing." - exit 1 + if [ -n "${{ needs.check-publish.outputs.dist_tag }}" ]; then + npm publish --provenance --tag "${{ needs.check-publish.outputs.dist_tag }}" "./artifacts/${{ needs.check-publish.outputs.tarball_name }}" + else + npm publish --provenance "./artifacts/${{ needs.check-publish.outputs.tarball_name }}" fi - - name: Create release if needed + create-release: + name: Create release + needs: check-publish + # Keep this guard on every job for defense-in-depth in case job dependencies are refactored. + 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' }} + runs-on: ubuntu-latest + environment: release + permissions: + contents: write # for creating GitHub release + steps: + - name: Download release notes + uses: actions/download-artifact@v4 + with: + name: release-notes + path: ./artifacts + + - name: Create release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - release_notes_file="./artifacts/release-notes.md" - gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}" \ - --jq '.commit.message | split("\n")[1:] | join("\n") | ltrimstr("\n")' \ - > "${release_notes_file}" - if [ ! -s "${release_notes_file}" ]; then - printf '## Release %s\n' "${{ needs.check-publish.outputs.tag }}" > "${release_notes_file}" + if gh release view "${{ needs.check-publish.outputs.tag }}" > /dev/null 2>&1; then + echo "GitHub release ${{ needs.check-publish.outputs.tag }} already exists. Skipping release creation." + exit 0 fi - if gh release view "${{ needs.check-publish.outputs.tag }}" > /dev/null 2>&1; then - echo "GitHub release ${{ needs.check-publish.outputs.tag }} already exists." + release_notes_file="./artifacts/release-notes.md" + + if [ "${{ needs.check-publish.outputs.prerelease }}" = "true" ]; then + gh release create "${{ needs.check-publish.outputs.tag }}" \ + --target "${GITHUB_SHA}" \ + --title "${{ needs.check-publish.outputs.tag }}" \ + --notes-file "${release_notes_file}" \ + --prerelease else gh release create "${{ needs.check-publish.outputs.tag }}" \ --target "${GITHUB_SHA}" \ --title "${{ needs.check-publish.outputs.tag }}" \ --notes-file "${release_notes_file}" fi - - - name: Dry-run npm publish - run: npm publish --provenance --dry-run "./artifacts/${{ needs.check-publish.outputs.tarball_name }}" diff --git a/resources/gen-changelog.ts b/resources/gen-changelog.ts index 18b442a7bd..590ba10b4b 100644 --- a/resources/gen-changelog.ts +++ b/resources/gen-changelog.ts @@ -1,4 +1,4 @@ -import { git, readPackageJSON } from './utils.js'; +import { git, readPackageJSON, readPackageJSONAtRef } from './utils.js'; const packageJSON = readPackageJSON(); const labelsConfig: { [label: string]: { section: string; fold?: boolean } } = { @@ -70,11 +70,6 @@ function parseFromRevArg(rawArgs: ReadonlyArray): string | null { ); } -function readVersionFromPackageJSONAtRef(ref: string): string { - const packageJSONAtRef = git().catFile('blob', `${ref}:package.json`); - return JSON.parse(packageJSONAtRef).version; -} - function resolveChangelogRangeConfig( workingTreeVersion: string, fromRev: string | null, @@ -98,7 +93,7 @@ function resolveChangelogRangeConfig( }; } - const headVersion = readVersionFromPackageJSONAtRef('HEAD'); + const headVersion = readPackageJSONAtRef('HEAD').version; const headReleaseTag = `v${headVersion}`; // Supported scenario 2: release preparation started @@ -116,7 +111,7 @@ function resolveChangelogRangeConfig( // - release preparation committed // - working-tree version tag equal to HEAD version tag, both not yet created // - HEAD~1 version tag exists - const parentVersion = readVersionFromPackageJSONAtRef('HEAD~1'); + const parentVersion = readPackageJSONAtRef('HEAD~1').version; const parentTag = `v${parentVersion}`; const parentTagExists = git().tagExists(parentTag); if (workingTreeReleaseTag === headReleaseTag && parentTagExists) { diff --git a/resources/release-metadata.ts b/resources/release-metadata.ts index 0693c12491..499eb70510 100644 --- a/resources/release-metadata.ts +++ b/resources/release-metadata.ts @@ -1,14 +1,32 @@ -import { npm, readPackageJSON } from './utils.js'; +import { git, npm, readPackageJSON, readPackageJSONAtRef } from './utils.js'; + +interface ReleaseMetadata { + version: string; + tag: string; + distTag: string; + prerelease: boolean; + releaseNotes: string; + packageSpec: string; + tarballName: string; + shouldPublish: boolean; +} try { const packageJSON = readPackageJSON(); - const { version } = packageJSON; + const { version, publishConfig } = packageJSON; if (typeof version !== 'string' || version === '') { throw new Error('package.json is missing a valid "version" field.'); } const tag = `v${version}`; + const distTag = publishConfig?.tag ?? ''; + const prerelease = distTag === 'alpha'; + const releaseCommitSha = findReleaseCommitSha(version); + const releaseNotes = + releaseCommitSha == null + ? '' + : git().log('-1', '--format=%b', releaseCommitSha).trim(); const packageSpec = `graphql@${version}`; const tarballName = `graphql-${version}.tgz`; @@ -17,15 +35,46 @@ try { const versions = Array.isArray(parsedVersions) ? parsedVersions : [parsedVersions]; - const shouldPublish = versions.includes(version) ? 'false' : 'true'; + const shouldPublish = !versions.includes(version); + const releaseMetadata: ReleaseMetadata = { + version, + tag, + distTag, + prerelease, + releaseNotes, + packageSpec, + tarballName, + shouldPublish, + }; - process.stdout.write(`version=${version}\n`); - process.stdout.write(`tag=${tag}\n`); - process.stdout.write(`package_spec=${packageSpec}\n`); - process.stdout.write(`tarball_name=${tarballName}\n`); - process.stdout.write(`should_publish=${shouldPublish}\n`); + process.stdout.write(JSON.stringify(releaseMetadata) + '\n'); } catch (error) { const message = error instanceof Error ? error.message : String(error); process.stderr.write(message + '\n'); process.exit(1); } + +function findReleaseCommitSha(version: string): string | null { + const commitsTouchingPackageJSON = git().revList( + '--first-parent', + '--reverse', + 'HEAD', + '--', + 'package.json', + ); + + let previousVersion: string | null = null; + for (const commit of commitsTouchingPackageJSON) { + const versionAtCommit = readPackageJSONAtRef(commit).version; + if (versionAtCommit === version && previousVersion !== version) { + return commit; + } + previousVersion = versionAtCommit; + } + + process.stderr.write( + `Warning: Unable to find commit introducing version ${version} in fetched history. ` + + `Release notes will be empty for this run.\n`, + ); + return null; +} diff --git a/resources/utils.ts b/resources/utils.ts index 30272406b7..919961be15 100644 --- a/resources/utils.ts +++ b/resources/utils.ts @@ -300,6 +300,11 @@ export function readPackageJSON( return JSON.parse(fs.readFileSync(filepath, 'utf-8')); } +export function readPackageJSONAtRef(ref: string): PackageJSON { + const packageJSONAtRef = git().catFile('blob', `${ref}:package.json`); + return JSON.parse(packageJSONAtRef); +} + export function readTSConfig(overrides?: any): ts.CompilerOptions { const tsConfigPath = localRepoPath('tsconfig.json');