Skip to content
48 changes: 37 additions & 11 deletions docs/guide/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,30 +64,56 @@ If your CI pipeline runs typechecking or other static analysis before deploy, ru

## GitHub Actions

You can deploy from a GitHub repo with a simple GitHub Actions workflow. Running `void init --github` will generate this as `.github/workflows/deploy.yml` in your project:
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 project-scoped deploy token (valid ~15 minutes), then runs `void deploy --project <slug>`:

```yaml
name: Deploy
name: Deploy to Void
on:
push:
branches: [main]

# Latest push wins: a newer commit cancels an in-flight deploy for the same
# repo + branch, so an older commit can never overtake a newer one.
concurrency:
group: void-deploy-${{ github.repository }}-${{ github.ref }}
cancel-in-progress: true

permissions:
id-token: write
contents: read

env:
VOID_API_URL: ${{ vars.VOID_API_URL || 'https://api.void.cloud' }}
VOID_PROJECT: my-app

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: voidzero-dev/setup-vp@v1
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: '22'
cache: true
- run: vp exec void deploy
env:
VOID_TOKEN: ${{ secrets.VOID_TOKEN }}
VOID_PROJECT: my-app
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"
```

To get a deploy token, run `void auth token` to copy it to your clipboard, then add it as a GitHub Actions secret.
Connect the repository to your project once in the Void dashboard to authorize it. The exact steps vary by package manager (the scaffold detects pnpm, yarn, bun, or npm). The `VOID_PROJECT` value is baked in from your linked project when known; otherwise the workflow reads a `VOID_PROJECT` repository variable. To target staging, set a `VOID_API_URL` repository variable to `https://api.staging.void.cloud`.

## Other Targets

Expand Down
6 changes: 4 additions & 2 deletions 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/deploy.yml` with the right package manager commands.
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.
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 All @@ -98,10 +98,12 @@ Use flags to run individual steps without prompts:
| ------------ | ------------------------------------------------- |
| `--tsconfig` | Only update `tsconfig.json` |
| `--agents` | Set up agent instructions, skills, and MCP config |
| `--github` | Only create GitHub Actions workflow |
| `--github` | Only create the GitHub Actions deploy workflow |

Flags can be combined. When any flag is provided, only the specified steps run and interactive prompts are skipped.

The `--github` workflow uses GitHub OIDC: you connect the repository to your Void project once (in the dashboard), and pushes to `main` deploy automatically. There is no `VOID_TOKEN` secret to create or rotate. To target staging, set a `VOID_API_URL` repository variable to `https://api.staging.void.cloud`.

For projects that already have `"extends"`, `void init --tsconfig` preserves the existing config and adds `./.void/tsconfig.json`. If the existing config defines `files` or `compilerOptions.paths`, Void also merges its generated declaration files and aliases into the root config because TypeScript replaces those fields across `extends` instead of deeply merging them.

### `void prepare`
Expand Down
92 changes: 84 additions & 8 deletions packages/void/src/cli/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,31 @@ export async function runDeploy(root: string, options?: RunDeployOptions): Promi
}
}

/**
* Build a child-process environment with the CF Access service-token PAIR
* removed.
*
* The deploy flow spawns UNTRUSTED user build code (`vite build`, framework
* preset builds, drizzle-kit loading the user schema) which inherits the
* spawn env. A shared, long-lived CF Access service token is needed only by
* this CLI's own API client (which captures the Access headers once at
* construction and reuses them for every request) — never by the build — so it
* must not survive into those children, or it would leak to arbitrary repo
* build scripts/plugins.
*
* We scrub it on the CHILD env rather than mutating `process.env`: deleting it
* from the parent also breaks interactive expired-token re-auth, which reads
* `cfAccessHeaders(process.env)` / the cloudflared pair-guard AFTER the build
* step. Only the service-token pair is scrubbed; the cloudflared
* `CF_ACCESS_TOKEN` JWT (dev path) is left untouched.
*/
export function buildEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
const env = { ...process.env, ...extra };
delete env.CF_ACCESS_CLIENT_ID;
delete env.CF_ACCESS_CLIENT_SECRET;
return env;
}

