@bongos/core 1.25.20 → 1.25.21

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.
Files changed (75) hide show
  1. package/.bongos-core.json +157 -62
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +10 -0
  4. package/clients/bongos-client/index.cjs +10 -0
  5. package/clients/bongos-client/index.d.ts +23 -8
  6. package/clients/bongos-client/index.mjs +10 -0
  7. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +10 -0
  8. package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +3 -1
  9. package/docs/api/openapi.json +409 -36
  10. package/docs/api-reference.md +7 -2
  11. package/docs/copy-inventory.md +20 -20
  12. package/docs/copy-registry.json +23 -23
  13. package/docs/module-api-changelog.md +2 -0
  14. package/docs/page-readings.json +2 -2
  15. package/modules/government/board.js +8 -7
  16. package/modules/government/charter-advice.js +10 -3
  17. package/modules/government/charter.js +88 -4
  18. package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
  19. package/modules/government/routes/government.js +12 -12
  20. package/modules/government/sole-member.js +75 -0
  21. package/modules/hall-ui/public/genesis-home.js +12 -5
  22. package/modules/onboarding/genesis-home.js +14 -2
  23. package/modules/platform-identity/module.json +1 -1
  24. package/modules/platform-identity/project-standing.js +49 -0
  25. package/modules/platform-identity/routes/my-projects.js +8 -0
  26. package/modules/provisioning/grants.js +126 -0
  27. package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
  28. package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
  29. package/modules/provisioning/module.json +2 -1
  30. package/modules/provisioning/project-refusal.js +107 -0
  31. package/modules/provisioning/provisioning.js +2 -2
  32. package/modules/provisioning/render-lookup.js +36 -1
  33. package/modules/provisioning/render-promote.js +80 -0
  34. package/modules/provisioning/route-helpers.js +70 -16
  35. package/modules/provisioning/routes/env-manifest.js +21 -5
  36. package/modules/provisioning/routes/grants.js +141 -0
  37. package/modules/provisioning/routes/provisioning.js +8 -7
  38. package/modules/provisioning/routes/render-standup.js +82 -3
  39. package/modules/public-landing/public/projects.html +99 -1
  40. package/package-lock.json +2 -2
  41. package/package.json +1 -1
  42. package/release-notes.json +18 -0
  43. package/scripts/bongos/genesis.js +11 -1
  44. package/scripts/bongos/government-walk-sole.js +41 -0
  45. package/scripts/bongos/government-walk.js +15 -16
  46. package/scripts/bongos/provision-render-common.js +48 -0
  47. package/scripts/bongos/provision-render-promote.js +93 -0
  48. package/scripts/bongos/provision-render-sandbox.js +14 -2
  49. package/scripts/bongos/provision-render.js +4 -36
  50. package/scripts/bongos/provision.js +1 -0
  51. package/scripts/bongos/render-api.js +17 -2
  52. package/scripts/gds/government-walk-sole.js +6 -0
  53. package/scripts/gds/provision-render-common.js +6 -0
  54. package/scripts/gds/provision-render-promote.js +6 -0
  55. package/src/module-api.js +1 -1
  56. package/tests/fleet_cost_view.mjs +2 -0
  57. package/tests/genesis_government.mjs +5 -1
  58. package/tests/genesis_home.mjs +17 -0
  59. package/tests/government_charter_advice.mjs +49 -0
  60. package/tests/government_charter_forms.mjs +14 -3
  61. package/tests/government_seed.mjs +1 -0
  62. package/tests/government_sole_member.mjs +153 -0
  63. package/tests/project_catalog_routes.mjs +2 -0
  64. package/tests/provisioning_env_manifest.mjs +4 -0
  65. package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
  66. package/tests/provisioning_project_grants.mjs +458 -0
  67. package/tests/provisioning_project_owner_gate.mjs +3 -1
  68. package/tests/provisioning_project_refusal.mjs +316 -0
  69. package/tests/provisioning_render_route.mjs +1 -1
  70. package/tests/provisioning_restart.mjs +2 -0
  71. package/tests/provisioning_settings.mjs +2 -0
  72. package/tests/provisioning_settings_apply.mjs +1 -1
  73. package/tests/provisioning_teardown_intent.mjs +2 -2
  74. package/tests/render_promote.mjs +309 -0
  75. package/tests/render_sandbox.mjs +9 -4
