From ee64d04d4d518f6af61088f8858ac7cc9686b7a2 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 2 Jul 2026 16:29:16 +0800 Subject: [PATCH 1/2] feat(cli): add --workflow flag to configure the GitHub deploy-workflow path `void github connect` and `void github update` now accept `--workflow ` to set which workflow file the platform authorizes for OIDC deploy-token minting, defaulting to `.github/workflows/void-deploy.yml`. The path is sent to the API (which is authoritative for validation), stored per-connection, and enforced at the OIDC exchange so only that one workflow can deploy. - github-args: parse `--workflow` for both connect and update (trimmed, value-required) - github-cmd: thread workflowPath into connect; update treats it as flag-only (no interactive prompt), no-ops when unchanged - client: send `workflowPath` in POST/PATCH bodies; add workflow_path to the GithubConnection type - help + docs/reference/cli.md: document the flag and its security semantics Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/reference/cli.md | 32 ++++++++------ packages/void/src/cli/client.ts | 3 ++ packages/void/src/cli/github-args.ts | 28 ++++++++++-- packages/void/src/cli/github-cmd.ts | 29 ++++++++++--- packages/void/src/cli/help.ts | 13 +++++- packages/void/test/unit/github-args.test.ts | 35 +++++++++++++++ packages/void/test/unit/github-client.test.ts | 5 +++ .../void/test/unit/github-update-cmd.test.ts | 43 ++++++++++++++++++- 8 files changed, 162 insertions(+), 26 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1e351ceb0..9ecf2c69d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -607,13 +607,16 @@ Connect a GitHub repository to a Void project for automatic deploys. On every pu **Options** -| Flag | Description | -| --------------------- | ------------------------------------------------------------------------------- | -| `--project ` | Project name (alias for the positional argument) | -| `--installation ` | GitHub App installation ID (required when you have multiple installations) | -| `--repo ` | Repository full name — required unless the installation grants exactly one repo | -| `--branch ` | Branch to deploy from — **required in non-interactive mode** | -| `--executor ` | Build executor: `container` (default) or `github_actions` | +| Flag | Description | +| --------------------- | --------------------------------------------------------------------------------- | +| `--project ` | Project name (alias for the positional argument) | +| `--installation ` | GitHub App installation ID (required when you have multiple installations) | +| `--repo ` | Repository full name — required unless the installation grants exactly one repo | +| `--branch ` | Branch to deploy from — **required in non-interactive mode** | +| `--executor ` | Build executor: `container` (default) or `github_actions` | +| `--workflow ` | Authorized deploy workflow file — defaults to `.github/workflows/void-deploy.yml` | + +The `--workflow` path is the workflow file the GitHub OIDC exchange authorizes to mint this project's deploy token. It defaults to the scaffolded `.github/workflows/void-deploy.yml`; set it only if your deploy step lives in a different workflow file (it must be under `.github/workflows/` and end in `.yml`/`.yaml`). Because the exchange is secretless, **only this one workflow** can mint a deploy token — any other workflow in the repo (including an unsafe `pull_request_target`) is rejected. Pointing it at a broad, multi-purpose workflow widens that trust, so prefer a dedicated file. **Non-interactive use (CI)** @@ -634,17 +637,18 @@ void github connect my-app \ void github update [project] [options] ``` -Update an existing connection's deploy **branch** and/or **build executor**. Use this to change settings on a project that is already connected — for example, flipping the executor from `container` to `github_actions` for a monorepo whose Void app lives in a subdirectory, or moving the deploy branch. The project must already be connected (run `void github connect` first); the repository and installation are not changed. +Update an existing connection's deploy **branch**, **build executor**, and/or authorized **workflow file**. Use this to change settings on a project that is already connected — for example, flipping the executor from `container` to `github_actions` for a monorepo whose Void app lives in a subdirectory, moving the deploy branch, or pointing the OIDC trust at a different workflow file. The project must already be connected (run `void github connect` first); the repository and installation are not changed. **Options** -| Flag | Description | -| ------------------- | --------------------------------------------------- | -| `--project ` | Project name (alias for the positional argument) | -| `--branch ` | New branch to deploy from | -| `--executor ` | New build executor: `container` or `github_actions` | +| Flag | Description | +| ------------------- | ---------------------------------------------------------------- | +| `--project ` | Project name (alias for the positional argument) | +| `--branch ` | New branch to deploy from | +| `--executor ` | New build executor: `container` or `github_actions` | +| `--workflow ` | New authorized deploy workflow file (under `.github/workflows/`) | -Interactively (TTY), `void github update` shows the current branch and executor and prompts for new values, defaulting each to the current setting. When stdin is not a TTY, it never prompts: pass at least one of `--branch` / `--executor`, or it fails closed. If nothing actually changes, the command reports "no changes" and does not call the API. +Interactively (TTY), `void github update` shows the current branch and executor and prompts for new values, defaulting each to the current setting. `--workflow` is flag-only — it is never prompted for; pass it explicitly to change the authorized workflow file. When stdin is not a TTY, it never prompts: pass at least one of `--branch` / `--executor` / `--workflow`, or it fails closed. If nothing actually changes, the command reports "no changes" and does not call the API. ``` void github update my-app --executor github_actions diff --git a/packages/void/src/cli/client.ts b/packages/void/src/cli/client.ts index 9f312efdb..d0dd8419e 100644 --- a/packages/void/src/cli/client.ts +++ b/packages/void/src/cli/client.ts @@ -234,6 +234,7 @@ export type GithubConnection = { repository_id: string | null; branch: string; build_executor: 'container' | 'github_actions'; + workflow_path: string; created_at: string; updated_at: string; }; @@ -975,6 +976,7 @@ export class PlatformClient { repoFullName: string; branch?: string; buildExecutor?: 'container' | 'github_actions'; + workflowPath?: string; }, ): Promise { const res = await fetch(`${this.baseUrl}/projects/${projectId}/github`, { @@ -1012,6 +1014,7 @@ export class PlatformClient { body: { branch?: string; buildExecutor?: 'container' | 'github_actions'; + workflowPath?: string; }, ): Promise { const res = await fetch(`${this.baseUrl}/projects/${projectId}/github`, { diff --git a/packages/void/src/cli/github-args.ts b/packages/void/src/cli/github-args.ts index 5a621a71f..7ba44d092 100644 --- a/packages/void/src/cli/github-args.ts +++ b/packages/void/src/cli/github-args.ts @@ -11,7 +11,7 @@ export const githubSubcommands: Array = [ { name: 'update', arg: '[project]', - description: "Update an existing connection's branch or build executor", + description: "Update an existing connection's branch, build executor, or deploy workflow", }, { name: 'link', description: 'Link your GitHub account to Void' }, ]; @@ -27,12 +27,14 @@ export type GithubArgs = repo?: string; branch?: string; executor?: 'container' | 'github_actions'; + workflowPath?: string; } | { subcommand: 'update'; projectName: string; branch?: string; executor?: 'container' | 'github_actions'; + workflowPath?: string; }; export function parseGithubArgs(args: Array): GithubArgs { @@ -65,6 +67,7 @@ export function parseGithubArgs(args: Array): GithubArgs { let repo: string | undefined; let branch: string | undefined; let executor: 'container' | 'github_actions' | undefined; + let workflowPath: string | undefined; for (let i = 0; i < rest.length; i++) { const arg = rest[i]; @@ -119,6 +122,15 @@ export function parseGithubArgs(args: Array): GithubArgs { throw new Error('github: invalid --executor (expected container|github_actions).'); } executor = value; + } else if (arg === '--workflow') { + if (i + 1 >= rest.length) { + throw new Error('github: --workflow requires a value.'); + } + const value = rest[++i]!; + if (value.trim() === '') { + throw new Error('github: --workflow requires a value.'); + } + workflowPath = value.trim(); } else if (arg.startsWith('-')) { throw new Error(`github: Unexpected argument '${arg}'.`); } else if (projectName) { @@ -128,13 +140,14 @@ export function parseGithubArgs(args: Array): GithubArgs { } } - return { subcommand, projectName, installationId, repo, branch, executor }; + return { subcommand, projectName, installationId, repo, branch, executor, workflowPath }; } if (subcommand === 'update') { let projectName = ''; let branch: string | undefined; let executor: 'container' | 'github_actions' | undefined; + let workflowPath: string | undefined; for (let i = 0; i < rest.length; i++) { const arg = rest[i]; @@ -175,6 +188,15 @@ export function parseGithubArgs(args: Array): GithubArgs { throw new Error('github: invalid --executor (expected container|github_actions).'); } executor = value; + } else if (arg === '--workflow') { + if (i + 1 >= rest.length) { + throw new Error('github: --workflow requires a value.'); + } + const value = rest[++i]!; + if (value.trim() === '') { + throw new Error('github: --workflow requires a value.'); + } + workflowPath = value.trim(); } else if (arg.startsWith('-')) { throw new Error(`github: Unexpected argument '${arg}'.`); } else if (projectName) { @@ -184,7 +206,7 @@ export function parseGithubArgs(args: Array): GithubArgs { } } - return { subcommand, projectName, branch, executor }; + return { subcommand, projectName, branch, executor, workflowPath }; } throw new Error('github: Usage: void github .'); diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index df0902f52..ea6bcccf2 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -464,6 +464,7 @@ async function runConnect( repoFullName, branch, buildExecutor: executor, + workflowPath: args.workflowPath, }); } catch (err) { if (err instanceof PlatformApiError) { @@ -497,9 +498,14 @@ export async function runGithubUpdate( // Guard BEFORE any project resolution or connection read so this usage error // never depends on API state (e.g. a disconnected project must not surface the // "connect first" 404 message in place of the missing-flags error). - if (!isTTY && args.branch === undefined && args.executor === undefined) { + if ( + !isTTY && + args.branch === undefined && + args.executor === undefined && + args.workflowPath === undefined + ) { log.error( - 'update: Nothing to update. Pass --branch and/or --executor .', + 'update: Nothing to update. Pass --branch , --executor , and/or --workflow <.github/workflows/...>.', ); process.exit(1); } @@ -617,18 +623,31 @@ export async function runGithubUpdate( // ------------------------------------------------------------------------- // 4. Build a patch carrying ONLY the fields that actually changed. // ------------------------------------------------------------------------- - const patch: { branch?: string; buildExecutor?: 'container' | 'github_actions' } = {}; + const patch: { + branch?: string; + buildExecutor?: 'container' | 'github_actions'; + workflowPath?: string; + } = {}; if (branch !== current.branch) { patch.branch = branch; } if (executor !== current.build_executor) { patch.buildExecutor = executor; } + // workflow_path is flag-only (no interactive prompt): change it only when + // --workflow was passed AND it differs from the stored value. + if (args.workflowPath !== undefined && args.workflowPath !== current.workflow_path) { + patch.workflowPath = args.workflowPath; + } // ------------------------------------------------------------------------- // 5. No-op guard — never hit the API when nothing changed. // ------------------------------------------------------------------------- - if (patch.branch === undefined && patch.buildExecutor === undefined) { + if ( + patch.branch === undefined && + patch.buildExecutor === undefined && + patch.workflowPath === undefined + ) { log.info( `No changes. ${current.repo_full_name} already deploys from ${current.branch} via ${current.build_executor}.`, ); @@ -656,6 +675,6 @@ export async function runGithubUpdate( } log.success( - `Updated ${updated.repo_full_name} → branch ${updated.branch}, executor ${updated.build_executor}.`, + `Updated ${updated.repo_full_name} → branch ${updated.branch}, executor ${updated.build_executor}, workflow ${updated.workflow_path}.`, ); } diff --git a/packages/void/src/cli/help.ts b/packages/void/src/cli/help.ts index 203385417..629eacb3f 100644 --- a/packages/void/src/cli/help.ts +++ b/packages/void/src/cli/help.ts @@ -202,7 +202,7 @@ const HELP_TREE: HelpTree = { }, { command: 'update [project]', - description: "Update an existing connection's branch or build executor", + description: "Update an existing connection's branch, build executor, or deploy workflow", }, { command: 'link', description: 'Link your GitHub account to Void' }, ], @@ -231,6 +231,11 @@ const HELP_TREE: HelpTree = { command: '--executor ', description: 'Build executor: container (default) | github_actions', }, + { + command: '--workflow ', + description: + 'Authorized deploy workflow file (default .github/workflows/void-deploy.yml)', + }, ], examples: [ { @@ -244,7 +249,7 @@ const HELP_TREE: HelpTree = { ], }, update: { - summary: "Update an existing connection's branch or build executor.", + summary: "Update an existing connection's branch, build executor, or deploy workflow.", usage: ['void github update [project] [options]'], options: [ { command: '--project ', description: 'Project name (alias for positional)' }, @@ -253,6 +258,10 @@ const HELP_TREE: HelpTree = { command: '--executor ', description: 'New build executor: container | github_actions', }, + { + command: '--workflow ', + description: 'New authorized deploy workflow file (under .github/workflows/)', + }, ], examples: [ { diff --git a/packages/void/test/unit/github-args.test.ts b/packages/void/test/unit/github-args.test.ts index c5def5e52..df2be5438 100644 --- a/packages/void/test/unit/github-args.test.ts +++ b/packages/void/test/unit/github-args.test.ts @@ -121,6 +121,25 @@ describe('parseGithubArgs', () => { }); }); + it('parses --workflow', () => { + const result = parseGithubArgs(['connect', '--workflow', '.github/workflows/ci.yml']); + expect(result).toEqual({ + subcommand: 'connect', + projectName: '', + installationId: undefined, + repo: undefined, + branch: undefined, + executor: undefined, + workflowPath: '.github/workflows/ci.yml', + }); + }); + + it('throws when --workflow has no value', () => { + expect(() => parseGithubArgs(['connect', '--workflow'])).toThrow( + 'github: --workflow requires a value.', + ); + }); + it('parses --project flag', () => { const result = parseGithubArgs(['connect', '--project', 'my-project']); expect(result).toEqual({ @@ -361,6 +380,22 @@ describe('parseGithubArgs', () => { }); }); + it('parses --workflow', () => { + expect(parseGithubArgs(['update', '--workflow', '.github/workflows/ci.yml'])).toEqual({ + subcommand: 'update', + projectName: '', + branch: undefined, + executor: undefined, + workflowPath: '.github/workflows/ci.yml', + }); + }); + + it('throws when --workflow has no value', () => { + expect(() => parseGithubArgs(['update', '--workflow'])).toThrow( + 'github: --workflow requires a value.', + ); + }); + it('parses project name with both flags together', () => { expect( parseGithubArgs([ diff --git a/packages/void/test/unit/github-client.test.ts b/packages/void/test/unit/github-client.test.ts index 1b22d64a5..c1527753d 100644 --- a/packages/void/test/unit/github-client.test.ts +++ b/packages/void/test/unit/github-client.test.ts @@ -105,6 +105,7 @@ describe('PlatformClient.connectGithub', () => { repository_id: '9876', branch: 'main', build_executor: 'container', + workflow_path: '.github/workflows/void-deploy.yml', created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', }; @@ -144,6 +145,7 @@ describe('PlatformClient.connectGithub', () => { repository_id: null, branch: 'main', build_executor: 'container', + workflow_path: '.github/workflows/void-deploy.yml', created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', }; @@ -188,6 +190,7 @@ describe('PlatformClient.getGithubConnection', () => { repository_id: '9876', branch: 'main', build_executor: 'container', + workflow_path: '.github/workflows/void-deploy.yml', created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', }; @@ -227,6 +230,7 @@ describe('PlatformClient.updateGithubConnection', () => { repository_id: '9876', branch: 'release', build_executor: 'github_actions', + workflow_path: '.github/workflows/void-deploy.yml', created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-02T00:00:00Z', }; @@ -262,6 +266,7 @@ describe('PlatformClient.updateGithubConnection', () => { repository_id: null, branch: 'main', build_executor: 'github_actions', + workflow_path: '.github/workflows/void-deploy.yml', created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-02T00:00:00Z', }; diff --git a/packages/void/test/unit/github-update-cmd.test.ts b/packages/void/test/unit/github-update-cmd.test.ts index dc88c9246..514ec7228 100644 --- a/packages/void/test/unit/github-update-cmd.test.ts +++ b/packages/void/test/unit/github-update-cmd.test.ts @@ -31,6 +31,7 @@ function makeConnection(overrides: Partial = {}): GithubConnec repository_id: '9876', branch: 'main', build_executor: 'container', + workflow_path: '.github/workflows/void-deploy.yml', created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', ...overrides, @@ -154,6 +155,44 @@ describe('runGithubUpdate (non-TTY)', () => { expect(successSpy).toHaveBeenCalledTimes(1); }); + it('updates the workflow only, sending just workflowPath', async () => { + const { client, updateGithubConnection } = makeClient({ + getGithubConnection: vi.fn(async () => makeConnection()), + updateGithubConnection: vi.fn(async () => + makeConnection({ workflow_path: '.github/workflows/ci.yml' }), + ), + }); + + await runGithubUpdate('/project', client, { + subcommand: 'update', + projectName: slug, + workflowPath: '.github/workflows/ci.yml', + }); + + expect(updateGithubConnection).toHaveBeenCalledWith(projectId, { + workflowPath: '.github/workflows/ci.yml', + }); + expect(successSpy).toHaveBeenCalledTimes(1); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + it('makes no API call when --workflow matches the current workflow_path', async () => { + const { client, updateGithubConnection } = makeClient({ + getGithubConnection: vi.fn(async () => + makeConnection({ workflow_path: '.github/workflows/ci.yml' }), + ), + }); + + await runGithubUpdate('/project', client, { + subcommand: 'update', + projectName: slug, + workflowPath: '.github/workflows/ci.yml', + }); + + expect(updateGithubConnection).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + it('exits(1) with a connect-first message when the project is not connected', async () => { const notConnected = new PlatformApiError( 'Failed to get GitHub connection', @@ -193,7 +232,7 @@ describe('runGithubUpdate (non-TTY)', () => { expect(exitSpy).toHaveBeenCalledWith(1); expect(errorSpy).toHaveBeenCalledWith( - 'update: Nothing to update. Pass --branch and/or --executor .', + 'update: Nothing to update. Pass --branch , --executor , and/or --workflow <.github/workflows/...>.', ); // The missing-flags guard must run BEFORE project resolution / connection // reads, so this usage error never depends on API state. @@ -224,7 +263,7 @@ describe('runGithubUpdate (non-TTY)', () => { expect(exitSpy).toHaveBeenCalledWith(1); expect(errorSpy).toHaveBeenCalledWith( - 'update: Nothing to update. Pass --branch and/or --executor .', + 'update: Nothing to update. Pass --branch , --executor , and/or --workflow <.github/workflows/...>.', ); expect(listProjects).not.toHaveBeenCalled(); expect(getGithubConnection).not.toHaveBeenCalled(); From ef845e8a8174d7281508cade3e5b9d8160d8c505 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 2 Jul 2026 16:56:51 +0800 Subject: [PATCH 2/2] fix(cli): fail closed when the server doesn't apply --workflow `void github connect`/`update` now verify the returned connection's workflow_path equals the requested --workflow and exit non-zero otherwise. An older platform that predates the field silently drops it and returns success, which would leave the user believing a custom workflow is authorized while the server stays pinned to the default. Fails closed with an "API may be too old" message instead. Addresses a finding from the adversarial review of the configurable deploy-workflow-path feature. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/void/src/cli/github-cmd.ts | 24 ++++++++++++++++++- .../void/test/unit/github-update-cmd.test.ts | 24 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/packages/void/src/cli/github-cmd.ts b/packages/void/src/cli/github-cmd.ts index ea6bcccf2..7f00804a2 100644 --- a/packages/void/src/cli/github-cmd.ts +++ b/packages/void/src/cli/github-cmd.ts @@ -458,8 +458,9 @@ async function runConnect( // ------------------------------------------------------------------------- // 6. Connect // ------------------------------------------------------------------------- + let connection: GithubConnection; try { - await client.connectGithub(projectId, { + connection = await client.connectGithub(projectId, { installationId: installation.installation_id, repoFullName, branch, @@ -480,6 +481,17 @@ async function runConnect( throw err; } + // Fail closed when an explicit --workflow was not applied. An older platform + // that predates workflow_path silently drops the field and returns success + // while staying pinned to the default, so the user would wrongly believe their + // custom workflow is authorized to deploy. Verify the server echoed it back. + if (args.workflowPath !== undefined && connection.workflow_path !== args.workflowPath) { + log.error( + `connect: server did not apply --workflow ${args.workflowPath} (got ${connection.workflow_path ?? 'none'}). The platform may be too old to support a custom workflow path.`, + ); + process.exit(1); + } + log.success(`Connected ${repoFullName} (${branch}) → project. Push to ${branch} to deploy.`); } @@ -674,6 +686,16 @@ export async function runGithubUpdate( throw err; } + // Fail closed when an explicit --workflow was not applied — same old-platform + // compatibility hazard as connect: the server could return success while + // ignoring the unknown field, leaving the workflow pin unchanged. + if (patch.workflowPath !== undefined && updated.workflow_path !== patch.workflowPath) { + log.error( + `update: server did not apply --workflow ${patch.workflowPath} (got ${updated.workflow_path ?? 'none'}). The platform may be too old to support a custom workflow path.`, + ); + process.exit(1); + } + log.success( `Updated ${updated.repo_full_name} → branch ${updated.branch}, executor ${updated.build_executor}, workflow ${updated.workflow_path}.`, ); diff --git a/packages/void/test/unit/github-update-cmd.test.ts b/packages/void/test/unit/github-update-cmd.test.ts index 514ec7228..c77e8bca8 100644 --- a/packages/void/test/unit/github-update-cmd.test.ts +++ b/packages/void/test/unit/github-update-cmd.test.ts @@ -193,6 +193,30 @@ describe('runGithubUpdate (non-TTY)', () => { expect(exitSpy).not.toHaveBeenCalled(); }); + it('fails closed when the server does not apply --workflow (old platform)', async () => { + const { client, updateGithubConnection } = makeClient({ + getGithubConnection: vi.fn(async () => makeConnection()), + // An old platform that predates workflow_path ignores the unknown field and + // returns the connection still pinned to the default. + updateGithubConnection: vi.fn(async () => makeConnection()), + }); + + await expect( + runGithubUpdate('/project', client, { + subcommand: 'update', + projectName: slug, + workflowPath: '.github/workflows/ci.yml', + }), + ).rejects.toThrow('process.exit(1)'); + + expect(updateGithubConnection).toHaveBeenCalledWith(projectId, { + workflowPath: '.github/workflows/ci.yml', + }); + expect(errorSpy).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(successSpy).not.toHaveBeenCalled(); + }); + it('exits(1) with a connect-first message when the project is not connected', async () => { const notConnected = new PlatformApiError( 'Failed to get GitHub connection',