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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bod-cli",
3
- "version": "0.10.10",
3
+ "version": "0.10.11",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "bod": "./src/cli.ts"
@@ -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
- async function detectBranch(explicit?: string): Promise<string> {
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
- return branch || 'main'
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
+ })