Skip to content

Commit 7729476

Browse files
authored
Merge pull request #12 from dmno-dev/fix/version-pr-ci-triggers
Support custom token for triggering CI on version PRs
2 parents 818fc66 + 05a2223 commit 7729476

5 files changed

Lines changed: 348 additions & 46 deletions

File tree

.bumpy/fix-version-pr-ci-triggers.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
'@varlock/bumpy': patch
33
---
44

5-
Fix version PR not triggering CI workflow runs
5+
Support custom token for triggering CI on version PRs
66

7-
After pushing the version branch, recreate the tip commit via the GitHub REST API so that pull_request workflows fire automatically. Commits pushed with GITHUB_TOKEN don't trigger workflows due to GitHub's anti-recursion guard, but API-created commits bypass this — no PATs, GitHub Apps, or user CI config changes needed.
7+
- Add `BUMPY_GH_TOKEN` env var support — when set, bumpy pushes the version branch using the custom token, bypassing GitHub's anti-recursion guard so PR workflows fire automatically
8+
- Add `bumpy ci setup` interactive command to help create a fine-grained PAT or GitHub App and store it as a repo secret
9+
- When no custom token is set, log a warning with setup instructions

.github/workflows/release.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,4 @@ jobs:
2525
- run: bun install
2626
- run: bunx @varlock/bumpy ci release
2727
env:
28-
GH_TOKEN: ${{ github.token }}
28+
BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }}

packages/bumpy/src/cli.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,11 @@ async function main() {
114114
tag: ciFlags.tag as string | undefined,
115115
branch: ciFlags.branch as string | undefined,
116116
});
117+
} else if (subcommand === 'setup') {
118+
const { ciSetupCommand } = await import('./commands/ci-setup.ts');
119+
await ciSetupCommand(rootDir);
117120
} else {
118-
log.error(`Unknown ci subcommand: ${subcommand}. Use "ci check" or "ci release".`);
121+
log.error(`Unknown ci subcommand: ${subcommand}. Use "ci check", "ci release", or "ci setup".`);
119122
process.exit(1);
120123
}
121124
break;
@@ -189,6 +192,7 @@ function printHelp() {
189192
publish Publish versioned packages
190193
ci check PR check — report pending releases, comment on PR
191194
ci release Release — create version PR or auto-publish
195+
ci setup Set up a token for triggering CI on version PRs
192196
migrate Migrate from .changeset/ to .bumpy/
193197
ai setup Install AI skill for creating changesets
194198
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import pc from 'picocolors';
2+
import { log } from '../utils/logger.ts';
3+
import { p, unwrap } from '../utils/clack.ts';
4+
import { tryRunArgs } from '../utils/shell.ts';
5+
import { detectPackageManager } from '../utils/package-manager.ts';
6+
import type { PackageManager } from '../types.ts';
7+
8+
const PAT_PERMISSIONS = [
9+
'contents: read & write',
10+
'pull requests: read & write',
11+
'metadata: read (selected automatically)',
12+
];
13+
14+
export async function ciSetupCommand(rootDir: string): Promise<void> {
15+
p.intro(pc.bgCyan(pc.black(' bumpy ci setup ')));
16+
17+
// Detect repo and package manager context
18+
const repo = detectRepo(rootDir);
19+
if (!repo) {
20+
log.error(
21+
'Could not detect a GitHub repository.\n' +
22+
' This command currently only supports GitHub-hosted repos.\n' +
23+
' Make sure you have a GitHub remote (git remote -v).',
24+
);
25+
process.exit(1);
26+
}
27+
const pm = await detectPackageManager(rootDir);
28+
29+
p.log.info(`Detected repository: ${pc.cyan(repo)}`);
30+
p.log.info('');
31+
p.log.info(
32+
'To trigger CI checks on the version PR, bumpy needs a token\n' +
33+
"that bypasses GitHub's default anti-recursion guard.\n" +
34+
'You can use a fine-grained PAT or a GitHub App installation token.',
35+
);
36+
37+
const method = unwrap(
38+
await p.select({
39+
message: 'How would you like to authenticate?',
40+
options: [
41+
{
42+
label: 'Fine-grained Personal Access Token (PAT)',
43+
value: 'pat' as const,
44+
hint: 'recommended — quick and simple',
45+
},
46+
{
47+
label: 'GitHub App installation token',
48+
value: 'app' as const,
49+
hint: 'advanced — not tied to a personal account',
50+
},
51+
],
52+
}),
53+
);
54+
55+
if (method === 'pat') {
56+
await setupPat(rootDir, repo, pm);
57+
} else {
58+
await setupApp(rootDir, repo, pm);
59+
}
60+
}
61+
62+
// ---- PAT flow ----
63+
64+
async function setupPat(rootDir: string, repo: string, pm: PackageManager): Promise<void> {
65+
const patUrl = 'https://github.com/settings/personal-access-tokens/new';
66+
67+
p.log.info('');
68+
p.note(
69+
[
70+
`1. Open: ${pc.cyan(patUrl)}`,
71+
'',
72+
`2. Set a name, e.g. ${pc.dim('"bumpy-ci"')}`,
73+
'',
74+
`3. Under ${pc.bold('Resource owner')}, select the org or account that owns ${pc.cyan(repo)}`,
75+
'',
76+
`4. Set ${pc.bold('Expiration')} — choose a longer duration to avoid frequent rotation`,
77+
` (you'll need to regenerate and update the secret when it expires)`,
78+
'',
79+
`5. Under ${pc.bold('Repository access')}, select ${pc.bold('"Only select repositories"')}`,
80+
` and choose ${pc.cyan(repo)}`,
81+
'',
82+
`6. Under ${pc.bold('Permissions → Repository permissions')}, grant:`,
83+
...PAT_PERMISSIONS.map((perm) => ` • ${pc.bold(perm)}`),
84+
'',
85+
'7. Click "Generate token" and copy the value',
86+
'',
87+
pc.dim('Tip: enable branch protection rules on your main branch to prevent'),
88+
pc.dim('direct pushes — the PAT will only be used to push the version branch.'),
89+
].join('\n'),
90+
'Create a fine-grained PAT',
91+
);
92+
93+
// Try to open browser
94+
const shouldOpen = unwrap(await p.confirm({ message: 'Open the token creation page in your browser?' }));
95+
if (shouldOpen) {
96+
openBrowser(patUrl);
97+
}
98+
99+
// Prompt for the token
100+
const token = unwrap(
101+
await p.text({
102+
message: 'Paste your token:',
103+
placeholder: 'github_pat_...',
104+
validate: (value) => {
105+
if (!value?.trim()) return 'Token is required';
106+
if (!value?.startsWith('github_pat_')) return 'Expected a fine-grained PAT (starts with github_pat_)';
107+
},
108+
}),
109+
);
110+
111+
await storeSecret(rootDir, repo, token, pm);
112+
}
113+
114+
// ---- GitHub App flow ----
115+
116+
async function setupApp(rootDir: string, repo: string, pm: PackageManager): Promise<void> {
117+
const owner = repo.split('/')[0]!;
118+
const appUrl = `https://github.com/organizations/${owner}/settings/apps/new`;
119+
const personalAppUrl = `https://github.com/settings/apps/new`;
120+
121+
const isOrg = unwrap(await p.confirm({ message: `Is ${pc.cyan(owner)} a GitHub organization?`, initialValue: true }));
122+
123+
const createUrl = isOrg ? appUrl : personalAppUrl;
124+
125+
p.log.info('');
126+
p.note(
127+
[
128+
'If you already have a GitHub App, skip to step 2.',
129+
'',
130+
pc.bold('Step 1: Create a GitHub App'),
131+
'',
132+
`1. Open: ${pc.cyan(createUrl)}`,
133+
`2. Set the name, e.g. ${pc.dim(`"${owner}-bumpy-ci"`)}`,
134+
'3. Uncheck "Active" under Webhooks (not needed)',
135+
'4. Under Permissions → Repository permissions, grant:',
136+
...PAT_PERMISSIONS.map((perm) => ` • ${pc.bold(perm)}`),
137+
'5. Under "Where can this app be installed?" select "Only on this account"',
138+
'6. Click "Create GitHub App"',
139+
'7. Note the App ID shown on the settings page',
140+
'8. Generate a private key and download the .pem file',
141+
'',
142+
pc.bold('Step 2: Install the App'),
143+
'',
144+
`Install the app on ${pc.cyan(repo)} from the app's "Install App" tab.`,
145+
'',
146+
pc.bold('Step 3: Add secrets'),
147+
'',
148+
"You'll need to add two repository secrets:",
149+
` • ${pc.bold('BUMPY_APP_ID')} — the App ID`,
150+
` • ${pc.bold('BUMPY_APP_PRIVATE_KEY')} — contents of the .pem file`,
151+
].join('\n'),
152+
'GitHub App setup',
153+
);
154+
155+
const shouldOpen = unwrap(await p.confirm({ message: 'Open the app creation page in your browser?' }));
156+
if (shouldOpen) {
157+
openBrowser(createUrl);
158+
}
159+
160+
const hasSecrets = unwrap(
161+
await p.confirm({ message: 'Have you added the BUMPY_APP_ID and BUMPY_APP_PRIVATE_KEY secrets?' }),
162+
);
163+
164+
if (hasSecrets) {
165+
printAppWorkflowSnippet(pm);
166+
} else {
167+
p.log.info('You can add them later. Once ready, update your release workflow:');
168+
printAppWorkflowSnippet(pm);
169+
}
170+
171+
p.outro(pc.green('GitHub App setup complete!'));
172+
}
173+
174+
// ---- Shared helpers ----
175+
176+
async function storeSecret(rootDir: string, repo: string, token: string, pm: PackageManager): Promise<void> {
177+
const hasGh = tryRunArgs(['gh', '--version']);
178+
if (!hasGh) {
179+
p.log.warn("`gh` CLI not found — you'll need to add the secret manually.");
180+
p.note(
181+
`Go to: https://github.com/${repo}/settings/secrets/actions/new\n` +
182+
`Name: ${pc.bold('BUMPY_GH_TOKEN')}\nValue: (the token you just created)`,
183+
'Add repository secret manually',
184+
);
185+
printPatWorkflowSnippet(pm);
186+
p.outro(pc.green('Setup complete!'));
187+
return;
188+
}
189+
190+
// Check if the secret already exists
191+
const existingSecrets = tryRunArgs(['gh', 'secret', 'list', '--repo', repo], { cwd: rootDir });
192+
const isReplacing = existingSecrets?.includes('BUMPY_GH_TOKEN') ?? false;
193+
194+
const spin = p.spinner();
195+
spin.start(
196+
isReplacing ? 'Replacing BUMPY_GH_TOKEN repository secret...' : 'Storing BUMPY_GH_TOKEN as a repository secret...',
197+
);
198+
try {
199+
// gh secret set reads from stdin and overwrites if the secret already exists
200+
tryRunArgs(['gh', 'secret', 'set', 'BUMPY_GH_TOKEN', '--repo', repo], {
201+
cwd: rootDir,
202+
input: token,
203+
} as any);
204+
spin.stop(isReplacing ? 'Secret replaced!' : 'Secret stored!');
205+
} catch {
206+
spin.stop('Failed to store secret');
207+
p.log.warn('Could not store the secret automatically.');
208+
p.note(
209+
`Go to: https://github.com/${repo}/settings/secrets/actions/new\n` +
210+
`Name: ${pc.bold('BUMPY_GH_TOKEN')}\nValue: (the token you just created)`,
211+
'Add repository secret manually',
212+
);
213+
}
214+
215+
printPatWorkflowSnippet(pm);
216+
p.outro(pc.green('Setup complete!'));
217+
}
218+
219+
function printPatWorkflowSnippet(pm: PackageManager): void {
220+
const runCmd = pmxCommand(pm);
221+
p.note(
222+
[
223+
'In your release workflow, pass the token to bumpy:',
224+
'',
225+
pc.dim('# .github/workflows/release.yaml'),
226+
pc.dim(`- run: ${runCmd} ci release`),
227+
pc.dim(' env:'),
228+
pc.green(' BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }}'),
229+
].join('\n'),
230+
'Update your workflow',
231+
);
232+
}
233+
234+
function printAppWorkflowSnippet(pm: PackageManager): void {
235+
const runCmd = pmxCommand(pm);
236+
p.note(
237+
[
238+
'In your release workflow, generate a token and pass it to bumpy:',
239+
'',
240+
pc.dim('# .github/workflows/release.yaml'),
241+
pc.green('- uses: actions/create-github-app-token@v2'),
242+
pc.green(' id: app-token'),
243+
pc.green(' with:'),
244+
pc.green(' app-id: ${{ secrets.BUMPY_APP_ID }}'),
245+
pc.green(' private-key: ${{ secrets.BUMPY_APP_PRIVATE_KEY }}'),
246+
'',
247+
pc.dim(`- run: ${runCmd} ci release`),
248+
pc.dim(' env:'),
249+
pc.green(' BUMPY_GH_TOKEN: ${{ steps.app-token.outputs.token }}'),
250+
].join('\n'),
251+
'Update your workflow',
252+
);
253+
}
254+
255+
/** Package-manager-appropriate command for running bumpy in CI workflows */
256+
function pmxCommand(pm: PackageManager): string {
257+
if (pm === 'bun') return 'bunx @varlock/bumpy';
258+
if (pm === 'pnpm') return 'pnpm exec bumpy';
259+
if (pm === 'yarn') return 'yarn bumpy';
260+
return 'npx @varlock/bumpy';
261+
}
262+
263+
function detectRepo(rootDir: string): string | null {
264+
// Check GitHub Actions env first
265+
if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY;
266+
267+
// Try to extract from git remote
268+
const remote = tryRunArgs(['git', 'remote', 'get-url', 'origin'], { cwd: rootDir });
269+
if (!remote) return null;
270+
271+
// SSH: git@github.com:owner/repo.git
272+
const sshMatch = remote.match(/github\.com[:/](.+?)(?:\.git)?$/);
273+
if (sshMatch) return sshMatch[1]!;
274+
275+
// HTTPS: https://github.com/owner/repo.git
276+
const httpsMatch = remote.match(/github\.com\/(.+?)(?:\.git)?$/);
277+
if (httpsMatch) return httpsMatch[1]!;
278+
279+
return null;
280+
}
281+
282+
function openBrowser(url: string): void {
283+
try {
284+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
285+
tryRunArgs([cmd, url]);
286+
} catch {
287+
// Silent fail — user can open manually
288+
}
289+
}

0 commit comments

Comments
 (0)