@@ -0,0 +1,309 @@
1
+ // tests/render_promote.mjs — a person promotes what ran on the sandbox to the live app
2
+ // (task 1005290; ADR 0406 §2). The runner leg (scripts/bongos/provision-render-promote.js)
3
+ // is driven over the REAL Render client and a recording fake of Render's API; the web
4
+ // tier's look (render-lookup.js lookupPromotion) over a fake fetch; the routes over
5
+ // express with the module's seams stubbed. No network, no database, no spend.
6
+ //
7
+ // The task's DONE WHEN:
8
+ // • a shipped change is on the sandbox while the main site is unchanged — the sandbox
9
+ // setup switches the live service to manual deploys (tests/render_sandbox.mjs asserts
10
+ // the PATCH), and the look lists what ran on the sandbox since live's change;
11
+ // • promoting changes the main site — the live service is deployed at EXACTLY the chosen
12
+ // commit;
13
+ // • nothing reaches the main site without a recorded promotion — the record is written
14
+ // BEFORE Render is asked, a refusal sends nothing, and only the owner may press.
15
+ //
16
+ // Run: node tests/render_promote.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+ import http from 'node:http';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const { RenderApi } = require('../scripts/bongos/render-api.js');
24
+ const { promoteInstance } = require('../scripts/bongos/provision-render-promote.js');
25
+ const { lookupPromotion } = require('../modules/provisioning/render-lookup.js');
26
+ const renderPromote = require('../modules/provisioning/render-promote.js');
27
+
28
+ let passed = 0, failed = 0;
29
+ async function ta(name, fn) {
30
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
31
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
32
+ }
33
+
34
+ const OURS = 'tea-ours00000001';
35
+ const OLD = 'aaaaaaa1111111111111111111111111111111aa';
36
+ const MID = 'bbbbbbb2222222222222222222222222222222bb';
37
+ const NEW = 'ccccccc3333333333333333333333333333333cc';
38
+ const BAD = 'ddddddd4444444444444444444444444444444dd';
39
+ const dep = (id, commit, status, message = `task 100 - change ${commit.slice(0, 3)}`) => ({ deploy: { id, status, commit: { id: commit, message, createdAt: '2026-11-13T10:00:00Z' } } });
40
+
41
+ function fakeRender(over = {}) {
42
+ const calls = [];
43
+ const answers = {
44
+ 'GET /owners?limit=100': [{ owner: { id: OURS } }],
45
+ 'GET /services/srv-live0001': { id: 'srv-live0001', name: 'demo', ownerId: OURS },
46
+ 'GET /services/srv-sbx00001': { id: 'srv-sbx00001', name: 'demo-sandbox', ownerId: OURS },
47
+ 'GET /services/srv-sbx00001/deploys?limit=50': [dep('dep-s4', BAD, 'build_failed'), dep('dep-s3', NEW, 'live'), dep('dep-s2', MID, 'deactivated'), dep('dep-s1', OLD, 'deactivated')],
48
+ 'GET /services/srv-live0001/deploys?limit=10': [dep('dep-l1', OLD, 'live')],
49
+ 'PATCH /services/srv-live0001': { id: 'srv-live0001', ownerId: OURS, autoDeploy: 'no' },
50
+ 'POST /services/srv-live0001/deploys': { id: 'dep-live-new' },
51
+ ...over,
52
+ };
53
+ const fetchImpl = async (url, init) => {
54
+ const path = url.replace('https://api.render.com/v1', '');
55
+ const body = init.body ? JSON.parse(init.body) : null;
56
+ calls.push({ method: init.method, path, body });
57
+ const a = answers[`${init.method} ${path}`];
58
+ if (a === undefined) return { ok: false, status: 404, text: async () => '{"message":"not found"}' };
59
+ if (a instanceof Error) return { ok: false, status: 500, text: async () => JSON.stringify({ message: a.message }) };
60
+ return { ok: true, status: 200, text: async () => JSON.stringify(a) };
61
+ };
62
+ return { calls, fetchImpl };
63
+ }
64
+
65
+ function harness({ ask = {}, host = {}, render = {}, intent = {} } = {}) {
66
+ const r = fakeRender(render);
67
+ const order = [], promotions = [], cleared = [], logs = [];
68
+ let nextId = 1;
69
+ const inst = { id: 18, slug: 'demo', app_host: { platform: 'render', ownerId: OURS, serviceId: 'srv-live0001', state: 'live',
70
+ sandbox: { state: 'live', serviceId: 'srv-sbx00001' }, promote: { state: 'requested', commitId: NEW, expectLiveCommit: OLD, requestedBy: '7', ...ask }, ...host } };
71
+ const db = { query: async (text, params) => {
72
+ const t = text.replace(/\s+/g, ' ');
73
+ if (/INSERT INTO provisioning_promotions/.test(t)) {
74
+ order.push('record');
75
+ const row = { id: nextId++, commit_id: params[1], commit_message: params[2], from_commit_id: params[3], sandbox_deploy_id: params[4], requested_by: params[5], state: 'started' };
76
+ promotions.push(row);
77
+ return { rows: [{ id: row.id }] };
78
+ }
79
+ if (/UPDATE provisioning_promotions SET state = 'sent'/.test(t)) { Object.assign(promotions.find((p) => p.id === params[0]), { state: 'sent', render_deploy_id: params[1] }); return { rows: [] }; }
80
+ if (/UPDATE provisioning_promotions SET state = 'failed'/.test(t)) { Object.assign(promotions.find((p) => p.id === params[0]), { state: 'failed', error: params[1] }); return { rows: [] }; }
81
+ if (/SET app_host = COALESCE\(app_host, '\{\}'::jsonb\) \|\| \$2::jsonb/.test(t)) { inst.app_host = { ...inst.app_host, ...JSON.parse(params[1]) }; return { rows: [] }; }
82
+ if (/borrowed_key_enc = NULL/.test(t)) { cleared.push(params[0]); return { rows: [] }; }
83
+ return { rows: [] };
84
+ } };
85
+ const wrapped = { calls: r.calls, fetchImpl: async (url, init) => { if (init.method !== 'GET') order.push(`${init.method} ${url.replace('https://api.render.com/v1', '')}`); return r.fetchImpl(url, init); } };
86
+ const deps = { db, log: (l) => logs.push(l), secretBox: { decrypt: () => 'rnd_borrowed' },
87
+ renderApiFactory: ({ token, ownerId }) => new RenderApi({ token, ownerId, dryRun: false, fetchImpl: wrapped.fetchImpl }) };
88
+ const it = { id: 61, action: 'render-promote', borrowed_key_enc: 'v1.enc', created_at: new Date().toISOString(), ...intent };
89
+ return { r, inst, order, promotions, cleared, run: () => promoteInstance(inst, deps, it) };
90
+ }
91
+ const writes = (h) => h.r.calls.filter((c) => c.method !== 'GET').map((c) => `${c.method} ${c.path}`);
92
+
93
+ console.log('the runner — promote:');
94
+ await ta('promoting deploys EXACTLY the chosen commit to the live app, recorded first, live switched to manual', async () => {
95
+ const h = harness();
96
+ const out = await h.run();
97
+ assert.equal(out.ok, true, out.error);
98
+ assert.deepEqual(h.order, ['record', 'PATCH /services/srv-live0001', 'POST /services/srv-live0001/deploys'], 'the record comes before anything is sent');
99
+ const deploy = h.r.calls.find((c) => c.method === 'POST');
100
+ assert.deepEqual(deploy.body, { commitId: NEW }, 'that commit, not whatever main is now');
101
+ assert.deepEqual(h.r.calls.find((c) => c.method === 'PATCH').body, { autoDeploy: 'no' });
102
+ assert.equal(h.promotions.length, 1);
103
+ assert.deepEqual([h.promotions[0].commit_id, h.promotions[0].from_commit_id, h.promotions[0].requested_by, h.promotions[0].state, h.promotions[0].render_deploy_id],
104
+ [NEW, OLD, '7', 'sent', 'dep-live-new'], 'who, which change, from which, and what Render answered');
105
+ assert.equal(h.inst.app_host.liveAutoDeploy, 'off');
106
+ assert.equal(h.inst.app_host.promote.state, 'sent');
107
+ assert.deepEqual(h.cleared, [61], 'the key is cleared');
108
+ });
109
+
110
+ await ta('an older change that ran on the sandbox can be promoted too', async () => {
111
+ const h = harness({ ask: { commitId: MID } });
112
+ assert.equal((await h.run()).ok, true);
113
+ assert.deepEqual(h.r.calls.find((c) => c.method === 'POST').body, { commitId: MID });
114
+ });
115
+
116
+ await ta('a change that never ran on the sandbox is refused, and nothing is sent or recorded', async () => {
117
+ for (const commitId of [BAD, 'eeeeeee5555555555555555555555555555555ee']) {
118
+ const h = harness({ ask: { commitId } });
119
+ const out = await h.run();
120
+ assert.equal(out.ok, false);
121
+ assert.equal(out.terminal, true);
122
+ assert.match(out.error, /has not run on the sandbox/);
123
+ assert.deepEqual(writes(h), [], 'no write to Render');
124
+ assert.equal(h.promotions.length, 0);
125
+ assert.deepEqual(h.cleared, [61]);
126
+ assert.equal(h.inst.app_host.promote.state, 'failed');
127
+ }
128
+ });
129
+
130
+ await ta('if the live app moved since the owner looked, nothing is done', async () => {
131
+ const h = harness({ ask: { expectLiveCommit: MID } });
132
+ const out = await h.run();
133
+ assert.equal(out.ok, false);
134
+ assert.match(out.error, /changed since you looked/);
135
+ assert.deepEqual(writes(h), []);
136
+ assert.equal(h.promotions.length, 0);
137
+ });
138
+
139
+ await ta('a change already live is refused', async () => {
140
+ const h = harness({ ask: { commitId: OLD } });
141
+ const out = await h.run();
142
+ assert.match(out.error, /already on your live app/);
143
+ assert.deepEqual(writes(h), []);
144
+ });
145
+
146
+ await ta('Render refusing the deploy leaves a failed record, said plainly', async () => {
147
+ const h = harness({ render: { 'POST /services/srv-live0001/deploys': new Error('build minutes exhausted') } });
148
+ const out = await h.run();
149
+ assert.equal(out.ok, false);
150
+ assert.match(out.error, /build minutes exhausted/);
151
+ assert.equal(h.promotions[0].state, 'failed');
152
+ assert.match(h.promotions[0].error, /build minutes exhausted/);
153
+ assert.deepEqual(h.cleared, [61]);
154
+ });
155
+
156
+ await ta('no key, a stale key, no sandbox or no app: refused before Render is called', async () => {
157
+ const cases = [
158
+ [{ intent: { borrowed_key_enc: null } }, /no Render key/],
159
+ [{ intent: { created_at: new Date(Date.now() - 3600_000).toISOString() } }, /waited too long/],
160
+ [{ host: { sandbox: {} } }, /no sandbox/],
161
+ [{ host: { serviceId: null } }, /no app on Render/],
162
+ [{ ask: { commitId: 'not-a-commit' } }, /no change was chosen/],
163
+ ];
164
+ for (const [opts, re] of cases) {
165
+ const h = harness(opts);
166
+ const out = await h.run();
167
+ assert.equal(out.ok, false);
168
+ assert.match(out.error, re);
169
+ assert.equal(h.r.calls.length, 0, `no call to Render for ${re}`);
170
+ }
171
+ });
172
+
173
+ console.log('\nthe look — what is waiting:');
174
+ function lookFetch(over = {}) {
175
+ const answers = {
176
+ '/services/srv-live0001': { id: 'srv-live0001', ownerId: OURS },
177
+ '/services/srv-sbx00001': { id: 'srv-sbx00001', ownerId: OURS },
178
+ '/services/srv-live0001/deploys?limit=10': [dep('dep-l1', OLD, 'live')],
179
+ '/services/srv-sbx00001/deploys?limit=30': [dep('dep-s4', BAD, 'build_failed'), dep('dep-s3', NEW, 'live'), dep('dep-s2', MID, 'deactivated'), dep('dep-s1', OLD, 'deactivated')],
180
+ ...over,
181
+ };
182
+ const seen = [];
183
+ const fetchImpl = async (url, init) => {
184
+ const path = url.replace('https://api.render.com/v1', '');
185
+ seen.push({ method: init.method, path });
186
+ const a = answers[path];
187
+ return { ok: a !== undefined, status: a !== undefined ? 200 : 404, json: async () => a };
188
+ };
189
+ return { seen, fetchImpl };
190
+ }
191
+ await ta('lists what ran on the sandbox since the change live runs, newest first, read-only', async () => {
192
+ const f = lookFetch();
193
+ const out = await lookupPromotion('rnd_k', { ownerId: OURS, liveServiceId: 'srv-live0001', sandboxServiceId: 'srv-sbx00001', fetchImpl: f.fetchImpl });
194
+ assert.equal(out.live.commit_id, OLD);
195
+ assert.deepEqual(out.waiting.map((w) => w.commit_id), [NEW, MID], 'the failed build is not waiting, and live’s own change is not');
196
+ assert.equal(out.waiting[0].short, NEW.slice(0, 7));
197
+ assert.ok(f.seen.every((c) => c.method === 'GET'), 'GET only');
198
+ });
199
+ await ta('a service from another workspace stops the look', async () => {
200
+ const f = lookFetch({ '/services/srv-sbx00001': { id: 'srv-sbx00001', ownerId: 'tea-theirs000001' } });
201
+ await assert.rejects(lookupPromotion('rnd_k', { ownerId: OURS, liveServiceId: 'srv-live0001', sandboxServiceId: 'srv-sbx00001', fetchImpl: f.fetchImpl }), /another workspace/);
202
+ });
203
+ await ta('live in step with the sandbox: nothing waits', async () => {
204
+ const f = lookFetch({ '/services/srv-live0001/deploys?limit=10': [dep('dep-l2', NEW, 'live')] });
205
+ const out = await lookupPromotion('rnd_k', { ownerId: OURS, liveServiceId: 'srv-live0001', sandboxServiceId: 'srv-sbx00001', fetchImpl: f.fetchImpl });
206
+ assert.deepEqual(out.waiting, []);
207
+ });
208
+
209
+ console.log('\nthe web tier:');
210
+ await ta('enqueuePromote: the intent with the key and the chosen change on the row, in one statement', async () => {
211
+ const guard = require('../modules/provisioning/control-plane-guard.js');
212
+ const orig = guard.assertIntentAllowed;
213
+ guard.assertIntentAllowed = async () => {};
214
+ try {
215
+ const seen = [];
216
+ const db = { query: async (t, p) => { seen.push({ t: t.replace(/\s+/g, ' '), p }); return { rows: [{ id: 5 }] }; } };
217
+ await renderPromote.enqueuePromote(db, 18, { keyEnc: 'v1.enc', commitId: NEW, expectLiveCommit: OLD, requestedBy: 7 });
218
+ assert.equal(seen.length, 1);
219
+ assert.match(seen[0].t, /'render-promote'/);
220
+ const ask = JSON.parse(seen[0].p[3]);
221
+ assert.deepEqual([ask.state, ask.commitId, ask.expectLiveCommit, ask.requestedBy], ['requested', NEW, OLD, '7']);
222
+ } finally { guard.assertIntentAllowed = orig; }
223
+ });
224
+ await ta('promoteView: whether the owner can, and whether live still follows main', () => {
225
+ const ok = renderPromote.promoteView({ status: 'active', app_host: { platform: 'render', serviceId: 'srv-live0001', liveAutoDeploy: 'off', sandbox: { state: 'live', serviceId: 'srv-sbx00001' } } });
226
+ assert.deepEqual([ok.can, ok.live_follows_main], [true, false]);
227
+ const no = renderPromote.promoteView({ status: 'active', app_host: { platform: 'render', serviceId: 'srv-live0001' } });
228
+ assert.equal(no.can, false);
229
+ assert.match(no.reason, /no sandbox/);
230
+ assert.equal(no.live_follows_main, true);
231
+ });
232
+
233
+ // ---- the routes ----
234
+ const provisioning = require('../modules/provisioning/provisioning.js');
235
+ const secretBox = require('../src/bongos/secret-box.js');
236
+ const kapi = require('../src/module-api.js');
237
+ let instance = null, enq = [], builder = { id: 7 };
238
+ provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
239
+ provisioning.recordEvent = async () => {};
240
+ renderPromote.enqueuePromote = async (_db, id, args) => { enq.push({ id, ...args }); return { intent: { id: 9 } }; };
241
+ secretBox.isConfigured = () => true;
242
+ secretBox.encrypt = (plain) => `v1.sealed(${plain.length})`;
243
+ kapi.requireBuilder = (req, _res, next) => { req.builder = builder; next(); };
244
+ kapi.logger = () => ({ error() {}, warn() {}, info() {}, debug() {} });
245
+ Object.defineProperty(kapi, 'pool', { configurable: true, get: () => ({ query: async () => ({ rows: [] }) }) });
246
+ require('../modules/provisioning/env-manifest.js').listEnvVars = async () => [];
247
+ const f = lookFetch();
248
+ const express = require('express');
249
+ const app = express();
250
+ app.use(express.json());
251
+ app.use((_req, res, next) => { res.fail = (code, o) => res.status(typeof o === 'number' ? o : (o && o.status) || 400).json({ error: { code, ...(typeof o === 'object' ? o : {}) } }); next(); });
252
+ app.use('/api/bongos', require('../modules/provisioning/routes/render-standup.js')({ fetchImpl: f.fetchImpl }));
253
+ const server = app.listen(0);
254
+ await new Promise((r) => server.once('listening', r));
255
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
256
+ function call(method, path, body) {
257
+ return new Promise((resolve, reject) => {
258
+ const req = http.request(`${base}${path}`, { method, headers: { 'Content-Type': 'application/json' } }, (res) => {
259
+ let d = ''; res.on('data', (c) => { d += c; });
260
+ res.on('end', () => resolve({ status: res.statusCode, body: d ? JSON.parse(d) : null, raw: d }));
261
+ });
262
+ req.on('error', reject);
263
+ req.end(body ? JSON.stringify(body) : undefined);
264
+ });
265
+ }
266
+ const KEY = 'rnd_abcdefghijklmnopqrstuvwx';
267
+ const READY = { owner_builder_id: 7, slug: 'demo', status: 'active', app_host: { platform: 'render', ownerId: OURS, serviceId: 'srv-live0001', state: 'live', sandbox: { state: 'live', serviceId: 'srv-sbx00001' } } };
268
+
269
+ await ta('POST …/render/promote/look by the owner: what is waiting, the key never sent back', async () => {
270
+ instance = READY;
271
+ const r = await call('POST', '/provisioning/instances/18/render/promote/look', { key: KEY });
272
+ assert.equal(r.status, 200, r.raw);
273
+ assert.deepEqual(r.body.waiting.map((w) => w.commit_id), [NEW, MID]);
274
+ assert.ok(!r.raw.includes(KEY));
275
+ });
276
+ await ta('POST …/render/promote by the owner: queued with ciphertext, the change and what live ran', async () => {
277
+ instance = READY; enq = [];
278
+ const r = await call('POST', '/provisioning/instances/18/render/promote', { key: KEY, commit_id: NEW, expect_live_commit: OLD });
279
+ assert.equal(r.status, 200, r.raw);
280
+ assert.deepEqual(enq, [{ id: 18, keyEnc: `v1.sealed(${KEY.length})`, commitId: NEW, expectLiveCommit: OLD, requestedBy: 7 }]);
281
+ assert.ok(!r.raw.includes(KEY));
282
+ });
283
+ await ta('nobody but the owner can promote or look (the stranger’s 404)', async () => {
284
+ instance = { ...READY, owner_builder_id: 99 }; enq = [];
285
+ assert.equal((await call('POST', '/provisioning/instances/18/render/promote', { key: KEY, commit_id: NEW })).status, 404);
286
+ assert.equal((await call('POST', '/provisioning/instances/18/render/promote/look', { key: KEY })).status, 404);
287
+ assert.equal(enq.length, 0);
288
+ });
289
+ await ta('no sandbox, or no chosen change: refused, nothing queued', async () => {
290
+ instance = { ...READY, app_host: { ...READY.app_host, sandbox: null } }; enq = [];
291
+ let r = await call('POST', '/provisioning/instances/18/render/promote', { key: KEY, commit_id: NEW });
292
+ assert.equal(r.status, 409);
293
+ assert.match(r.body.error.message, /no sandbox/);
294
+ instance = READY;
295
+ r = await call('POST', '/provisioning/instances/18/render/promote', { key: KEY, commit_id: 'main' });
296
+ assert.equal(r.status, 400);
297
+ assert.equal(enq.length, 0);
298
+ });
299
+ await ta('GET …/render carries whether the owner can promote', async () => {
300
+ instance = READY;
301
+ const r = await call('GET', '/provisioning/instances/18/render');
302
+ assert.equal(r.status, 200, r.raw);
303
+ assert.equal(r.body.promote.can, true);
304
+ assert.deepEqual(r.body.promote.recent, []);
305
+ });
306
+ server.close();
307
+
308
+ console.log(`\nrender_promote: ${passed} passed, ${failed} failed`);
309
+ process.exit(failed ? 1 : 0);
@@ -59,6 +59,7 @@ function fakeRender(over = {}) {
59
59
  serviceDetails: { url: LIVE_URL, plan: 'starter', runtime: 'python', region: 'oregon', envSpecificDetails: { buildCommand: 'pip install -r requirements.txt', startCommand: 'gunicorn app:app' } } },
