From adff4e66ed048cdac4eb96b8750ab6d5a2763cfe Mon Sep 17 00:00:00 2001 From: Sarah Sanders <88458517+sarahxsanders@users.noreply.github.com> Date: Wed, 4 Mar 2026 03:15:48 -0500 Subject: [PATCH 1/4] docs: move migrate from express graphql guide to graphqlJS docs (#4433) Resolves this feedback: https://github.com/graphql/graphql.github.io/pull/2005#pullrequestreview-2900857524 Moving Migrate from express graphQL guide to graphQL-js --------- Co-authored-by: Benjie Co-authored-by: Yaacov Rydzinski --- cspell.yml | 1 + website/pages/docs/_meta.ts | 1 + .../docs/migrating-from-express-graphql.mdx | 216 ++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 100644 website/pages/docs/migrating-from-express-graphql.mdx diff --git a/cspell.yml b/cspell.yml index b09902dfec..2c0774d643 100644 --- a/cspell.yml +++ b/cspell.yml @@ -41,6 +41,7 @@ overrides: - Graphile - precompiled - debuggable + - dataloaders ignoreRegExpList: - u\{[0-9a-f]{1,8}\} diff --git a/website/pages/docs/_meta.ts b/website/pages/docs/_meta.ts index 3ad0f1dd42..87858e5b73 100644 --- a/website/pages/docs/_meta.ts +++ b/website/pages/docs/_meta.ts @@ -6,6 +6,7 @@ const meta = { }, 'getting-started': '', 'running-an-express-graphql-server': '', + 'migrating-from-express-graphql': '', 'graphql-clients': '', 'authentication-and-express-middleware': '', '-- 2': { diff --git a/website/pages/docs/migrating-from-express-graphql.mdx b/website/pages/docs/migrating-from-express-graphql.mdx new file mode 100644 index 0000000000..358ba4eb82 --- /dev/null +++ b/website/pages/docs/migrating-from-express-graphql.mdx @@ -0,0 +1,216 @@ +--- +title: Migrate from Express GraphQL to GraphQL over HTTP +sidebarTitle: Migrate from Express GraphQL +--- + +# Migrate from Express GraphQL to GraphQL over HTTP + +When GraphQL was open-sourced in 2015, `express-graphql` quickly became the standard way to run a GraphQL server in Node.js. +Built as middleware for Express, it offered a simple and reliable development experience. However, it hasn’t received a +feature update since 2018 and is no longer actively maintained. For modern applications, it lacks support for new transport +features, fine-grained request handling, and deployment flexibility. + +[`graphql-http`](https://github.com/graphql/graphql-http) is a lightweight implementation of +the [GraphQL over HTTP specification](https://graphql.github.io/graphql-over-http/draft/). It's framework-agnostic, built to be +composable, and easy to integrate into different server environments. Unlike `express-graphql`, it can run in a wide range of +environments, not just Express. + +This guide is for developers currently using `express-graphql` who want to +modernize their stack, adopt the HTTP spec, or decouple their GraphQL server +from Express. + +## Benefits of migrating + +### `express-graphql` is no longer supported + +The library has not received updates for some time. As a deprecated package, it is not evolving with the GraphQL ecosystem. This makes it less flexible for long-term projects. + +### `graphql-http` is spec-compliant by default + +The GraphQL over HTTP specification defines how GraphQL should be transported over HTTP, including request methods, status codes, content types, and more. `graphql-http` follows this spec precisely, helping your server behave predictably and remain compatible with future tooling. + +### It's framework-agnostic by design + +Instead of relying on Express, `graphql-http` is built on the standard Web `Request` and `Response` interfaces. It works with Express, Fastify, Node's native HTTP server, and can also be used in serverless and edge environments. + +### It fits into modern JavaScript stacks + +`graphql-http` supports ESM and works well with modern build tools and lightweight deployment platforms. Its composable design makes it easy to customize, wrap, and integrate into different application architectures. + +### Designed for future compatibility + +As GraphQL evolves, tools and platforms increasingly expect spec-compliant behavior. Migration to `graphql-http` helps ensure your +server will support future capabilities without relying on workarounds. + +### Understand current limitations + +Although `graphql-http` is a strong foundation for modern GraphQL servers, it's important to note what it doesn't include: + +- It doesn't support subscriptions or experimental features like incremental delivery (`@defer` / `@stream`) out of the box. +- These limitations are by design. `graphql-http` strictly adheres to the current +[GraphQL over HTTP specification](https://graphql.github.io/graphql-over-http/draft/), which does +not yet define behavior for those features. +- If your application needs support for subscriptions or live queries, consider using complementary libraries like +[`graphql-ws`](https://github.com/enisdenjo/graphql-ws) or [`graphql-sse`](https://github.com/enisdenjo/graphql-sse). + +These are not limitations unique to `graphql-http`. `express-graphql` does not support these features either, but it's important +to set the right expectations about extensibility. + +## Migration guide + +The following steps walk through how to migrate an existing `express-graphql` server to use `graphql-http`. The steps assume you already have a working Express app using `express-graphql`. + +### Prerequisites + +Before you begin, make sure you have: + +- Node.js 16 or later +- A GraphQL schema +- An existing Express app configured with `express-graphql` + +### Step 1: Install graphql-http and the Express adapter + +Install the core `graphql-http` package along with its Express adapter: + +```bash +npm install graphql graphql-http +``` + +The `graphql` package is a peer dependency of `graphql-http`, and must be installed if it isn't already. + +### Step 2: Remove express-graphql middleware + +In your Express server file, remove the `express-graphql` middleware: + +```js +// Before (using express-graphql) +import { graphqlHTTP } from 'express-graphql'; + +app.use('/graphql', graphqlHTTP({ + schema, + graphiql: true, +})); +``` + +### Step 3: Add graphql-http middleware with createHandler + +Replace it with the `graphql-http` handler using the Express adapter: + +```js +import express from 'express'; +import { createHandler } from 'graphql-http/lib/use/express'; +import { schema } from './schema.js'; + +const app = express(); + +app.all('/graphql', createHandler({ schema })); + +app.listen(4000); +``` + +- Use `app.all()` to allow both `GET` and `POST` requests. +- The handler accepts an options object for GraphQL-specific settings like `schema`, +`rootValue`, and `context`, but doesn’t handle server-level features such as middleware +or request preprocessing like `express-graphql` did. + +### Step 4: Handle context, error formatting, and extensions + +You can provide options like `context`, `rootValue`, and `formatError`: + +```js +import { GraphQLError } from 'graphql'; + +app.all('/graphql', createHandler({ + schema, + context: async (req, res) => { + const user = await authenticate(req); + return { user }; + }, + formatError: (error) => + new GraphQLError(error.message, { + nodes: error.nodes, + path: error.path, + extensions: { + code: 'INTERNAL_SERVER_ERROR', + timestamp: Date.now(), + }, + }), +})); +``` + +- `context` can be a static object or an async function. +- You can also pass `rootValue` or other GraphQL-specific options. +- To modify the HTTP response, such as adding headers or extensions, you’ll need +to do that outside of graphql-http, using Express middleware or route handlers. + +### Step 5: Add a GraphQL IDE + +Unlike `express-graphql`, `graphql-http` does not include a built-in GraphQL IDE. If you want to add one: + +- Use a tool like [Ruru](https://www.npmjs.com/package/ruru) to serve an interactive GraphQL UI locally: + + ```bash + npx ruru -SP -p 4001 -e http://localhost:4000/graphql + ``` + +- Or serve a static HTML page that embeds [GraphiQL](https://github.com/graphql/graphiql) from a CDN. + +In either case, make sure to restrict access in production environments. + +### Step 6: Test your setup + +After migrating, verify that your server responds correctly: + +- Send queries and mutations using your preferred client. +- Check for proper HTTP status codes and response shapes. +- Check the GraphQL `context` and related variables are populated correctly and + that your dataloaders and authorization logic are functioning as expected. + +## Best practices + +When migrating from `express-graphql` to `graphql-http`, there are a few key differences and potential pitfalls to keep in mind. These tips can help you avoid common issues and ensure a smoother transition. + +### Be aware of different error behavior + +`graphql-http` follows the GraphQL over HTTP spec closely, which means error formatting and status codes may differ from what you're used to with `express-graphql`. For example: + +- Invalid queries may return a `400 Bad Request` instead of a `200 OK`. +- Errors in parsing or validation are surfaced earlier and more strictly. +- You can customize error output using the `formatError` option, but it must conform to the spec. + +This can affect client expectations if they were relying on `express-graphql`'s more lenient defaults. + +### Watch for framework-specific middleware behavior + +Since `graphql-http` is framework-agnostic, it does not handle things like body parsing, CORS, or compression. You'll need to ensure those are handled appropriately by your Express setup: + +```js +import cors from 'cors'; +import express from 'express'; + +app.use(cors()); +app.use(express.json()); +``` + +This gives you more control but requires a bit more setup. + +### Understand streaming and file upload limitations + +`graphql-http` aims to support the GraphQL over HTTP spec, including eventually supporting response streaming. However, +support for features like `@defer` and `@stream` is still evolving. These capabilities are experimental in `graphql-js` +and not yet supported by `graphql-http`. + +- Some GraphQL clients have begun adding support for multipart responses, but broad adoption is still evolving. +- `graphql-http` does not support streaming features such as `@defer` or `@stream`, as these are not part of the +current GraphQL over HTTP specification. +- If your app relies on incremental delivery, use a transport library like [`graphql-sse`](https://github.com/enisdenjo/graphql-sse), +but note that it replaces `graphql-http` and must be used as your server handler. + +## What's next + +`graphql-http` is the reference implementation of the GraphQL-over-HTTP specification, +but there are many other servers you can use to serve your GraphQL API, each with +different features and trade-offs. For a list of other spec-compliant server +implementations see the +[`graphql-http` server list](https://github.com/graphql/graphql-http?tab=readme-ov-file#servers), and don't forget to check out the +[Tools and Libraries page on graphql.org](https://graphql.org/community/tools-and-libraries/?tags=server_javascript). From c2ad5c6494a5e2d8b6a73c49bf13306d7f5c9533 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 4 Mar 2026 10:29:37 +0200 Subject: [PATCH 2/4] internal: backport new release flow from 17.x.x (#4608) --- .github/CONTRIBUTING.md | 28 ++--- .github/workflows/release.yml | 166 ++++++++++++++++++++++++++++++ cspell.yml | 1 + 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 +++++++++++ 9 files changed, 621 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/cspell.yml b/cspell.yml index 2c0774d643..49356561a7 100644 --- a/cspell.yml +++ b/cspell.yml @@ -70,6 +70,7 @@ words: # TODO: contribute upstream - deno - codecov + - preid # Website tech - Nextra 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 6dee4356c78458003cee3a851242ec99e4e1627b Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 4 Mar 2026 10:57:45 +0200 Subject: [PATCH 3/4] chore(release): v16.13.1 (#4609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## v16.13.1 (2026-03-04) #### Docs 📝 * [#4433](https://github.com/graphql/graphql-js/pull/4433) docs: move migrate from express graphql guide to graphqlJS docs ([@sarahxsanders](https://github.com/sarahxsanders)) #### Internal 🏠 * [#4608](https://github.com/graphql/graphql-js/pull/4608) internal: backport new release flow from 17.x.x ([@yaacovCR](https://github.com/yaacovCR)) #### Committers: 2 * Sarah Sanders([@sarahxsanders](https://github.com/sarahxsanders)) * Yaacov Rydzinski ([@yaacovCR](https://github.com/yaacovCR)) --- package-lock.json | 4 ++-- package.json | 2 +- src/version.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index faae9fa750..4c9fc3a7dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "graphql", - "version": "16.13.0", + "version": "16.13.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "graphql", - "version": "16.13.0", + "version": "16.13.1", "license": "MIT", "devDependencies": { "@babel/core": "7.17.9", diff --git a/package.json b/package.json index f117839cbb..44598aad66 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "graphql", - "version": "16.13.0", + "version": "16.13.1", "description": "A Query Language and Runtime which can target any service.", "license": "MIT", "private": true, diff --git a/src/version.ts b/src/version.ts index b97b604a90..4011687f40 100644 --- a/src/version.ts +++ b/src/version.ts @@ -4,7 +4,7 @@ /** * A string containing the version of the GraphQL.js library */ -export const version = '16.13.0' as string; +export const version = '16.13.1' as string; /** * An object containing the components of the GraphQL.js version string @@ -12,6 +12,6 @@ export const version = '16.13.0' as string; export const versionInfo = Object.freeze({ major: 16 as number, minor: 13 as number, - patch: 0 as number, + patch: 1 as number, preReleaseTag: null as string | null, }); From 3b5c3f949ba1f3709f260984becd266b0c6fb4e8 Mon Sep 17 00:00:00 2001 From: Yaacov Rydzinski Date: Wed, 4 Mar 2026 11:42:46 +0200 Subject: [PATCH 4/4] internal: pin node version for release action (#4610) npm trusted publishing requires a newer Node/npm line than 16.x.x pinning to node v24, latest LTS version --- .github/workflows/release.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e90f2f8b39..190389bd9f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,18 +93,15 @@ jobs: runs-on: ubuntu-latest environment: release permissions: - contents: read # for actions/checkout + contents: read # keep token scopes minimal 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' + # npm trusted publishing requires a newer Node/npm line than 16.x.x + # branch runtime constraints, so pin only this job to Node 24. + node-version: 24 - name: Download npmDist package uses: actions/download-artifact@v4