@event4u/agent-config 14.14.1 → 14.16.0
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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +88 -0
- package/dist/agent-src/rules/security-sensitive-stop.md +7 -5
- package/dist/agent-src/skills/accessibility-auditor/SKILL.md +20 -2
- package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +99 -99
- package/dist/agent-src/skills/fe-design/SKILL.md +6 -3
- package/dist/agent-src/skills/fe-design/references/design-patterns.md +28 -0
- package/dist/agent-src/skills/iconography/SKILL.md +37 -6
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_conformance.js +655 -34
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +7 -7
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +669 -45
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +661 -35
- package/dist/router.json +1 -1
- package/docs/guidelines/design-antipatterns.md +10 -2
- package/package.json +1 -1
- package/src/config/gate-violation-baselines.json +2 -2
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/scripts/_lib/release_highlights.ts +63 -7
- package/src/scripts/design_slop_rules.ts +19 -9
- package/src/scripts/git_authorization_hook.ts +719 -27
- package/src/scripts/hooks/block_unauthorized_git.ts +250 -9
- package/src/scripts/hooks/merge_impact.ts +275 -0
- package/src/scripts/release.ts +9 -34
- package/src/scripts/release_drill.ts +180 -8
- package/src/scripts/release_env.ts +31 -0
- package/src/scripts/release_publication.ts +158 -1
package/src/scripts/release.ts
CHANGED
|
@@ -160,6 +160,8 @@ import {
|
|
|
160
160
|
SystemExitError,
|
|
161
161
|
set_augment_manifest_version,
|
|
162
162
|
set_marketplace_version,
|
|
163
|
+
_rstrip,
|
|
164
|
+
_splitlines,
|
|
163
165
|
} from './release_env.js';
|
|
164
166
|
import type { RunResult } from './release_publication.js';
|
|
165
167
|
// Re-export surface for the six names tests import from `release.js` and that
|
|
@@ -192,6 +194,8 @@ import {
|
|
|
192
194
|
_target_from_branch,
|
|
193
195
|
create_and_push_annotated_tag,
|
|
194
196
|
guard_publication,
|
|
197
|
+
checkout_release_branch,
|
|
198
|
+
guard_release_curation,
|
|
195
199
|
die,
|
|
196
200
|
gh,
|
|
197
201
|
git,
|
|
@@ -744,22 +748,6 @@ function prepend_changelog(p: string, entry: string): void {
|
|
|
744
748
|
}
|
|
745
749
|
|
|
746
750
|
/** Mirror of Python `str.splitlines()` (no trailing empty for a final newline). */
|
|
747
|
-
function _splitlines(text: string): string[] {
|
|
748
|
-
if (text === '') {
|
|
749
|
-
return [];
|
|
750
|
-
}
|
|
751
|
-
const parts = text.split(/\r\n|\r|\n/);
|
|
752
|
-
if (parts.length > 0 && parts[parts.length - 1] === '') {
|
|
753
|
-
parts.pop();
|
|
754
|
-
}
|
|
755
|
-
return parts;
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
/** Mirror of Python `str.rstrip()` (trailing whitespace). */
|
|
759
|
-
function _rstrip(text: string): string {
|
|
760
|
-
return text.replace(/\s+$/u, '');
|
|
761
|
-
}
|
|
762
|
-
|
|
763
751
|
// ─── file mutations ───────────────────────────────────────────────────────────
|
|
764
752
|
|
|
765
753
|
/** Update the top-level `version` field; preserve 4-space indentation. */
|
|
@@ -1167,7 +1155,6 @@ function _step(n: number, total: number, msg: string): void {
|
|
|
1167
1155
|
process.stdout.write(`[${n}/${total}] ${msg}\n`);
|
|
1168
1156
|
}
|
|
1169
1157
|
|
|
1170
|
-
|
|
1171
1158
|
function execute(
|
|
1172
1159
|
plan: Plan,
|
|
1173
1160
|
opts: { wait_for_checks: boolean; dry_run: boolean; resume?: boolean; ci?: boolean },
|
|
@@ -1191,23 +1178,9 @@ function execute(
|
|
|
1191
1178
|
const pr_merged = pr_state === 'MERGED';
|
|
1192
1179
|
|
|
1193
1180
|
// ─── 1. branch ──────────────────────────────────────────────────────────
|
|
1194
|
-
|
|
1195
|
-
_step(1, total,
|
|
1196
|
-
|
|
1197
|
-
run(['git', 'checkout', MAIN_BRANCH]);
|
|
1198
|
-
}
|
|
1199
|
-
run(['git', 'pull', '--ff-only', REMOTE, MAIN_BRANCH]);
|
|
1200
|
-
} else if (resume && _branch_exists_local(branch)) {
|
|
1201
|
-
_step(1, total, `Branch ${branch} exists locally — checkout`);
|
|
1202
|
-
run(['git', 'checkout', branch]);
|
|
1203
|
-
} else if (resume && _branch_exists_remote(branch)) {
|
|
1204
|
-
_step(1, total, `Branch ${branch} exists on ${REMOTE} — fetch + checkout`);
|
|
1205
|
-
run(['git', 'fetch', REMOTE, branch]);
|
|
1206
|
-
run(['git', 'checkout', '-b', branch, `${REMOTE}/${branch}`]);
|
|
1207
|
-
} else {
|
|
1208
|
-
_step(1, total, `Create branch ${branch}`);
|
|
1209
|
-
run(['git', 'checkout', '-b', branch]);
|
|
1210
|
-
}
|
|
1181
|
+
checkout_release_branch(branch, pr_merged, (m) => {
|
|
1182
|
+
_step(1, total, m);
|
|
1183
|
+
});
|
|
1211
1184
|
|
|
1212
1185
|
// ─── 1b. era split (optional, separate commit) ─────────────────────────
|
|
1213
1186
|
// Lands as `chore(changelog): split era ...` BEFORE the release commit
|
|
@@ -1276,6 +1249,8 @@ function execute(
|
|
|
1276
1249
|
run(['task', 'release-prepare']);
|
|
1277
1250
|
}
|
|
1278
1251
|
|
|
1252
|
+
guard_release_curation(plan.target, pr_merged);
|
|
1253
|
+
|
|
1279
1254
|
// ─── 3. commit ──────────────────────────────────────────────────────────
|
|
1280
1255
|
if (pr_merged) {
|
|
1281
1256
|
_step(3, total, 'PR already merged — skip commit');
|
|
@@ -38,6 +38,58 @@ import {
|
|
|
38
38
|
SystemExitError,
|
|
39
39
|
} from './release.js';
|
|
40
40
|
import { DERIVED_MARKER } from './_lib/release_highlights.js';
|
|
41
|
+
import {
|
|
42
|
+
AUGMENT_MARKETPLACE_JSON,
|
|
43
|
+
AUGMENT_PLUGIN_JSON,
|
|
44
|
+
CHANGELOG,
|
|
45
|
+
MARKETPLACE_JSON,
|
|
46
|
+
PACKAGE_JSON,
|
|
47
|
+
PACKAGE_LOCK_JSON,
|
|
48
|
+
PROJECT_TEMPLATE,
|
|
49
|
+
} from './release_env.js';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The files step 2 mutates on disk, snapshotted around every scenario.
|
|
53
|
+
*
|
|
54
|
+
* The FakeWorld intercepts COMMANDS, not `fs` writes, and step 2's bump is
|
|
55
|
+
* seven direct writes. Every scenario before 2026-09-03 passed `--resume` with
|
|
56
|
+
* `package.json` already at the target, so step 2 short-circuited and nothing
|
|
57
|
+
* was ever written — the drill looked side-effect-free because no scenario had
|
|
58
|
+
* reached the writing arm. The first `resume: false` scenario appended twelve
|
|
59
|
+
* `## 14.15.0` headings to the REAL `CHANGELOG.md`.
|
|
60
|
+
*
|
|
61
|
+
* Snapshot-and-restore rather than a writer seam: a seam means threading an
|
|
62
|
+
* injection point through seven call sites in `release.ts`, which sits past the
|
|
63
|
+
* 1500-line cap where every line is charged. This is contained to the runner
|
|
64
|
+
* and covers any future scenario without further thought.
|
|
65
|
+
*/
|
|
66
|
+
const _MUTATED_BY_STEP_2: readonly string[] = [
|
|
67
|
+
CHANGELOG,
|
|
68
|
+
PACKAGE_JSON,
|
|
69
|
+
PACKAGE_LOCK_JSON,
|
|
70
|
+
MARKETPLACE_JSON,
|
|
71
|
+
AUGMENT_PLUGIN_JSON,
|
|
72
|
+
AUGMENT_MARKETPLACE_JSON,
|
|
73
|
+
PROJECT_TEMPLATE,
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
function _snapshot_step_2_files(): Map<string, string> {
|
|
77
|
+
const snap = new Map<string, string>();
|
|
78
|
+
for (const f of _MUTATED_BY_STEP_2) {
|
|
79
|
+
if (fs.existsSync(f)) {
|
|
80
|
+
snap.set(f, fs.readFileSync(f, 'utf-8'));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return snap;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function _restore_step_2_files(snap: ReadonlyMap<string, string>): void {
|
|
87
|
+
for (const [f, content] of snap) {
|
|
88
|
+
if (fs.readFileSync(f, 'utf-8') !== content) {
|
|
89
|
+
fs.writeFileSync(f, content);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
41
93
|
|
|
42
94
|
const _HERE = fileURLToPath(import.meta.url);
|
|
43
95
|
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
@@ -64,6 +116,27 @@ interface WorldConfig {
|
|
|
64
116
|
push_rejections?: number;
|
|
65
117
|
/** mergeStateStatus reports BEHIND this many probes in a row (9.16.0). */
|
|
66
118
|
behind_probes?: number;
|
|
119
|
+
/**
|
|
120
|
+
* Commits the release branch is behind `origin/main` at step 1.
|
|
121
|
+
*
|
|
122
|
+
* Distinct from `behind_probes`, which fakes the PR's own
|
|
123
|
+
* `mergeStateStatus` at step 6. This one fakes `git rev-list --count
|
|
124
|
+
* HEAD..origin/main`, the probe `integrate_main_if_behind` uses — the
|
|
125
|
+
* defect it covers surfaced at the PUSH, six steps before that PR ever
|
|
126
|
+
* existed.
|
|
127
|
+
*/
|
|
128
|
+
branch_behind_main?: number;
|
|
129
|
+
/**
|
|
130
|
+
* Whether `execute()` is invoked with `--resume`. Defaults to TRUE so every
|
|
131
|
+
* pre-existing scenario keeps the shape it was written against.
|
|
132
|
+
*
|
|
133
|
+
* Configurable since 2026-09-03 because the drill could not otherwise
|
|
134
|
+
* reach the defect: branch reuse used to be gated on `resume`, so a plain
|
|
135
|
+
* `task release` over an existing branch died at `git checkout -b` with
|
|
136
|
+
* exit 128 — and every scenario here passed `resume: true`, which is
|
|
137
|
+
* exactly the arm that worked.
|
|
138
|
+
*/
|
|
139
|
+
resume?: boolean;
|
|
67
140
|
/** `gh pr merge` fails once with the not-up-to-date error (the race). */
|
|
68
141
|
merge_races_once?: boolean;
|
|
69
142
|
/** `gh pr merge` fails hard with an unrelated error (must surface). */
|
|
@@ -199,6 +272,7 @@ class FakeWorld {
|
|
|
199
272
|
|
|
200
273
|
private push_rejections: number;
|
|
201
274
|
private behind_probes: number;
|
|
275
|
+
private branch_behind_main: number;
|
|
202
276
|
private merge_races_once: boolean;
|
|
203
277
|
private merge_fails_hard: boolean;
|
|
204
278
|
private checks_fail: boolean;
|
|
@@ -217,6 +291,7 @@ class FakeWorld {
|
|
|
217
291
|
this.tag_local = (cfg.tag_created_unpushed ?? false) || (cfg.tag_already_remote ?? false);
|
|
218
292
|
this.tag_remote = cfg.tag_already_remote ?? false;
|
|
219
293
|
this.behind_probes = cfg.behind_probes ?? 0;
|
|
294
|
+
this.branch_behind_main = cfg.branch_behind_main ?? 0;
|
|
220
295
|
this.merge_races_once = cfg.merge_races_once ?? false;
|
|
221
296
|
this.merge_fails_hard = cfg.merge_fails_hard ?? false;
|
|
222
297
|
this.checks_fail = cfg.checks_fail ?? false;
|
|
@@ -235,6 +310,15 @@ class FakeWorld {
|
|
|
235
310
|
this.head = args[2] === '-b' ? (args[3] as string) : (args[2] as string);
|
|
236
311
|
return OK;
|
|
237
312
|
}
|
|
313
|
+
if (cmd === 'git rev-list --count HEAD..origin/main') {
|
|
314
|
+
return { ...OK, stdout: `${String(this.branch_behind_main)}\n` };
|
|
315
|
+
}
|
|
316
|
+
if (cmd === 'git merge origin/main --no-edit') {
|
|
317
|
+
// A real merge closes the gap, so a second probe reads 0. Without
|
|
318
|
+
// this the scenario could not tell one merge from a loop.
|
|
319
|
+
this.branch_behind_main = 0;
|
|
320
|
+
return OK;
|
|
321
|
+
}
|
|
238
322
|
if (cmd === `git rev-parse --verify --quiet refs/heads/${this.branch}`) {
|
|
239
323
|
return this.pr_merged ? { ...OK, status: 1 } : OK;
|
|
240
324
|
}
|
|
@@ -345,6 +429,15 @@ class FakeWorld {
|
|
|
345
429
|
// live body before deciding whether to edit.
|
|
346
430
|
return { ...OK, stdout: this.pr_body };
|
|
347
431
|
}
|
|
432
|
+
if (args[0] === 'gh' && args[1] === 'pr' && args[2] === 'create') {
|
|
433
|
+
// Only reachable on the NON-resume path: a resumed run refreshes an
|
|
434
|
+
// existing PR body instead (step 5's other arm), which is why every
|
|
435
|
+
// scenario before 2026-09-03 could leave this unscripted.
|
|
436
|
+
const bodyIdx = args.indexOf('--body');
|
|
437
|
+
this.pr_body = bodyIdx >= 0 ? String(args[bodyIdx + 1] ?? '') : '';
|
|
438
|
+
this.branch_exists_remote = true;
|
|
439
|
+
return { ...OK, stdout: `https://github.com/event4u-app/agent-config/pull/999\n` };
|
|
440
|
+
}
|
|
348
441
|
if (args[0] === 'gh' && args[1] === 'pr' && args[2] === 'edit' && args[3] === this.branch) {
|
|
349
442
|
const bodyIdx = args.indexOf('--body');
|
|
350
443
|
this.pr_body = bodyIdx >= 0 ? String(args[bodyIdx + 1] ?? '') : this.pr_body;
|
|
@@ -390,22 +483,99 @@ function _count(world: FakeWorld, needle: string): number {
|
|
|
390
483
|
}
|
|
391
484
|
|
|
392
485
|
const SCENARIOS: Record<string, Scenario> = {
|
|
393
|
-
'
|
|
486
|
+
'plain-run-reuses-an-existing-branch': {
|
|
487
|
+
summary:
|
|
488
|
+
'step 1: a PLAIN run (no --resume) over an existing release branch checks it out instead of dying at `git checkout -b`',
|
|
489
|
+
// The defect, measured on 14.15.0: branch reuse read
|
|
490
|
+
// `resume && _branch_exists_local(branch)`, so without --resume the
|
|
491
|
+
// run fell through to the create arm and died with exit 128. That is
|
|
492
|
+
// precisely the state `guard_release_curation` leaves behind — it stops
|
|
493
|
+
// BEFORE the commit, on a branch step 1 already created — while its own
|
|
494
|
+
// message says to re-run `task release`. The code refused the remedy its
|
|
495
|
+
// own message prescribed.
|
|
496
|
+
config: { resume: false },
|
|
497
|
+
expect_success: true,
|
|
498
|
+
verify: (w) => {
|
|
499
|
+
const f: string[] = [];
|
|
500
|
+
_expect(
|
|
501
|
+
w.calls.includes(`git checkout ${w.branch}`),
|
|
502
|
+
'the existing branch was not checked out',
|
|
503
|
+
f,
|
|
504
|
+
);
|
|
505
|
+
_expect(
|
|
506
|
+
!w.calls.includes(`git checkout -b ${w.branch}`),
|
|
507
|
+
'took the create arm over an existing branch — this is the exit-128 path',
|
|
508
|
+
f,
|
|
509
|
+
);
|
|
510
|
+
return f;
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
'stale-branch-merges-main-at-step-1': {
|
|
514
|
+
summary:
|
|
515
|
+
'step 1: a branch behind origin/main merges it in there — NOT at the push, where the preflight used to be the first to notice',
|
|
516
|
+
// Second half of the same defect. On 14.15.0 the branch had been cut
|
|
517
|
+
// from an older main during an earlier aborted run; steps 2 and 3
|
|
518
|
+
// bumped and committed, and only the pre-push preflight said `branch is
|
|
519
|
+
// BEHIND origin/main`. The cheapest moment to integrate main is the
|
|
520
|
+
// moment the branch is checked out.
|
|
521
|
+
config: { resume: false, branch_behind_main: 3 },
|
|
522
|
+
expect_success: true,
|
|
523
|
+
verify: (w) => {
|
|
524
|
+
const f: string[] = [];
|
|
525
|
+
const merge = w.calls.indexOf('git merge origin/main --no-edit');
|
|
526
|
+
const push = w.calls.indexOf(`git push -u origin ${w.branch}`);
|
|
527
|
+
_expect(merge >= 0, 'main was never merged into the stale branch', f);
|
|
528
|
+
_expect(push >= 0, 'the branch was never pushed', f);
|
|
529
|
+
// Ordering is the whole point: a merge AFTER the push would mean the
|
|
530
|
+
// preflight had already refused.
|
|
531
|
+
_expect(
|
|
532
|
+
merge >= 0 && push >= 0 && merge < push,
|
|
533
|
+
'main was merged after the push, not before it',
|
|
534
|
+
f,
|
|
535
|
+
);
|
|
536
|
+
_expect(
|
|
537
|
+
_count(w, 'git merge origin/main --no-edit') === 1,
|
|
538
|
+
'merged main more than once — the probe is not being re-read after the merge',
|
|
539
|
+
f,
|
|
540
|
+
);
|
|
541
|
+
return f;
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
'marker-refuses-before-commit': {
|
|
394
545
|
summary:
|
|
395
|
-
'step
|
|
546
|
+
'step 3: a draft marker refuses BEFORE the release commit — no local release state, and therefore no remote state either',
|
|
396
547
|
config: { changelog_file: markedChangelogFixture(current_version()) },
|
|
397
548
|
expect_success: false,
|
|
398
549
|
verify: (w, err) => {
|
|
399
550
|
const f: string[] = [];
|
|
400
551
|
_expect(
|
|
401
|
-
(err ?? '').includes(
|
|
552
|
+
(err ?? '').includes('release highlights are still the generator'),
|
|
402
553
|
`died for the wrong reason: ${err ?? '(no error)'}`,
|
|
403
554
|
f,
|
|
404
555
|
);
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
556
|
+
_expect(
|
|
557
|
+
!w.calls.some((c) => c.startsWith('git commit')),
|
|
558
|
+
'a release commit was made after the refusal',
|
|
559
|
+
f,
|
|
560
|
+
);
|
|
561
|
+
// RENAMED from `marker-refuses-before-branch-push` when
|
|
562
|
+
// `guard_release_curation` moved the refusal one step earlier.
|
|
563
|
+
// Every negative assertion below is UNCHANGED and still
|
|
564
|
+
// load-bearing — each is a remote side effect the 14.14.0 run
|
|
565
|
+
// actually produced: a pushed branch, an open PR, a check watch on
|
|
566
|
+
// a check that could not pass. They now hold more strongly, since
|
|
567
|
+
// the run stops before the commit rather than after it, and the
|
|
568
|
+
// no-commit assertion above is the property that is new.
|
|
569
|
+
//
|
|
570
|
+
// What this scenario no longer reaches, said plainly rather than
|
|
571
|
+
// left for a reader to infer: `guard_release_branch_push`. On the
|
|
572
|
+
// non-merged path it is now unreachable in ONE run, because a
|
|
573
|
+
// section marked at step 4 was already marked at step 3. It stays
|
|
574
|
+
// as defence-in-depth for a section that changes BETWEEN the two —
|
|
575
|
+
// a hand edit mid-run, or a resume over an altered changelog — and
|
|
576
|
+
// its own behaviour stays pinned by the unit tests in
|
|
577
|
+
// `tests/scripts/release_push_failure_masking.test.ts`, which is
|
|
578
|
+
// where that coverage moved to rather than being dropped.
|
|
409
579
|
_expect(
|
|
410
580
|
!w.calls.includes(`git push -u origin ${w.branch}`),
|
|
411
581
|
'the branch was pushed after the refusal',
|
|
@@ -666,12 +836,13 @@ function run_scenario(name: string): ScenarioOutcome {
|
|
|
666
836
|
captured.push(String(s));
|
|
667
837
|
return true;
|
|
668
838
|
};
|
|
839
|
+
const tree_snapshot = _snapshot_step_2_files();
|
|
669
840
|
_set_exec_override((args) => world.exec(args));
|
|
670
841
|
// The working-tree read, faked for the same reason the command layer is.
|
|
671
842
|
// Without this the drill's step 8 reads the repository's real CHANGELOG.md.
|
|
672
843
|
_set_changelog_reader(() => world.changelog_file);
|
|
673
844
|
try {
|
|
674
|
-
execute(plan, { wait_for_checks: true, dry_run: false, resume: true });
|
|
845
|
+
execute(plan, { wait_for_checks: true, dry_run: false, resume: scenario.config.resume ?? true });
|
|
675
846
|
} catch (err) {
|
|
676
847
|
if (err instanceof SystemExitError) {
|
|
677
848
|
error = `SystemExit(${err.code}): ${captured.join('')}`;
|
|
@@ -683,6 +854,7 @@ function run_scenario(name: string): ScenarioOutcome {
|
|
|
683
854
|
_set_changelog_reader(null);
|
|
684
855
|
process.stdout.write = orig_out;
|
|
685
856
|
process.stderr.write = orig_err;
|
|
857
|
+
_restore_step_2_files(tree_snapshot);
|
|
686
858
|
}
|
|
687
859
|
|
|
688
860
|
const failures: string[] = [];
|
|
@@ -303,6 +303,35 @@ function set_marketplace_version(p: string, version: string): void {
|
|
|
303
303
|
fs.writeFileSync(p, jsonDumpsIndent(data, 2) + '\n', 'utf-8');
|
|
304
304
|
}
|
|
305
305
|
|
|
306
|
+
/**
|
|
307
|
+
* Mirror of Python `str.splitlines()` — split on CR/LF/CRLF and drop a single
|
|
308
|
+
* trailing empty element.
|
|
309
|
+
*
|
|
310
|
+
* Lives here rather than in `release.ts` because that file is 2000+ lines and
|
|
311
|
+
* every line above 1500 is charged by `check_source_size_budget`, while this
|
|
312
|
+
* module is a third of the cap. Behaviour unchanged; it is a pure text helper
|
|
313
|
+
* beside the other pure text helpers in this module.
|
|
314
|
+
*
|
|
315
|
+
* Known duplication, recorded rather than fixed here: `adoption_report.ts:106`
|
|
316
|
+
* and `readme_linter.ts:418` carry their own copies. Collapsing all three onto
|
|
317
|
+
* one definition touches three modules and is not this change's subject.
|
|
318
|
+
*/
|
|
319
|
+
function _splitlines(text: string): string[] {
|
|
320
|
+
if (text === '') {
|
|
321
|
+
return [];
|
|
322
|
+
}
|
|
323
|
+
const parts = text.split(/\r\n|\r|\n/);
|
|
324
|
+
if (parts.length > 0 && parts[parts.length - 1] === '') {
|
|
325
|
+
parts.pop();
|
|
326
|
+
}
|
|
327
|
+
return parts;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Mirror of Python `str.rstrip()` (trailing whitespace). */
|
|
331
|
+
function _rstrip(text: string): string {
|
|
332
|
+
return text.replace(/\s+$/u, '');
|
|
333
|
+
}
|
|
334
|
+
|
|
306
335
|
export {
|
|
307
336
|
ArgparseExit,
|
|
308
337
|
commaGroup,
|
|
@@ -330,4 +359,6 @@ export {
|
|
|
330
359
|
REPO_ROOT,
|
|
331
360
|
REPO_SLUG,
|
|
332
361
|
SystemExitError,
|
|
362
|
+
_rstrip,
|
|
363
|
+
_splitlines,
|
|
333
364
|
};
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* import path changes.
|
|
17
17
|
*/
|
|
18
18
|
import { spawnSync } from 'node:child_process';
|
|
19
|
-
import { publication_blockers } from './_lib/release_highlights.js';
|
|
19
|
+
import { DERIVED_MARKER, publication_blockers } from './_lib/release_highlights.js';
|
|
20
20
|
import * as fs from 'node:fs';
|
|
21
21
|
import process from 'node:process';
|
|
22
22
|
|
|
@@ -479,6 +479,163 @@ export function guard_release_branch_push(branch: string): void {
|
|
|
479
479
|
);
|
|
480
480
|
}
|
|
481
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Ask for the curation BEFORE the pipeline commits anything — the fifth guard
|
|
484
|
+
* site, and the one that closes the gap the other four only narrowed.
|
|
485
|
+
*
|
|
486
|
+
* `guard_release_branch_push` already refuses before any REMOTE state exists,
|
|
487
|
+
* which is why 14.14.1 cost nothing but a re-run. What it cannot do is refuse
|
|
488
|
+
* before LOCAL state exists: by the time the push is attempted, step 2 has
|
|
489
|
+
* bumped six files and regenerated the derived trees and step 3 has committed
|
|
490
|
+
* all of it as `release: X.Y.Z`. The operator then edits prose on top of a
|
|
491
|
+
* commit that already claims to be the release and continues with `--resume`.
|
|
492
|
+
*
|
|
493
|
+
* The sentence this fixes is in that guard's own docstring: the 2026-09-01
|
|
494
|
+
* flip's risk register named *"the releaser edits prose before merge"* as the
|
|
495
|
+
* mitigation, and *"nothing in the pipeline asked, so nothing did"*. Refusing
|
|
496
|
+
* is not asking. This is the ask, placed where the answer is cheapest: the
|
|
497
|
+
* changelog section has just been written, nothing is committed, and the fix is
|
|
498
|
+
* an edit to a file already open in the working tree.
|
|
499
|
+
*
|
|
500
|
+
* It names the labels rather than only the marker, because the operator's next
|
|
501
|
+
* action is to rewrite specific lines and hunting for them in a 30-line section
|
|
502
|
+
* is the friction that made the refusal feel like a failure rather than a step.
|
|
503
|
+
*
|
|
504
|
+
* Deliberately silent when the section does not exist — same reasoning as the
|
|
505
|
+
* push guard: a missing section is the publication sites' refusal to make, not
|
|
506
|
+
* this one's.
|
|
507
|
+
*
|
|
508
|
+
* `prMerged` is a parameter rather than a check at the call site, and it is
|
|
509
|
+
* load-bearing in both directions. On the merged path steps 2 and 3 do not run,
|
|
510
|
+
* so there is no pre-commit moment to ask about; firing anyway PREEMPTS the
|
|
511
|
+
* step-8 and step-9 guards, whose whole subject is the merged section. That is
|
|
512
|
+
* measured, not anticipated — the unconditional form red three `release_drill`
|
|
513
|
+
* scenarios that exist to pin exactly those later refusals. It lives here
|
|
514
|
+
* rather than in an `if` at the call site because `release.ts` is 2000+ lines
|
|
515
|
+
* and every line above 1500 is charged by the growth ratchet, while this module
|
|
516
|
+
* is far below it.
|
|
517
|
+
*/
|
|
518
|
+
/**
|
|
519
|
+
* Own the whole step-1 branch decision: reuse an existing release branch, and
|
|
520
|
+
* make sure whatever we end up on carries current `main`.
|
|
521
|
+
*
|
|
522
|
+
* Three defects it closes, all measured on 14.15.0 (2026-09-03) in one sitting,
|
|
523
|
+
* and all the same underlying mistake — a release branch that is not on current
|
|
524
|
+
* `main`, discovered later than it could have been.
|
|
525
|
+
*
|
|
526
|
+
* 1. **Reuse was gated on `--resume`.** The two "branch exists" arms read
|
|
527
|
+
* `resume && _branch_exists_local(branch)`, so a PLAIN `task release` over an
|
|
528
|
+
* existing branch fell through to `git checkout -b` and died with exit 128.
|
|
529
|
+
* That is exactly the state `guard_release_curation` leaves behind — it stops
|
|
530
|
+
* before the commit, and the branch it stops on already exists — while its
|
|
531
|
+
* own message says to re-run `task release`. The message was right about
|
|
532
|
+
* what to do and the code refused to do it. Reuse no longer consults
|
|
533
|
+
* `resume`: an existing release branch is a fact about the repository, not
|
|
534
|
+
* about the flag.
|
|
535
|
+
* 2. **A branch cut from an older `main` failed at the PUSH.** The pre-push
|
|
536
|
+
* preflight reported `branch is BEHIND origin/main` after steps 2 and 3 had
|
|
537
|
+
* already bumped and committed — the cheapest moment to integrate `main` is
|
|
538
|
+
* the moment we check the branch out, not eight steps later.
|
|
539
|
+
* 3. **A fresh branch was cut from whatever local `main` happened to be.** The
|
|
540
|
+
* create arm never pulled, so a stale local `main` produced a stale release
|
|
541
|
+
* branch and defect 2 followed on the next run. Fixed on the same terms
|
|
542
|
+
* rather than left as the half that still bites: leaving it would reproduce
|
|
543
|
+
* the "fix that does not fix it" pattern the other two came from.
|
|
544
|
+
*
|
|
545
|
+
* `say` is a callback rather than a direct write because the `[N/total]` step
|
|
546
|
+
* prefix belongs to the orchestrator, which knows the total; this module does
|
|
547
|
+
* not. Keeping the printing there and the decision here is also what lets
|
|
548
|
+
* `release.ts` shed twenty lines — it sits ~500 lines past the 1500-line cap,
|
|
549
|
+
* where every line is charged by the growth ratchet, and this module is under
|
|
550
|
+
* it.
|
|
551
|
+
*
|
|
552
|
+
* The merge is `git merge`, never a rebase and never a force: a release branch
|
|
553
|
+
* can already carry a pushed commit and somebody else's merge, and neither is
|
|
554
|
+
* ours to rewrite.
|
|
555
|
+
*/
|
|
556
|
+
export function checkout_release_branch(
|
|
557
|
+
branch: string,
|
|
558
|
+
prMerged: boolean,
|
|
559
|
+
say: (msg: string) => void,
|
|
560
|
+
): void {
|
|
561
|
+
if (prMerged) {
|
|
562
|
+
say(`PR for ${branch} already merged — staying on ${MAIN_BRANCH}`);
|
|
563
|
+
if (git(['rev-parse', '--abbrev-ref', 'HEAD'], { capture: true }) !== MAIN_BRANCH) {
|
|
564
|
+
run(['git', 'checkout', MAIN_BRANCH]);
|
|
565
|
+
}
|
|
566
|
+
run(['git', 'pull', '--ff-only', REMOTE, MAIN_BRANCH]);
|
|
567
|
+
return;
|
|
568
|
+
}
|
|
569
|
+
if (_branch_exists_local(branch)) {
|
|
570
|
+
say(`Branch ${branch} exists locally — checkout`);
|
|
571
|
+
run(['git', 'checkout', branch]);
|
|
572
|
+
} else if (_branch_exists_remote(branch)) {
|
|
573
|
+
say(`Branch ${branch} exists on ${REMOTE} — fetch + checkout`);
|
|
574
|
+
run(['git', 'fetch', REMOTE, branch]);
|
|
575
|
+
run(['git', 'checkout', '-b', branch, `${REMOTE}/${branch}`]);
|
|
576
|
+
} else {
|
|
577
|
+
// Cut from CURRENT main, not from whatever the local ref happens to be.
|
|
578
|
+
say(`Create branch ${branch} from current ${MAIN_BRANCH}`);
|
|
579
|
+
if (git(['rev-parse', '--abbrev-ref', 'HEAD'], { capture: true }) === MAIN_BRANCH) {
|
|
580
|
+
run(['git', 'pull', '--ff-only', REMOTE, MAIN_BRANCH]);
|
|
581
|
+
}
|
|
582
|
+
run(['git', 'checkout', '-b', branch]);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
integrate_main_if_behind(branch, say);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Merge `origin/<default>` into the checked-out release branch when it is
|
|
590
|
+
* behind, so the pre-push preflight is never the first place that surfaces.
|
|
591
|
+
*
|
|
592
|
+
* Silent when the branch is already current — a release run should not print a
|
|
593
|
+
* merge it did not make. Counting with `rev-list --count` against the fetched
|
|
594
|
+
* remote ref rather than the local one: the local `main` is exactly what went
|
|
595
|
+
* stale in the case this exists for.
|
|
596
|
+
*/
|
|
597
|
+
export function integrate_main_if_behind(branch: string, say: (msg: string) => void): void {
|
|
598
|
+
run(['git', 'fetch', REMOTE, MAIN_BRANCH], { check: false });
|
|
599
|
+
const behind = git(['rev-list', '--count', `HEAD..${REMOTE}/${MAIN_BRANCH}`], {
|
|
600
|
+
capture: true,
|
|
601
|
+
}).trim();
|
|
602
|
+
if (behind === '' || behind === '0') {
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
say(`Branch ${branch} is ${behind} commit(s) behind ${REMOTE}/${MAIN_BRANCH} — merging it in`);
|
|
606
|
+
run(['git', 'merge', `${REMOTE}/${MAIN_BRANCH}`, '--no-edit']);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export function guard_release_curation(version: string, prMerged = false): void {
|
|
610
|
+
if (prMerged) {
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const section = extract_changelog_section(read_changelog_text(), version);
|
|
614
|
+
if (!section) {
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
const blockers = publication_blockers(section.body, version, `\`release/${version}\``);
|
|
618
|
+
if (blockers.length === 0) {
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const marked = section.body
|
|
622
|
+
.split('\n')
|
|
623
|
+
.filter((l) => l.includes(DERIVED_MARKER))
|
|
624
|
+
.map((l) => ` ${l.trim()}`);
|
|
625
|
+
die(
|
|
626
|
+
`the ${version} release highlights are still the generator's draft ` +
|
|
627
|
+
`(${String(blockers.length)} blocker(s)):\n` +
|
|
628
|
+
blockers.map((b) => ` - ${b}`).join('\n') +
|
|
629
|
+
(marked.length > 0
|
|
630
|
+
? `\n Lines to rewrite in \`CHANGELOG.md\` under \`## [${version}]\`:\n` +
|
|
631
|
+
marked.join('\n')
|
|
632
|
+
: '') +
|
|
633
|
+
'\n Stopped BEFORE committing — no release commit, no branch on ' +
|
|
634
|
+
`${REMOTE}, no pull request, no tag. Curate the ` +
|
|
635
|
+
'`### Release highlights` head, then re-run `task release`.',
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
482
639
|
function push_release_branch(branch: string): void {
|
|
483
640
|
guard_release_branch_push(branch);
|
|
484
641
|
const first = run(['git', 'push', '-u', REMOTE, branch], { check: false, capture: true });
|