Skip to content

Commit 94b0d4e

Browse files
Brooooooklynclaude
andauthored
feat(cli): void init --github — scaffold GitHub Actions OIDC deploy workflow (#217)
## Summary Adds `void init --github`, the CLI side of **Deploy on GitHub**. It scaffolds `.github/workflows/void-deploy.yml`, which: - requests a GitHub OIDC token (audience `void`) — `permissions: id-token: write`, no long-lived secret stored in the repo - exchanges it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived, project-scoped Void deploy token - runs `void deploy` on the GitHub-hosted runner On a `git push`, GitHub Actions builds and deploys via OIDC. The platform side (GitHub App, webhooks, OIDC exchange endpoint, builds + container executor) lives in the companion `void-platform` PR. ## Changes - `--github` flag on `void init` (workflow-only scaffold; safe to re-run, skips if the workflow already exists) - `void init --github` CLI help + examples entry - scaffolded workflow uses `VOID_API_URL` repo var (defaults to `https://api.void.cloud`) and per-`(repo, ref)` concurrency - docs: `docs/guide/deployment.md` + `docs/reference/cli.md` - `init.test.ts` coverage for the new flag + scaffold ## Test - `init.test.ts`: 129 passed - fmt / lint / typecheck: clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes deploy credential handling in child processes and CI auth patterns; routing publish semantics affect whether users see success vs degraded reachability without failing the command. > > **Overview** > **GitHub Actions deploy** moves from a long-lived `VOID_TOKEN` secret and `deploy.yml` to **`void init --github`** writing **`.github/workflows/void-deploy.yml`**: `id-token: write`, repo/ref **concurrency**, OIDC exchange at **`POST $VOID_API_URL/auth/github-oidc`**, then **`void deploy --project`**. The workflow bakes in the linked slug from **`.void/project.json`** when present, otherwise **`vars.VOID_PROJECT`**; interactive init **patches the placeholder** after project linking. Docs and CLI help describe OIDC and staging via **`VOID_API_URL`**. > > **`void deploy`** adds **`buildEnv()`** so **`CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET`** are removed from **child** env for **`vite build`**, git metadata, and **drizzle-kit** (parent **`process.env`** unchanged for interactive re-auth). Deploy **done** events may include **`published: false`**; the CLI still treats the deploy as success but shows **degraded spinner text**, logs **`deploy_degraded`**, and **`warnUnpublishedRouting`** after the summary. > > Unit tests cover OIDC workflow scaffolding, slug patching, **`buildEnv`**, unpublished routing, and **PlatformClient** Access header capture. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit af19690. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bcc51f8 commit 94b0d4e

10 files changed

Lines changed: 788 additions & 110 deletions

File tree

‎docs/guide/deployment.md‎

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,30 +64,56 @@ If your CI pipeline runs typechecking or other static analysis before deploy, ru
6464

6565
## GitHub Actions
6666

67-
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:
67+
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.
68+
69+
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>`:
6870

6971
```yaml
70-
name: Deploy
72+
name: Deploy to Void
7173
on:
7274
push:
7375
branches: [main]
7476

77+
# Latest push wins: a newer commit cancels an in-flight deploy for the same
78+
# repo + branch, so an older commit can never overtake a newer one.
79+
concurrency:
80+
group: void-deploy-${{ github.repository }}-${{ github.ref }}
81+
cancel-in-progress: true
82+
83+
permissions:
84+
id-token: write
85+
contents: read
86+
87+
env:
88+
VOID_API_URL: ${{ vars.VOID_API_URL || 'https://api.void.cloud' }}
89+
VOID_PROJECT: my-app
90+
7591
jobs:
7692
deploy:
7793
runs-on: ubuntu-latest
7894
steps:
79-
- uses: actions/checkout@v4
80-
- uses: voidzero-dev/setup-vp@v1
95+
- uses: actions/checkout@v6
96+
- uses: actions/setup-node@v6
8197
with:
82-
node-version: '22'
83-
cache: true
84-
- run: vp exec void deploy
85-
env:
86-
VOID_TOKEN: ${{ secrets.VOID_TOKEN }}
87-
VOID_PROJECT: my-app
98+
node-version: lts/*
99+
cache: npm
100+
- run: npm ci
101+
- name: Exchange GitHub OIDC token for a Void deploy token
102+
run: |
103+
OIDC_TOKEN="$(curl -sf \
104+
-H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
105+
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=void" | jq -r '.value')"
106+
VOID_TOKEN="$(curl -sf \
107+
-H 'Content-Type: application/json' \
108+
-d "$(jq -n --arg t "$OIDC_TOKEN" --arg p "$VOID_PROJECT" '{oidcToken: $t, project: $p}')" \
109+
"$VOID_API_URL/auth/github-oidc" | jq -r '.token')"
110+
echo "::add-mask::$VOID_TOKEN"
111+
echo "VOID_TOKEN=$VOID_TOKEN" >> "$GITHUB_ENV"
112+
- name: Deploy
113+
run: npx void deploy --project "$VOID_PROJECT"
88114
```
89115
90-
To get a deploy token, run `void auth token` to copy it to your clipboard, then add it as a GitHub Actions secret.
116+
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`.
91117

92118
## Other Targets
93119

‎docs/reference/cli.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ After that, the full interactive flow walks through:
8686
4. **Skills:** links Void skills using the same detected or selected agent context.
8787
5. **MCP config:** writes MCP server config using that same agent context.
8888
6. **Demo code:** for existing non-Pages projects, optionally scaffolds a `db/migrations/` directory plus an API route and typed fetch example.
89-
7. **GitHub Actions:** optionally creates `.github/workflows/deploy.yml` with the right package manager commands.
89+
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.
9090
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.
9191
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`.
9292

@@ -98,10 +98,12 @@ Use flags to run individual steps without prompts:
9898
| ------------ | ------------------------------------------------- |
9999
| `--tsconfig` | Only update `tsconfig.json` |
100100
| `--agents` | Set up agent instructions, skills, and MCP config |
101-
| `--github` | Only create GitHub Actions workflow |
101+
| `--github` | Only create the GitHub Actions deploy workflow |
102102

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

105+
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`.
106+
105107
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.
106108

107109
### `void prepare`

‎packages/void/src/cli/deploy.ts‎

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,31 @@ export async function runDeploy(root: string, options?: RunDeployOptions): Promi
349349
}
350350
}
351351

352+
/**
353+
* Build a child-process environment with the CF Access service-token PAIR
354+
* removed.
355+
*
356+
* The deploy flow spawns UNTRUSTED user build code (`vite build`, framework
357+
* preset builds, drizzle-kit loading the user schema) which inherits the
358+
* spawn env. A shared, long-lived CF Access service token is needed only by
359+
* this CLI's own API client (which captures the Access headers once at
360+
* construction and reuses them for every request) — never by the build — so it
361+
* must not survive into those children, or it would leak to arbitrary repo
362+
* build scripts/plugins.
363+
*
364+
* We scrub it on the CHILD env rather than mutating `process.env`: deleting it
365+
* from the parent also breaks interactive expired-token re-auth, which reads
366+
* `cfAccessHeaders(process.env)` / the cloudflared pair-guard AFTER the build
367+
* step. Only the service-token pair is scrubbed; the cloudflared
368+
* `CF_ACCESS_TOKEN` JWT (dev path) is left untouched.
369+
*/
370+
export function buildEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
371+
const env = { ...process.env, ...extra };
372+
delete env.CF_ACCESS_CLIENT_ID;
373+
delete env.CF_ACCESS_CLIENT_SECRET;
374+
return env;
375+
}
376+
352377
async function runDeployInner(
353378
root: string,
354379
options: RunDeployOptions | undefined,
@@ -366,6 +391,11 @@ async function runDeployInner(
366391

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

394+
// The CF Access service-token pair is intentionally left in `process.env`
395+
// so interactive expired-token re-auth (below) can still read it. It is
396+
// kept OUT of every untrusted build child instead, via `buildEnv()` on the
397+
// spawn env — see that helper for the full rationale.
398+
369399
// 2. Resolve target project (CLI flag > env var > linked project file)
370400
const requestedProjectSlug = getRequestedProjectSlug(options);
371401
let config: ProjectConfig | null = null;
@@ -666,7 +696,17 @@ export function applyDeployEvent(event: DeployEvent, s: SpinnerResult): ApplyDep
666696
s.message(`Prerendering ${event.pages} pages...`);
667697
return { kind: 'continue' };
668698
case 'done':
669-
s.stop('Deployed!');
699+
if (event.published === false) {
700+
// Committed-live in the DB but routing KV was not published. The
701+
// deploy succeeded (do NOT throw / return an error kind) but the
702+
// project may be unreachable (first deploy) or still serving the
703+
// previous deploy (later deploy). Degrade the message accordingly.
704+
s.stop(
705+
'Deployed, but routing was not published — the project may be unreachable or still serving the previous deploy. Re-run `void deploy` to publish routing.',
706+
);
707+
} else {
708+
s.stop('Deployed!');
709+
}
670710
return { kind: 'done', event };
671711
case 'error':
672712
s.stop('Deploy failed');
@@ -680,7 +720,7 @@ export function applyDeployEvent(event: DeployEvent, s: SpinnerResult): ApplyDep
680720
* resolve with the terminal `done` event (or throw on terminal error /
681721
* connection loss).
682722
*/
683-
async function streamDeployEvents(
723+
export async function streamDeployEvents(
684724
projectId: string,
685725
events: AsyncGenerator<DeployEvent>,
686726
s: SpinnerResult,
@@ -716,12 +756,17 @@ async function streamDeployEvents(
716756
deploymentId = result.deploymentId;
717757
cliLog?.info('stream_deployment_id', { deploymentId });
718758
} else if (result.kind === 'done') {
719-
cliLog?.info('deploy_success', {
759+
const degraded = result.event.published === false;
760+
// Degraded = committed-live but routing KV not published. Still a
761+
// `done` outcome (not an error), but logged distinctly so the
762+
// degraded result is greppable in the JSONL trace.
763+
cliLog?.info(degraded ? 'deploy_degraded' : 'deploy_success', {
720764
deploymentId,
721765
url: result.event.url,
722766
assets: result.event.assets,
723767
workers: result.event.workers,
724768
durationMs: Date.now() - startedAt,
769+
degraded,
725770
});
726771
return result.event;
727772
} else if (result.kind === 'error') {
@@ -956,7 +1001,7 @@ export async function runStaticDeploy(
9561001
log.step('Building...');
9571002
cliLog?.info('build_start', { command: preset.buildCommand });
9581003
const buildStartedAt = Date.now();
959-
execSync(preset.buildCommand, { cwd: root, stdio: 'inherit' });
1004+
execSync(preset.buildCommand, { cwd: root, stdio: 'inherit', env: buildEnv() });
9601005
cliLog?.info('build_end', { durationMs: Date.now() - buildStartedAt });
9611006
} else if (skipBuild) {
9621007
cliLog?.info('build_skipped', { reason: 'skipBuild' });
@@ -1034,9 +1079,26 @@ export async function runStaticDeploy(
10341079
const summary = [`${typeLabel} — ${formatKnownAssetSummary(result, skipped)}`];
10351080
note(summary.join('\n'), result.url);
10361081

1082+
warnUnpublishedRouting(result);
10371083
outro('Done!');
10381084
}
10391085

1086+
/**
1087+
* Surface a persistent warning when a deploy committed live but its routing
1088+
* KV was not published. Uses `log.warn` (not the spinner) so the warning
1089+
* survives the following `note`/`outro` calls and the user sees it after the
1090+
* deploy summary. The deploy is still committed — this is a warning, not a
1091+
* failure, so the process exit code is unaffected.
1092+
*/
1093+
export function warnUnpublishedRouting(result: StreamDeployResult): void {
1094+
if (result.published !== false) {
1095+
return;
1096+
}
1097+
log.warn(
1098+
'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.',
1099+
);
1100+
}
1101+
10401102
/**
10411103
* Emit a single aggregated lint warning when a `_redirects` file contains
10421104
* one or more `!` force suffixes on 3xx entries (e.g. `301!`, `302!`).
@@ -1081,6 +1143,7 @@ async function runFrameworkDeploy(
10811143
execSync(voidConfig.inference?.build ?? resolveProjectCommand(root, fwPreset.buildCommand), {
10821144
cwd: root,
10831145
stdio: 'inherit',
1146+
env: buildEnv(),
10841147
});
10851148
cliLog?.info('build_end', { durationMs: Date.now() - buildStartedAt });
10861149
} else {
@@ -1373,6 +1436,7 @@ async function runFrameworkDeploy(
13731436
// Clean up generated wrapper files
13741437
cleanupWrapper(root);
13751438

1439+
warnUnpublishedRouting(result);
13761440
outro('Done!');
13771441
}
13781442

@@ -1404,7 +1468,7 @@ async function runFullDeploy(
14041468
execSync(resolveProjectCommand(root, 'vite build'), {
14051469
cwd: root,
14061470
stdio: 'inherit',
1407-
env: { ...process.env, VOID_DEPLOY_PROJECT_ID: config.projectId },
1471+
env: buildEnv({ VOID_DEPLOY_PROJECT_ID: config.projectId }),
14081472
});
14091473
cliLog?.info('build_end', { durationMs: Date.now() - buildStartedAt });
14101474
} catch (err: unknown) {
@@ -1869,6 +1933,7 @@ async function runFullDeploy(
18691933
}
18701934
note(summary.join('\n'), result.url);
18711935

1936+
warnUnpublishedRouting(result);
18721937
outro('Done!');
18731938
}
18741939

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

1963+
// `git status` can invoke a repo-configured `core.fsmonitor` hook, so keep
1964+
// the CF Access service-token pair out of these git children too (matches
1965+
// the build spawns and the prior global env scrub).
1966+
const gitEnv = buildEnv();
18981967
// Check if in a git repo
1899-
execSync('git rev-parse --is-inside-work-tree', { cwd: root, stdio: 'ignore' });
1968+
execSync('git rev-parse --is-inside-work-tree', { cwd: root, stdio: 'ignore', env: gitEnv });
19001969
// Check if working tree is clean
1901-
const status = execSync('git status --porcelain', { cwd: root, encoding: 'utf-8' }).trim();
1970+
const status = execSync('git status --porcelain', {
1971+
cwd: root,
1972+
encoding: 'utf-8',
1973+
env: gitEnv,
1974+
}).trim();
19021975
if (status !== '') {
19031976
return null;
19041977
}
19051978
// Get current commit hash
1906-
return execSync('git rev-parse HEAD', { cwd: root, encoding: 'utf-8' }).trim();
1979+
return execSync('git rev-parse HEAD', { cwd: root, encoding: 'utf-8', env: gitEnv }).trim();
19071980
} catch {
19081981
return null;
19091982
}
@@ -1963,6 +2036,9 @@ function runDrizzleKitGenerate(root: string, configPath: string): string {
19632036
const result = spawnSync(process.execPath, [drizzleKitBin, 'generate', '--config', configPath], {
19642037
cwd: root,
19652038
encoding: 'utf-8',
2039+
// drizzle-kit loads the user's drizzle config + schema (user code); keep the
2040+
// CF Access service token out of it, matching the build spawns.
2041+
env: buildEnv(),
19662042
});
19672043
const stderr = bufferToString(result.stderr);
19682044
const stdout = bufferToString(result.stdout);

‎packages/void/src/cli/help.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,15 @@ const HELP_TREE: HelpTree = {
2828
options: [
2929
{ command: '--agents', description: 'Set up agent instructions, skills, and MCP config' },
3030
{ command: '--tsconfig', description: 'Only update tsconfig.json' },
31-
{ command: '--github', description: 'Only create GitHub Actions workflow' },
31+
{
32+
command: '--github',
33+
description: 'Only create the GitHub Actions deploy workflow (OIDC, no stored token)',
34+
},
3235
],
3336
examples: [
3437
{ command: 'void init', description: 'Scaffold or configure the current project' },
3538
{ command: 'void init --agents', description: 'Install agent instructions and MCP config' },
39+
{ command: 'void init --github', description: 'Scaffold the GitHub Actions deploy workflow' },
3640
],
3741
},
3842
deploy: {

0 commit comments

Comments
 (0)