@bongos/core 1.20.62 → 1.20.64

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.
@@ -90,6 +90,8 @@ The sections above are the *manual* `bongos upgrade`. The **subscription** runs
90
90
 
91
91
  A **major** is never automatic; prereleases are never auto-targeted.
92
92
 
93
+ **Crossing a minor from /deploy** (task [1004405](https://cloudbongos.com/builders#/task/1004405)): set the project's update rule to **Fixes and new releases** (`minor`), or press **Allow new releases and move** on the refused move — either is the explicit "this crosses a minor version" choice. That only works once the box runs a core whose runner reads the rule (core 1.20.29 or later, task [1004445](https://cloudbongos.com/builders#/task/1004445)). A box on anything older runs the old runner, which bounds every move to `patch` whatever the rule says, so its **first** crossing is the manual `upgrade.js --to <version>` above; every later one goes through /deploy.
94
+
93
95
  **Where the channel comes from** ([ADR 0357](../adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md), task [1004468](https://cloudbongos.com/builders#/task/1004468)): the rule set on **/deploy** (`provisioning_instances.update_channel`) wins for any roster entry whose `slug` matches a project row. The roster's `channel` below is only the fallback, used when no row matches or the sweep can't read the database. The sweep reads the control plane's DB from `UPDATE_CHANNEL_DB` (a db name or connection string), else `DATABASE_URL` / `PGDATABASE`. On a control plane, set `UPDATE_CHANNEL_DB`, never `PGDATABASE`: the sweep's env reaches every upgrade it spawns, so a tenant entry with no database of its own would migrate against the control plane's. Every run prints each entry's channel and its source.
94
96
 
95
97
  **Enrolling an instance** — add it to `config/update-subscriptions.json` (instance-side) (ships empty). `env` carries per-instance vars merged into the upgrade child — set `PGDATABASE` so `migrate` hits the right DB (see the Gotchas):
@@ -53,6 +53,13 @@ const SERVICE_RE = /^srv-[a-z0-9]{8,64}$/;
53
53
 
54
54
  // The reason a request cannot be queued, in the owner's words, or null. `body` is the
55
55
  // validated request body.
56
+ //
57
+ // The hall is never the app (ADR 0345 D1). A command that runs the Bongos core itself puts
58
+ // a second, database-less copy of the project's hall on Render, where it never answers and
59
+ // the deploy times out — measured live on project 43 (task 1004483). It cannot see through
60
+ // the scaffold's own `npm start`, which still runs the hall; that is task 1004484.
61
+ const HALL_COMMAND_RE = /platform-server\.js|node_modules[\\/]@bongos[\\/]core/i;
62
+ const HALL_REFUSAL = 'That command starts the project’s Builders’ hall, which we already host — not your app. Give the command that starts your app.';
56
63
  function badRequest(body) {
57
64
  const b = body || {};
58
65
  if (!KEY_RE.test(String(b.key || ''))) return 'That doesn’t look like a Render API key. It starts with “rnd_” — create one under Account Settings → API Keys in Render.';
@@ -65,6 +72,7 @@ function badRequest(body) {
65
72
  if (!RUNTIMES.includes(b.runtime)) return 'Say what your app is built with — for example Node or Python, or Docker to use your repository’s Dockerfile.';
66
73
  if (b.runtime === 'docker' && (b.build_command || b.start_command)) return 'Docker builds and starts your app from your Dockerfile, so leave the commands out.';
67
74
  if (b.runtime !== 'docker' && !String(b.start_command || '').trim()) return 'Say how your app starts — the command Render should run, for example “npm start”.';
75
+ if (HALL_COMMAND_RE.test(String(b.start_command || '')) || HALL_COMMAND_RE.test(String(b.build_command || ''))) return HALL_REFUSAL;
68
76
  }
69
77
  if (b.web_plan != null && !WEB_PLANS.includes(b.web_plan)) return `“${b.web_plan}” is not a Render web plan.`;
70
78
  if (b.db_plan != null && !DB_PLANS.includes(b.db_plan)) return `“${b.db_plan}” is not a Render database plan.`;
@@ -1696,6 +1696,7 @@ summary{min-height:24px;padding:3px 0;}
1696
1696
  <span>Create the project now. You can choose where your app runs afterwards.</span></span>
1697
1697
  </label>
1698
1698
  </fieldset>
1699
+ <p class="renderWhy" id="renderLater" role="status" hidden>Your new repository has no app in it yet, so there is nothing for Render to run today. Once your builders have written the app, set it up on Render from your project’s page — it takes a minute, and asks for your Render key once. Nothing is created on Render now.</p>
1699
1700
  <div class="renderSetup" id="renderSetup" hidden>
1700
1701
  <p class="renderWhy">Your Render key is used once, to create your app on your own Render account, and then thrown away — it is not kept, not even in this browser. Render bills you directly. A key can act on your whole Render account, so only paste it here. <a href="https://dashboard.render.com/u/settings#api-keys" target="_blank" rel="noopener noreferrer">Create a key in Render</a></p>
1701
1702
  <div class="renderKeyRow">
@@ -5527,7 +5528,8 @@ summary{min-height:24px;padding:3px 0;}
5527
5528
  }
5528
5529
  function appRow() {
5529
5530
  if (appPlatformChoice() !== 'render') return 'Not chosen yet — you can decide later';
5530
- var ws = esc(state.renderOwnerName || state.renderOwnerId || '');
5531
+ if (renderLater()) return 'Render — set up from your project’s page once your app has code. Nothing is created on Render now.';
5532
+ var ws =esc(state.renderOwnerName || state.renderOwnerId || '');
5531
5533
  if (state.renderMode === 'adopt') return 'Render, workspace ' + ws + ' — link <code>' + esc(state.renderServiceName || state.renderServiceId || '') + '</code>, changing nothing on it';
5532
5534
  var plan = planLabel(RENDER_WEB_PLANS, state.renderPlan || 'starter');
5533
5535
  var db = state.renderDb ? ', with a database (' + planLabel(RENDER_DB_PLANS, state.renderDbPlan || 'basic_256mb') + ')' : ', no database';
@@ -5543,13 +5545,24 @@ summary{min-height:24px;padding:3px 0;}
5543
5545
  1004184). Here it reads and writes the draft (`state`), so every answer but the key
5544
5546
  survives a reload. The key is never on state, so a resumed draft asks for it again,
5545
5547
  and says why. */
5548
+ /* A brand-new repository holds only the hall's scaffold — no app — so Render is set
5549
+ up now only for an ADOPTED repo (task 1004483, owner call 2026-10-01). Set up on a
5550
+ fresh one, Render ran the repo's `npm start`, which is the hall, and timed out.
5551
+ Choosing Render for a fresh repo is still an answer: the step says when and where
5552
+ it happens, and nothing is sent to Render at Create. */
5553
+ function renderNow() { return appPlatformChoice() === 'render' && state.mode === 'adopt'; }
5554
+ function renderLater() { return appPlatformChoice() === 'render' && state.mode !== 'adopt'; }
5546
5555
  var rc = renderConnect($, state, {
5547
5556
  save: saveDraft,
5548
5557
  repo: function () { return String(state.owner || '') + '/' + String(state.repo || ''); },
5549
- on: function () { return appPlatformChoice() === 'render'; },
5558
+ on: renderNow,
5550
5559
  noKey: 'Check your Render key first — or choose “Decide later”.'
5551
5560
  });
5552
- function syncRenderSetup() { rc.sync(); }
5561
+ function syncRenderSetup() {
5562
+ var later = $('renderLater');
5563
+ if (later) later.hidden = !renderLater();
5564
+ rc.sync();
5565
+ }
5553
5566
  function forgetRenderAnswers() { rc.forget(); }
5554
5567
  function validateRender() { return rc.validate(); }
5555
5568
  function renderRequestBody() { return rc.requestBody(); }
@@ -5557,7 +5570,13 @@ summary{min-height:24px;padding:3px 0;}
5557
5570
  // answer. The project exists either way; a refusal here is the APP's, and the done
5558
5571
  // panel says so in plain words.
5559
5572
  function sendRenderSetup(instanceId) {
5560
- if (appPlatformChoice() !== 'render' || !rc.hasKey() || !instanceId) return;
5573
+ if (renderLater()) {
5574
+ var later = $('doneRender');
5575
+ later.hidden = false; later.className = 'hint';
5576
+ later.textContent = 'Your app goes on Render once it has code: set it up from your project’s page when your builders have written it.';
5577
+ return;
5578
+ }
5579
+ if (!renderNow() || !rc.hasKey() || !instanceId) return;
5561
5580
  var body = renderRequestBody();
5562
5581
  rc.drop();
5563
5582
  var note = $('doneRender');
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.62",
3
+ "version": "1.20.64",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.62",
9
+ "version": "1.20.64",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.62",
3
+ "version": "1.20.64",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8497,5 +8497,21 @@
8497
8497
  "id": "1004307",
8498
8498
  "text": "The automatic update on cloudbongos.com will now always run the newest version of itself, instead of a copy stuck from August."
8499
8499
  }
8500
+ ],
8501
+ "1.20.63": [
8502
+ {
8503
+ "id": "1004405",
8504
+ "text": "When the Upgrade button refuses a jump to a new release line, it now tells you exactly which setting on /deploy to change, instead of pointing at a setting that didn't exist."
8505
+ }
8506
+ ],
8507
+ "1.20.64": [
8508
+ {
8509
+ "id": "1004483",
8510
+ "text": "When you start a brand-new project and choose Render for your app, the wizard no longer tries to put anything on Render straight away: a new repository has no app in it yet, and Render was running the project's builders' hall"
8511
+ },
8512
+ {
8513
+ "id": "1004390",
8514
+ "text": "When someone adds a prerequisite to a job that was already waiting in the \"available work\" list, the job used to stay on that list — a builder would pick it up, and only then be told they couldn't start it yet. The listing was"
8515
+ }
8500
8516
  ]
8501
8517
  }
@@ -137,11 +137,26 @@ function targetFault(to, available, installed, channel, channelAllows) {
137
137
  return `could not read the core version installed at this instance — nothing was changed. An upgrade that cannot say where it is starting from cannot be bounded by the '${channel}' channel.`;
138
138
  }
139
139
  if (channelAllows && !channelAllows(installed, to, channel)) {
140
- return `core ${to} is outside this project's '${channel}' update channel — nothing was changed. Change the channel first if this is deliberate.`;
140
+ return `core ${to} is outside this project's '${channel}' update channel — nothing was changed. ${channelNextStep(channel)}`;
141
141
  }
142
142
  return null;
143
143
  }
144
144
 
145
+ // The step that gets a channel-refused move through, named by the control a person can
146
+ // actually reach (task 1004405). The old line said "change the channel first" while no
147
+ // channel could be changed; the update rule on /deploy (task 1004445) is that control now,
148
+ // and its labels are the words used here. `minor` is the widest rule, so a move it refuses
149
+ // (a major, a prerelease) has no rule to widen to and is named as the by-hand step it is.
150
+ function channelNextStep(channel) {
151
+ if (channel === 'pinned') {
152
+ return 'Its update rule on /deploy is "Stay put"; change it to "Fixes only" or "Fixes and new releases", then move again.';
153
+ }
154
+ if (channel === 'minor') {
155
+ return 'No update rule moves a project across a major version or onto a prerelease; that move is made by hand with upgrade.js (docs/recipes/upgrading-the-core.md).';
156
+ }
157
+ return 'To take a new release line, set this project\'s update rule on /deploy to "Fixes and new releases" (or press "Allow new releases and move"), then move again.';
158
+ }
159
+
145
160
  // The update channel that bounds a move for THIS instance (task 1004445): the row's own
146
161
  // `update_channel`, normalised by update-channel.js so anything unknown or absent (a row
147
162
  // read before migration provisioning_029) falls back to DEFAULT_CHANNEL. PURE. One helper
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.62'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.64'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -219,6 +219,19 @@ await t('a version that is not published yet is refused, and the message says th
219
219
  assert.match(f, /tag appears several minutes before/);
220
220
  });
