Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 1 addition & 12 deletions docs/guide/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ If your CI pipeline runs typechecking or other static analysis before deploy, ru

You can deploy from a GitHub repo on every push to `main`. Running `void init --github` generates `.github/workflows/void-deploy.yml` in your project.

The workflow authenticates with [GitHub OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) — there is **no long-lived `VOID_TOKEN` secret to store or rotate**. The job requests a short-lived OIDC token from GitHub, exchanges it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived project-scoped deploy token (about 15 minutes on the free tier; longer on higher plans, capped at ~60 minutes), then runs `void deploy --project <slug>`:
The workflow authenticates with [GitHub OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) — there is **no long-lived `VOID_TOKEN` secret to store or rotate**. `void deploy` detects GitHub Actions and does the exchange itself: it requests a short-lived OIDC token from GitHub (audience `void`), exchanges it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived project-scoped deploy token (about 15 minutes on the free tier; longer on higher plans, capped at ~60 minutes), and deploys — so the workflow is a single `void deploy` step. `permissions: id-token: write` is still required: it is what lets the job (and the CLI) mint the OIDC token.

```yaml
name: Deploy to Void
Expand Down Expand Up @@ -98,17 +98,6 @@ jobs:
node-version: lts/*
cache: npm
- run: npm ci
- name: Exchange GitHub OIDC token for a Void deploy token
run: |
OIDC_TOKEN="$(curl -sf \
-H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=void" | jq -r '.value')"
VOID_TOKEN="$(curl -sf \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg t "$OIDC_TOKEN" --arg p "$VOID_PROJECT" '{oidcToken: $t, project: $p}')" \
"$VOID_API_URL/auth/github-oidc" | jq -r '.token')"
echo "::add-mask::$VOID_TOKEN"
echo "VOID_TOKEN=$VOID_TOKEN" >> "$GITHUB_ENV"
- name: Deploy
run: npx void deploy --project "$VOID_PROJECT"
```
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ After that, the full interactive flow walks through:
4. **Skills:** links Void skills using the same detected or selected agent context.
5. **MCP config:** writes MCP server config using that same agent context.
6. **Demo code:** for existing non-Pages projects, optionally scaffolds a `db/migrations/` directory plus an API route and typed fetch example.
7. **GitHub Actions:** optionally creates `.github/workflows/void-deploy.yml`. The workflow deploys on pushes to `main` and authenticates via [GitHub OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) — no long-lived `VOID_TOKEN` secret is stored in the repo. The job requests an OIDC token (audience `void`), exchanges it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived project-scoped deploy token, then runs `void deploy --project <slug>` with the right package manager. The project slug is baked in from your linked project (`.void/project.json`) when known; otherwise the workflow reads a `VOID_PROJECT` repository variable.
7. **GitHub Actions:** optionally creates `.github/workflows/void-deploy.yml`. The workflow deploys on pushes to `main` and authenticates via [GitHub OIDC](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect) — no long-lived `VOID_TOKEN` secret is stored in the repo. The workflow is a single `void deploy` step: when it runs in GitHub Actions, `void deploy` mints an OIDC token (audience `void`), exchanges it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived project-scoped deploy token, and deploys. `permissions: id-token: write` is retained so the CLI can mint that token. The project slug is baked in from your linked project (`.void/project.json`) when known; otherwise the workflow reads a `VOID_PROJECT` repository variable.
8. **`env.ts` scaffold:** if the project has no `env.ts` but has `.env` / `.env.example` / `.env.local` / `.env.development*` files on disk, generates an `env.ts` pre-populated with their keys. Values get conservative type inference (`boolean`/`url`/`number`/`string`) — the file carries a banner nudging you to tighten anything the heuristic got wrong.
9. **Project setup:** optionally logs you in, lets you select or create a project, and writes `.void/project.json` so your first deploy can just be `void deploy`.

