Skip to content

Commit de1d581

Browse files
Brooooooklynclaude
andauthored
feat(cli): add --workflow flag to configure the GitHub deploy-workflow path (#245)
## What Adds a `--workflow <path>` flag to `void github connect` and `void github update`, letting the project owner configure which workflow file the platform authorizes to mint an OIDC deploy token. Defaults to the scaffolded `.github/workflows/void-deploy.yml`. ``` void github connect my-app --repo owner/app --executor github_actions --workflow .github/workflows/ci.yml void github update my-app --workflow .github/workflows/deploy.yml ``` ## Why The authorized deploy workflow was previously hardcoded server-side. This lets owners point the OIDC trust at their own workflow file (it must live under `.github/workflows/` and end in `.yml`/`.yaml`). The API is authoritative for validation and storage; the CLI just forwards the value. ## Details - `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) and no-ops when unchanged. - `client`: send `workflowPath` in the POST/PATCH bodies; add `workflow_path` to the `GithubConnection` type. - **Fail closed**: connect/update verify the server echoed the requested `workflow_path` and exit non-zero otherwise, so a CLI pointed at an older API (which silently drops the field) can't leave the user believing a custom workflow is authorized. - `help` + `docs/reference/cli.md`: document the flag and its security semantics. ## Testing `vp test run` — github CLI unit tests (parser + connect/update threading + fail-closed verification) all pass; typecheck/fmt/lint clean. > Pairs with a platform-side PR that adds the server `workflow_path` column, owner-gated routes, and the OIDC pin. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes which workflow may mint deploy tokens (security-sensitive OIDC trust), though validation is server-side and the CLI fails closed when the platform does not apply the path. > > **Overview** > Adds **`--workflow <path>`** to `void github connect` and `void github update` so project owners can configure which GitHub Actions workflow file the platform trusts for OIDC deploy-token minting (default `.github/workflows/void-deploy.yml`). > > The CLI parses the flag, sends optional **`workflowPath`** on connect/update API calls, and extends **`GithubConnection`** with **`workflow_path`**. On **`update`**, workflow changes are flag-only (no TTY prompt), count toward the non-interactive “at least one flag” guard, and patch only when the value differs. **Connect** and **update** **fail closed** if the user passed **`--workflow`** but the response **`workflow_path`** does not match—avoiding silent success against an older API that ignores the field. > > Help text and **`docs/reference/cli.md`** document the flag and OIDC trust semantics. Unit tests cover parsing, update threading, no-op behavior, and old-platform verification. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit ef845e8. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ab38b96 commit de1d581

8 files changed

Lines changed: 209 additions & 27 deletions

File tree

‎docs/reference/cli.md‎

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -607,13 +607,16 @@ Connect a GitHub repository to a Void project for automatic deploys. On every pu
607607

608608
**Options**
609609

610-
| Flag | Description |
611-
| --------------------- | ------------------------------------------------------------------------------- |
612-
| `--project <name>` | Project name (alias for the positional argument) |
613-
| `--installation <id>` | GitHub App installation ID (required when you have multiple installations) |
614-
| `--repo <owner/repo>` | Repository full name — required unless the installation grants exactly one repo |
615-
| `--branch <name>` | Branch to deploy from — **required in non-interactive mode** |
616-
| `--executor <type>` | Build executor: `container` (default) or `github_actions` |
610+
| Flag | Description |
611+
| --------------------- | --------------------------------------------------------------------------------- |
612+
| `--project <name>` | Project name (alias for the positional argument) |
613+
| `--installation <id>` | GitHub App installation ID (required when you have multiple installations) |
614+
| `--repo <owner/repo>` | Repository full name — required unless the installation grants exactly one repo |
615+
| `--branch <name>` | Branch to deploy from — **required in non-interactive mode** |
616+
| `--executor <type>` | Build executor: `container` (default) or `github_actions` |
617+
| `--workflow <path>` | Authorized deploy workflow file — defaults to `.github/workflows/void-deploy.yml` |
618+
619+
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.
617620

618621
**Non-interactive use (CI)**
619622

@@ -634,17 +637,18 @@ void github connect my-app \
634637
void github update [project] [options]
635638
```
636639

637-
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.
640+
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.
638641

639642
**Options**
640643

641-
| Flag | Description |
642-
| ------------------- | --------------------------------------------------- |
643-
| `--project <name>` | Project name (alias for the positional argument) |
644-
| `--branch <name>` | New branch to deploy from |
645-
| `--executor <type>` | New build executor: `container` or `github_actions` |
644+
| Flag | Description |
645+
| ------------------- | ---------------------------------------------------------------- |
646+
| `--project <name>` | Project name (alias for the positional argument) |
647+
| `--branch <name>` | New branch to deploy from |
648+
| `--executor <type>` | New build executor: `container` or `github_actions` |
649+
| `--workflow <path>` | New authorized deploy workflow file (under `.github/workflows/`) |
646650

647-
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.
651+
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.
648652

649653
```
650654
void github update my-app --executor github_actions

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ export type GithubConnection = {
234234
repository_id: string | null;
235235
branch: string;
236236
build_executor: 'container' | 'github_actions';
237+
workflow_path: string;
237238
created_at: string;
238239
updated_at: string;
239240
};
@@ -975,6 +976,7 @@ export class PlatformClient {
975976
repoFullName: string;
976977
branch?: string;
977978
buildExecutor?: 'container' | 'github_actions';
979+
workflowPath?: string;
978980
},
979981
): Promise<GithubConnection> {
980982
const res = await fetch(`${this.baseUrl}/projects/${projectId}/github`, {
@@ -1012,6 +1014,7 @@ export class PlatformClient {
10121014
body: {
10131015
branch?: string;
10141016
buildExecutor?: 'container' | 'github_actions';
1017+
workflowPath?: string;
10151018
},
10161019
): Promise<GithubConnection> {
10171020
const res = await fetch(`${this.baseUrl}/projects/${projectId}/github`, {

‎packages/void/src/cli/github-args.ts‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const githubSubcommands: Array<SubcommandDef> = [
1111
{
1212
name: 'update',
1313
arg: '[project]',
14-
description: "Update an existing connection's branch or build executor",
14+
description: "Update an existing connection's branch, build executor, or deploy workflow",
1515
},
1616
{ name: 'link', description: 'Link your GitHub account to Void' },
1717
];
@@ -27,12 +27,14 @@ export type GithubArgs =
2727
repo?: string;
2828
branch?: string;
2929
executor?: 'container' | 'github_actions';
30+
workflowPath?: string;
3031
}
3132
| {
3233
subcommand: 'update';
3334
projectName: string;
3435
branch?: string;
3536
executor?: 'container' | 'github_actions';
37+
workflowPath?: string;
3638
};
3739

3840
export function parseGithubArgs(args: Array<string>): GithubArgs {
@@ -65,6 +67,7 @@ export function parseGithubArgs(args: Array<string>): GithubArgs {
6567
let repo: string | undefined;
6668
let branch: string | undefined;
6769
let executor: 'container' | 'github_actions' | undefined;
70+
let workflowPath: string | undefined;
6871

6972
for (let i = 0; i < rest.length; i++) {
7073
const arg = rest[i];
@@ -119,6 +122,15 @@ export function parseGithubArgs(args: Array<string>): GithubArgs {
119122
throw new Error('github: invalid --executor (expected container|github_actions).');
120123
}
121124
executor = value;
125+
} else if (arg === '--workflow') {
126+
if (i + 1 >= rest.length) {
127+
throw new Error('github: --workflow requires a value.');
128+
}
129+
const value = rest[++i]!;
130+
if (value.trim() === '') {
131+
throw new Error('github: --workflow requires a value.');
132+
}
133+
workflowPath = value.trim();
122134
} else if (arg.startsWith('-')) {
123135
throw new Error(`github: Unexpected argument '${arg}'.`);
124136
} else if (projectName) {
@@ -128,13 +140,14 @@ export function parseGithubArgs(args: Array<string>): GithubArgs {
128140
}
129141
}
130142

131-
return { subcommand, projectName, installationId, repo, branch, executor };
143+
return { subcommand, projectName, installationId, repo, branch, executor, workflowPath };
132144
}
133145

134146
if (subcommand === 'update') {
135147
let projectName = '';
136148
let branch: string | undefined;
137149
let executor: 'container' | 'github_actions' | undefined;
150+
let workflowPath: string | undefined;
138151

139152
for (let i = 0; i < rest.length; i++) {
140153
const arg = rest[i];
@@ -175,6 +188,15 @@ export function parseGithubArgs(args: Array<string>): GithubArgs {
175188
throw new Error('github: invalid --executor (expected container|github_actions).');
176189
}
177190
executor = value;
191+
} else if (arg === '--workflow') {
192+
if (i + 1 >= rest.length) {
193+
throw new Error('github: --workflow requires a value.');
194+
}
195+
const value = rest[++i]!;
196+
if (value.trim() === '') {
197+
throw new Error('github: --workflow requires a value.');
198+
}
199+
workflowPath = value.trim();
178200
} else if (arg.startsWith('-')) {
179201
throw new Error(`github: Unexpected argument '${arg}'.`);
180202
} else if (projectName) {
@@ -184,7 +206,7 @@ export function parseGithubArgs(args: Array<string>): GithubArgs {
184206
}
185207
}
186208

187-
return { subcommand, projectName, branch, executor };
209+
return { subcommand, projectName, branch, executor, workflowPath };
188210
}
189211

190212
throw new Error('github: Usage: void github <install|installations|connect|update|link>.');

‎packages/void/src/cli/github-cmd.ts‎

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -458,12 +458,14 @@ async function runConnect(
458458
// -------------------------------------------------------------------------
459459
// 6. Connect
460460
// -------------------------------------------------------------------------
461+
let connection: GithubConnection;
461462
try {
462-
await client.connectGithub(projectId, {
463+
connection = await client.connectGithub(projectId, {
463464
installationId: installation.installation_id,
464465
repoFullName,
465466
branch,
466467
buildExecutor: executor,
468+
workflowPath: args.workflowPath,
467469
});
468470
} catch (err) {
469471
if (err instanceof PlatformApiError) {
@@ -479,6 +481,17 @@ async function runConnect(
479481
throw err;
480482
}
481483

484+
// Fail closed when an explicit --workflow was not applied. An older platform
485+
// that predates workflow_path silently drops the field and returns success
486+
// while staying pinned to the default, so the user would wrongly believe their
487+
// custom workflow is authorized to deploy. Verify the server echoed it back.
488+
if (args.workflowPath !== undefined && connection.workflow_path !== args.workflowPath) {
489+
log.error(
490+
`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.`,
491+
);
492+
process.exit(1);
493+
}
494+
482495
log.success(`Connected ${repoFullName} (${branch}) → project. Push to ${branch} to deploy.`);
483496
}
484497

@@ -497,9 +510,14 @@ export async function runGithubUpdate(
497510
// Guard BEFORE any project resolution or connection read so this usage error
498511
// never depends on API state (e.g. a disconnected project must not surface the
499512
// "connect first" 404 message in place of the missing-flags error).
500-
if (!isTTY && args.branch === undefined && args.executor === undefined) {
513+
if (
514+
!isTTY &&
515+
args.branch === undefined &&
516+
args.executor === undefined &&
517+
args.workflowPath === undefined
518+
) {
501519
log.error(
502-
'update: Nothing to update. Pass --branch <name> and/or --executor <container|github_actions>.',
520+
'update: Nothing to update. Pass --branch <name>, --executor <container|github_actions>, and/or --workflow <.github/workflows/...>.',
503521
);
504522
process.exit(1);
505523
}
@@ -617,18 +635,31 @@ export async function runGithubUpdate(
617635
// -------------------------------------------------------------------------
618636
// 4. Build a patch carrying ONLY the fields that actually changed.
619637
// -------------------------------------------------------------------------
620-
const patch: { branch?: string; buildExecutor?: 'container' | 'github_actions' } = {};
638+
const patch: {
639+
branch?: string;
640+
buildExecutor?: 'container' | 'github_actions';
641+
workflowPath?: string;
642+
} = {};
621643
if (branch !== current.branch) {
622644
patch.branch = branch;
623645
}
624646
if (executor !== current.build_executor) {
625647
patch.buildExecutor = executor;
626648
}
649+
// workflow_path is flag-only (no interactive prompt): change it only when
650+
// --workflow was passed AND it differs from the stored value.
651+
if (args.workflowPath !== undefined && args.workflowPath !== current.workflow_path) {
652+
patch.workflowPath = args.workflowPath;
653+
}
627654

628655
// -------------------------------------------------------------------------
629656
// 5. No-op guard — never hit the API when nothing changed.
630657
// -------------------------------------------------------------------------
631-
if (patch.branch === undefined && patch.buildExecutor === undefined) {
658+
if (
659+
patch.branch === undefined &&
660+
patch.buildExecutor === undefined &&
661+
patch.workflowPath === undefined
662+
) {
632663
log.info(
633664
`No changes. ${current.repo_full_name} already deploys from ${current.branch} via ${current.build_executor}.`,
634665
);
@@ -655,7 +686,17 @@ export async function runGithubUpdate(
655686
throw err;
656687
}
657688

689+
// Fail closed when an explicit --workflow was not applied — same old-platform
690+
// compatibility hazard as connect: the server could return success while
691+
// ignoring the unknown field, leaving the workflow pin unchanged.
692+
if (patch.workflowPath !== undefined && updated.workflow_path !== patch.workflowPath) {
693+
log.error(
694+
`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.`,
695+
);
696+
process.exit(1);
697+
}
698+
658699
log.success(
659-
`Updated ${updated.repo_full_name} → branch ${updated.branch}, executor ${updated.build_executor}.`,
700+
`Updated ${updated.repo_full_name} → branch ${updated.branch}, executor ${updated.build_executor}, workflow ${updated.workflow_path}.`,
660701
);
661702
}

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ const HELP_TREE: HelpTree = {
202202
},
203203
{
204204
command: 'update [project]',
205-
description: "Update an existing connection's branch or build executor",
205+
description: "Update an existing connection's branch, build executor, or deploy workflow",
206206
},
207207
{ command: 'link', description: 'Link your GitHub account to Void' },
208208
],
@@ -231,6 +231,11 @@ const HELP_TREE: HelpTree = {
231231
command: '--executor <type>',
232232
description: 'Build executor: container (default) | github_actions',
233233
},
234+
{
235+
command: '--workflow <path>',
236+
description:
237+
'Authorized deploy workflow file (default .github/workflows/void-deploy.yml)',
238+
},
234239
],
235240
examples: [
236241
{
@@ -244,7 +249,7 @@ const HELP_TREE: HelpTree = {
244249
],
245250
},
246251
update: {
247-
summary: "Update an existing connection's branch or build executor.",
252+
summary: "Update an existing connection's branch, build executor, or deploy workflow.",
248253
usage: ['void github update [project] [options]'],
249254
options: [
250255
{ command: '--project <name>', description: 'Project name (alias for positional)' },
@@ -253,6 +258,10 @@ const HELP_TREE: HelpTree = {
253258
command: '--executor <type>',
254259
description: 'New build executor: container | github_actions',
255260
},
261+
{
262+
command: '--workflow <path>',
263+
description: 'New authorized deploy workflow file (under .github/workflows/)',
264+
},
256265
],
257266
examples: [
258267
{

‎packages/void/test/unit/github-args.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,25 @@ describe('parseGithubArgs', () => {
121121
});
122122
});
123123

124+
it('parses --workflow', () => {
125+
const result = parseGithubArgs(['connect', '--workflow', '.github/workflows/ci.yml']);
126+
expect(result).toEqual({
127+
subcommand: 'connect',
128+
projectName: '',
129+
installationId: undefined,
130+
repo: undefined,
131+
branch: undefined,
132+
executor: undefined,
133+
workflowPath: '.github/workflows/ci.yml',
134+
});
135+
});
136+
137+
it('throws when --workflow has no value', () => {
138+
expect(() => parseGithubArgs(['connect', '--workflow'])).toThrow(
139+
'github: --workflow requires a value.',
140+
);
141+
});
142+
124143
it('parses --project flag', () => {
125144
const result = parseGithubArgs(['connect', '--project', 'my-project']);
126145
expect(result).toEqual({
@@ -361,6 +380,22 @@ describe('parseGithubArgs', () => {
361380
});
362381
});
363382

383+
it('parses --workflow', () => {
384+
expect(parseGithubArgs(['update', '--workflow', '.github/workflows/ci.yml'])).toEqual({
385+
subcommand: 'update',
386+
projectName: '',
387+
branch: undefined,
388+
executor: undefined,
389+
workflowPath: '.github/workflows/ci.yml',
390+
});
391+
});
392+
393+
it('throws when --workflow has no value', () => {
394+
expect(() => parseGithubArgs(['update', '--workflow'])).toThrow(
395+
'github: --workflow requires a value.',
396+
);
397+
});
398+
364399
it('parses project name with both flags together', () => {
365400
expect(
366401
parseGithubArgs([

‎packages/void/test/unit/github-client.test.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ describe('PlatformClient.connectGithub', () => {
105105
repository_id: '9876',
106106
branch: 'main',
107107
build_executor: 'container',
108+
workflow_path: '.github/workflows/void-deploy.yml',
108109
created_at: '2024-01-01T00:00:00Z',
109110
updated_at: '2024-01-01T00:00:00Z',
110111
};
@@ -144,6 +145,7 @@ describe('PlatformClient.connectGithub', () => {
144145
repository_id: null,
145146
branch: 'main',
146147
build_executor: 'container',
148+
workflow_path: '.github/workflows/void-deploy.yml',
147149
created_at: '2024-01-01T00:00:00Z',
148150
updated_at: '2024-01-01T00:00:00Z',
149151
};
@@ -188,6 +190,7 @@ describe('PlatformClient.getGithubConnection', () => {
188190
repository_id: '9876',
189191
branch: 'main',
190192
build_executor: 'container',
193+
workflow_path: '.github/workflows/void-deploy.yml',
191194
created_at: '2024-01-01T00:00:00Z',
192195
updated_at: '2024-01-01T00:00:00Z',
193196
};
@@ -227,6 +230,7 @@ describe('PlatformClient.updateGithubConnection', () => {
227230
repository_id: '9876',
228231
branch: 'release',
229232
build_executor: 'github_actions',
233+
workflow_path: '.github/workflows/void-deploy.yml',
230234
created_at: '2024-01-01T00:00:00Z',
231235
updated_at: '2024-01-02T00:00:00Z',
232236
};
@@ -262,6 +266,7 @@ describe('PlatformClient.updateGithubConnection', () => {
262266
repository_id: null,
263267
branch: 'main',
264268
build_executor: 'github_actions',
269+
workflow_path: '.github/workflows/void-deploy.yml',
265270
created_at: '2024-01-01T00:00:00Z',
266271
updated_at: '2024-01-02T00:00:00Z',
267272
};

0 commit comments

Comments
 (0)