221
221
 
222
+ await t('the channel refusal names a step that EXISTS on /deploy, never "change the channel first" (task 1004405)', () => {
223
+ const patch = R.targetFault('1.20.1', ['1.19.1081', '1.20.1'], '1.19.1081', 'patch', () => false);
224
+ assert.ok(!/Change the channel first/.test(patch), 'the old wording named nothing a person could do');
225
+ assert.match(patch, /\/deploy/);
226
+ assert.match(patch, /Fixes and new releases/, 'patch → names the rule that admits a minor jump');
227
+ const pinned = R.targetFault('1.19.1082', ['1.19.1081', '1.19.1082'], '1.19.1081', 'pinned', () => false);
228
+ assert.match(pinned, /Stay put/);
229
+ assert.match(pinned, /Fixes only/);
230
+ const major = R.targetFault('2.0.0', ['1.19.1081', '2.0.0'], '1.19.1081', 'minor', () => false);
231
+ assert.match(major, /no update rule/i, 'minor is the widest rule, so pointing at a wider one would be a lie');
232
+ assert.match(major, /upgrading-the-core\.md/);
233
+ });
234
+
222
235
  await t('a registry that could not be read is refused DIFFERENTLY from a version that does not exist', () => {
223
236
  const empty = R.targetFault('1.19.760', [], '1.19.758', 'patch', () => true);
224
237
  assert.match(empty, /could not read the published core versions/);
@@ -188,4 +188,40 @@ await test('routes/tasks.js: POST uses the atomic batch writer and both writers
188
188
  assert.match(mapper, /CYCLE[\s\S]*409/);
189
189
  });
190
190
 
191
+ // ================= the writers never DEMOTE the target (task 1004390) =================
192
+ //
193
+ // The decision recorded in ADR 0015 ("A dependency added AFTER promotion"): an edge
194
+ // written against a task that is already `ready` leaves the status alone, and the
195
+ // CLAIMABLE FEED hides the row instead (core_264 / task 1004190). The branch that was
196
+ // rejected is the cheap one — demote the target to `backlog` here, no migration needed —
197
+ // so it is the one that will look like a tidy-up to a future reader. These tests make
198
+ // re-adding it go red: the writers must issue no write against `tasks` at all, which is
199
+ // what keeps a planner's edge from silently reversing a human's `POST /promote`.
200
+ //
201
+ // Narrow on purpose. `SELECT id FROM tasks` (the existence check) is legitimate and
202
+ // stays; what is forbidden is a MUTATION of the row.
203
+ const touchesTaskRows = (log) => log.filter((q) => /^(UPDATE|DELETE FROM|INSERT INTO) tasks\b/i.test(q.sql));
204
+
205
+ await test('addTaskDependencies writes edges only — it never updates the task it blocks', async () => {
206
+ const p = fakePool({ existing: [1, 2] });
207
+ await addTaskDependencies(1, [2], { pool: p });
208
+ assert.deepEqual(touchesTaskRows(p.log), [],
209
+ 'a dep edge must not demote its target — ADR 0015, the rejected branch');
210
+ assert.ok(!has(p.log, /'backlog'/), 'and nothing here names the backlog status');
211
+ });
212
+
213
+ await test('setTaskDependencies (the full-set replace) never updates the task either', async () => {
214
+ const p = fakePool({ existing: [1, 2] });
215
+ await setTaskDependencies(1, [2], { pool: p });
216
+ assert.deepEqual(touchesTaskRows(p.log), []);
217
+ // The one DELETE it does run is scoped to the edge table, not the task row.
218
+ assert.ok(has(p.log, /^DELETE FROM dependencies/));
219
+ });
220
+
221
+ await test('addDependency (the generic edge) never updates the from-node', async () => {
222
+ const p = fakePool();
223
+ await addDependency('task', 1, 'task', 2, { pool: p });
224
+ assert.deepEqual(touchesTaskRows(p.log), []);
225
+ });
226
+
191
227
  summary();
@@ -99,7 +99,9 @@ function load({ draft = {}, answers = {} } = {}) {
99
99
  },
100
100
  querySelectorAll(sel) { assert.equal(sel, '#appOpts input[name=appPlatform]'); return inputs; },
101
101
  };
