Skip to content

feat(cli): auto-exchange GitHub OIDC in void deploy (single-step Actions workflow) - #239

Merged
Brooooooklyn merged 4 commits into
mainfrom
feat/deploy-github-oidc-auto
Jul 1, 2026
Merged

Brooooooklyn merged 4 commits into
mainfrom
feat/deploy-github-oidc-auto

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Jul 1, 2026 •

Copy link
Copy Markdown
Member

Motivation

The scaffolded GitHub Actions deploy workflow (void init --github) had two steps: a curl/jq step that GETs a GitHub OIDC token (audience void), POSTs it to $VOID_API_URL/auth/github-oidc, reads .token, and exports VOID_TOKEN; then a void deploy step. This PR moves the exchange into the CLI, so the scaffold collapses to a single void deploy step — no curl/jq.

Detection + exchange flow

New module packages/void/src/cli/github-oidc.ts:

  • isGithubActionsOidcAvailable(env) — true iff GITHUB_ACTIONS === 'true' and non-empty ACTIONS_ID_TOKEN_REQUEST_URL and non-empty ACTIONS_ID_TOKEN_REQUEST_TOKEN (i.e. the workflow granted permissions: id-token: write).
  • exchangeGithubOidcForDeployToken(apiUrl, project, env):
    1. GET ${ACTIONS_ID_TOKEN_REQUEST_URL}&audience=void with Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN} → .value (the OIDC JWT).
    2. POST ${apiUrl}/auth/github-oidc with JSON { oidcToken, project } → .token (the deploy token).
    • Throws clear, actionable errors (HTTP status + server error body) on any non-OK response or missing field.
  • 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

  1. Explicit token still wins — getToken() (VOID_TOKEN env or keychain/file). Existing workflows that still curl-exchange set VOID_TOKEN and keep working unchanged.
  2. Else if running in GitHub Actions → auto-exchange OIDC. If the slug is unknown, errors: "running in GitHub Actions but the project is unknown — pass --project or link the project."
  3. Else → existing not-authenticated error path.

Local (non-Actions) behavior and the VOID_TOKEN path are untouched.

Scaffold change

  • Removed the "Exchange GitHub OIDC token…" curl/jq step.
  • Kept permissions: id-token: write + contents: read — the CLI now mints the OIDC token, so this permission is still required.
  • Kept env: VOID_API_URL and VOID_PROJECT (the CLI reads both).
  • Only remaining step after setup+install is ${runner} deploy --project "$VOID_PROJECT".
  • Docs updated (docs/guide/deployment.md, docs/reference/cli.md).

Tests

  • Unit: isGithubActionsOidcAvailable across env combinations.
  • Unit: exchangeGithubOidcForDeployToken with mocked fetch — success returns the token; audience void appended 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.
  • Unit: maskGithubActionsSecret (emits in Actions, no-op otherwise / empty value).
  • Scaffold: updated init.test.ts to assert the single-step output (no curl/exchange step; id-token: write retained; single deploy step).
  • Deploy-path: in-Actions + no VOID_TOKEN triggers 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, so void init --github workflows drop the curl/jq step and only run void deploy (still with permissions: id-token: write).

Adds github-oidc.ts: detect Actions OIDC availability, mint audience void JWT, POST to /auth/github-oidc, mask tokens in logs, and forward CF Access headers for gated APIs.

Deploy auth order is now: explicit VOID_TOKEN env → OIDC exchange in Actions when no env token → saved keychain/config. OIDC is checked before getToken() so a leftover login on a reused runner cannot override the short-lived deploy token. After exchange, the minted token is written to process.env.VOID_TOKEN so build/prerender paths that call getToken() behave like the old $GITHUB_ENV step.

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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 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".

Comment thread packages/void/src/cli/github-oidc.ts Outdated
Brooooooklyn and others added 3 commits July 1, 2026 16:53
…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>

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 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".

Comment thread packages/void/src/cli/deploy.ts
@Brooooooklyn
Brooooooklyn force-pushed the feat/deploy-github-oidc-auto branch from b6e70ce to a7b53b7 Compare July 1, 2026 08:54
…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>
@Brooooooklyn
Brooooooklyn merged commit 59484fc into main Jul 1, 2026
7 checks passed
@Brooooooklyn
Brooooooklyn deleted the feat/deploy-github-oidc-auto branch July 1, 2026 09:11
cpojer pushed a commit that referenced this pull request Sep 11, 2026
… 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>
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