@bongos/core 1.21.116 → 1.21.118
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 +68 -33
- package/docs/adr/0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md +2 -1
- package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +34 -0
- package/docs/adr/0381-the-hub-moves-each-hosted-project-by-its-update-rule.md +34 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +49 -49
- package/docs/copy-registry.json +56 -56
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +1 -1
- package/docs/recipes/upgrading-the-core.md +3 -1
- package/modules/hall-ui/public/deploy.js +3 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/gds/fitness-lib.js +35 -0
- package/scripts/gds/fitness.js +2 -2
- package/scripts/gds/provision-auto-upgrade.js +139 -0
- package/scripts/gds/provision-core-upgrade.js +26 -7
- package/scripts/gds/provision-pin-replay.js +126 -0
- package/scripts/gds/provision-repo.js +60 -0
- package/scripts/gds/provision.js +7 -1
- package/scripts/gds/run-unit-tests.js +4 -0
- package/scripts/gds/upgrade-outcome.js +4 -0
- package/scripts/gds/wedge-remedy.js +13 -2
- package/src/module-api.js +1 -1
- package/tests/core_auto_upgrade.mjs +189 -0
- package/tests/db_ownership_handover.mjs +156 -0
- package/tests/fitness_installed_core.mjs +127 -0
- package/tests/pin_replay.mjs +147 -0
- package/tests/pin_write_key.mjs +26 -0
- package/tests/provision.mjs +34 -0
- package/tests/upgrade_migrate.mjs +10 -0
- package/tests/wedge_remedy.mjs +23 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// tests/core_auto_upgrade.mjs — the hub moves each hosted project's core on its own,
|
|
2
|
+
// by that project's update rule (task 1004729, ADR 0381).
|
|
3
|
+
//
|
|
4
|
+
// The runner tick used to compute `recommended` for every cloud-host and stop; a move
|
|
5
|
+
// only happened when someone pressed the button. These tests drive the new step with a
|
|
6
|
+
// fake db and a fake provisioning port and assert WHAT IT QUEUES:
|
|
7
|
+
// behind on `patch` → a preflight for `recommended`
|
|
8
|
+
// preflight passed that target → the move to it
|
|
9
|
+
// pinned / open intent / held target / control-plane row → nothing
|
|
10
|
+
// more candidates than the pace → only AUTO_MOVES_PER_TICK queued
|
|
11
|
+
// the platform-wide off switch → nothing at all
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/core_auto_upgrade.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { autoUpgradeTick, AUTO_MOVES_PER_TICK, autoUpgradeEnabled } from '../scripts/gds/provision-auto-upgrade.js';
|
|
17
|
+
|
|
18
|
+
let passed = 0;
|
|
19
|
+
let failed = 0;
|
|
20
|
+
const t = async (name, fn) => {
|
|
21
|
+
try { await fn(); passed += 1; console.log(` PASS ${name}`); }
|
|
22
|
+
catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// One hosted project, its snapshot row joined on, as the candidate query returns it.
|
|
26
|
+
function row(over = {}) {
|
|
27
|
+
return {
|
|
28
|
+
id: 52, slug: 'toast', owner_builder_id: 7, hosting_shape: 'cloud-host', status: 'active',
|
|
29
|
+
update_channel: 'patch', served_version: '1.21.20', recommended: '1.21.69', preflight: null,
|
|
30
|
+
...over,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A fake world: `rows` are candidates, `intents` the provisioning_intents history.
|
|
35
|
+
function makeDeps({ rows = [row()], intents = [], env = {} } = {}) {
|
|
36
|
+
const queued = [];
|
|
37
|
+
const events = [];
|
|
38
|
+
const db = {
|
|
39
|
+
async query(sql, params) {
|
|
40
|
+
if (/FROM provisioning_instances/.test(sql)) return { rows };
|
|
41
|
+
if (/FROM provisioning_intents/.test(sql)) {
|
|
42
|
+
const ids = params[0].map(Number);
|
|
43
|
+
return { rows: intents.filter((i) => ids.includes(Number(i.instance_id))) };
|
|
44
|
+
}
|
|
45
|
+
throw new Error(`unexpected query: ${sql}`);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
const provisioning = {
|
|
49
|
+
async enqueueIntent(_db, instanceId, action, requestedBy, target) {
|
|
50
|
+
const intent = { id: 900 + queued.length, instance_id: instanceId, action, requested_by: requestedBy, target_version: target, state: 'pending' };
|
|
51
|
+
queued.push(intent);
|
|
52
|
+
return { intent, created: true };
|
|
53
|
+
},
|
|
54
|
+
async recordEvent(_db, e) { events.push(e); },
|
|
55
|
+
};
|
|
56
|
+
return { db, provisioning, env, log: () => {}, queued, events };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
console.log('core auto-upgrade tick (task 1004729)');
|
|
60
|
+
|
|
61
|
+
await t('a patch tenant behind gets a preflight queued for recommended, attributed to the runner', async () => {
|
|
62
|
+
const d = makeDeps();
|
|
63
|
+
const r = await autoUpgradeTick(d);
|
|
64
|
+
assert.equal(d.queued.length, 1);
|
|
65
|
+
assert.equal(d.queued[0].action, 'core-upgrade-preflight');
|
|
66
|
+
assert.equal(d.queued[0].target_version, '1.21.69');
|
|
67
|
+
assert.equal(d.queued[0].instance_id, 52);
|
|
68
|
+
assert.equal(d.queued[0].requested_by, 'runner:auto-upgrade');
|
|
69
|
+
assert.equal(r.queued, 1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await t('a preflight that PASSED for that target queues the move to it', async () => {
|
|
73
|
+
const d = makeDeps({ rows: [row({ preflight: { target: '1.21.69', ok: true } })],
|
|
74
|
+
intents: [{ instance_id: 52, action: 'core-upgrade-preflight', state: 'done', target_version: '1.21.69' }] });
|
|
75
|
+
await autoUpgradeTick(d);
|
|
76
|
+
assert.equal(d.queued.length, 1);
|
|
77
|
+
assert.equal(d.queued[0].action, 'core-upgrade');
|
|
78
|
+
assert.equal(d.queued[0].target_version, '1.21.69');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await t('a passed preflight for a DIFFERENT target re-asks for the current one', async () => {
|
|
82
|
+
const d = makeDeps({ rows: [row({ preflight: { target: '1.21.50', ok: true } })] });
|
|
83
|
+
await autoUpgradeTick(d);
|
|
84
|
+
assert.equal(d.queued[0].action, 'core-upgrade-preflight');
|
|
85
|
+
assert.equal(d.queued[0].target_version, '1.21.69');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
await t('a pinned tenant gets nothing', async () => {
|
|
89
|
+
const d = makeDeps({ rows: [row({ update_channel: 'pinned' })] });
|
|
90
|
+
await autoUpgradeTick(d);
|
|
91
|
+
assert.equal(d.queued.length, 0);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await t('a tenant with an open intent gets nothing new', async () => {
|
|
95
|
+
for (const state of ['pending', 'running']) {
|
|
96
|
+
const d = makeDeps({ intents: [{ instance_id: 52, action: 'restart', state, target_version: null }] });
|
|
97
|
+
await autoUpgradeTick(d);
|
|
98
|
+
assert.equal(d.queued.length, 0, state);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
await t('a target held by a failed move is not queued (retired core-upgrade)', async () => {
|
|
103
|
+
const d = makeDeps({ rows: [row({ preflight: { target: '1.21.69', ok: true } })],
|
|
104
|
+
intents: [{ instance_id: 52, action: 'core-upgrade', state: 'error', target_version: '1.21.69' }] });
|
|
105
|
+
await autoUpgradeTick(d);
|
|
106
|
+
assert.equal(d.queued.length, 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await t('a target held by a refused preflight is not queued', async () => {
|
|
110
|
+
const d = makeDeps({ rows: [row({ preflight: { target: '1.21.69', ok: false, error: 'migration would fail' } })] });
|
|
111
|
+
await autoUpgradeTick(d);
|
|
112
|
+
assert.equal(d.queued.length, 0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await t('a target whose preflight intent itself errored is not re-asked', async () => {
|
|
116
|
+
const d = makeDeps({ intents: [{ instance_id: 52, action: 'core-upgrade-preflight', state: 'error', target_version: '1.21.69' }] });
|
|
117
|
+
await autoUpgradeTick(d);
|
|
118
|
+
assert.equal(d.queued.length, 0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await t('a move that finished but left the box behind is not re-queued every tick', async () => {
|
|
122
|
+
const d = makeDeps({ rows: [row({ preflight: { target: '1.21.69', ok: true } })],
|
|
123
|
+
intents: [{ instance_id: 52, action: 'core-upgrade', state: 'done', target_version: '1.21.69' }] });
|
|
124
|
+
await autoUpgradeTick(d);
|
|
125
|
+
assert.equal(d.queued.length, 0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await t('an older failed target does not hold a newer one', async () => {
|
|
129
|
+
const d = makeDeps({ intents: [{ instance_id: 52, action: 'core-upgrade', state: 'error', target_version: '1.21.60' }] });
|
|
130
|
+
await autoUpgradeTick(d);
|
|
131
|
+
assert.equal(d.queued.length, 1);
|
|
132
|
+
assert.equal(d.queued[0].target_version, '1.21.69');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await t('the control-plane row (and any non-cloud-host) gets nothing', async () => {
|
|
136
|
+
const d = makeDeps({ rows: [row({ id: 1, slug: 'hub', hosting_shape: 'control-plane' }), row({ id: 3, slug: 'ct', hosting_shape: 'co-tenant' })] });
|
|
137
|
+
await autoUpgradeTick(d);
|
|
138
|
+
assert.equal(d.queued.length, 0);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await t('nothing is queued when recommended is not newer than served', async () => {
|
|
142
|
+
for (const over of [{ recommended: null }, { recommended: '1.21.20' }, { recommended: '1.21.10' }, { served_version: null }]) {
|
|
143
|
+
const d = makeDeps({ rows: [row(over)] });
|
|
144
|
+
await autoUpgradeTick(d);
|
|
145
|
+
assert.equal(d.queued.length, 0, JSON.stringify(over));
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await t('at most the pace limit is queued per tick', async () => {
|
|
150
|
+
assert.equal(AUTO_MOVES_PER_TICK, 1);
|
|
151
|
+
const d = makeDeps({ rows: [row({ id: 52 }), row({ id: 53, slug: 'b' }), row({ id: 54, slug: 'c' })] });
|
|
152
|
+
const r = await autoUpgradeTick(d);
|
|
153
|
+
assert.equal(d.queued.length, AUTO_MOVES_PER_TICK);
|
|
154
|
+
assert.equal(r.queued, AUTO_MOVES_PER_TICK);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await t('the pace counts only intents actually created (a lost race does not spend it)', async () => {
|
|
158
|
+
const d = makeDeps({ rows: [row({ id: 52 }), row({ id: 53, slug: 'b' })] });
|
|
159
|
+
const real = d.provisioning.enqueueIntent;
|
|
160
|
+
let first = true;
|
|
161
|
+
d.provisioning.enqueueIntent = async (...a) => {
|
|
162
|
+
if (first) { first = false; return { intent: { state: 'pending' }, created: false }; }
|
|
163
|
+
return real(...a);
|
|
164
|
+
};
|
|
165
|
+
await autoUpgradeTick(d);
|
|
166
|
+
assert.equal(d.queued.length, 1);
|
|
167
|
+
assert.equal(d.queued[0].instance_id, 53);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await t('the off switch stops every automatic move, and it defaults ON', async () => {
|
|
171
|
+
for (const v of ['off', '0', 'false', 'no', 'OFF']) {
|
|
172
|
+
const d = makeDeps({ env: { BONGOS_AUTO_CORE_UPGRADE: v } });
|
|
173
|
+
const r = await autoUpgradeTick(d);
|
|
174
|
+
assert.equal(d.queued.length, 0, v);
|
|
175
|
+
assert.equal(r.off, true);
|
|
176
|
+
}
|
|
177
|
+
assert.equal(autoUpgradeEnabled({}), true);
|
|
178
|
+
assert.equal(autoUpgradeEnabled({ BONGOS_AUTO_CORE_UPGRADE: 'on' }), true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
await t('a refusal from enqueueIntent (e.g. the control-plane guard) never fails the tick', async () => {
|
|
182
|
+
const d = makeDeps();
|
|
183
|
+
d.provisioning.enqueueIntent = async () => { throw new Error('refused'); };
|
|
184
|
+
const r = await autoUpgradeTick(d);
|
|
185
|
+
assert.equal(r.queued, 0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
189
|
+
if (failed) process.exit(1);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// tests/db_ownership_handover.mjs — the ownership handover against a REAL Postgres
|
|
2
|
+
// (task 1004735).
|
|
3
|
+
//
|
|
4
|
+
// A project with its own database account had its tables made by postgres at provisioning
|
|
5
|
+
// and only GRANTed to the account. Later core moves migrate AS that account, and GRANT ALL
|
|
6
|
+
// does not carry the right to ALTER TABLE or CREATE OR REPLACE FUNCTION — only ownership
|
|
7
|
+
// does — so toast's first ALTERing move failed "must be owner of table tasks" and rolled
|
|
8
|
+
// back. dbOwnershipSql (scripts/gds/provision-repo.js) hands everything over. It is one long
|
|
9
|
+
// DO block of catalog queries, which a string test cannot prove: whether it actually runs,
|
|
10
|
+
// which objects it reaches, and that it leaves extension-owned objects alone are questions
|
|
11
|
+
// only a real engine answers.
|
|
12
|
+
//
|
|
13
|
+
// Hermetic within the database: a throwaway schema and role, both dropped at the end, so the
|
|
14
|
+
// shared fixture database's public schema is never touched. Needs a superuser (CREATE ROLE,
|
|
15
|
+
// giving away postgres-owned objects) — the integration lane's postgres is one.
|
|
16
|
+
//
|
|
17
|
+
// Real-DB integration test: self-skips without a reachable Postgres; in the INTEGRATION set
|
|
18
|
+
// so the default DB-free lane never runs it.
|
|
19
|
+
//
|
|
20
|
+
// Run: DATABASE_URL=... node tests/db_ownership_handover.mjs
|
|
21
|
+
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
|
|
25
|
+
const require = createRequire(import.meta.url);
|
|
26
|
+
const { Pool } = require('pg');
|
|
27
|
+
const { dbOwnershipSql } = require('../scripts/gds/provision-repo.js');
|
|
28
|
+
|
|
29
|
+
const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
|
|
30
|
+
let client;
|
|
31
|
+
try {
|
|
32
|
+
client = await pool.connect();
|
|
33
|
+
} catch (e) {
|
|
34
|
+
console.log(`db_ownership_handover.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
|
|
35
|
+
await pool.end().catch(() => {});
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
const { rows: [me] } = await client.query('SELECT rolsuper FROM pg_roles WHERE rolname = current_user');
|
|
39
|
+
if (!me || !me.rolsuper) {
|
|
40
|
+
console.log('db_ownership_handover.mjs SKIPPED — the connecting role is not a superuser, so it cannot stand in for postgres here.');
|
|
41
|
+
client.release();
|
|
42
|
+
await pool.end();
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SCHEMA = `own_t_${process.pid}`;
|
|
47
|
+
const ROLE = `own-t-role-${process.pid}`; // a hyphen, like the real bongos-<slug> roles
|
|
48
|
+
const q = (sql) => client.query(sql);
|
|
49
|
+
let passed = 0;
|
|
50
|
+
let failed = 0;
|
|
51
|
+
const t = async (name, fn) => {
|
|
52
|
+
try { await fn(); passed += 1; console.log(` PASS ${name}`); }
|
|
53
|
+
catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// Every object in the schema that the handover is meant to reach, with its owner. Excludes
|
|
57
|
+
// what follows something else: table row types, auto-made array types, column-owned sequences.
|
|
58
|
+
const OWNERS = `
|
|
59
|
+
SELECT c.relname AS name, pg_get_userbyid(c.relowner) AS owner FROM pg_class c
|
|
60
|
+
WHERE c.relnamespace = '${SCHEMA}'::regnamespace AND c.relkind IN ('r','p','v','m','f','S')
|
|
61
|
+
UNION ALL
|
|
62
|
+
SELECT p.proname, pg_get_userbyid(p.proowner) FROM pg_proc p WHERE p.pronamespace = '${SCHEMA}'::regnamespace
|
|
63
|
+
UNION ALL
|
|
64
|
+
SELECT y.typname, pg_get_userbyid(y.typowner) FROM pg_type y
|
|
65
|
+
WHERE y.typnamespace = '${SCHEMA}'::regnamespace AND y.typtype IN ('e','d','r')`;
|
|
66
|
+
|
|
67
|
+
let extension = null;
|
|
68
|
+
try {
|
|
69
|
+
await q(`CREATE ROLE "${ROLE}" LOGIN`);
|
|
70
|
+
// The schema is the role's, as public is the database owner's on a real project (PG15+:
|
|
71
|
+
// pg_database_owner) — the objects INSIDE it are still postgres's, which is the bug.
|
|
72
|
+
await q(`CREATE SCHEMA ${SCHEMA} AUTHORIZATION "${ROLE}"`);
|
|
73
|
+
await q(`SET search_path = ${SCHEMA}`);
|
|
74
|
+
await q(`CREATE TABLE tasks (id serial PRIMARY KEY, n int GENERATED BY DEFAULT AS IDENTITY, title text)`);
|
|
75
|
+
await q(`CREATE TABLE events (id bigint, at date) PARTITION BY RANGE (at)`);
|
|
76
|
+
await q(`CREATE TABLE events_2026 PARTITION OF events FOR VALUES FROM ('2026-01-01') TO ('2027-01-01')`);
|
|
77
|
+
await q(`CREATE VIEW open_tasks AS SELECT * FROM tasks`);
|
|
78
|
+
await q(`CREATE MATERIALIZED VIEW task_count AS SELECT count(*) FROM tasks`);
|
|
79
|
+
await q(`CREATE SEQUENCE free_seq`);
|
|
80
|
+
await q(`CREATE FUNCTION promote_dependents_on_ship() RETURNS int LANGUAGE sql AS 'SELECT 1'`);
|
|
81
|
+
await q(`CREATE PROCEDURE tidy() LANGUAGE sql AS 'SELECT 1'`);
|
|
82
|
+
await q(`CREATE AGGREGATE total(int) (SFUNC = int4pl, STYPE = int)`);
|
|
83
|
+
await q(`CREATE TYPE mood AS ENUM ('ok', 'not')`);
|
|
84
|
+
await q(`CREATE DOMAIN positive AS int CHECK (VALUE > 0)`);
|
|
85
|
+
await q(`CREATE TYPE pair AS (a int, b int)`);
|
|
86
|
+
// An extension's objects in the same schema must be left alone (and must not make the
|
|
87
|
+
// handover error). Use whichever stock extension this server has and has not installed.
|
|
88
|
+
const { rows: ext } = await q(`SELECT name FROM pg_available_extensions
|
|
89
|
+
WHERE name IN ('pg_trgm','citext','hstore','tablefunc','fuzzystrmatch') AND installed_version IS NULL ORDER BY name LIMIT 1`);
|
|
90
|
+
if (ext.length) {
|
|
91
|
+
extension = ext[0].name;
|
|
92
|
+
await q(`CREATE EXTENSION ${extension} SCHEMA ${SCHEMA}`);
|
|
93
|
+
}
|
|
94
|
+
await q(`RESET search_path`);
|
|
95
|
+
|
|
96
|
+
console.log('\nthe ownership handover against a real Postgres:');
|
|
97
|
+
|
|
98
|
+
await t('before: the account can USE the tables but cannot ALTER one — the bug, reproduced', async () => {
|
|
99
|
+
await q(`GRANT ALL ON ALL TABLES IN SCHEMA ${SCHEMA} TO "${ROLE}"`);
|
|
100
|
+
await q(`SET ROLE "${ROLE}"`);
|
|
101
|
+
try {
|
|
102
|
+
await assert.rejects(q(`ALTER TABLE ${SCHEMA}.tasks ADD COLUMN parked boolean`), /must be owner of table tasks/);
|
|
103
|
+
} finally { await q('RESET ROLE'); }
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await t('the handover runs, and every table, view, sequence, function and type is the account\'s', async () => {
|
|
107
|
+
await q(dbOwnershipSql(ROLE, SCHEMA));
|
|
108
|
+
const { rows } = await q(OWNERS);
|
|
109
|
+
const extOwned = extension
|
|
110
|
+
? new Set((await q(`SELECT p.proname AS name FROM pg_proc p JOIN pg_depend d ON d.objid = p.oid AND d.deptype = 'e'
|
|
111
|
+
WHERE p.pronamespace = '${SCHEMA}'::regnamespace
|
|
112
|
+
UNION SELECT y.typname FROM pg_type y JOIN pg_depend d ON d.objid = y.oid AND d.deptype = 'e'
|
|
113
|
+
WHERE y.typnamespace = '${SCHEMA}'::regnamespace`)).rows.map((r) => r.name))
|
|
114
|
+
: new Set();
|
|
115
|
+
const mine = rows.filter((r) => !extOwned.has(r.name));
|
|
116
|
+
for (const name of ['tasks', 'tasks_id_seq', 'tasks_n_seq', 'events', 'events_2026', 'open_tasks', 'task_count', 'free_seq',
|
|
117
|
+
'promote_dependents_on_ship', 'tidy', 'total', 'mood', 'positive']) {
|
|
118
|
+
const row = mine.find((r) => r.name === name);
|
|
119
|
+
assert.ok(row, `${name} exists`);
|
|
120
|
+
assert.equal(row.owner, ROLE, `${name} is owned by the account`);
|
|
121
|
+
}
|
|
122
|
+
const { rows: [pair] } = await q(`SELECT pg_get_userbyid(typowner) AS owner FROM pg_type WHERE typname = 'pair' AND typnamespace = '${SCHEMA}'::regnamespace`);
|
|
123
|
+
assert.equal(pair.owner, ROLE, 'a CREATE TYPE composite too');
|
|
124
|
+
const left = mine.filter((r) => r.owner !== ROLE);
|
|
125
|
+
assert.deepEqual(left, [], 'nothing of the project\'s is left with postgres');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await t('extension objects stay with the extension\'s owner — never handed over', async () => {
|
|
129
|
+
if (!extension) { console.log(' (no spare stock extension on this server — sub-case not exercised)'); return; }
|
|
130
|
+
const { rows } = await q(`SELECT pg_get_userbyid(p.proowner) AS owner FROM pg_proc p JOIN pg_depend d ON d.objid = p.oid AND d.deptype = 'e'
|
|
131
|
+
WHERE p.pronamespace = '${SCHEMA}'::regnamespace`);
|
|
132
|
+
assert.ok(rows.length > 0, `precondition: ${extension} put functions in the schema`);
|
|
133
|
+
assert.ok(rows.every((r) => r.owner !== ROLE), `${extension}'s functions are untouched`);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await t('after: the account can ALTER a table and CREATE OR REPLACE a function — what core_274 needed', async () => {
|
|
137
|
+
await q(`SET ROLE "${ROLE}"`);
|
|
138
|
+
try {
|
|
139
|
+
await q(`ALTER TABLE ${SCHEMA}.tasks ADD COLUMN parked boolean`);
|
|
140
|
+
await q(`CREATE OR REPLACE FUNCTION ${SCHEMA}.promote_dependents_on_ship() RETURNS int LANGUAGE sql AS 'SELECT 2'`);
|
|
141
|
+
} finally { await q('RESET ROLE'); }
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await t('a second run is a clean no-op (provisioning re-runs it; the remedy may meet a healed project)', async () => {
|
|
145
|
+
await q(dbOwnershipSql(ROLE, SCHEMA));
|
|
146
|
+
});
|
|
147
|
+
} finally {
|
|
148
|
+
await q('RESET ROLE').catch(() => {});
|
|
149
|
+
await q(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch((e) => console.log(` cleanup: ${e.message}`));
|
|
150
|
+
await q(`DROP ROLE IF EXISTS "${ROLE}"`).catch((e) => console.log(` cleanup: ${e.message}`));
|
|
151
|
+
client.release();
|
|
152
|
+
await pool.end();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
console.log(`\ndb_ownership_handover: ${passed} passed, ${failed} failed`);
|
|
156
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// tests/fitness_installed_core.mjs — fitness.js in a project that INSTALLS the core
|
|
2
|
+
// (task 1004737).
|
|
3
|
+
//
|
|
4
|
+
// There the core sits at <project>/node_modules/@bongos/core: not a git checkout of its
|
|
5
|
+
// own, just an ignored folder inside the project's repo. Every fitness check guards the
|
|
6
|
+
// core's own source and enumerates it with git, so each scan found nothing and either
|
|
7
|
+
// threw ("docs-entropy: scan defect — enumerated 0 tracked markdown files") or reported
|
|
8
|
+
// a scan defect — `bongos exec scripts/gds/fitness.js` crashed on toast, on 1.21.20 and
|
|
9
|
+
// again on 1.21.115. The core repo's CI already ran this gate before publishing, so in an
|
|
10
|
+
// installed core it steps aside, and says so.
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { test } from 'node:test';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
22
|
+
const lib = require(path.join(ROOT, 'scripts', 'gds', 'fitness-lib.js'));
|
|
23
|
+
|
|
24
|
+
const git = (cwd, ...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
25
|
+
|
|
26
|
+
function tempDir(t) {
|
|
27
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'fitness-installed-'));
|
|
28
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
29
|
+
return dir;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
test('isInstalledCore: yes only for a core under node_modules that is not its own checkout', (t) => {
|
|
33
|
+
const base = tempDir(t);
|
|
34
|
+
const repo = path.join(base, 'project');
|
|
35
|
+
const nested = path.join(repo, 'node_modules', '@bongos', 'core');
|
|
36
|
+
const vendored = path.join(repo, 'vendor', 'core');
|
|
37
|
+
const looseInstalled = path.join(base, 'bare', 'node_modules', '@bongos', 'core');
|
|
38
|
+
const loose = path.join(base, 'loose');
|
|
39
|
+
for (const d of [nested, vendored, looseInstalled, loose]) fs.mkdirSync(d, { recursive: true });
|
|
40
|
+
git(repo, 'init', '-q');
|
|
41
|
+
assert.equal(lib.isInstalledCore(nested), true, 'an installed package inside a project repo is installed');
|
|
42
|
+
assert.equal(lib.isInstalledCore(looseInstalled), true, 'so is one under node_modules with no repo around it');
|
|
43
|
+
assert.equal(lib.isInstalledCore(ROOT), false, 'this checkout (main or a worktree) is not — the gate keeps running here');
|
|
44
|
+
assert.equal(lib.isInstalledCore(repo), false, 'nor is the root of any checkout');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// The grader's finding on the first cut: deciding "installed" from a git FAILURE let any git
|
|
48
|
+
// trouble in the core's own checkout (git off PATH, no .git, a misresolved root) skip the whole
|
|
49
|
+
// gate — rank guards and the core/host boundary included — and go green. Outside node_modules
|
|
50
|
+
// the answer is now no regardless of git, so those trees run every check and fail closed.
|
|
51
|
+
test('isInstalledCore: a git failure or a nested folder OUTSIDE node_modules never reads as installed (fail closed)', (t) => {
|
|
52
|
+
const base = tempDir(t);
|
|
53
|
+
const repo = path.join(base, 'project');
|
|
54
|
+
const vendored = path.join(repo, 'vendor', 'core');
|
|
55
|
+
const loose = path.join(base, 'loose');
|
|
56
|
+
fs.mkdirSync(vendored, { recursive: true });
|
|
57
|
+
fs.mkdirSync(loose);
|
|
58
|
+
git(repo, 'init', '-q');
|
|
59
|
+
assert.equal(lib.isInstalledCore(loose), false, 'no repo at all (git fails) outside node_modules: run the checks');
|
|
60
|
+
assert.equal(lib.isInstalledCore(vendored), false, 'a folder inside another repo but not under node_modules: run the checks');
|
|
61
|
+
assert.equal(lib.isInstalledCore(path.join(base, 'does-not-exist')), false, 'a misresolved root: run the checks');
|
|
62
|
+
const savedPath = process.env.PATH;
|
|
63
|
+
process.env.PATH = '';
|
|
64
|
+
try {
|
|
65
|
+
assert.equal(lib.isInstalledCore(ROOT), false, 'git missing from PATH in the core checkout: run the checks');
|
|
66
|
+
} finally {
|
|
67
|
+
process.env.PATH = savedPath;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('runChecks: in the core\'s own checkout every check runs, and a throw still crashes (fail closed)', () => {
|
|
72
|
+
const ran = [];
|
|
73
|
+
const out = lib.runChecks([() => { ran.push(1); return { hardFail: false }; }, () => { ran.push(2); return { hardFail: true }; }], { installed: false });
|
|
74
|
+
assert.deepEqual(ran, [1, 2]);
|
|
75
|
+
assert.equal(out.length, 2);
|
|
76
|
+
assert.throws(() => lib.runChecks([() => { throw new Error('scan defect'); }], { installed: false }), /scan defect/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('runChecks: in an installed core no check runs, and one non-failing result says why', () => {
|
|
80
|
+
let ran = 0;
|
|
81
|
+
const out = lib.runChecks([() => { ran++; throw new Error('scan defect'); }, () => { ran++; return { hardFail: true }; }], { installed: true });
|
|
82
|
+
assert.equal(ran, 0, 'no check is run against a tree with no core source in it');
|
|
83
|
+
assert.equal(out.length, 1);
|
|
84
|
+
assert.equal(out[0].hardFail, false);
|
|
85
|
+
assert.match(out[0].name, /skipped/);
|
|
86
|
+
assert.match(out[0].name, /installed package/);
|
|
87
|
+
assert.ok(out[0].warnings.length > 0, 'the skip surfaces as a warning, not a silent pass');
|
|
88
|
+
assert.match(out[0].note, /2 checks/, 'and counts what it did not run');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('end to end: fitness.js run from <project>/node_modules/@bongos/core exits 0 with the skip, instead of crashing', (t) => {
|
|
92
|
+
const project = tempDir(t);
|
|
93
|
+
const core = path.join(project, 'node_modules', '@bongos', 'core');
|
|
94
|
+
// The working tree as the package would ship it (tracked + new files), copied — not
|
|
95
|
+
// linked — so git inside it answers for the PROJECT, exactly as on toast.
|
|
96
|
+
const files = git(ROOT, 'ls-files', '-z', '--cached', '--others', '--exclude-standard').split('\0').filter(Boolean);
|
|
97
|
+
for (const f of files) {
|
|
98
|
+
const src = path.join(ROOT, f);
|
|
99
|
+
let st;
|
|
100
|
+
try { st = fs.lstatSync(src); } catch { continue; } // deleted in the working tree
|
|
101
|
+
if (!st.isFile()) continue;
|
|
102
|
+
fs.mkdirSync(path.dirname(path.join(core, f)), { recursive: true });
|
|
103
|
+
fs.copyFileSync(src, path.join(core, f));
|
|
104
|
+
}
|
|
105
|
+
fs.writeFileSync(path.join(project, '.gitignore'), 'node_modules\n');
|
|
106
|
+
fs.writeFileSync(path.join(project, 'CLAUDE.md'), '# project\n');
|
|
107
|
+
git(project, 'init', '-q');
|
|
108
|
+
git(project, 'add', '-A');
|
|
109
|
+
git(project, '-c', 'user.email=t@example.com', '-c', 'user.name=t', 'commit', '-qm', 'init');
|
|
110
|
+
|
|
111
|
+
let out = '';
|
|
112
|
+
let status = 0;
|
|
113
|
+
try {
|
|
114
|
+
out = execFileSync(process.execPath, [path.join(core, 'scripts', 'gds', 'fitness.js')], {
|
|
115
|
+
cwd: project,
|
|
116
|
+
encoding: 'utf8',
|
|
117
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
118
|
+
env: { ...process.env, NODE_PATH: path.join(ROOT, 'node_modules') },
|
|
119
|
+
});
|
|
120
|
+
} catch (e) {
|
|
121
|
+
status = e.status;
|
|
122
|
+
out = `${e.stdout || ''}${e.stderr || ''}`;
|
|
123
|
+
}
|
|
124
|
+
assert.equal(status, 0, `fitness must not fail an installed core's project:\n${out.slice(0, 2000)}`);
|
|
125
|
+
assert.match(out, /skipped: the core here is an installed package/);
|
|
126
|
+
assert.doesNotMatch(out, /scan defect/);
|
|
127
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// tests/pin_replay.mjs — a core move whose pin push is refused as "behind" replays the pin
|
|
2
|
+
// commit onto GitHub's newer history instead of leaving GitHub on the old core (task 1004733).
|
|
3
|
+
//
|
|
4
|
+
// The normal case for a project worked from a laptop: the owner has pushed commits the box
|
|
5
|
+
// does not have, so the box's pin commit cannot fast-forward GitHub. Before this task the move
|
|
6
|
+
// finished `done` with a `repo_not_updated:behind` note and GitHub kept naming the old core, so
|
|
7
|
+
// the next deploy from GitHub could quietly roll the core back. What these pin, against REAL
|
|
8
|
+
// git repositories (a bare "GitHub", the box's checkout, the owner's laptop clone):
|
|
9
|
+
// * origin ahead → only the pin commit is replayed on top of it and pushed; the owner's
|
|
10
|
+
// commits stay, nothing is forced, and the live box's checkout is not touched
|
|
11
|
+
// * the owner changed the lockfile too → reported in plain words, GitHub left as it was
|
|
12
|
+
// * origin already up to date → the ordinary push, no replay at all
|
|
13
|
+
//
|
|
14
|
+
// Run: node tests/pin_replay.mjs
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { pushUpgradePin } from '../scripts/gds/provision-core-upgrade.js';
|
|
22
|
+
|
|
23
|
+
let passed = 0, failed = 0;
|
|
24
|
+
const t = async (name, fn) => {
|
|
25
|
+
try { await fn(); passed += 1; console.log(` PASS ${name}`); }
|
|
26
|
+
catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Forward slashes: the commands run through bash, which would eat a Windows backslash.
|
|
30
|
+
const fwd = (p) => p.replace(/\\/g, '/');
|
|
31
|
+
const ID = '-c user.name=tester -c user.email=tester@example.invalid';
|
|
32
|
+
|
|
33
|
+
// The control plane's executor, for real: a shell command, the allowFail result shape
|
|
34
|
+
// provision-config.js softFailResult returns (output kept, softFailed set).
|
|
35
|
+
function realExec(ran) {
|
|
36
|
+
return (cmd, opts = {}) => {
|
|
37
|
+
ran.push(cmd);
|
|
38
|
+
const r = spawnSync('bash', ['-c', cmd], { encoding: 'utf8', env: { ...process.env, ...(opts.env || {}) } });
|
|
39
|
+
if (r.status === 0) return { ok: true, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
40
|
+
if (!opts.allowFail) throw new Error(`command failed: ${cmd}\n${r.stderr}`);
|
|
41
|
+
return { ok: true, stdout: r.stdout || '', stderr: r.stderr || '', softFailed: true };
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const sh = (cmd) => {
|
|
45
|
+
const r = spawnSync('bash', ['-c', cmd], { encoding: 'utf8' });
|
|
46
|
+
if (r.status !== 0) throw new Error(`setup failed: ${cmd}\n${r.stderr}`);
|
|
47
|
+
return String(r.stdout || '').trim();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const pkg = (core, extra = {}) => `${JSON.stringify({ name: 'site', dependencies: { '@bongos/core': core, ...extra } }, null, 2)}\n`;
|
|
51
|
+
const lock = (core, extra = '') => `${JSON.stringify({ name: 'site', lockfileVersion: 3, packages: { 'node_modules/@bongos/core': { version: core } }, note: extra }, null, 2)}\n`;
|
|
52
|
+
|
|
53
|
+
// A bare "GitHub", the box's checkout (holding an unpushed pin commit, as upgrade.js
|
|
54
|
+
// --commit-pin --pin-no-push leaves it), and the owner's laptop clone.
|
|
55
|
+
function world({ ownerChanges = null } = {}) {
|
|
56
|
+
const root = fwd(mkdtempSync(join(tmpdir(), 'pin-replay-')));
|
|
57
|
+
const origin = `${root}/origin.git`, box = `${root}/box`, laptop = `${root}/laptop`, scratch = `${root}/scratch`;
|
|
58
|
+
sh(`git init -q --bare ${origin} && git -C ${origin} symbolic-ref HEAD refs/heads/main`);
|
|
59
|
+
sh(`git init -q ${laptop} && git -C ${laptop} checkout -q -b main`);
|
|
60
|
+
writeFileSync(`${laptop}/package.json`, pkg('1.0.0'));
|
|
61
|
+
writeFileSync(`${laptop}/package-lock.json`, lock('1.0.0'));
|
|
62
|
+
writeFileSync(`${laptop}/index.html`, '<p>v1</p>\n');
|
|
63
|
+
sh(`git -C ${laptop} add -A && git -C ${laptop} ${ID} commit -q -m init && git -C ${laptop} remote add origin ${origin} && git -C ${laptop} push -q origin main`);
|
|
64
|
+
sh(`git clone -q ${origin} ${box}`);
|
|
65
|
+
// The move's pin commit, on the box.
|
|
66
|
+
writeFileSync(`${box}/package.json`, pkg('1.1.0'));
|
|
67
|
+
writeFileSync(`${box}/package-lock.json`, lock('1.1.0'));
|
|
68
|
+
sh(`git -C ${box} add package.json package-lock.json && git -C ${box} ${ID} commit -q -m "pin @bongos/core 1.1.0 (was 1.0.0)"`);
|
|
69
|
+
if (ownerChanges) {
|
|
70
|
+
ownerChanges(laptop);
|
|
71
|
+
sh(`git -C ${laptop} add -A && git -C ${laptop} ${ID} commit -q -m "owner work" && git -C ${laptop} push -q origin main`);
|
|
72
|
+
}
|
|
73
|
+
return { root, origin, box, laptop, scratch, done: () => { try { rmSync(root, { recursive: true, force: true }); } catch { /* best effort */ } } };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const inst = { id: 9, slug: 'acme-site', hosting_shape: 'cloud-host', status: 'active', owner_builder_id: 5, target_ref: 'acme/site' };
|
|
77
|
+
function run(w) {
|
|
78
|
+
const ran = [], logs = [];
|
|
79
|
+
const deps = {
|
|
80
|
+
controlExec: realExec(ran), log: (l) => logs.push(String(l)),
|
|
81
|
+
getGithubToken: async () => null, // no owner token: the box's own remote pushes
|
|
82
|
+
fsImpl: { existsSync: () => false }, // and no write key on this hub
|
|
83
|
+
pinKeyDir: `${w.root}/keys`, tmpDir: w.scratch,
|
|
84
|
+
};
|
|
85
|
+
sh(`mkdir -p ${w.scratch}`);
|
|
86
|
+
return pushUpgradePin(inst, deps, { instanceDir: w.box }).then((out) => ({ out, ran, logs }));
|
|
87
|
+
}
|
|
88
|
+
const at = (dir, ref) => sh(`git -C ${dir} rev-parse ${ref}`);
|
|
89
|
+
const show = (dir, ref, file) => sh(`git -C ${dir} show ${ref}:${file}`);
|
|
90
|
+
|
|
91
|
+
console.log('origin AHEAD of the box: the pin commit is replayed on top and pushed');
|
|
92
|
+
|
|
93
|
+
await t('the owner\'s commits stay, the pin lands on top of them, and nothing is forced', async () => {
|
|
94
|
+
const w = world({ ownerChanges: (d) => writeFileSync(`${d}/index.html`, '<p>v2 from the laptop</p>\n') });
|
|
95
|
+
try {
|
|
96
|
+
const ownerTip = at(w.origin, 'main');
|
|
97
|
+
const boxHead = at(w.box, 'HEAD');
|
|
98
|
+
const { out, ran } = await run(w);
|
|
99
|
+
assert.equal(out.pushed, true, `pushed — got ${JSON.stringify(out)}`);
|
|
100
|
+
assert.equal(out.replayed, true, 'by replaying the pin, not by the first push');
|
|
101
|
+
assert.equal(out.via, 'origin', 'over the same credential that was refused as behind');
|
|
102
|
+
const tip = at(w.origin, 'main');
|
|
103
|
+
assert.equal(at(w.origin, 'main^'), ownerTip, 'the replayed pin sits directly on the owner\'s newest commit');
|
|
104
|
+
assert.match(show(w.origin, 'main', 'package.json'), /"@bongos\/core": "1\.1\.0"/, 'GitHub now names the new core');
|
|
105
|
+
assert.match(show(w.origin, 'main', 'package-lock.json'), /"version": "1\.1\.0"/);
|
|
106
|
+
assert.match(show(w.origin, 'main', 'index.html'), /v2 from the laptop/, 'the owner\'s work is still there');
|
|
107
|
+
assert.equal(sh(`git -C ${w.origin} log -1 --format=%s main`), 'pin @bongos/core 1.1.0 (was 1.0.0)', 'the pin commit\'s own message');
|
|
108
|
+
assert.notEqual(tip, boxHead, 'a new commit, not the box\'s');
|
|
109
|
+
assert.equal(at(w.box, 'HEAD'), boxHead, 'the live box\'s checkout was NOT moved');
|
|
110
|
+
assert.match(readFileSync(`${w.box}/index.html`, 'utf8'), /v1/, 'nor its files changed under the running site');
|
|
111
|
+
assert.equal(sh(`git -C ${w.box} worktree list`).split('\n').length, 1, 'the scratch copy was cleaned up');
|
|
112
|
+
assert.ok(!ran.some((c) => /--force|\s\+\S|reset --hard|rebase/.test(c)), `never forced: ${ran.join('\n')}`);
|
|
113
|
+
} finally { w.done(); }
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await t('a conflicting LOCKFILE is reported in plain words, and GitHub is left exactly as it was', async () => {
|
|
117
|
+
const w = world({ ownerChanges: (d) => writeFileSync(`${d}/package-lock.json`, lock('1.0.1', 'the owner reinstalled')) });
|
|
118
|
+
try {
|
|
119
|
+
const ownerTip = at(w.origin, 'main');
|
|
120
|
+
const boxHead = at(w.box, 'HEAD');
|
|
121
|
+
const { out, ran } = await run(w);
|
|
122
|
+
assert.equal(out.pushed, false);
|
|
123
|
+
assert.equal(out.code, 'conflict');
|
|
124
|
+
assert.match(out.note, /^repo_not_updated:conflict: /, 'the note the web tier reads');
|
|
125
|
+
assert.match(out.note, /package-lock\.json/, 'it names the file');
|
|
126
|
+
assert.equal(at(w.origin, 'main'), ownerTip, 'nothing was pushed over the owner\'s commit');
|
|
127
|
+
assert.equal(at(w.box, 'HEAD'), boxHead, 'the box is untouched');
|
|
128
|
+
assert.equal(sh(`git -C ${w.box} status --porcelain`), '', 'and its tree is clean');
|
|
129
|
+
assert.equal(sh(`git -C ${w.box} worktree list`).split('\n').length, 1, 'the scratch copy was cleaned up');
|
|
130
|
+
assert.ok(!ran.some((c) => /--force|\s\+\S|reset --hard/.test(c)), 'never forced');
|
|
131
|
+
} finally { w.done(); }
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await t('origin UP TO DATE: the ordinary push, no fetch and no replay', async () => {
|
|
135
|
+
const w = world();
|
|
136
|
+
try {
|
|
137
|
+
const boxHead = at(w.box, 'HEAD');
|
|
138
|
+
const { out, ran } = await run(w);
|
|
139
|
+
assert.equal(out.pushed, true);
|
|
140
|
+
assert.ok(!out.replayed, 'nothing to replay');
|
|
141
|
+
assert.equal(at(w.origin, 'main'), boxHead, 'GitHub fast-forwarded to the box\'s own pin commit');
|
|
142
|
+
assert.ok(!ran.some((c) => / fetch | worktree | cherry-pick /.test(c)), `no replay machinery ran: ${ran.join('\n')}`);
|
|
143
|
+
} finally { w.done(); }
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
console.log(`\n${passed} passed, ${failed} failed`);
|
|
147
|
+
if (failed) process.exit(1);
|
package/tests/pin_write_key.mjs
CHANGED
|
@@ -262,6 +262,32 @@ await t('GitHub AHEAD of the box (rejected non-fast-forward) on the write key: r
|
|
|
262
262
|
assert.ok(!call(r, /x-access-token@/) && !call(r, / push origin /));
|
|
263
263
|
});
|
|
264
264
|
|
|
265
|
+
await t('GitHub AHEAD on the write key: the pin is replayed and pushed over THAT key, never forced (task 1004733)', async () => {
|
|
266
|
+
const SHA = 'a'.repeat(40), UP = 'b'.repeat(40), NEXT = 'c'.repeat(40);
|
|
267
|
+
const r = pushRig();
|
|
268
|
+
let keyPushes = 0;
|
|
269
|
+
const inner = r.deps.exec;
|
|
270
|
+
r.deps.exec = (cmd, opts) => {
|
|
271
|
+
if (/core\.sshCommand=.* push /.test(cmd) && keyPushes++ === 0) { r.ran.push({ cmd, env: opts && opts.env }); return refused(' ! [rejected] HEAD -> main (fetch first)'); }
|
|
272
|
+
if (/log -1 --format=/.test(cmd)) { r.ran.push({ cmd }); return { ok: true, stdout: `${SHA}\n${'d'.repeat(40)}\nbox\nbox@x.invalid\npin @bongos/core 1.2.0 (was 1.1.0)\n` }; }
|
|
273
|
+
if (/rev-parse FETCH_HEAD/.test(cmd)) { r.ran.push({ cmd }); return { ok: true, stdout: `${UP}\n` }; }
|
|
274
|
+
if (/diff --quiet /.test(cmd)) { r.ran.push({ cmd }); return refused(''); }
|
|
275
|
+
if (/rev-parse HEAD/.test(cmd)) { r.ran.push({ cmd }); return { ok: true, stdout: `${NEXT}\n` }; }
|
|
276
|
+
return inner(cmd, opts);
|
|
277
|
+
};
|
|
278
|
+
const out = await push(r);
|
|
279
|
+
assert.equal(out.pushed, true);
|
|
280
|
+
assert.equal(out.via, 'write-key');
|
|
281
|
+
assert.equal(out.replayed, true);
|
|
282
|
+
const fetch = call(r, / fetch /);
|
|
283
|
+
assert.ok(fetch && fetch.cmd.includes('core.sshCommand=') && fetch.cmd.includes('git@github.com:acme/site.git main'), `fetched over the key: ${fetch && fetch.cmd}`);
|
|
284
|
+
const again = r.ran.filter((c) => /core\.sshCommand=.* push /.test(c.cmd)).pop();
|
|
285
|
+
assert.ok(again.cmd.includes(`push git@github.com:acme/site.git ${NEXT}:refs/heads/main`), `the replayed commit, over the key: ${again.cmd}`);
|
|
286
|
+
assert.ok(call(r, new RegExp(`cherry-pick ${SHA}`)), 'only the pin commit is replayed');
|
|
287
|
+
assert.ok(!r.ran.some((c) => /--force|\s\+\S|reset --hard|rebase/.test(c.cmd)), 'never forced');
|
|
288
|
+
assert.ok(!call(r, /x-access-token@/) && !call(r, / push origin /), 'no other credential');
|
|
289
|
+
});
|
|
290
|
+
|
|
265
291
|
await t('NO KEY yet but a FRESH TOKEN: the key is minted first (reconnecting is enough), then used', async () => {
|
|
266
292
|
const r = pushRig({ hasKey: false });
|
|
267
293
|
const out = await push(r);
|