Repository navigation
feat(cli): auto-exchange GitHub OIDC in void deploy (single-step Actions workflow) - #239
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e5faca6394
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…tions workflow)
When `void deploy` runs inside GitHub Actions with no explicit VOID_TOKEN, the
CLI now mints a GitHub OIDC token (audience `void`) and exchanges it at
`POST $VOID_API_URL/auth/github-oidc` for a short-lived, project-scoped deploy
token. This lets the scaffolded workflow collapse to a single `void deploy`
step — the curl/jq exchange step is gone.
Detection + exchange (new `src/cli/github-oidc.ts`):
- `isGithubActionsOidcAvailable()` — GITHUB_ACTIONS=true plus a non-empty OIDC
request URL + token (i.e. `id-token: write` was granted).
- `exchangeGithubOidcForDeployToken()` — GET the OIDC JWT (audience `void`),
then POST `{ oidcToken, project }` to the API and read `.token`. Clear,
actionable errors carrying the HTTP status + server body.
- `maskGithubActionsSecret()` — emits `::add-mask::` so the token is redacted
from workflow logs.
Precedence in the deploy path is unchanged for the common cases: an explicit
token (VOID_TOKEN env / keychain / file) still wins, so existing curl-exchange
workflows keep working. Only when no token is found AND running in Actions does
the CLI exchange; the slug is resolved without a client (--project / VOID_PROJECT
/ linked .void/project.json). Local (non-Actions) behavior is untouched.
Scaffold: drop the curl/jq exchange step; keep `permissions: id-token: write`
(the CLI mints the OIDC token) and the VOID_API_URL / VOID_PROJECT env. Docs
updated to match.
Tests: unit coverage for detection, the two-hop exchange (audience appended,
exact `{oidcToken, project}` body, non-OK GET/POST + missing-field errors), the
mask helper, the single-step scaffold output, and the deploy-path wiring
(in-Actions triggers the exchange; explicit token skips it; unknown project errors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… sees it (review) The single-step GitHub Actions workflow drops the curl/jq step that wrote `VOID_TOKEN` to `$GITHUB_ENV`. `void deploy` now mints the deploy token in-process, but assigned it only to a local variable. The downstream build and prerender re-read credentials via `getToken()` (which reads `process.env.VOID_TOKEN` first) — both in-process (`collectPrerenderPaths`) and inside the spawned `vite build` child (`plugin-prerender`'s closeBundle, which inherits `process.env` via `buildEnv`). A local variable reaches neither, so in CI the prerender injected no `__VOID_TOKEN`/`__VOID_PROJECT_ID` and rendered against local/empty bindings (AI, remote D1/KV/R2 break) — a regression from the removed scaffold step. Export the minted token to `process.env.VOID_TOKEN` right after masking, inside the `!token` OIDC branch so an explicit `VOID_TOKEN` is never overwritten. This restores exact parity with the removed `$GITHUB_ENV` write. The token is the same deploy-scoped (`aud: deploy`, short-TTL) token the old workflow exposed to the build, so visibility is parity, not new exposure. The `command === 'serve'` gate on `__VOID_TOKEN` worker-var injection is untouched, so this does not leak the token into `dist/ssr/wrangler.json`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The GitHub Actions OIDC → deploy-token exchange POSTs to $VOID_API_URL/auth/github-oidc before any PlatformClient exists, so it sent only Content-Type. When VOID_API_URL targets the Access-gated staging API, Cloudflare Access rejected the request at the edge before /auth/github-oidc could mint the deploy token. Mirror every other CLI → Void-API call: call ensureCloudflaredToken(apiUrl) and spread cfAccessHeaders(process.env) on the POST. Both no-op for the prod/ungated path (no CF Access env, host not gated), so this is a safe defense-in-depth change. The GitHub OIDC GET (token.actions.githubusercontent.com) is untouched — CF Access headers belong only to the Void API. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b6e70cef22
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
b6e70ce to
a7b53b7
Compare
…view) Deploy token resolution called getToken() first, which returns a keychain/config-file token in addition to the VOID_TOKEN env. On a self-hosted or reused GitHub Actions runner with a leftover `void auth login` credential, that ambient token preempted the OIDC branch: the deploy used a broad, long-lived `aud:'api'` user token (or failed if stale) instead of the short-lived, project-scoped `aud:'deploy'` token the workflow's `id-token: write` intends. Reorder precedence to: explicit VOID_TOKEN env (checked directly, not via getToken) > GitHub Actions OIDC exchange > saved keychain/file. Hosted runners (no saved token, no env) are unaffected — OIDC still runs. Explicit VOID_TOKEN still wins everywhere and is never overwritten. Restores the old scaffold's intent, which exported the exchanged token as VOID_TOKEN before `void deploy`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… 5xx (#239) * fix(alerts): stamp the error window in 5xx alert emails; surface edge 5xx ErrorAlertMonitor accumulated 5xx counts silently through its 24h cooldown and could later fire with a days-old total labelled "recently". The email now carries the concrete UTC window (firstErrorAt..lastErrorAt) plus a `void project logs --level error --range <R>` hint sized to that window. Count and window survive the cooldown (kept, then stamped); window fields backfill to null on legacy DO state. `--level error` log queries now also match a 5xx status (blob6 LIKE '5%'), so a returned 500 with no throw/log is findable (paired with the CLI-side filter in #281). Adds `GET /projects/:id/requests` over the edge request-metering dataset (void_requests) to surface 5xx that never reach a worker — edge-generated errors and static/SPA requests — with an optional status-class/exact-code filter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011iZtc3xSUBc3NqapBXuYCH * fix(alerts): point the alert email at request-level data; harden query params Addresses review feedback on #239: - The email hinted only at `void project logs --level error`, which by construction cannot show edge-generated or static/SPA 5xx: those never invoke a worker, so they produce no tail rows — yet they are counted by this very alert. It now leads with `void project requests --status 5xx` and keeps the logs command for worker logs and exceptions. - `suggestRange` measured only the error window, so a send that retried for hours emitted a range excluding the errors it was reporting. It is now anchored at send time. - `/requests` keys on `blob1`, matching the requests-dataset convention used by the billing and top-traffic queries (meta/lessons.md). - `limit` is validated as a positive integer on both `/requests` and `/logs`; a negative previously reached the SQL `LIMIT` and surfaced an Analytics Engine rejection as a misleading 502. Exact `status` codes are bounded to 100-599. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011iZtc3xSUBc3NqapBXuYCH * chore(fmt): reformat 0051-websockets.md for the oxfmt in vite-plus 0.2.6 The vite-plus 0.2.6 bump (#240) ships an oxfmt that wraps this doc's TS union differently, which turned `lint-and-check` red on main and on every branch rebased onto it. Purely mechanical — one code-fence line, no content change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011iZtc3xSUBc3NqapBXuYCH * fix(alerts): stamp the 5xx window from edge enqueue times Cloudflare stamps every queue message with its enqueue time — which is when dispatch metered the response at the edge — and processUsageEventBatch was dropping it on the floor at the `.body` map. The ErrorAlertMonitor then stamped `Date.now()` at ingest, so a backlogged or retried batch reported a window around drain time, and sized the emailed `--range` so it excluded the very rows the command points at. Carry per-project min/max enqueue times through `/record` instead. `firstAt` and `lastAt` are optional with a Date.now() fallback, so in-flight calls keep working; no dispatch-side change, so the api-before-dispatch deploy order is not a factor. The timestamp read is instanceof-guarded because this is also the billing path. Legacy DO state (count > 0 written before the window fields existed) keeps a null window on purpose: those errors have no known time, so the email falls back to "recently" rather than claiming a window that excludes them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZzGBP6hyUQNVNZXfd3h5M * chore(deps): point framework submodule at merged void#281 #281 squash-merged as 870b8c1, adding `void project requests` and folding 5xx into `--level error` on the CLI side of this change. The branch commit the submodule was pinned to (99dbe39) becomes unreachable once the PR branch is deleted, which would break `git submodule update --init` in CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZzGBP6hyUQNVNZXfd3h5M --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Motivation
The scaffolded GitHub Actions deploy workflow (
void init --github) had two steps: a curl/jq step that GETs a GitHub OIDC token (audiencevoid), POSTs it to$VOID_API_URL/auth/github-oidc, reads.token, and exportsVOID_TOKEN; then avoid deploystep. This PR moves the exchange into the CLI, so the scaffold collapses to a singlevoid deploystep — no curl/jq.Detection + exchange flow
New module
packages/void/src/cli/github-oidc.ts:isGithubActionsOidcAvailable(env)— true iffGITHUB_ACTIONS === 'true'and non-emptyACTIONS_ID_TOKEN_REQUEST_URLand non-emptyACTIONS_ID_TOKEN_REQUEST_TOKEN(i.e. the workflow grantedpermissions: id-token: write).exchangeGithubOidcForDeployToken(apiUrl, project, env):GET ${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=voidwithAuthorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}→.value(the OIDC JWT).POST ${apiUrl}/auth/github-oidcwith JSON{ oidcToken, project }→.token(the deploy token).maskGithubActionsSecret(value, env)— emits::add-mask::<token>to stdout when running in Actions so the token is redacted from logs.Wired into
runDeploy's token resolution:let token = getToken(root); if no token and in Actions, resolve the slug (--project/VOID_PROJECT/ linked.void/project.json), exchange, mask, and use it.Precedence / back-compat
getToken()(VOID_TOKENenv or keychain/file). Existing workflows that still curl-exchange setVOID_TOKENand keep working unchanged.Local (non-Actions) behavior and the
VOID_TOKENpath are untouched.Scaffold change
permissions: id-token: write+contents: read— the CLI now mints the OIDC token, so this permission is still required.env: VOID_API_URLandVOID_PROJECT(the CLI reads both).${runner} deploy --project "$VOID_PROJECT".docs/guide/deployment.md,docs/reference/cli.md).Tests
isGithubActionsOidcAvailableacross env combinations.exchangeGithubOidcForDeployTokenwith mocked fetch — success returns the token; audiencevoidappended to the GET URL; POST body is exactly{oidcToken, project}; non-OK GET/POST and missing fields throw useful messages; trailing-slash API URL handled.maskGithubActionsSecret(emits in Actions, no-op otherwise / empty value).init.test.tsto assert the single-step output (no curl/exchange step;id-token: writeretained; single deploy step).VOID_TOKENtriggers the exchange; explicit token skips it; unknown project errors.All touched tests pass (154), typecheck / lint / fmt clean.
🤖 Generated with Claude Code
Note
Medium Risk
Changes deploy authentication precedence and CI credential flow (OIDC vs saved tokens); mistakes could affect self-hosted runners or prerender remote bindings, though behavior is covered by new tests and explicit env/OIDC ordering.
Overview
Moves GitHub Actions OIDC → Void deploy-token exchange from the scaffolded workflow into
void deploy, sovoid init --githubworkflows drop the curl/jq step and only runvoid deploy(still withpermissions: id-token: write).Adds
github-oidc.ts: detect Actions OIDC availability, mint audiencevoidJWT, POST to/auth/github-oidc, mask tokens in logs, and forward CF Access headers for gated APIs.Deploy auth order is now: explicit
VOID_TOKENenv → OIDC exchange in Actions when no env token → saved keychain/config. OIDC is checked beforegetToken()so a leftover login on a reused runner cannot override the short-lived deploy token. After exchange, the minted token is written toprocess.env.VOID_TOKENso build/prerender paths that callgetToken()behave like the old$GITHUB_ENVstep.Docs and init scaffold comments/YAML are updated; unit tests cover exchange, precedence, env export, and the slimmer workflow output.
Reviewed by Cursor Bugbot for commit cc683fc. Bugbot is set up for automated code reviews on this repo. Configure here.