@bongos/core 1.19.735 → 1.19.737
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 +31 -21
- package/docs/adr/0134-private-first-npm-distribution.md +1 -1
- package/docs/adr/0283-the-core-publishes-publicly-because-access-was-never-the-thing-protecting-it.md +53 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/private-npm-distribution.md +1 -1
- package/package-lock.json +2 -2
- package/package.json +2 -2
- package/scripts/gds/package-core.js +30 -16
- package/scripts/gds/provision-repo-auth.js +106 -0
- package/scripts/gds/provision-repo.js +8 -37
- package/scripts/gds/upgrade.js +187 -12
- package/src/module-api.js +1 -1
- package/tests/executed_closure.mjs +8 -0
- package/tests/package_core.mjs +27 -10
- package/tests/upgrade.mjs +284 -1
package/tests/upgrade.mjs
CHANGED
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
// 5. dry-run — plans without mutating package.json or spawning npm.
|
|
10
10
|
// 6. full happy-path — pre-flight → pin → install → migrate → materialize → restart → verify,
|
|
11
11
|
// with every side effect injected (DB-free: the ledger is skipped when no DATABASE_URL).
|
|
12
|
+
// 10. the pin push on a PRIVATE instance rides the owner's GitHub token through a chmod-600
|
|
13
|
+
// credential-store file, falls back to the remote only when none is stored, and a refused
|
|
14
|
+
// push is LOUD, naming the read-only deploy key and the fix (task 1003081, ADR 0176).
|
|
12
15
|
//
|
|
13
16
|
// Run: node tests/upgrade.mjs
|
|
14
17
|
|
|
15
18
|
import { strict as assert } from 'node:assert';
|
|
16
19
|
import { createRequire } from 'node:module';
|
|
17
|
-
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
20
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
18
21
|
import { tmpdir } from 'node:os';
|
|
19
22
|
import { join, dirname } from 'node:path';
|
|
20
23
|
|
|
@@ -1390,6 +1393,286 @@ t('pollServedVersion: gives up rather than spinning when the clock never advance
|
|
|
1390
1393
|
assert.match(r.error, /no coreVersion/);
|
|
1391
1394
|
});
|
|
1392
1395
|
|
|
1396
|
+
// ---- 10. the pin push rides the owner's GitHub token on a private instance (task 1003081, ADR 0176 follow-up) ----
|
|
1397
|
+
//
|
|
1398
|
+
// A PRIVATE standalone instance's origin authenticates with a deploy key GitHub registers
|
|
1399
|
+
// READ-ONLY, so `git push origin HEAD:main` is refused and the pin bump silently un-does
|
|
1400
|
+
// itself at the next pull-deploy. persistPin now prefers the owner's stored `repo` token
|
|
1401
|
+
// (the scaffold's + refreshStandaloneCorePin's credential-store shape), falls back to the
|
|
1402
|
+
// remote only when none is stored, and a refused push is LOUD. `run` is injected, so no
|
|
1403
|
+
// real repo, remote, or network — the assertions are on the argv persistPin CHOOSES and
|
|
1404
|
+
// on the credential file it writes beside it.
|
|
1405
|
+
|
|
1406
|
+
const PIN_DIRTY = ' M package.json\n M package-lock.json\n';
|
|
1407
|
+
const hush = () => {};
|
|
1408
|
+
// A `run` fake for persistPin: `git -c <cfg> push …` is routed as the verb `push`, so a
|
|
1409
|
+
// handler sees the token-path push and the origin push alike. Unset config / no such
|
|
1410
|
+
// remote by default (exit 1), the way git answers a checkout that is not wired.
|
|
1411
|
+
function pinRun(handlers = {}) {
|
|
1412
|
+
const calls = [];
|
|
1413
|
+
const fn = (cmd, args, opts) => {
|
|
1414
|
+
calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
|
|
1415
|
+
const verb = args[0] === '-c' ? args[2] : args[0];
|
|
1416
|
+
const h = handlers[verb];
|
|
1417
|
+
if (typeof h === 'function') return h(args, opts);
|
|
1418
|
+
if (h) return h;
|
|
1419
|
+
if (verb === 'status') return { status: 0, stdout: PIN_DIRTY };
|
|
1420
|
+
if (verb === 'rev-parse') return { status: 0, stdout: 'main\n' };
|
|
1421
|
+
if (verb === 'config' || verb === 'remote') return { status: 1, stdout: '' };
|
|
1422
|
+
return { status: 0, stdout: '' };
|
|
1423
|
+
};
|
|
1424
|
+
fn.calls = calls;
|
|
1425
|
+
return fn;
|
|
1426
|
+
}
|
|
1427
|
+
const pinBase = { instanceDir: '/srv/bongos/acme', fromVersion: '1.19.56', toVersion: '1.19.69', commitPin: true };
|
|
1428
|
+
const acme = { owner: 'acme', name: 'widgets' };
|
|
1429
|
+
const credFileOf = (args) => (String(args[1] || '').match(/^credential\.helper=store --file=(.+)$/) || [])[1] || null;
|
|
1430
|
+
|
|
1431
|
+
t('persistPin: a stored owner token pushes over HTTPS through a chmod-600 credential-store file, and the token never rides argv', () => {
|
|
1432
|
+
const token = 'gho_SECRET_OWNER_TOKEN';
|
|
1433
|
+
let seen = null;
|
|
1434
|
+
const run = pinRun({
|
|
1435
|
+
push: (args, opts) => {
|
|
1436
|
+
const credFile = credFileOf(args);
|
|
1437
|
+
const there = !!credFile && existsSync(credFile);
|
|
1438
|
+
seen = {
|
|
1439
|
+
credFile, there,
|
|
1440
|
+
mode: there ? statSync(credFile).mode & 0o777 : null,
|
|
1441
|
+
body: there ? readFileSync(credFile, 'utf8') : null,
|
|
1442
|
+
url: args[3], target: args[4],
|
|
1443
|
+
noPrompt: opts && opts.env && opts.env.GIT_TERMINAL_PROMPT,
|
|
1444
|
+
};
|
|
1445
|
+
return { status: 0, stdout: '' };
|
|
1446
|
+
},
|
|
1447
|
+
});
|
|
1448
|
+
const r = u.persistPin({ ...pinBase, pushAuth: { token, repo: acme } }, run, hush, hush);
|
|
1449
|
+
assert.deepEqual(r, { applicable: true, committed: true, pushed: true, dirty: u.PIN_FILES, via: 'owner-token' });
|
|
1450
|
+
assert.ok(seen, 'the push ran');
|
|
1451
|
+
assert.ok(seen.credFile, `the push reads a credential-store file; argv was ${JSON.stringify(run.calls)}`);
|
|
1452
|
+
assert.equal(seen.url, 'https://x-access-token@github.com/acme/widgets.git', 'HTTPS at the remote repo, the placeholder user in the URL');
|
|
1453
|
+
assert.equal(seen.target, 'HEAD:main');
|
|
1454
|
+
assert.equal(seen.there, true, 'the credential file exists while git runs');
|
|
1455
|
+
if (process.platform !== 'win32') assert.equal(seen.mode, 0o600, 'chmod 600 — the secret is readable by this user only');
|
|
1456
|
+
assert.equal(seen.body, `https://x-access-token:${token}@github.com\n`, 'the token lives in the file, credential-store shape');
|
|
1457
|
+
assert.equal(seen.noPrompt, '0', 'a rejected credential fails instead of sitting at a password prompt');
|
|
1458
|
+
assert.ok(!existsSync(seen.credFile), 'removed in finally');
|
|
1459
|
+
assert.ok(run.calls.every((c) => !c.includes(token)), `the token never rides argv: ${JSON.stringify(run.calls)}`);
|
|
1460
|
+
assert.ok(!run.calls.some((c) => /^git push /.test(c)), 'the SSH origin was not used');
|
|
1461
|
+
assert.ok(run.calls.some((c) => c === `git add ${u.PIN_FILES.join(' ')}`) && run.calls.some((c) => c.startsWith('git commit -m pin @bongos/core 1.19.69')), 'stage + commit are unchanged');
|
|
1462
|
+
});
|
|
1463
|
+
|
|
1464
|
+
t('persistPin: no stored token → the push falls back to the git remote, with no credential helper anywhere', () => {
|
|
1465
|
+
const run = pinRun();
|
|
1466
|
+
const r = u.persistPin({ ...pinBase }, run, hush, hush);
|
|
1467
|
+
assert.deepEqual(r, { applicable: true, committed: true, pushed: true, dirty: u.PIN_FILES }, 'the origin-path result is byte-for-byte what every existing caller reads');
|
|
1468
|
+
assert.ok(run.calls.some((c) => c === 'git push origin HEAD:main'), 'pushes via the remote');
|
|
1469
|
+
assert.ok(!run.calls.some((c) => /credential\.helper|x-access-token/.test(c)), 'no credential helper, no token URL');
|
|
1470
|
+
// `pushAuth: null` — what resolvePinPushAuth returns on every miss — is the same path.
|
|
1471
|
+
const run2 = pinRun();
|
|
1472
|
+
assert.equal(u.persistPin({ ...pinBase, pushAuth: null }, run2, hush, hush).pushed, true);
|
|
1473
|
+
assert.ok(run2.calls.some((c) => c === 'git push origin HEAD:main'));
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
t('persistPin: a refused origin push on a WIRED private instance is loud — names the read-only key, the consequence, and the fix', () => {
|
|
1477
|
+
const warnings = [];
|
|
1478
|
+
const run = pinRun({
|
|
1479
|
+
push: { status: 128, stdout: '', stderr: 'ERROR: The key you are authenticating with has been marked as read only.\nfatal: Could not read from remote repository.\n' },
|
|
1480
|
+
config: { status: 0, stdout: 'ssh -F /dev/null -i /srv/bongos/acme/.git/cloudbongos-deploy-key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new\n' },
|
|
1481
|
+
remote: { status: 0, stdout: 'git@github.com:acme/widgets.git\n' },
|
|
1482
|
+
});
|
|
1483
|
+
const r = u.persistPin({ ...pinBase }, run, hush, (m) => warnings.push(m));
|
|
1484
|
+
assert.equal(r.committed, true);
|
|
1485
|
+
assert.equal(r.pushed, false);
|
|
1486
|
+
assert.equal(r.refused, 'read-only-deploy-key');
|
|
1487
|
+
// The tokenless refusal is a plain sentence, not the bare status line.
|
|
1488
|
+
assert.match(r.error, /read-only deploy key at \/srv\/bongos\/acme\/\.git\/cloudbongos-deploy-key/, 'names the key');
|
|
1489
|
+
assert.match(r.error, /no owner GitHub token is stored/, 'and why nothing else was tried');
|
|
1490
|
+
assert.notEqual(r.error, 'git push failed');
|
|
1491
|
+
const text = warnings.join('\n');
|
|
1492
|
+
assert.match(text, /PIN NOT PUSHED/, 'shouts');
|
|
1493
|
+
assert.match(text, /\/srv\/bongos\/acme\/\.git\/cloudbongos-deploy-key/, 'names the key on stderr too');
|
|
1494
|
+
assert.match(text, /READ-ONLY/);
|
|
1495
|
+
assert.match(text, /reset --hard/, 'names the mechanism that reverts it');
|
|
1496
|
+
assert.match(text, /npm ci/);
|
|
1497
|
+
assert.match(text, /1\.19\.56/, 'and the version it would fall back to');
|
|
1498
|
+
assert.match(text, /reconnect GitHub with repository access/, 'names the fix');
|
|
1499
|
+
assert.match(text, /--commit-pin/);
|
|
1500
|
+
assert.match(text, /--instance '\/srv\/bongos\/acme'/, 'and where to re-run it');
|
|
1501
|
+
assert.match(text, /push 'https:\/\/github\.com\/acme\/widgets\.git' 'HEAD:main'/, 'plus a by-hand push at the real repo, paste-safe');
|
|
1502
|
+
assert.match(text, /marked as read only/, "git's own reason rides along");
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
t('persistPin: a refused origin push on an UN-wired checkout keeps the plain failure (no false read-only diagnosis)', () => {
|
|
1506
|
+
const warnings = [];
|
|
1507
|
+
const run = pinRun({ push: { status: 1, stdout: '', stderr: 'fatal: unable to access: Could not resolve host\n' } });
|
|
1508
|
+
const r = u.persistPin({ ...pinBase }, run, hush, (m) => warnings.push(m));
|
|
1509
|
+
assert.equal(r.pushed, false);
|
|
1510
|
+
assert.equal(r.error, 'git push failed');
|
|
1511
|
+
assert.equal(r.refused, undefined);
|
|
1512
|
+
assert.ok(!/READ-ONLY|deploy key/.test(warnings.join('\n')), 'no read-only story when no key is pinned');
|
|
1513
|
+
assert.match(warnings.join('\n'), /still not durable/i);
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
t('persistPin: a refused push OVER the token is loud too, and the credential file is still removed', () => {
|
|
1517
|
+
const token = 'gho_EXPIRED_TOKEN';
|
|
1518
|
+
const warnings = [];
|
|
1519
|
+
let credFile = null;
|
|
1520
|
+
const run = pinRun({
|
|
1521
|
+
push: (args) => {
|
|
1522
|
+
credFile = credFileOf(args);
|
|
1523
|
+
return { status: 128, stdout: '', stderr: 'remote: Write access to repository not granted.\nfatal: unable to access \'https://github.com/acme/widgets.git/\': The requested URL returned error: 403\n' };
|
|
1524
|
+
},
|
|
1525
|
+
});
|
|
1526
|
+
const r = u.persistPin({ ...pinBase, pushAuth: { token, repo: acme } }, run, hush, (m) => warnings.push(m));
|
|
1527
|
+
assert.equal(r.committed, true);
|
|
1528
|
+
assert.equal(r.pushed, false);
|
|
1529
|
+
assert.equal(r.refused, 'owner-token');
|
|
1530
|
+
assert.match(r.error, /over the owner's GitHub token/);
|
|
1531
|
+
assert.match(r.error, /expired, or missing the repo scope/);
|
|
1532
|
+
assert.ok(credFile && !existsSync(credFile), 'the credential file is gone even on failure');
|
|
1533
|
+
const text = warnings.join('\n');
|
|
1534
|
+
assert.match(text, /PIN NOT PUSHED/);
|
|
1535
|
+
assert.match(text, /expired/);
|
|
1536
|
+
assert.match(text, /repo.*scope/);
|
|
1537
|
+
assert.match(text, /reconnect GitHub with repository access/, 'names the fix');
|
|
1538
|
+
assert.match(text, /Write access to repository not granted/, "GitHub's own reason rides along, not git's generic fatal line");
|
|
1539
|
+
assert.match(text, /push 'https:\/\/github\.com\/acme\/widgets\.git' 'HEAD:main'/, 'and a by-hand push');
|
|
1540
|
+
assert.ok(!text.includes(token), 'the token is never logged');
|
|
1541
|
+
assert.ok(!run.calls.some((c) => /^git push /.test(c)), 'a token refusal does not retry the read-only origin');
|
|
1542
|
+
});
|
|
1543
|
+
|
|
1544
|
+
t('pinnedDeployKey / remoteGithubRepo: read the ADR 0176 wiring off the checkout, and only GitHub remotes count', () => {
|
|
1545
|
+
const wired = pinRun({ config: { status: 0, stdout: 'ssh -F /dev/null -i /srv/bongos/acme/.git/cloudbongos-deploy-key -o IdentitiesOnly=yes\n' } });
|
|
1546
|
+
assert.equal(u.pinnedDeployKey('/srv/bongos/acme', wired), '/srv/bongos/acme/.git/cloudbongos-deploy-key');
|
|
1547
|
+
assert.equal(u.pinnedDeployKey('/srv/bongos/acme', pinRun()), null, 'unset core.sshCommand → not wired');
|
|
1548
|
+
assert.equal(u.pinnedDeployKey('/srv/bongos/acme', pinRun({ config: { status: 0, stdout: 'ssh -o BatchMode=yes\n' } })), null, 'a sshCommand without -i pins no key');
|
|
1549
|
+
|
|
1550
|
+
const at = (url) => pinRun({ remote: { status: 0, stdout: `${url}\n` } });
|
|
1551
|
+
assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('git@github.com:acme/widgets.git')), acme, 'the ADR 0176 private wiring');
|
|
1552
|
+
assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('https://github.com/acme/widgets')), acme);
|
|
1553
|
+
assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('https://x-access-token@github.com/acme/widgets.git')), acme);
|
|
1554
|
+
const P = require('../scripts/gds/provision.js');
|
|
1555
|
+
assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at(P.instanceRepoRemote(acme))), acme, 'the shared SSH alias the public scaffold wires is GitHub too');
|
|
1556
|
+
assert.equal(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('https://gitlab.com/acme/widgets.git')), null, 'another host is never pointed at github.com');
|
|
1557
|
+
assert.equal(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('git@bitbucket.org:acme/widgets.git')), null);
|
|
1558
|
+
assert.equal(u.remoteGithubRepo('/srv/bongos/acme', 'origin', pinRun()), null, 'no such remote');
|
|
1559
|
+
// --git-remote is honoured: the URL of THAT remote is read.
|
|
1560
|
+
const deploy = pinRun({ remote: (args) => (args[2] === 'deploy' ? { status: 0, stdout: 'git@github.com:acme/widgets.git\n' } : { status: 1, stdout: '' }) });
|
|
1561
|
+
assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'deploy', deploy), acme);
|
|
1562
|
+
});
|
|
1563
|
+
|
|
1564
|
+
t('resolvePinPushAuth: the owner token is spent only when one is stored AND the provisioning row names the remote repo', async () => {
|
|
1565
|
+
const inst = { slug: 'acme', owner_builder_id: 7, target_ref: 'acme/widgets', hosting_shape: 'standalone' };
|
|
1566
|
+
const at = (url) => pinRun({ remote: { status: 0, stdout: `${url}\n` } });
|
|
1567
|
+
const dir = { instanceDir: '/srv/bongos/acme' };
|
|
1568
|
+
|
|
1569
|
+
// stored token + matching row → { token, repo }, the repo parsed from the REMOTE
|
|
1570
|
+
const a = await u.resolvePinPushAuth(dir, { log: hush, instance: inst, getGithubToken: async (bid) => (bid === 7 ? { token: 'gho_T', scope: 'read:user,repo' } : null) }, at('git@github.com:acme/widgets.git'));
|
|
1571
|
+
assert.deepEqual(a, { token: 'gho_T', repo: acme });
|
|
1572
|
+
// case never decides it (GitHub owner/repo names are case-insensitive)
|
|
1573
|
+
const b = await u.resolvePinPushAuth(dir, { log: hush, instance: { ...inst, target_ref: 'Acme/Widgets' }, getGithubToken: async () => ({ token: 'gho_T' }) }, at('git@github.com:acme/widgets.git'));
|
|
1574
|
+
assert.equal(b && b.token, 'gho_T');
|
|
1575
|
+
// no token stored → null: the origin path
|
|
1576
|
+
assert.equal(await u.resolvePinPushAuth(dir, { log: hush, instance: inst, getGithubToken: async () => null }, at('git@github.com:acme/widgets.git')), null);
|
|
1577
|
+
// a row for ANOTHER repo → null: never another instance's credential for this checkout
|
|
1578
|
+
let asked = 0;
|
|
1579
|
+
const other = await u.resolvePinPushAuth(dir, { log: hush, instance: { ...inst, target_ref: 'acme/other' }, getGithubToken: async () => { asked++; return { token: 'gho_T' }; } }, at('git@github.com:acme/widgets.git'));
|
|
1580
|
+
assert.equal(other, null);
|
|
1581
|
+
assert.equal(asked, 0, 'the token store is not even consulted for a mismatched row');
|
|
1582
|
+
// a non-GitHub remote → null, before any row or token lookup
|
|
1583
|
+
let rows = 0;
|
|
1584
|
+
assert.equal(await u.resolvePinPushAuth(dir, { log: hush, getInstanceBySlug: async () => { rows++; return inst; }, getGithubToken: async () => ({ token: 'gho_T' }) }, at('https://gitlab.com/acme/widgets.git')), null);
|
|
1585
|
+
assert.equal(rows, 0);
|
|
1586
|
+
// no row at all (an instance's own DB, a laptop) → null, and a THROWING lookup is the same answer
|
|
1587
|
+
assert.equal(await u.resolvePinPushAuth(dir, { log: hush, getInstanceBySlug: async () => null, getGithubToken: async () => ({ token: 'gho_T' }) }, at('git@github.com:acme/widgets.git')), null);
|
|
1588
|
+
assert.equal(await u.resolvePinPushAuth(dir, { log: hush, getInstanceBySlug: async () => { throw new Error('relation "provisioning_instances" does not exist'); }, getGithubToken: async () => ({ token: 'gho_T' }) }, at('git@github.com:acme/widgets.git')), null);
|
|
1589
|
+
// the by-slug lookup uses the checkout's basename — standaloneRoot is <base>/<slug>
|
|
1590
|
+
const slugs = [];
|
|
1591
|
+
const c = await u.resolvePinPushAuth(dir, { log: hush, getInstanceBySlug: async (s) => { slugs.push(s); return inst; }, getGithubToken: async () => ({ token: 'gho_T' }) }, at('git@github.com:acme/widgets.git'));
|
|
1592
|
+
assert.deepEqual(slugs, ['acme']);
|
|
1593
|
+
assert.equal(c && c.token, 'gho_T');
|
|
1594
|
+
// a row without an owner (or a token lookup that throws) is the origin path, never a crash
|
|
1595
|
+
assert.equal(await u.resolvePinPushAuth(dir, { log: hush, getInstanceBySlug: async () => ({ ...inst, owner_builder_id: null }), getGithubToken: async () => ({ token: 'gho_T' }) }, at('git@github.com:acme/widgets.git')), null);
|
|
1596
|
+
assert.equal(await u.resolvePinPushAuth(dir, { log: hush, instance: inst, getGithubToken: async () => { throw new Error('secret-box off'); } }, at('git@github.com:acme/widgets.git')), null);
|
|
1597
|
+
});
|
|
1598
|
+
|
|
1599
|
+
t('runUpgrade --commit-pin: on a wired private instance the pin lands via the owner token, end to end', async () => {
|
|
1600
|
+
const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
|
|
1601
|
+
const src = mkdtempSync(join(tmpdir(), 'otb-upgrade-src-'));
|
|
1602
|
+
writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
|
|
1603
|
+
const token = 'gho_END_TO_END_TOKEN';
|
|
1604
|
+
const calls = [];
|
|
1605
|
+
let pushArgv = null;
|
|
1606
|
+
const run = (cmd, args, opts) => {
|
|
1607
|
+
calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
|
|
1608
|
+
// pre-flight `git status --porcelain` is clean; persistPin's pathspec-limited look
|
|
1609
|
+
// (`-- package.json package-lock.json`) sees the moved pin.
|
|
1610
|
+
if (cmd === 'git' && args[0] === 'status') return { status: 0, stdout: args.includes('--') ? PIN_DIRTY : '' };
|
|
1611
|
+
if (cmd === 'git' && args[0] === 'rev-parse') return { status: 0, stdout: 'main\n' };
|
|
1612
|
+
if (cmd === 'git' && args[0] === 'remote') return { status: 0, stdout: 'git@github.com:acme/widgets.git\n' };
|
|
1613
|
+
if (cmd === 'git' && args[0] === 'config') return { status: 0, stdout: 'ssh -F /dev/null -i /srv/bongos/acme/.git/cloudbongos-deploy-key -o IdentitiesOnly=yes\n' };
|
|
1614
|
+
if (cmd === 'git' && args[0] === '-c') { pushArgv = args; return { status: 0, stdout: '' }; }
|
|
1615
|
+
if (cmd === 'npm' && args[0] === 'install') {
|
|
1616
|
+
writeFileSync(join(dir, 'node_modules', '@bongos', 'core', 'package.json'), JSON.stringify({ name: '@bongos/core', version: '1.15.0' }) + '\n');
|
|
1617
|
+
}
|
|
1618
|
+
return { status: 0 }; // no stdout: resolveInstanceDb reads "could not run" (not fatal), as the happy-path test's shim does
|
|
1619
|
+
};
|
|
1620
|
+
const res = await u.runUpgrade(
|
|
1621
|
+
{ to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'acme.service', healthCheck: false, commitPin: true },
|
|
1622
|
+
{
|
|
1623
|
+
log: hush, err: hush, run,
|
|
1624
|
+
materialize: () => ({ skills: 35, settings: true, hooks: 3 }),
|
|
1625
|
+
regenerateApiArtifacts: () => ({ ok: true, ran: ['gen-api-docs.js', 'gen-api-client.js'] }),
|
|
1626
|
+
databaseUrl: '', pgDatabase: '',
|
|
1627
|
+
instance: { slug: 'acme', owner_builder_id: 7, target_ref: 'acme/widgets', hosting_shape: 'standalone' },
|
|
1628
|
+
getGithubToken: async () => ({ token, scope: 'read:user,repo' }),
|
|
1629
|
+
},
|
|
1630
|
+
);
|
|
1631
|
+
assert.equal(res.ok, true, `upgrade should succeed: ${res.error}`);
|
|
1632
|
+
assert.equal(res.pin && res.pin.pushed, true, 'the pin was pushed');
|
|
1633
|
+
assert.equal(res.pin.via, 'owner-token', 'over the owner token, not the read-only origin');
|
|
1634
|
+
assert.ok(pushArgv, 'pushed via the credential-store helper');
|
|
1635
|
+
assert.equal(pushArgv[3], 'https://x-access-token@github.com/acme/widgets.git');
|
|
1636
|
+
assert.equal(pushArgv[4], 'HEAD:main');
|
|
1637
|
+
assert.ok(!calls.some((c) => c.includes(token)), 'the token never rides argv');
|
|
1638
|
+
assert.ok(!calls.some((c) => /^git push /.test(c)), 'the read-only origin was never asked');
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
t('runUpgrade: the token store is consulted only when a push is actually coming', async () => {
|
|
1642
|
+
// --pin-no-push, --dry-run, and a clean pin all skip resolvePinPushAuth entirely:
|
|
1643
|
+
// nothing reads a credential it will not spend.
|
|
1644
|
+
const cases = [
|
|
1645
|
+
{ name: 'no --commit-pin', opts: {}, dirtyPin: true },
|
|
1646
|
+
{ name: '--pin-no-push', opts: { commitPin: true, pinNoPush: true }, dirtyPin: true },
|
|
1647
|
+
{ name: '--dry-run', opts: { commitPin: true, dryRun: true }, dirtyPin: true },
|
|
1648
|
+
{ name: 'pin already committed', opts: { commitPin: true }, dirtyPin: false },
|
|
1649
|
+
];
|
|
1650
|
+
for (const c of cases) {
|
|
1651
|
+
const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
|
|
1652
|
+
const src = mkdtempSync(join(tmpdir(), 'otb-upgrade-src-'));
|
|
1653
|
+
writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
|
|
1654
|
+
let resolved = 0;
|
|
1655
|
+
const run = (cmd, args) => {
|
|
1656
|
+
if (cmd === 'git' && args[0] === 'status') return { status: 0, stdout: args.includes('--') && c.dirtyPin ? PIN_DIRTY : '' };
|
|
1657
|
+
if (cmd === 'git' && args[0] === 'rev-parse') return { status: 0, stdout: 'main\n' };
|
|
1658
|
+
if (cmd === 'npm' && args[0] === 'install') {
|
|
1659
|
+
writeFileSync(join(dir, 'node_modules', '@bongos', 'core', 'package.json'), JSON.stringify({ name: '@bongos/core', version: '1.15.0' }) + '\n');
|
|
1660
|
+
}
|
|
1661
|
+
return { status: 0 };
|
|
1662
|
+
};
|
|
1663
|
+
await u.runUpgrade(
|
|
1664
|
+
{ to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'acme.service', healthCheck: false, ...c.opts },
|
|
1665
|
+
{
|
|
1666
|
+
log: hush, err: hush, run,
|
|
1667
|
+
materialize: () => ({}), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
|
|
1668
|
+
databaseUrl: '', pgDatabase: '',
|
|
1669
|
+
resolvePinPushAuth: async () => { resolved++; return null; },
|
|
1670
|
+
},
|
|
1671
|
+
);
|
|
1672
|
+
assert.equal(resolved, 0, `${c.name}: must not consult the token store`);
|
|
1673
|
+
}
|
|
1674
|
+
});
|
|
1675
|
+
|
|
1393
1676
|
await Promise.all(pending);
|
|
1394
1677
|
console.log(`\nupgrade: ${passed} passed, ${failed} failed`);
|
|
1395
1678
|
process.exit(failed ? 1 : 0);
|