60
60
  'GET /services/srv-sbx00001': { id: 'srv-sbx00001', name: 'demo-sandbox', ownerId: OURS, serviceDetails: { url: SB_URL, plan: 'free' } },
61
61
  'DELETE /services/srv-sbx00001': null,
62
+ 'PATCH /services/srv-live0001': { id: 'srv-live0001', ownerId: OURS, autoDeploy: 'no' },
62
63
  [SB_NAME_Q]: [],
63
64
  ...over,
64
65
  };
@@ -122,7 +123,10 @@ await ta('a new app with a database: the app, then its sandbox — free, follows
122
123
  const h = harness();
123
124
  const out = await h.run();
124
125
  assert.equal(out.ok, true, out.error);
125
- assert.deepEqual(writesTo(h.r.calls), ['POST /postgres', 'POST /services', 'POST /services'], 'database, live app, sandbox — and nothing else written');
126
+ assert.deepEqual(writesTo(h.r.calls), ['POST /postgres', 'POST /services', 'POST /services', 'PATCH /services/srv-live0001'],
127
+ 'database, live app, sandbox, then the live app switched to manual deploys (task 1005290) — and nothing else written');
128
+ assert.deepEqual(h.r.calls.find((c) => c.method === 'PATCH').body, { autoDeploy: 'no' }, 'from now on only a promotion moves the live app');
129
+ assert.equal(h.inst.app_host.liveAutoDeploy, 'off');
126
130
  const sb = created(h, 'demo-sandbox').body;
127
131
  assert.equal(sb.serviceDetails.plan, 'free');
128
132
  assert.equal(sb.branch, 'main');
@@ -153,11 +157,11 @@ await ta('an app with no database: the sandbox has none either, and says so', as
153
157
  assert.match(h.inst.app_host.sandbox.dbNote, /no database, so its sandbox has none/);
154
158
  });