Expand Down
60 changes: 57 additions & 3 deletions packages/void/src/cli/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ import {
} from '../sandbox/config.ts';
import { resolveRemoteMode, stripInternalEnvKeys } from '../shared/env.ts';
import { validateSsrEntry } from '../ssr/entry.ts';
import { getToken, getTokenSource } from './auth.ts';
import { getApiUrl, getToken, getTokenSource } from './auth.ts';
import {
isExpiredTokenError,
PlatformClient,
Expand All @@ -76,6 +76,11 @@ import {
validateProdEnv,
} from './env-validation.ts';
import { collectGitDeployMetadata, type GitDeployMetadata } from './git-metadata.ts';
import {
exchangeGithubOidcForDeployToken,
isGithubActionsOidcAvailable,
maskGithubActionsSecret,
} from './github-oidc.ts';
import { lintDestinationSplats, lintDuplicateSources, mergeRoutingRules } from './headers.ts';
import { openDeployLog, type CliLogger } from './log-file.ts';
import { promptForLoginToken } from './login.ts';
Expand Down Expand Up @@ -382,8 +387,57 @@ async function runDeployInner(
let reauthenticated = false;
while (true) {
try {
// 1. Auth
const token = getToken(root);
// 1. Auth — resolve the deploy token by precedence:
// 1. explicit `VOID_TOKEN` env (deliberate override; wins everywhere)
// 2. GitHub Actions OIDC exchange (in Actions, when no explicit token)
// 3. saved keychain / config file (local dev)
//
// OIDC MUST beat an AMBIENT saved token here. `getToken()` returns the env
// token AND a keychain/config-file token, so calling it first would let a
// leftover `void auth login` credential on a self-hosted / reused runner
// preempt OIDC — silently deploying with a broad, long-lived `aud:'api'`
// user token (or failing outright if it is stale) instead of the
// short-lived, project-scoped `aud:'deploy'` token the workflow's
// `id-token: write` intends. So we read the EXPLICIT env directly (NOT via
// getToken), then OIDC, then the saved token. This matches the old
// scaffold, which exported the freshly exchanged token as `VOID_TOKEN`
// before `void deploy` so the deploy token always won inside Actions.
let token = process.env.VOID_TOKEN ?? null;
if (!token && isGithubActionsOidcAvailable()) {
Comment thread
Brooooooklyn marked this conversation as resolved.
// Running inside GitHub Actions with no explicit VOID_TOKEN: mint a
// GitHub OIDC token and exchange it for a short-lived, project-scoped
// deploy token. The slug must resolve WITHOUT a platform client (there
// is no token yet): --project flag > VOID_PROJECT env > linked
// .void/project.json — the same sources the deploy path uses below. A
// workflow with `id-token: write` intends OIDC, so an unknown slug is a
// hard error — we do NOT silently fall back to a saved token.
const slug = getRequestedProjectSlug(options) ?? readProjectConfig(root)?.slug;
if (!slug) {
throw new Error(
'deploy: running in GitHub Actions but the project is unknown — pass --project <slug> or link the project.',
);
}
cliLog.info('github_oidc_exchange_start', { slug });
token = await exchangeGithubOidcForDeployToken(getApiUrl(root), slug);
maskGithubActionsSecret(token);
// Export the minted token so the downstream build/prerender can reach
// it. Both prerender paths re-read credentials via `getToken()`, which
// reads `process.env.VOID_TOKEN` first: the in-process path
// (`collectPrerenderPaths` below) and the spawned `vite build` child
// (`plugin-prerender`'s closeBundle, which inherits `process.env` via
// `buildEnv`). A local variable can't reach either, so without this the
// prerender injects no `__VOID_TOKEN`/`__VOID_PROJECT_ID` and renders
// against local/empty bindings (AI, remote D1/KV/R2 break). This
// restores parity with the removed scaffold step that wrote
// `VOID_TOKEN` to `$GITHUB_ENV`. Guarded by the enclosing `!token`
// branch, so an explicit `VOID_TOKEN` is never overwritten.
process.env.VOID_TOKEN = token;
cliLog.info('github_oidc_exchange_success', { slug });
}
// Local dev / non-Actions (or Actions without id-token permission): fall
// back to the saved keychain/config token. `getToken()` re-checks
// `VOID_TOKEN` first, which is harmlessly unset on this branch.
token ??= getToken(root);
if (!token) {
log.error('No auth token found. Run `void auth login` first.');
process.exit(1);
Expand Down
176 changes: 176 additions & 0 deletions packages/void/src/cli/github-oidc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
/**
* GitHub Actions OIDC → Void deploy-token exchange.
*
* When `void deploy` runs inside GitHub Actions with no explicit `VOID_TOKEN`,
* the CLI mints a short-lived GitHub OIDC token (audience `void`) and exchanges
* it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived, project-scoped
* Void deploy token. Doing the exchange in-process lets the scaffolded workflow
* collapse to a single `void deploy` step — no curl/jq token-exchange step.
*
* Server contract (platform `POST /auth/github-oidc` handler):
* request { oidcToken: string, project: string } (project = slug)
* response { token: string, projectId, buildId }
*/

import { cfAccessHeaders } from '../shared/cf-access.ts';
import { ensureCloudflaredToken } from './cf-access.ts';

const OIDC_AUDIENCE = 'void';

/**
* True when the process is running inside GitHub Actions with the OIDC token
* request endpoint available — i.e. the workflow granted
* `permissions: id-token: write`, so GitHub injected the request URL + token.
*/
export function isGithubActionsOidcAvailable(env: NodeJS.ProcessEnv = process.env): boolean {
return (
env.GITHUB_ACTIONS === 'true' &&
typeof env.ACTIONS_ID_TOKEN_REQUEST_URL === 'string' &&
env.ACTIONS_ID_TOKEN_REQUEST_URL.length > 0 &&
typeof env.ACTIONS_ID_TOKEN_REQUEST_TOKEN === 'string' &&
env.ACTIONS_ID_TOKEN_REQUEST_TOKEN.length > 0
);
}

/**
* Mint a GitHub Actions OIDC token (audience `void`) and exchange it for a
* short-lived, project-scoped Void deploy token.
*
* Two hops:
* 1. GET $ACTIONS_ID_TOKEN_REQUEST_URL&audience=void (Bearer request token)
* → `.value` is the OIDC JWT.
* 2. POST $apiUrl/auth/github-oidc { oidcToken, project }
* → `.token` is the Void deploy token.
*
* Throws a clear, actionable error (including the HTTP status and server error
* body when available) on any non-OK response or missing field.
*/
export async function exchangeGithubOidcForDeployToken(
apiUrl: string,
project: string,
env: NodeJS.ProcessEnv = process.env,
): Promise<string> {
const requestUrl = env.ACTIONS_ID_TOKEN_REQUEST_URL;
const requestToken = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
if (!requestUrl || !requestToken) {
throw new Error(
'github-oidc: GitHub Actions OIDC is unavailable (missing ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN). Ensure the workflow grants `permissions: id-token: write`.',
);
}

const oidcToken = await requestGithubOidcToken(requestUrl, requestToken);
return exchangeOidcTokenAtApi(apiUrl, project, oidcToken);
}

/**
* GET the GitHub Actions OIDC token for audience `void`. GitHub's request URL
* already carries a query string, so `audience` is appended alongside it.
*/
async function requestGithubOidcToken(requestUrl: string, requestToken: string): Promise<string> {
const url = new URL(requestUrl);
url.searchParams.set('audience', OIDC_AUDIENCE);

let res: Response;
try {
res = await fetch(url, {
headers: { Authorization: `Bearer ${requestToken}` },
});
} catch (err) {
throw new Error(`github-oidc: Failed to reach the GitHub OIDC token endpoint: ${errText(err)}`);
}

if (!res.ok) {
const body = await safeText(res);
throw new Error(
`github-oidc: Failed to mint a GitHub OIDC token (${res.status})${body ? `: ${body}` : ''}.`,
);
}

let body: unknown;
try {
body = await res.json();
} catch {
throw new Error('github-oidc: GitHub OIDC token endpoint returned an invalid response.');
}

const value = (body as { value?: unknown } | null)?.value;
if (typeof value !== 'string' || value.length === 0) {
throw new Error('github-oidc: GitHub OIDC token endpoint did not return a token value.');
}

return value;
}

/** POST the OIDC JWT to the Void API and return the minted deploy token. */
async function exchangeOidcTokenAtApi(
apiUrl: string,
project: string,
oidcToken: string,
): Promise<string> {
const base = apiUrl.endsWith('/') ? apiUrl.slice(0, -1) : apiUrl;
const url = `${base}/auth/github-oidc`;

// This POST hits the Void API (which may be the Access-gated staging host)
// before any PlatformClient exists, so it must carry CF Access credentials
// itself — exactly like every other CLI → Void-API call (PlatformClient
// ctor, exchangeLoginCode). `ensureCloudflaredToken` no-ops for prod/ungated
// hosts and when the service-token pair or CF_ACCESS_TOKEN is already set;
// `cfAccessHeaders` returns {} when no CF Access env is present (prod path).
ensureCloudflaredToken(apiUrl);

let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...cfAccessHeaders(process.env) },
body: JSON.stringify({ oidcToken, project }),
});
} catch (err) {
throw new Error(`github-oidc: Failed to reach ${url}: ${errText(err)}`);
}

if (!res.ok) {
const body = await safeText(res);
throw new Error(
`github-oidc: OIDC token exchange failed (${res.status})${body ? `: ${body}` : ''}. Confirm the repository is connected to project "${project}" with the github_actions executor.`,
);
}

let body: unknown;
try {
body = await res.json();
} catch {
throw new Error('github-oidc: OIDC token exchange returned an invalid response.');
}

const token = (body as { token?: unknown } | null)?.token;
if (typeof token !== 'string' || token.length === 0) {
throw new Error('github-oidc: OIDC token exchange did not return a deploy token.');
}

return token;
}

/**
* Emit a GitHub Actions `::add-mask::` workflow command so `value` is redacted
* from workflow logs. No-op when not running inside GitHub Actions. GitHub reads
* workflow commands from stdout.
*/
export function maskGithubActionsSecret(value: string, env: NodeJS.ProcessEnv = process.env): void {
if (env.GITHUB_ACTIONS !== 'true' || !value) {
return;
}
process.stdout.write(`::add-mask::${value}\n`);
}

function errText(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}

async function safeText(res: Response): Promise<string> {
try {
return (await res.text()).trim();
} catch {
return '';
}
}
25 changes: 8 additions & 17 deletions packages/void/src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1918,10 +1918,12 @@ function getWorkflowSetupSteps(

/**
* Scaffold the GitHub Actions workflow that deploys via GitHub OIDC — no
* long-lived secret stored in the repo. The job requests an OIDC token from
* GitHub (audience `void`), exchanges it at `POST $VOID_API_URL/auth/github-oidc`
* for a short-lived (15-min) project-scoped deploy token, then runs
* `void deploy --project <slug>` with that token exported as `VOID_TOKEN`.
* long-lived secret stored in the repo. `void deploy` itself detects GitHub
* Actions, mints an OIDC token (audience `void`), exchanges it at
* `POST $VOID_API_URL/auth/github-oidc` for a short-lived, project-scoped deploy
* token, and uses it — so the workflow is a single `void deploy` step (no
* curl/jq). `permissions: id-token: write` is still required: it is what lets
* the job (and thus the CLI) mint the OIDC token.
*
* `slug` is the linked project slug when known (from `.void/project.json`);
* otherwise a `${{ vars.VOID_PROJECT }}` placeholder is emitted with a comment
Expand Down Expand Up @@ -1955,8 +1957,8 @@ concurrency:
cancel-in-progress: true

# id-token: write lets the job mint a GitHub OIDC token. No long-lived
# VOID_TOKEN secret is stored — the token below is exchanged for a
# short-lived, project-scoped deploy token at deploy time.
# VOID_TOKEN secret is stored — \`void deploy\` mints that OIDC token and
# exchanges it for a short-lived, project-scoped deploy token at deploy time.
permissions:
id-token: write
contents: read
Expand All @@ -1973,17 +1975,6 @@ jobs:
- uses: actions/checkout@v6
${setup}
${install}
- name: Exchange GitHub OIDC token for a Void deploy token
run: |
OIDC_TOKEN="$(curl -sf \\
-H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \\
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=void" | jq -r '.value')"
VOID_TOKEN="$(curl -sf \\
-H 'Content-Type: application/json' \\
-d "$(jq -n --arg t "$OIDC_TOKEN" --arg p "$VOID_PROJECT" '{oidcToken: $t, project: $p}')" \\
"$VOID_API_URL/auth/github-oidc" | jq -r '.token')"
echo "::add-mask::$VOID_TOKEN"
echo "VOID_TOKEN=$VOID_TOKEN" >> "$GITHUB_ENV"
- name: Deploy
run: ${runner} deploy --project "$VOID_PROJECT"
`;
Expand Down
Loading