102
- const state = Object.assign({ appPlatform: 'later', owner: 'example-owner', repo: 'spike' }, draft);
102
+ // mode 'adopt' by default: since task 1004483 the Render flow runs at creation only for an
103
+ // adopted repo — a fresh one has no app yet. The fresh-repo cases set mode themselves.
104
+ const state = Object.assign({ appPlatform: 'later', owner: 'example-owner', repo: 'spike', mode: 'adopt' }, draft);
103
105
  const fetch = async (url, init) => {
104
106
  const body = init && init.body ? JSON.parse(init.body) : null;
105
107
  calls.push({ url, method: init && init.method, body });
@@ -417,6 +419,37 @@ test('after Create the key is sent once, to the new project, and then dropped',
417
419
  assert.equal(w.calls.filter((c) => c.url.endsWith('/render')).length, 1, 'a second send has nothing to send');
418
420
  });
419
421
 
422
+ // task 1004483 — live on project 43: a fresh repo holds only the hall's scaffold, so Render
423
+ // ran the hall and timed out. A fresh repo records the choice and creates nothing on Render.
424
+ test('a brand-new repo choosing Render asks for no key, says when it happens, and sends nothing at Create', async () => {
425
+ const w = load({ answers: ANSWERS, draft: { appPlatform: 'render', mode: 'greenfield' } });
426
+ w.syncRenderSetup();
427
+ assert.equal(w.$('renderLater').hidden, false, 'the note shows');
428
+ assert.equal(w.$('renderSetup').hidden, true, 'no key, workspace or plan questions');
429
+ assert.equal(w.validateRender(), true, 'Next is not blocked on a key nobody needs yet');
430
+ assert.match(w.appRow(), /^Render — set up from your project’s page once your app has code\. Nothing is created on Render now\.$/);
431
+ w.sendRenderSetup(42);
432
+ await flush();
433
+ assert.equal(w.calls.filter((c) => c.url.endsWith('/render')).length, 0, 'nothing is sent to Render');
434
+ assert.equal(w.$('doneRender').hidden, false);
435
+ assert.match(w.$('doneRender').textContent, /once it has code: set it up from your project’s page/);
436
+ assert.equal(w.watches.length, 0, 'there is no app progress to watch');
437
+ });
438
+
439
+ test('switching the same draft to an adopted repo brings the full Render flow back', () => {
440
+ const w = load({ draft: { appPlatform: 'render', mode: 'greenfield' } });
441
+ w.syncRenderSetup();
442
+ assert.equal(w.$('renderLater').hidden, false);
443
+ w.state.mode = 'adopt';
444
+ w.syncRenderSetup();
445
+ assert.equal(w.$('renderLater').hidden, true);
446
+ assert.equal(w.$('renderSetup').hidden, false);
447
+ assert.equal(w.validateRender(), false, 'an adopted repo needs the key checked');
448
+ w.state.appPlatform = 'later';
449
+ w.syncRenderSetup();
450
+ assert.equal(w.$('renderLater').hidden, true, 'Decide later shows neither');
451
+ });
452
+
420
453
  test('a refused setup request says the project is still being created', async () => {
421
454
  const w = load({ answers: { ...ANSWERS, '/api/bongos/provisioning/instances/42/render': { status: 409, message: 'This project has no GitHub repository linked.' } }, draft: { appPlatform: 'render' } });
422
455
  w.typeKey(KEY);
@@ -136,6 +136,9 @@ for (const [name, body, re] of [
136
136
  ['an unknown runtime', { ...GREEN, runtime: 'cobol' }, /built with/],
137
137
  ['a native runtime with no start command', { ...GREEN, start_command: ' ' }, /how your app starts/],
138
138
  ['docker with commands', { ...GREEN, runtime: 'docker' }, /leave the commands out/],
139
+ // task 1004483 — the hall is never the app; on Render it has no database and times out
140
+ ['a start command that runs the hall', { ...GREEN, start_command: 'node node_modules/@bongos/core/src/platform-server.js' }, /starts the project’s Builders’ hall/],
141
+ ['the hall by its file alone', { ...GREEN, start_command: 'node platform-server.js' }, /starts the project’s Builders’ hall/],
139
142
  ['adopt saying how to build', { key: KEY, owner_id: 'tea-abcdefgh1234', mode: 'adopt', service_id: 'srv-abcdefgh1234', runtime: 'node' }, /exactly as it is/],
140
143
  ]) {
141
144
  await ta(`${name} → 400, nothing kept`, async () => {