From 6c7c057d2dfe47d1cb07042b97792c7b19a7e839 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 9 Jun 2026 09:06:26 +0800 Subject: [PATCH 1/6] feat(cli): add `void init --github` to scaffold the GitHub Actions deploy workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scaffolds `.github/workflows/void-deploy.yml`, which mints a GitHub OIDC token (audience `void`), exchanges it at `POST $VOID_API_URL/auth/github-oidc` for a short-lived scoped deploy token, and runs `void deploy` — no long-lived secret stored in the repo. Adds the `--github` flag (workflow-only init), CLI help entry, deployment guide, and CLI reference updates. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/guide/deployment.md | 48 +++++-- docs/reference/cli.md | 6 +- packages/void/src/cli/help.ts | 6 +- packages/void/src/cli/init.ts | 196 +++++++++++++++++---------- packages/void/test/unit/init.test.ts | 64 +++++++-- 5 files changed, 224 insertions(+), 96 deletions(-) diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 10135d124..846c21bb8 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -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 `: ```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 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index aeb75ee2e..1c66f641e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -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 ` 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`. @@ -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` diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 276036dc9..fb6a7bfc3 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -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: { diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index 751943d8d..f440cc477 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -31,6 +31,7 @@ import { maybeSetupDeployProject } from './deploy-setup.ts'; import { maybeScaffoldEnvTs } from './env-scaffold.ts'; import { cliTitle } from './output.ts'; import { formatProjectCommand } from './preset.ts'; +import { readProjectConfig } from './project.ts'; import { createPagesStarterTemplate, detectScaffoldFrameworkFromDependencies, @@ -1866,107 +1867,156 @@ function getNodeVersionLine(root: string): string { return 'node-version: lts/*'; } -function getWorkflowYaml(root: string, pm: ProjectState['packageManager']): string { +// The default Void API endpoint baked into the scaffolded workflow. Users can +// override it with a `VOID_API_URL` repository variable (see the env block). +const WORKFLOW_DEFAULT_API_URL = 'https://api.void.cloud'; + +// Per-package-manager install + setup steps. `void deploy` always runs through +// the package-manager-appropriate runner in the deploy step below. +function getWorkflowSetupSteps( + root: string, + pm: ProjectState['packageManager'], +): { setup: string; install: string; runner: string } { const nodeVersion = getNodeVersionLine(root); switch (pm) { case 'pnpm': - return `name: Deploy -on: - push: - branches: [main] - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: pnpm/action-setup@v5 + return { + setup: ` - uses: pnpm/action-setup@v5 - uses: actions/setup-node@v6 with: ${nodeVersion} - cache: pnpm - - run: pnpm install - - run: pnpm void deploy - env: - VOID_TOKEN: \${{ secrets.VOID_TOKEN }} -`; + cache: pnpm`, + install: ' - run: pnpm install', + runner: 'pnpm exec void', + }; case 'yarn': - return `name: Deploy -on: - push: - branches: [main] - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + return { + setup: ` - uses: actions/setup-node@v6 with: ${nodeVersion} - cache: yarn - - run: yarn install --immutable - - run: yarn void deploy - env: - VOID_TOKEN: \${{ secrets.VOID_TOKEN }} -`; + cache: yarn`, + install: ' - run: yarn install --immutable', + runner: 'yarn void', + }; case 'bun': - return `name: Deploy -on: - push: - branches: [main] - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: oven-sh/setup-bun@v2 - - run: bun install - - run: bunx void deploy - env: - VOID_TOKEN: \${{ secrets.VOID_TOKEN }} -`; + return { + setup: ' - uses: oven-sh/setup-bun@v2', + install: ' - run: bun install', + runner: 'bunx void', + }; default: - return `name: Deploy + return { + setup: ` - uses: actions/setup-node@v6 + with: + ${nodeVersion} + cache: npm`, + install: ' - run: npm ci', + runner: 'npx void', + }; + } +} + +/** + * 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 ` with that token exported as `VOID_TOKEN`. + * + * `slug` is the linked project slug when known (from `.void/project.json`); + * otherwise a `${{ vars.VOID_PROJECT }}` placeholder is emitted with a comment + * telling the user to set the repository variable. + */ +function getWorkflowYaml( + root: string, + pm: ProjectState['packageManager'], + slug: string | null, +): string { + const { setup, install, runner } = getWorkflowSetupSteps(root, pm); + // When the slug is unknown, fall back to a repository variable and prefix the + // `VOID_PROJECT` line with a comment (each comment line indented to 2 spaces + // so it stays inside the `env:` mapping and the YAML remains valid). + const projectLine = slug + ? `VOID_PROJECT: ${slug}` + : `# Set a VOID_PROJECT repository variable to your project slug + # (Settings → Secrets and variables → Actions → Variables). + VOID_PROJECT: \${{ vars.VOID_PROJECT }}`; + return `name: Deploy to Void on: push: branches: [main] +# Latest push wins: cancel an in-flight deploy for the same repo+branch so a +# newer commit is never overtaken by an older one. Keyed by repo + ref only — +# workflow-level concurrency cannot read the \`env\` context, so VOID_PROJECT is +# not used here (one repo+branch deploys to one project in this scaffold). +concurrency: + group: void-deploy-\${{ github.repository }}-\${{ github.ref }} + 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. +permissions: + id-token: write + contents: read + +env: + # Override with a VOID_API_URL repository variable to target staging. + VOID_API_URL: \${{ vars.VOID_API_URL || '${WORKFLOW_DEFAULT_API_URL}' }} + ${projectLine} + jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - ${nodeVersion} - cache: npm - - run: npm ci - - run: npx void deploy - env: - VOID_TOKEN: \${{ secrets.VOID_TOKEN }} +${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" `; - } } function setupGithubActions(root: string, state: ProjectState): void { - const workflowPath = join(root, '.github', 'workflows', 'deploy.yml'); + const workflowPath = join(root, '.github', 'workflows', 'void-deploy.yml'); if (existsSync(workflowPath)) { - log.warn('deploy.yml already exists, skipping'); + log.warn('void-deploy.yml already exists, skipping'); return; } + const slug = readProjectConfig(root)?.slug ?? null; + mkdirSync(join(root, '.github', 'workflows'), { recursive: true }); - writeFileSync(workflowPath, getWorkflowYaml(root, state.packageManager)); - log.success('Created .github/workflows/deploy.yml'); + writeFileSync(workflowPath, getWorkflowYaml(root, state.packageManager, slug)); + log.success('Created .github/workflows/void-deploy.yml'); - note( - 'After init completes, run `npx void auth token` to copy a deploy token.\n' + - 'Then add it in GitHub under Settings → Secrets and variables → Actions as `VOID_TOKEN`.', - 'GitHub Actions deploy token', - ); + if (slug) { + note( + `Pushes to main will build and deploy "${slug}" via GitHub OIDC — no token to store.\n` + + 'Connect the repo to your project in the Void dashboard to authorize it.', + 'GitHub Actions OIDC deploy', + ); + } else { + note( + 'Pushes to main will build and deploy via GitHub OIDC — no token to store.\n' + + 'Set a VOID_PROJECT repository variable (Settings → Secrets and variables → Actions → Variables)\n' + + 'to your project slug, and connect the repo to your project in the Void dashboard.', + 'GitHub Actions OIDC deploy', + ); + } } function writeFileIfMissing(root: string, filePath: string, content: string): void { @@ -3614,15 +3664,15 @@ export async function runInitCommand(root: string, initArgs?: InitArgs): Promise // interactive modes agree. const githubStepSkipForSubpackage = isInsideWorkspaceSubpackage(root); if ((!targeted || initArgs?.github) && !githubStepSkipForSubpackage) { - const deployYmlPath = join(root, '.github', 'workflows', 'deploy.yml'); + const deployYmlPath = join(root, '.github', 'workflows', 'void-deploy.yml'); if (existsSync(deployYmlPath)) { - log.warn('deploy.yml already exists, skipping'); + log.warn('void-deploy.yml already exists, skipping'); } else if (targeted) { // --github flag: run directly without prompting setupGithubActions(root, state); } else { const setupGha = await confirm({ - message: 'Create .github/workflows/deploy.yml to deploy on pushes to main?', + message: 'Create .github/workflows/void-deploy.yml to deploy on pushes to main?', }); if (isCancel(setupGha)) { cancel('Setup cancelled.'); diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index cdc27cc7a..a4fb1f3d1 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -1482,7 +1482,7 @@ describe('isScaffoldableEmptyProject', () => { expect(viteConfig).toContain('import { voidPlugin } from "void";'); expect(viteConfig).toContain('plugins: [voidPlugin()]'); expect(mockConfirm).toHaveBeenCalledWith({ - message: 'Create .github/workflows/deploy.yml to deploy on pushes to main?', + message: 'Create .github/workflows/void-deploy.yml to deploy on pushes to main?', }); expect(mockSpawnSync).toHaveBeenCalledWith( 'npm', @@ -1498,7 +1498,7 @@ describe('isScaffoldableEmptyProject', () => { } }); - it('explains GitHub Actions token setup as an after-init step', async () => { + it('scaffolds an OIDC deploy workflow with a VOID_PROJECT placeholder when no project is linked', async () => { const root = mkdtempSync(join(tmpdir(), 'void-init-')); try { writeFileSync( @@ -1508,12 +1508,58 @@ describe('isScaffoldableEmptyProject', () => { await runInitCommand(root, { github: true, tsconfig: false, agents: false }); - expect(readFileSync(join(root, '.github', 'workflows', 'deploy.yml'), 'utf-8')).toContain( - 'VOID_TOKEN: ${{ secrets.VOID_TOKEN }}', + const yaml = readFileSync(join(root, '.github', 'workflows', 'void-deploy.yml'), 'utf-8'); + // OIDC, not a stored secret. + expect(yaml).not.toContain('secrets.VOID_TOKEN'); + expect(yaml).toContain('id-token: write'); + expect(yaml).toContain('contents: read'); + expect(yaml).toContain('$ACTIONS_ID_TOKEN_REQUEST_URL&audience=void'); + expect(yaml).toContain('$VOID_API_URL/auth/github-oidc'); + // No linked project → placeholder repository variable. + expect(yaml).toContain('VOID_PROJECT: ${{ vars.VOID_PROJECT }}'); + expect(yaml).toContain("VOID_API_URL: ${{ vars.VOID_API_URL || 'https://api.void.cloud' }}"); + // npm project → npx runner, deploy passes the project slug. + expect(yaml).toContain('npx void deploy --project "$VOID_PROJECT"'); + // Latest-push-wins concurrency keyed by repo + ref (no env context, which + // is unavailable to workflow-level concurrency). + expect(yaml).toContain('concurrency:'); + expect(yaml).toContain('group: void-deploy-${{ github.repository }}-${{ github.ref }}'); + expect(yaml).toContain('cancel-in-progress: true'); + expect(mockNote).toHaveBeenCalledWith( + expect.stringContaining('Set a VOID_PROJECT repository variable'), + 'GitHub Actions OIDC deploy', + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('bakes the linked project slug into the OIDC deploy workflow', async () => { + const root = mkdtempSync(join(tmpdir(), 'void-init-')); + try { + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: 'existing-app', private: true }, null, 2) + '\n', ); + mkdirSync(join(root, '.void'), { recursive: true }); + writeFileSync( + join(root, '.void', 'project.json'), + JSON.stringify({ projectId: 'prj_123', slug: 'my-app' }, null, 2) + '\n', + ); + + await runInitCommand(root, { github: true, tsconfig: false, agents: false }); + + const yaml = readFileSync(join(root, '.github', 'workflows', 'void-deploy.yml'), 'utf-8'); + expect(yaml).toContain('VOID_PROJECT: my-app'); + expect(yaml).not.toContain('vars.VOID_PROJECT'); + // Latest-push-wins concurrency keyed by repo + ref (no env context, which + // is unavailable to workflow-level concurrency). + expect(yaml).toContain('concurrency:'); + expect(yaml).toContain('group: void-deploy-${{ github.repository }}-${{ github.ref }}'); + expect(yaml).toContain('cancel-in-progress: true'); expect(mockNote).toHaveBeenCalledWith( - expect.stringContaining('After init completes, run `npx void auth token`'), - 'GitHub Actions deploy token', + expect.stringContaining('"my-app"'), + 'GitHub Actions OIDC deploy', ); } finally { rmSync(root, { recursive: true, force: true }); @@ -3965,7 +4011,7 @@ export default config; await runInitCommand(subpkgRoot, { github: true, tsconfig: false, agents: false }); // The deploy.yml MUST NOT have been written under the subpackage. - const deployYmlPath = join(subpkgRoot, '.github', 'workflows', 'deploy.yml'); + const deployYmlPath = join(subpkgRoot, '.github', 'workflows', 'void-deploy.yml'); expect(existsSync(deployYmlPath)).toBe(false); } finally { rmSync(wsroot, { recursive: true, force: true }); @@ -4010,7 +4056,7 @@ export default config; await runInitCommand(subpkgRoot, { github: true, tsconfig: false, agents: false }); - const deployYmlPath = join(subpkgRoot, '.github', 'workflows', 'deploy.yml'); + const deployYmlPath = join(subpkgRoot, '.github', 'workflows', 'void-deploy.yml'); expect(existsSync(deployYmlPath)).toBe(false); } finally { rmSync(wsroot, { recursive: true, force: true }); @@ -4152,7 +4198,7 @@ export default config; // No `.github/workflows/deploy.yml` was written under the // subpackage — that file would never be read by GitHub. - const deployYmlPath = join(subpkgRoot, '.github', 'workflows', 'deploy.yml'); + const deployYmlPath = join(subpkgRoot, '.github', 'workflows', 'void-deploy.yml'); expect(existsSync(deployYmlPath)).toBe(false); // The workspace skip note still fired. From 62692d12d1a74ae338cb7fac694c1efc29fe0080 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 9 Jun 2026 12:59:40 +0800 Subject: [PATCH 2/6] feat(cli): surface degraded deploy when routing is not published The deploy `done` event now carries an optional `published` flag. When the server reports `published: false` (routing KV not flipped after the go-live DB commit), the CLI degrades its messaging instead of printing a clean "Deployed!", logs `deploy_degraded` rather than `deploy_success`, and emits a persistent warning in the deploy summary directing the user to re-run `void deploy`. Additive and backward-compatible: older servers omit the field, so the deploy reports success exactly as before. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/deploy.ts | 40 ++++- packages/void/src/cli/types.ts | 7 + packages/void/test/unit/deploy-events.test.ts | 167 +++++++++++++++++- 3 files changed, 210 insertions(+), 4 deletions(-) diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 90d9a8671..bceb8bfba 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -633,7 +633,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'); @@ -647,7 +657,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, s: SpinnerResult, @@ -683,12 +693,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') { @@ -1001,9 +1016,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!`). @@ -1340,6 +1372,7 @@ async function runFrameworkDeploy( // Clean up generated wrapper files cleanupWrapper(root); + warnUnpublishedRouting(result); outro('Done!'); } @@ -1832,6 +1865,7 @@ async function runFullDeploy( } note(summary.join('\n'), result.url); + warnUnpublishedRouting(result); outro('Done!'); } diff --git a/packages/void/src/cli/types.ts b/packages/void/src/cli/types.ts index 336e3205d..603f7268c 100644 --- a/packages/void/src/cli/types.ts +++ b/packages/void/src/cli/types.ts @@ -221,5 +221,12 @@ export type DeployEvent = assets: number; workers: number; migrations?: number; + // Optional additive field: whether routing KV was published after the + // go-live DB commit. Omitted by older servers (treated as published). + // `false` means the deploy is committed-live but its routing was not + // published — the project may be unreachable or still serving the + // previous deploy. The CLI degrades its messaging but does NOT treat + // this as an error: the new model never rolls back a committed deploy. + published?: boolean; } | { event: 'error'; message: string; step: string }; diff --git a/packages/void/test/unit/deploy-events.test.ts b/packages/void/test/unit/deploy-events.test.ts index 43333e71a..b8ec4b0b4 100644 --- a/packages/void/test/unit/deploy-events.test.ts +++ b/packages/void/test/unit/deploy-events.test.ts @@ -12,9 +12,45 @@ vi.mock('@clack/prompts', () => ({ })); import type { SpinnerResult } from '@clack/prompts'; -import { applyDeployEvent } from '../../src/cli/deploy.ts'; +import { + applyDeployEvent, + streamDeployEvents, + warnUnpublishedRouting, +} from '../../src/cli/deploy.ts'; +import type { CliLogger } from '../../src/cli/log-file.ts'; import type { DeployEvent } from '../../src/cli/types.ts'; +type DoneEvent = Extract; + +function createMockCliLog() { + const calls = { + info: [] as Array<{ msg: string; fields?: Record }>, + warn: [] as Array<{ msg: string; fields?: Record }>, + error: [] as Array<{ msg: string }>, + }; + const logger: CliLogger = { + info: (msg, fields) => { + calls.info.push({ msg, fields }); + }, + warn: (msg, fields) => { + calls.warn.push({ msg, fields }); + }, + error: (msg) => { + calls.error.push({ msg }); + }, + debug: () => {}, + path: null, + close: async () => {}, + }; + return { logger, calls }; +} + +async function* eventsOf(events: ReadonlyArray): AsyncGenerator { + for (const event of events) { + yield event; + } +} + function createMockSpinner() { const calls = { start: [] as Array, @@ -106,6 +142,41 @@ describe('applyDeployEvent', () => { expect(calls.stop[0]).toBe('Deployed!'); }); + it('keeps the "Deployed!" message when published is true', () => { + const { spinner, calls } = createMockSpinner(); + const done: DoneEvent = { + event: 'done', + deployment: 'dpl_1', + url: 'https://app.void.app', + urls: ['https://app.void.app'], + assets: 0, + workers: 1, + published: true, + }; + const result = applyDeployEvent(done, spinner); + expect(result).toEqual({ kind: 'done', event: done }); + expect(calls.stop[0]).toBe('Deployed!'); + }); + + it('degrades the done message when published is false but still resolves as a committed done', () => { + const { spinner, calls } = createMockSpinner(); + const done: DoneEvent = { + event: 'done', + deployment: 'dpl_1', + url: 'https://app.void.app', + urls: ['https://app.void.app'], + assets: 0, + workers: 1, + published: false, + }; + const result = applyDeployEvent(done, spinner); + // Committed-live: still a `done` kind, NOT an error. + expect(result).toEqual({ kind: 'done', event: done }); + const msg = String(calls.stop[0]); + expect(msg).toMatch(/routing was not published/i); + expect(msg).not.toBe('Deployed!'); + }); + it('returns {kind:"error"} for error event', () => { const { spinner, calls } = createMockSpinner(); const result = applyDeployEvent( @@ -116,3 +187,97 @@ describe('applyDeployEvent', () => { expect(calls.stop[0]).toBe('Deploy failed'); }); }); + +describe('streamDeployEvents', () => { + it('logs deploy_success and returns the done event on a published deploy', async () => { + const { spinner } = createMockSpinner(); + const { logger, calls } = createMockCliLog(); + const done: DoneEvent = { + event: 'done', + deployment: 'dpl_1', + url: 'https://app.void.app', + urls: ['https://app.void.app'], + assets: 0, + workers: 1, + }; + + const result = await streamDeployEvents( + 'proj_1', + eventsOf([{ event: 'start', deploymentId: 'dpl_1' }, done]), + spinner, + logger, + ); + + expect(result).toEqual(done); + const keys = calls.info.map((c) => c.msg); + expect(keys).toContain('deploy_success'); + expect(keys).not.toContain('deploy_degraded'); + const success = calls.info.find((c) => c.msg === 'deploy_success'); + expect(success?.fields?.degraded).toBe(false); + }); + + it('logs deploy_degraded (not deploy_success) and still returns the committed done event when published is false', async () => { + const { spinner } = createMockSpinner(); + const { logger, calls } = createMockCliLog(); + const done: DoneEvent = { + event: 'done', + deployment: 'dpl_1', + url: 'https://app.void.app', + urls: ['https://app.void.app'], + assets: 0, + workers: 1, + published: false, + }; + + const result = await streamDeployEvents( + 'proj_1', + eventsOf([{ event: 'start', deploymentId: 'dpl_1' }, done]), + spinner, + logger, + ); + + // Resolves as a committed deploy — no throw, returns the done event. + expect(result).toEqual(done); + const keys = calls.info.map((c) => c.msg); + expect(keys).toContain('deploy_degraded'); + expect(keys).not.toContain('deploy_success'); + const degraded = calls.info.find((c) => c.msg === 'deploy_degraded'); + expect(degraded?.fields?.degraded).toBe(true); + }); +}); + +describe('warnUnpublishedRouting', () => { + function makeDone(published?: boolean): DoneEvent { + return { + event: 'done', + deployment: 'dpl_1', + url: 'https://app.void.app', + urls: ['https://app.void.app'], + assets: 0, + workers: 1, + ...(published === undefined ? {} : { published }), + }; + } + + it('warns persistently when routing was not published', async () => { + const { log } = await import('@clack/prompts'); + const warnSpy = log.warn as ReturnType; + warnSpy.mockClear(); + + warnUnpublishedRouting(makeDone(false)); + + expect(warnSpy).toHaveBeenCalledOnce(); + expect(String(warnSpy.mock.calls[0][0])).toMatch(/routing was not published/i); + }); + + it('does not warn when published is true or omitted', async () => { + const { log } = await import('@clack/prompts'); + const warnSpy = log.warn as ReturnType; + + warnSpy.mockClear(); + warnUnpublishedRouting(makeDone(true)); + warnUnpublishedRouting(makeDone(undefined)); + + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); From ad1d9191329b9b72730554f6216b2716bc063187 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 10 Jun 2026 12:16:39 +0800 Subject: [PATCH 3/6] fix(cli): scrub CF Access service token before spawning user build `void deploy` constructs the PlatformClient (which captures the CF Access headers once at construction and reuses them for every request), then later spawns untrusted `vite build` / framework preset builds with the inherited process.env. Without scrubbing, a shared, long-lived CF Access service token would leak to arbitrary repo build scripts and plugins. Delete the CF_ACCESS_CLIENT_ID/SECRET pair from process.env immediately after the client is constructed: the creds are needed only by the CLI's own API calls, never by the build, so every later request stays authenticated while user code can no longer read them. Only the service-token pair is scrubbed; the cloudflared CF_ACCESS_TOKEN JWT (dev path) is left untouched. Regression tests: the client keeps sending CF-Access headers after the env is scrubbed (capture-at-construction invariant), and runDeploy deletes the pair while preserving CF_ACCESS_TOKEN. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/deploy.ts | 14 ++++++ packages/void/test/unit/deploy-auth.test.ts | 40 +++++++++++++++++ .../void/test/unit/direct-r2-client.test.ts | 45 +++++++++++++++++++ 3 files changed, 99 insertions(+) diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index bceb8bfba..fae11a4c9 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -333,6 +333,20 @@ async function runDeployInner( const client = new PlatformClient(token, { root, cliLog }); + // Scrub the CF Access service-token pair from the environment now that the + // client has captured the Access headers (PlatformClient reads + // `cfAccessHeaders(process.env)` once in its constructor and reuses them for + // every request). The deploy flow later spawns UNTRUSTED user build code + // (`vite build` and framework preset builds) with the inherited + // `process.env`; without this, a shared, long-lived Access service token + // would leak to arbitrary repo build scripts/plugins. The credentials are + // needed only by this CLI's own API calls — never by the build — so deleting + // them here keeps every later request authenticated while denying user code + // any access to them. Only the service-token PAIR is scrubbed; the + // cloudflared `CF_ACCESS_TOKEN` JWT (dev path) is left untouched. + delete process.env.CF_ACCESS_CLIENT_ID; + delete process.env.CF_ACCESS_CLIENT_SECRET; + // 2. Resolve target project (CLI flag > env var > linked project file) const requestedProjectSlug = getRequestedProjectSlug(options); let config: ProjectConfig | null = null; diff --git a/packages/void/test/unit/deploy-auth.test.ts b/packages/void/test/unit/deploy-auth.test.ts index 4382e5f6d..f0346ea17 100644 --- a/packages/void/test/unit/deploy-auth.test.ts +++ b/packages/void/test/unit/deploy-auth.test.ts @@ -102,6 +102,46 @@ describe('runDeploy auth failures', () => { }); }); +// The deploy flow spawns untrusted `vite build` with the inherited process.env. +// A shared CF Access service token (used only by the CLI's own API client, which +// captures the headers at construction) must NOT survive into that child. runDeploy +// deletes the CF_ACCESS_* pair from process.env immediately after constructing the +// PlatformClient — this test proves the scrub runs (it fails if the delete lines +// are removed). It does NOT touch the cloudflared CF_ACCESS_TOKEN JWT. +describe('runDeploy scrubs the CF Access service token from process.env', () => { + it('deletes CF_ACCESS_CLIENT_ID/SECRET after the API client is constructed', async () => { + const savedId = process.env.CF_ACCESS_CLIENT_ID; + const savedSecret = process.env.CF_ACCESS_CLIENT_SECRET; + const savedToken = process.env.CF_ACCESS_TOKEN; + try { + process.env.CF_ACCESS_CLIENT_ID = 'svc-id.access'; + process.env.CF_ACCESS_CLIENT_SECRET = 'svc-secret'; + process.env.CF_ACCESS_TOKEN = 'jwt-keep-me'; + + // Default mocks make listProjects reject with an expired token in non-TTY + // mode, so runDeploy throws AFTER the scrub line has run. + await expect(runDeploy(process.cwd(), { projectSlug: 'my-app' })).rejects.toThrow(); + + // The service-token pair is gone — an inherited-env build child can't read it. + expect(process.env.CF_ACCESS_CLIENT_ID).toBeUndefined(); + expect(process.env.CF_ACCESS_CLIENT_SECRET).toBeUndefined(); + // The cloudflared JWT (a different var, dev path) is left untouched. + expect(process.env.CF_ACCESS_TOKEN).toBe('jwt-keep-me'); + } finally { + const restore = (key: string, value: string | undefined): void => { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }; + restore('CF_ACCESS_CLIENT_ID', savedId); + restore('CF_ACCESS_CLIENT_SECRET', savedSecret); + restore('CF_ACCESS_TOKEN', savedToken); + } + }); +}); + afterEach(() => { Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); }); diff --git a/packages/void/test/unit/direct-r2-client.test.ts b/packages/void/test/unit/direct-r2-client.test.ts index 8383aeb86..28cabda1d 100644 --- a/packages/void/test/unit/direct-r2-client.test.ts +++ b/packages/void/test/unit/direct-r2-client.test.ts @@ -269,3 +269,48 @@ describe('PlatformClient.finalizeDeploy', () => { }); }); }); + +// The deploy command scrubs the CF Access service-token PAIR from process.env +// right after constructing the PlatformClient, so the untrusted `vite build` +// child it later spawns can't read the shared staging credential. That scrub is +// only safe because the client captures the Access headers AT CONSTRUCTION and +// reuses them — proven here: delete the env vars after construction, then verify +// a later API call STILL sends CF-Access-Client-Id / CF-Access-Client-Secret. +describe('PlatformClient CF Access header capture (survives the post-construct env scrub)', () => { + it('keeps sending CF-Access headers after CF_ACCESS_* are deleted from process.env', async () => { + const savedId = process.env.CF_ACCESS_CLIENT_ID; + const savedSecret = process.env.CF_ACCESS_CLIENT_SECRET; + try { + process.env.CF_ACCESS_CLIENT_ID = 'svc-id.access'; + process.env.CF_ACCESS_CLIENT_SECRET = 'svc-secret'; + + // Construct the client — cfAccessHeaders(process.env) is read here. + const client = makeClient(); + + // Simulate deploy.ts's scrub (before any user build spawns). + delete process.env.CF_ACCESS_CLIENT_ID; + delete process.env.CF_ACCESS_CLIENT_SECRET; + + fetchMock.mockResolvedValueOnce(jsonResponse({ urls: {} })); + await client.requestUploadUrls(projectId, [ + { blake3: 'a'.repeat(32), md5: 'b'.repeat(32), size: 1, contentType: 'text/plain' }, + ]); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + expect(headers['CF-Access-Client-Id']).toBe('svc-id.access'); + expect(headers['CF-Access-Client-Secret']).toBe('svc-secret'); + } finally { + if (savedId === undefined) { + delete process.env.CF_ACCESS_CLIENT_ID; + } else { + process.env.CF_ACCESS_CLIENT_ID = savedId; + } + if (savedSecret === undefined) { + delete process.env.CF_ACCESS_CLIENT_SECRET; + } else { + process.env.CF_ACCESS_CLIENT_SECRET = savedSecret; + } + } + }); +}); From 16c1065d01914d0a00fe1cf1c0393c6c0e444415 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 12 Jun 2026 16:46:04 +0800 Subject: [PATCH 4/6] fix(cli): frozen-lockfile installs in the scaffolded GitHub Actions workflow `pnpm install` and `bun install` could resolve dependency state not represented by the pushed commit, undermining reproducible deploy-on-push. npm (`npm ci`) and yarn (`--immutable`) were already frozen. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/init.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index f440cc477..ee8e248bf 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -1886,7 +1886,7 @@ function getWorkflowSetupSteps( with: ${nodeVersion} cache: pnpm`, - install: ' - run: pnpm install', + install: ' - run: pnpm install --frozen-lockfile', runner: 'pnpm exec void', }; case 'yarn': @@ -1901,7 +1901,7 @@ function getWorkflowSetupSteps( case 'bun': return { setup: ' - uses: oven-sh/setup-bun@v2', - install: ' - run: bun install', + install: ' - run: bun install --frozen-lockfile', runner: 'bunx void', }; default: From baa8eddd78fee9bac083cf3ba9b97a0de1d82627 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 15 Jun 2026 18:17:13 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(cli):=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20slug-after-link,=20legacy=20workflow=20migration,?= =?UTF-8?q?=20CF=20Access=20re-auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - init: when a project is linked during the same interactive `void init`, bake the resolved slug into void-deploy.yml (Step 8 scaffolds before Step 9 links). Patch the placeholder after linking instead of reordering the prompts (which would drop GitHub setup on cancel and break confirm-order assertions). - init: migrate a legacy token-based .github/workflows/deploy.yml (void@0.9.x) to the OIDC void-deploy.yml in place on upgrade, instead of writing a second workflow that would double-deploy. - deploy: stop mutating process.env to scrub the CF Access service-token pair — it broke interactive expired-token re-auth (cfAccessHeaders re-reads env after the scrub). Pass a sanitized child env (buildEnv) to every untrusted build / drizzle-kit / git spawn instead, keeping both the live client and re-auth working while denying the token to user code. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/deploy.ts | 64 +++++++--- packages/void/src/cli/init.ts | 82 +++++++++++-- packages/void/test/unit/deploy-auth.test.ts | 109 +++++++++++++++--- .../void/test/unit/direct-r2-client.test.ts | 15 +-- packages/void/test/unit/init.test.ts | 103 +++++++++++++++++ 5 files changed, 326 insertions(+), 47 deletions(-) diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index fae11a4c9..6a01d1bca 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -316,6 +316,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): 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, @@ -333,19 +358,10 @@ async function runDeployInner( const client = new PlatformClient(token, { root, cliLog }); - // Scrub the CF Access service-token pair from the environment now that the - // client has captured the Access headers (PlatformClient reads - // `cfAccessHeaders(process.env)` once in its constructor and reuses them for - // every request). The deploy flow later spawns UNTRUSTED user build code - // (`vite build` and framework preset builds) with the inherited - // `process.env`; without this, a shared, long-lived Access service token - // would leak to arbitrary repo build scripts/plugins. The credentials are - // needed only by this CLI's own API calls — never by the build — so deleting - // them here keeps every later request authenticated while denying user code - // any access to them. Only the service-token PAIR is scrubbed; the - // cloudflared `CF_ACCESS_TOKEN` JWT (dev path) is left untouched. - delete process.env.CF_ACCESS_CLIENT_ID; - delete process.env.CF_ACCESS_CLIENT_SECRET; + // 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); @@ -952,7 +968,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' }); @@ -1094,6 +1110,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 { @@ -1418,7 +1435,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) { @@ -1906,15 +1923,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; } @@ -1974,6 +1999,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); diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index ee8e248bf..bb4ce1e6c 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -1990,18 +1990,43 @@ ${install} } function setupGithubActions(root: string, state: ProjectState): void { - const workflowPath = join(root, '.github', 'workflows', 'void-deploy.yml'); + const workflowDir = join(root, '.github', 'workflows'); + const workflowPath = join(workflowDir, 'void-deploy.yml'); + // `void@0.9.x` `void init --github` wrote a token-based workflow to + // `deploy.yml` (`VOID_TOKEN: ${{ secrets.VOID_TOKEN }}`). This PR renamed the + // output to `void-deploy.yml` (OIDC). Detect the legacy file so re-running + // init on an upgrade migrates in place instead of leaving two workflows + // (which would double-deploy, and the old token workflow would fail once + // `VOID_TOKEN` is removed). + const legacyPath = join(workflowDir, 'deploy.yml'); if (existsSync(workflowPath)) { log.warn('void-deploy.yml already exists, skipping'); + // If a stale legacy token workflow lingers alongside the current OIDC one, + // it would still double-deploy — warn so the user can remove it. + if (existsSync(legacyPath)) { + log.warn( + 'Found a legacy .github/workflows/deploy.yml alongside void-deploy.yml. ' + + 'Remove deploy.yml to avoid duplicate deploys.', + ); + } return; } const slug = readProjectConfig(root)?.slug ?? null; - mkdirSync(join(root, '.github', 'workflows'), { recursive: true }); + const migratingFromLegacy = existsSync(legacyPath); + + mkdirSync(workflowDir, { recursive: true }); writeFileSync(workflowPath, getWorkflowYaml(root, state.packageManager, slug)); - log.success('Created .github/workflows/void-deploy.yml'); + // Replace (not rename) the legacy token workflow: token-based and OIDC + // workflows are not interchangeable, so the old content must be removed. + if (migratingFromLegacy) { + unlinkSync(legacyPath); + log.success('Migrated .github/workflows/deploy.yml → void-deploy.yml (token → GitHub OIDC)'); + } else { + log.success('Created .github/workflows/void-deploy.yml'); + } if (slug) { note( @@ -2019,6 +2044,37 @@ function setupGithubActions(root: string, state: ProjectState): void { } } +/** + * Bake a resolved project slug into an already-written `void-deploy.yml`. + * + * Step 8 (GitHub Actions) runs BEFORE Step 9 (project link), so when a user + * links a project during the same interactive init the workflow was written + * with the `${{ vars.VOID_PROJECT }}` placeholder. Once the link resolves a + * slug, rewrite the placeholder `env.VOID_PROJECT` block in place so the user + * does not also have to set a repository variable. No-op (returns `false`) + * when the workflow is missing or the placeholder is not present (e.g. the + * slug was already known at scaffold time, or the user declined linking). + */ +function patchWorkflowProjectSlug(root: string, slug: string): boolean { + const workflowPath = join(root, '.github', 'workflows', 'void-deploy.yml'); + if (!existsSync(workflowPath)) { + return false; + } + const yaml = readFileSync(workflowPath, 'utf-8'); + // The placeholder block emitted by `getWorkflowYaml` is three lines: two + // 2-space-indented comment lines followed by the placeholder value line. + const placeholderBlock = + ' # Set a VOID_PROJECT repository variable to your project slug\n' + + ' # (Settings → Secrets and variables → Actions → Variables).\n' + + ' VOID_PROJECT: ${{ vars.VOID_PROJECT }}'; + if (!yaml.includes(placeholderBlock)) { + return false; + } + writeFileSync(workflowPath, yaml.replace(placeholderBlock, ` VOID_PROJECT: ${slug}`)); + log.success(`Set VOID_PROJECT: ${slug} in .github/workflows/void-deploy.yml`); + return true; +} + function writeFileIfMissing(root: string, filePath: string, content: string): void { const fullPath = join(root, filePath); if (existsSync(fullPath)) { @@ -3664,10 +3720,13 @@ export async function runInitCommand(root: string, initArgs?: InitArgs): Promise // interactive modes agree. const githubStepSkipForSubpackage = isInsideWorkspaceSubpackage(root); if ((!targeted || initArgs?.github) && !githubStepSkipForSubpackage) { - const deployYmlPath = join(root, '.github', 'workflows', 'void-deploy.yml'); - if (existsSync(deployYmlPath)) { - log.warn('void-deploy.yml already exists, skipping'); - } else if (targeted) { + // `setupGithubActions` is the single source of truth for the + // existence/migration decision: it skips (with a warning) when + // `void-deploy.yml` already exists, and migrates a legacy `deploy.yml` + // (token-based, `void@0.9.x`) to the OIDC `void-deploy.yml` in place. + // Don't short-circuit here on `void-deploy.yml` alone — that would skip + // the legacy migration when only `deploy.yml` exists. + if (targeted) { // --github flag: run directly without prompting setupGithubActions(root, state); } else { @@ -3714,7 +3773,14 @@ export async function runInitCommand(root: string, initArgs?: InitArgs): Promise } if (!targeted) { - await maybeSetupDeployProject(root); + const linked = await maybeSetupDeployProject(root); + // Step 8 wrote `void-deploy.yml` before the project was linked, so it baked + // in the `${{ vars.VOID_PROJECT }}` placeholder. Now that linking resolved a + // slug, patch it into the workflow so the user does not also have to set a + // repository variable. Declining to link leaves the placeholder intact. + if (linked?.slug) { + patchWorkflowProjectSlug(root, linked.slug); + } } if (workspaceSubpackageSkip) { diff --git a/packages/void/test/unit/deploy-auth.test.ts b/packages/void/test/unit/deploy-auth.test.ts index f0346ea17..cd5bdc939 100644 --- a/packages/void/test/unit/deploy-auth.test.ts +++ b/packages/void/test/unit/deploy-auth.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; -import { runDeploy } from '../../src/cli/deploy.ts'; +import { buildEnv, runDeploy } from '../../src/cli/deploy.ts'; const mockError = vi.fn(); const mockInfo = vi.fn(); @@ -102,14 +102,14 @@ describe('runDeploy auth failures', () => { }); }); -// The deploy flow spawns untrusted `vite build` with the inherited process.env. -// A shared CF Access service token (used only by the CLI's own API client, which -// captures the headers at construction) must NOT survive into that child. runDeploy -// deletes the CF_ACCESS_* pair from process.env immediately after constructing the -// PlatformClient — this test proves the scrub runs (it fails if the delete lines -// are removed). It does NOT touch the cloudflared CF_ACCESS_TOKEN JWT. -describe('runDeploy scrubs the CF Access service token from process.env', () => { - it('deletes CF_ACCESS_CLIENT_ID/SECRET after the API client is constructed', async () => { +// The deploy flow spawns untrusted `vite build` (and other build children) with +// the spawn env. A shared CF Access service token (used only by the CLI's own API +// client, which captures the headers at construction) must NOT survive into those +// children. runDeploy keeps the pair OUT of the child env via `buildEnv()` while +// LEAVING process.env untouched — mutating process.env would break interactive +// expired-token re-auth, which reads the pair after the build step. +describe('runDeploy keeps the CF Access service token in process.env', () => { + it('does NOT delete CF_ACCESS_CLIENT_ID/SECRET from process.env (re-auth needs them)', async () => { const savedId = process.env.CF_ACCESS_CLIENT_ID; const savedSecret = process.env.CF_ACCESS_CLIENT_SECRET; const savedToken = process.env.CF_ACCESS_TOKEN; @@ -119,13 +119,12 @@ describe('runDeploy scrubs the CF Access service token from process.env', () => process.env.CF_ACCESS_TOKEN = 'jwt-keep-me'; // Default mocks make listProjects reject with an expired token in non-TTY - // mode, so runDeploy throws AFTER the scrub line has run. + // mode, so runDeploy throws after constructing the client. await expect(runDeploy(process.cwd(), { projectSlug: 'my-app' })).rejects.toThrow(); - // The service-token pair is gone — an inherited-env build child can't read it. - expect(process.env.CF_ACCESS_CLIENT_ID).toBeUndefined(); - expect(process.env.CF_ACCESS_CLIENT_SECRET).toBeUndefined(); - // The cloudflared JWT (a different var, dev path) is left untouched. + // The pair is preserved so a later re-auth can still read it. + expect(process.env.CF_ACCESS_CLIENT_ID).toBe('svc-id.access'); + expect(process.env.CF_ACCESS_CLIENT_SECRET).toBe('svc-secret'); expect(process.env.CF_ACCESS_TOKEN).toBe('jwt-keep-me'); } finally { const restore = (key: string, value: string | undefined): void => { @@ -140,6 +139,88 @@ describe('runDeploy scrubs the CF Access service token from process.env', () => restore('CF_ACCESS_TOKEN', savedToken); } }); + + it('exposes the interactive re-auth path to the still-present Access pair', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + const savedId = process.env.CF_ACCESS_CLIENT_ID; + const savedSecret = process.env.CF_ACCESS_CLIENT_SECRET; + try { + process.env.CF_ACCESS_CLIENT_ID = 'svc-id.access'; + process.env.CF_ACCESS_CLIENT_SECRET = 'svc-secret'; + + // Capture the env the re-auth path sees when it runs (after the client was + // already constructed). cf-access.ts / auth.ts read process.env here, so the + // pair must still be present. + let pairAtReauth: { id?: string; secret?: string } | undefined; + mockPromptForLoginToken.mockImplementationOnce(() => { + pairAtReauth = { + id: process.env.CF_ACCESS_CLIENT_ID, + secret: process.env.CF_ACCESS_CLIENT_SECRET, + }; + return Promise.resolve('fresh-token'); + }); + // First call expires (triggers re-auth), second fails to end the loop. + mockListProjects + .mockRejectedValueOnce(new Error('expired-token')) + .mockRejectedValueOnce(new Error('second failure')); + + await expect(runDeploy(process.cwd(), { projectSlug: 'my-app' })).rejects.toThrow( + 'second failure', + ); + + expect(mockPromptForLoginToken).toHaveBeenCalledTimes(1); + expect(pairAtReauth).toEqual({ id: 'svc-id.access', secret: 'svc-secret' }); + } finally { + const restore = (key: string, value: string | undefined): void => { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }; + restore('CF_ACCESS_CLIENT_ID', savedId); + restore('CF_ACCESS_CLIENT_SECRET', savedSecret); + } + }); +}); + +// The build child env must omit the CF Access service-token pair even though +// process.env still carries it, so untrusted build code can't read the shared +// staging credential. +describe('buildEnv', () => { + it('omits CF_ACCESS_CLIENT_ID/SECRET while process.env keeps them', () => { + const savedId = process.env.CF_ACCESS_CLIENT_ID; + const savedSecret = process.env.CF_ACCESS_CLIENT_SECRET; + const savedToken = process.env.CF_ACCESS_TOKEN; + try { + process.env.CF_ACCESS_CLIENT_ID = 'svc-id.access'; + process.env.CF_ACCESS_CLIENT_SECRET = 'svc-secret'; + process.env.CF_ACCESS_TOKEN = 'jwt-keep-me'; + + const childEnv = buildEnv({ VOID_DEPLOY_PROJECT_ID: 'proj_1' }); + + // Pair scrubbed from the CHILD env... + expect(childEnv.CF_ACCESS_CLIENT_ID).toBeUndefined(); + expect(childEnv.CF_ACCESS_CLIENT_SECRET).toBeUndefined(); + // ...the cloudflared JWT (dev path) and extras pass through... + expect(childEnv.CF_ACCESS_TOKEN).toBe('jwt-keep-me'); + expect(childEnv.VOID_DEPLOY_PROJECT_ID).toBe('proj_1'); + // ...and process.env is untouched. + expect(process.env.CF_ACCESS_CLIENT_ID).toBe('svc-id.access'); + expect(process.env.CF_ACCESS_CLIENT_SECRET).toBe('svc-secret'); + } finally { + const restore = (key: string, value: string | undefined): void => { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + }; + restore('CF_ACCESS_CLIENT_ID', savedId); + restore('CF_ACCESS_CLIENT_SECRET', savedSecret); + restore('CF_ACCESS_TOKEN', savedToken); + } + }); }); afterEach(() => { diff --git a/packages/void/test/unit/direct-r2-client.test.ts b/packages/void/test/unit/direct-r2-client.test.ts index 28cabda1d..e4512fa32 100644 --- a/packages/void/test/unit/direct-r2-client.test.ts +++ b/packages/void/test/unit/direct-r2-client.test.ts @@ -270,13 +270,14 @@ describe('PlatformClient.finalizeDeploy', () => { }); }); -// The deploy command scrubs the CF Access service-token PAIR from process.env -// right after constructing the PlatformClient, so the untrusted `vite build` -// child it later spawns can't read the shared staging credential. That scrub is -// only safe because the client captures the Access headers AT CONSTRUCTION and -// reuses them — proven here: delete the env vars after construction, then verify -// a later API call STILL sends CF-Access-Client-Id / CF-Access-Client-Secret. -describe('PlatformClient CF Access header capture (survives the post-construct env scrub)', () => { +// The deploy command keeps the CF Access service-token PAIR out of the untrusted +// build children (via `buildEnv()` on the spawn env) so they can't read the +// shared staging credential. That is only safe because the client captures the +// Access headers AT CONSTRUCTION and reuses them — proven here: delete the env +// vars after construction, then verify a later API call STILL sends +// CF-Access-Client-Id / CF-Access-Client-Secret. (deploy.ts no longer mutates +// process.env; this test deletes them itself to assert the capture invariant.) +describe('PlatformClient CF Access header capture (survives a later env scrub)', () => { it('keeps sending CF-Access headers after CF_ACCESS_* are deleted from process.env', async () => { const savedId = process.env.CF_ACCESS_CLIENT_ID; const savedSecret = process.env.CF_ACCESS_CLIENT_SECRET; diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index a4fb1f3d1..b25d706e8 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -1566,6 +1566,109 @@ describe('isScaffoldableEmptyProject', () => { } }); + it('bakes the slug into void-deploy.yml when a project is linked during the same interactive init', async () => { + // Step 8 (GitHub Actions) runs before Step 9 (project link), so the workflow + // is first written with the `${{ vars.VOID_PROJECT }}` placeholder. After the + // interactive link resolves a slug it must be patched into the workflow. + const root = mkdtempSync(join(tmpdir(), 'void-init-')); + try { + writeFileSync( + join(root, 'package.json'), + JSON.stringify( + { + name: 'existing-astro-app', + private: true, + dependencies: { astro: '^5.0.0' }, + }, + null, + 2, + ) + '\n', + ); + mockGetToken.mockReturnValue('existing_token'); + mockListProjects.mockResolvedValueOnce([ + { + id: 'proj_789', + user_id: 'user_1', + slug: 'linked-app', + type: 'worker', + created_at: '2026-03-27T00:00:00.000Z', + custom_domain: null, + }, + ]); + // database → none, agent → generic, then the project-selection prompt → proj_789. + mockSelect + .mockResolvedValueOnce('none') + .mockResolvedValueOnce('generic') + .mockResolvedValueOnce('proj_789'); + // GitHub Actions confirm → true; deploy-setup link confirm → true; + // overwrite-existing-project confirm → true. + mockConfirm + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true); + + await runInitCommand(root); + + // The link was written... + expect(JSON.parse(readFileSync(join(root, '.void', 'project.json'), 'utf-8'))).toEqual({ + projectId: 'proj_789', + slug: 'linked-app', + }); + // ...and the workflow has the resolved slug, not the placeholder. + const yaml = readFileSync(join(root, '.github', 'workflows', 'void-deploy.yml'), 'utf-8'); + expect(yaml).toContain('VOID_PROJECT: linked-app'); + expect(yaml).not.toContain('vars.VOID_PROJECT'); + expect(yaml).not.toContain('Set a VOID_PROJECT repository variable'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('migrates a legacy token-based deploy.yml to the OIDC void-deploy.yml on upgrade', async () => { + // `void@0.9.x` `void init --github` wrote a token-based `deploy.yml`. + // Re-running after upgrade must MIGRATE it (write the OIDC void-deploy.yml, + // remove the legacy file) rather than leave two workflows. + const root = mkdtempSync(join(tmpdir(), 'void-init-')); + try { + writeFileSync( + join(root, 'package.json'), + JSON.stringify({ name: 'existing-app', private: true }, null, 2) + '\n', + ); + mkdirSync(join(root, '.github', 'workflows'), { recursive: true }); + const legacyPath = join(root, '.github', 'workflows', 'deploy.yml'); + writeFileSync( + legacyPath, + `name: Deploy to Void +on: + push: + branches: [main] +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - run: npx void deploy + env: + VOID_TOKEN: \${{ secrets.VOID_TOKEN }} +`, + ); + + await runInitCommand(root, { github: true, tsconfig: false, agents: false }); + + // The legacy token workflow is gone. + expect(existsSync(legacyPath)).toBe(false); + // Exactly one workflow remains, and it is the OIDC one. + const workflows = readdirSync(join(root, '.github', 'workflows')); + expect(workflows).toEqual(['void-deploy.yml']); + const yaml = readFileSync(join(root, '.github', 'workflows', 'void-deploy.yml'), 'utf-8'); + expect(yaml).not.toContain('secrets.VOID_TOKEN'); + expect(yaml).toContain('id-token: write'); + expect(yaml).toContain('$VOID_API_URL/auth/github-oidc'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + it('configures an existing class-C framework without plain Vite package setup', async () => { const root = mkdtempSync(join(tmpdir(), 'void-init-')); try { From 51518c40795b6120f375d4599e702f1d1ceb32ee Mon Sep 17 00:00:00 2001 From: LongYinan Date: Mon, 15 Jun 2026 18:47:49 +0800 Subject: [PATCH 6/6] fix(cli): drop the void init --github legacy-workflow migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per product decision, `void init --github` no longer detects, deletes, or migrates a pre-existing `.github/workflows/deploy.yml`. It only writes `void-deploy.yml` (skipping with a warning if that file already exists) and leaves any other workflow untouched. This removes the legacy-`deploy.yml` migration added earlier in this PR — which could have deleted an unrelated user workflow that merely shared the filename (Codex P2). Migrating workflows in place was the wrong default; a user upgrading from the old token-based workflow removes the stale `deploy.yml` themselves. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/init.ts | 34 +++-------------------- packages/void/test/unit/init.test.ts | 40 ++++++++++++---------------- 2 files changed, 20 insertions(+), 54 deletions(-) diff --git a/packages/void/src/cli/init.ts b/packages/void/src/cli/init.ts index bb4ce1e6c..4c5a849c7 100644 --- a/packages/void/src/cli/init.ts +++ b/packages/void/src/cli/init.ts @@ -1992,41 +1992,17 @@ ${install} function setupGithubActions(root: string, state: ProjectState): void { const workflowDir = join(root, '.github', 'workflows'); const workflowPath = join(workflowDir, 'void-deploy.yml'); - // `void@0.9.x` `void init --github` wrote a token-based workflow to - // `deploy.yml` (`VOID_TOKEN: ${{ secrets.VOID_TOKEN }}`). This PR renamed the - // output to `void-deploy.yml` (OIDC). Detect the legacy file so re-running - // init on an upgrade migrates in place instead of leaving two workflows - // (which would double-deploy, and the old token workflow would fail once - // `VOID_TOKEN` is removed). - const legacyPath = join(workflowDir, 'deploy.yml'); if (existsSync(workflowPath)) { log.warn('void-deploy.yml already exists, skipping'); - // If a stale legacy token workflow lingers alongside the current OIDC one, - // it would still double-deploy — warn so the user can remove it. - if (existsSync(legacyPath)) { - log.warn( - 'Found a legacy .github/workflows/deploy.yml alongside void-deploy.yml. ' + - 'Remove deploy.yml to avoid duplicate deploys.', - ); - } return; } const slug = readProjectConfig(root)?.slug ?? null; - const migratingFromLegacy = existsSync(legacyPath); - mkdirSync(workflowDir, { recursive: true }); writeFileSync(workflowPath, getWorkflowYaml(root, state.packageManager, slug)); - // Replace (not rename) the legacy token workflow: token-based and OIDC - // workflows are not interchangeable, so the old content must be removed. - if (migratingFromLegacy) { - unlinkSync(legacyPath); - log.success('Migrated .github/workflows/deploy.yml → void-deploy.yml (token → GitHub OIDC)'); - } else { - log.success('Created .github/workflows/void-deploy.yml'); - } + log.success('Created .github/workflows/void-deploy.yml'); if (slug) { note( @@ -3720,12 +3696,8 @@ export async function runInitCommand(root: string, initArgs?: InitArgs): Promise // interactive modes agree. const githubStepSkipForSubpackage = isInsideWorkspaceSubpackage(root); if ((!targeted || initArgs?.github) && !githubStepSkipForSubpackage) { - // `setupGithubActions` is the single source of truth for the - // existence/migration decision: it skips (with a warning) when - // `void-deploy.yml` already exists, and migrates a legacy `deploy.yml` - // (token-based, `void@0.9.x`) to the OIDC `void-deploy.yml` in place. - // Don't short-circuit here on `void-deploy.yml` alone — that would skip - // the legacy migration when only `deploy.yml` exists. + // `setupGithubActions` writes `void-deploy.yml` (skipping with a warning if + // it already exists). It does not touch any pre-existing `deploy.yml`. if (targeted) { // --github flag: run directly without prompting setupGithubActions(root, state); diff --git a/packages/void/test/unit/init.test.ts b/packages/void/test/unit/init.test.ts index b25d706e8..41a3bb776 100644 --- a/packages/void/test/unit/init.test.ts +++ b/packages/void/test/unit/init.test.ts @@ -1624,10 +1624,9 @@ describe('isScaffoldableEmptyProject', () => { } }); - it('migrates a legacy token-based deploy.yml to the OIDC void-deploy.yml on upgrade', async () => { - // `void@0.9.x` `void init --github` wrote a token-based `deploy.yml`. - // Re-running after upgrade must MIGRATE it (write the OIDC void-deploy.yml, - // remove the legacy file) rather than leave two workflows. + it('writes void-deploy.yml and never touches an existing deploy.yml (no migration)', async () => { + // `void init --github` does not migrate, rename, or delete any pre-existing + // `.github/workflows/deploy.yml` — it only writes `void-deploy.yml`. const root = mkdtempSync(join(tmpdir(), 'void-init-')); try { writeFileSync( @@ -1635,35 +1634,30 @@ describe('isScaffoldableEmptyProject', () => { JSON.stringify({ name: 'existing-app', private: true }, null, 2) + '\n', ); mkdirSync(join(root, '.github', 'workflows'), { recursive: true }); - const legacyPath = join(root, '.github', 'workflows', 'deploy.yml'); - writeFileSync( - legacyPath, - `name: Deploy to Void + const existingPath = join(root, '.github', 'workflows', 'deploy.yml'); + const existing = `name: CI on: push: branches: [main] jobs: - deploy: + build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - run: npx void deploy - env: - VOID_TOKEN: \${{ secrets.VOID_TOKEN }} -`, - ); + - run: npm ci && npm test +`; + writeFileSync(existingPath, existing); await runInitCommand(root, { github: true, tsconfig: false, agents: false }); - // The legacy token workflow is gone. - expect(existsSync(legacyPath)).toBe(false); - // Exactly one workflow remains, and it is the OIDC one. - const workflows = readdirSync(join(root, '.github', 'workflows')); - expect(workflows).toEqual(['void-deploy.yml']); - const yaml = readFileSync(join(root, '.github', 'workflows', 'void-deploy.yml'), 'utf-8'); - expect(yaml).not.toContain('secrets.VOID_TOKEN'); - expect(yaml).toContain('id-token: write'); - expect(yaml).toContain('$VOID_API_URL/auth/github-oidc'); + // The pre-existing workflow is preserved byte-for-byte... + expect(existsSync(existingPath)).toBe(true); + expect(readFileSync(existingPath, 'utf-8')).toBe(existing); + // ...and void-deploy.yml was written alongside it. + const workflows = readdirSync(join(root, '.github', 'workflows')).sort(); + expect(workflows).toEqual(['deploy.yml', 'void-deploy.yml']); + const oidc = readFileSync(join(root, '.github', 'workflows', 'void-deploy.yml'), 'utf-8'); + expect(oidc).toContain('$VOID_API_URL/auth/github-oidc'); } finally { rmSync(root, { recursive: true, force: true }); }