@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.
- package/.bongos-core.json +46 -36
- package/.claude/skills/new-project/SKILL.md +3 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +8 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0055-server-mediated-branch-publish.md +2 -0
- package/docs/adr/0133-github-app-manifest-onboarding-signin.md +2 -2
- package/docs/api/openapi.json +160 -5
- package/docs/api-reference.md +4 -2
- package/docs/recipes/ops-gotchas.md +8 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/lifecycle/github-push.js +130 -18
- package/modules/lifecycle/publish-reconciler.js +44 -1
- package/modules/lifecycle/routes/tasks.js +21 -4
- package/modules/provisioning/migrations/provisioning_009_project_settings.sql +19 -0
- package/modules/provisioning/onboard-plan.js +1 -0
- package/modules/provisioning/provisioning.js +59 -10
- package/modules/provisioning/routes/provisioning.js +67 -0
- package/modules/provisioning/tests/provisioning.mjs +43 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +19 -2
- package/scripts/gds/onboard.js +11 -3
- package/scripts/gds/provision.js +27 -4
- package/src/module-api.js +5 -1
- package/tests/onboard.mjs +5 -1
- package/tests/provision.mjs +30 -2
- package/tests/provisioning_settings.mjs +200 -0
- package/tests/publish_branch_route.mjs +153 -2
- 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
|
|
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(),
|
|
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 }],
|