Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 18 additions & 14 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -607,13 +607,16 @@ Connect a GitHub repository to a Void project for automatic deploys. On every pu

**Options**

| Flag | Description |
| --------------------- | ------------------------------------------------------------------------------- |
| `--project <name>` | Project name (alias for the positional argument) |
| `--installation <id>` | GitHub App installation ID (required when you have multiple installations) |
| `--repo <owner/repo>` | Repository full name — required unless the installation grants exactly one repo |
| `--branch <name>` | Branch to deploy from — **required in non-interactive mode** |
| `--executor <type>` | Build executor: `container` (default) or `github_actions` |
| Flag | Description |
| --------------------- | --------------------------------------------------------------------------------- |
| `--project <name>` | Project name (alias for the positional argument) |
| `--installation <id>` | GitHub App installation ID (required when you have multiple installations) |
| `--repo <owner/repo>` | Repository full name — required unless the installation grants exactly one repo |
| `--branch <name>` | Branch to deploy from — **required in non-interactive mode** |
| `--executor <type>` | Build executor: `container` (default) or `github_actions` |
| `--workflow <path>` | 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)**

Expand All @@ -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 <name>` | Project name (alias for the positional argument) |
| `--branch <name>` | New branch to deploy from |
| `--executor <type>` | New build executor: `container` or `github_actions` |
| Flag | Description |
| ------------------- | ---------------------------------------------------------------- |
| `--project <name>` | Project name (alias for the positional argument) |
| `--branch <name>` | New branch to deploy from |
| `--executor <type>` | New build executor: `container` or `github_actions` |
| `--workflow <path>` | 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
Expand Down
3 changes: 3 additions & 0 deletions packages/void/src/cli/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down Expand Up @@ -975,6 +976,7 @@ export class PlatformClient {
repoFullName: string;
branch?: string;
buildExecutor?: 'container' | 'github_actions';
workflowPath?: string;
},
): Promise<GithubConnection> {
const res = await fetch(`${this.baseUrl}/projects/${projectId}/github`, {
Expand Down Expand Up @@ -1012,6 +1014,7 @@ export class PlatformClient {
body: {
branch?: string;
buildExecutor?: 'container' | 'github_actions';
workflowPath?: string;
},
): Promise<GithubConnection> {
const res = await fetch(`${this.baseUrl}/projects/${projectId}/github`, {
Expand Down
28 changes: 25 additions & 3 deletions packages/void/src/cli/github-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export const githubSubcommands: Array<SubcommandDef> = [
{
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' },
];
Expand All @@ -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<string>): GithubArgs {
Expand Down Expand Up @@ -65,6 +67,7 @@ export function parseGithubArgs(args: Array<string>): 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];
Expand Down Expand Up @@ -119,6 +122,15 @@ export function parseGithubArgs(args: Array<string>): 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) {
Expand All @@ -128,13 +140,14 @@ export function parseGithubArgs(args: Array<string>): 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];
Expand Down Expand Up @@ -175,6 +188,15 @@ export function parseGithubArgs(args: Array<string>): 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) {
Expand All @@ -184,7 +206,7 @@ export function parseGithubArgs(args: Array<string>): GithubArgs {
}
}

return { subcommand, projectName, branch, executor };
return { subcommand, projectName, branch, executor, workflowPath };
}

throw new Error('github: Usage: void github <install|installations|connect|update|link>.');
Expand Down
53 changes: 47 additions & 6 deletions packages/void/src/cli/github-cmd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,12 +458,14 @@ async function runConnect(
// -------------------------------------------------------------------------
// 6. Connect
// -------------------------------------------------------------------------
let connection: GithubConnection;
try {
await client.connectGithub(projectId, {
connection = await client.connectGithub(projectId, {
installationId: installation.installation_id,
repoFullName,
branch,
buildExecutor: executor,
workflowPath: args.workflowPath,
});
} catch (err) {
if (err instanceof PlatformApiError) {
Expand All @@ -479,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.`);
}

Expand All @@ -497,9 +510,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 <name> and/or --executor <container|github_actions>.',
'update: Nothing to update. Pass --branch <name>, --executor <container|github_actions>, and/or --workflow <.github/workflows/...>.',
);
process.exit(1);
}
Expand Down Expand Up @@ -617,18 +635,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}.`,
);
Expand All @@ -655,7 +686,17 @@ 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}.`,
`Updated ${updated.repo_full_name} → branch ${updated.branch}, executor ${updated.build_executor}, workflow ${updated.workflow_path}.`,
);
}
13 changes: 11 additions & 2 deletions packages/void/src/cli/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
Expand Down Expand Up @@ -231,6 +231,11 @@ const HELP_TREE: HelpTree = {
command: '--executor <type>',
description: 'Build executor: container (default) | github_actions',
},
{
command: '--workflow <path>',
description:
'Authorized deploy workflow file (default .github/workflows/void-deploy.yml)',
},
],
examples: [
{
Expand All @@ -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 <name>', description: 'Project name (alias for positional)' },
Expand All @@ -253,6 +258,10 @@ const HELP_TREE: HelpTree = {
command: '--executor <type>',
description: 'New build executor: container | github_actions',
},
{
command: '--workflow <path>',
description: 'New authorized deploy workflow file (under .github/workflows/)',
},
],
examples: [
{
Expand Down
35 changes: 35 additions & 0 deletions packages/void/test/unit/github-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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([
Expand Down
5 changes: 5 additions & 0 deletions packages/void/test/unit/github-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand Down Expand Up @@ -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',
};
Expand Down Expand Up @@ -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',
};
Expand Down Expand Up @@ -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',
};
Expand Down Expand Up @@ -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',
};
Expand Down
Loading