@mutmutco/pi-plugin 4.1.20 → 4.2.1
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/package.json
CHANGED
|
@@ -11,7 +11,7 @@ import { decide as decideCommandLadder, matchedVerb } from './command-ladder-gat
|
|
|
11
11
|
import { handleGateCrash, handleMissingHookInput, recordGateSuccess } from './deny-gate-crash.mjs';
|
|
12
12
|
import { readHookInput } from './hook-io.mjs';
|
|
13
13
|
import { appendHookActivity } from './hook-trace.mjs';
|
|
14
|
-
import { evaluateTestCommandPolicy } from './test-command-policy-core.mjs';
|
|
14
|
+
import { evaluateTestCommandPolicy, isShallowRepository, readOverride } from './test-command-policy-core.mjs';
|
|
15
15
|
|
|
16
16
|
// Secret echoes are blocked before execution on every active host.
|
|
17
17
|
const SECRET_ECHO_MODE = process.env.MMI_SECRET_ECHO_LINT || 'block';
|
|
@@ -369,7 +369,7 @@ function policyMandatoryEntries(root) {
|
|
|
369
369
|
return parsed.mandatory;
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
-
function
|
|
372
|
+
function taskDiffBase(root) {
|
|
373
373
|
const base = ['origin/development', 'origin/main'].find((ref) => {
|
|
374
374
|
try {
|
|
375
375
|
git(root, ['rev-parse', '--verify', `${ref}^{commit}`]);
|
|
@@ -379,6 +379,11 @@ function taskDiffPaths(root) {
|
|
|
379
379
|
}
|
|
380
380
|
});
|
|
381
381
|
if (!base) throw new Error('neither origin/development nor origin/main resolves');
|
|
382
|
+
return base;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function taskDiffPaths(root) {
|
|
386
|
+
const base = taskDiffBase(root);
|
|
382
387
|
const outputs = [
|
|
383
388
|
git(root, ['diff', '--name-only', `${base}...HEAD`]),
|
|
384
389
|
git(root, ['diff', '--name-only', '--cached']),
|
|
@@ -388,6 +393,27 @@ function taskDiffPaths(root) {
|
|
|
388
393
|
return [...new Set(outputs.flatMap((output) => output.split(/\r?\n/).map((path) => path.trim()).filter(Boolean)))];
|
|
389
394
|
}
|
|
390
395
|
|
|
396
|
+
/**
|
|
397
|
+
* The waiver the gate may honour for this diff's range, or null (#5804).
|
|
398
|
+
*
|
|
399
|
+
* `mmi-cli tests policy` honours a valid `Test-Policy-Override` trailer on the findings layer while
|
|
400
|
+
* this gate refused the same diff's focused test — one policy, two answers, and the agent that wrote
|
|
401
|
+
* the audited trailer to run ONE focused test was then blocked from running exactly that test. The
|
|
402
|
+
* reader is the SHARED one, so the gate cannot drift from the CLI's verdict on what a waiver is.
|
|
403
|
+
*
|
|
404
|
+
* Two fail-closed guards mirror what the CLI reports before it honours anything:
|
|
405
|
+
* - a SHALLOW clone's `base..HEAD` is unbounded (#3628) — nothing is honoured from a range the gate
|
|
406
|
+
* cannot trust;
|
|
407
|
+
* - refusals newer than the winning trailer (malformed shape, unknown scope) block the CLI's run,
|
|
408
|
+
* so they block here too — no honouring a waiver the reporting layer just refused.
|
|
409
|
+
*/
|
|
410
|
+
function taskOverride(root, baseRef) {
|
|
411
|
+
if (isShallowRepository(root)) return null;
|
|
412
|
+
const base = git(root, ['merge-base', 'HEAD', baseRef]).toString().trim();
|
|
413
|
+
const { override, refusals } = readOverride(base, root);
|
|
414
|
+
return refusals.length === 0 ? override : null;
|
|
415
|
+
}
|
|
416
|
+
|
|
391
417
|
function runTestCommandPolicy(input, { stdout = process.stdout } = {}) {
|
|
392
418
|
if (!requestedTestCommand(input?.tool_input?.command)) return { denied: false };
|
|
393
419
|
const root = repositoryRoot(input);
|
|
@@ -397,10 +423,14 @@ function runTestCommandPolicy(input, { stdout = process.stdout } = {}) {
|
|
|
397
423
|
if (!root || (mandatory = policyMandatoryEntries(root)) === null) return { denied: false };
|
|
398
424
|
// #5519: same evaluator `mmi-cli tests policy` attaches to its OK summary — matched globs and
|
|
399
425
|
// command classes cannot disagree with the CLI on the same path set.
|
|
426
|
+
// #5804: and the same waiver, read from the same range this diff was computed against, feeds the
|
|
427
|
+
// evaluator — an honoured override for out-of-zone test work permits the matching focused test.
|
|
428
|
+
const base = taskDiffBase(root);
|
|
400
429
|
const decision = evaluateTestCommandPolicy({
|
|
401
430
|
paths: taskDiffPaths(root),
|
|
402
431
|
mandatory,
|
|
403
432
|
regulated: true,
|
|
433
|
+
override: taskOverride(root, base),
|
|
404
434
|
});
|
|
405
435
|
if (decision.testCommandsAllowed) return { denied: false, decision };
|
|
406
436
|
} catch (error) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// test-command-policy-core.mjs — shared verdict for "may this diff run tests?" (#5519)
|
|
1
|
+
// test-command-policy-core.mjs — shared verdict for "may this diff run tests?" (#5519), and the
|
|
2
|
+
// ONE `Test-Policy-Override` reader both surfaces share (#5804).
|
|
2
3
|
//
|
|
3
4
|
// `mmi-cli tests policy` and the PreToolUse test-command gate both answer that question. Before
|
|
4
5
|
// #5519 they answered it separately: the CLI summary printed the repository's CONFIGURED mandatory
|
|
@@ -7,12 +8,49 @@
|
|
|
7
8
|
// hit TEST-POLICY TEST COMMAND REFUSED on the next line. Delegated workers and the parent hook also
|
|
8
9
|
// diverged when they did not share an evaluator.
|
|
9
10
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
11
|
+
// #5804 reopened the same divergence along the waiver axis: the CLI honoured a valid override
|
|
12
|
+
// trailer on the findings layer and printed OK, while the gate — fed the same diff without the
|
|
13
|
+
// waiver — refused the matching focused test on the next line. So the reader lives HERE, next to
|
|
14
|
+
// the verdict it feeds, and both surfaces pass the SAME honoured waiver into `evaluateTestCommandPolicy`.
|
|
15
|
+
// The reader is the only git-IO in this module, and it is git's own trailer parser plus the exact
|
|
16
|
+
// guards #3628/#3637 established — the hook must never grow a second, looser trailer parser.
|
|
17
|
+
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
12
19
|
|
|
13
20
|
/** Command class the PreToolUse gate regulates. Non-test verification is never refused here. */
|
|
14
21
|
export const TEST_COMMAND_CLASS = 'test';
|
|
15
22
|
|
|
23
|
+
/** The commit trailer that carries a waiver. Live here so the reader and every message built from
|
|
24
|
+
* it spell the key identically. */
|
|
25
|
+
export const TRAILER_KEY = 'Test-Policy-Override';
|
|
26
|
+
/** NOT the authority on what is honoured — {@link readOverride} asks git for that. This is the
|
|
27
|
+
* DISAGREEMENT detector: a line shaped like the trailer that git's parser does not report is the
|
|
28
|
+
* defect worth surfacing, because it waives the gate while leaving the audit trail empty. */
|
|
29
|
+
const OVERRIDE_RE = /^Test-Policy-Override:\s*(.+)$/im;
|
|
30
|
+
/** Record and field separators for the one `git log` call that reads sha, trailer and body at once. */
|
|
31
|
+
const REC = '\u001e';
|
|
32
|
+
const FLD = '\u001f';
|
|
33
|
+
|
|
34
|
+
/** The kinds a `Test-Policy-Override` trailer may waive — the diff rules and nothing else. The
|
|
35
|
+
* three refusal kinds (unresolvable-base, untrusted-range, malformed-override-trailer) are
|
|
36
|
+
* deliberately absent: a waiver read out of a range the gate cannot trust would be the original
|
|
37
|
+
* defect wearing the fix's clothes. */
|
|
38
|
+
export const WAIVABLE_KINDS = [
|
|
39
|
+
'mandatory-zone-untested',
|
|
40
|
+
'unrequested-test-file',
|
|
41
|
+
'protected-removed',
|
|
42
|
+
'stale-protected-entry',
|
|
43
|
+
'stale-satisfied-by',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** The one waivable kind whose finding is a VERDICT ON OUT-OF-ZONE TEST WORK ITSELF (#5804). Rule 1
|
|
47
|
+
* always co-occurs with a matched glob (test commands already allowed there), and the deletion and
|
|
48
|
+
* staleness kinds speak to policy hygiene, not to running tests — waiving them buys the deletion,
|
|
49
|
+
* never the command class. `unrequested-test-file` is different: its finding says exactly what the
|
|
50
|
+
* command gate refuses, "this test work was never asked for". A waiver over it IS the human's
|
|
51
|
+
* "this test is requested" decision, so it — and only it — authorizes the `test` class. */
|
|
52
|
+
const TEST_WORK_KIND = 'unrequested-test-file';
|
|
53
|
+
|
|
16
54
|
/**
|
|
17
55
|
* Glob body → regex body. Supports `**`, `*`, and `{a,b}` (including wildcards inside braces).
|
|
18
56
|
* Kept byte-compatible with {@link globToRegExp} in cli/src/test-policy-core.ts so rule matching
|
|
@@ -66,13 +104,19 @@ export function matchedMandatoryGlobs(paths, mandatory) {
|
|
|
66
104
|
}
|
|
67
105
|
|
|
68
106
|
/**
|
|
69
|
-
* Decide whether test commands are allowed against a path set
|
|
107
|
+
* Decide whether test commands are allowed against a path set, a mandatory zone, and the waiver the
|
|
108
|
+
* caller has already read for this range (#5804).
|
|
70
109
|
*
|
|
71
|
-
* @param {{ paths: string[], mandatory?: unknown, regulated?: boolean
|
|
110
|
+
* @param {{ paths: string[], mandatory?: unknown, regulated?: boolean,
|
|
111
|
+
* override?: { kinds?: string[] } | null }} input
|
|
72
112
|
* `regulated: false` — no test-policy.json (estate default); tests are not gated.
|
|
73
113
|
* `regulated: true` (default) — a declared policy applies; zero matched globs refuses `test`.
|
|
114
|
+
* `override` — the waiver honoured for this range, as {@link readOverride} returned it, or null.
|
|
115
|
+
* A waiver whose kinds cover {@link TEST_WORK_KIND} authorizes the `test` class even with zero
|
|
116
|
+
* matched globs: reporting and enforcement then state ONE decision, because both feed this
|
|
117
|
+
* function the same receipt.
|
|
74
118
|
*/
|
|
75
|
-
export function evaluateTestCommandPolicy({ paths, mandatory, regulated = true } = {}) {
|
|
119
|
+
export function evaluateTestCommandPolicy({ paths, mandatory, regulated = true, override = null } = {}) {
|
|
76
120
|
const configuredMandatoryCount = mandatoryGlobList(mandatory).length;
|
|
77
121
|
if (!regulated) {
|
|
78
122
|
return {
|
|
@@ -85,7 +129,8 @@ export function evaluateTestCommandPolicy({ paths, mandatory, regulated = true }
|
|
|
85
129
|
};
|
|
86
130
|
}
|
|
87
131
|
const matched = matchedMandatoryGlobs(paths, mandatory);
|
|
88
|
-
const
|
|
132
|
+
const overrideAuthorizes = Array.isArray(override?.kinds) && override.kinds.includes(TEST_WORK_KIND);
|
|
133
|
+
const testCommandsAllowed = matched.length > 0 || overrideAuthorizes;
|
|
89
134
|
return {
|
|
90
135
|
configuredMandatoryCount,
|
|
91
136
|
matchedMandatoryGlobs: matched,
|
|
@@ -98,3 +143,111 @@ export function evaluateTestCommandPolicy({ paths, mandatory, regulated = true }
|
|
|
98
143
|
},
|
|
99
144
|
};
|
|
100
145
|
}
|
|
146
|
+
|
|
147
|
+
function git(args, cwd) {
|
|
148
|
+
return execFileSync('git', args, { windowsHide: true, cwd, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Split an optional `[kind, kind]` scope off the front of a trailer value. */
|
|
152
|
+
function parseScope(value) {
|
|
153
|
+
const scoped = /^\[([^\]]*)\]\s*([\s\S]*)$/.exec(value);
|
|
154
|
+
if (!scoped) return { kinds: [...WAIVABLE_KINDS], reason: value, unknown: [] };
|
|
155
|
+
const named = scoped[1].split(',').map((k) => k.trim()).filter(Boolean);
|
|
156
|
+
return {
|
|
157
|
+
kinds: named,
|
|
158
|
+
reason: scoped[2].trim(),
|
|
159
|
+
unknown: named.filter((k) => !WAIVABLE_KINDS.includes(k)),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function invisibleTrailer(sha, line) {
|
|
164
|
+
return {
|
|
165
|
+
kind: 'malformed-override-trailer',
|
|
166
|
+
paths: [],
|
|
167
|
+
detail:
|
|
168
|
+
`OVERRIDE TRAILER GIT CANNOT SEE — commit ${sha.slice(0, 8)} carries\n`
|
|
169
|
+
+ ` ${line}\n`
|
|
170
|
+
+ ` but \`git log --format='%(trailers:key=${TRAILER_KEY})'\` reports nothing for it, so the waiver would\n`
|
|
171
|
+
+ ' exist only to the regex that granted it. The trailer was chosen over a flag because it can be\n'
|
|
172
|
+
+ ' AUDITED, and a reason the auditor cannot see is not a reason this gate accepts (#3628).\n'
|
|
173
|
+
+ ' Git reads trailers from the LAST paragraph only, and every line of that paragraph must be a\n'
|
|
174
|
+
+ ' trailer or an indented continuation — one bare line such as `Closes #123` disqualifies the whole\n'
|
|
175
|
+
+ ' block. Indent the continuation lines, and leave nothing but trailers in that paragraph.\n'
|
|
176
|
+
+ ' This is fixable forward: push a LATER commit on this branch carrying a well-formed trailer.\n'
|
|
177
|
+
+ ' The nearest waiver wins, and it supersedes this one — no force-push, no re-filed branch.',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function unknownScope(sha, unknown) {
|
|
182
|
+
return {
|
|
183
|
+
kind: 'malformed-override-trailer',
|
|
184
|
+
paths: [],
|
|
185
|
+
detail:
|
|
186
|
+
`UNKNOWN OVERRIDE SCOPE — commit ${sha.slice(0, 8)} scopes its waiver to ${unknown.join(', ')}, which this\n`
|
|
187
|
+
+ ' gate cannot report.\n'
|
|
188
|
+
+ ` Waivable kinds: ${WAIVABLE_KINDS.join(', ')}.\n`
|
|
189
|
+
+ ' Refused rather than widened: treating a typo as "waive everything" is how a scoped waiver turns\n'
|
|
190
|
+
+ ' into a blanket exemption without anyone deciding it should.',
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Whether this clone's history is grafted. Anything other than a plain `false` — including a git that
|
|
196
|
+
* cannot answer — counts as shallow: the question being asked is "may I trust a commit range", and
|
|
197
|
+
* "I could not tell" is not a yes.
|
|
198
|
+
*/
|
|
199
|
+
export function isShallowRepository(cwd) {
|
|
200
|
+
try {
|
|
201
|
+
return git(['rev-parse', '--is-shallow-repository'], cwd).trim() !== 'false';
|
|
202
|
+
} catch {
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Read the waiver out of `base..HEAD` with GIT's own trailer parser, and refuse the disagreements.
|
|
209
|
+
*
|
|
210
|
+
* The regex is kept, but demoted to a cross-check: what it sees and what git sees must be the same
|
|
211
|
+
* set, and where they differ the difference IS the finding. On Jerv-PowerTools@e3d4b8ba the regex
|
|
212
|
+
* granted a waiver git reported no trailer for, and stored the first line of it — `"src/commands/
|
|
213
|
+
* grind.ts is outside test-policy.json's"` — as the justification, argument discarded.
|
|
214
|
+
*
|
|
215
|
+
* Moved here from cli/src/test-policy-core.ts (#5804) so the PreToolUse gate reads the SAME waiver
|
|
216
|
+
* the CLI reports — git's parser, the folded-whole value, the sha, the `[kind]` scoping, and the
|
|
217
|
+
* nearest-waiver-wins precedence — rather than a parallel line regex that would reintroduce the
|
|
218
|
+
* #3628 class on the execution side.
|
|
219
|
+
*
|
|
220
|
+
* @param {string} base merge-base of the change set; the waiver is read from `base..HEAD` only
|
|
221
|
+
* @param {string} cwd repository root the git commands run in
|
|
222
|
+
* @returns {{ override: { sha: string, reason: string, kinds: string[] } | null,
|
|
223
|
+
* refusals: Array<{ kind: string, detail: string, paths: string[] }> }}
|
|
224
|
+
* A non-empty `refusals` lists override-shaped problems from commits NEWER than the winning
|
|
225
|
+
* waiver; the caller must treat the run as refused, never honour `override` past them.
|
|
226
|
+
*/
|
|
227
|
+
export function readOverride(base, cwd) {
|
|
228
|
+
const format = `%H${FLD}%(trailers:key=${TRAILER_KEY},valueonly,unfold)${FLD}%B${REC}`;
|
|
229
|
+
const refusals = [];
|
|
230
|
+
let override = null;
|
|
231
|
+
for (const record of git(['log', `${base}..HEAD`, `--format=${format}`], cwd).split(REC)) {
|
|
232
|
+
const [sha, trailer, body] = record.replace(/^\s+/, '').split(FLD);
|
|
233
|
+
if (!sha) continue;
|
|
234
|
+
// git log is newest-first, so the nearest waiver wins — as it always did — and once one is held
|
|
235
|
+
// every remaining record is OLDER than it and cannot change the answer. Stopping here is what
|
|
236
|
+
// gives a malformed trailer a forward-only remedy (#3637): the disagreement check below used to
|
|
237
|
+
// sit above this line, so an unparseable trailer refused the PR forever while the honoured waiver
|
|
238
|
+
// came from a commit after it. Rewriting that commit needs a force-push this estate does not do,
|
|
239
|
+
// which left re-filing the whole branch as the only exit. Precedence already applied to values;
|
|
240
|
+
// it now applies to the refusals read out of the same range.
|
|
241
|
+
if (override) break;
|
|
242
|
+
const value = (trailer ?? '').trim();
|
|
243
|
+
if (!value) {
|
|
244
|
+
const shaped = OVERRIDE_RE.exec(body ?? '');
|
|
245
|
+
if (shaped) refusals.push(invisibleTrailer(sha, shaped[0].trim()));
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
const { kinds, reason, unknown } = parseScope(value);
|
|
249
|
+
if (unknown.length > 0) refusals.push(unknownScope(sha, unknown));
|
|
250
|
+
else override = { sha, reason, kinds };
|
|
251
|
+
}
|
|
252
|
+
return { override, refusals };
|
|
253
|
+
}
|
|
@@ -429,9 +429,10 @@ collaborator list + the per-branch allowlist are the record — no separate rost
|
|
|
429
429
|
central workflows, so `MMI_APP_ID` / `MMI_APP_PRIVATE_KEY` live only on the Hub — a product repo seeds no
|
|
430
430
|
App var/secret.
|
|
431
431
|
- **Issue templates** — seed `.github/ISSUE_TEMPLATE/` (Bug · Feature · Task + `config.yml`).
|
|
432
|
-
- **Merge settings (org canon)** — every repo: auto-merge on, squash on,
|
|
433
|
-
|
|
434
|
-
|
|
432
|
+
- **Merge settings (org canon)** — every repo: auto-merge on, squash on, automatic merged-branch deletion
|
|
433
|
+
off. A merged remote branch survives until delayed cleanup proves no open PR still uses it as a base
|
|
434
|
+
(#5789); deleting it at merge can irreversibly close stacked PRs:
|
|
435
|
+
`gh api -X PATCH repos/$OWNER/$REPO -f allow_auto_merge=true -f allow_squash_merge=true -F delete_branch_on_merge=false`
|
|
435
436
|
- **Cursor environment** — `.cursor/environment.json` is retired org-wide (#2501): it caused Cursor to
|
|
436
437
|
auto-spawn cloud agents nobody asked for. Bootstrap no longer seeds it and no
|
|
437
438
|
repo should carry a tracked copy.
|
package/skills/mmi/SKILL.md
CHANGED
|
@@ -300,6 +300,15 @@ mmi-cli oracle issue view <N> --repo <owner/repo> --comments # body + every co
|
|
|
300
300
|
>
|
|
301
301
|
> Never truncate at the last `}`; trailing prose can contain braces, and malformed/incomplete JSON must fail closed.
|
|
302
302
|
|
|
303
|
+
> **Never capture board JSON with a shell redirect on Windows** (#5802). PowerShell 5.1's `> file.json` is
|
|
304
|
+
> `Out-File`, which writes **UTF-16LE with a BOM**, so the file starts `FF FE` and the next step —
|
|
305
|
+
> `readFileSync(path, 'utf8')` + `JSON.parse` — dies at position 1. Pass `--out <path>` instead: the CLI
|
|
306
|
+
> writes the file itself as UTF-8 with no BOM, so the bytes never reach the shell.
|
|
307
|
+
>
|
|
308
|
+
> ```bash
|
|
309
|
+
> mmi-cli oracle board read --json --out .jerv/tmp/board.json # UTF-8, parses everywhere
|
|
310
|
+
> ```
|
|
311
|
+
|
|
303
312
|
(Triggers only when a dev commits to an existing item — no-op for the *report a bug / request a feature /
|
|
304
313
|
something else* paths.)
|
|
305
314
|
|
package/skills/release/SKILL.md
CHANGED
|
@@ -191,8 +191,9 @@ before any branch/tag mutation (#5666).** They read the repo's expected CI npm f
|
|
|
191
191
|
(when the infra gate's exact node pin maps to a bundled npm, e.g. node 24.19.0 → npm 11.17.0) or, when
|
|
192
192
|
gate.yml only declares a floating node major with no bundled npm, from the publish workflow's explicit npm pin
|
|
193
193
|
(e.g. `npx --yes npm@11.17.0 pack` in `publish.yml`). Local `npm -v` must match that npm **major**; a
|
|
194
|
-
mismatch refuses `--apply`
|
|
195
|
-
|
|
194
|
+
mismatch refuses `--apply` before any repository write. The receipt gives the supported npm-only repair
|
|
195
|
+
(`npm install -g npm@<exact CI version>`), the `node -v && npm -v` verification, and tells you to rerun the
|
|
196
|
+
same release command. This preserves Node and avoids routing a machine-global mutation through the train.
|
|
196
197
|
|
|
197
198
|
**A local `npm ci` failure during the fold on a lockfile CI already accepted is an npm-major mismatch, not a
|
|
198
199
|
bad lockfile (#4578).** Before touching the lockfile, compare toolchains:
|
|
@@ -277,7 +278,8 @@ secret from a transport-error response.
|
|
|
277
278
|
The train dispatches `actions-job-start-canary.yml` on MMI-Hub (Linux hosted, not `windows-latest`) and
|
|
278
279
|
refuses to mint a tag if the canary job never starts (empty steps / billing refusal). If a tag is already
|
|
279
280
|
on origin, do **not** recut: `mmi-cli devops release --retry-publish <run-id> --apply` retries that exact
|
|
280
|
-
run.
|
|
281
|
+
failed or cancelled run. A failed run retries failed jobs; a cancelled run reruns the whole idempotent
|
|
282
|
+
publish workflow because GitHub does not classify cancelled jobs as failed (#5797).
|
|
281
283
|
|
|
282
284
|
## Step 0c — hotfix-coverage guard (fail closed, #839, #958)
|
|
283
285
|
|
|
@@ -624,8 +626,9 @@ mmi-cli oracle org project sync-info --apply # omit --apply for the read-only
|
|
|
624
626
|
- `checkout restoration failed while returning to …` / `origin/<branch> fast-forward failed` — the
|
|
625
627
|
checkout or the pull errored. Read the appended git message; the release is unaffected.
|
|
626
628
|
|
|
627
|
-
`--resume`
|
|
628
|
-
|
|
629
|
+
`--resume` uses the same `checkout:` clause after every branch-mutating terminal path. Direct-track
|
|
630
|
+
resumes return to `development`; full-track resumes return to `rc`. A skipped restoration names the
|
|
631
|
+
dirty tree, checkout failure, or fast-forward failure and still leaves the shipped release intact.
|
|
629
632
|
|
|
630
633
|
## Step 6 — collect deploy verdict + report
|
|
631
634
|
|