Skip to content

P1: Upgrade to graphql-yoga 5 and Node 22 LTS (#176) - #194

Merged
dkijania merged 3 commits into
mainfrom
feat/yoga5-node22
Aug 26, 2026
Merged

P1: Upgrade to graphql-yoga 5 and Node 22 LTS (#176)#194
dkijania merged 3 commits into
mainfrom
feat/yoga5-node22

Conversation

@dkijania

Copy link
Copy Markdown
Contributor

What & why

Part of the production-readiness epic (#163). Closes #176.

Key server deps were a major behind. This brings them current:

  • graphql-yoga 4 → 5
  • @envelop/core 4 → 5, @envelop/graphql-jit 6 → 11, @envelop/disable-introspection 5 → 9, @envelop/opentelemetry 5 → 9
  • Node 20 → 22 LTS across the Dockerfile, Volta pin, and the lint / unit-test / publish / smoke-load workflows

Code impact

Minimal — a single targeted cast: @envelop/opentelemetry@9 types its provider argument against a duplicate @opentelemetry/api copy, so provider is cast to the exact parameter type useOpenTelemetry expects. Everything else compiled unchanged.

Verification

  • npm run build — clean
  • npm run test:unit — all pass
  • npm run lint / npx prettier --debug-check . — clean
  • Built the node:22 image locally — builds and runs v22.23.1
  • (The lightnet Run-Tests + Docker build-and-deploy in CI are the full integration proof.)

Follow-up (not in this PR)

The OpenTelemetry SDK is intentionally not bumped — modern OTel drops the Jaeger exporter for OTLP, so clearing the remaining @opentelemetry/* audit highs is a separate Jaeger→OTLP migration. With Yoga on 5, the graphql-armor meta package (#164 currently uses the individual sub-plugins due to the old envelop-4 peer) could also be revisited.

🤖 Generated with Claude Code

@dkijania dkijania added production-readiness Work toward making the API production-ready / publicly available P1 Strongly recommended before GA labels Jun 28, 2026
@SanabriaRusso

Copy link
Copy Markdown
Collaborator

Nice upgrade — clean and minimal (great that it came down to a single typed cast for the OTel provider), and thanks for the thorough build/lint/image verification.

Since this is the one change in the production-readiness train that could subtly affect the mina-explorer client, I verified the highest-risk axis empirically: yoga 5's GraphQL-over-HTTP status codes. The Explorer's archive client throws on any non-2xx before it reads the GraphQL body (if (!response.ok) throw 'HTTP error…' in client.ts), then keys its graceful-degradation on the exact Cannot query field message — so if yoga 5 had started returning 400 for validation errors, the Explorer's fallback chains (transactions.ts:488/660/909) would break into blank sections.

Good news — it's safe. I ran both 4.0.4 and 5.21.2 with the Explorer's exact request shape (POST, Content-Type: application/json, no Accept header):

yoga 4.0.4 yoga 5.21.2
Cannot query field validation error HTTP 200 {errors:[…]} HTTP 200 {errors:[…]}
resolver throws (masked) HTTP 200 "Unexpected error." HTTP 200 "Unexpected error."

Both only switch to 400 under Accept: application/graphql-response+json, which the Explorer never sends. The error message strings are byte-identical (5.x just adds an additive extensions.code, which the Explorer ignores), and CORS / /healthcheck behave identically — so no client impact.

One suggestion to keep it safe: the 200-on-validation-error behavior is an implicit content-negotiation default, so a future yoga bump could flip it unnoticed. A tiny integration test alongside the existing ones would pin the Explorer contract:

// the exact shape the mina-explorer client sends (no Accept header)
const res = await fetch(endpoint, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ query: '{ __definitelyNotAField }' }),
});
assert.equal(res.status, 200);                                // must NOT be 400
const body = await res.json();
assert.match(body.errors[0].message, /Cannot query field/);   // Explorer fallback keys on this

Minor nit while here: @types/node stays ^20.5.7 even though the runtime moves to 22 — worth bumping to ^22 so the types match the runtime.

@SanabriaRusso

Copy link
Copy Markdown
Collaborator

One more small thing, on the Docker side: this bumps both stages to a floating node:22-alpine tag, which would drop the @sha256 digest pin that #189 adds for reproducible / tamper-evident builds. If #189 lands first, this quietly reverts that hardening. Worth pinning the 22 image by digest here too, so the two changes don't fight:

FROM node:22-alpine@sha256:<digest> AS build
# ...
FROM node:22-alpine@sha256:<digest>

(Once #192 lands, its Dependabot docker ecosystem will keep that digest fresh automatically.)

dkijania added a commit that referenced this pull request Jul 17, 2026
Pins both Dockerfile stages to node:22-alpine@sha256:16e22a55… (v22.23.1,
verified by pull and build). Without this, moving to a floating 22 tag
would silently drop the digest pin #189 adds, reverting its
reproducible/tamper-evident build hardening depending on merge order.
Once #192 lands, Dependabot's docker ecosystem keeps the digest fresh.

Bumps @types/node to ^22 so the types match the runtime rather than
staying on 20.

Adds tests pinning the GraphQL-over-HTTP behaviour the mina-explorer
depends on: validation errors return 200 (its client throws on non-2xx
before reading the body) and carry the literal "Cannot query field" (its
fallbacks key on that string). Both were verified by hand across the
4 → 5 upgrade and are unchanged, but they rest on an implicit
content-negotiation default — yoga only returns 400 under an Accept
header that client never sends — so a later bump could flip them
unnoticed. Now a standing guard rather than a one-time check.

Addresses review feedback on #194.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dkijania

Copy link
Copy Markdown
Contributor Author

Thanks @SanabriaRusso — and thank you for actually running both versions against the Explorer's request shape rather than reasoning about it. All three done in 20cf867.

Digest pin. Both stages now pin node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2. Resolved from the registry, then verified by pulling it and building the image: reports v22.23.1, builds clean. Your point was the important one — without this, whichever of #189/#194 landed second would silently undo the other, and a reverted digest pin is the kind of thing that reads as "still pinned" in review. Comment on both stages says to bump them together, and #192's Dependabot docker ecosystem will keep it fresh once it lands.

@types/node^22 (22.20.1). Typecheck and build clean against it.

The contract test. Added tests/unit/yoga-http-contract.test.ts, pinning both halves of what you verified — 200-not-400 on validation errors, and the literal Cannot query field — using the Explorer's exact shape (POST, JSON, no Accept header). Independently reproduces your result on yoga 5.21.2. It's a unit test via yoga.fetch rather than a full integration test, which needs no server and runs on every PR; buildYoga doesn't exist on this branch yet (it arrives with #195), so it constructs yoga directly.

Your reasoning for why it's worth pinning is in the file: the 200 is an implicit content-negotiation default, so nothing about a future bump would announce a flip. This turns "someone checked once" into a standing guard — and since this PR is the upgrade itself, the guard belongs here rather than downstream.

SanabriaRusso added a commit that referenced this pull request Jul 30, 2026
Closes #206.

## Problem

The `v0.0.9` tag push failed at the **`Update npm`** step — [run
30540591005](https://github.com/o1-labs/Archive-Node-API/actions/runs/30540591005)
— before dependencies were installed, before tests, and before `npm
publish`:

```
npm error code EBADENGINE
npm error Not compatible with your version of node/npm: npm@12.0.2
npm error notsup Required: {"node":"^22.22.2 || ^24.15.0 || >=26.0.0"}
npm error notsup Actual:   {"npm":"10.8.2","node":"v20.20.2"}
```

`npm@latest` has moved to 12.0.2, which dropped Node 20. The job pins
`node-version: '20'`, so the install is refused. Nothing was published —
npm still shows only `0.0.6`.

This is an upstream change, not a repo regression: it breaks **any** tag
pushed from today onward.

## Change

```diff
-      # Ensure npm 11.5.1 or later is installed
       - name: Update npm
-        run: npm install -g npm@latest
+        run: npm install -g npm@11
```

`npm@11` is currently 11.19.0, engines `^20.17.0 || >=22.9.0` —
satisfied by the job's Node 20.20.2, and well past the 11.5.1 that
trusted publishing / OIDC requires. Pinning the major keeps patch
updates flowing without another silent engine break; the unqualified
`latest` is what made this a time bomb.

## Why not bump Node to 22

More future-proof, but wider: the publish job also runs the full `npm
test`, so changing its Node version changes the runtime the release is
validated against. #194 (`P1: Upgrade to graphql-yoga 5 and Node 22
LTS`) is already open and is the right place for that. Once it lands,
this pin can be revisited.

## Verification

This workflow only runs on `v*` tag pushes and `workflow_dispatch`, so
PR CI cannot exercise it. The engine claim is checked directly against
the registry:

```
$ npm view npm@11 version   ->  11.19.0
$ npm view npm@11 engines   ->  { node: '^20.17.0 || >=22.9.0' }
$ npm view npm@12 engines   ->  { node: '^22.22.2 || ^24.15.0 || >=26.0.0' }
```

Node 20.20.2 satisfies `^20.17.0` and does not satisfy `^22.22.2`, which
is exactly the observed pass/fail split. Real proof comes from the
retriggered `v0.0.9` publish after this merges.

## Follow-up

`v0.0.9` is tagged at `3d254f7` but published nothing. Re-running the
failed run will not help — a tag-push event uses the workflow file as it
exists at the tagged commit, which still has the broken step. The tag
needs to be moved to the commit containing this fix and re-pushed. Since
no npm version was consumed, that is a clean operation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@SanabriaRusso

Copy link
Copy Markdown
Collaborator

Verdict: MERGEABLE

Second pass, focused purely on the wire protocol. I installed the exact resolved trees — graphql-yoga@5.21.2 + @envelop/graphql-jit@11.1.1 + @envelop/core@5.5.1 vs. graphql-yoga@4.0.4 + @envelop/graphql-jit@6.0.5 + @envelop/core@4.0.3 — and diffed the actual HTTP responses, including over a raw TCP socket so the request genuinely carries no Accept header (undici's fetch injects Accept: */*, so yoga.fetch alone can't prove that case).

What I checked

  • Status code + body on validation errors (HARD CONSTRAINT Add Actions resolver support #1). Identical across 4.0.4 and 5.21.2 for every shape both consumers send — no Accept at all (raw socket), Accept: */*, Accept: application/json, and Content-Type: application/json; charset=utf-8: all HTTP 200, Content-Type: application/json; charset=utf-8, body {"errors":[{"message":"Cannot query field \"protocolState\" on type \"Query\"." …}]}. Only Accept: application/graphql-response+json flips to 400 + application/graphql-response+json — and that is also true of 4.0.4, so it is not a change this PR introduces, and neither client ever sends it.
  • Message text is byte-identical for all four markers the consumers string-match: Cannot query field "X" on type "Y"., Unknown argument "x" on field "Query.blocks"., Unknown type "X". Did you mean …, and Field "nope" is not defined by type "BlockQueryInput". (the inBestChain probe path). Yoga 5 only adds extensions.code (GRAPHQL_VALIDATION_FAILED / GRAPHQL_PARSE_FAILED / INTERNAL_SERVER_ERROR) — purely additive, and neither consumer reads extensions (grep for it in mina-explorer/src and mina-explorer-api/app returns nothing). So mina-explorer/src/services/api/bestChainFilter.ts:30 and mina-explorer-api/app/upstream/graphql.py:33 keep matching.
  • Content negotiation — no 406/415. The SPA combination (Content-Type: application/json, no Accept) and the httpx json= combination (Accept: */*, Accept-Encoding: gzip, deflate) both get 200 + application/json; charset=utf-8. Yoga 5 never volunteers application/graphql-response+json unless the client asks for it, so response.json() on both sides is unaffected.
  • Masking default unchanged (item 3). createYoga with no maskedErrors option: 4.0.4 → "Unexpected error.", 5.21.2 → "Unexpected error." (plus the additive extensions.code). Validation and parse errors are untouched by masking in both. P2: Verify error masking; make masking explicit (#177) #195 sets maskedErrors: true explicitly, which is exactly the default I measured here — the two PRs agree, no effective behaviour change from either.
  • Plugin/envelop majors (item 4). PR lockfile has a single hoisted @envelop/core@5.5.1; disable-introspection@9.1.1, graphql-jit@11.1.1, on-resolve@7.1.1, opentelemetry@9.1.1 all declare peerDependencies: {"@envelop/core": "^5.5.1"} and it is satisfied. The stale nested @graphql-tools/executor-common/node_modules/@envelop/core@5.5.1 duplicate is removed by this PR (net dedupe). No envelop-4 copy survives anywhere in the tree. Confirmed by loading the four plugins together at runtime, and by the green lightnet Run-Tests.
  • @envelop/graphql-jit (item 5). graphql-jit moves 0.8.4 → 0.8.7 (patch). Validation still runs in graphql-js, not the JIT, so validation message text is unaffected. For masked runtime errors the JIT path drops locations from the error object — but it does so identically on jit 6 + yoga 4, so it is pre-existing, not a regression from this PR. message, path, and null-propagation are identical between the two trees.
  • CORS. Preflight OPTIONS204 with access-control-allow-origin echoing the request origin, access-control-allow-methods: GET, POST, access-control-allow-headers: content-type — byte-identical between 4.0.4 and 5.21.2. /healthcheck still 200.
  • Prior review points. All three from your earlier comments are present at 20cf867: digest pin on both stages (I resolved sha256:16e22a55… against Docker Hub — valid multi-arch node:22-alpine index, alpine:3.24 base), @types/node@^22.20.1, and tests/unit/yoga-http-contract.test.ts. The test file matches the ./build/tests/unit/*test.js glob in test:unit, so it actually runs.

One correction to the framing in the first review comment, in the PR's favour: mina-explorer's client no longer throws before reading the body — src/services/api/client.ts:52-68 re-parses the JSON on !response.ok and surfaces errors[].message. mina-explorer-api/app/upstream/graphql.py:254-271 does the same (parses the body first, classifies, and only falls back to Upstream HTTP {status} when there is no errors[]). So both consumers would survive even the 400 path. The 200 is still the right thing to pin — it just means the blast radius here is smaller than feared.

Non-blocking nits

1. Rebase conflict silently reverts #207. The branch is 3 behind main and mergeable: CONFLICTING. main now carries #207, which pinned the publish job to npm@11; this branch still has the pre-#207 npm install -g npm@latest. A "take ours" resolution reverts that hardening. npm@latest is 12.0.2 with engines: {node: "^22.22.2 || ^24.15.0 || >=26.0.0"}, so it happens to work on node-version: '22' (resolves to 22.23.x) — but it is a floating major on a trusted-publishing job. Keep the pin and fix the now-stale comment:

--- a/.github/workflows/publish-npm.yml
+++ b/.github/workflows/publish-npm.yml
@@
       - uses: actions/setup-node@v4
         with:
           node-version: '22'
 
-      # Trusted publishing needs npm 11.5.1 or later. Pin the major rather than
-      # tracking `latest`: npm 12 requires Node >= 22.22.2, so `npm@latest`
-      # fails EBADENGINE against the Node 20 pinned above.
+      # Trusted publishing needs npm 11.5.1 or later. Pin the major rather than
+      # tracking `latest` so a future npm major cannot change publish semantics
+      # or raise its Node floor out from under the version pinned above.
       - name: Update npm
         run: npm install -g npm@11

(package-lock.json is the other conflicting file; regenerate it with npm install after the rebase rather than hand-merging.)

2. run-tests.yaml never pins Node, so the "full integration proof" doesn't actually run on 22. It's the one workflow with no setup-node step (.github/workflows/run-tests.yaml:24-38), so the lightnet job uses whatever the ubuntu-24.04 image ships. The PR body leans on this job as the Node 22 evidence; one line makes that true:

--- a/.github/workflows/run-tests.yaml
+++ b/.github/workflows/run-tests.yaml
@@
       - name: Checkout code
         uses: actions/checkout@v2
 
+      - uses: actions/setup-node@v4
+        with:
+          node-version: '22'
+
       - name: Install dependencies
         run: npm ci

3. No engines field. With four Node pins now in play (Volta 22.12.0, three workflows on 22, Dockerfile digest → v22.23.1) there is still nothing that fails an install on Node 20. This is also the guard that makes the npm-12 engine check above meaningful:

--- a/package.json
+++ b/package.json
@@
   "author": "O(1) Labs <build@o1labs.org>",
   "license": "ISC",
+  "engines": {
+    "node": ">=22.12.0"
+  },
   "devDependencies": {

4. graphql-yoga went from exact 4.0.4 to ^5.21.2. Builds here use npm ci, so this repo is reproducible either way — but this package publishes to npm (@o1-labs/mina-archive-node-graphql), and downstream installers will resolve the caret freely against the one dependency whose HTTP semantics the Explorer depends on. Given you just added a test to pin those semantics at a specific version, pinning the version too is consistent:

--- a/package.json
+++ b/package.json
@@
-    "graphql-yoga": "^5.21.2",
+    "graphql-yoga": "5.21.2",

(Fine to skip if the intent is to let #192's Dependabot drive yoga bumps — the contract test is the real guard either way.)

5. Contract test could guard more once #195 lands. It builds a bare createYoga({schema, graphqlEndpoint:'/'}), which omits the plugins, cors, and maskedErrors that the real server uses — so it would not catch someone setting maskedErrors: { errorMessage: … } or adding a plugin that rewrites errors. After #195 introduces buildYoga, re-point it there. Worth adding the other half of the negotiation contract too, since that's the axis that could flip:

test('graphql-response+json is opt-in only', async () => {
  const yoga = buildYoga(/* real config */);
  const res = await yoga.fetch('http://localhost/', {
    method: 'POST',
    headers: { 'content-type': 'application/json', accept: 'application/graphql-response+json' },
    body: JSON.stringify({ query: '{ __definitelyNotAField }' }),
  });
  // documents that 400 is reachable *only* under this Accept — if a future
  // yoga makes it the default, the 200 test above starts failing and this
  // one explains why.
  assert.strictEqual(res.status, 400);
});

6. FYI, not a defect: node:22-alpine has moved since this PR was opened (sha256:c610fcdf… today vs. the pinned sha256:16e22a55…, built 2026-06-23). That's the point of a digest pin; #192's Dependabot will roll it. Just don't let a reviewer read "stale digest" as "wrong digest".

Automated second-pass review — focus: downstream compatibility with mina-explorer / mina-explorer-api.

SanabriaRusso
SanabriaRusso previously approved these changes Aug 18, 2026

@SanabriaRusso SanabriaRusso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving on the basis of the second-pass review comment above: no mid-to-high severity security, compatibility, or degradation issue found, and the downstream contract with mina-explorer / mina-explorer-api holds — GraphQL validation error text reaches errors[].message verbatim, the browser SPA's cross-origin access is preserved, and the real consumer query shapes (including the 2000-block analytics query and the 500-row page crawl) still pass.

Two things this approval does not mean:

  • It does not close the non-blocking items in the review comment. Several are worth fixing before or shortly after merge; they are written up there with patches.
  • It does not by itself mean the branch is ready to merge. main requires branches to be up to date, so this needs an update-branch (or a rebase, if the branch is conflicting) first, and a few PRs in this series have cross-PR ordering constraints called out in their review comments.

Automated second-pass review — focus: downstream compatibility with mina-explorer / mina-explorer-api.

dkijania added a commit that referenced this pull request Aug 24, 2026
Pins both Dockerfile stages to node:22-alpine@sha256:16e22a55… (v22.23.1,
verified by pull and build). Without this, moving to a floating 22 tag
would silently drop the digest pin #189 adds, reverting its
reproducible/tamper-evident build hardening depending on merge order.
Once #192 lands, Dependabot's docker ecosystem keeps the digest fresh.

Bumps @types/node to ^22 so the types match the runtime rather than
staying on 20.

Adds tests pinning the GraphQL-over-HTTP behaviour the mina-explorer
depends on: validation errors return 200 (its client throws on non-2xx
before reading the body) and carry the literal "Cannot query field" (its
fallbacks key on that string). Both were verified by hand across the
4 → 5 upgrade and are unchanged, but they rest on an implicit
content-negotiation default — yoga only returns 400 under an Accept
header that client never sends — so a later bump could flip them
unnoticed. Now a standing guard rather than a one-time check.

Addresses review feedback on #194.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SanabriaRusso
SanabriaRusso previously approved these changes Aug 24, 2026

@SanabriaRusso SanabriaRusso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. I re-verified the round-1 findings against the rebased head (0227094) rather than the diff alone, plus the two things the rebase could plausibly have broken.

The rebase hazard is clear. .github/workflows/publish-npm.yml:38 still pins npm install -g npm@11. #207's fix survived — only the surrounding comment was rewritten (the rationale moved from "npm 12 requires Node ≥22.22.2 so npm@latest fails EBADENGINE against Node 20" to a version-agnostic phrasing). I read the whole file at the head rather than trusting the diff, because a revert here would have been invisible in a green CI run. It's clean, and that was the single highest-risk item in this PR.

Node 22 is now genuinely exercised. run-tests.yaml:28-30 adds the missing actions/setup-node@v4 with node-version: '22' inside the Run-Tests job (the required check), after checkout and before npm ci. I pulled the actual job log for this head (run 32755595604, job 97522270868): node-version: 22node: v22.23.2. Same for unit-tests. The lightnet suite this PR cited as Node-22 evidence is now actually running on 22 rather than on the runner default.

The dependency tree is the one that was validated in round 1. Read the lockfile at head: graphql-yoga 5.21.2, @envelop/core 5.5.1, @envelop/graphql-jit 11.1.1, @envelop/disable-introspection 9.1.1, @envelop/opentelemetry 9.1.1, @envelop/types 5.2.1, single graphql 16.8.1. Exactly the tree round 1 tested, so those results carry over. No envelop major mismatch — this PR actually removes one that exists on main today (root @envelop/core 4.0.3 alongside a nested 5.5.1 under @graphql-tools/executor-common).

Downstream contracts re-checked independently. I unpacked the published @envelop/disable-introspection@9.1.1 tarball: it is a thin wrapper around graphql-js's NoSchemaIntrospectionCustomRule, identical in kind to v5. Running that rule directly: { __typename } → OK, { nope }Cannot query field "nope" on type "Query"., { __schema … } → blocked. So mina-explorer's tier detection and mina-explorer-api's { __typename } probe are both safe even with introspection disabled — __typename returns String, not an introspection type.

The base image digest is real. sha256:16e22a55… resolves on Docker Hub to a genuine multi-arch node:22-alpine index (annotation org.opencontainers.image.version: 22-alpine, base alpine:3.24, created 2026-06-23; amd64/arm/arm64/s390x). I pulled and ran it: node v22.23.1, which satisfies the new engines floor. Both stages use the same digest, which is right.

tests/unit/yoga-http-contract.test.ts pins the right three things — validation error = HTTP 200, message matches /Cannot query field/, valid query unaffected — using the Explorer's exact request shape (POST, content-type: application/json, no Accept header). Confirmed it actually executes in CI: # Subtest: GraphQL-over-HTTP contract, 3 pass / 0 fail.

Non-blocking nits

  1. Docs still say Node 20. docs/getting-started.md:20 (Node.js 20+) and :53 (Node 20+ supports --env-file) now contradict the engines: ">=22.12.0" this PR adds, and Node 20 went EOL 2026-04-30. It won't hard-break (no .npmrc, so engine-strict is off and npm only warns), but this PR creates the inconsistency and it's a two-line fix:

    -- **Path A:** Node.js 20+ (`node --version`) and a reachable archive-node Postgres database
    +- **Path A:** Node.js 22+ (`node --version`) and a reachable archive-node Postgres database
    -Or via a `.env` file (Node 20+ supports `--env-file` natively):
    +Or via a `.env` file (Node 22+ supports `--env-file` natively):
  2. .github/workflows/nightly-devnet-dump.yaml:56 is still node-version: '20' and isn't in this diff. Every other workflow is migrated; after merge that nightly runs an EOL runtime against a package declaring >=22.12.0 and will log EBADENGINE on npm ci. Worth bumping in the same PR. (live-integration.yaml:28 was already on 22.)

  3. engines is declarative only — no .npmrc, so npm warns rather than fails. Fine as-is, but it is a real breaking change for npm consumers; #198's versioning policy now lists "minimum Node runtime" as a breaking-change category, so this is the first thing that category applies to.

  4. The cast at src/server/plugins.ts:28 papers over a duplicate @opentelemetry/api — root 1.4.1 (pinned by sdk-trace-base@1.21.0 / exporter-jaeger@1.21.0) vs 1.9.1 nested under @envelop/opentelemetry@9.1.1. Two copies means two context globals; harmless here because the provider is passed explicitly and there's no other instrumentation, but the cast is the symptom, not the cause. Bumping sdk-trace-base + exporter-jaeger to 2.x would dedupe and let the cast go. Follow-up.

  5. The new test builds a bare createYoga, not buildServer — so it pins yoga's default HTTP contract, not the production stack's (no jit plugin, no introspection disabling, no CORS, no masking). A future plugin that rewrites status codes would slip past it. Threading a stubbed GraphQLContext into buildServer would close that.

Merge ordering — please read before merging. This PR and #189 both rewrite the same two FROM lines and disagree on the base: this one moves to node:22-alpine@sha256:16e22a55…, #189 pins node:20-alpine@sha256:fb4cd12c…. Both digests are genuine official images. This is a textual and semantic conflict of the same class as the npm@11 pin — a careless resolution silently reverts one side. Merge this PR first, then rebase #189 onto it, keeping the node:22 digest and layering tini/HEALTHCHECK on top. Resolving the other way would enshrine an EOL base. I verified that apk add --no-cache tini and BusyBox wget both work identically on the node:22 base, so #189's hardening is safe on top of this.

Also note src/server/plugins.ts is touched by this PR, #188 and #193 in the same ~20 lines — all semantically compatible, just needs a careful three-way resolution.

Downstream: none. Validation errors stay HTTP 200, the Cannot query field marker is byte-identical graphql-js text, masking default unchanged, no schema change.

dkijania added a commit that referenced this pull request Aug 24, 2026
Pins both Dockerfile stages to node:22-alpine@sha256:16e22a55… (v22.23.1,
verified by pull and build). Without this, moving to a floating 22 tag
would silently drop the digest pin #189 adds, reverting its
reproducible/tamper-evident build hardening depending on merge order.
Once #192 lands, Dependabot's docker ecosystem keeps the digest fresh.

Bumps @types/node to ^22 so the types match the runtime rather than
staying on 20.

Adds tests pinning the GraphQL-over-HTTP behaviour the mina-explorer
depends on: validation errors return 200 (its client throws on non-2xx
before reading the body) and carry the literal "Cannot query field" (its
fallbacks key on that string). Both were verified by hand across the
4 → 5 upgrade and are unchanged, but they rest on an implicit
content-negotiation default — yoga only returns 400 under an Accept
header that client never sends — so a later bump could flip them
unnoticed. Now a standing guard rather than a one-time check.

Addresses review feedback on #194.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dkijania added a commit that referenced this pull request Aug 25, 2026
Pins both Dockerfile stages to node:22-alpine@sha256:16e22a55… (v22.23.1,
verified by pull and build). Without this, moving to a floating 22 tag
would silently drop the digest pin #189 adds, reverting its
reproducible/tamper-evident build hardening depending on merge order.
Once #192 lands, Dependabot's docker ecosystem keeps the digest fresh.

Bumps @types/node to ^22 so the types match the runtime rather than
staying on 20.

Adds tests pinning the GraphQL-over-HTTP behaviour the mina-explorer
depends on: validation errors return 200 (its client throws on non-2xx
before reading the body) and carry the literal "Cannot query field" (its
fallbacks key on that string). Both were verified by hand across the
4 → 5 upgrade and are unchanged, but they rest on an implicit
content-negotiation default — yoga only returns 400 under an Accept
header that client never sends — so a later bump could flip them
unnoticed. Now a standing guard rather than a one-time check.

Addresses review feedback on #194.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dkijania added a commit that referenced this pull request Aug 26, 2026
Pins both Dockerfile stages to node:22-alpine@sha256:16e22a55… (v22.23.1,
verified by pull and build). Without this, moving to a floating 22 tag
would silently drop the digest pin #189 adds, reverting its
reproducible/tamper-evident build hardening depending on merge order.
Once #192 lands, Dependabot's docker ecosystem keeps the digest fresh.

Bumps @types/node to ^22 so the types match the runtime rather than
staying on 20.

Adds tests pinning the GraphQL-over-HTTP behaviour the mina-explorer
depends on: validation errors return 200 (its client throws on non-2xx
before reading the body) and carry the literal "Cannot query field" (its
fallbacks key on that string). Both were verified by hand across the
4 → 5 upgrade and are unchanged, but they rest on an implicit
content-negotiation default — yoga only returns 400 under an Accept
header that client never sends — so a later bump could flip them
unnoticed. Now a standing guard rather than a one-time check.

Addresses review feedback on #194.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@SanabriaRusso SanabriaRusso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-approving after the rebase (the previous approval was dismissed by the force-push).

The rebase changed real content here, so I re-checked at the head rather than from the diff. Both changes are correct adaptations to a main that moved:

1. The Dockerfile hazard is resolved the right way round. When I reviewed, this PR replaced an unpinned FROM node:20-alpine. #189 has since merged and pinned node 20 by digest, so the PR now replaces the pinned line:

-FROM node:20-alpine@sha256:fb4cd12c85ee03686f6af5362a0b0d56d50c58a04632e6c0fb8363f609372293
+FROM node:22-alpine@sha256:16e22a550f3863206a3f701448c45f7912c6896a62de43add43bb9c86130c3e2

Both stages bumped together, and #189's hardening survives intact at the head: tini installed and ENTRYPOINT ["/sbin/tini", "--"] (line 38), USER nodeuser (28), HEALTHCHECK on /healthcheck via BusyBox wget -qO- (33–34). Node 20 went EOL 2026-04-30 and engines: ">=22.12.0" does not accept it, so this ordering — #189 first, then this — was the correct one.

2. package.json no longer adds the keywords array#198 merged it in the meantime. Correct, not a drop.

Re-verified the things that a rebase could have silently reverted:

  • publish-npm.yml:39 still reads npm install -g npm@11#207's fix survived; only the comment around it was rewritten. Checked at the head, because a revert here would be invisible in green CI.
  • run-tests.yaml has setup-node@v4 with node-version: '22', and engines declares ">=22.12.0".
  • Lockfile still carries full resolved/integrity metadata for all 1579 entries; the delta versus main is the envelop duplicate removal plus the yoga 5 tree, as before.

Non-blocking: docs still say "Node.js 20+"; nightly-devnet-dump.yaml is still on node 20.

dkijania and others added 3 commits August 26, 2026 23:11
Bring the server stack onto current majors:

- graphql-yoga 4 → 5, @envelop/core 4 → 5, @envelop/graphql-jit 6 → 11,
  @envelop/disable-introspection 5 → 9, @envelop/opentelemetry 5 → 9.
- Node 20 → 22 LTS across the Dockerfile, Volta pin, and the lint / unit-test /
  publish / smoke-load workflows.

The only code change required is a targeted cast where useOpenTelemetry now types
its provider argument against a duplicate @opentelemetry/api copy; the cast
targets the exact expected parameter type. Build, unit tests, lint, and prettier
all pass; the node:22 image builds and runs v22.x locally.

Note: the OpenTelemetry SDK is intentionally not bumped here — the modern OTel
packages drop the Jaeger exporter in favour of OTLP, so clearing the remaining
@opentelemetry/* audit advisories is a separate Jaeger→OTLP migration.

Closes #176.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QSuak9smCHbp4N17xjjLF6
Pins both Dockerfile stages to node:22-alpine@sha256:16e22a55… (v22.23.1,
verified by pull and build). Without this, moving to a floating 22 tag
would silently drop the digest pin #189 adds, reverting its
reproducible/tamper-evident build hardening depending on merge order.
Once #192 lands, Dependabot's docker ecosystem keeps the digest fresh.

Bumps @types/node to ^22 so the types match the runtime rather than
staying on 20.

Adds tests pinning the GraphQL-over-HTTP behaviour the mina-explorer
depends on: validation errors return 200 (its client throws on non-2xx
before reading the body) and carry the literal "Cannot query field" (its
fallbacks key on that string). Both were verified by hand across the
4 → 5 upgrade and are unchanged, but they rest on an implicit
content-negotiation default — yoga only returns 400 under an Accept
header that client never sends — so a later bump could flip them
unnoticed. Now a standing guard rather than a one-time check.

Addresses review feedback on #194.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dkijania
dkijania merged commit 37fd9d8 into main Aug 26, 2026
9 checks passed
@dkijania
dkijania deleted the feat/yoga5-node22 branch August 26, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P1 Strongly recommended before GA production-readiness Work toward making the API production-ready / publicly available

Projects

None yet

Development

Successfully merging this pull request may close these issues.

P1: Dependency upgrades — graphql-yoga 4→5, Node 20→22 LTS

2 participants