@bongos/core 1.19.205 → 1.19.207

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 (32) hide show
  1. package/.bongos-core.json +46 -36
  2. package/.claude/skills/new-project/SKILL.md +3 -1
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +8 -1
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0055-server-mediated-branch-publish.md +2 -0
  9. package/docs/adr/0133-github-app-manifest-onboarding-signin.md +2 -2
  10. package/docs/api/openapi.json +160 -5
  11. package/docs/api-reference.md +4 -2
  12. package/docs/recipes/ops-gotchas.md +8 -1
  13. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  14. package/modules/lifecycle/github-push.js +130 -18
  15. package/modules/lifecycle/publish-reconciler.js +44 -1
  16. package/modules/lifecycle/routes/tasks.js +21 -4
  17. package/modules/provisioning/migrations/provisioning_009_project_settings.sql +19 -0
  18. package/modules/provisioning/onboard-plan.js +1 -0
  19. package/modules/provisioning/provisioning.js +59 -10
  20. package/modules/provisioning/routes/provisioning.js +67 -0
  21. package/modules/provisioning/tests/provisioning.mjs +43 -3
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/public-landing/projects.html +19 -2
  25. package/scripts/gds/onboard.js +11 -3
  26. package/scripts/gds/provision.js +27 -4
  27. package/src/module-api.js +5 -1
  28. package/tests/onboard.mjs +5 -1
  29. package/tests/provision.mjs +30 -2
  30. package/tests/provisioning_settings.mjs +200 -0
  31. package/tests/publish_branch_route.mjs +153 -2
  32. package/tests/publish_reconciler.mjs +63 -0