async function runDeployInner(
root: string,
options: RunDeployOptions | undefined,
Expand All @@ -366,6 +391,11 @@ async function runDeployInner(

const client = new PlatformClient(token, { root, cliLog });

// The CF Access service-token pair is intentionally left in `process.env`
// so interactive expired-token re-auth (below) can still read it. It is
// kept OUT of every untrusted build child instead, via `buildEnv()` on the
// spawn env — see that helper for the full rationale.

// 2. Resolve target project (CLI flag > env var > linked project file)
const requestedProjectSlug = getRequestedProjectSlug(options);
let config: ProjectConfig | null = null;
Expand Down Expand Up @@ -666,7 +696,17 @@ export function applyDeployEvent(event: DeployEvent, s: SpinnerResult): ApplyDep
s.message(`Prerendering ${event.pages} pages...`);
return { kind: 'continue' };
case 'done':
s.stop('Deployed!');
if (event.published === false) {
// Committed-live in the DB but routing KV was not published. The
// deploy succeeded (do NOT throw / return an error kind) but the
// project may be unreachable (first deploy) or still serving the
// previous deploy (later deploy). Degrade the message accordingly.
s.stop(
'Deployed, but routing was not published — the project may be unreachable or still serving the previous deploy. Re-run `void deploy` to publish routing.',
);
} else {
s.stop('Deployed!');
}
return { kind: 'done', event };
case 'error':
s.stop('Deploy failed');
Expand All @@ -680,7 +720,7 @@ export function applyDeployEvent(event: DeployEvent, s: SpinnerResult): ApplyDep
* resolve with the terminal `done` event (or throw on terminal error /
* connection loss).
*/
async function streamDeployEvents(
export async function streamDeployEvents(
projectId: string,
events: AsyncGenerator<DeployEvent>,
s: SpinnerResult,
Expand Down Expand Up @@ -716,12 +756,17 @@ async function streamDeployEvents(
deploymentId = result.deploymentId;
cliLog?.info('stream_deployment_id', { deploymentId });
} else if (result.kind === 'done') {
cliLog?.info('deploy_success', {
const degraded = result.event.published === false;
// Degraded = committed-live but routing KV not published. Still a
// `done` outcome (not an error), but logged distinctly so the
// degraded result is greppable in the JSONL trace.
cliLog?.info(degraded ? 'deploy_degraded' : 'deploy_success', {
deploymentId,
url: result.event.url,
assets: result.event.assets,
workers: result.event.workers,
durationMs: Date.now() - startedAt,
degraded,
});
return result.event;
} else if (result.kind === 'error') {
Expand Down Expand Up @@ -956,7 +1001,7 @@ export async function runStaticDeploy(
log.step('Building...');
cliLog?.info('build_start', { command: preset.buildCommand });
const buildStartedAt = Date.now();
execSync(preset.buildCommand, { cwd: root, stdio: 'inherit' });
execSync(preset.buildCommand, { cwd: root, stdio: 'inherit', env: buildEnv() });
cliLog?.info('build_end', { durationMs: Date.now() - buildStartedAt });
} else if (skipBuild) {
cliLog?.info('build_skipped', { reason: 'skipBuild' });
Expand Down Expand Up @@ -1034,9 +1079,26 @@ export async function runStaticDeploy(
const summary = [`${typeLabel} — ${formatKnownAssetSummary(result, skipped)}`];
note(summary.join('\n'), result.url);

warnUnpublishedRouting(result);
outro('Done!');
}

/**
* Surface a persistent warning when a deploy committed live but its routing
* KV was not published. Uses `log.warn` (not the spinner) so the warning
* survives the following `note`/`outro` calls and the user sees it after the
* deploy summary. The deploy is still committed — this is a warning, not a
* failure, so the process exit code is unaffected.
*/
export function warnUnpublishedRouting(result: StreamDeployResult): void {
if (result.published !== false) {
return;
}
log.warn(
'Routing was not published for this deploy. The project may be unreachable (first deploy) or still serving the previous deploy. Re-run `void deploy` to publish routing.',
);
}

/**
* Emit a single aggregated lint warning when a `_redirects` file contains
* one or more `!` force suffixes on 3xx entries (e.g. `301!`, `302!`).
Expand Down Expand Up @@ -1081,6 +1143,7 @@ async function runFrameworkDeploy(
execSync(voidConfig.inference?.build ?? resolveProjectCommand(root, fwPreset.buildCommand), {
cwd: root,
stdio: 'inherit',
env: buildEnv(),
});
cliLog?.info('build_end', { durationMs: Date.now() - buildStartedAt });
} else {
Expand Down Expand Up @@ -1373,6 +1436,7 @@ async function runFrameworkDeploy(
// Clean up generated wrapper files
cleanupWrapper(root);

warnUnpublishedRouting(result);
outro('Done!');
}

Expand Down Expand Up @@ -1404,7 +1468,7 @@ async function runFullDeploy(
execSync(resolveProjectCommand(root, 'vite build'), {
cwd: root,
stdio: 'inherit',
env: { ...process.env, VOID_DEPLOY_PROJECT_ID: config.projectId },
env: buildEnv({ VOID_DEPLOY_PROJECT_ID: config.projectId }),
});
cliLog?.info('build_end', { durationMs: Date.now() - buildStartedAt });
} catch (err: unknown) {
Expand Down Expand Up @@ -1869,6 +1933,7 @@ async function runFullDeploy(
}
note(summary.join('\n'), result.url);

warnUnpublishedRouting(result);
outro('Done!');
}

Expand All @@ -1895,15 +1960,23 @@ function getGitCommit(root: string): string | null {
return githubSha;
}

// `git status` can invoke a repo-configured `core.fsmonitor` hook, so keep
// the CF Access service-token pair out of these git children too (matches
// the build spawns and the prior global env scrub).
const gitEnv = buildEnv();
// Check if in a git repo
execSync('git rev-parse --is-inside-work-tree', { cwd: root, stdio: 'ignore' });
execSync('git rev-parse --is-inside-work-tree', { cwd: root, stdio: 'ignore', env: gitEnv });
// Check if working tree is clean
const status = execSync('git status --porcelain', { cwd: root, encoding: 'utf-8' }).trim();
const status = execSync('git status --porcelain', {
cwd: root,
encoding: 'utf-8',
env: gitEnv,
}).trim();
if (status !== '') {
return null;
}
// Get current commit hash
return execSync('git rev-parse HEAD', { cwd: root, encoding: 'utf-8' }).trim();
return execSync('git rev-parse HEAD', { cwd: root, encoding: 'utf-8', env: gitEnv }).trim();
} catch {
return null;
}
Expand Down Expand Up @@ -1963,6 +2036,9 @@ function runDrizzleKitGenerate(root: string, configPath: string): string {
const result = spawnSync(process.execPath, [drizzleKitBin, 'generate', '--config', configPath], {
cwd: root,
encoding: 'utf-8',
// drizzle-kit loads the user's drizzle config + schema (user code); keep the
// CF Access service token out of it, matching the build spawns.
env: buildEnv(),
});
const stderr = bufferToString(result.stderr);
const stdout = bufferToString(result.stdout);
Expand Down
6 changes: 5 additions & 1 deletion packages/void/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,15 @@ const HELP_TREE: HelpTree = {
options: [
{ command: '--agents', description: 'Set up agent instructions, skills, and MCP config' },
{ command: '--tsconfig', description: 'Only update tsconfig.json' },
{ command: '--github', description: 'Only create GitHub Actions workflow' },
{
command: '--github',
description: 'Only create the GitHub Actions deploy workflow (OIDC, no stored token)',
},
],
examples: [
{ command: 'void init', description: 'Scaffold or configure the current project' },
{ command: 'void init --agents', description: 'Install agent instructions and MCP config' },
{ command: 'void init --github', description: 'Scaffold the GitHub Actions deploy workflow' },
],
},
deploy: {
Expand Down
Loading