From 216fbf0ffa4e686925ff0f6254338e165dde4f96 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 4 Mar 2026 10:05:06 +0200 Subject: [PATCH 1/2] internal: backport new release flow from 17.x.x --- .github/CONTRIBUTING.md | 28 ++--- .github/workflows/release.yml | 166 ++++++++++++++++++++++++++++++ package.json | 8 +- resources/checkgit.sh | 39 ------- resources/gen-changelog.js | 122 ++++++++++++++++++---- resources/release-metadata.js | 90 ++++++++++++++++ resources/release-prepare.js | 187 ++++++++++++++++++++++++++++++++++ resources/utils.js | 60 +++++++++++ 8 files changed, 620 insertions(+), 80 deletions(-) create mode 100644 .github/workflows/release.yml delete mode 100644 resources/checkgit.sh create mode 100644 resources/release-metadata.js create mode 100644 resources/release-prepare.js diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 820df3e461..d0158fe243 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -76,25 +76,19 @@ ensure your pull request matches the style guides, run `npm run prettier`. ## Release on NPM -_Only core contributors may release to NPM._ - -To release a new version on NPM, first ensure all tests pass with `npm test`, -then use `npm version patch|minor|major` in order to increment the version in -package.json and tag and commit a release. Then `git push && git push --tags` -to sync this change with source control. Then `npm publish npmDist` to actually -publish the release to NPM. -Once published, add [release notes](https://github.com/graphql/graphql-js/tags). -Use [semver](https://semver.org/) to determine which version part to increment. - -Example for a patch release: - -```sh -npm test -npm version patch --ignore-scripts=false -git push --follow-tags -cd npmDist && npm publish +Releases on `16.x.x` are managed by local scripts and GitHub Actions: + +```bash +git switch 16.x.x +git switch -c +export GH_TOKEN= # required to build changelog via GitHub API requests +npm run release:prepare -- 16.x.x patch ``` +Push ``, open a PR from `` to `16.x.x`, +wait for CI to pass, merge the PR, and then approve the GitHub Actions release +workflow. + ## License By contributing to graphql-js, you agree that your contributions will be diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..e90f2f8b39 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,166 @@ +name: Release +on: + push: + branches: + - 16.x.x +permissions: {} +jobs: + check-publish: + name: Check for publish need and prepare artifacts + # 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 == '16.x.x' }} + runs-on: ubuntu-latest + 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 }} + cancel-in-progress: true + permissions: + contents: read # for actions/checkout + steps: + - 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 + uses: actions/setup-node@v4 + with: + cache: npm + node-version-file: '.node-version' + + - name: Install Dependencies + run: npm ci --ignore-scripts + + - name: Read release metadata + id: release_metadata + run: | + 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: | + if [ "${{ steps.release_metadata.outputs.should_publish }}" = "true" ]; then + echo "${{ steps.release_metadata.outputs.package_spec }} is not published yet." + else + echo "${{ steps.release_metadata.outputs.package_spec }} is already published." + fi + + - name: Build NPM package + if: steps.release_metadata.outputs.should_publish == 'true' + run: npm run build:npm + + - name: Pack npmDist package + if: steps.release_metadata.outputs.should_publish == 'true' + run: npm pack ./npmDist --pack-destination . > /dev/null + + - name: Upload npm package tarball + if: steps.release_metadata.outputs.should_publish == 'true' + uses: actions/upload-artifact@v4 + with: + name: npmDist-tarball + path: ./${{ steps.release_metadata.outputs.tarball_name }} + + - 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 == '16.x.x' && needs.check-publish.outputs.should_publish == 'true' && needs.check-publish.result == 'success' }} + runs-on: ubuntu-latest + environment: release + permissions: + contents: read # for actions/checkout + 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: + node-version-file: '.node-version' + + - name: Download npmDist package + uses: actions/download-artifact@v4 + with: + name: npmDist-tarball + path: ./artifacts + + - name: Publish npm package + run: | + 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 + + 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 == '16.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: Checkout repo + uses: actions/checkout@v4 + with: + persist-credentials: false + + - 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: | + 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 + + 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 diff --git a/package.json b/package.json index e73f16e1e9..f117839cbb 100644 --- a/package.json +++ b/package.json @@ -30,10 +30,10 @@ "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" }, "scripts": { - "preversion": "bash -c '. ./resources/checkgit.sh && npm ci --ignore-scripts'", - "version": "node resources/gen-version.js && npm test && git add src/version.ts", "fuzzonly": "mocha --full-trace src/**/__tests__/**/*-fuzz.ts", "changelog": "node resources/gen-changelog.js", + "release:prepare": "node resources/release-prepare.js", + "release:metadata": "node resources/release-metadata.js", "benchmark": "node benchmark/benchmark.js", "test": "npm run lint && npm run check && npm run testonly && npm run prettier:check && npm run check:spelling && npm run check:integrations", "lint": "eslint --cache --max-warnings 0 .", @@ -45,9 +45,7 @@ "check:spelling": "cspell --cache --no-progress '**/*'", "check:integrations": "npm run build:npm && npm run build:deno && mocha --full-trace integrationTests/*-test.js", "build:npm": "node resources/build-npm.js", - "build:deno": "node resources/build-deno.js", - "gitpublish:npm": "bash ./resources/gitpublish.sh npm npmDist", - "gitpublish:deno": "bash ./resources/gitpublish.sh deno denoDist" + "build:deno": "node resources/build-deno.js" }, "devDependencies": { "@babel/core": "7.17.9", diff --git a/resources/checkgit.sh b/resources/checkgit.sh deleted file mode 100644 index e5e4c67cf2..0000000000 --- a/resources/checkgit.sh +++ /dev/null @@ -1,39 +0,0 @@ -# Exit immediately if any subcommand terminated -set -e -trap "exit 1" ERR - -# -# This script determines if current git state is the up to date main. If so -# it exits normally. If not it prompts for an explicit continue. This script -# intends to protect from versioning for NPM without first pushing changes -# and including any changes on main. -# - -# Check that local copy has no modifications -GIT_MODIFIED_FILES=$(git ls-files -dm 2> /dev/null); -GIT_STAGED_FILES=$(git diff --cached --name-only 2> /dev/null); -if [ "$GIT_MODIFIED_FILES" != "" -o "$GIT_STAGED_FILES" != "" ]; then - read -p "Git has local modifications. Continue? (y|N) " yn; - if [ "$yn" != "y" ]; then exit 1; fi; -fi; - -# First fetch to ensure git is up to date. Fail-fast if this fails. -git fetch; -if [[ $? -ne 0 ]]; then exit 1; fi; - -# Extract useful information. -GIT_BRANCH=$(git branch -v 2> /dev/null | sed '/^[^*]/d'); -GIT_BRANCH_NAME=$(echo "$GIT_BRANCH" | sed 's/* \([A-Za-z0-9_\-]*\).*/\1/'); -GIT_BRANCH_SYNC=$(echo "$GIT_BRANCH" | sed 's/* [^[]*.\([^]]*\).*/\1/'); - -# Check if main is checked out -if [ "$GIT_BRANCH_NAME" != "main" ]; then - read -p "Git not on main but $GIT_BRANCH_NAME. Continue? (y|N) " yn; - if [ "$yn" != "y" ]; then exit 1; fi; -fi; - -# Check if branch is synced with remote -if [ "$GIT_BRANCH_SYNC" != "" ]; then - read -p "Git not up to date but $GIT_BRANCH_SYNC. Continue? (y|N) " yn; - if [ "$yn" != "y" ]; then exit 1; fi; -fi; diff --git a/resources/gen-changelog.js b/resources/gen-changelog.js index 02bb634050..654a594caf 100644 --- a/resources/gen-changelog.js +++ b/resources/gen-changelog.js @@ -5,7 +5,7 @@ const https = require('https'); const packageJSON = require('../package.json'); -const { exec } = require('./utils.js'); +const { exec, readPackageJSONAtRef, tagExists } = require('./utils.js'); const graphqlRequest = util.promisify(graphqlRequestImpl); const labelsConfig = { @@ -68,26 +68,97 @@ getChangeLog() }); function getChangeLog() { - const { version } = packageJSON; - - let tag = null; - let commitsList = exec(`git rev-list --reverse v${version}..`); - if (commitsList === '') { - const parentPackageJSON = exec('git cat-file blob HEAD~1:package.json'); - const parentVersion = JSON.parse(parentPackageJSON).version; - commitsList = exec(`git rev-list --reverse v${parentVersion}..HEAD~1`); - tag = `v${version}`; - } + const workingTreeVersion = packageJSON.version; + const fromRev = parseFromRevArg(process.argv.slice(2)); + const { title, rangeStart, rangeEnd } = resolveChangelogRangeConfig( + workingTreeVersion, + fromRev, + ); + const commitsRange = `${rangeStart}..${rangeEnd}`; + const commitsListOutput = exec(`git rev-list --reverse ${commitsRange}`); + const commitsList = + commitsListOutput === '' ? [] : commitsListOutput.split('\n'); const date = exec('git log -1 --format=%cd --date=short'); - return getCommitsInfo(commitsList.split('\n')) + return getCommitsInfo(commitsList) .then((commitsInfo) => getPRsInfo(commitsInfoToPRs(commitsInfo))) - .then((prsInfo) => genChangeLog(tag, date, prsInfo)); + .then((prsInfo) => genChangeLog(title, date, prsInfo)); +} + +function parseFromRevArg(rawArgs) { + if (rawArgs.length === 0) { + return null; + } + + if (rawArgs.length === 1 && rawArgs[0].trim() !== '') { + return rawArgs[0]; + } + + throw new Error( + 'Usage: npm run changelog [-- ]\n' + + 'Example: npm run changelog -- d41f59bbfdfc207712a2fc3778934694a3410ddf', + ); +} + +function resolveChangelogRangeConfig(workingTreeVersion, fromRev) { + const workingTreeReleaseTag = `v${workingTreeVersion}`; + + // packageJSON in the working tree can differ from HEAD:package.json during + // release:prepare after npm version updates files but before committing. + // Supported scenario 1: release preparation not started + // - working-tree version tag exists + // - HEAD version older than or equal to working-tree version, must also exist + if (tagExists(workingTreeReleaseTag)) { + return { + title: 'Unreleased', + rangeStart: fromRev || workingTreeReleaseTag, + rangeEnd: 'HEAD', + }; + } + + const headVersion = readPackageJSONAtRef('HEAD').version; + const headReleaseTag = `v${headVersion}`; + + // Supported scenario 2: release preparation started + // - working-tree version tag not yet created + // - HEAD version tag exists + if (tagExists(headReleaseTag)) { + return { + title: workingTreeReleaseTag, + rangeStart: fromRev || headReleaseTag, + rangeEnd: 'HEAD', + }; + } + + // Supported scenario 3: + // - release preparation committed + // - working-tree version tag equal to HEAD version tag, both not yet created + // - HEAD~1 version tag exists + const parentVersion = readPackageJSONAtRef('HEAD~1').version; + const parentTag = `v${parentVersion}`; + const parentTagExists = tagExists(parentTag); + if (workingTreeReleaseTag === headReleaseTag && parentTagExists) { + console.warn('Release committed, should already contain this changelog!'); + + return { + title: workingTreeReleaseTag, + rangeStart: fromRev || parentTag, + rangeEnd: 'HEAD~1', + }; + } + + throw new Error( + 'Unable to determine changelog range. One of the following scenarios must be true:\n' + + '1) HEAD/working-tree release tags exist, i.e. release preparation not started.\n' + + '2) HEAD release tag exists, but working-tree release tag not yet created, i.e. release preparation started, not yet committed.\n' + + '3) HEAD/working-tree release tags not yet created, i.e. release preparation committed, not yet released, no additional commits on branch.', + ); } -function genChangeLog(tag, date, allPRs) { +function genChangeLog(title, date, allPRs) { const byLabel = {}; const committersByLogin = {}; + const validationIssues = []; for (const pr of allPRs) { const labels = pr.labels.nodes @@ -95,24 +166,37 @@ function genChangeLog(tag, date, allPRs) { .filter((label) => label.startsWith('PR: ')); if (labels.length === 0) { - throw new Error(`PR is missing label. See ${pr.url}`); + validationIssues.push(`PR #${pr.number} is missing label. See ${pr.url}`); + continue; } + if (labels.length > 1) { - throw new Error( - `PR has conflicting labels: ${labels.join('\n')}\nSee ${pr.url}`, + validationIssues.push( + `PR #${pr.number} has conflicting labels: ${labels.join(', ')}\nSee ${ + pr.url + }`, ); + continue; } const label = labels[0]; if (!labelsConfig[label]) { - throw new Error(`Unknown label: ${label}. See ${pr.url}`); + validationIssues.push( + `PR #${pr.number} has unknown label: ${label}\nSee ${pr.url}`, + ); + continue; } + byLabel[label] = byLabel[label] || []; byLabel[label].push(pr); committersByLogin[pr.author.login] = pr.author; } - let changelog = `## ${tag || 'Unreleased'} (${date})\n`; + if (validationIssues.length > 0) { + throw new Error(validationIssues.join('\n\n')); + } + + let changelog = `## ${title} (${date})\n`; for (const [label, config] of Object.entries(labelsConfig)) { const prs = byLabel[label]; if (prs) { diff --git a/resources/release-metadata.js b/resources/release-metadata.js new file mode 100644 index 0000000000..73f2554cb9 --- /dev/null +++ b/resources/release-metadata.js @@ -0,0 +1,90 @@ +'use strict'; + +const { + readPackageJSON, + readPackageJSONAtRef, + spawnOutput, +} = require('./utils.js'); + +try { + const packageJSON = readPackageJSON(); + 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 + ? '' + : spawnOutput('git', [ + 'log', + '-1', + '--format=%b', + releaseCommitSha, + ]).trim(); + const packageSpec = `graphql@${version}`; + const tarballName = `graphql-${version}.tgz`; + + const versionsJSON = spawnOutput('npm', [ + 'view', + 'graphql', + 'versions', + '--json', + ]); + const parsedVersions = JSON.parse(versionsJSON); + const versions = Array.isArray(parsedVersions) + ? parsedVersions + : [parsedVersions]; + const shouldPublish = !versions.includes(version); + const releaseMetadata = { + version, + tag, + distTag, + prerelease, + releaseNotes, + packageSpec, + tarballName, + shouldPublish, + }; + + 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) { + const commitsTouchingPackageJSONOutput = spawnOutput('git', [ + 'rev-list', + '--first-parent', + '--reverse', + 'HEAD', + '--', + 'package.json', + ]); + const commitsTouchingPackageJSON = + commitsTouchingPackageJSONOutput === '' + ? [] + : commitsTouchingPackageJSONOutput.split('\n'); + + let previousVersion = 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/release-prepare.js b/resources/release-prepare.js new file mode 100644 index 0000000000..baa46c8955 --- /dev/null +++ b/resources/release-prepare.js @@ -0,0 +1,187 @@ +'use strict'; + +const { readPackageJSON, spawn, spawnOutput } = require('./utils.js'); + +let args; +try { + args = parseArgs(); + validateBranchState(args.releaseBranch); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +} + +console.log('Installing dependencies...'); +spawn('npm', ['ci', '--ignore-scripts']); + +console.log('Bumping package version without creating a tag...'); +spawn('npm', ['version', ...args.npmVersionArgs, '--no-git-tag-version']); + +console.log('Updating src/version.ts...'); +spawn('node', ['resources/gen-version.js']); + +console.log('Running test suite...'); +spawn('npm', ['run', 'test']); + +const { version } = readPackageJSON(); +console.log(`Generating changelog for v${version}...`); +const changelogArgs = ['run', '--silent', 'changelog']; +if (args.fromRev != null) { + changelogArgs.push('--', args.fromRev); +} +const releaseChangelog = spawnOutput('npm', changelogArgs); +const releaseCommitTitle = `chore(release): v${version}`; + +console.log('Creating release commit...'); +spawn('git', ['add', 'package.json', 'package-lock.json', 'src/version.ts']); +spawn('git', ['commit', '-m', releaseCommitTitle, '-m', releaseChangelog]); + +const currentBranch = spawnOutput('git', ['rev-parse', '--abbrev-ref', 'HEAD']); + +console.log(''); +console.log(`Release commit created for v${version}.`); +console.log( + `Next steps: push "${currentBranch}", open a PR to "${args.releaseBranch}", wait for CI, then merge.`, +); + +function parseArgs() { + const rawArgs = process.argv.slice(2); + const fromRevArgName = '--fromRev'; + let fromRev = null; + let releasePrepareArgs = rawArgs; + + if (rawArgs[0] === fromRevArgName) { + fromRev = rawArgs[1] || null; + releasePrepareArgs = rawArgs.slice(2); + } else if (rawArgs.includes(fromRevArgName)) { + throwUsage(`${fromRevArgName} must be the first argument when provided.`); + } + + const releaseBranch = releasePrepareArgs[0]; + if (releaseBranch == null || releaseBranch.trim() === '') { + throwUsage('Missing required release branch as the first argument.'); + } + if (releaseBranch.startsWith('-')) { + throwUsage( + 'Missing required release branch as the first argument (before options).', + ); + } + + const npmVersionArgs = releasePrepareArgs.slice(1); + if (npmVersionArgs.length === 0) { + throwUsage( + 'Missing npm version arguments (e.g. patch, major, prerelease --preid alpha).', + ); + } + + return { + fromRev, + releaseBranch, + npmVersionArgs, + }; +} + +function validateBranchState(releaseBranch) { + const checkedBranch = spawnOutput('git', [ + 'rev-parse', + '--abbrev-ref', + 'HEAD', + ]); + if (checkedBranch === 'HEAD') { + throw new Error( + 'Git is in detached HEAD state (not on a local branch). ' + + 'Switch to a local branch based on the release branch first, for example:\n' + + ` git switch -c release-${releaseBranch.replace( + /[^a-zA-Z0-9._-]/g, + '-', + )} ${releaseBranch}`, + ); + } + if (checkedBranch === releaseBranch) { + throw new Error( + `Release prepare must not run on "${releaseBranch}". Create a local release branch first.`, + ); + } + + const status = spawnOutput('git', ['status', '--porcelain']).trim(); + if (status !== '') { + throw new Error( + 'Working directory must be clean before running release:prepare.', + ); + } + + const branchStatus = spawnOutput('git', [ + 'status', + '--porcelain', + '--branch', + ]); + const branchSummary = branchStatus.split('\n')[0] || ''; + if (/\[[^\]]+\]/.test(branchSummary)) { + throw new Error( + `Current branch "${checkedBranch}" is not up to date with its upstream.`, + ); + } + + let releaseBranchHead; + try { + releaseBranchHead = spawnOutput('git', ['rev-parse', releaseBranch]); + } catch { + throw new Error( + `Release branch "${releaseBranch}" does not exist locally.`, + ); + } + + let releaseBranchUpstream; + try { + releaseBranchUpstream = spawnOutput('git', [ + 'rev-parse', + '--abbrev-ref', + `${releaseBranch}@{upstream}`, + ]); + } catch { + throw new Error( + `Release branch "${releaseBranch}" does not track a remote branch. ` + + 'Set one first (for example: git branch --set-upstream-to ' + + `/${releaseBranch} ${releaseBranch}).`, + ); + } + + const upstreamRemote = releaseBranchUpstream.split('/')[0]; + try { + spawn('git', ['fetch', '--quiet', upstreamRemote, releaseBranch]); + } catch { + throw new Error( + `Failed to fetch "${releaseBranchUpstream}". ` + + 'Verify network access and git remote configuration, then retry.', + ); + } + + const upstreamReleaseBranchHead = spawnOutput('git', [ + 'rev-parse', + `${releaseBranch}@{upstream}`, + ]); + if (releaseBranchHead !== upstreamReleaseBranchHead) { + throw new Error( + `Local "${releaseBranch}" is not up to date with "${releaseBranchUpstream}". ` + + `Update it first (for example: git switch ${releaseBranch} && git pull --ff-only).`, + ); + } + + const currentHead = spawnOutput('git', ['rev-parse', 'HEAD']); + if (currentHead !== releaseBranchHead) { + throw new Error( + `Current branch "${checkedBranch}" must match "${releaseBranch}" before preparing a release.`, + ); + } +} + +function throwUsage(message) { + throw new Error( + `${message}\n` + + 'Usage: npm run release:prepare -- [--fromRev ] \n' + + 'Examples:\n' + + ' npm run release:prepare -- 16.x.x patch\n' + + ' npm run release:prepare -- 16.x.x prerelease --preid alpha\n' + + ' npm run release:prepare -- --fromRev 16.x.x prerelease --preid alpha', + ); +} diff --git a/resources/utils.js b/resources/utils.js index 37cd83e801..abc9bc21a0 100644 --- a/resources/utils.js +++ b/resources/utils.js @@ -15,6 +15,39 @@ function exec(command, options) { return output && output.trimEnd(); } +function spawn(command, args, options = {}) { + const result = childProcess.spawnSync(command, args, { + stdio: 'inherit', + ...options, + }); + ensureSpawnSuccess(command, args, result); +} + +function spawnOutput(command, args, options = {}) { + const result = childProcess.spawnSync(command, args, { + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + ...options, + }); + ensureSpawnSuccess(command, args, result); + return result.stdout.trimEnd(); +} + +function ensureSpawnSuccess(command, args, result) { + if (result.status === 0) { + return; + } + + const stderr = result.stderr ? String(result.stderr).trim() : ''; + throw new Error( + stderr !== '' + ? stderr + : `${command} ${args.join(' ')} exited with code ${String( + result.status, + )}.`, + ); +} + function readdirRecursive(dirPath, opts = {}) { const { ignoreDir } = opts; const result = []; @@ -91,9 +124,36 @@ function writeGeneratedFile(filepath, body) { fs.writeFileSync(filepath, formatted); } +function readPackageJSON(filepath = require.resolve('../package.json')) { + return JSON.parse(fs.readFileSync(filepath, 'utf-8')); +} + +function readPackageJSONAtRef(ref) { + const packageJSONAtRef = spawnOutput('git', [ + 'cat-file', + 'blob', + `${ref}:package.json`, + ]); + return JSON.parse(packageJSONAtRef); +} + +function tagExists(tag) { + const result = childProcess.spawnSync( + 'git', + ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`], + { stdio: 'ignore' }, + ); + return result.status === 0; +} + module.exports = { exec, + spawn, + spawnOutput, readdirRecursive, showDirStats, + readPackageJSON, + readPackageJSONAtRef, + tagExists, writeGeneratedFile, }; From a5f5a3238d8f25477866574a3ba39a6a7378f1c9 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 4 Mar 2026 10:09:08 +0200 Subject: [PATCH 2/2] spelling --- cspell.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/cspell.yml b/cspell.yml index b09902dfec..1c9b9b5ae3 100644 --- a/cspell.yml +++ b/cspell.yml @@ -69,6 +69,7 @@ words: # TODO: contribute upstream - deno - codecov + - preid # Website tech - Nextra