@bongos/core 1.18.0 → 1.18.3
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 +132 -322
- package/README.md +1 -1
- package/config/branding.neutral.json +5 -1
- package/docs/adr/0142-project-context-is-prose-only.md +41 -0
- package/docs/adr/0143-onboarding-repo-picker-oauth-scope.md +35 -0
- package/docs/adr/README.md +5 -2
- package/docs/api/openapi.json +1789 -450
- package/docs/api-reference.md +52 -5
- package/docs/file-map.md +7 -7
- package/docs/recipes/packaging-the-core.md +13 -0
- package/docs/recipes/upgrading-the-core.md +5 -2
- package/migrations/039_newcomer_friendly_column.sql +5 -0
- package/migrations/102_remove_box_first_provision_approval.sql +4 -0
- package/migrations/core_192_github_oauth_tokens.sql +33 -0
- package/modules/hall-ui/public/my-projects.html +78 -0
- package/modules/hall-ui/public/my-projects.js +142 -0
- package/modules/hall-ui/public/scouting.html +45 -0
- package/modules/hall-ui/public/scouting.js +73 -0
- package/modules/status-ui/public/explore.html +54 -0
- package/modules/status-ui/public/explore.js +67 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/create.html +211 -14
- package/scripts/gds/cli-lib.js +11 -5
- package/scripts/gds/lint-migrations.js +102 -0
- package/scripts/gds/login.js +42 -10
- package/scripts/gds/mirror-redact.js +5 -0
- package/scripts/gds/package-core.js +263 -4
- package/scripts/gds/provision.js +145 -7
- package/scripts/gds/publish-manifest.js +15 -0
- package/scripts/gds/run-unit-tests.js +38 -14
- package/scripts/gds/setup.js +41 -0
- package/scripts/gds/ship.js +115 -31
- package/scripts/gds/upgrade.js +147 -5
- package/scripts/migrate.sh +10 -0
- package/src/bongos/auth.js +362 -5
- package/src/bongos/db.js +66 -0
- package/src/bongos/github-repos.js +93 -0
- package/src/bongos/routes/auth.js +93 -11
- package/src/bongos/routes/github.js +90 -0
- package/src/bongos/routes/instance.js +15 -3
- package/src/bongos/routes.js +2 -0
- package/src/branding.js +17 -2
- package/src/instance-config.js +16 -0
- package/src/module-api.js +29 -1
- package/tests/branding.mjs +29 -23
- package/tests/github_repos_helpers.mjs +91 -0
- package/tests/github_token_store.mjs +121 -0
- package/tests/idp_device_exchange.mjs +105 -0
- package/tests/idp_federation.mjs +100 -0
- package/tests/migrations_forward_safe.mjs +86 -0
- package/tests/module_api.mjs +1 -1
- package/tests/package_core.mjs +246 -1
- package/tests/provision.mjs +157 -0
- package/tests/pruned_lockfile_integrity.mjs +79 -0
- package/tests/ship_ci_deploy.mjs +60 -0
- package/tests/upgrade.mjs +254 -5
- package/.gitignore +0 -91
- package/docs/adr/0139-platform-history-migration-to-cloudbongos.md +0 -94
- package/docs/adr/0140-cloudbongos-standalone-reprovision.md +0 -43
- package/scripts/hall-preview/fixtures/access-requests.json +0 -20
- package/scripts/hall-preview/fixtures/achievements.json +0 -76
- package/scripts/hall-preview/fixtures/analytics__builder.json +0 -251
- package/scripts/hall-preview/fixtures/audit-log.json +0 -68
- package/scripts/hall-preview/fixtures/blockers__detail.json +0 -12
- package/scripts/hall-preview/fixtures/box__me.json +0 -5
- package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +0 -69
- package/scripts/hall-preview/fixtures/builders__active-claims.json +0 -1
- package/scripts/hall-preview/fixtures/builders__directory.json +0 -81
- package/scripts/hall-preview/fixtures/builders__roster.json +0 -48
- package/scripts/hall-preview/fixtures/goals.json +0 -502
- package/scripts/hall-preview/fixtures/goals__25.json +0 -96
- package/scripts/hall-preview/fixtures/goals__26.json +0 -67
- package/scripts/hall-preview/fixtures/goals__3.json +0 -103
- package/scripts/hall-preview/fixtures/goals__35.json +0 -49
- package/scripts/hall-preview/fixtures/goals__conflicts.json +0 -154
- package/scripts/hall-preview/fixtures/goals__detail.json +0 -96
- package/scripts/hall-preview/fixtures/goals__inbox.json +0 -5
- package/scripts/hall-preview/fixtures/goals__requests.json +0 -3
- package/scripts/hall-preview/fixtures/grades__by-builder.json +0 -32
- package/scripts/hall-preview/fixtures/inbox.json +0 -330
- package/scripts/hall-preview/fixtures/inbox__detail.json +0 -15
- package/scripts/hall-preview/fixtures/instance.json +0 -124
- package/scripts/hall-preview/fixtures/leaderboard.json +0 -184
- package/scripts/hall-preview/fixtures/me.json +0 -476
- package/scripts/hall-preview/fixtures/me__art-key__own.json +0 -6
- package/scripts/hall-preview/fixtures/me__event-sound-prefs.json +0 -209
- package/scripts/hall-preview/fixtures/me__recent-ships.json +0 -304
- package/scripts/hall-preview/fixtures/me__render.json +0 -5
- package/scripts/hall-preview/fixtures/me__skill-prefs.json +0 -143
- package/scripts/hall-preview/fixtures/me__sound-prefs.json +0 -67
- package/scripts/hall-preview/fixtures/me__wandering.json +0 -43
- package/scripts/hall-preview/fixtures/override-requests.json +0 -15
- package/scripts/hall-preview/fixtures/provisioning__fleet.json +0 -1
- package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +0 -1
- package/scripts/hall-preview/fixtures/provisioning__instances.json +0 -1
- package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +0 -516
- package/scripts/hall-preview/fixtures/public__bounty-table.json +0 -37
- package/scripts/hall-preview/fixtures/public__cost-summary.json +0 -742
- package/scripts/hall-preview/fixtures/public__estimation-drift.json +0 -41
- package/scripts/hall-preview/fixtures/public__grades.json +0 -50
- package/scripts/hall-preview/fixtures/public__progress.json +0 -124
- package/scripts/hall-preview/fixtures/public__recent-shipped.json +0 -426
- package/scripts/hall-preview/fixtures/public__repo-health.json +0 -160
- package/scripts/hall-preview/fixtures/public__repo-info.json +0 -5
- package/scripts/hall-preview/fixtures/security__docs.json +0 -10
- package/scripts/hall-preview/fixtures/security__reports.json +0 -31
- package/scripts/hall-preview/fixtures/sessions__search.json +0 -9
- package/scripts/hall-preview/fixtures/tasks.json +0 -2420
- package/scripts/hall-preview/fixtures/tasks__claimable.json +0 -2555
- package/scripts/hall-preview/fixtures/tasks__detail.json +0 -111
- package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +0 -330
- package/scripts/hall-preview/fixtures/versions.json +0 -89
package/scripts/gds/provision.js
CHANGED
|
@@ -47,6 +47,7 @@ const fs = require('node:fs');
|
|
|
47
47
|
const os = require('node:os');
|
|
48
48
|
const path = require('node:path');
|
|
49
49
|
const cp = require('node:child_process');
|
|
50
|
+
const crypto = require('node:crypto');
|
|
50
51
|
|
|
51
52
|
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
52
53
|
|
|
@@ -180,7 +181,23 @@ function loadDeps() {
|
|
|
180
181
|
const provisioning = require(path.join(REPO_ROOT, 'modules/provisioning/provisioning'));
|
|
181
182
|
// eslint-disable-next-line global-require
|
|
182
183
|
const { DoApi } = require(path.join(REPO_ROOT, 'scripts/gds/do-api'));
|
|
183
|
-
|
|
184
|
+
// Cross-site identity (ADR 0141 §4, task #1002109). Resolve the hub-side module by a
|
|
185
|
+
// VARIABLE key, gated on isModuleEnabled — the module-loader's own dynamic-dispatch
|
|
186
|
+
// pattern (the run-unit-tests.js precedent), NOT a static 'modules/platform-identity/'
|
|
187
|
+
// literal that would couple this publishable core file to an instance-EXCLUDED module
|
|
188
|
+
// (fitness Check 16b / task 2135). A vanilla core that neither ships nor enables the
|
|
189
|
+
// module never resolves it → no MODULE_NOT_FOUND on a fresh boot. Null here → federation
|
|
190
|
+
// dormant (shouldFederate skips) → the instance owns its auth, byte-identical to today.
|
|
191
|
+
let platformIdentity = null, hubKeys = null;
|
|
192
|
+
const PI_KEY = 'platform-identity';
|
|
193
|
+
let piEnabled = false;
|
|
194
|
+
try { piEnabled = require(path.join(REPO_ROOT, 'src/modules')).isModuleEnabled(PI_KEY); } catch { piEnabled = false; }
|
|
195
|
+
if (piEnabled) {
|
|
196
|
+
const piBase = path.join(REPO_ROOT, 'modules', PI_KEY);
|
|
197
|
+
try { platformIdentity = require(path.join(piBase, 'platform-identity')); hubKeys = require(path.join(piBase, 'hub-keys')); }
|
|
198
|
+
catch { platformIdentity = null; hubKeys = null; }
|
|
199
|
+
}
|
|
200
|
+
_deps = { pool, provisioning, DoApi, platformIdentity, hubKeys };
|
|
184
201
|
return _deps;
|
|
185
202
|
}
|
|
186
203
|
|
|
@@ -562,7 +579,7 @@ function provisionedEnvPrefix() {
|
|
|
562
579
|
// the hall on this host + OAuth uses the real callback origin — without them the pack
|
|
563
580
|
// stays at neutral localhost and the hall never loads (task 1972). No domain yet
|
|
564
581
|
// (bring-your-own deferred, ADR 0111 §4) ⇒ origins stay neutral until one is set.
|
|
565
|
-
function webEnvBody(inst, { clientId = '', clientSecret = '' } = {}) {
|
|
582
|
+
function webEnvBody(inst, { clientId = '', clientSecret = '', federation = null } = {}) {
|
|
566
583
|
const prefix = provisionedEnvPrefix();
|
|
567
584
|
const origin = inst.domain ? `https://${inst.domain}` : '';
|
|
568
585
|
const originLines = origin
|
|
@@ -583,7 +600,11 @@ ${prefix}_OAUTH_ORIGIN=${origin}
|
|
|
583
600
|
// Manifest flow (placeManifestCreds fetches them control-plane-side + re-writes this
|
|
584
601
|
// file with clientId/clientSecret set), or the manual oauth-secret.js placement. On
|
|
585
602
|
// the first provision write they are blank placeholders the flow fills later.
|
|
586
|
-
const credComment =
|
|
603
|
+
const credComment = federation
|
|
604
|
+
? `# Provisioned by scripts/gds/provision.js (ADR 0111). This instance FEDERATES sign-in
|
|
605
|
+
# through the Cloud Bongos hub (ADR 0141 §4) — it needs no GitHub app of its own, so the
|
|
606
|
+
# GitHub client id/secret below stay blank; the ${prefix}_HUB_* block drives sign-in.`
|
|
607
|
+
: clientId || clientSecret
|
|
587
608
|
? `# Provisioned by scripts/gds/provision.js (ADR 0111). OAuth app creds placed via the
|
|
588
609
|
# GitHub App Manifest flow (ADR 0133) — the owner clicked one link; no secret was pasted.`
|
|
589
610
|
: `# Provisioned by scripts/gds/provision.js (ADR 0111). Fill the OAuth app creds via the
|
|
@@ -602,6 +623,20 @@ ${prefix}_OAUTH_ORIGIN=${origin}
|
|
|
602
623
|
const firstAdminLine = ownerRepo && ownerRepo.owner
|
|
603
624
|
? `${prefix}_FIRST_ADMIN=${ownerRepo.owner}\n`
|
|
604
625
|
: '';
|
|
626
|
+
// Cross-site identity federation block (ADR 0141 §4, task #1002109). Present only for
|
|
627
|
+
// a HOSTED (federated) instance: flips auth.idp ON via the env override branding.js
|
|
628
|
+
// reads (idpEnabled), and carries the hub origin, this instance's per-client secret
|
|
629
|
+
// (authenticates it TO the hub, server-to-server), and the hub's PUBLIC key (verifies
|
|
630
|
+
// the CLI's hub-signed assertion offline). All written straight to this chmod-600
|
|
631
|
+
// file — never through a browser or chat (the ADR 0133 secret-placement posture).
|
|
632
|
+
const fedLines = federation
|
|
633
|
+
? `${prefix}_AUTH_IDP=true
|
|
634
|
+
${prefix}_HUB_ORIGIN=${federation.hubOrigin}
|
|
635
|
+
${prefix}_HUB_CLIENT_ID=${federation.clientId}
|
|
636
|
+
${prefix}_HUB_CLIENT_SECRET=${federation.clientSecret}
|
|
637
|
+
${prefix}_HUB_PUBLIC_KEY=${federation.hubPublicKey}
|
|
638
|
+
`
|
|
639
|
+
: '';
|
|
605
640
|
return `# /etc/${inst.slug}/web.env — per-instance secrets (chmod 600, OUTSIDE the repo).
|
|
606
641
|
${credComment}
|
|
607
642
|
${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
|
|
@@ -609,12 +644,88 @@ ${prefix}_GITHUB_CLIENT_SECRET=${clientSecret || ''}
|
|
|
609
644
|
${firstAdminLine}GDS_BRANDING_FILE=${PROVISIONED_BRANDING_FILE}
|
|
610
645
|
${originLines}PORT=${inst.port}
|
|
611
646
|
PGDATABASE=${dbName(inst)}
|
|
612
|
-
`;
|
|
647
|
+
${fedLines}`;
|
|
613
648
|
}
|
|
614
649
|
function webEnvPath(inst, { etcBase = CONFIG.etcBase } = {}) {
|
|
615
650
|
return path.posix.join(etcBase, inst.slug, 'web.env');
|
|
616
651
|
}
|
|
617
652
|
|
|
653
|
+
// ── Cross-site identity: federate a hosted instance's sign-in through the hub ───
|
|
654
|
+
// (ADR 0141 §4, task #1002109). The control plane IS the hub (cloudbongos.com), so
|
|
655
|
+
// registration is a direct in-process call against the hub pool — no HTTP hop.
|
|
656
|
+
|
|
657
|
+
// The hub's own public origin, written into each federated instance as
|
|
658
|
+
// <PREFIX>_HUB_ORIGIN (what auth.js loadIdpConfig reads). Explicit PROVISION_HUB_ORIGIN
|
|
659
|
+
// wins; otherwise the control plane's OWN public origin (<PREFIX>_PUBLIC_ORIGIN, the
|
|
660
|
+
// env the hub already runs under). Null → not hub-configured → shouldFederate is false.
|
|
661
|
+
function federationHubOrigin() {
|
|
662
|
+
const explicit = process.env.PROVISION_HUB_ORIGIN;
|
|
663
|
+
if (explicit) return String(explicit).replace(/\/+$/, '');
|
|
664
|
+
const own = process.env[`${provisionedEnvPrefix()}_PUBLIC_ORIGIN`];
|
|
665
|
+
return own ? String(own).replace(/\/+$/, '') : null;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Whether provisioning should federate this instance's sign-in through the hub.
|
|
669
|
+
// Dormant-by-default: true ONLY when the control plane is hub-configured (the
|
|
670
|
+
// platform-identity module is present, a signing key is loadable, AND a hub origin
|
|
671
|
+
// resolves), the instance is a CO-TENANT (the mothership-hosted shape whose web.env is
|
|
672
|
+
// written locally — a `standalone` is owner-hosted and keeps its own GitHub app, the
|
|
673
|
+
// self-hosted path; a `dedicated` droplet has no local web.env write yet, same gap the
|
|
674
|
+
// ADR 0133 manifest flow leaves — see placeManifestCreds), and it has a public domain
|
|
675
|
+
// (the origin the hub binds the CLI mint audience to — the #1002108 account-takeover
|
|
676
|
+
// fix). Returns { federate, reason } so the caller can log WHY it skipped. PURE-ish
|
|
677
|
+
// (reads env + the injected module handles; no DB, no I/O).
|
|
678
|
+
function shouldFederate(inst, deps) {
|
|
679
|
+
const { platformIdentity, hubKeys } = deps;
|
|
680
|
+
if (!platformIdentity || !hubKeys) return { federate: false, reason: 'platform-identity module not present (control plane is not the hub)' };
|
|
681
|
+
if (!hubKeys.hubSigningConfigured()) return { federate: false, reason: 'hub signing key not configured (<PREFIX>_HUB_SIGNING_KEY unset)' };
|
|
682
|
+
if (!federationHubOrigin()) return { federate: false, reason: 'hub origin not resolvable (set PROVISION_HUB_ORIGIN or <PREFIX>_PUBLIC_ORIGIN)' };
|
|
683
|
+
if (inst.hosting_shape !== 'co-tenant') return { federate: false, reason: `${inst.hosting_shape} keeps its own GitHub app (not the co-tenant hosted path)` };
|
|
684
|
+
if (!inst.domain) return { federate: false, reason: 'no domain yet — federation needs the instance public origin' };
|
|
685
|
+
return { federate: true, reason: null };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Register this hosted instance as an sso_clients row on the HUB (fresh per-client
|
|
689
|
+
// secret) and return the federation block to bake into its web.env (ADR 0141 §4).
|
|
690
|
+
// SECURITY: `origin` MUST be the instance's EXACT public origin — the hub binds the
|
|
691
|
+
// CLI-federation mint audience to it (the #1002108 fix), so a mismatch would break
|
|
692
|
+
// device sign-in (and a wrong origin would be a takeover vector). client_id is the
|
|
693
|
+
// (unique, DNS-safe) slug, so a re-provision rotates the SAME row's secret via
|
|
694
|
+
// registerClient's ON CONFLICT rather than orphaning a client. redirect_uri lists both
|
|
695
|
+
// callback prefixes the core may emit (/api/gds is what auth.js builds today) so the
|
|
696
|
+
// hub's exact-match allow-list accepts the browser leg. --apply gates the real hub
|
|
697
|
+
// write; dry-run returns a placeholder block (so webEnvBody can still be composed) + logs.
|
|
698
|
+
async function federateInstance(inst, deps) {
|
|
699
|
+
const { db, platformIdentity, hubKeys, log, apply } = deps;
|
|
700
|
+
const origin = `https://${inst.domain}`;
|
|
701
|
+
const clientId = inst.slug;
|
|
702
|
+
const hubOrigin = federationHubOrigin();
|
|
703
|
+
const hubPublicKey = hubKeys.hubPublicKeyB64();
|
|
704
|
+
if (!hubPublicKey) throw new Error('hub signing key is set but unreadable — cannot derive HUB_PUBLIC_KEY');
|
|
705
|
+
const redirectUris = [
|
|
706
|
+
`${origin}/api/gds/auth/web/callback`,
|
|
707
|
+
`${origin}/api/bongos/auth/web/callback`,
|
|
708
|
+
];
|
|
709
|
+
if (!apply) {
|
|
710
|
+
log(` [federate] would register client '${clientId}' on the hub (origin ${origin}) + write ${provisionedEnvPrefix()}_HUB_* + AUTH_IDP into web.env`);
|
|
711
|
+
return { hubOrigin, clientId, clientSecret: '0'.repeat(43), hubPublicKey };
|
|
712
|
+
}
|
|
713
|
+
const clientSecret = crypto.randomBytes(32).toString('base64url');
|
|
714
|
+
// Best-effort owner github_id — nice for the hub catalog's ownership link, but
|
|
715
|
+
// nullable in registerClient, so a lookup miss must never fail the provision.
|
|
716
|
+
let ownerGithubId = null;
|
|
717
|
+
try {
|
|
718
|
+
const { rows } = await db.query('SELECT github_id FROM builders WHERE id = $1', [inst.owner_builder_id]);
|
|
719
|
+
ownerGithubId = rows[0] ? rows[0].github_id : null;
|
|
720
|
+
} catch { /* non-fatal */ }
|
|
721
|
+
await platformIdentity.registerClient(
|
|
722
|
+
{ clientId, secret: clientSecret, name: inst.slug, redirectUris, origin, ownerGithubId },
|
|
723
|
+
{ pool: db },
|
|
724
|
+
);
|
|
725
|
+
log(` [federate] registered client '${clientId}' on the hub (origin ${origin})`);
|
|
726
|
+
return { hubOrigin, clientId, clientSecret, hubPublicKey };
|
|
727
|
+
}
|
|
728
|
+
|
|
618
729
|
// The Cloudflare zone a hostname's A record lives in. Cloudflare zones are
|
|
619
730
|
// registrable domains, which for the common case is the last two DNS labels
|
|
620
731
|
// (mercury.example.com → example.com; apex example.com → example.com). Multi-part
|
|
@@ -925,6 +1036,28 @@ async function provisionInstance(inst, deps) {
|
|
|
925
1036
|
if (apply) await provisioning.setInstanceStatus(db, inst.id, 'provisioning');
|
|
926
1037
|
const patch = {};
|
|
927
1038
|
|
|
1039
|
+
// ── cross-site identity: register hub federation (ADR 0141 §4, task #1002109) ──
|
|
1040
|
+
// Computed BEFORE the shape steps so the co-tenant web.env write (step 3) bakes it
|
|
1041
|
+
// in — the instance comes up already federated, no post-hoc restart. Dormant unless
|
|
1042
|
+
// the control plane is hub-configured AND the shape/domain qualify (shouldFederate);
|
|
1043
|
+
// otherwise the instance owns its auth (direct GitHub), byte-identically to today.
|
|
1044
|
+
let federation = null;
|
|
1045
|
+
{
|
|
1046
|
+
const fed = shouldFederate(inst, deps);
|
|
1047
|
+
if (fed.federate) {
|
|
1048
|
+
federation = await federateInstance(inst, deps);
|
|
1049
|
+
log(` [federate] ${inst.slug} → hub sign-in (auth.idp on) — no GitHub app of its own`);
|
|
1050
|
+
if (apply) {
|
|
1051
|
+
await provisioning.recordEvent(db, {
|
|
1052
|
+
instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'federated',
|
|
1053
|
+
detail: `sign-in federated to the hub (client ${inst.slug}, origin https://${inst.domain})`, actor: 'runner',
|
|
1054
|
+
}).catch(() => {});
|
|
1055
|
+
}
|
|
1056
|
+
} else {
|
|
1057
|
+
log(` [federate] skipped (${fed.reason}) — instance owns its auth (direct GitHub)`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
928
1061
|
// ── step 1: server ───────────────────────────────────────────────────────
|
|
929
1062
|
// The A-record target for step 5's DNS UPSERT:
|
|
930
1063
|
// • co-tenant → PROVISION_PUBLIC_IP (the box it runs on — under remote-exec that
|
|
@@ -1022,7 +1155,7 @@ async function provisionInstance(inst, deps) {
|
|
|
1022
1155
|
|
|
1023
1156
|
// ── step 3: config + per-instance secrets ─────────────────────────────────
|
|
1024
1157
|
log(' [config] per-instance web.env');
|
|
1025
|
-
if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst), { mode: 0o600, sudo: true });
|
|
1158
|
+
if (local) boxWriteFile(webEnvPath(inst), webEnvBody(inst, { federation }), { mode: 0o600, sudo: true });
|
|
1026
1159
|
else log(' (baked into cloud-init)');
|
|
1027
1160
|
|
|
1028
1161
|
// ── step 4: systemd unit + enable ─────────────────────────────────────────
|
|
@@ -1476,8 +1609,8 @@ async function main() {
|
|
|
1476
1609
|
const slug = args[1] && !args[1].startsWith('--') ? args[1] : null;
|
|
1477
1610
|
if ((cmd === 'provision' || cmd === 'teardown') && !slug) { console.error(`${cmd} needs a <slug>`); process.exit(2); }
|
|
1478
1611
|
|
|
1479
|
-
let pool, provisioning, DoApi;
|
|
1480
|
-
try { ({ pool, provisioning, DoApi } = loadDeps()); }
|
|
1612
|
+
let pool, provisioning, DoApi, platformIdentity, hubKeys;
|
|
1613
|
+
try { ({ pool, provisioning, DoApi, platformIdentity, hubKeys } = loadDeps()); }
|
|
1481
1614
|
catch (e) { console.error(`cannot load DB layer (run this on the control plane): ${e.message}`); process.exit(1); }
|
|
1482
1615
|
|
|
1483
1616
|
// Box-target executor (remote over SSH when PROVISION_REMOTE_HOST is set, else
|
|
@@ -1492,6 +1625,9 @@ async function main() {
|
|
|
1492
1625
|
writeFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec, remoteHost: CONFIG.remoteHost }),
|
|
1493
1626
|
controlWriteFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec: controlExec }),
|
|
1494
1627
|
doApi: DoApi ? new DoApi({ dryRun: !apply, token: CONFIG.doToken, log: (m) => console.log(' ' + m) }) : null,
|
|
1628
|
+
// Cross-site identity (ADR 0141 §4). Null on a non-hub control plane → federation
|
|
1629
|
+
// dormant; shouldFederate() then skips and the instance owns its auth (as today).
|
|
1630
|
+
platformIdentity, hubKeys,
|
|
1495
1631
|
};
|
|
1496
1632
|
|
|
1497
1633
|
let result;
|
|
@@ -1527,4 +1663,6 @@ module.exports = {
|
|
|
1527
1663
|
cmdRunIntents, cmdReconcile, cmdStatus,
|
|
1528
1664
|
// GitHub App Manifest flow (task 2080, ADR 0133)
|
|
1529
1665
|
convertManifestCode, placeManifestCreds, drainOAuthManifests, MANIFEST_UA,
|
|
1666
|
+
// cross-site identity federation (ADR 0141 §4, task #1002109)
|
|
1667
|
+
federationHubOrigin, shouldFederate, federateInstance,
|
|
1530
1668
|
};
|
|
@@ -250,6 +250,21 @@ const INSTANCE_EXCLUDE = [
|
|
|
250
250
|
'docs/design/container-exterior-tiles.md',
|
|
251
251
|
'docs/design/container-interior.md',
|
|
252
252
|
'docs/design/world-map-scroll.md',
|
|
253
|
+
// instance-HISTORY ADRs (task 1002219 / audit H5). These record THIS platform-family's own
|
|
254
|
+
// migration + identity history (the amazonprimea→cloudbongos lift, the standalone reprovision,
|
|
255
|
+
// the cloudbongos identity hub) — they are inherently full of the prior instance's name/owner and
|
|
256
|
+
// genericize to nothing (unlike a portable methodology ADR). docs/adr/ is allowlisted wholesale for
|
|
257
|
+
// the portable decision records; these three stay in this repo and never ship in the core, mirroring
|
|
258
|
+
// the instance-recipe / game-design carve-outs above. (The portable ADRs + the README index still publish.)
|
|
259
|
+
'docs/adr/0139-platform-history-migration-to-cloudbongos.md',
|
|
260
|
+
'docs/adr/0140-cloudbongos-standalone-reprovision.md',
|
|
261
|
+
'docs/adr/0141-cross-site-identity-federated-hub-idp.md',
|
|
262
|
+
// hall-preview FIXTURES (task 1002219 / audit H5) — the local hall-UI preview harness's sample
|
|
263
|
+
// data is a snapshot of THIS instance's builders/goals/tasks (owner login, world name, prior
|
|
264
|
+
// instance name), pure instance identity. The harness code stays portable; only the OTB data is
|
|
265
|
+
// carved out, so a consumer core never ships customer-zero's directory. (Fixtures remain in-repo
|
|
266
|
+
// for local dev — this only affects what package-core publishes.)
|
|
267
|
+
'scripts/hall-preview/fixtures/',
|
|
253
268
|
'blockers/',
|
|
254
269
|
'limitations/',
|
|
255
270
|
'generative-ideas.md',
|
|
@@ -31,6 +31,10 @@ const { isModuleEnabled } = require('../../src/modules');
|
|
|
31
31
|
const TESTS_DIR = path.resolve(__dirname, '..', '..', 'tests');
|
|
32
32
|
const MODULES_DIR = path.resolve(__dirname, '..', '..', 'modules');
|
|
33
33
|
const PER_TEST_TIMEOUT_MS = 90_000;
|
|
34
|
+
// Integration tests (package build + `npm install` + a spawned server) are far heavier than a
|
|
35
|
+
// DB-free unit file, so the `--integration` gate (task 1002221 / audit H1) gives each one a much
|
|
36
|
+
// longer budget than the 90s unit cap.
|
|
37
|
+
const INTEGRATION_TIMEOUT_MS = 300_000;
|
|
34
38
|
|
|
35
39
|
// Feature-module test sets (R59 / task 1198). A test that exercises a feature
|
|
36
40
|
// MODULE runs only when this instance enables that module — a vanilla Cloud
|
|
@@ -128,31 +132,50 @@ function discoverModuleTests({ modulesDir = MODULES_DIR, isEnabled = isModuleEna
|
|
|
128
132
|
}
|
|
129
133
|
|
|
130
134
|
function main() {
|
|
135
|
+
// Two gates from one runner (task 1002221 / audit H1):
|
|
136
|
+
// default — the DB-free UNIT gate: every test file MINUS the integration set (+ known-
|
|
137
|
+
// failing / support / disabled-module). Fast, runs in a clean checkout / per PR.
|
|
138
|
+
// --integration — the standing INTEGRATION-SMOKE gate: ONLY the integration set (package build +
|
|
139
|
+
// `npm install` + live boot etc.). Heavier, longer per-test budget; run per
|
|
140
|
+
// release / on a schedule in an env that provides the services (DB/network). This
|
|
141
|
+
// is what promotes tests/consumer_layout_boot.mjs from "excluded + ad-hoc" to a
|
|
142
|
+
// first-class gate a release step can invoke with one command.
|
|
143
|
+
const integrationMode = process.argv.includes('--integration');
|
|
144
|
+
const perTestTimeout = integrationMode ? INTEGRATION_TIMEOUT_MS : PER_TEST_TIMEOUT_MS;
|
|
145
|
+
|
|
131
146
|
const files = fs
|
|
132
147
|
.readdirSync(TESTS_DIR)
|
|
133
148
|
.filter((f) => f.endsWith('.mjs'))
|
|
134
149
|
.map((f) => f.replace(/\.mjs$/, ''))
|
|
135
|
-
.filter((name) =>
|
|
136
|
-
&& !
|
|
150
|
+
.filter((name) => integrationMode
|
|
151
|
+
? (INTEGRATION.has(name) && !KNOWN_FAILING.has(name) && !SUPPORT_MODULES.has(name))
|
|
152
|
+
: (!INTEGRATION.has(name) && !KNOWN_FAILING.has(name) && !SUPPORT_MODULES.has(name) && !MODULE_DISABLED.has(name)))
|
|
137
153
|
.sort();
|
|
138
154
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
155
|
+
// Module-owned tests are DB-free unit tests — only the default gate runs them.
|
|
156
|
+
const { included: moduleTests, skippedDisabled: moduleTestsSkipped } = integrationMode
|
|
157
|
+
? { included: [], skippedDisabled: [] }
|
|
158
|
+
: discoverModuleTests();
|
|
159
|
+
|
|
160
|
+
if (integrationMode) {
|
|
161
|
+
console.log(`run-unit-tests --integration: ${files.length} integration-smoke test file(s) (${files.join(', ') || 'none found'}); per-test budget ${INTEGRATION_TIMEOUT_MS / 1000}s`);
|
|
162
|
+
} else {
|
|
163
|
+
const moduleNote = MODULE_DISABLED.size
|
|
164
|
+
? ` + ${MODULE_DISABLED.size} disabled-module (${[...MODULE_DISABLED].sort().join(', ')})`
|
|
165
|
+
: '';
|
|
166
|
+
const modOwnedSkipNote = moduleTestsSkipped.length
|
|
167
|
+
? ` + ${moduleTestsSkipped.length} module-owned test(s) skipped (module disabled: ${[...new Set(moduleTestsSkipped.map((t) => t.key))].sort().join(', ')})`
|
|
168
|
+
: '';
|
|
169
|
+
console.log(`run-unit-tests: ${files.length + moduleTests.length} DB-free unit test file(s) (${files.length} core + ${moduleTests.length} module-owned; excluded ${INTEGRATION.size} integration + ${KNOWN_FAILING.size} known-failing${moduleNote}${modOwnedSkipNote})`);
|
|
170
|
+
}
|
|
147
171
|
const total = files.length + moduleTests.length;
|
|
148
|
-
console.log(`run-unit-tests: ${total} DB-free unit test file(s) (${files.length} core + ${moduleTests.length} module-owned; excluded ${INTEGRATION.size} integration + ${KNOWN_FAILING.size} known-failing${moduleNote}${modOwnedSkipNote})`);
|
|
149
172
|
|
|
150
173
|
const failures = [];
|
|
151
174
|
const runOne = (displayName, filePath) => {
|
|
152
175
|
try {
|
|
153
176
|
execFileSync(process.execPath, [filePath], {
|
|
154
177
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
155
|
-
timeout:
|
|
178
|
+
timeout: perTestTimeout,
|
|
156
179
|
env: { ...process.env, NODE_ENV: 'test' },
|
|
157
180
|
});
|
|
158
181
|
console.log(` ok ${displayName}`);
|
|
@@ -168,12 +191,13 @@ function main() {
|
|
|
168
191
|
for (const name of files) runOne(name, path.join(TESTS_DIR, `${name}.mjs`));
|
|
169
192
|
for (const { key, name, file } of moduleTests) runOne(`${key}/${name}`, file);
|
|
170
193
|
|
|
194
|
+
const label = integrationMode ? 'run-unit-tests --integration' : 'run-unit-tests';
|
|
171
195
|
console.log('');
|
|
172
196
|
if (failures.length === 0) {
|
|
173
|
-
console.log(
|
|
197
|
+
console.log(`${label}: all ${total} passed.`);
|
|
174
198
|
process.exit(0);
|
|
175
199
|
}
|
|
176
|
-
console.log(
|
|
200
|
+
console.log(`${label}: ${failures.length} FAILED — ${failures.join(', ')}`);
|
|
177
201
|
process.exit(1);
|
|
178
202
|
}
|
|
179
203
|
|
package/scripts/gds/setup.js
CHANGED
|
@@ -378,6 +378,47 @@ async function promptOnboardingConsent() {
|
|
|
378
378
|
}
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
// Federation (ADR 0141 §1, task #1002108): a HOSTED instance has no GitHub app of
|
|
382
|
+
// its own — it federates sign-in through the Cloud Bongos hub. If the instance
|
|
383
|
+
// manifest advertises auth.idp, run the hub device flow + assertion exchange via
|
|
384
|
+
// the shared login.js runDeviceFlow, then the SAME onboarding prompts. The direct
|
|
385
|
+
// GitHub device flow below (auth.idp unset) is untouched — the self-hosted default.
|
|
386
|
+
let idpCfg = null;
|
|
387
|
+
try {
|
|
388
|
+
const m = await fetch(`${API_BASE}/api/gds/instance`);
|
|
389
|
+
const mj = m.ok ? await m.json() : null;
|
|
390
|
+
idpCfg = (mj && mj.auth && mj.auth.idp) || null;
|
|
391
|
+
} catch (_) { /* manifest unreachable → fall through to the direct flow */ }
|
|
392
|
+
if (idpCfg) {
|
|
393
|
+
console.log('Signing in via the Cloud Bongos platform (hub)...');
|
|
394
|
+
const { runDeviceFlow } = require('./login');
|
|
395
|
+
const flow = await runDeviceFlow(API_BASE, { log: console.log, idp: idpCfg });
|
|
396
|
+
if (flow.state === 'not_approved') {
|
|
397
|
+
console.error('');
|
|
398
|
+
console.error('This GitHub account has not been approved yet.');
|
|
399
|
+
console.error('An Archon must approve your access request before you can enroll.');
|
|
400
|
+
console.error(' → Ask an Archon to invite you, then re-run /builder-setup.');
|
|
401
|
+
process.exit(1);
|
|
402
|
+
}
|
|
403
|
+
if (flow.state !== 'ok') {
|
|
404
|
+
console.error(`\nSign-in did not complete (${flow.state}). Re-run /builder-setup.`);
|
|
405
|
+
process.exit(1);
|
|
406
|
+
}
|
|
407
|
+
const { token, builder } = flow;
|
|
408
|
+
await saveSession({ token, builder, api_base: API_BASE });
|
|
409
|
+
console.log('');
|
|
410
|
+
console.log(`Authenticated as ${builder.github_login} (#${builder.id}). Session saved to ${SESSION_PATH}.`);
|
|
411
|
+
try {
|
|
412
|
+
const { activate } = require('./install-git-hooks');
|
|
413
|
+
activate();
|
|
414
|
+
} catch (_) { /* hook activation is best-effort */ }
|
|
415
|
+
await promptGeminiKey();
|
|
416
|
+
await promptAnthropicLink();
|
|
417
|
+
await promptDisciplines();
|
|
418
|
+
await promptOnboardingConsent();
|
|
419
|
+
process.exit(0);
|
|
420
|
+
}
|
|
421
|
+
|
|
381
422
|
console.log('Starting GitHub Device Flow...');
|
|
382
423
|
const start = await (await cliClient()).auth.postAuthDeviceStart();
|
|
383
424
|
if (!start.ok) {
|
package/scripts/gds/ship.js
CHANGED
|
@@ -95,7 +95,11 @@ const { playSound } = (() => {
|
|
|
95
95
|
return { playSound: () => {} };
|
|
96
96
|
}
|
|
97
97
|
})();
|
|
98
|
-
|
|
98
|
+
// The laptop-mode SSH deploy target is resolved at land time from config/deploy.json
|
|
99
|
+
// ("sshTarget") or <PREFIX>_DEPLOY_SSH_TARGET — see resolveDeployTarget(). There is NO
|
|
100
|
+
// hardcoded default: the core carries no deploy target of its own. The founder
|
|
101
|
+
// instance's old game-box SSH target used to be baked in here, which post-migration
|
|
102
|
+
// meant a laptop-mode ship deployed to the WRONG server (task 1002202).
|
|
99
103
|
const BEHIND_MAIN_LIMIT = 5;
|
|
100
104
|
|
|
101
105
|
// arg() — the =-aware flag-value parser now lives in cli-lib (the canonical
|
|
@@ -762,9 +766,13 @@ function buildMergeArgv(branch, valueSummary) {
|
|
|
762
766
|
// Two ways the confirmed→shipped land can run, chosen by a SINGLE committed
|
|
763
767
|
// switch (config/deploy.json `mode`, env OTB_DEPLOY_MODE overrides):
|
|
764
768
|
//
|
|
765
|
-
// 'laptop' (default) — the operator's machine merges to main locally and
|
|
766
|
-
//
|
|
767
|
-
//
|
|
769
|
+
// 'laptop' (default) — the operator's machine merges to main locally and, IFF a
|
|
770
|
+
// deploy target is configured (config/deploy.json "sshTarget" or
|
|
771
|
+
// <PREFIX>_DEPLOY_SSH_TARGET, resolveDeployTarget()), SSHes `~/deploy.sh` on it.
|
|
772
|
+
// The core carries NO target of its own, so by default this is a merge-to-main
|
|
773
|
+
// with no SSH deploy — a change reaches a live instance via package-core +
|
|
774
|
+
// `bongos upgrade` (pin-bump), NOT an SSH deploy (ADR 0108). When a target IS
|
|
775
|
+
// set it requires that host's SSH key, so only the operator can land.
|
|
768
776
|
//
|
|
769
777
|
// 'ci' — NO builder box holds the prod key. ship pushes the branch, opens a
|
|
770
778
|
// PR, and enables GitHub auto-merge; once required checks pass, GitHub
|
|
@@ -815,12 +823,7 @@ function effectiveDeployMode(mode, { canPushLocally, envForced } = {}) {
|
|
|
815
823
|
}
|
|
816
824
|
|
|
817
825
|
function resolveDeployMode() {
|
|
818
|
-
|
|
819
|
-
try {
|
|
820
|
-
configRaw = fs.readFileSync(path.join(REPO_ROOT, 'config', 'deploy.json'), 'utf8');
|
|
821
|
-
} catch (_) {
|
|
822
|
-
/* no config file → safe default */
|
|
823
|
-
}
|
|
826
|
+
const configRaw = readDeployConfigRaw();
|
|
824
827
|
const mode = parseDeployMode(process.env.OTB_DEPLOY_MODE, configRaw);
|
|
825
828
|
const envForced = ['ci', 'laptop'].includes(
|
|
826
829
|
String(process.env.OTB_DEPLOY_MODE || '').trim().toLowerCase()
|
|
@@ -834,6 +837,63 @@ function resolveDeployMode() {
|
|
|
834
837
|
return mode;
|
|
835
838
|
}
|
|
836
839
|
|
|
840
|
+
// readDeployConfigRaw — raw contents of config/deploy.json (or null if absent /
|
|
841
|
+
// unreadable). One reader shared by resolveDeployMode (the mode switch) and
|
|
842
|
+
// resolveDeployTarget (the SSH target) so they never diverge on where the file is.
|
|
843
|
+
function readDeployConfigRaw() {
|
|
844
|
+
try {
|
|
845
|
+
return fs.readFileSync(path.join(REPO_ROOT, 'config', 'deploy.json'), 'utf8');
|
|
846
|
+
} catch (_) {
|
|
847
|
+
return null; // no config file → callers apply their safe default
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// safeBranding — the instance brand pack, or null if it can't be read. Guarded the
|
|
852
|
+
// same way ship.js guards the sound player (line ~91): a deploy must never crash
|
|
853
|
+
// because the brand file is missing on a fresh / re-seeded checkout.
|
|
854
|
+
function safeBranding() {
|
|
855
|
+
try {
|
|
856
|
+
return require('../../src/branding').branding();
|
|
857
|
+
} catch (_) {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// resolveDeployTarget — WHERE a laptop-mode ship SSHes `~/deploy.sh`, and which
|
|
863
|
+
// /healthz it re-probes when deploy.sh's own health check races the restart window.
|
|
864
|
+
// Pure (env + raw config + branding → target) and exported so the precedence and the
|
|
865
|
+
// safe "no target" default are unit-tested without touching fs/env/network.
|
|
866
|
+
//
|
|
867
|
+
// There is deliberately NO hardcoded default. The founder instance's old game-box
|
|
868
|
+
// SSH target was baked in here, so a laptop-mode ship from a migrated
|
|
869
|
+
// checkout SSHed the WRONG server (task 1002202). The core carries no deploy target
|
|
870
|
+
// of its own — under the standalone model a core change reaches a live instance via
|
|
871
|
+
// package-core + `bongos upgrade` (pin-bump), not an SSH deploy (ADR 0108). An
|
|
872
|
+
// instance whose OWN checkout is its live server opts back into laptop-SSH deploy by
|
|
873
|
+
// setting the target:
|
|
874
|
+
// config/deploy.json → { "sshTarget": "user@host", "healthzUrl": "https://…/healthz" }
|
|
875
|
+
// env → <PREFIX>_DEPLOY_SSH_TARGET, <PREFIX>_DEPLOY_HEALTHZ_URL
|
|
876
|
+
// healthzUrl, when unset, falls back to the instance's own publicOrigin + /healthz
|
|
877
|
+
// (never a hardcoded host); null when even that is unknown (re-probe then skipped).
|
|
878
|
+
function resolveDeployTarget({ env = process.env, configRaw = null, branding = null } = {}) {
|
|
879
|
+
const ic = require('../../src/instance-config');
|
|
880
|
+
let cfg = {};
|
|
881
|
+
if (configRaw) {
|
|
882
|
+
try { cfg = JSON.parse(configRaw) || {}; } catch (_) { cfg = {}; }
|
|
883
|
+
}
|
|
884
|
+
const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
|
885
|
+
|
|
886
|
+
const sshTarget = ic.resolveEnv('DEPLOY_SSH_TARGET', { env }) || str(cfg.sshTarget) || null;
|
|
887
|
+
|
|
888
|
+
let healthzUrl = ic.resolveEnv('DEPLOY_HEALTHZ_URL', { env }) || str(cfg.healthzUrl) || null;
|
|
889
|
+
const publicOrigin = branding && branding.domains && branding.domains.publicOrigin;
|
|
890
|
+
if (!healthzUrl && str(publicOrigin)) {
|
|
891
|
+
try { healthzUrl = new URL('/healthz', publicOrigin).toString(); }
|
|
892
|
+
catch (_) { /* malformed origin → leave healthzUrl null */ }
|
|
893
|
+
}
|
|
894
|
+
return { sshTarget, healthzUrl };
|
|
895
|
+
}
|
|
896
|
+
|
|
837
897
|
// gh runners — mirror gitArgvOk/gitOk but for the GitHub CLI. No shell, ever:
|
|
838
898
|
// branch names + DB-sourced value_summary reach `gh` as literal argv elements
|
|
839
899
|
// (same injection discipline as #681). ghArgvOk = boolean side-effect; ghJson =
|
|
@@ -2129,26 +2189,48 @@ async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
|
|
|
2129
2189
|
return false;
|
|
2130
2190
|
}
|
|
2131
2191
|
|
|
2132
|
-
|
|
2133
|
-
//
|
|
2134
|
-
//
|
|
2135
|
-
//
|
|
2136
|
-
//
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
console.log('
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2192
|
+
// Deploy step. The core carries NO deploy target of its own — under the
|
|
2193
|
+
// standalone model a core change reaches a live instance via package-core +
|
|
2194
|
+
// `bongos upgrade` (pin-bump), NOT an SSH deploy (ADR 0108). So unless an
|
|
2195
|
+
// instance whose own checkout IS its live server has configured a target
|
|
2196
|
+
// (config/deploy.json "sshTarget" or <PREFIX>_DEPLOY_SSH_TARGET), the land is
|
|
2197
|
+
// complete at "merged + pushed to main" and the SSH deploy is skipped. (This
|
|
2198
|
+
// used to SSH a hardcoded target — the founder instance's old game box, i.e. the
|
|
2199
|
+
// WRONG server post-migration. task 1002202.)
|
|
2200
|
+
const { sshTarget, healthzUrl } = resolveDeployTarget({
|
|
2201
|
+
configRaw: readDeployConfigRaw(),
|
|
2202
|
+
branding: safeBranding(),
|
|
2203
|
+
});
|
|
2204
|
+
if (!sshTarget) {
|
|
2205
|
+
console.log(' merged + pushed to main. No deploy target configured, so no SSH deploy —');
|
|
2206
|
+
console.log(' expected for the core. To publish to a live instance run the standalone release');
|
|
2207
|
+
console.log(' flow: package-core → bongos upgrade (pin-bump), per ADR 0108. (Not an SSH deploy.)');
|
|
2208
|
+
} else {
|
|
2209
|
+
console.log(` triggering deploy on ${sshTarget}...`);
|
|
2210
|
+
// Quote ~/deploy.sh so the LOCAL shell doesn't tilde-expand it before ssh
|
|
2211
|
+
// transmits the command; with the quotes ssh sends the literal `~/deploy.sh`
|
|
2212
|
+
// and the REMOTE shell expands it. (Matches /merge-mode SKILL.md:81.)
|
|
2213
|
+
if (!shellOk(`ssh -o ConnectTimeout=10 ${sshTarget} "~/deploy.sh"`)) {
|
|
2214
|
+
// task 984 / idea 180: ~/deploy.sh's internal /healthz curl can race the
|
|
2215
|
+
// service-restart window (Node not yet bound) and exit non-zero even though
|
|
2216
|
+
// the deploy succeeded — which used to false-fail the ship and force a
|
|
2217
|
+
// needless /merge-mode. Before concluding failure, re-probe the CONFIGURED
|
|
2218
|
+
// /healthz with backoff (curl's own --retry; cross-platform, no sleep) —
|
|
2219
|
+
// never a hardcoded host. --retry-all-errors retries connection-refused
|
|
2220
|
+
// during the bind window; -f makes a non-200 an error so a 502/503 also
|
|
2221
|
+
// retries. If it comes up within the window the deploy really succeeded.
|
|
2222
|
+
if (healthzUrl) {
|
|
2223
|
+
console.log(' auto-merge: ~/deploy.sh returned non-zero — re-probing /healthz with backoff before concluding...');
|
|
2224
|
+
if (shellOk(`curl -fsS --max-time 10 --retry 5 --retry-delay 3 --retry-all-errors ${JSON.stringify(healthzUrl)} -o /dev/null`)) {
|
|
2225
|
+
console.log(' auto-merge: /healthz 200 on re-probe — deploy actually succeeded (deploy.sh raced the restart window). Proceeding.');
|
|
2226
|
+
} else {
|
|
2227
|
+
console.log(' auto-merge: deploy failed (/healthz never returned 200 after retries); task stays at confirmed');
|
|
2228
|
+
return false;
|
|
2229
|
+
}
|
|
2230
|
+
} else {
|
|
2231
|
+
console.log(' auto-merge: ~/deploy.sh returned non-zero and no healthzUrl configured to re-probe; task stays at confirmed');
|
|
2232
|
+
return false;
|
|
2233
|
+
}
|
|
2152
2234
|
}
|
|
2153
2235
|
}
|
|
2154
2236
|
|
|
@@ -3548,7 +3630,9 @@ module.exports = {
|
|
|
3548
3630
|
gitArgvOk, buildMergeArgv, sanitizeGitHubRefs,
|
|
3549
3631
|
// #679 / ADR 0042: CI deploy mode — pure helpers exported for the regression
|
|
3550
3632
|
// test (mode precedence/safe-default, no-shell PR argv, deploy-run match).
|
|
3551
|
-
|
|
3633
|
+
// task 1002202: resolveDeployTarget — SSH target / healthz precedence + the safe
|
|
3634
|
+
// "no target" default (the core has none), exported for the same reason.
|
|
3635
|
+
parseDeployMode, resolveDeployTarget, buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded,
|
|
3552
3636
|
// task 1049 (B): pure CI-land outcome classifier — a timed-out-but-armed PR is
|
|
3553
3637
|
// 'pending' (success), not failure. Exported so the semantics are unit-tested.
|
|
3554
3638
|
classifyLandOutcome,
|