@bongos/core 1.19.882 → 1.19.884
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/.bongos-core.json +63 -58
- package/clients/bongos-client/index.d.ts +3 -3
- package/docs/adr/0309-durable-module-enablement-db-override.md +116 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +10 -2
- package/docs/copy-inventory.md +24 -23
- package/docs/copy-registry.json +34 -25
- package/docs/file-map.md +1 -2
- package/docs/module-api-changelog.md +5 -1
- package/docs/recipes/bongos-cli-release.md +1 -1
- package/docs/recipes/managed-settings-remote-control.md +1 -1
- package/migrations/core_248_module_enablement_overrides.sql +50 -0
- package/modules/dev-box/app/README.md +4 -3
- package/modules/hall-ui/public/collab-lib.js +33 -1
- package/modules/hall-ui/public/collab.css +33 -4
- package/modules/hall-ui/public/collab.html +9 -2
- package/modules/hall-ui/public/collab.js +68 -15
- package/modules/hall-ui/public/dom-utils.js +23 -1
- package/modules/lifecycle/routes/tasks.js +18 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/ship-land.js +103 -14
- package/scripts/gds/ship-merge.js +28 -0
- package/scripts/gds/ship.js +8 -0
- package/scripts/hall-preview/server.js +38 -1
- package/src/bongos/module-overrides.js +117 -0
- package/src/bongos/routes/modules.js +56 -23
- package/src/module-api.js +1 -1
- package/src/modules.js +120 -14
- package/src/platform-server.js +47 -5
- package/tests/box_sweep_docs.mjs +7 -3
- package/tests/collab_page.mjs +136 -1
- package/tests/modules.mjs +110 -0
- package/tests/modules_route_wiring.mjs +103 -0
- package/tests/publish_branch_route.mjs +70 -2
- package/tests/ship_land_proof.mjs +218 -0
- package/docs/recipes/builder-box-lifecycle.md +0 -181
- package/docs/recipes/devbox-app-release.md +0 -103
- package/docs/recipes/devbox-app-windows-test.md +0 -128
- package/docs/recipes/work-on-a-task-from-your-dev-box.md +0 -122
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// tests/ship_land_proof.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1004055 — the ship that merges to main and then refuses to record it.
|
|
4
|
+
//
|
|
5
|
+
// ship.js has three land paths. Two of them tell the server which commit is this
|
|
6
|
+
// task's: ciLandLocal posts attest-gate after its push, ciLandServer gets it from
|
|
7
|
+
// publish-branch. The LOCAL-merge path (mergeLocally, 'laptop' deploy mode) merged
|
|
8
|
+
// into main and pushed it ITSELF, and told the server nothing — so
|
|
9
|
+
// claims.published_head_sha stayed NULL. It also opens no PR and deletes the branch
|
|
10
|
+
// on origin, so getPublishStatus finds nothing by head ref either. That left
|
|
11
|
+
// deriveShipAssurance with neither of its two proofs, and the POST /ship three lines
|
|
12
|
+
// later refused with ship_land_not_proven EVERY time, with the work already merged.
|
|
13
|
+
// The task parked at 'confirmed', gating its builder's whole queue, until someone ran
|
|
14
|
+
// attest-gate + ship by hand. Five consecutive ships on 2026-09-19; twelve refusals
|
|
15
|
+
// in that day's audit log.
|
|
16
|
+
//
|
|
17
|
+
// What is pinned here:
|
|
18
|
+
// 1. attestGateTrust REPORTS whether the tip landed, and never throws
|
|
19
|
+
// 2. landProofRecorded's truth table — including the pinned-older-core fallback
|
|
20
|
+
// 3. the warning is in the right TENSE for the path that emits it
|
|
21
|
+
// 4. the local-merge path records a tip, before it deletes the branch
|
|
22
|
+
//
|
|
23
|
+
// DB-free and network-free: the only collaborator attestGateTrust has is cli-lib's
|
|
24
|
+
// cliClient, stubbed through the require cache below. Nothing here spawns git, gh,
|
|
25
|
+
// or a server.
|
|
26
|
+
//
|
|
27
|
+
// Run: node tests/ship_land_proof.mjs
|
|
28
|
+
|
|
29
|
+
import { strict as assert } from 'node:assert';
|
|
30
|
+
import { createRequire } from 'node:module';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import { fileURLToPath } from 'node:url';
|
|
33
|
+
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
|
|
36
|
+
// The stub MUST be in place before ship-land.js is first required — it destructures
|
|
37
|
+
// cliClient at module load.
|
|
38
|
+
let nextAttest = null;
|
|
39
|
+
const cliLibPath = require.resolve('../scripts/gds/cli-lib');
|
|
40
|
+
require.cache[cliLibPath] = {
|
|
41
|
+
id: cliLibPath,
|
|
42
|
+
filename: cliLibPath,
|
|
43
|
+
loaded: true,
|
|
44
|
+
exports: {
|
|
45
|
+
cliClient: async () => ({
|
|
46
|
+
tasks: {
|
|
47
|
+
postTasksIdAttestGate: async ({ id, body }) => {
|
|
48
|
+
if (typeof nextAttest === 'function') return nextAttest({ id, body });
|
|
49
|
+
return nextAttest;
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
}),
|
|
53
|
+
isBoxScope: () => false,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const { attestGateTrust, landProofRecorded, landProofWarningLines } = require('../scripts/gds/ship-land.js');
|
|
58
|
+
|
|
59
|
+
let passed = 0;
|
|
60
|
+
let failed = 0;
|
|
61
|
+
async function t(name, fn) {
|
|
62
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
63
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// attestGateTrust narrates to stdout; swallow that so the test output stays readable.
|
|
67
|
+
async function quietly(fn) {
|
|
68
|
+
const real = console.log;
|
|
69
|
+
const said = [];
|
|
70
|
+
console.log = (...a) => said.push(a.join(' '));
|
|
71
|
+
try { return { value: await fn(), said: said.join('\n') }; }
|
|
72
|
+
finally { console.log = real; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const MERGE_SHA = '5c4db10e2a1f4c6b8d3e9f70a1b2c3d4e5f60718';
|
|
76
|
+
|
|
77
|
+
// --- 1. attestGateTrust reports, and never throws -----------------------------
|
|
78
|
+
|
|
79
|
+
await t('a 200 carrying tip_recorded:true → recorded, and the sha is what we sent', async () => {
|
|
80
|
+
let seen = null;
|
|
81
|
+
nextAttest = ({ id, body }) => {
|
|
82
|
+
seen = { id, body };
|
|
83
|
+
return { ok: true, status: 200, data: { ok: true, attested: true, reason: null, tip_recorded: true } };
|
|
84
|
+
};
|
|
85
|
+
const { value } = await quietly(() => attestGateTrust(1004055, MERGE_SHA));
|
|
86
|
+
assert.equal(value.recorded, true);
|
|
87
|
+
assert.equal(value.attested, true);
|
|
88
|
+
assert.deepEqual(seen, { id: 1004055, body: { head_sha: MERGE_SHA } });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// The case the field exists for: the attestation succeeded, so the old code called
|
|
92
|
+
// the whole thing a success — but no claim row matched, so nothing was recorded and
|
|
93
|
+
// the ship was already doomed.
|
|
94
|
+
await t('a 200 whose claim lookup matched nothing → NOT recorded, even though attested', async () => {
|
|
95
|
+
nextAttest = { ok: true, status: 200, data: { ok: true, attested: true, reason: null, tip_recorded: false } };
|
|
96
|
+
const { value } = await quietly(() => attestGateTrust(1004055, MERGE_SHA));
|
|
97
|
+
assert.equal(value.attested, true, 'the gate attestation did succeed');
|
|
98
|
+
assert.equal(value.recorded, false, 'but the land proof did not land');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await t('a 503 push_unconfigured still recorded the tip (the write precedes the credential check)', async () => {
|
|
102
|
+
nextAttest = { ok: false, status: 503, data: { error: { code: 'push_unconfigured', details: { tip_recorded: true } } } };
|
|
103
|
+
const { value } = await quietly(() => attestGateTrust(1004055, MERGE_SHA));
|
|
104
|
+
assert.equal(value.recorded, true);
|
|
105
|
+
assert.equal(value.attested, false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
await t('a 403 never reached the write → NOT recorded', async () => {
|
|
109
|
+
nextAttest = { ok: false, status: 403, data: { error: { code: 'not_your_task' } } };
|
|
110
|
+
const { value } = await quietly(() => attestGateTrust(1004055, MERGE_SHA));
|
|
111
|
+
assert.equal(value.recorded, false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// The quietest of the old failure shapes: a bare `catch (_) {}` left no trace at all.
|
|
115
|
+
await t('a thrown request is reported, not swallowed, and never propagates', async () => {
|
|
116
|
+
nextAttest = () => { throw new Error('fetch failed'); };
|
|
117
|
+
const { value, said } = await quietly(() => attestGateTrust(1004055, MERGE_SHA));
|
|
118
|
+
assert.equal(value.recorded, false);
|
|
119
|
+
assert.match(value.reason, /fetch failed/);
|
|
120
|
+
assert.match(said, /fetch failed/, 'the failure must reach the log too, not only the return value');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await t('no head SHA → not recorded, and no request is attempted', async () => {
|
|
124
|
+
nextAttest = () => { throw new Error('should not have been called'); };
|
|
125
|
+
const { value } = await quietly(() => attestGateTrust(1004055, null));
|
|
126
|
+
assert.equal(value.recorded, false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// --- 2. landProofRecorded: the truth table ------------------------------------
|
|
130
|
+
|
|
131
|
+
await t('explicit tip_recorded wins over any status reading', () => {
|
|
132
|
+
assert.equal(landProofRecorded({ ok: true, status: 200, data: { tip_recorded: true } }), true);
|
|
133
|
+
assert.equal(landProofRecorded({ ok: true, status: 200, data: { tip_recorded: false } }), false);
|
|
134
|
+
assert.equal(landProofRecorded({ ok: false, status: 503, data: { error: { details: { tip_recorded: false } } } }), false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// A live instance runs a PINNED core, so it lags main by however long the pin sits
|
|
138
|
+
// still — cloudbongos.com was serving 1.19.871 against a main declaring 1.19.881 on
|
|
139
|
+
// the day this was diagnosed. Those servers send no tip_recorded at all, and reading
|
|
140
|
+
// its absence as "not recorded" would make every healthy land cry wolf.
|
|
141
|
+
await t('older core, no tip_recorded: 200 and 503 both mean the tip landed', () => {
|
|
142
|
+
assert.equal(landProofRecorded({ ok: true, status: 200, data: { ok: true, attested: false, reason: 'rank_below_metic' } }), true);
|
|
143
|
+
assert.equal(landProofRecorded({ ok: false, status: 503, data: { error: { code: 'push_unconfigured' } } }), true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await t('older core: every other status → NOT recorded', () => {
|
|
147
|
+
for (const status of [400, 403, 404, 409, 500, 502]) {
|
|
148
|
+
assert.equal(
|
|
149
|
+
landProofRecorded({ ok: false, status, data: { error: { code: 'not_your_task' } } }),
|
|
150
|
+
false,
|
|
151
|
+
`HTTP ${status} must not count as a recorded tip`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await t('no response at all → NOT recorded', () => {
|
|
157
|
+
assert.equal(landProofRecorded(null), false);
|
|
158
|
+
assert.equal(landProofRecorded(undefined), false);
|
|
159
|
+
assert.equal(landProofRecorded({}), false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// --- 3. the warning is in the right tense -------------------------------------
|
|
163
|
+
|
|
164
|
+
await t('merged path: names the merge sha and gives the full two-call recovery', () => {
|
|
165
|
+
const lines = landProofWarningLines(1004055, MERGE_SHA).join('\n');
|
|
166
|
+
assert.match(lines, /NOT recorded/);
|
|
167
|
+
assert.match(lines, /merge IS on main/);
|
|
168
|
+
assert.ok(lines.includes(`"head_sha":"${MERGE_SHA}"`), 'must name the exact sha to re-record');
|
|
169
|
+
assert.match(lines, /POST \/tasks\/1004055\/attest-gate/);
|
|
170
|
+
assert.match(lines, /POST \/tasks\/1004055\/ship/);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// The PR path has only PUSHED — the land may still be minutes out, and telling a
|
|
174
|
+
// builder their merge is on main when it is not is the same class of lie this whole
|
|
175
|
+
// task is about.
|
|
176
|
+
await t('un-merged path: does not claim the work is on main, and does not ship blind', () => {
|
|
177
|
+
const lines = landProofWarningLines(1004055, MERGE_SHA, 'HTTP 403', { merged: false }).join('\n');
|
|
178
|
+
assert.doesNotMatch(lines, /merge IS on main/);
|
|
179
|
+
assert.match(lines, /the PR still lands normally/);
|
|
180
|
+
assert.ok(!lines.includes('/ship --body'), 'must not hand over a ship call before anything merged');
|
|
181
|
+
assert.match(lines, /ship\.js 1004055/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
await t('the reason is surfaced, and a missing sha degrades to a placeholder', () => {
|
|
185
|
+
assert.match(landProofWarningLines(7, MERGE_SHA, 'HTTP 403').join('\n'), /HTTP 403/);
|
|
186
|
+
assert.match(landProofWarningLines(7, null).join('\n'), /<the merge commit on main>/);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// --- 4. the local-merge path records a tip at all -----------------------------
|
|
190
|
+
//
|
|
191
|
+
// mergeLocally has no dependency seam — it merges a real second checkout, runs smoke
|
|
192
|
+
// and pushes main — so this is asserted against the source. Weaker than driving it,
|
|
193
|
+
// but it is exactly the assertion that failed before the fix: the file contained no
|
|
194
|
+
// reference to ANY of the three ways to record a tip, and the order of the two calls
|
|
195
|
+
// is the other half of the bug (deleting the branch first destroys the only evidence
|
|
196
|
+
// a later sweep could have used).
|
|
197
|
+
|
|
198
|
+
const mergeSrc = fs.readFileSync(fileURLToPath(new URL('../scripts/gds/ship-merge.js', import.meta.url)), 'utf8');
|
|
199
|
+
|
|
200
|
+
await t('ship-merge.js records the merge commit as this task\'s land proof', () => {
|
|
201
|
+
assert.match(mergeSrc, /attestGateTrust\(taskId, mergeSha\)/,
|
|
202
|
+
'the local-merge path must record a land proof — before the fix it recorded none');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await t('the proof is recorded BEFORE the origin branch is deleted', () => {
|
|
206
|
+
const attestAt = mergeSrc.indexOf('attestGateTrust(taskId, mergeSha)');
|
|
207
|
+
const deleteAt = mergeSrc.indexOf("'--delete'");
|
|
208
|
+
assert.ok(attestAt > 0, 'the attest call must be present');
|
|
209
|
+
assert.ok(deleteAt > 0, 'the branch delete must be present');
|
|
210
|
+
assert.ok(attestAt < deleteAt, 'the land proof must be recorded before the branch is deleted');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
await t('a miss reaches the builder at the merge, not as an opaque 409 later', () => {
|
|
214
|
+
assert.match(mergeSrc, /landProofWarningLines\(taskId, mergeSha, proof\.reason\)/);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
218
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -1,181 +0,0 @@
|
|
|
1
|
-
# Recipe — per-builder dev box lifecycle (provision · auto-suspend · reclaim)
|
|
2
|
-
|
|
3
|
-
> **Task [#598](https://example.com/builders#/task/598) · [ADR 0031](../adr/0031-cloud-dev-environments-for-builders.md) §2/§5/§9.3.** This is the operator guide for the per-builder DigitalOcean dev boxes. The code ships in the repo; **going live is a deliberate, money-spending operator action** (like `provision-staging.sh`) — nothing here runs until you set `DO_API_TOKEN` and pass `--apply`.
|
|
4
|
-
|
|
5
|
-
## The shape
|
|
6
|
-
|
|
7
|
-
Each builder gets **one** box (ADR 0031 §2). Its lifecycle is a small state machine tracked in the `builder_boxes` table:
|
|
8
|
-
|
|
9
|
-
```
|
|
10
|
-
none --provision--> active
|
|
11
|
-
^ |
|
|
12
|
-
wake (manual, or | | idle > BOX_IDLE_MINUTES (auto, hourly)
|
|
13
|
-
on next connect) | | park (manual)
|
|
14
|
-
| v
|
|
15
|
-
parked --- parked > BOX_DORMANT_DAYS ---> destroyed
|
|
16
|
-
(auto, daily, WARNS FIRST) snapshot deleted
|
|
17
|
-
|
|
18
|
-
park keeps the SNAPSHOT (only the droplet is destroyed, to stop billing),
|
|
19
|
-
so it is reversible with `wake`. Dormant reclaim is the ONLY automated
|
|
20
|
-
path that destroys a snapshot, and it warns the builder first.
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
- **provision** — create + boot a droplet running the [#596](https://example.com/builders#/task/596) devcontainer. **Rank-gated.**
|
|
24
|
-
- **idle park** (auto, hourly) — when an `active` box is idle > `BOX_IDLE_MINUTES`, snapshot it and **destroy the droplet only**, keeping the snapshot ([task 1002726](https://example.com/builders#/task/1002726)). Compute billing stops; the box is **reversible** via `wake`. An earlier revision ([task 804](https://example.com/builders#/task/804)) destroyed the snapshot too; that was reverted because it took uncommitted work with it (spike [task 837](https://example.com/builders#/task/837)).
|
|
25
|
-
- **park** (the idle sweep, or an Archon by hand) — snapshot the droplet then **destroy the droplet**. A powered-off DO droplet still bills; only a destroyed one stops. The snapshot (~$0.30/mo) is the cheap residual you can `wake` from.
|
|
26
|
-
- **wake** (manual, on demand) — recreate the droplet from its snapshot. New IP; the `hostname` is repointed via `BOX_DNS_HOOK`.
|
|
27
|
-
- **dormant reclaim** (auto, daily) — destroy the snapshot of a box `parked` > `BOX_DORMANT_DAYS`. This is the **only automated path that destroys a snapshot**, so it **warns the builder first** and defers the reclaim if that warning was never delivered ([task 1002727](https://example.com/builders#/task/1002727)). Its input is whatever the idle sweep parked, plus any manual Archon park.
|
|
28
|
-
|
|
29
|
-
### How "idle" is detected
|
|
30
|
-
|
|
31
|
-
The idle sweep **parks** a box when its `last_activity_at` (else `active_since`) is older than `BOX_IDLE_MINUTES` **and** `claude_active` is not true. Both signals are set by **`infra/box-heartbeat.sh`**, which runs **inside the box** every ~5 min and POSTs `/api/gds/box/heartbeat` *only while the box is in use* — an interactive login session, an established SSH connection, an **open browser terminal** (ttyd websocket, [#1161](https://example.com/builders#/task/1161)), or non-trivial CPU load. Silence = the sweep is free to park it.
|
|
32
|
-
|
|
33
|
-
> ⚠️ **The idle sweep's correctness depends on the box-side heartbeat reaching the server.** If it isn't pinging — no Bongos token on the box yet, a pre-[#1161](https://example.com/builders#/task/1161) script that can't see a browser terminal, or the cron not installed — `last_activity_at` never advances and the sweep falls back to `active_since`, i.e. it measures **uptime**, not activity, and will **park** a box someone is actively using. Because the idle path keeps the snapshot ([task 1002726](https://example.com/builders#/task/1002726)), that box can be restored with `wake` and no work is lost — it is disruptive, not destructive. The destructive case is the dormant sweep, which warns first ([task 1002727](https://example.com/builders#/task/1002727)).
|
|
34
|
-
>
|
|
35
|
-
> **Live mitigation (2026-06-16):** `BOX_IDLE_MINUTES` is raised to **120** in `/etc/example/box.env` as a soak margin until [#1161](https://example.com/builders#/task/1161)'s browser-terminal heartbeat is verified on a newcomer box; lower it back toward the 10-min default once that's proven.
|
|
36
|
-
|
|
37
|
-
Two sweeps automate the middle (both mirror `stale-claim-release` — dry-run first, idempotent, capped):
|
|
38
|
-
|
|
39
|
-
| Sweep | Cadence | What it does | Threshold |
|
|
40
|
-
|---|---|---|---|
|
|
41
|
-
| `box-idle-suspend` | hourly | **park** `active` boxes with no activity — snapshot kept, droplet destroyed ([task 1002726](https://example.com/builders#/task/1002726)); reversible via `wake` | `BOX_IDLE_MINUTES` (default 10; **currently 120 live**) |
|
|
42
|
-
| `box-dormant-reclaim` | daily | destroy the snapshot of long-`parked` boxes — the only automated snapshot delete, and it warns first ([task 1002727](https://example.com/builders#/task/1002727)) | `BOX_DORMANT_DAYS` (14) |
|
|
43
|
-
|
|
44
|
-
> ⚠️ **Both sweeps are currently `enabled: false`** in `config/scheduled-routines.json` — retired with the dev-box module (goal 1000091, [task 1003886](https://example.com/builders#/task/1003886)). Neither fires; the CLI below still works by hand.
|
|
45
|
-
|
|
46
|
-
## Why a self-built sweep (not Coder)
|
|
47
|
-
|
|
48
|
-
ADR 0031 §9.3, resolved 2026-06-04: Coder needs an always-on control-plane droplet (~$12–24/mo fixed) + ops; a self-built sweep has ≈ $0 fixed cost and rides the existing sweep/timer pattern. See the ADR for the full rationale. (The original sweep snapshotted-and-parked idle boxes; [task 804](https://example.com/builders#/task/804) briefly simplified the idle path to a direct deprovision, and [task 1002726](https://example.com/builders#/task/1002726) reverted it — so the snapshot/wake machinery serves the automatic idle path again, as well as the manual `park`/`wake` commands.)
|
|
49
|
-
|
|
50
|
-
## The CLI — `node scripts/gds/box.js`
|
|
51
|
-
|
|
52
|
-
**Dry-run is the default. Nothing touches DigitalOcean or the DB without `--apply`.** Run any command once without `--apply` to see the plan.
|
|
53
|
-
|
|
54
|
-
```bash
|
|
55
|
-
node scripts/gds/box.js status # roster + cost (read-only)
|
|
56
|
-
node scripts/gds/box.js provision <login|id> # plan a provision
|
|
57
|
-
node scripts/gds/box.js provision <login|id> --apply
|
|
58
|
-
node scripts/gds/box.js park <login|id> --apply
|
|
59
|
-
node scripts/gds/box.js wake <login|id> --apply
|
|
60
|
-
node scripts/gds/box.js deprovision <login|id> --apply
|
|
61
|
-
node scripts/gds/box.js sweep-idle # plan; add --apply to act
|
|
62
|
-
node scripts/gds/box.js sweep-dormant # plan; add --apply to act
|
|
63
|
-
node scripts/gds/box.js reconcile-drift # DO⇄DB drift; add --apply to act
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
`<builder>` is a GitHub login or a numeric builder id.
|
|
67
|
-
|
|
68
|
-
### Drift reconcile — `reconcile-drift` (task [#1289](https://example.com/builders#/task/1289), [ADR 0071](../adr/0071-box-confirm-before-destroyed-and-drift-reconcile.md))
|
|
69
|
-
|
|
70
|
-
The teardown is now **confirm-before-destroyed**: `deprovision` only marks a box `destroyed` AFTER DigitalOcean confirms the droplet is deleted (idempotent + retried). A delete that genuinely fails parks the box at `state='error'` with `droplet_id` preserved and **throws** — it never silently leaves a running droplet behind a `destroyed` row (the old "zombie" bug).
|
|
71
|
-
|
|
72
|
-
`reconcile-drift` is the backstop. It lists the real fleet droplets (a live read — needs `DO_API_TOKEN`, present in `box.env`) and reconciles against the Bongos rows **both ways**:
|
|
73
|
-
|
|
74
|
-
- **Zombie** — a droplet no live box row claims → force-delete the droplet + its tunnel/DNS.
|
|
75
|
-
- **Ghost** — a live row whose `droplet_id` vanished from DO → mark it `destroyed`.
|
|
76
|
-
|
|
77
|
-
A droplet younger than 30 min is never killed (it may be a provision in flight). It runs hourly on the control plane via `box-drift-reconcile.timer` (see *Going live* below). Run it by hand (dry-run first) any time you suspect drift:
|
|
78
|
-
|
|
79
|
-
```bash
|
|
80
|
-
node scripts/gds/box.js reconcile-drift # show the plan
|
|
81
|
-
node scripts/gds/box.js reconcile-drift --apply # repair
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
### Rank gate (ADR 0016 trust boundary)
|
|
85
|
-
|
|
86
|
-
`provision` reads the builder's **live DB rank** (the same value `requireRank` reads — uncacheable, server-side) and refuses below `BOX_PROVISION_MIN_RANK`. Default floor is `xenos`: per ADR 0031 §5(b) even an onboarded newcomer gets an org-funded **starter** box. The **scope** differs by rank, not the right to a box:
|
|
87
|
-
|
|
88
|
-
- **xenos → `scope='starter'`** — the box is tagged for the scoped surface [#600](https://example.com/builders#/task/600) will enforce.
|
|
89
|
-
- **metic / archon → `scope='full'`** — full private-repo access (also [#600](https://example.com/builders#/task/600)).
|
|
90
|
-
|
|
91
|
-
Raise the floor (e.g. boxes for Metic+ only) by setting `BOX_PROVISION_MIN_RANK=metic`.
|
|
92
|
-
|
|
93
|
-
## Config (environment)
|
|
94
|
-
|
|
95
|
-
Put these in `/etc/example/box.env` (chmod 600 — **secrets never go in the repo**, [ADR 0022](../adr/0022-secrets-policy.md)). The systemd units load it via `EnvironmentFile=`.
|
|
96
|
-
|
|
97
|
-
| Var | Default | Meaning |
|
|
98
|
-
|---|---|---|
|
|
99
|
-
| `DO_API_TOKEN` | — | DigitalOcean API token. **Required for `--apply`**; absent ⇒ live calls throw clearly. |
|
|
100
|
-
| `BOX_PROVISION_MIN_RANK` | `xenos` | provisioning rank floor |
|
|
101
|
-
| `BOX_REGION` | `nyc3` | droplet region |
|
|
102
|
-
| `BOX_SIZE` | `s-2vcpu-4gb` | droplet size (4 GB floor — the devcontainer sets a 4 GB Node heap) |
|
|
103
|
-
| `BOX_BASE_IMAGE` | `ubuntu-24-04-x64` | first-provision base image (cloud-init installs Docker + the devcontainer) |
|
|
104
|
-
| `BOX_IDLE_MINUTES` | `10` | idle-**park** threshold (minutes). **Currently overridden to `120` live** as a soak margin — see "How idle is detected". |
|
|
105
|
-
| `BOX_DORMANT_DAYS` | `14` | dormant-reclaim threshold |
|
|
106
|
-
| `BOX_SSH_KEY_IDS` | — | comma-list of DO SSH key ids/fingerprints to inject (so you can SSH in) |
|
|
107
|
-
| `BOX_REPO_URL` | — | git URL the cloud-init clones into `/workspace`. The scoped **credential** is [#600](https://example.com/builders#/task/600); no identity is hardcoded. |
|
|
108
|
-
| `BOX_DNS_HOOK` | — | optional shell command run on provision/wake with `BOX_HOSTNAME`/`BOX_IP` in env, to repoint the per-builder hostname |
|
|
109
|
-
| `BOX_DNS_ZONE` | `dev.example.com` | the per-builder hostname zone (`box-<login>.<zone>`) |
|
|
110
|
-
| `BOX_TAG` | `otb-builder-box` | DO tag applied to every box droplet |
|
|
111
|
-
|
|
112
|
-
## Cost (Criterion C1 / cost)
|
|
113
|
-
|
|
114
|
-
A box accrues compute cost **only while `active`**. At park/deprovision the CLI computes `active hours × the size's hourly rate` and writes it to both the `box_events` ledger and `cost_log` (category `compute`, builder-attributed) — so the public status dashboard's per-builder split sees real box spend. `box.js status` prints the active monthly run-rate + total spent. The **rebill to example/earnings** (org-funded starter → self-fund) is a separate task, [#602](https://example.com/builders#/task/602).
|
|
115
|
-
|
|
116
|
-
## Going live (one-time operator steps)
|
|
117
|
-
|
|
118
|
-
These spend money and are intentionally manual.
|
|
119
|
-
|
|
120
|
-
> **Hardware-verified end-to-end (2026-06-06, [#720](https://example.com/builders#/task/720)).** The lane now boots a *usable* box: auto-provision by draining the intent queue (`box.js run-intents` / `box-intent-runner.timer`), wake-on-connect from snapshot via `POST /box/ensure`, and SSH connect with the injected key. **`box-intent-runner.timer` is installed + enabled on the control plane as of [#720](https://example.com/builders#/task/720).** That run also found **[#724](https://example.com/builders#/task/724)**: the cloud-init template carried non-ASCII characters (em-dash, §) that cloud-init's strict YAML loader silently rejected — so *every* box before [#724](https://example.com/builders#/task/724) (including the [#699](https://example.com/builders#/task/699) lifecycle test) booted as a bare Ubuntu image with no devcontainer. **Keep `infra/builder-box-cloud-init.yaml` ASCII-only** (scan with `LC_ALL=C grep -nP '[^\x00-\x7f]' infra/builder-box-cloud-init.yaml`) and assert `cloud-init status` is `done` (not `degraded`) on the box after any provision change. Still deferred to **[#725](https://example.com/builders#/task/725)** (needs `BOX_SOURCE_*` GitHub tokens): the rank-gated source-fetch ([#600](https://example.com/builders#/task/600)) leg, full RC pairing, the fresh-account dry-run, plus the `box-source-fetch.sh` `HOME`-unbound fix. The idle/dormant sweeps are now LIVE — wired on the control plane 2026-06-15 ([#1101](https://example.com/builders#/task/1101)); they depend on the box-heartbeat, which needs the source clone (leg 1).
|
|
121
|
-
|
|
122
|
-
> **Source + DNS verified (2026-06-06, [#725](https://example.com/builders#/task/725)).** **Leg 1 ✅** — `BOX_SOURCE_*` is wired on the **web** tier via `/etc/example/web-source.env` (a systemd drop-in `EnvironmentFile`, deliberately separate from `box.env` so the internet-facing web process never holds `DO_API_TOKEN`); `GET /box/source-access` issues a rank-scoped credential and the box clones into `/workspace` with the credential **scrubbed from `.git/config`**. **[#599](https://example.com/builders#/task/599) hostname cutover ✅ DONE** — in `box.env`: `CLOUDFLARE_API_TOKEN` (Zone:DNS:Edit, IP-locked to the droplet) + `BOX_DNS_HOOK=/home/lars/example/infra/box-dns-cloudflare.sh`; `box.js` runs it on every provision/wake so `box-<login>.dev.example.com` tracks the box IP (verified live: a park→wake gave a new IP and DNS auto-updated with no manual step; SSH via the hostname works). **The hook forces `curl -4`** — the control-plane droplet has IPv6 egress that would otherwise bypass an IPv4 token allow-list. Gotcha: a Cloudflare token IP-locked to the droplet's IPv4 returns "Invalid API Token" from `/user/tokens/verify` (and from any IPv6-sourced call) — test against the real op (`GET /zones`), not the verify endpoint, especially for account-scoped `cfat_`-prefixed tokens. Still partial: leg 5 (RC pairing needs a `claude` login on the box) + leg 6 (fresh-account needs a 2nd GitHub account). **DNS reliability hardened ([#1180](https://example.com/builders#/task/1180), 2026-06-16):** the hook now RETRIES every Cloudflare call with backoff — a transient auth blip during the provision call-burst used to leave the A record pinned at the prior (now-dead) IP while reporting a misleading "zone not found"; it now self-heals and, on a genuine failure, surfaces the real CF error code. `runDnsHook` no longer swallows a hook failure (it records a `box_events` `error` row and flags the box DNS-stale instead of logging a clean "✓ active"). The browser terminal is independent of this record (it rides the outbound tunnel). _(Root cause: @kingaonline's Claude Desktop hit "handshake timeout" SSHing to a hostname pinned at a dead IP.)_ **DNS teardown refined ([idea 309](https://example.com/builders#/idea/309) → task [#1234](https://example.com/builders#/task/1234)):** on deprovision `deprovisionOne` **repoints** the A record to a non-routable blackhole sentinel (`192.0.2.1`, TEST-NET-1 / RFC 5737) via `runDnsHookBlackhole` instead of **deleting** it. 1180 originally deleted the record on teardown so it couldn't dangle at a recycled DO IP — but delete-then-recreate across a reap→rebuild opened a window where the name returned `NXDOMAIN`, which resolvers negative-cache for the zone's SOA neg-TTL (~30 min on the Cloudflare `example.com` zone, not builder-editable); a client that queried mid-gap (e.g. an open Claude Desktop) then kept getting `ENOTFOUND` long after the box was back. A record that always resolves (to a guaranteed-dead sentinel) has nothing to negative-cache, yet still can't dangle at a recycled IP; the next provision/wake `PUT`s it straight to the live IP at the 60s record TTL, no gap. (Lowering the neg-TTL — the other lever floated in idea 309 — isn't used: Cloudflare's per-zone SOA neg-TTL isn't builder/API-editable, and the repoint makes it moot.) The hook's `BOX_DNS_DELETE=1` / `--delete` mode is retained for **manual** operator cleanup.
|
|
123
|
-
|
|
124
|
-
1. **Token + config.** Create `/etc/example/box.env` (chmod 600) with at least `DO_API_TOKEN` and `BOX_SSH_KEY_IDS`. Apply migration 067 on the prod DB (`bash scripts/migrate.sh`).
|
|
125
|
-
> **`DO_API_TOKEN` must carry `tag:create` scope.** The first 2026-06-04 hardware run failed with `DO POST /droplets … missing the required permission tag:create` (the CLI applies `BOX_TAG` to every box). Use a **Full-Access** token, or a custom token that includes **tag write** alongside droplet + snapshot read/write.
|
|
126
|
-
> **Hardware-verified (2026-06-04, [#699](https://example.com/builders#/task/699)):** the full `provision → park → wake → deprovision` cycle has run on real DigitalOcean droplets (`box_events` ledger). Still unverified on hardware: the rank-gated source-fetch ([#600](https://example.com/builders#/task/600)) leg and the live Claude Desktop connect.
|
|
127
|
-
2. **Try one box in dry-run, then live.**
|
|
128
|
-
```bash
|
|
129
|
-
node scripts/gds/box.js provision <login> # read the plan
|
|
130
|
-
node scripts/gds/box.js provision <login> --apply # create it
|
|
131
|
-
node scripts/gds/box.js status
|
|
132
|
-
```
|
|
133
|
-
SSH in (DO assigns the IP shown), confirm the devcontainer came up, then `park`/`wake` it once to confirm the snapshot round-trips.
|
|
134
|
-
3. **Wire the box-side heartbeat** (so the idle sweep measures activity, not uptime). On the box, schedule `infra/box-heartbeat.sh` every ~5 min — e.g. `*/5 * * * * /workspace/infra/box-heartbeat.sh >> /tmp/box-heartbeat.log 2>&1` (or hang it off [#599](https://example.com/builders#/task/599)'s box service supervisor). Confirm `GET /api/gds/box/me` shows `last_activity_at` advancing while you're connected.
|
|
135
|
-
4. **Wire the sweeps + the onboarding intent runner** (systemd units ship in `infra/`):
|
|
136
|
-
```bash
|
|
137
|
-
sudo cp infra/box-idle-suspend.{service,timer} infra/box-dormant-reclaim.{service,timer} infra/box-intent-runner.{service,timer} infra/box-drift-reconcile.{service,timer} /etc/systemd/system/
|
|
138
|
-
sudo systemctl daemon-reload
|
|
139
|
-
sudo systemctl enable --now box-idle-suspend.timer box-dormant-reclaim.timer box-intent-runner.timer box-drift-reconcile.timer
|
|
140
|
-
systemctl list-timers 'box-*' # confirm next-run times
|
|
141
|
-
```
|
|
142
|
-
Tail `journalctl -u box-idle-suspend.service` for a day to confirm boxes park + wake cleanly.
|
|
143
|
-
> **`box-intent-runner.timer` ([#701](https://example.com/builders#/task/701)) is the on-ramp's responsiveness knob.** It runs `box.js run-intents --apply` every minute, draining the `box_intents` queue that `POST /api/gds/box/ensure` writes — so a builder who hits "connect" gets their box **auto-provisioned** (no box yet) or **woken** (parked) within ~1 min, with no operator CLI. It reads the SAME `/etc/example/box.env` as the sweeps. Without it installed, `ensure` still queues intents — they just sit pending until someone runs `box.js run-intents --apply` by hand. Confirm with `journalctl -u box-intent-runner.service` after a test `ensure`.
|
|
144
|
-
> **`box-drift-reconcile.timer` (task [#1289](https://example.com/builders#/task/1289)/[ADR 0071](../adr/0071-box-confirm-before-destroyed-and-drift-reconcile.md)) is the zombie backstop.** It runs `box.js reconcile-drift --apply` hourly, reading the live DO droplet list and repairing DO⇄DB drift both ways — force-deleting **zombie** droplets (running but no live box row claims them) and marking **ghost** rows destroyed (droplet gone from DO). Reads the SAME `/etc/example/box.env` (needs `DO_API_TOKEN` to enumerate droplets). Without it, the confirm-before-destroyed teardown still prevents *new* zombies, but a pre-existing one (or an out-of-band DO change) only heals on the next manual `reconcile-drift`. Confirm with `journalctl -u box-drift-reconcile.service`.
|
|
145
|
-
> **Chromebook / browser terminal ([#760](https://example.com/builders#/task/760)/[ADR 0038](../adr/0038-chromebook-ttyd-cloudflare-tunnel.md), superseding the old `claude rc` pairing).** Research proved `claude rc` cannot run headless, so the box instead serves its OWN browser terminal — **ttyd** (127.0.0.1:7681, per-box basic-auth, drops into `/workspace`) exposed over an outbound-only **Cloudflare Tunnel** — where the builder runs normal interactive `claude`. A browser/Chromebook builder gets the URL + credential from `GET /api/gds/box/terminal` after their box is active — no SSH needed. The terminal/cloudflared units are inlined in cloud-init and start at first boot ([#774](https://example.com/builders#/task/774)). The deleted RC bits (`claude-rc.service`, `box-report-pairing.sh`, `/box/pairing(-link)`) are gone.
|
|
146
|
-
|
|
147
|
-
## Cloudflare token: the edge cookie-strip rule (ADR 0053 / SR#11)
|
|
148
|
-
|
|
149
|
-
`provision` applies two zone **rulesets** best-effort: the **cookie-strip Transform Rule** (removes the `Cookie` request header on `term-*`/`sandbox-*`, so the apex session cookie never rides the tunnel onto a box — a host outside the trust boundary) and the **sandbox cache-bypass Cache Rule**. Both need scopes beyond Tunnel + DNS, and a token that lacks them fails **silently** — the box provisions fine and the security control simply isn't there.
|
|
150
|
-
|
|
151
|
-
Since task 1002511, `box.js provision` **read-probes** the ruleset phase before spending a cent (dry-run included) and prints a `⚠⚠ SECURITY` block naming the fix. Read the probe honestly: it is a `GET`, so it can only prove the scope is **missing** (401/403). A readable phase means *readable* — edit scope is not proven until the rule PUT actually runs, which is why the summary line after `✓ named tunnel ready` is the authoritative signal.
|
|
152
|
-
|
|
153
|
-
**Fix — widen the token** (account owner only):
|
|
154
|
-
|
|
155
|
-
> CF dashboard → **My Profile → API Tokens** → edit the token used as `CLOUDFLARE_API_TOKEN` → **Permissions** → add **Zone → Transform Rules → Edit** and **Zone → Cache Rules → Edit** → **Zone Resources → Include → Specific zone → `<your apex zone>`** → Continue → Save. Then re-run `node scripts/gds/box.js provision <login> --apply`.
|
|
156
|
-
|
|
157
|
-
**Fallback — apply the rule by hand** (no token change; do it once per zone):
|
|
158
|
-
|
|
159
|
-
> CF dashboard → the zone → **Rules → Transform Rules → Modify Request Header** → Create rule. Custom filter expression: `starts_with(http.host, "term-") or starts_with(http.host, "sandbox-")` (the exported `COOKIE_STRIP_EXPRESSION`). Action: **Remove** header `Cookie` (phase `http_request_late_transform`). Deploy. Give it the same description the code uses (`COOKIE_STRIP_RULE_DESC`) so a later widened-token run recognises and replaces it instead of adding a duplicate.
|
|
160
|
-
|
|
161
|
-
**Verify:**
|
|
162
|
-
|
|
163
|
-
```bash
|
|
164
|
-
node scripts/gds/box.js provision <login> # dry-run — prints the probe verdict, spends nothing
|
|
165
|
-
curl -sI https://term-<login>.<apex>/ -H 'Cookie: x=1' # from a box-facing check, confirm the origin sees no Cookie
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
> `/user/tokens/verify` is **not** a valid check here — an IP-locked token answers "Invalid API Token" there even when it is valid (see the [#725](https://example.com/builders#/task/725) note above). Always probe the real operation.
|
|
169
|
-
|
|
170
|
-
## Boundaries (other ADR-0031 tasks)
|
|
171
|
-
|
|
172
|
-
- **[#596](https://example.com/builders#/task/596)** (done) — the devcontainer (box-as-code) this provisions.
|
|
173
|
-
- **[#599](https://example.com/builders#/task/599)** — Claude Desktop managed-settings + the stable-hostname cutover the lifecycle records. (The browser-on-ramp is now the ttyd web terminal over a Cloudflare Tunnel — [#760](https://example.com/builders#/task/760)/[ADR 0038](../adr/0038-chromebook-ttyd-cloudflare-tunnel.md) — not the retired `claude rc` Remote-Control service.)
|
|
174
|
-
- **[#600](https://example.com/builders#/task/600)** — rank-gated **source access**: the scoped GitHub credential the box holds, granted/revoked by rank. [#598](https://example.com/builders#/task/598) records `scope`; [#600](https://example.com/builders#/task/600) enforces it.
|
|
175
|
-
- **[#602](https://example.com/builders#/task/602)** — cost passthrough (org-funded starter → self-fund via the `costs`/example system). [#598](https://example.com/builders#/task/598) logs the cost; [#602](https://example.com/builders#/task/602) rebills it.
|
|
176
|
-
|
|
177
|
-
## Safety properties
|
|
178
|
-
|
|
179
|
-
- **No HTTP provisioning — the web process never touches the DO API.** Lifecycle mutations (provision/park/wake/deprovision) run only on the control-plane CLI; `DO_API_TOKEN` lives only in `/etc/example/box.env`, never the web tier. The API surface is read (`GET /box/me`, `GET /boxes`, `GET /box/terminal`) plus own-scoped writes that **record a request, not an action**: `POST /box/heartbeat` and — [#701](https://example.com/builders#/task/701) — `POST /box/ensure`, which only writes a `box_intents` row. The control-plane `box.js run-intents` is what actually calls DO. A stolen builder token can, at most, queue a provision/wake of **its own** box (rank-gated, one open intent max) — it can never create, destroy, or reach another builder's infrastructure.
|
|
180
|
-
- **Dry-run default + token-required-for-live.** You cannot accidentally spend money; a live call without `DO_API_TOKEN` throws rather than silently no-op'ing.
|
|
181
|
-
- **Audited.** Every transition writes a `box_events` row (`provision`/`park`/`wake`/`deprovision`/`cost`/`error`) with the actor (`cli:<login>` / `sweep:idle` / `sweep:dormant`).
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
# Releasing the Dev Box desktop app
|
|
2
|
-
|
|
3
|
-
How a new version of the Dev Box app (`devbox-app/`, ADR 0045) reaches builders.
|
|
4
|
-
The chain: CI builds installers → operator publishes them to the PUBLIC
|
|
5
|
-
releases repo → a one-line config bump re-points the branded
|
|
6
|
-
`/downloads/devbox/*` redirects. Nothing binary ever rides the git deploy.
|
|
7
|
-
|
|
8
|
-
## 0. What's where
|
|
9
|
-
|
|
10
|
-
- **Source:** `devbox-app/` in the main (private) repo.
|
|
11
|
-
- **CI:** `.github/workflows/build-devbox-app.yml` — builds on every push
|
|
12
|
-
touching `devbox-app/**` (mac dmg arm64+x64 on macos-14, NSIS exe x64+arm64 on
|
|
13
|
-
windows-latest). **Only `main` keeps the artifacts** (3-day retention); a
|
|
14
|
-
`claude/**` run builds both platforms as a CI signal and uploads nothing —
|
|
15
|
-
~500MB per run had filled the shared Actions storage cap and blocked uploads
|
|
16
|
-
repo-wide. No publishing from CI either way.
|
|
17
|
-
- **Public releases repo:** named in `config/devbox-app.json`
|
|
18
|
-
(`releases_repo`). Assets live on GitHub releases there; GitHub's CDN
|
|
19
|
-
serves the downloads, $0.
|
|
20
|
-
- **Branded URLs:** `https://example.com/downloads/devbox/<target>`
|
|
21
|
-
(302 → release asset; targets `mac-arm64`, `mac-x64`, `win-x64`, `win-arm64` + aliases)
|
|
22
|
-
and `/downloads/devbox/latest.json` (what the app's update check reads).
|
|
23
|
-
Server side: `src/devbox-downloads.js`, mounted in `server.js`.
|
|
24
|
-
- **Install-window backdrop ([#912](https://example.com/builders#/task/912), fixed in [#1235](https://example.com/builders#/task/1235)):** the backdrop
|
|
25
|
-
art is `build-resources/dmg-background.png` (+ `@2x`), drawn by
|
|
26
|
-
`scripts/gen-dmg-backdrop.py`. Committed art — re-run that script + commit if you change
|
|
27
|
-
the layout/copy. **The DMG is NOT styled by electron-builder.** electron-builder writes a
|
|
28
|
-
`.DS_Store` whose background reference includes a modern `pBBk` bookmark that Finder on
|
|
29
|
-
macOS ≥ Sonoma/Sequoia (verified white on 15.7.4) cannot resolve — so its install window
|
|
30
|
-
renders solid white even though the image is embedded (this is exactly what shipped broken
|
|
31
|
-
in v1.0.5/v1.0.6; it is NOT a Finder cache and NOT an OS limitation — macOS renders DMG
|
|
32
|
-
backgrounds fine). Instead, the CI step **"Apply install-window backdrop (dmgbuild)"**
|
|
33
|
-
(`scripts/apply-dmg-backdrop.sh` + `scripts/dmgbuild-settings.py`) re-packages the
|
|
34
|
-
signed+notarized `.app` out of electron-builder's DMG into the final DMG with
|
|
35
|
-
[`dmgbuild`](https://pypi.org/project/dmgbuild/), which writes an alias-only `.DS_Store`
|
|
36
|
-
(no `pBBk`) that DOES resolve and render. `dmgbuild` is headless (no Finder), so it runs in
|
|
37
|
-
CI; the `.app` is copied verbatim with `ditto`, so notarization is preserved; the DMG is an
|
|
38
|
-
unsigned carrier either way. Keep the icon coords + window/iconSize in
|
|
39
|
-
`scripts/dmgbuild-settings.py` matched to `electron-builder.yml` and the art's wells
|
|
40
|
-
(180,198 / 480,198, 660×400, size 100). **Verify after a mac release:** mount the published
|
|
41
|
-
arm64 DMG and confirm the backdrop draws — quick non-Finder check:
|
|
42
|
-
`hdiutil attach <dmg> -nobrowse -readonly -mountpoint /tmp/m && python3 -c "d=open('/tmp/m/.DS_Store','rb').read(); print('renders:', b'pBBk' not in d and b'.background' in d)"`
|
|
43
|
-
(`pBBk` must be **absent**).
|
|
44
|
-
|
|
45
|
-
## 1. Cut a release
|
|
46
|
-
|
|
47
|
-
1. Bump `devbox-app/package.json` version (e.g. `1.1.0`) and regenerate
|
|
48
|
-
icons only if they changed (`npm run icons`).
|
|
49
|
-
2. Land the bump on `main` through the normal Bongos flow, then wait for
|
|
50
|
-
`build-devbox-app` to go green **on the `main` run** — that is the run that
|
|
51
|
-
carries the installers.
|
|
52
|
-
3. Download the artifacts (they expire after 3 days — re-run the workflow on
|
|
53
|
-
`main` via `gh workflow run build-devbox-app.yml --ref main` if they aged out):
|
|
54
|
-
```
|
|
55
|
-
gh run download -R example-owner/example -n devbox-app-mac -D /tmp/devbox-rel
|
|
56
|
-
gh run download -R example-owner/example -n devbox-app-win -D /tmp/devbox-rel
|
|
57
|
-
```
|
|
58
|
-
**These commands only work against a `main` run.** Pointing them at a
|
|
59
|
-
`claude/**` run fails with nothing to download — the upload steps are gated to
|
|
60
|
-
`refs/heads/main` (see §0). To get an installer out of an unmerged branch,
|
|
61
|
-
build it locally instead: `cd modules/dev-box/app && npm run dist:mac` (or
|
|
62
|
-
`npm run dist:win` on Windows).
|
|
63
|
-
4. Publish to the public releases repo (tag = `v<version>`, must match what
|
|
64
|
-
`src/devbox-downloads.js` composes):
|
|
65
|
-
```
|
|
66
|
-
gh release create v1.1.0 -R example-owner/example-devbox \
|
|
67
|
-
--title "Dev Box v1.1.0" --notes "…" /tmp/devbox-rel/*.dmg /tmp/devbox-rel/*.exe
|
|
68
|
-
```
|
|
69
|
-
5. Update `config/devbox-app.json`: `version` + the four asset filenames
|
|
70
|
-
(they embed the version — `tests/devbox_downloads.mjs` fails the gate on a
|
|
71
|
-
half-bumped config).
|
|
72
|
-
6. Ship the config change through the normal Bongos flow. **Bumping `config/devbox-app.json`
|
|
73
|
-
`version` is what triggers the in-app update banner in the field ([#924](https://example.com/builders#/task/924))** — running
|
|
74
|
-
apps poll `GET /downloads/devbox/version`, compare it to their own `package.json`
|
|
75
|
-
version with a proper semver compare (`devbox-app/src/updater.js`), and show
|
|
76
|
-
"Update available — v<x>" + a one-click Update button only when the published
|
|
77
|
-
version is STRICTLY newer. Apps re-check on open and every 12h, so a running app
|
|
78
|
-
surfaces a new release within ~12h (sooner if reopened). The config version must
|
|
79
|
-
therefore be ≥ the shipped `package.json` version, or no one is offered the update.
|
|
80
|
-
The hall `/settings` download card reads the same endpoint to show "Latest version: vX".
|
|
81
|
-
|
|
82
|
-
## 2. Verify after deploy
|
|
83
|
-
|
|
84
|
-
```
|
|
85
|
-
curl -sI https://example.com/downloads/devbox/mac-arm64 | grep -i location
|
|
86
|
-
curl -s https://example.com/downloads/devbox/version # { version, assets:{target:url} } — the app's update-check payload
|
|
87
|
-
curl -s https://example.com/downloads/devbox/latest.json # { version, assets:{target:{file,url}} } — the downloads page payload
|
|
88
|
-
curl -sIL https://example.com/downloads/devbox/win-x64 -o /dev/null -w '%{http_code}\n' # expect 200
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
## Gotchas
|
|
92
|
-
|
|
93
|
-
- **The tag and asset filenames must match the config exactly** — the
|
|
94
|
-
redirect is `releases/download/v<version>/<asset>`; a renamed asset 404s.
|
|
95
|
-
- **Unsigned builds:** until the Apple Developer ID / Windows cert decision
|
|
96
|
-
(open blocker), keep the "Open Anyway" / SmartScreen copy in the hall
|
|
97
|
-
onboarding + settings pages accurate. When certs land: sign + notarize in
|
|
98
|
-
CI, then revisit real auto-update.
|
|
99
|
-
- **`devbox-app/build-resources/`** is committed source (the app icon); the
|
|
100
|
-
name avoids the root `.gitignore`'s global `build/` rule. Output goes to
|
|
101
|
-
`devbox-app/dist/` (ignored).
|
|
102
|
-
- **The releases repo is public** — never attach anything but the installers
|
|
103
|
-
(no env files, no maps, no internal docs).
|