155
159
 
156
- await ta('a linked (adopted) app: the sandbox copies its build, and the live service gets no write', async () => {
160
+ await ta('a linked (adopted) app: the sandbox copies its build, and the live service only stops auto-deploying', async () => {
157
161
  const h = harness({ host: { mode: 'adopt', serviceId: 'srv-live0001', runtime: undefined, buildCommand: undefined, startCommand: undefined, database: undefined } });
158
162
  const out = await h.run();
159
163
  assert.equal(out.ok, true, out.error);
160
- assert.deepEqual(writesTo(h.r.calls), ['POST /services'], 'the only write is the sandbox');
164
+ assert.deepEqual(writesTo(h.r.calls), ['POST /services', 'PATCH /services/srv-live0001'], 'the sandbox, and live switched to manual deploys');
161
165
  const sb = created(h, 'demo-sandbox').body;
162
166
  assert.equal(sb.serviceDetails.runtime, 'python');
163
167
  assert.equal(sb.serviceDetails.region, 'oregon');
@@ -186,6 +190,7 @@ await ta('the sandbox failing never fails the app, and the key is still cleared'
186
190
  assert.equal(h.inst.app_host.state, 'deploying');
187
191
  assert.equal(h.inst.app_host.sandbox.state, 'failed');
188
192
  assert.match(h.inst.app_host.sandbox.error, /free instance limit reached/);
193
+ assert.ok(!h.r.calls.some((c) => c.method === 'PATCH'), 'with no sandbox, live keeps deploying every merge — work always has somewhere to land');
189
194
  assert.deepEqual(h.intentWrites, [44], 'the key is cleared');
190
195
  });
191
196
 
@@ -210,7 +215,7 @@ await ta('an app already live adds its sandbox: the live app is not created, rea
210
215
  const h = harness({ host: { postgresId: 'dpg-new', serviceId: 'srv-live0001', state: 'live', sandbox: { state: 'requested' } } });
211
216
  const out = await h.run();
212
217
  assert.equal(out.ok, true, out.error);
213
- assert.deepEqual(writesTo(h.r.calls), ['POST /services'], 'one write: the sandbox');
218
+ assert.deepEqual(writesTo(h.r.calls), ['POST /services', 'PATCH /services/srv-live0001'], 'the sandbox, then live switched to manual deploys — never redeployed');
214
219
  assert.ok(created(h, 'demo-sandbox'));
215
220
  assert.equal(h.inst.app_host.state, 'live', 'the app stays live — not knocked back to building');
216
221
  assert.equal(h.inst.app_host.sandbox.state, 'deploying');