@bongos/core 1.19.1032 → 1.19.1034
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 +322 -117
- package/.claude/skills/builder-start/SKILL.md +1 -1
- package/.claude/skills/grade-audit/SKILL.md +1 -1
- package/.claude/skills/grade-recover/SKILL.md +2 -1
- package/.claude/skills/grader-health/SKILL.md +5 -1
- package/.claude/skills/merge-mode/SKILL.md +1 -1
- package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
- package/docs/adr/0175-the-board-room-full-idea-ratification.md +3 -2
- package/docs/adr/0178-the-copy-desk-flag-and-queue.md +3 -3
- package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +2 -2
- package/docs/adr/0189-baseline-floors-and-the-freeze-detector.md +1 -1
- package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +1 -1
- package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +11 -3
- package/docs/adr/README.md +4 -4
- package/docs/api/openapi.json +1 -1
- package/docs/architecture.md +4 -3
- package/docs/canonical-permissions.md +2 -2
- package/docs/copy-inventory.md +34 -30
- package/docs/copy-registry.json +69 -33
- package/docs/file-map.md +4 -3
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/autobongos-windows-host.md +3 -1
- package/docs/recipes/local-dev.md +45 -3
- package/docs/recipes/packaging-the-core.md +3 -1
- package/docs/recipes/redteam-patrol.md +2 -2
- package/docs/recipes/upgrading-the-core.md +1 -1
- package/modules/autonomy/cadence.js +52 -0
- package/modules/autonomy/gauge.js +57 -9
- package/modules/government/protected-surfaces.json +1 -0
- package/modules/grading/grade-engine.js +267 -0
- package/modules/grading/grader-subagent.js +4 -3
- package/modules/grading/grader-workers/index.js +67 -11
- package/modules/grading/grader-workers/worker-verdict.js +32 -2
- package/modules/grading/grader.js +109 -88
- package/modules/hall-ui/public/deploy.js +35 -0
- package/modules/hall-ui/public/project-settings.css +7 -0
- package/modules/hall-ui/records/builders-roster.md +10 -0
- package/modules/hall-ui/records/catalog.md +10 -0
- package/modules/hall-ui/records/collaboration.md +14 -0
- package/modules/hall-ui/records/goal-dependency-forest.md +11 -0
- package/modules/hall-ui/records/oversight-family.md +19 -0
- package/modules/hall-ui/records/panel-family.md +19 -0
- package/modules/hall-ui/records/reading-rooms.md +12 -0
- package/modules/hall-ui/records/record-family.md +13 -0
- package/modules/hall-ui/records/reference-room.md +9 -0
- package/modules/hall-ui/records/roster-and-ledger.md +9 -0
- package/modules/hall-ui/records/sky.md +23 -0
- package/modules/lifecycle/dependency-advisory.js +2 -1
- package/modules/lifecycle/routes/tasks.js +19 -4
- package/modules/lifecycle/ship-preflight.js +91 -0
- package/modules/provisioning/control-plane-guard.js +14 -13
- package/modules/provisioning/core-upgrade.js +30 -17
- package/modules/provisioning/public-refusal.js +44 -0
- package/modules/provisioning/routes/disconnect.js +2 -1
- package/modules/provisioning/routes/env-manifest.js +5 -4
- package/modules/provisioning/routes/provisioning.js +16 -15
- package/modules/provisioning/routes/repo-private.js +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +7481 -0
- package/scripts/gds/address-park-sweep.sh +3 -1
- package/scripts/gds/autobongos-run.js +91 -3
- package/scripts/gds/backfill-cost.js +20 -9
- package/scripts/gds/baseline-staleness.js +1 -1
- package/scripts/gds/claim.js +2 -2
- package/scripts/gds/credential-presence.js +281 -0
- package/scripts/gds/db-backup-nightly.sh +9 -4
- package/scripts/gds/db-backup-offsite.sh +4 -2
- package/scripts/gds/deploy-main-audit-gate.sh +11 -4
- package/scripts/gds/dev-lib.js +212 -5
- package/scripts/gds/dev.js +102 -50
- package/scripts/gds/doctor.js +3 -2
- package/scripts/gds/grade-correlation-audit.js +280 -0
- package/scripts/gds/grade-replay.js +237 -0
- package/scripts/gds/hooks/pre-push.js +7 -3
- package/scripts/gds/main-audit.js +19 -10
- package/scripts/gds/memory-map.js +3 -1
- package/scripts/gds/onboarding-config.js +3 -2
- package/scripts/gds/package-core.js +72 -1
- package/scripts/gds/plain-cards.js +4 -0
- package/scripts/gds/preflight.js +12 -4
- package/scripts/gds/provision-core-upgrade.js +282 -80
- package/scripts/gds/provision-repo-auth.js +15 -0
- package/scripts/gds/provision.js +1 -1
- package/scripts/gds/publish-credential-check.js +3 -0
- package/scripts/gds/push-main.js +14 -3
- package/scripts/gds/push-path-brief.js +3 -2
- package/scripts/gds/redteam-patrol.js +4 -4
- package/scripts/gds/release-notes.js +271 -0
- package/scripts/gds/run-routine.js +18 -5
- package/scripts/gds/run-unit-tests.js +14 -3
- package/scripts/gds/seed-board-room-tasks.js +1 -1
- package/scripts/gds/session-cost.js +12 -3
- package/scripts/gds/ship-deploy-target.js +11 -6
- package/scripts/gds/ship-flow.js +5 -3
- package/scripts/gds/ship-grade-steps.js +33 -0
- package/scripts/gds/ship-land.js +241 -16
- package/scripts/gds/ship-merge.js +3 -2
- package/scripts/gds/ship-preflight-steps.js +86 -11
- package/scripts/gds/ship.js +1 -1
- package/scripts/gds/stray-unit-sweep.sh +3 -1
- package/scripts/gds/upgrade-migrate.js +67 -0
- package/scripts/gds/upgrade.js +9 -7
- package/scripts/hall-preview/README.md +20 -3
- package/scripts/hall-preview/live-identity.js +1 -1
- package/scripts/hall-preview/refresh-fixtures.js +71 -11
- package/scripts/hall-preview/scrub-identity.js +296 -0
- package/src/bongos/routes/security.js +3 -3
- package/src/build-info.js +4 -2
- package/src/module-api.js +1 -1
- package/tests/adr_0089_scope.mjs +70 -0
- package/tests/adr_0162_citation.mjs +184 -0
- package/tests/autobongos_cadence.mjs +74 -0
- package/tests/autobongos_loop.mjs +114 -0
- package/tests/autonomy_gauge.mjs +56 -0
- package/tests/consumer_layout_boot.mjs +363 -31
- package/tests/core_upgrade_door.mjs +40 -6
- package/tests/core_upgrade_runner.mjs +329 -5
- package/tests/credential_presence.mjs +153 -0
- package/tests/db_backup_nightly.mjs +21 -3
- package/tests/db_backup_offsite.mjs +81 -0
- package/tests/deploy_audit_gate.mjs +5 -2
- package/tests/deploy_page_projects.mjs +63 -2
- package/tests/dev_lib.mjs +2 -1
- package/tests/dev_windows.mjs +324 -0
- package/tests/env_knobs_neutral_prefix.mjs +203 -0
- package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
- package/tests/government_protected_surfaces.mjs +10 -0
- package/tests/grade_correlation_audit.mjs +159 -0
- package/tests/grade_engine.mjs +249 -0
- package/tests/grade_engine_replay.mjs +99 -0
- package/tests/grader_prompt_window_bound.mjs +132 -0
- package/tests/grader_unbacked_score.mjs +194 -0
- package/tests/hall_preview_scrub.mjs +214 -0
- package/tests/hall_records.mjs +85 -0
- package/tests/helpers.mjs +25 -0
- package/tests/instance_db_migrated_on_code_move.mjs +8 -1
- package/tests/integration_parked_ratchet.mjs +24 -1
- package/tests/plain_cards.mjs +1 -1
- package/tests/provisioning_public_refusal.mjs +195 -0
- package/tests/publish_status_branch.mjs +128 -0
- package/tests/redteam_patrol.mjs +1 -1
- package/tests/release_notes.mjs +327 -0
- package/tests/shell_stat_portability.mjs +49 -0
- package/tests/ship_land_conflict.mjs +181 -0
- package/tests/ship_land_red_pr.mjs +236 -0
- package/tests/ship_preflight.mjs +226 -3
- package/tests/start_env_guard.mjs +16 -0
- package/tests/sweep_app_account.mjs +15 -2
- package/tests/upgrade.mjs +20 -1
- package/tests/upgrade_migrate.mjs +195 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// tests/release_notes.mjs — the release summaries shipped INSIDE the core package
|
|
2
|
+
// (task 1004218): generated from history at pack time, redacted like a doc, pinned like
|
|
3
|
+
// every other file, and read back by the upgrade runner for a from→to range.
|
|
4
|
+
//
|
|
5
|
+
// WHAT IS ACTUALLY AT RISK, in the order it would hurt:
|
|
6
|
+
//
|
|
7
|
+
// 1. THE WRONG RELEASE GETS THE CREDIT. The obvious generator — split the log wherever a
|
|
8
|
+
// `release: core X.Y.Z` subject appears — credits a merge made on a branch before one
|
|
9
|
+
// release, but landed after it, to the EARLIER release. The history below contains
|
|
10
|
+
// exactly that shape, backdated, and every release is checked against the checkout
|
|
11
|
+
// reader's own `git log --merges <prev>..<release>` answer.
|
|
12
|
+
// 2. A LEAK THAT WEDGES THE LANE. These are builder-written sentences, and the real
|
|
13
|
+
// history holds ones that name a prior instance. Shipped verbatim, the fail-closed leak
|
|
14
|
+
// scan would refuse every pack from then on — the history cannot be edited.
|
|
15
|
+
// 3. A RANGE READ AS "NOTHING CHANGED". A range the notes do not reach must come back as
|
|
16
|
+
// a reason, never as an empty list, and the boundaries are `from` exclusive, `to`
|
|
17
|
+
// inclusive.
|
|
18
|
+
//
|
|
19
|
+
// Run: node tests/release_notes.mjs
|
|
20
|
+
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import { strict as assert } from 'node:assert';
|
|
23
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import os from 'node:os';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import {
|
|
28
|
+
RELEASE_NOTES_FILE, parseChangeRecords, parseLogRecords, buildReleaseNotes, generateReleaseNotes,
|
|
29
|
+
serializeReleaseNotes, boundReleaseNotes, notesBetween,
|
|
30
|
+
} from '../scripts/gds/release-notes.js';
|
|
31
|
+
import {
|
|
32
|
+
releaseNotesArtifact, scanArtifactForLeaks, PRIOR_INSTANCE_FLOOR, buildTar, TAR_ROOT,
|
|
33
|
+
sha256hex, canonicalTreeHash, buildCoreManifest, withTarballInfo, reproducePinFromTree,
|
|
34
|
+
} from '../scripts/gds/package-core.js';
|
|
35
|
+
import {
|
|
36
|
+
readPackagedNotes, readInstalledNotes, parseChangeRecords as runnerParser,
|
|
37
|
+
} from '../scripts/gds/provision-core-upgrade.js';
|
|
38
|
+
import { buildRedactionRules, collectSensitiveLiterals } from '../scripts/gds/mirror-redact.js';
|
|
39
|
+
|
|
40
|
+
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-release-notes-test-'));
|
|
41
|
+
process.on('exit', () => { try { fs.rmSync(TMP, { recursive: true, force: true }); } catch { /* best-effort */ } });
|
|
42
|
+
|
|
43
|
+
const CARRIER = (v) => `release: core ${v} — CI auto-patch carrier (publish-on-merge, ADR 0161)`;
|
|
44
|
+
|
|
45
|
+
// ---- a real core-shaped history ---------------------------------------------------
|
|
46
|
+
//
|
|
47
|
+
// main: C0 ─ M1 ─ R(1.0.1) ─────────────── M2 ─ R(1.0.2) ─ M3
|
|
48
|
+
// │ │ │ │
|
|
49
|
+
// │ task-1 feat ──┘ hand-3 ┘
|
|
50
|
+
// └─ F1 ─ N(nested merge, backdated to 2020) ─ S(merges main at 1.0.1)
|
|
51
|
+
//
|
|
52
|
+
// N was made on the branch long before 1.0.1 and landed with M2, after it. Its date puts
|
|
53
|
+
// it at the very end of a date-ordered log, so a subject-split generator hands it to 1.0.1.
|
|
54
|
+
function makeHistory() {
|
|
55
|
+
const dir = fs.mkdtempSync(path.join(TMP, 'repo-'));
|
|
56
|
+
const git = (args, env = {}) => execFileSync('git', args, {
|
|
57
|
+
cwd: dir, encoding: 'utf8', env: { ...process.env, ...env },
|
|
58
|
+
});
|
|
59
|
+
let n = 0;
|
|
60
|
+
const commit = (msg, env) => {
|
|
61
|
+
fs.writeFileSync(path.join(dir, `c${n++}.txt`), `${msg}\n`); // a file each, so no branch conflicts
|
|
62
|
+
git(['add', '-A'], env);
|
|
63
|
+
git(['-c', 'commit.gpgsign=false', 'commit', '-q', '--no-verify', '-m', msg], env);
|
|
64
|
+
};
|
|
65
|
+
const merge = (branch, subject, body, env) => git(['-c', 'commit.gpgsign=false', 'merge', '-q', '--no-ff', '--no-verify',
|
|
66
|
+
'-m', subject, ...(body ? ['-m', body] : []), branch], env);
|
|
67
|
+
const old = (d) => ({ GIT_AUTHOR_DATE: d, GIT_COMMITTER_DATE: d });
|
|
68
|
+
|
|
69
|
+
git(['init', '-q', '-b', 'main']);
|
|
70
|
+
git(['config', 'user.email', 'test@example.com']);
|
|
71
|
+
git(['config', 'user.name', 'Test']);
|
|
72
|
+
commit('initial', old('2020-01-01T00:00:00Z'));
|
|
73
|
+
git(['checkout', '-q', '-b', 'feat']);
|
|
74
|
+
commit('feature work begins', old('2020-01-02T00:00:00Z'));
|
|
75
|
+
git(['checkout', '-q', '-b', 'sub-7']);
|
|
76
|
+
commit('a sub-branch', old('2020-01-03T00:00:00Z'));
|
|
77
|
+
git(['checkout', '-q', 'feat']);
|
|
78
|
+
merge('sub-7', 'Merge sub-7: The nested summary, merged on its branch long ago.', null, old('2020-01-04T00:00:00Z'));
|
|
79
|
+
git(['checkout', '-q', 'main']);
|
|
80
|
+
git(['checkout', '-q', '-b', 'task-1']);
|
|
81
|
+
commit('work for task 1');
|
|
82
|
+
git(['checkout', '-q', 'main']);
|
|
83
|
+
merge('task-1', 'Merge pull request #1 from an-owner/task-1', 'task 1001: The first summary.');
|
|
84
|
+
commit(CARRIER('1.0.1'));
|
|
85
|
+
git(['checkout', '-q', 'feat']);
|
|
86
|
+
merge('main', 'Merge origin/main into feat (pre-stage freshness, task 1068)');
|
|
87
|
+
git(['checkout', '-q', 'main']);
|
|
88
|
+
merge('feat', 'Merge pull request #2 from an-owner/task-2', 'task 1002: The second summary.');
|
|
89
|
+
commit(CARRIER('1.0.2'));
|
|
90
|
+
git(['checkout', '-q', '-b', 'hand-3']);
|
|
91
|
+
commit('work for the next release');
|
|
92
|
+
git(['checkout', '-q', 'main']);
|
|
93
|
+
merge('hand-3', 'Merge hand-3: An unreleased summary.');
|
|
94
|
+
const shaOf = (v) => git(['log', '--format=%H', '-1', '-F', `--grep=release: core ${v} `]).trim();
|
|
95
|
+
return { dir, git: (args) => git(args), shaOf };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const H = makeHistory();
|
|
99
|
+
const repoGit = H.git;
|
|
100
|
+
const texts = (list) => list.map((e) => e.text);
|
|
101
|
+
|
|
102
|
+
test('each release is credited with EXACTLY what landed since the previous one — even a backdated branch merge', () => {
|
|
103
|
+
const r = generateReleaseNotes({ git: repoGit, ref: 'HEAD', version: '1.0.3' });
|
|
104
|
+
assert.equal(r.skipped, undefined);
|
|
105
|
+
assert.deepEqual(texts(r.notes['1.0.1']), ['The first summary.']);
|
|
106
|
+
assert.equal(r.notes['1.0.1'][0].id, '1001', 'the CI shape keeps its task id');
|
|
107
|
+
assert.deepEqual(texts(r.notes['1.0.2']), ['The second summary.', 'The nested summary, merged on its branch long ago.'],
|
|
108
|
+
'the nested merge LANDED with 1.0.2 — its 2020 date must not hand it to 1.0.1');
|
|
109
|
+
assert.ok(!texts(r.notes['1.0.2']).some((t) => /pre-stage freshness/.test(t)), 'the mechanics of landing stay dropped');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test('the answer is the checkout reader’s own range — git log --merges <prev>..<release>', () => {
|
|
113
|
+
// The fallback path asks git for this one range at a time; the generator must agree
|
|
114
|
+
// with it for every release, or the page would say different things on different boxes.
|
|
115
|
+
const { notes } = generateReleaseNotes({ git: repoGit, ref: 'HEAD', version: '1.0.3' });
|
|
116
|
+
const log = repoGit(['log', '--merges', '--format=%s%x1f%b%x1e', `${H.shaOf('1.0.1')}..${H.shaOf('1.0.2')}`]);
|
|
117
|
+
assert.deepEqual(texts(notes['1.0.2']).sort(), texts(parseChangeRecords(log)).sort());
|
|
118
|
+
assert.equal(runnerParser, parseChangeRecords, 'one parser, not two: the runner re-exports this one');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('the unreleased tail belongs to the packed version only when that version has no release yet', () => {
|
|
122
|
+
const declared = generateReleaseNotes({ git: repoGit, ref: 'HEAD', version: '1.0.3' });
|
|
123
|
+
assert.deepEqual(texts(declared.notes['1.0.3']), ['An unreleased summary.'], 'a hand-declared version carries its own tail');
|
|
124
|
+
const released = generateReleaseNotes({ git: repoGit, ref: 'HEAD', version: '1.0.2' });
|
|
125
|
+
assert.deepEqual(Object.keys(released.notes), ['1.0.1', '1.0.2'], 'on a work merge the tail is the NEXT release’s, and is not guessed at');
|
|
126
|
+
const behind = generateReleaseNotes({ git: repoGit, ref: 'HEAD', version: '0.9.0' });
|
|
127
|
+
assert.deepEqual(Object.keys(behind.notes), ['1.0.1', '1.0.2'], 'a version older than the newest release never takes the tail');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('a shallow clone produces NO notes, and says why — never a partial file that looks whole', () => {
|
|
131
|
+
const shallow = path.join(TMP, 'shallow');
|
|
132
|
+
execFileSync('git', ['clone', '-q', '--depth', '1', `file://${H.dir}`, shallow]);
|
|
133
|
+
const r = generateReleaseNotes({
|
|
134
|
+
git: (args) => execFileSync('git', ['-C', shallow, ...args], { encoding: 'utf8' }), ref: 'HEAD', version: '1.0.3',
|
|
135
|
+
});
|
|
136
|
+
assert.equal(r.notes, undefined);
|
|
137
|
+
assert.match(r.skipped, /shallow clone/);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('a version released twice keeps both halves, and a repeated sentence once', () => {
|
|
141
|
+
const rec = (sha, parents, subject, body = '') => `${sha}\x1f${parents}\x1f${subject}\x1f${body}\x1e\n`;
|
|
142
|
+
const log = [
|
|
143
|
+
rec('e', 'd', CARRIER('2.0.0')),
|
|
144
|
+
rec('d', 'c x', 'Merge b-2: Second half.'),
|
|
145
|
+
rec('c', 'b', CARRIER('2.0.0')),
|
|
146
|
+
rec('b', 'a y', 'Merge b-1: First half.'),
|
|
147
|
+
rec('a', '', 'initial'),
|
|
148
|
+
].join('');
|
|
149
|
+
const { notes } = buildReleaseNotes(parseLogRecords(log));
|
|
150
|
+
assert.deepEqual(texts(notes['2.0.0']), ['First half.', 'Second half.']);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---- the range ---------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
const NOTES = {
|
|
156
|
+
'1.19.900': [{ text: 'already live' }],
|
|
157
|
+
'1.19.901': [{ id: '1', text: 'first in range' }],
|
|
158
|
+
'1.19.920': [],
|
|
159
|
+
'1.19.949': [{ id: '3', text: 'the target itself' }, { text: 'first in range' }],
|
|
160
|
+
'1.19.950': [{ text: 'after the target' }],
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
test('(from, to]: from EXCLUSIVE, to INCLUSIVE, newest release first, a sentence said once', () => {
|
|
164
|
+
assert.deepEqual(texts(notesBetween(NOTES, '1.19.900', '1.19.949').entries), ['the target itself', 'first in range']);
|
|
165
|
+
assert.deepEqual(texts(notesBetween(NOTES, '1.19.901', '1.19.949').entries), ['the target itself', 'first in range'],
|
|
166
|
+
'starting AT 1.19.901 excludes its own entry — the one left is the target’s repeat of it');
|
|
167
|
+
assert.deepEqual(texts(notesBetween(NOTES, '1.19.949', '1.19.950').entries), ['after the target']);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('a release of pure housekeeping is COVERED and empty — distinct from not covered', () => {
|
|
171
|
+
assert.deepEqual(notesBetween(NOTES, '1.19.901', '1.19.920'), { entries: [] });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('a range the notes do not reach is refused with a reason, never an empty list', () => {
|
|
175
|
+
assert.match(notesBetween(NOTES, '1.19.900', '1.19.999').uncovered, /do not list core 1\.19\.999/);
|
|
176
|
+
assert.match(notesBetween(NOTES, '1.19.850', '1.19.949').uncovered, /begin at core 1\.19\.900, after 1\.19\.850/);
|
|
177
|
+
assert.match(notesBetween(null, '1.19.900', '1.19.949').uncovered, /not a list of releases/);
|
|
178
|
+
assert.match(notesBetween([], '1.19.900', '1.19.949').uncovered, /not a list of releases/);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test('a file off the network is re-checked, not trusted', () => {
|
|
182
|
+
const messy = {
|
|
183
|
+
'1.0.1': [],
|
|
184
|
+
'1.0.2': [null, { text: '' }, { text: 42 }, { id: 'x; rm -rf', text: 'kept, bad id dropped' }, { id: 12, text: 'y'.repeat(900) }],
|
|
185
|
+
'not-a-version': [{ text: 'ignored' }],
|
|
186
|
+
'1.0.3': 'not an array',
|
|
187
|
+
};
|
|
188
|
+
const { entries } = notesBetween(messy, '1.0.1', '1.0.2');
|
|
189
|
+
assert.deepEqual(entries[0], { text: 'kept, bad id dropped' });
|
|
190
|
+
assert.equal(entries[1].id, '12');
|
|
191
|
+
assert.ok(entries[1].text.length <= 400, 'bounded — it reaches a DOM');
|
|
192
|
+
assert.equal(entries.length, 2);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ---- the bound -----------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
test('past the bound the OLDEST releases go, the fewest that fit, and the newest always stays', () => {
|
|
198
|
+
const big = {};
|
|
199
|
+
for (let i = 1; i <= 60; i++) big[`1.0.${i}`] = [{ text: `release ${i} ${'x'.repeat(200)}` }];
|
|
200
|
+
const full = serializeReleaseNotes(big).length;
|
|
201
|
+
const cap = Math.floor(full / 2);
|
|
202
|
+
const b = boundReleaseNotes(big, cap);
|
|
203
|
+
assert.ok(serializeReleaseNotes(b.notes).length <= cap);
|
|
204
|
+
assert.equal(b.dropped[0], '1.0.1', 'oldest first');
|
|
205
|
+
assert.ok('1.0.60' in b.notes);
|
|
206
|
+
const oneMore = [b.dropped[b.dropped.length - 1], ...Object.keys(b.notes)];
|
|
207
|
+
assert.ok(serializeReleaseNotes(Object.fromEntries(oneMore.map((v) => [v, big[v]]))).length > cap, 'no more dropped than needed');
|
|
208
|
+
assert.deepEqual(boundReleaseNotes(big, full).dropped, [], 'under the bound nothing moves');
|
|
209
|
+
// …and the reader then refuses what the trimmed file no longer holds.
|
|
210
|
+
assert.match(notesBetween(b.notes, '1.0.1', '1.0.60').uncovered, /begin at core/);
|
|
211
|
+
assert.ok(notesBetween(b.notes, Object.keys(b.notes)[0], '1.0.60').entries);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ---- the neutral floor ---------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
const floorExtra = { owners: [...PRIOR_INSTANCE_FLOOR.owners], tokens: [...PRIOR_INSTANCE_FLOOR.tokens], literals: [] };
|
|
217
|
+
const rules = buildRedactionRules({}, floorExtra);
|
|
218
|
+
const sensitiveLiterals = collectSensitiveLiterals({}, floorExtra);
|
|
219
|
+
const OWNER = PRIOR_INSTANCE_FLOOR.owners[0]; // never typed here: this file ships too
|
|
220
|
+
|
|
221
|
+
test('a summary naming a prior instance’s owner is REDACTED like a doc, and the scan then passes', () => {
|
|
222
|
+
const notes = { '1.0.1': [], '1.0.2': [{ id: '7', text: `Asking ${OWNER} for help now reaches them.` }] };
|
|
223
|
+
const art = releaseNotesArtifact(notes, { rules, sensitiveLiterals, envSecrets: [], knownDocNames: new Set() });
|
|
224
|
+
assert.deepEqual(art.leaks, []);
|
|
225
|
+
assert.equal(art.redacted, 1);
|
|
226
|
+
assert.ok(!art.buf.toString('utf8').toLowerCase().includes(OWNER.toLowerCase()));
|
|
227
|
+
assert.match(art.notes['1.0.2'][0].text, /^Asking example-owner for help/);
|
|
228
|
+
assert.deepEqual(scanArtifactForLeaks([{ path: RELEASE_NOTES_FILE, content: art.buf }]).literalHits, []);
|
|
229
|
+
// The post-build scan reads the generated file like any other: unredacted, it is flagged.
|
|
230
|
+
assert.ok(scanArtifactForLeaks([{ path: RELEASE_NOTES_FILE, content: serializeReleaseNotes(notes) }]).literalHits.length > 0);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('residue AFTER redaction is reported as a leak — which main() treats as fatal', () => {
|
|
234
|
+
const notes = { '1.0.2': [{ text: `Asking ${OWNER} for help.` }] };
|
|
235
|
+
const blind = buildRedactionRules({}, {}); // a redactor that does not know the literal
|
|
236
|
+
const art = releaseNotesArtifact(notes, { rules: blind, sensitiveLiterals, envSecrets: [], knownDocNames: new Set() });
|
|
237
|
+
assert.ok(art.leaks.some((l) => /^doc:literal:/.test(l.label)), JSON.stringify(art.leaks));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ---- packed, verified, downloaded, read back ------------------------------------------
|
|
241
|
+
|
|
242
|
+
// The artifact the way main() assembles it: the notes staged beside package.json into ONE
|
|
243
|
+
// file set, from which the pin is computed — then the pin reproduced from the unpacked tgz,
|
|
244
|
+
// which is what --self-verify does at publish time.
|
|
245
|
+
function pack({ withNotes = true } = {}) {
|
|
246
|
+
const gen = generateReleaseNotes({ git: repoGit, ref: 'HEAD', version: '1.0.3' });
|
|
247
|
+
const art = releaseNotesArtifact(gen.notes, { rules, sensitiveLiterals, envSecrets: [], knownDocNames: new Set() });
|
|
248
|
+
const files = [{ path: 'package.json', buf: Buffer.from('{"name":"@bongos/core","version":"1.0.3"}\n') }];
|
|
249
|
+
if (withNotes) files.push({ path: RELEASE_NOTES_FILE, buf: art.buf });
|
|
250
|
+
const filesMeta = files.map((f) => ({ path: f.path, mode: '100644', sha256: sha256hex(f.buf) }));
|
|
251
|
+
const tgz = buildTar(files.map((f) => ({ name: TAR_ROOT + f.path, mode: 0o644, buf: f.buf })));
|
|
252
|
+
const out = fs.mkdtempSync(path.join(TMP, 'dist-'));
|
|
253
|
+
const tgzPath = path.join(out, 'bongos-core-1.0.3.tgz');
|
|
254
|
+
fs.writeFileSync(tgzPath, tgz);
|
|
255
|
+
const sidecar = withTarballInfo(buildCoreManifest({
|
|
256
|
+
version: '1.0.3', coreContract: '1.0.3', sourceCommit: null, sourceRef: 'HEAD', builtAtIso: new Date(0).toISOString(),
|
|
257
|
+
files: filesMeta, treeHash: canonicalTreeHash(filesMeta),
|
|
258
|
+
}), { name: 'bongos-core-1.0.3.tgz', sha256: sha256hex(tgz), bytes: tgz.length });
|
|
259
|
+
return { art, tgzPath, sidecar };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// `npm pack` stood in for (no registry in a unit test); `tar` is the real one.
|
|
263
|
+
function registry(tgzPath, asked = []) {
|
|
264
|
+
return (cmd, args, opts) => {
|
|
265
|
+
if (cmd !== 'npm') return spawnSync(cmd, args, opts);
|
|
266
|
+
asked.push(args[1]);
|
|
267
|
+
fs.copyFileSync(tgzPath, path.join(args[args.indexOf('--pack-destination') + 1], path.basename(tgzPath)));
|
|
268
|
+
return { status: 0, stdout: `${path.basename(tgzPath)}\n`, stderr: '' };
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
test('PACKED, SELF-VERIFIED, DOWNLOADED, READ BACK — and the range comes out right', () => {
|
|
273
|
+
const { art, tgzPath, sidecar } = pack();
|
|
274
|
+
// --self-verify: the pin reproduces from the unpacked tarball, notes file included.
|
|
275
|
+
const unpacked = fs.mkdtempSync(path.join(TMP, 'unpacked-'));
|
|
276
|
+
assert.equal(spawnSync('tar', ['-xzf', tgzPath, '-C', unpacked]).status, 0);
|
|
277
|
+
const rep = reproducePinFromTree({ treeRoot: path.join(unpacked, 'package'), sidecar, tgzPath });
|
|
278
|
+
assert.ok(rep.ok, rep.problems.join('; '));
|
|
279
|
+
assert.ok(sidecar.files.some((f) => f.path === RELEASE_NOTES_FILE), 'the notes are IN the pin, not beside it');
|
|
280
|
+
|
|
281
|
+
const asked = [];
|
|
282
|
+
const got = readPackagedNotes('1.0.3', { spawn: registry(tgzPath, asked), tmpDir: TMP });
|
|
283
|
+
assert.deepEqual(asked, ['@bongos/core@1.0.3'], 'the target’s own package is the one fetched');
|
|
284
|
+
assert.deepEqual(got.notes, art.notes, 'what comes out is what went in');
|
|
285
|
+
const r = notesBetween(got.notes, '1.0.1', '1.0.3');
|
|
286
|
+
assert.deepEqual(texts(r.entries), ['An unreleased summary.', 'The second summary.', 'The nested summary, merged on its branch long ago.'],
|
|
287
|
+
'1.0.1’s own summary is excluded; the target’s is included');
|
|
288
|
+
assert.deepEqual(fs.readdirSync(TMP).filter((n) => n.startsWith('bongos-release-notes-')), [], 'the download does not outlive the call');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('a package published before the notes existed says so — the real tar, a real tarball without the file', () => {
|
|
292
|
+
const { tgzPath } = pack({ withNotes: false });
|
|
293
|
+
const got = readPackagedNotes('1.0.3', { spawn: registry(tgzPath), tmpDir: TMP });
|
|
294
|
+
assert.equal(got.notes, undefined);
|
|
295
|
+
assert.match(got.missing, /the core 1\.0\.3 package carries no release notes/);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test('every way the download can fail is named, and cleaned up', () => {
|
|
299
|
+
const fake = (npm, tar) => (cmd, args) => {
|
|
300
|
+
if (cmd === 'npm') {
|
|
301
|
+
if (npm.writes) fs.writeFileSync(path.join(args[args.indexOf('--pack-destination') + 1], 'bongos-core-9.9.9.tgz'), 'x');
|
|
302
|
+
return npm;
|
|
303
|
+
}
|
|
304
|
+
return tar;
|
|
305
|
+
};
|
|
306
|
+
const ok = { status: 0, stdout: '', stderr: '', writes: true };
|
|
307
|
+
const cases = [
|
|
308
|
+
[fake({ status: 1, stdout: '', stderr: 'npm error code E404\nnpm error 404 Not Found - GET https://registry.npmjs.org/x' }, null),
|
|
309
|
+
/could not download the core 9\.9\.9 package to read its release notes \(npm error 404 Not Found/],
|
|
310
|
+
[fake({ status: 0, stdout: '', stderr: '' }, null), /left no tarball/],
|
|
311
|
+
[fake(ok, { status: 2, stdout: '', stderr: 'tar: Error opening archive: Unrecognized archive format' }), /could not read the release notes out of the core 9\.9\.9 package/],
|
|
312
|
+
[fake(ok, { status: 0, stdout: '{not json', stderr: '' }), /not valid JSON/],
|
|
313
|
+
[fake({ error: new Error('spawnSync npm ETIMEDOUT'), status: null }, null), /ETIMEDOUT/],
|
|
314
|
+
];
|
|
315
|
+
for (const [spawn, want] of cases) assert.match(readPackagedNotes('9.9.9', { spawn, tmpDir: TMP }).error, want);
|
|
316
|
+
let spawned = false;
|
|
317
|
+
assert.match(readPackagedNotes("9.9.9'; rm -rf /", { spawn: () => { spawned = true; } }).error, /could not work out/);
|
|
318
|
+
assert.equal(spawned, false, 'a version that is not three numbers reaches no command');
|
|
319
|
+
assert.deepEqual(fs.readdirSync(TMP).filter((n) => n.startsWith('bongos-release-notes-')), []);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('the installed core’s own file: read when present, and a missing or broken one is not an answer', () => {
|
|
323
|
+
const at = (body) => ({ fs: { readFileSync: (p) => { assert.ok(p.endsWith(RELEASE_NOTES_FILE)); if (body === null) throw new Error('ENOENT'); return body; } } });
|
|
324
|
+
assert.deepEqual(readInstalledNotes(at('{"1.0.1":[]}')).notes, { '1.0.1': [] });
|
|
325
|
+
assert.match(readInstalledNotes(at(null)).missing, /carries no release notes/);
|
|
326
|
+
assert.match(readInstalledNotes(at('{bad')).error, /not valid JSON/);
|
|
327
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// tests/shell_stat_portability.mjs — no shell script leans on one platform's `stat`
|
|
2
|
+
// (task 1004170).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG. GNU stat takes `-c FORMAT`; BSD/macOS stat refuses `-c` outright, and its own
|
|
5
|
+
// `-f FORMAT` means "file system status" to GNU. Five scripts called `stat -c` alone, so
|
|
6
|
+
// each died, or lost its answer, on every Mac, and passed on CI's Linux, so nobody
|
|
7
|
+
// reading Actions saw it. Only one of the five had a test that ran it; the suites beside
|
|
8
|
+
// each script now run them under macOS-shaped tools. This is the ratchet that stops a
|
|
9
|
+
// sixth arriving without one.
|
|
10
|
+
//
|
|
11
|
+
// THE RULE, per line of every tracked shell script: a `stat -c` carries its BSD twin after
|
|
12
|
+
// an `||` on the same line, and a `stat -f` never appears without one before it. A byte
|
|
13
|
+
// count needs neither: POSIX `wc -c` answers it everywhere.
|
|
14
|
+
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const GNU = /(^|[^\w-])stat\s+(-c|--format|--printf)[\s=]/;
|
|
24
|
+
const BSD = /(^|[^\w-])stat\s+-f\s/;
|
|
25
|
+
|
|
26
|
+
// offending — the lines of `src` that ask stat in only one platform's dialect.
|
|
27
|
+
function offending(src) {
|
|
28
|
+
return src.split('\n').map((l, i) => ({ n: i + 1, l })).filter(({ l }) => {
|
|
29
|
+
if (/^\s*#/.test(l)) return false;
|
|
30
|
+
const g = l.search(GNU);
|
|
31
|
+
const b = l.search(BSD);
|
|
32
|
+
if (g < 0 && b < 0) return false;
|
|
33
|
+
return !(g >= 0 && b > g && l.slice(g, b).includes('||'));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test('every tracked shell script asks stat in both dialects, or not at all', () => {
|
|
38
|
+
const files = execFileSync('git', ['ls-files', '*.sh'], { cwd: ROOT, encoding: 'utf8' }).split('\n').filter(Boolean);
|
|
39
|
+
assert.ok(files.length > 20, `the scan must see the scripts, got ${files.length}`);
|
|
40
|
+
const hits = files.flatMap((f) => offending(fs.readFileSync(path.join(ROOT, f), 'utf8')).map(({ n, l }) => `${f}:${n}: ${l.trim()}`));
|
|
41
|
+
assert.deepEqual(hits, [], "size: wc -c < f | tr -d '[:space:]'; anything else: stat -c FMT f || stat -f FMT f");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('the rule catches a lone GNU or BSD stat, and passes the paired form and prose', () => {
|
|
45
|
+
assert.equal(offending("BYTES=$(stat -c '%s' \"$f\")").length, 1);
|
|
46
|
+
assert.equal(offending('U=$(stat -f %Su "$d")').length, 1);
|
|
47
|
+
assert.equal(offending('U=$(stat -c %U "$d" 2>/dev/null || stat -f %Su "$d" 2>/dev/null)').length, 0);
|
|
48
|
+
assert.equal(offending(" # was stat -c '%s', which BSD refuses").length, 0, 'a comment is not a call');
|
|
49
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// tests/ship_land_conflict.mjs — a CONFLICTING PR is named while the land waits (task 1003499).
|
|
2
|
+
//
|
|
3
|
+
// WHY. GitHub's auto-merge waits for a clean merge as well as green checks, so a PR
|
|
4
|
+
// whose branch conflicts with main sits open under a fully green rollup and never
|
|
5
|
+
// merges. PR #497 did that for ~25 minutes while the ship's poll printed nothing and
|
|
6
|
+
// then signed off with "auto-merge is armed ... NOT a failure" and a card saying
|
|
7
|
+
// "Nothing to do" — so the completion card read "landing automatically" over a land
|
|
8
|
+
// that could not happen. These tests drive the REAL poll loops (pollCiDeploy, the
|
|
9
|
+
// gh path; pollServerPublish, the server path) with a scripted PR, and pin both
|
|
10
|
+
// halves: the conflict is said once, when it appears, and a wait that ends
|
|
11
|
+
// conflicting is a bail with the fix named, never the pending banner.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/ship_land_conflict.mjs
|
|
14
|
+
|
|
15
|
+
import { strict as assert } from 'node:assert';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
|
|
18
|
+
process.env.BONGOS_SURFACE = 'agent'; // the plain cards print on the agent surface only
|
|
19
|
+
|
|
20
|
+
const require = createRequire(import.meta.url);
|
|
21
|
+
const land = require('../scripts/gds/ship-land.js');
|
|
22
|
+
const { CARDS } = require('../scripts/gds/plain-cards.js');
|
|
23
|
+
|
|
24
|
+
let passed = 0;
|
|
25
|
+
let failed = 0;
|
|
26
|
+
async function test(name, fn) {
|
|
27
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
28
|
+
catch (err) { failed++; console.log(` FAIL ${name}`); console.log(` ${err.message}`); }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Run fn with console.log captured; returns { result, out }.
|
|
32
|
+
async function capture(fn) {
|
|
33
|
+
const lines = [];
|
|
34
|
+
const real = console.log;
|
|
35
|
+
console.log = (...a) => { lines.push(a.join(' ')); };
|
|
36
|
+
try { return { result: await fn(), out: lines.join('\n') }; }
|
|
37
|
+
finally { console.log = real; }
|
|
38
|
+
}
|
|
39
|
+
const count = (hay, needle) => hay.split(needle).length - 1;
|
|
40
|
+
const CONFLICT_LINE = 'the PR CONFLICTS with main';
|
|
41
|
+
const PENDING_BANNER = 'This is NOT a failure';
|
|
42
|
+
|
|
43
|
+
// A scripted `gh pr view`: the Nth read answers with states[N] (the last one repeats).
|
|
44
|
+
function ghScript(states, calls = []) {
|
|
45
|
+
let i = 0;
|
|
46
|
+
return (args) => {
|
|
47
|
+
calls.push(args.join(' '));
|
|
48
|
+
const s = states[Math.min(i, states.length - 1)];
|
|
49
|
+
i++;
|
|
50
|
+
return typeof s === 'function' ? s(args) : s;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const OPEN = (mergeable, mergeStateStatus) => ({ state: 'OPEN', mergeCommit: null, mergeable, mergeStateStatus });
|
|
54
|
+
const noPause = () => {};
|
|
55
|
+
|
|
56
|
+
// ---- the pure half ----
|
|
57
|
+
|
|
58
|
+
await test('prConflicting reads both shapes it is handed, and a failed read is not a conflict', () => {
|
|
59
|
+
// gh pr view (camelCase)
|
|
60
|
+
assert.equal(land.prConflicting(OPEN('CONFLICTING', 'DIRTY')), true);
|
|
61
|
+
assert.equal(land.prConflicting(OPEN('UNKNOWN', 'DIRTY')), true);
|
|
62
|
+
assert.equal(land.prConflicting(OPEN('CONFLICTING', 'UNKNOWN')), true);
|
|
63
|
+
assert.equal(land.prConflicting(OPEN('MERGEABLE', 'CLEAN')), false);
|
|
64
|
+
assert.equal(land.prConflicting(OPEN('MERGEABLE', 'BLOCKED')), false, 'a red or pending check is not a conflict');
|
|
65
|
+
assert.equal(land.prConflicting(OPEN('UNKNOWN', 'UNKNOWN')), false, 'mergeability still computing is not a conflict');
|
|
66
|
+
// publish-status merge_driver (snake_case, as mergeIfGreen returns it)
|
|
67
|
+
assert.equal(land.prConflicting({ reason: 'merge_conflict', mergeable: 'CONFLICTING', merge_state: 'DIRTY' }), true);
|
|
68
|
+
assert.equal(land.prConflicting({ reason: null, merge_state: 'DIRTY' }), true);
|
|
69
|
+
assert.equal(land.prConflicting({ reason: 'failing_check:unit', mergeable: 'MERGEABLE', merge_state: 'BLOCKED' }), false);
|
|
70
|
+
for (const nothing of [null, undefined, {}]) assert.equal(land.prConflicting(nothing), false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await test('the conflict lines name the fix — merge main in, then re-run this task\'s ship — and carry the card', () => {
|
|
74
|
+
const text = land.conflictPrLines(4242).join('\n');
|
|
75
|
+
assert.ok(text.includes(CONFLICT_LINE), text);
|
|
76
|
+
assert.ok(text.includes('git merge origin/main'), 'merge, never rebase: the branch is already published (strand-fix case C)');
|
|
77
|
+
assert.ok(!/rebase/.test(text), 'a rebase makes every later push non-fast-forward');
|
|
78
|
+
assert.ok(text.includes('node scripts/gds/ship.js 4242'), 'the resume names THIS task');
|
|
79
|
+
assert.ok(text.includes(CARDS.merge_conflict.happened), 'the plain-words card for merge_conflict follows');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await test('landConflicted is a bail with the fix as its next step, never pending', async () => {
|
|
83
|
+
const { result } = await capture(() => land.landConflicted(4242));
|
|
84
|
+
assert.equal(result.ok, false);
|
|
85
|
+
assert.equal(result.pending, false, 'pending is what makes the card say "landing automatically"');
|
|
86
|
+
assert.match(result.reason, /conflicts with main/);
|
|
87
|
+
assert.equal(result.nextStep, '/strand-fix 4242');
|
|
88
|
+
assert.equal(result.bailId, null, 'untagged, so ship-merge never reroutes it into the PR path it already took');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ---- the gh path: pollCiDeploy ----
|
|
92
|
+
|
|
93
|
+
await test('pollCiDeploy: a PR conflicting for the whole wait bails, says so ONCE, and never prints the pending banner', async () => {
|
|
94
|
+
const calls = [];
|
|
95
|
+
const { result, out } = await capture(() => land.pollCiDeploy(4242, 'task-4242', 'abc', {
|
|
96
|
+
ghJson: ghScript([OPEN('CONFLICTING', 'DIRTY')], calls), pause: noPause,
|
|
97
|
+
}));
|
|
98
|
+
assert.equal(result.pending, false, 'a conflicting PR is not "landing automatically"');
|
|
99
|
+
assert.equal(result.nextStep, '/strand-fix 4242');
|
|
100
|
+
assert.equal(count(out, CONFLICT_LINE), 1, `said once when it appeared, not on every poll:\n${out}`);
|
|
101
|
+
assert.ok(!out.includes(PENDING_BANNER), 'the "NOT a failure" banner is for a land that is on its way');
|
|
102
|
+
assert.ok(!out.includes(CARDS.land_pending.next), 'nor the "Nothing to do" card');
|
|
103
|
+
assert.ok(calls[0].includes('mergeable') && calls[0].includes('mergeStateStatus'), `the poll asks GitHub whether it can merge: ${calls[0]}`);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
await test('pollCiDeploy: a mergeable PR that merely outlasts the wait is still pending, as before', async () => {
|
|
107
|
+
const { result, out } = await capture(() => land.pollCiDeploy(4242, 'task-4242', 'abc', {
|
|
108
|
+
ghJson: ghScript([OPEN('MERGEABLE', 'BLOCKED')]), pause: noPause,
|
|
109
|
+
}));
|
|
110
|
+
assert.equal(result.pending, true);
|
|
111
|
+
assert.ok(out.includes(PENDING_BANNER));
|
|
112
|
+
assert.equal(count(out, CONFLICT_LINE), 0);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await test('pollCiDeploy: a conflict the server heals inside the window is announced, then the wait ends pending', async () => {
|
|
116
|
+
const states = [OPEN('UNKNOWN', 'UNKNOWN'), OPEN('CONFLICTING', 'DIRTY'), OPEN('CONFLICTING', 'DIRTY'), OPEN('MERGEABLE', 'BLOCKED')];
|
|
117
|
+
const { result, out } = await capture(() => land.pollCiDeploy(4242, 'task-4242', 'abc', {
|
|
118
|
+
ghJson: ghScript(states), pause: noPause,
|
|
119
|
+
}));
|
|
120
|
+
assert.equal(count(out, CONFLICT_LINE), 1);
|
|
121
|
+
assert.equal(result.pending, true, 'healed: the checks re-run and auto-merge is armed again');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await test('pollCiDeploy: a heal undone by main moving on is announced again, and the verdict is the LAST state', async () => {
|
|
125
|
+
const states = [OPEN('CONFLICTING', 'DIRTY'), OPEN('MERGEABLE', 'BLOCKED'), OPEN('CONFLICTING', 'DIRTY')];
|
|
126
|
+
const { result, out } = await capture(() => land.pollCiDeploy(4242, 'task-4242', 'abc', {
|
|
127
|
+
ghJson: ghScript(states), pause: noPause,
|
|
128
|
+
}));
|
|
129
|
+
assert.equal(count(out, CONFLICT_LINE), 2);
|
|
130
|
+
assert.equal(result.pending, false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await test('pollCiDeploy: a gh that predates the mergeable fields falls back to the old read, no worse than before', async () => {
|
|
134
|
+
const calls = [];
|
|
135
|
+
const oldGh = (args) => (args.join(' ').includes('mergeable') ? null : { state: 'OPEN', mergeCommit: null });
|
|
136
|
+
const { result, out } = await capture(() => land.pollCiDeploy(4242, 'task-4242', 'abc', {
|
|
137
|
+
ghJson: ghScript([oldGh], calls), pause: noPause,
|
|
138
|
+
}));
|
|
139
|
+
assert.equal(result.pending, true, 'with nothing to say about mergeability, the old verdict stands');
|
|
140
|
+
assert.equal(count(out, CONFLICT_LINE), 0);
|
|
141
|
+
assert.ok(calls.some((c) => c.endsWith('--json state,mergeCommit')), 'the two-field read was the fallback');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await test('pollCiDeploy: a failed read keeps the last answer instead of forgetting the conflict', async () => {
|
|
145
|
+
const states = [OPEN('CONFLICTING', 'DIRTY'), null];
|
|
146
|
+
const { result } = await capture(() => land.pollCiDeploy(4242, 'task-4242', 'abc', {
|
|
147
|
+
ghJson: ghScript(states), pause: noPause,
|
|
148
|
+
}));
|
|
149
|
+
assert.equal(result.pending, false, 'a gh hiccup at the end must not turn a conflict back into "landing automatically"');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ---- the server path: pollServerPublish ----
|
|
153
|
+
|
|
154
|
+
const status = (merge_driver) => async () => ({
|
|
155
|
+
ok: true, data: { merged: false, pr_state: 'open', deploy_status: null, reconcile_blocked: null, merge_driver },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await test('pollServerPublish: a merge_conflict from the server is named once, and the timeout bails', async () => {
|
|
159
|
+
const { result, out } = await capture(() => land.pollServerPublish(4242, 'task-4242', {
|
|
160
|
+
readStatus: status({ reason: 'merge_conflict', mergeable: 'CONFLICTING', merge_state: 'DIRTY', rollup_state: 'SUCCESS' }),
|
|
161
|
+
pause: noPause,
|
|
162
|
+
}));
|
|
163
|
+
assert.equal(count(out, CONFLICT_LINE), 1, out);
|
|
164
|
+
assert.ok(!out.includes('is RED'), 'green checks: a conflict is not a red PR');
|
|
165
|
+
assert.ok(!out.includes(PENDING_BANNER));
|
|
166
|
+
assert.equal(result.pending, false);
|
|
167
|
+
assert.equal(result.nextStep, '/strand-fix 4242');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await test('pollServerPublish: a red check still reads RED and still waits pending — this change does not touch it', async () => {
|
|
171
|
+
const { result, out } = await capture(() => land.pollServerPublish(4242, 'task-4242', {
|
|
172
|
+
readStatus: status({ reason: 'failing_check:unit', mergeable: 'MERGEABLE', merge_state: 'BLOCKED' }),
|
|
173
|
+
pause: noPause,
|
|
174
|
+
}));
|
|
175
|
+
assert.ok(out.includes('is RED'));
|
|
176
|
+
assert.equal(count(out, CONFLICT_LINE), 0);
|
|
177
|
+
assert.equal(result.pending, true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
console.log(`\nship-land conflict signal (task 1003499) tests: ${passed} passed, ${failed} failed`);
|
|
181
|
+
process.exitCode = failed > 0 ? 1 : 0;
|