bod-cli 0.10.10 → 0.10.11
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 +28 -2
- package/test/detect-branch.test.ts +76 -0
package/package.json
CHANGED
package/src/commands/deploy.ts
CHANGED
|
@@ -6,11 +6,37 @@ 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
42
|
export async function uploadDeploy(client: BodClient, appId: string, branch: string) {
|
|
@@ -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
|
+
})
|