@@ -476,7 +476,7 @@ async function withEnv(vars, fn) {
476
476
  const APP_ENV = { GITHUB_APP_ID: '123', GITHUB_APP_INSTALLATION_ID: '456', GITHUB_APP_PRIVATE_KEY: APP_PRIVKEY };
477
477
  const NO_APP = { GITHUB_APP_ID: undefined, GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: undefined };
478
478
 
479
- await t('appConfigured / isConfigured: true only with App (all 3 vars) or PAT', async () => {
479
+ await t('appConfigured / isConfigured: true with App (id + key; installation id optional) or PAT', async () => {
480
480
  await withEnv({ ...NO_APP, GITHUB_PUSH_TOKEN: undefined }, async () => {
481
481
  assert.equal(gp.appConfigured(), false);
482
482
  assert.equal(gp.isConfigured(), false);
@@ -490,7 +490,11 @@ await t('appConfigured / isConfigured: true only with App (all 3 vars) or PAT',
490
490
  assert.equal(gp.isConfigured(), true, 'App alone configures it');
491
491
  });
492
492
  await withEnv({ GITHUB_APP_ID: '123', GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: APP_PRIVKEY, GITHUB_PUSH_TOKEN: undefined }, async () => {
493
- assert.equal(gp.appConfigured(), false, 'partial App config (no installation id) is NOT configured');
493
+ assert.equal(gp.appConfigured(), true, 'id + key ARE enough — the installation id is discovered from the repo (task 1003049)');
494
+ assert.equal(gp.isConfigured(), true);
495
+ });
496
+ await withEnv({ GITHUB_APP_ID: '123', GITHUB_APP_INSTALLATION_ID: '456', GITHUB_APP_PRIVATE_KEY: undefined, GITHUB_PUSH_TOKEN: undefined }, async () => {
497
+ assert.equal(gp.appConfigured(), false, 'no private key → not configured');
494
498
  assert.equal(gp.isConfigured(), false);
495
499
  });
496
500
  });
@@ -548,6 +552,153 @@ await t('getInstallationToken: App configured but the exchange fails → APP_TOK
548
552
  });
549
553
  });
550
554
 
555
+ // task 1003049 — a manifest-born instance knows its App id + key at cred-placement
556
+ // time but never the installation id: that only exists after the owner's one-click
557
+ // install. The server derives it live instead of demanding a third env var.
558
+ const APP_ENV_NO_INST = { GITHUB_APP_ID: '123', GITHUB_APP_INSTALLATION_ID: undefined, GITHUB_APP_PRIVATE_KEY: APP_PRIVKEY };
559
+
560
+ await t('getInstallationToken: no installation id in env → discovers it from the repo, then mints (task 1003049)', async () => {
561
+ await withEnv({ ...APP_ENV_NO_INST, GITHUB_PUSH_TOKEN: undefined }, async () => {
562
+ const urls = [];
563
+ const fetchImpl = async (url, opts = {}) => {
564
+ urls.push(url);
565
+ if (url.endsWith('/repos/someowner/somerepo/installation')) {
566
+ const authz = opts.headers.Authorization;
567
+ assert.ok(/^Bearer /.test(authz) && jwtSignatureVerifies(authz.slice(7), APP_PUBKEY), 'discovery authenticates with a valid App JWT');
568
+ return jsonRes(200, { id: 789 });
569
+ }
570
+ if (url.includes('/app/installations/789/access_tokens')) {
571
+ return jsonRes(201, { token: 'ghs_discovered', expires_at: '2999-01-01T00:00:00Z' });
572
+ }
573
+ throw new Error('unexpected ' + url);
574
+ };
575
+ const now = () => 1_700_000_000 * 1000;
576
+ const tok = await gp.getInstallationToken({ fetchImpl, now, repoInfo: REPO });
577
+ assert.equal(tok, 'ghs_discovered');
578
+ assert.ok(urls.some((u) => u.endsWith('/repos/someowner/somerepo/installation')), 'looked the installation up on the repo');
579
+ });
580
+ });
581
+
582
+ await t('getInstallationToken: env GITHUB_APP_INSTALLATION_ID still wins — no discovery call (the ADR 0055 v1 shape)', async () => {
583
+ await withEnv({ ...APP_ENV, GITHUB_PUSH_TOKEN: undefined }, async () => {
584
+ const fetchImpl = async (url) => {
585
+ if (url.endsWith('/installation')) throw new Error('must not discover when the env id is set');
586
+ return jsonRes(201, { token: 'ghs_env', expires_at: '2999-01-01T00:00:00Z' });
587
+ };
588
+ assert.equal(await gp.getInstallationToken({ fetchImpl, now: () => 1_700_000_000 * 1000, repoInfo: REPO }), 'ghs_env');
589
+ });
590
+ });
591
+
592
+ await t('getInstallationToken: App not installed on the repo → APP_NOT_INSTALLED carrying the install URL (task 1003049)', async () => {
593
+ await withEnv({ ...APP_ENV_NO_INST, GITHUB_PUSH_TOKEN: undefined }, async () => {
594
+ const fetchImpl = async (url) => {
595
+ if (url.endsWith('/repos/someowner/somerepo/installation')) return jsonRes(404, { message: 'Not Found' });
596
+ if (url.endsWith('api.github.com/app')) return jsonRes(200, { html_url: 'https://github.com/apps/mercury-signin' });
597
+ throw new Error('unexpected ' + url);
598
+ };
599
+ await assert.rejects(
600
+ () => gp.getInstallationToken({ fetchImpl, now: () => 1_700_000_000 * 1000, repoInfo: REPO }),
601
+ (e) => e.code === 'APP_NOT_INSTALLED'
602
+ && e.owner === 'someowner' && e.repo === 'somerepo'
603
+ && e.install_url === 'https://github.com/apps/mercury-signin/installations/new',
604
+ 'names the repo + the one-click install URL so the refusal is self-serve'
605
+ );
606
+ });
607
+ });
608
+
609
+ await t('discoverInstallationId: APP_NOT_INSTALLED is negative-cached for a short TTL (no per-poll re-probe)', async () => {
610
+ await withEnv({ ...APP_ENV_NO_INST, GITHUB_PUSH_TOKEN: undefined }, async () => {
611
+ let probes = 0;
612
+ const fetchImpl = async (url) => {
613
+ if (url.endsWith('/repos/someowner/somerepo/installation')) { probes++; return jsonRes(404, {}); }
614
+ if (url.endsWith('api.github.com/app')) return jsonRes(200, { html_url: 'https://github.com/apps/x' });
615
+ throw new Error('unexpected ' + url);
616
+ };
617
+ let nowMs = 1_700_000_000 * 1000;
618
+ const now = () => nowMs;
619
+ const call = () => gp.getInstallationToken({ fetchImpl, now, repoInfo: REPO });
620
+ await assert.rejects(call, (e) => e.code === 'APP_NOT_INSTALLED');
621
+ await assert.rejects(call, (e) => e.code === 'APP_NOT_INSTALLED');
622
+ assert.equal(probes, 1, 'the second poll inside the TTL reuses the cached refusal');
623
+ nowMs += 61_000;
624
+ await assert.rejects(call, (e) => e.code === 'APP_NOT_INSTALLED');
625
+ assert.equal(probes, 2, 'after the TTL the state is re-probed (an install must be noticed)');
626
+ });
627
+ });
628
+
629
+ await t('getInstallationToken: a stale DISCOVERED id is dropped on exchange failure — next call re-discovers (no restart needed)', async () => {
630
+ await withEnv({ ...APP_ENV_NO_INST, GITHUB_PUSH_TOKEN: undefined }, async () => {
631
+ let discoveries = 0, exchanges = 0;
632
+ const fetchImpl = async (url) => {
633
+ if (url.endsWith('/repos/someowner/somerepo/installation')) {
634
+ discoveries++;
635
+ return jsonRes(200, { id: discoveries === 1 ? 111 : 222 });
636
+ }
637
+ if (url.includes('/access_tokens')) {
638
+ exchanges++;
639
+ // the first (stale) id fails the exchange; the re-discovered one works
640
+ if (url.includes('/111/')) return jsonRes(404, { message: 'gone (uninstalled+reinstalled)' });
641
+ return jsonRes(201, { token: 'ghs_fresh', expires_at: '2999-01-01T00:00:00Z' });
642
+ }
643
+ throw new Error('unexpected ' + url);
644
+ };
645
+ const now = () => 1_700_000_000 * 1000;
646
+ await assert.rejects(
647
+ () => gp.getInstallationToken({ fetchImpl, now, repoInfo: REPO }),
648
+ (e) => e.code === 'APP_TOKEN_FAILED'
649
+ );
650
+ assert.equal(await gp.getInstallationToken({ fetchImpl, now, repoInfo: REPO }), 'ghs_fresh',
651
+ 'the stale id was dropped and re-discovery healed the exchange');
652
+ assert.equal(discoveries, 2);
653
+ assert.equal(exchanges, 2);
654
+ });
655
+ });
656
+
657
+ await t('foreignInstallations: installs beyond the repo owner are surfaced; own install is not; API miss → null (task 1003049 grade)', async () => {
658
+ await withEnv({ ...APP_ENV_NO_INST, GITHUB_PUSH_TOKEN: undefined }, async () => {
659
+ const roster = [
660
+ { id: 1, account: { login: 'SomeOwner' } }, // own (case-insensitive)
661
+ { id: 2, account: { login: 'stranger' } },
662
+ { id: 3, account: { login: 'another-org' } },
663
+ ];
664
+ const fetchImpl = async (url) => {
665
+ if (url.includes('/app/installations?')) return jsonRes(200, roster);
666
+ throw new Error('unexpected ' + url);
667
+ };
668
+ const foreign = await gp.foreignInstallations({ fetchImpl, now: () => 1_700_000_000 * 1000, repoInfo: REPO });
669
+ assert.deepEqual(foreign.map((f) => f.account).sort(), ['another-org', 'stranger']);
670
+ const miss = await gp.foreignInstallations({ fetchImpl: async () => jsonRes(500, {}), now: () => 1_700_000_000 * 1000, repoInfo: REPO });
671
+ assert.equal(miss, null, 'a watch that cannot tell says null, never a fake all-clear');
672
+ });
673
+ await withEnv({ ...NO_APP, GITHUB_PUSH_TOKEN: undefined }, async () => {
674
+ assert.equal(await gp.foreignInstallations({ fetchImpl: async () => { throw new Error('no'); } }), null,
675
+ 'unconfigured App → null');
676
+ });
677
+ });
678
+
679
+ await t('GET /tasks/:id/publish-status with no credential → a plain-sentence 503, not a bare code (task 1003049)', async () => {
680
+ await withEnv({ ...NO_APP, GITHUB_PUSH_TOKEN: undefined }, async () => {
681
+ const router = buildTasksRouter();
682
+ const layer = findRouteLayer(router, '/tasks/:id/publish-status', 'get');
683
+ const handler = layer.route.stack[layer.route.stack.length - 1].handle;
684
+ const out = { failCode: null, failOpts: null };
685
+ const res = {
686
+ status() { return this; },
687
+ json() { return this; },
688
+ fail(code, opts) { out.failCode = code; out.failOpts = opts; return this; },
689
+ };
690
+ // rank archon → gateTaskOwnership passes with no DB lookup, keeping the test hermetic.
691
+ const req = { params: { id: '77' }, query: { branch: 'claude/x' }, builder: { id: 95, rank: 'archon' } };
692
+ await handler(req, res);
693
+ assert.equal(out.failCode, 'push_unconfigured');
694
+ assert.equal(out.failOpts && out.failOpts.status, 503);
695
+ const msg = String((out.failOpts && out.failOpts.message) || '');
696
+ assert.match(msg, /publish credential/i, 'says WHAT is missing in a sentence');
697
+ assert.match(msg, /GitHub App/, 'says what to set up');
698
+ assert.match(msg, /GITHUB_PUSH_TOKEN/, 'names the PAT alternative');
699
+ });
700
+ });
701
+
551
702
  await t('resolveToken precedence: deps.token > App installation token > PAT > null', async () => {
552
703
  await withEnv({ ...APP_ENV, GITHUB_PUSH_TOKEN: 'pat' }, async () => {
553
704
  assert.equal(
@@ -212,6 +212,69 @@ test('no push credential → no-op (skipped: unconfigured), never touches the DB
212
212
  assert.equal(deps._calls.status.length, 0, 'must not call GitHub when unconfigured');
213
213
  });
214
214
 
215
+ // task 1003049 — an App with id + key but no repo installation passes isConfigured,
216
+ // then every per-branch call throws APP_NOT_INSTALLED into a best-effort catch:
217
+ // silent stranding. The sweep must instead say it loudly ONCE and park.
218
+ test('1003049 App configured but NOT installed → loud one-time WARN, sweep parked, heals when installed', async () => {
219
+ const deps = mkDeps({ configured: true, rows: [{ task_id: 1, builder_id: 9, worktree_name: 'wt' }] });
220
+ deps.warnState = {};
221
+ let installed = false;
222
+ deps.githubPush.appConfigured = () => true;
223
+ deps.githubPush.getInstallationToken = async () => {
224
+ if (!installed) {
225
+ throw Object.assign(new Error('APP_NOT_INSTALLED'), {
226
+ code: 'APP_NOT_INSTALLED', owner: 'someowner', repo: 'somerepo',
227
+ install_url: 'https://github.com/apps/x/installations/new',
228
+ });
229
+ }
230
+ return 'tok';
231
+ };
232
+ const r1 = await reconciler.runSweep(deps);
233
+ assert.equal(r1.skipped, 'app_not_installed');
234
+ assert.equal(deps._calls.status.length, 0, 'no per-branch GitHub calls while uninstallable');
235
+ const warns = () => deps._calls.errors.filter((m) => /not installed/.test(m));
236
+ assert.equal(warns().length, 1, 'warned exactly once');
237
+ assert.match(warns()[0], /someowner\/somerepo/, 'names the repo');
238
+ assert.match(warns()[0], /installations\/new/, 'carries the install URL');
239
+ const r2 = await reconciler.runSweep(deps);
240
+ assert.equal(r2.skipped, 'app_not_installed');
241
+ assert.equal(warns().length, 1, 'the streak stays quiet');
242
+ installed = true;
243
+ const r3 = await reconciler.runSweep(deps);
244
+ assert.ok(!r3.skipped, 'an installed App lets the sweep run again');
245
+ });
246
+
247
+ test('1003049 foreign-install watch: WARNs once per streak on installs beyond the repo owner, re-arms when clean', async () => {
248
+ const deps = mkDeps({ configured: true, rows: [] });
249
+ deps.warnState = {};
250
+ let roster = [{ id: 2, account: 'stranger' }];
251
+ deps.githubPush.appConfigured = () => true;
252
+ deps.githubPush.getInstallationToken = async () => 'tok';
253
+ deps.githubPush.foreignInstallations = async () => roster;
254
+ const warns = () => deps._calls.errors.filter((m) => /beyond the repo owner/.test(m));
255
+ await reconciler.runSweep(deps);
256
+ assert.equal(warns().length, 1, 'a foreign install is surfaced loudly');
257
+ assert.match(warns()[0], /stranger/, 'names the account');
258
+ await reconciler.runSweep(deps);
259
+ assert.equal(warns().length, 1, 'the streak stays quiet');
260
+ roster = [];
261
+ await reconciler.runSweep(deps);
262
+ roster = [{ id: 9, account: 'other' }];
263
+ await reconciler.runSweep(deps);
264
+ assert.equal(warns().length, 2, 'a clean roster re-arms the warning');
265
+ });
266
+
267
+ test('1003049 a transient App-token exchange failure does NOT park the sweep', async () => {
268
+ const deps = mkDeps({ configured: true, rows: [] });
269
+ deps.warnState = {};
270
+ deps.githubPush.appConfigured = () => true;
271
+ deps.githubPush.getInstallationToken = async () => {
272
+ throw Object.assign(new Error('boom'), { code: 'APP_TOKEN_FAILED', status: 500 });
273
+ };
274
+ const r = await reconciler.runSweep(deps);
275
+ assert.ok(!r.skipped, 'transient exchange failures are per-branch territory, not a park');
276
+ });
277
+
215
278
  test('a landed branch → shipTask + broadcast fire, reconciled=1, attributed to the claim holder', async () => {
216
279
  const deps = mkDeps({
217
280
  rows: [{ task_id: 1096, builder_id: 7, worktree_name: 't1096-helpers', published_head_sha: ON_MAIN }],