Skip to content

feat(graphql-server): serve the operator-injected release tuple at /.well-known/constructive-release - #1917

Closed
pyramation wants to merge 2 commits into
mainfrom
feat/well-known-constructive-release
Closed

pyramation wants to merge 2 commits into
mainfrom
feat/well-known-constructive-release

Conversation

@pyramation

@pyramation pyramation commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes constructive-io/constructive-planning#2289. Hub's live.yml preflight and tests/acceptance/graphql.ts both GET https://api.launchql.dev/.well-known/constructive-release and need a non-empty JSON release tuple. Nothing served that path, so devnet answers 404 and live acceptance cannot pass.

The GraphQL server now serves the tuple the cloud operator already renders from ConstructiveApp.spec.release:

GET /.well-known/constructive-release   (Cache-Control: no-store)
{"hub":"880","constructive":"91e18cc","constructiveDb":"f14937c","dashboard":"1623557","functions":"f14937c"}
  • graphql/env getReleaseEnv: reads CONSTRUCTIVE_RELEASE_{HUB,CONSTRUCTIVE,CONSTRUCTIVE_DB,DASHBOARD,FUNCTIONS} into opts.release (new ReleaseOptions in graphql-types). It is all-or-nothing. If none of the variables are set, there is no tuple and no route. If any of them is set, all five must be non-empty, or getEnvOptions throws and names the bad variables. The server will not start with a partial tuple.
  • graphql/server createReleaseHandler checks the same invariant again for runtime options that skip the env parser (new Server({ release }) goes through an unvalidated deepmerge). If any of the five fields is missing or empty, it throws and names them, so the server never answers 200 with a partial tuple.
  • graphql/server: when opts.release is set, app.get(RELEASE_PATH, createReleaseHandler(...)) is mounted next to /healthz. It sits before domain/API routing, so it answers on any host, including api.launchql.dev.
  • Shape: exactly the five keys of the operator's ReleaseSpec (hub, constructive, constructiveDb, dashboard, functions). These are also hub's RELEASE_FIELDS, which live.yml compares key by key. The larger constructive.cloud/release/v1 candidate artifact from constructive-hub#892 is a different object: cloud promotion turns it into spec.release. It is not what this endpoint serves.

Why here and not a new operator-rendered static route

The issue proposes having the operator render spec.release as a static JSON route. The operator already renders spec.release into every server container as CONSTRUCTIVE_RELEASE_*, and those values take precedence over anything in the app's own env (operator/internal/resources/release.go, release_env_test.go). The only missing piece was an HTTP responder on the API host, and api.launchql.dev already routes to constructive-public-server. Traefik has no static-response primitive, so an operator-only fix would need a new Deployment, a new pinned image, a Service, NetworkPolicy floor entries, and Traefik path matching (traefikMatch ignores path today). Serving it from the server:

  • needs no operator, CRD, ingress or manifest changes;
  • keeps spec.release as the single source of truth, with no second hand-kept copy;
  • reports what the running server pod was started with. During a half-finished rollout it returns the old tuple, which is what live acceptance should compare against. A ConfigMap responder would show the new spec before the server had rolled out.

Reproduce before fix

The tests are in the normal package jest flow (graphql/env, graphql/server). For the "before" run, I reverted the src changes (types/env/server), rebuilt the graphql-types and graphql-env dist, and ran the tests:

graphql/server  src/middleware/__tests__/release.test.ts
  ✕ serves the operator-injected release tuple as JSON on any host
    expected 200 "OK", got 500 "Internal Server Error"   # the request fell through to API routing (devnet: 404)
graphql/env  __tests__/release.test.ts
  FAIL  TS2339: Property 'release' does not exist on type 'Partial<ConstructiveOptions>'

After the fix (dist rebuilt):

graphql/server  ✓ serves the operator-injected release tuple as JSON on any host
graphql/env     Tests: 65 passed, 65 total   (release.test.ts: 12 passed — mapping, plus missing/empty rejection for each of the 5 vars)

Partial runtime release option (raised in review). The tests were added first and run against the previous createReleaseHandler:

before: Tests: 11 failed, 1 passed, 12 total
  ✕ rejects a runtime release option missing {hub,constructive,constructiveDb,dashboard,functions}
  ✕ rejects a runtime release option with an empty {hub,…,functions}
  ✕ refuses to construct a Server from a partial runtime release option
after:  Tests: 12 passed, 12 total

eslint and tsc --noEmit (graphql/server) are clean.

Rollout for devnet

  1. Merge this PR. docker-constructive.yaml then builds the constructive server image from main.
  2. In constructive-cloud, bump the server image pin in deploy/dev/constructiveapp.yaml, and release.constructive / deploy/release.yaml with it, to that commit. Then roll it out through the normal devnet deploy. No operator or CRD change is needed: the operator already injects CONSTRUCTIVE_RELEASE_*.
  3. Read-only check: curl -fsS https://api.launchql.dev/.well-known/constructive-release | jq -cS .

Link to Devin session: https://app.devin.ai/sessions/c7c0041938a3463eb27ccf6a67a7b67e
Open in Devin Desktop: https://app.devin.ai/desktop/session/c7c0041938a3463eb27ccf6a67a7b67e?variant=devin
Requested by: @pyramation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@tenki-reviewer

tenki-reviewer Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 1 medium

💬 Inline comments (1)

  • 🟡 Partial runtime release tuple served with HTTP 200 — release.ts:6

This change introduces a configurable "release" identity tuple (version, sha, constructiveDb) sourced from CONSTRUCTIVE_RELEASE_* env vars and surfaces it via a new Express middleware on the GraphQL server.

Files Change
graphql/env/src/release.ts, graphql/env/src/env.ts, graphql/env/__tests__/release.test.ts Adds getReleaseEnv parsing/validation with all-or-nothing tuple gating and wires it into the GraphQL env options.
graphql/server/src/middleware/release.ts, graphql/server/src/middleware/__tests__/release.test.ts, graphql/server/src/server.ts Adds the release endpoint middleware with Cache-Control handling, tests, and server registration.
graphql/types/src/release.ts, graphql/types/src/constructive.ts, graphql/types/src/index.ts Adds the ReleaseOptions type, extends ConstructiveOptions with an optional release field, and barrel-exports it.

Reviewed commit: a9cd3af

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Adds a versioned release middleware exposing CONSTRUCTIVE_RELEASE_* metadata at a well-known server endpoint, with shared env parsing in graphql/env and exported types in graphql/types.

Key findings

  • 🟡 Partial runtime release tuple served with HTTP 200 — release.ts:6

Comment on lines +6 to +13
export const createReleaseHandler = (release: ReleaseOptions): RequestHandler => {
const tuple: ReleaseOptions = {
hub: release.hub,
constructive: release.constructive,
constructiveDb: release.constructiveDb,
dashboard: release.dashboard,
functions: release.functions
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 bug · medium

Partial runtime release tuple served with HTTP 200

The all-or-nothing validation for the release tuple exists only on the env path (getReleaseEnv, graphql/env/src/release.ts:20-27). createReleaseHandler copies its five fields with no completeness check (graphql/server/src/middleware/release.ts:6-13), and getEnvOptions deepmerges runtime overrides unvalidated (graphql/env/src/merge.ts:51), so a runtime-supplied partial release option reaches the handler intact. Since JSON.stringify drops keys whose values are undefined, the endpoint returns 200 with missing fields, silently breaking the RELEASE_FIELDS contract (exact 5-key body) that the acceptance tests and workflow preflight rely on.

📋 Prompt for AI Agents

In graphql/server/src/middleware/release.ts lines 6-13, inside createReleaseHandler, validate that every field of the passed release object (hub, constructive, constructiveDb, dashboard, functions) is a non-empty string and throw a descriptive Error otherwise, mirroring the all-or-nothing invariant enforced by getReleaseEnv (graphql/env/src/release.ts:15-31) so a runtime-supplied partial release option cannot produce a 200 response missing keys required by the RELEASE_FIELDS contract.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant