bod-cli 0.10.10 → 0.10.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/deploy.ts +88 -14
- package/test/deploy-attribution.test.ts +146 -0
- package/test/detect-branch.test.ts +76 -0
package/package.json
CHANGED
package/src/commands/deploy.ts
CHANGED
|
@@ -6,14 +6,40 @@ import { formatSize } from '../utils/output'
|
|
|
6
6
|
import { resolveExcludes, tarballOffenders } from '../utils/excludes'
|
|
7
7
|
import { resolveAppId, resolveAppName, readInstanceFromYaml, readDeployModeFromYaml, readExcludesFromYaml, detectSiblingDeps, readYamlConfig, resolveRepoFromYaml } from '../utils/resolve'
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* The branch to deploy — never the literal string `HEAD`.
|
|
11
|
+
*
|
|
12
|
+
* `git rev-parse --abbrev-ref HEAD` prints `HEAD` in a DETACHED checkout, which is what
|
|
13
|
+
* every GitHub Actions run and every detached worktree is. That string was then deployed
|
|
14
|
+
* as an ordinary branch name: a real deployment, a live `head--<app>` preview host, DNS
|
|
15
|
+
* and a Caddy route, for a branch that does not exist. Three of them existed on the blank
|
|
16
|
+
* app on 2026-09-05, and the server now refuses one outright (400), so resolving it here
|
|
17
|
+
* is what keeps CI deploying instead of failing.
|
|
18
|
+
*
|
|
19
|
+
* CI knows the answer even when git does not: Actions sets GITHUB_HEAD_REF on a
|
|
20
|
+
* pull_request run and GITHUB_REF_NAME on a push. Those are consulted before giving up.
|
|
21
|
+
* If nothing can name the branch we REFUSE rather than guessing `main` — silently
|
|
22
|
+
* promoting an arbitrary detached commit to production is far worse than stopping.
|
|
23
|
+
*/
|
|
24
|
+
export async function detectBranch(explicit?: string): Promise<string> {
|
|
10
25
|
if (explicit) return explicit
|
|
11
26
|
const proc = Bun.spawnSync(['git', 'rev-parse', '--abbrev-ref', 'HEAD'])
|
|
12
27
|
const branch = proc.exitCode === 0 ? proc.stdout.toString().trim() : ''
|
|
13
|
-
|
|
28
|
+
if (branch && branch.toUpperCase() !== 'HEAD') return branch
|
|
29
|
+
|
|
30
|
+
const fromCi = (process.env.GITHUB_HEAD_REF || process.env.GITHUB_REF_NAME || '').trim()
|
|
31
|
+
if (fromCi && fromCi.toUpperCase() !== 'HEAD') return fromCi
|
|
32
|
+
|
|
33
|
+
if (branch.toUpperCase() === 'HEAD') {
|
|
34
|
+
throw new Error(
|
|
35
|
+
'Detached HEAD: git reports no branch, and no CI branch variable is set. ' +
|
|
36
|
+
'Pass --branch explicitly (in GitHub Actions: --branch "${{ github.head_ref || github.ref_name }}").'
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
return 'main' // not a git repo at all — the historical default
|
|
14
40
|
}
|
|
15
41
|
|
|
16
|
-
export async function uploadDeploy(client: BodClient, appId: string, branch: string) {
|
|
42
|
+
export async function uploadDeploy(client: BodClient, appId: string, branch: string): Promise<string | undefined> {
|
|
17
43
|
const allExcludes = resolveExcludes(readExcludesFromYaml())
|
|
18
44
|
const excludeFlags = allExcludes.map(e => `--exclude=${e}`)
|
|
19
45
|
|
|
@@ -121,7 +147,7 @@ export async function uploadDeploy(client: BodClient, appId: string, branch: str
|
|
|
121
147
|
console.log(chalk.dim(`Uploading ${formatSize(size)}...`))
|
|
122
148
|
|
|
123
149
|
const start = Date.now()
|
|
124
|
-
await client.upload<any>(
|
|
150
|
+
const res = await client.upload<any>(
|
|
125
151
|
`/apps/${appId}/deploy/upload`,
|
|
126
152
|
file.stream(),
|
|
127
153
|
{ 'x-branch': branch },
|
|
@@ -131,10 +157,12 @@ export async function uploadDeploy(client: BodClient, appId: string, branch: str
|
|
|
131
157
|
|
|
132
158
|
// Cleanup temp file
|
|
133
159
|
try { (await import('fs')).unlinkSync(tmpFile) } catch {}
|
|
160
|
+
return typeof res?.sha === 'string' ? res.sha : undefined
|
|
134
161
|
}
|
|
135
162
|
|
|
136
|
-
async function gitDeploy(client: BodClient, appId: string, branch: string) {
|
|
137
|
-
await client.post<any>(`/apps/${appId}/deploy`, { branch })
|
|
163
|
+
async function gitDeploy(client: BodClient, appId: string, branch: string): Promise<string | undefined> {
|
|
164
|
+
const res = await client.post<any>(`/apps/${appId}/deploy`, { branch })
|
|
165
|
+
return typeof res?.sha === 'string' ? res.sha : undefined
|
|
138
166
|
}
|
|
139
167
|
|
|
140
168
|
function branchToSubdomain(branch: string, domain: string, productionBranch: string): string {
|
|
@@ -178,7 +206,29 @@ async function getWithRetry<T>(client: BodClient, path: string, attempts = 4): P
|
|
|
178
206
|
throw lastErr
|
|
179
207
|
}
|
|
180
208
|
|
|
181
|
-
|
|
209
|
+
/**
|
|
210
|
+
* THE deployment this command created — never merely "the newest one on the app".
|
|
211
|
+
*
|
|
212
|
+
* The poll used to take the first deployment newer than the command's start time. Apps are
|
|
213
|
+
* deployed CONCURRENTLY (a CI run on main while a developer pushes a branch preview), so that
|
|
214
|
+
* picked up a stranger's deployment and reported it as this command's result. On 2026-09-05 a
|
|
215
|
+
* `bod deploy --upload --branch feat/…` printed "✓ Deployment live! → https://blank.bod.ee"
|
|
216
|
+
* because a GitHub Actions deploy of main landed 3 seconds earlier — and the branch deploy was
|
|
217
|
+
* then blamed for a production release it never made, and prod was "rolled back" over it.
|
|
218
|
+
*
|
|
219
|
+
* The server answers every deploy with the `sha` it minted for that job, so match on that.
|
|
220
|
+
* Without a sha (an older server) fall back to the BRANCH, which at least cannot cross the
|
|
221
|
+
* preview/production line — and never to "whatever is newest".
|
|
222
|
+
*/
|
|
223
|
+
export function selectOwnDeployment(
|
|
224
|
+
deployments: any[],
|
|
225
|
+
own: { sha?: string; branch: string; since: number },
|
|
226
|
+
): any | undefined {
|
|
227
|
+
if (own.sha) return deployments.find((d: any) => d.sha === own.sha)
|
|
228
|
+
return deployments.find((d: any) => d.branch === own.branch && (d.createdAt ?? 0) >= own.since)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function pollDeploy(client: BodClient, appId: string, since: number, own: { sha?: string; branch: string }, instanceCaps?: { baseDomain?: string | null }, localConfig?: { localDomain?: string; caddyPort?: number } | null) {
|
|
182
232
|
console.log(chalk.dim('Waiting for deployment...'))
|
|
183
233
|
let lastStatus = ''
|
|
184
234
|
let lastLogTs = since
|
|
@@ -198,7 +248,7 @@ async function pollDeploy(client: BodClient, appId: string, since: number, insta
|
|
|
198
248
|
return
|
|
199
249
|
}
|
|
200
250
|
const deps = detail.deployments ?? []
|
|
201
|
-
const latest = deps
|
|
251
|
+
const latest = selectOwnDeployment(deps, { sha: own.sha, branch: own.branch, since })
|
|
202
252
|
if (!latest) continue
|
|
203
253
|
const status = latest.status
|
|
204
254
|
if (status !== lastStatus) {
|
|
@@ -270,6 +320,7 @@ export default defineCommand({
|
|
|
270
320
|
app: { type: 'positional', description: 'App name (or reads from bodify.yaml)', required: false },
|
|
271
321
|
branch: { type: 'string', description: 'Branch to deploy (default: current git branch)' },
|
|
272
322
|
upload: { type: 'boolean', description: 'Upload local source instead of git clone', default: false },
|
|
323
|
+
yes: { type: 'boolean', alias: 'y', description: 'Skip the confirmation for a production deploy that was not asked for by name', default: false },
|
|
273
324
|
},
|
|
274
325
|
async run({ args }) {
|
|
275
326
|
const { url, apiKey, name: instanceName, instance } = getResolvedInstance(loadConfig(), readInstanceFromYaml())
|
|
@@ -294,6 +345,31 @@ export default defineCommand({
|
|
|
294
345
|
await client.put(`/apps/${appId}`, yamlConfig).catch(() => {})
|
|
295
346
|
}
|
|
296
347
|
|
|
348
|
+
// Publishing the LIVE SITE is never a side effect of forgetting a flag. Without `--branch`
|
|
349
|
+
// the branch comes from whatever this checkout happens to be on, so `bod deploy` in a main
|
|
350
|
+
// checkout silently ships production — the same one-word difference that put an unreviewed
|
|
351
|
+
// branch on blank.bod.ee on 2026-09-05. Naming the production branch (`--branch main`, which
|
|
352
|
+
// is what CI does) is an explicit act and passes straight through; guessing it is not.
|
|
353
|
+
const productionBranch = detail.productionBranch ?? 'main'
|
|
354
|
+
if (!args.branch && branch === productionBranch) {
|
|
355
|
+
const cmd = `bod deploy${args.app ? ` ${args.app}` : ''}${args.upload ? ' --upload' : ''} --branch ${productionBranch}`
|
|
356
|
+
if (!args.yes && !process.stdout.isTTY) {
|
|
357
|
+
console.error(chalk.red(`Refusing to deploy PRODUCTION (branch "${productionBranch}") without being asked to.`))
|
|
358
|
+
console.error(chalk.red(`This checkout is on "${branch}", which is ${appName}'s production branch, and no --branch was given.`))
|
|
359
|
+
console.error(`Run: ${cmd}`)
|
|
360
|
+
console.error(` or: deploy a preview instead, e.g. --branch <your-branch>`)
|
|
361
|
+
process.exit(1)
|
|
362
|
+
}
|
|
363
|
+
if (!args.yes) {
|
|
364
|
+
const { confirm } = await import('@inquirer/prompts')
|
|
365
|
+
const ok = await confirm({
|
|
366
|
+
message: `Deploy PRODUCTION for "${appName}" (branch ${productionBranch})? No --branch was given.`,
|
|
367
|
+
default: false,
|
|
368
|
+
})
|
|
369
|
+
if (!ok) { console.log(chalk.yellow('Aborted.')); return }
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
297
373
|
const forceUpload = args.upload || readDeployModeFromYaml() === 'upload'
|
|
298
374
|
|
|
299
375
|
let hasRepo = !!detail.repo
|
|
@@ -310,13 +386,11 @@ export default defineCommand({
|
|
|
310
386
|
console.log(chalk.dim(`Deploying ${appName} (branch: ${branch})${useUpload ? ' via upload' : ''}...`))
|
|
311
387
|
|
|
312
388
|
const since = Date.now() - 5000 // buffer for clock skew
|
|
313
|
-
|
|
314
|
-
await uploadDeploy(client, appId, branch)
|
|
315
|
-
|
|
316
|
-
await gitDeploy(client, appId, branch)
|
|
317
|
-
}
|
|
389
|
+
const sha = useUpload
|
|
390
|
+
? await uploadDeploy(client, appId, branch)
|
|
391
|
+
: await gitDeploy(client, appId, branch)
|
|
318
392
|
|
|
319
393
|
console.log(chalk.green(`✓ Deployment queued`))
|
|
320
|
-
await pollDeploy(client, appId, since, instance.capabilities, localConfig)
|
|
394
|
+
await pollDeploy(client, appId, since, { sha, branch }, instance.capabilities, localConfig)
|
|
321
395
|
},
|
|
322
396
|
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TWO WAYS `bod deploy` TOLD THE OPERATOR SOMETHING UNTRUE ABOUT PRODUCTION (2026-09-05).
|
|
3
|
+
*
|
|
4
|
+
* 1. ATTRIBUTION. The poll reported "the newest deployment on this app", not the one this
|
|
5
|
+
* command created. A GitHub Actions deploy of `main` landed 3 seconds inside the window of a
|
|
6
|
+
* `bod deploy --upload --branch feat/…`, so the branch deploy printed
|
|
7
|
+
* "✓ Deployment live! → https://blank.bod.ee" — the production URL, for a deployment it had
|
|
8
|
+
* not made. The branch flag was blamed for a production release, and prod was then "rolled
|
|
9
|
+
* back" over CI's legitimate build. Nothing was wrong with the branch header at all.
|
|
10
|
+
*
|
|
11
|
+
* 2. AN UNASKED PRODUCTION TARGET. With no `--branch`, the branch is whatever the checkout is
|
|
12
|
+
* on — so a bare `bod deploy` in a main checkout publishes the live site as a side effect of
|
|
13
|
+
* a missing flag.
|
|
14
|
+
*
|
|
15
|
+
* Both are asserted through the REAL CLI against a stub control plane: the assertions are the
|
|
16
|
+
* bytes the operator reads and the requests the server actually receives.
|
|
17
|
+
*/
|
|
18
|
+
import { test, expect, afterAll } from 'bun:test'
|
|
19
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
|
|
20
|
+
import { tmpdir } from 'os'
|
|
21
|
+
import { join } from 'path'
|
|
22
|
+
import { selectOwnDeployment } from '../src/commands/deploy'
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------- unit: the selector itself
|
|
25
|
+
|
|
26
|
+
const OURS = { sha: 'upload-dc524d78', branch: 'feat/onboarding', createdAt: 2_000, status: 'live' }
|
|
27
|
+
const STRANGER = { sha: 'upload-d95087b2', branch: 'main', createdAt: 3_000, status: 'live' } // CI, mid-flight
|
|
28
|
+
|
|
29
|
+
test('the poll picks THIS deploy, not a concurrent production deploy that landed later', () => {
|
|
30
|
+
const deps = [STRANGER, OURS] // server returns newest-first
|
|
31
|
+
expect(selectOwnDeployment(deps, { sha: OURS.sha, branch: OURS.branch, since: 1_000 })?.branch)
|
|
32
|
+
.toBe('feat/onboarding')
|
|
33
|
+
// Non-vacuous: the rule this replaced ("first one newer than `since`") really did pick main.
|
|
34
|
+
expect(deps.find(d => d.createdAt >= 1_000)?.branch).toBe('main')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('without a sha it still cannot cross the preview/production line', () => {
|
|
38
|
+
expect(selectOwnDeployment([STRANGER], { branch: 'feat/onboarding', since: 1_000 })).toBeUndefined()
|
|
39
|
+
expect(selectOwnDeployment([STRANGER, OURS], { branch: 'feat/onboarding', since: 1_000 })?.sha).toBe(OURS.sha)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
// ------------------------------------------------------- end-to-end: the real CLI, real bytes
|
|
43
|
+
|
|
44
|
+
const CLI = join(import.meta.dir, '../src/cli.ts')
|
|
45
|
+
const roots: string[] = []
|
|
46
|
+
afterAll(() => { for (const r of roots) rmSync(r, { recursive: true, force: true }) })
|
|
47
|
+
|
|
48
|
+
/** A checkout on `branch`, with a HOME holding a bod config pointing at `url`. */
|
|
49
|
+
function workspace(branch: string, url: string) {
|
|
50
|
+
const root = mkdtempSync(join(tmpdir(), 'bod-attr-'))
|
|
51
|
+
roots.push(root)
|
|
52
|
+
const app = join(root, 'app')
|
|
53
|
+
mkdirSync(app)
|
|
54
|
+
writeFileSync(join(app, 'package.json'), JSON.stringify({ name: 'stubapp' }))
|
|
55
|
+
writeFileSync(join(app, 'bodify.yaml'), 'name: stubapp\ndeploy: upload\n')
|
|
56
|
+
for (const a of [['init', '-q', '-b', branch], ['add', '-A'], ['-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'x']]) {
|
|
57
|
+
Bun.spawnSync(['git', ...a], { cwd: app })
|
|
58
|
+
}
|
|
59
|
+
mkdirSync(join(root, 'home', '.bod'), { recursive: true })
|
|
60
|
+
writeFileSync(join(root, 'home', '.bod', 'config.json'), JSON.stringify({
|
|
61
|
+
instances: { stub: { url, apiKey: 'k', capabilities: { registryEnabled: false, baseDomain: 'stub.test', githubConfigured: false } } },
|
|
62
|
+
defaultInstance: 'stub',
|
|
63
|
+
}))
|
|
64
|
+
return { app, home: join(root, 'home') }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Async on purpose: `spawnSync` would block this process's event loop, and the stub control
|
|
68
|
+
* plane lives in it — a synchronous spawn deadlocks against its own server. */
|
|
69
|
+
async function runCli(args: string[], ws: { app: string; home: string }) {
|
|
70
|
+
const p = Bun.spawn(['bun', CLI, ...args], {
|
|
71
|
+
cwd: ws.app,
|
|
72
|
+
env: { ...process.env, HOME: ws.home, NO_COLOR: '1', FORCE_COLOR: '0' },
|
|
73
|
+
stdout: 'pipe', stderr: 'pipe',
|
|
74
|
+
})
|
|
75
|
+
const [out, err] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()])
|
|
76
|
+
return { code: await p.exited, out: out + err }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test('a branch deploy reports ITS OWN deployment while a production deploy lands beside it', async () => {
|
|
80
|
+
const received: { branch: string | null }[] = []
|
|
81
|
+
// The stub answers every poll with BOTH deployments live: ours (feat) and a stranger's (main,
|
|
82
|
+
// created later) — the exact shape of the 2026-09-05 race.
|
|
83
|
+
const server = Bun.serve({
|
|
84
|
+
port: 0,
|
|
85
|
+
async fetch(req) {
|
|
86
|
+
const path = new URL(req.url).pathname
|
|
87
|
+
if (path === '/api/apps') return Response.json([{ id: 'a1', name: 'stubapp' }])
|
|
88
|
+
if (path === '/api/apps/a1/deploy/upload') {
|
|
89
|
+
received.push({ branch: req.headers.get('x-branch') })
|
|
90
|
+
await req.arrayBuffer()
|
|
91
|
+
return Response.json({ queued: true, sha: OURS.sha }, { status: 202 })
|
|
92
|
+
}
|
|
93
|
+
if (path === '/api/apps/a1') {
|
|
94
|
+
return Response.json({
|
|
95
|
+
id: 'a1', name: 'stubapp', domain: 'stubapp.stub.test', productionBranch: 'main',
|
|
96
|
+
deployments: [
|
|
97
|
+
{ ...STRANGER, createdAt: Date.now() },
|
|
98
|
+
{ ...OURS, createdAt: Date.now() - 1000 },
|
|
99
|
+
],
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
return Response.json([])
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
try {
|
|
106
|
+
const ws = workspace('feat/onboarding', `http://localhost:${server.port}`)
|
|
107
|
+
const { code, out } = await runCli(['deploy', '--upload', '--branch', 'feat/onboarding'], ws)
|
|
108
|
+
expect(received).toEqual([{ branch: 'feat/onboarding' }])
|
|
109
|
+
expect(code).toBe(0)
|
|
110
|
+
expect(out).toContain('Deployment live!')
|
|
111
|
+
// The operator must read the PREVIEW host, never the production domain of someone else's deploy.
|
|
112
|
+
expect(out).toContain('feat-onboarding--stubapp.stub.test')
|
|
113
|
+
expect(out).toContain('Preview deployment (branch: feat/onboarding)')
|
|
114
|
+
expect(out).not.toContain('https://stubapp.stub.test')
|
|
115
|
+
} finally { server.stop(true) }
|
|
116
|
+
}, 60_000)
|
|
117
|
+
|
|
118
|
+
test('a bare deploy from a production checkout refuses instead of publishing the live site', async () => {
|
|
119
|
+
const uploads: unknown[] = []
|
|
120
|
+
const server = Bun.serve({
|
|
121
|
+
port: 0,
|
|
122
|
+
async fetch(req) {
|
|
123
|
+
const path = new URL(req.url).pathname
|
|
124
|
+
if (path === '/api/apps') return Response.json([{ id: 'a1', name: 'stubapp' }])
|
|
125
|
+
if (path.endsWith('/deploy/upload')) { uploads.push(1); return Response.json({ queued: true, sha: 'x' }, { status: 202 }) }
|
|
126
|
+
if (path === '/api/apps/a1') return Response.json({
|
|
127
|
+
id: 'a1', name: 'stubapp', domain: 'stubapp.stub.test', productionBranch: 'main',
|
|
128
|
+
deployments: uploads.length ? [{ sha: 'x', branch: 'main', createdAt: Date.now(), status: 'live' }] : [],
|
|
129
|
+
})
|
|
130
|
+
return Response.json([])
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
try {
|
|
134
|
+
const ws = workspace('main', `http://localhost:${server.port}`)
|
|
135
|
+
const { code, out } = await runCli(['deploy', '--upload'], ws)
|
|
136
|
+
expect(code).toBe(1)
|
|
137
|
+
expect(out).toContain('Refusing to deploy PRODUCTION')
|
|
138
|
+
expect(out).toContain('--branch main') // the resolved command, not an instruction to go think
|
|
139
|
+
expect(uploads).toEqual([]) // nothing was published
|
|
140
|
+
|
|
141
|
+
// Naming it is an explicit act and still deploys — the guard is not a blanket refusal.
|
|
142
|
+
const named = await runCli(['deploy', '--upload', '--branch', 'main'], ws)
|
|
143
|
+
expect(uploads.length).toBe(1)
|
|
144
|
+
expect(named.out).toContain('Deployment queued')
|
|
145
|
+
} finally { server.stop(true) }
|
|
146
|
+
}, 60_000)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, rmSync } from 'fs'
|
|
3
|
+
import { tmpdir } from 'os'
|
|
4
|
+
import { join } from 'path'
|
|
5
|
+
import { detectBranch } from '../src/commands/deploy'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A DETACHED HEAD IS NOT A BRANCH CALLED "HEAD".
|
|
9
|
+
*
|
|
10
|
+
* `git rev-parse --abbrev-ref HEAD` prints the literal string `HEAD` in a detached
|
|
11
|
+
* checkout — every GitHub Actions run, and any detached worktree. `bod deploy` used to
|
|
12
|
+
* send that as the branch, and the platform obligingly created a real deployment, a live
|
|
13
|
+
* `head--<app>` host, DNS and a Caddy route for it. Three existed on the blank app on
|
|
14
|
+
* 2026-09-05.
|
|
15
|
+
*
|
|
16
|
+
* This uses a REAL git repo in a real detached state, not a stubbed `git` — the whole bug
|
|
17
|
+
* lives in what git actually prints.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
let dir: string
|
|
21
|
+
let origCwd: string
|
|
22
|
+
const git = (...args: string[]) => Bun.spawnSync(['git', ...args], { cwd: dir })
|
|
23
|
+
|
|
24
|
+
beforeAll(() => {
|
|
25
|
+
origCwd = process.cwd()
|
|
26
|
+
dir = mkdtempSync(join(tmpdir(), 'bod-detect-branch-'))
|
|
27
|
+
git('init', '-q', '-b', 'feature-x')
|
|
28
|
+
git('config', 'user.email', 't@t.t'); git('config', 'user.name', 't')
|
|
29
|
+
Bun.spawnSync(['touch', join(dir, 'f')])
|
|
30
|
+
git('add', '.'); git('commit', '-qm', 'c1')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
afterAll(() => { process.chdir(origCwd); rmSync(dir, { recursive: true, force: true }) })
|
|
34
|
+
|
|
35
|
+
const withEnv = async (env: Record<string, string | undefined>, fn: () => Promise<unknown>) => {
|
|
36
|
+
const saved: Record<string, string | undefined> = {}
|
|
37
|
+
for (const k of Object.keys(env)) { saved[k] = process.env[k]; if (env[k] === undefined) delete process.env[k]; else process.env[k] = env[k]! }
|
|
38
|
+
try { return await fn() } finally {
|
|
39
|
+
for (const k of Object.keys(saved)) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]! }
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const noCi = { GITHUB_HEAD_REF: undefined, GITHUB_REF_NAME: undefined }
|
|
43
|
+
|
|
44
|
+
describe('detectBranch', () => {
|
|
45
|
+
test('an attached checkout reports its real branch', async () => {
|
|
46
|
+
process.chdir(dir)
|
|
47
|
+
expect(await withEnv(noCi, () => detectBranch())).toBe('feature-x')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test('an explicit branch always wins', async () => {
|
|
51
|
+
process.chdir(dir)
|
|
52
|
+
expect(await withEnv(noCi, () => detectBranch('release'))).toBe('release')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('a DETACHED checkout refuses instead of deploying a branch named HEAD', async () => {
|
|
56
|
+
process.chdir(dir)
|
|
57
|
+
const sha = Bun.spawnSync(['git', 'rev-parse', 'HEAD'], { cwd: dir }).stdout.toString().trim()
|
|
58
|
+
git('checkout', '-q', sha)
|
|
59
|
+
// Prove the rig: git really does print the literal string here.
|
|
60
|
+
expect(Bun.spawnSync(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: dir }).stdout.toString().trim()).toBe('HEAD')
|
|
61
|
+
|
|
62
|
+
await expect(withEnv(noCi, () => detectBranch())).rejects.toThrow(/Detached HEAD/)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('...but a GitHub Actions run resolves from its own env instead of failing', async () => {
|
|
66
|
+
process.chdir(dir) // still detached from the previous test
|
|
67
|
+
expect(await withEnv({ ...noCi, GITHUB_REF_NAME: 'main' }, () => detectBranch())).toBe('main')
|
|
68
|
+
// A pull_request run: head_ref is the source branch and wins over the merge ref.
|
|
69
|
+
expect(await withEnv({ GITHUB_HEAD_REF: 'feat/pr', GITHUB_REF_NAME: '123/merge' }, () => detectBranch())).toBe('feat/pr')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('a CI variable that is itself HEAD is not accepted either', async () => {
|
|
73
|
+
process.chdir(dir)
|
|
74
|
+
await expect(withEnv({ ...noCi, GITHUB_REF_NAME: 'HEAD' }, () => detectBranch())).rejects.toThrow(/Detached HEAD/)
|
|
75
|
+
})
|
|
76
|
+
})
|