Skip to content

Commit d72c4c0

Browse files
baozhoutaoclaude
andauthored
fix(ci): settle an indeterminate label write against the board, not the response (#18144)
Closes #17984 `Clause-②: no` ## The defect, and the premise that turned out to be false On PR #17982 `Auto Label` went red for work it had **completed**. Step #3 POSTed `tests`, the API answered HTTP 500, the script exited 1 — and the PR's label set read `size/s, skip-changeset, tests` immediately afterwards. A 500 is not evidence the write failed. The job's red said *"the response failed"*; every reader takes it to mean *"the label is missing"*. Those are different facts, and that gap is the defect. ⚠️ **One premise in the card is false, and it is worth stating plainly.** The card reads "**No retry on 5xx.** A 500 from the GitHub API is transient by definition". `scripts/pr-labels.mjs` has had a **bounded exponential-backoff retry on 5xx since the day the file landed** (#10777, 2026-08-22) — 4 attempts, `2 ** attempt * 500` ms, with 4xx-other-than-429 breaking out as fatal. Verified against the tree: the file's last commit before the incident is 2026-09-06, and the loop is present in the 2026-08-22 blob. So **two of the card's three suggested-shape bullets were already implemented** (retry-on-5xx, 4xx-stays-fatal). The card's incidental observation that "the same window produced at least four" 500s is most likely *those four retry attempts*: `runPlan` logs the POST once and `ghRequest` reports only `lastError`, so four internal attempts render as exactly the one-POST-one-500 log the card read. The one bullet that was genuinely missing is the one the card itself identified as the actual defect: **idempotency-awareness**. Nothing ever re-read the board. ## What changed One file, `scripts/pr-labels.mjs`. Failures are now **classified** rather than uniformly fatal: | class | statuses | treatment | |:--|:--|:--| | **INDETERMINATE** — the server may have acted before the answer was lost | any `5xx`; a fetch that **threw** | re-read the PR's labels and judge the step's **post-condition** | | **DETERMINATE** — the server refused and did not act | `4xx`, **`429` included** | fatal, loud, byte-identical error message to before | Three new pure exports carry the decision: - `failureIsIndeterminate({ status, threw })` - `postconditionOf(step)` — reads the wanted state **off the step itself** (`POST` ⇒ its labels present; `DELETE` ⇒ the one named label absent), so it cannot drift from what the step asks for - `settleWriteFailure({ step, liveLabels, indeterminate })` `ghRequest` now tracks indeterminacy **stickily across attempts** — if any attempt could have reached the server's state, the whole request is indeterminate even when a later attempt came back with a clean 4xx. `runPlan` routes a failed write through `settleOrRethrow`. Two deliberate non-relaxations: - a **determinate 4xx stays fatal even when the board satisfies the post-condition**. A `403` is a broken token and a `422` is a label that does not exist in the repo; the label being there by some other hand does not make the token work. - a **settling re-read that itself fails settles nothing** — the write is reported `UNVERIFIED`, and the *original* error is raised (AGENTS.md *Route & surface ownership* §3: absence must be loud, prefer failing to falling back). ## The two-direction test, and its verdicts `--self-test` gains a 16-case battery, `the #17982 indeterminate write, settled against the board`, pinned in `SELF_TEST_BATTERIES`; `SELF_TEST_BATTERY_FLOOR` goes 6 → 7 so the new battery cannot be silently deleted. A battery that only proved the settle succeeds where the write landed would be the same exit-0-by-construction shape this card is about. So **both legs were ablated**, each proven to land on disk by an occurrence count on the mutated anchor before the run: | ablation | anchor before → after | self-test | |:--|:--|:--| | **A** — delete the 4xx-stays-loud leg (`if (!indeterminate)` → `if (false)`) | 1 → 0 old, 1 new | **exit 1**, `FAIL a determinate 4xx is NOT settled, even with the label present` · `FAIL …and it says so in those words` | | **B** — make 5xx read as determinate (`return Number(status) >= 500` → `return false`) | 1 → 0 old, 1 new | **exit 1**, `FAIL a 500 is indeterminate` · `FAIL so is a 503` | Both restores were settled by `git hash-object` against the HEAD blob (`24e4c5098d6b…`, matched) plus an empty `git diff HEAD` — ⛔ not by a `trap`, and not by a restore command's exit code. Unmutated verdict: `VERDICT: pr-labels self-test PASSED`. ## The sweep — population, criterion, controls The card asked for the population, not a "I also checked others". **Criterion counted by:** a tracked file that (a) names a GitHub API host (`api.github.com` / `GITHUB_API_URL`) **and** (b) issues a non-GET verb, then judged on whether its write failure handling **treats the response status as the verdict with no post-condition re-read**. **Population:** `git grep -lE "api\.github\.com|GITHUB_API_URL"` over tracked files → **23** under `scripts/**`, **3** elsewhere (`.claude/hooks/guard-governed-enqueue.{sh,selftest.sh}`, `.claude/settings.json`), **0** under `packages/**`. Of the 23, **7** contain a write verb. **Controls.** Firing control: `scripts/pr-labels.mjs` — the known positive, still present, 38 953 bytes — appears in both the population and the write-verb narrowing. Nonsense control: the same probe for `api.gitlab.com|GITLAB_API_URL` reads **0**. | file | verdict | |:--|:--| | `scripts/check-whole-set-label-write.mjs` | **not a writer.** Its 8 verb hits are literals in its own detector vocabulary and fixtures. Excluded with evidence. | | `.claude/settings.json`, `.claude/hooks/guard-governed-enqueue.selftest.sh` | **not writers.** Verb hits are *matcher patterns* for a read-only guard hook (the hook itself: 0 verb hits). | | `scripts/pm/label-write.mjs` | ⭐ **already correct, and it is the in-repo reference for this cure.** Nothing there throws on an HTTP status; `classifyHttp` routes, and exit 0 requires *"the write landed AND the read-back matched the target"*, with a second read-back after a re-add. | | `scripts/pm/sweep-stale-finding.mjs` | **deliberate, documented no-retry design** — stops on the first `403`/`429` and prints a resume cursor; carries a `read-back-mismatch` stop reason. Not this shape. | | `scripts/pm/post-stamped.mjs` | **has the shape.** Bare `if (!res.ok) throw` in `rest()`, no retry, no post-condition re-read; its existing read-back verifies stored *bytes* on the success path only. | | `scripts/pm/sweep-closed-cards.mjs` | **has the shape.** Byte-identical `rest()` helper to the above. | | `scripts/release-github-releases.mjs` | **has the shape** on `POST`/`PATCH` release. Release-lane, Prime Directive #15 territory. | | `.github/workflows/**` inline (5 files) | `github.rest.issues.*` via `actions/github-script`; **not swept further** — out of this card's file surface. | **What was fixed vs. left as a card candidate.** Fixed: `scripts/pr-labels.mjs` only. ⛔ Deliberately **not** extracted into a shared helper and swapped into the other three: they are **seat-invoked CLI tools**, where a human or agent reads the output and re-runs, not unattended CI jobs whose red blocks a PR — the severity that makes this card worth fixing does not carry over, and `scripts/pm/label-write.mjs` already shows the repo has the discipline where the stakes are highest. The three are reported to the PM as a **card candidate**, not widened into this PR. ## Reverse-read, both directions - **Currently-true sentence this makes false:** *"`scripts/pr-labels.mjs` fails the job on any non-2xx answer from the labels API."* Now false for the 5xx/thrown class — false only when the board proves the post-condition holds. - **Currently-false sentence this makes true:** *"`scripts/pr-labels.mjs` re-reads the PR's labels after a failed write and judges the write by the board's state."* - **A zero, reported as required:** the change moves **no** sentence about 4xx behaviour. A `4xx` threw and exited 1 before, and rethrows the **same error object with the same message** now. That invariance is asserted by ablation A rather than asserted in prose. ## Scope, gates, publishing - ⛔ **This PR does NOT touch `.github/workflows/**`.** The fix is entirely inside the script, which both `lint.yml` and `pr-automation.yml` already invoke via `--self-test`. No workflow arming problem for the PM seat. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands` derived **32** families from the change set at the final tree; all 32 run, **all exit 0**. `--ran` reconciliation with per-command exit codes: `✓ 32 derived famil(ies) accounted for — 32 run, 0 NOT-MEASURED (a DERIVED zero)`. - **Lint:** the full repo-wide union was run rather than narrowed — `eslint . --no-inline-config --format json` over **6 751** files, **0 errors, 0 warnings**, exit 0, at `1bc65ff50e`. (`eslint.config.mjs` declares no `parserOptions.project` and no typed rules, so the narrowing question is moot in any case.) - **`skip-changeset`, measured not asserted:** every `pnpm-workspace.yaml` glob roots under `packages/*` / `apps/*` / `examples/*`; npm `files[]` resolves relative to a package dir and cannot reach outside it; `scripts/pr-labels.mjs` sits at the repo root inside **no** package, and the root manifest is `private: true`. Positive control: `packages/spec` is non-private with a real `files[]`, so the probe can distinguish. ⇒ nothing publishes ⇒ label, not a changeset. ## 验收备注 - noted, not filed: `ghRequest`'s 4-attempt / `2 ** attempt * 500` ms budget is a hard-coded literal with no env override. Not a defect and not in scope; whoever next tunes the labeler's patience will meet it. 承接者:无 — no queued PR touches this file. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU --- _Generated by [Claude Code](https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 179f23c commit d72c4c0

1 file changed

Lines changed: 246 additions & 6 deletions

File tree

‎scripts/pr-labels.mjs‎

Lines changed: 246 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,111 @@ export function planPathWrites({ prNumber, matched, current }) {
375375
];
376376
}
377377

378+
// ---------------------------------------------------------------------------
379+
// Settling an indeterminate write (#17984). Pure -- the self-test pins it.
380+
// ---------------------------------------------------------------------------
381+
//
382+
// ## The defect
383+
//
384+
// On PR #17982 this job went red for work it had COMPLETED. Step `--paths`
385+
// POSTed `tests`, the API answered `500`, the retry loop below exhausted, the
386+
// script exited 1 -- and the PR's label set read `size/s, skip-changeset,
387+
// tests` immediately afterwards. The label was on the PR. The job said it was
388+
// not.
389+
//
390+
// ⛔ A `500` is NOT evidence the write failed. That is the whole finding: the
391+
// red says "the response failed", every reader takes it to mean "the label is
392+
// missing", and those are different facts. A bounded 5xx retry does not close
393+
// it (this file has had one since #10777 -- the card's "no retry on 5xx"
394+
// premise is false); what closes it is asking the BOARD instead of believing
395+
// the RESPONSE.
396+
//
397+
// ## The line this draws
398+
//
399+
// A failure is either DETERMINATE -- the server refused and did not act -- or
400+
// INDETERMINATE -- it may have acted before the answer was lost. Only the
401+
// second kind is settleable by a re-read, and only the second kind gets one:
402+
//
403+
// * `5xx`, and a fetch that THREW (the request may have been sent and the
404+
// response lost) -> indeterminate -> re-read the label set and judge the
405+
// POST-CONDITION;
406+
// * `4xx`, `429` included -> determinate -> fatal, loud, unchanged. A `403`
407+
// is a broken token and a `422` is a label that does not exist in the
408+
// repo; both are real misconfigurations and must stay red.
409+
//
410+
// ⛔ And a re-read that itself fails settles NOTHING -- the caller reports the
411+
// write as UNVERIFIED and fails on the original error rather than falling back
412+
// to a guess ("absence must be loud", AGENTS.md Route & surface ownership §3).
413+
414+
/**
415+
* Is a failed attempt one the server might have ACTED on before it failed?
416+
* @param {{ status?: number, threw?: boolean }} attempt
417+
*/
418+
export function failureIsIndeterminate({ status, threw = false } = {}) {
419+
if (threw) return true;
420+
return Number(status) >= 500;
421+
}
422+
423+
/**
424+
* The state of the world a plan step was trying to bring about -- read off the
425+
* step itself, so it cannot drift from what the step actually asks for.
426+
* `null` means "this step declares none this function can read", which settles
427+
* nothing.
428+
*
429+
* @returns {{ kind: 'present'|'absent', labels: string[] } | null}
430+
*/
431+
export function postconditionOf(step) {
432+
if (step?.method === 'POST') {
433+
const labels = step?.body?.labels;
434+
if (!Array.isArray(labels) || labels.length === 0) return null;
435+
return { kind: 'present', labels: [...labels] };
436+
}
437+
if (step?.method === 'DELETE' && typeof step.path === 'string') {
438+
const marker = '/labels/';
439+
const at = step.path.indexOf(marker);
440+
if (at < 0) return null;
441+
const name = decodeURIComponent(step.path.slice(at + marker.length));
442+
if (!name) return null;
443+
return { kind: 'absent', labels: [name] };
444+
}
445+
return null;
446+
}
447+
448+
/**
449+
* Given a failed write and the label set read back afterwards: did the thing we
450+
* wanted happen anyway?
451+
*
452+
* @param {{ step: object, liveLabels?: string[], indeterminate?: boolean }} input
453+
* @returns {{ settled: boolean, why: string }}
454+
*/
455+
export function settleWriteFailure({ step, liveLabels = [], indeterminate = false } = {}) {
456+
if (!indeterminate) {
457+
return {
458+
settled: false,
459+
why: 'the API answered DETERMINATELY (a 4xx, 429 included) -- the server refused and did not act, '
460+
+ 'so this is a real misconfiguration and stays loud'
461+
};
462+
}
463+
const post = postconditionOf(step);
464+
if (post === null) {
465+
return {
466+
settled: false,
467+
why: 'this step declares no post-condition that can be read off it, and an unreadable post-condition settles nothing'
468+
};
469+
}
470+
const present = new Set(liveLabels);
471+
if (post.kind === 'present') {
472+
const missing = post.labels.filter((label) => !present.has(label));
473+
return missing.length === 0
474+
? { settled: true, why: `the write reported as failed had LANDED: ${post.labels.join(', ')} is on the PR` }
475+
: { settled: false, why: `the write did NOT land: still missing from the PR: ${missing.join(', ')}` };
476+
}
477+
const lingering = post.labels.filter((label) => present.has(label));
478+
return lingering.length === 0
479+
? { settled: true, why: `the write reported as failed had LANDED: ${post.labels.join(', ')} is off the PR` }
480+
: { settled: false, why: `the write did NOT land: still on the PR: ${lingering.join(', ')}` };
481+
}
482+
378483
// ---------------------------------------------------------------------------
379484
// GitHub REST plumbing.
380485
// ---------------------------------------------------------------------------
@@ -395,6 +500,11 @@ async function ghRequest(method, path, body) {
395500
const url = `${apiUrl}/repos/${repo}${path}`;
396501

397502
let lastError = null;
503+
// Sticky across attempts on purpose: if ANY attempt could have reached the
504+
// server's state, the whole request is indeterminate, even when a later
505+
// attempt came back with a clean 4xx. The write we cannot rule out is the
506+
// one from the attempt that went dark, not the one that was refused.
507+
let indeterminate = false;
398508
for (let attempt = 1; attempt <= 4; attempt += 1) {
399509
try {
400510
const response = await fetch(url, {
@@ -418,14 +528,21 @@ async function ghRequest(method, path, body) {
418528

419529
const text = await response.text();
420530
lastError = new Error(`${method} ${url} -> HTTP ${response.status}: ${text.slice(0, 400)}`);
531+
indeterminate ||= failureIsIndeterminate({ status: response.status });
421532
// 4xx other than 429 will not get better by trying again.
422533
if (response.status < 500 && response.status !== 429) break;
423534
} catch (error) {
424535
lastError = error;
536+
indeterminate ||= failureIsIndeterminate({ threw: true });
425537
}
426538
if (attempt < 4) await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 500));
427539
}
428-
throw lastError ?? new Error(`${method} ${url} failed`);
540+
const failure = lastError ?? new Error(`${method} ${url} failed`);
541+
// The caller settles an indeterminate failure by re-reading the label set;
542+
// a determinate one it rethrows unchanged. Carrying the class on the error is
543+
// what keeps that decision out of string-matching on the message.
544+
failure.indeterminate = indeterminate;
545+
throw failure;
429546
}
430547

431548
async function listPullFiles(prNumber) {
@@ -444,7 +561,41 @@ async function listCurrentLabels(prNumber) {
444561
return Array.isArray(labels) ? labels.map((l) => l.name) : [];
445562
}
446563

447-
async function runPlan(plan, dryRun) {
564+
/**
565+
* A write that failed indeterminately is settled against the BOARD, not against
566+
* the response. See the `settleWriteFailure` section header for why.
567+
*/
568+
async function settleOrRethrow(step, error, readLabels) {
569+
if (error?.indeterminate !== true || typeof readLabels !== 'function') {
570+
// Determinate refusal, or nothing wired to read with: the response IS the
571+
// verdict and it is a failure.
572+
throw error;
573+
}
574+
console.log(`pr-labels: ${error.message}`);
575+
console.log(
576+
'pr-labels: that failure is INDETERMINATE -- the server may have acted before it failed. '
577+
+ "Re-reading the PR's labels to settle it against the board."
578+
);
579+
580+
let live;
581+
try {
582+
live = await readLabels();
583+
} catch (readError) {
584+
console.error(
585+
`pr-labels: the settling re-read ITSELF failed (${readError.message}) -- the write is UNVERIFIED, not verified.`
586+
);
587+
throw error;
588+
}
589+
console.log(`pr-labels: labels on the PR after the failure: ${live.join(', ') || '(none)'}`);
590+
591+
const verdict = settleWriteFailure({ step, liveLabels: live, indeterminate: true });
592+
if (!verdict.settled) {
593+
throw new Error(`${error.message} -- and ${verdict.why}`);
594+
}
595+
console.log(`pr-labels: SETTLED -- ${verdict.why}. Not failing the job for a response that was wrong about its own effect.`);
596+
}
597+
598+
async function runPlan(plan, dryRun, readLabels) {
448599
if (plan.length === 0) {
449600
console.log('pr-labels: nothing to write.');
450601
return;
@@ -456,7 +607,12 @@ async function runPlan(plan, dryRun) {
456607
throw new Error(`pr-labels: refusing to issue a whole-set ${FORBIDDEN_VERB} (${step.path}).`);
457608
}
458609
console.log(`pr-labels: ${step.method} ${step.path} -- ${step.why}`);
459-
if (!dryRun) await ghRequest(step.method, step.path, step.body);
610+
if (dryRun) continue;
611+
try {
612+
await ghRequest(step.method, step.path, step.body);
613+
} catch (error) {
614+
await settleOrRethrow(step, error, readLabels);
615+
}
460616
}
461617
}
462618

@@ -501,7 +657,7 @@ async function runSize(dryRun) {
501657
family: buckets.map((b) => b.label),
502658
current
503659
});
504-
await runPlan(plan, dryRun);
660+
await runPlan(plan, dryRun, () => listCurrentLabels(prNumber));
505661
}
506662

507663
async function runPaths(dryRun) {
@@ -516,7 +672,7 @@ async function runPaths(dryRun) {
516672

517673
const current = await listCurrentLabels(prNumber);
518674
console.log(`pr-labels: labels on PR #${prNumber} right now: ${current.join(', ') || '(none)'}`);
519-
await runPlan(planPathWrites({ prNumber, matched, current }), dryRun);
675+
await runPlan(planPathWrites({ prNumber, matched, current }), dryRun, () => listCurrentLabels(prNumber));
520676
}
521677

522678
// ---------------------------------------------------------------------------
@@ -550,13 +706,14 @@ const SELF_TEST_BATTERIES = Object.freeze({
550706
'size bucketing: `<`, not `<=`': 10,
551707
'the write plans: POST and DELETE only': 11,
552708
'the #10698 interleaving, replayed': 3,
709+
'the #17982 indeterminate write, settled against the board': 16,
553710
'config parsing': 6,
554711
'the REAL config, so drift fails lint rather than a PR run': 2,
555712
});
556713

557714
// DELETING an entry silences that battery's floor exactly as effectively as
558715
// zeroing it, so the roster's own size is pinned too.
559-
const SELF_TEST_BATTERY_FLOOR = 6;
716+
const SELF_TEST_BATTERY_FLOOR = 7;
560717

561718
// The key an assertion is filed under when no battery is open. It is not a
562719
// declared battery, so it reds by the same set difference rather than silently
@@ -755,6 +912,89 @@ function selfTest() {
755912
['skip-changeset', 'size/l']
756913
);
757914

915+
// --- the #17982 indeterminate write, settled against the board -----------
916+
//
917+
// TWO DIRECTIONS, and both are required. A battery that only proved the
918+
// settle succeeds where the write landed would be the same exit-0-by-
919+
// construction shape this card is about: it would stay green if the 4xx leg
920+
// were deleted tomorrow.
921+
battery('the #17982 indeterminate write, settled against the board');
922+
923+
// ① Which failures could the server have ACTED on?
924+
check('a 500 is indeterminate', failureIsIndeterminate({ status: 500 }), true);
925+
check('so is a 503', failureIsIndeterminate({ status: 503 }), true);
926+
check('a thrown fetch is indeterminate -- the response, not the request, is what was lost',
927+
failureIsIndeterminate({ threw: true }), true);
928+
check('a 403 is DETERMINATE -- a broken token refused and did not act', failureIsIndeterminate({ status: 403 }), false);
929+
check('a 422 is DETERMINATE -- the label does not exist in this repo', failureIsIndeterminate({ status: 422 }), false);
930+
check('a 429 is DETERMINATE -- rate-limited means not served, not half-served',
931+
failureIsIndeterminate({ status: 429 }), false);
932+
933+
// ② The post-condition is read off the step, so it cannot drift from it.
934+
const addTests = { method: 'POST', path: '/issues/17982/labels', body: { labels: ['tests'] }, why: 'add' };
935+
const dropSizeS = { method: 'DELETE', path: '/issues/17982/labels/size%2Fs', why: 'retire' };
936+
check('a POST wants its labels PRESENT', postconditionOf(addTests), { kind: 'present', labels: ['tests'] });
937+
check('a DELETE wants ONE named label ABSENT, url-decoded', postconditionOf(dropSizeS),
938+
{ kind: 'absent', labels: ['size/s'] });
939+
check('a POST with no labels declares no post-condition',
940+
postconditionOf({ method: 'POST', path: '/issues/1/labels', body: { labels: [] } }), null);
941+
check('a verb this file does not emit declares none either',
942+
postconditionOf({ method: FORBIDDEN_VERB, path: '/issues/1/labels', body: { labels: ['x'] } }), null);
943+
944+
// ③ DIRECTION ONE -- the incident, replayed. The POST 500'd; the board shows
945+
// the label. The job must NOT be red for work it completed.
946+
check(
947+
'the #17982 incident: POST -> 500, and `tests` is on the PR -> SETTLED',
948+
settleWriteFailure({
949+
step: addTests,
950+
liveLabels: ['size/s', 'skip-changeset', 'tests'],
951+
indeterminate: true
952+
}).settled,
953+
true
954+
);
955+
check(
956+
'a PARTIAL landing is not a landing',
957+
settleWriteFailure({
958+
step: { method: 'POST', path: '/issues/17982/labels', body: { labels: ['tests', 'ci/cd'] }, why: 'add' },
959+
liveLabels: ['tests'],
960+
indeterminate: true
961+
}),
962+
{ settled: false, why: 'the write did NOT land: still missing from the PR: ci/cd' }
963+
);
964+
check(
965+
'a 5xx whose label really is missing stays red',
966+
settleWriteFailure({ step: addTests, liveLabels: ['size/s'], indeterminate: true }).settled,
967+
false
968+
);
969+
check(
970+
'a DELETE that 5xx-ed but took the label off IS settled',
971+
settleWriteFailure({ step: dropSizeS, liveLabels: ['size/l', 'tests'], indeterminate: true }).settled,
972+
true
973+
);
974+
check(
975+
'a DELETE whose label is still there is not',
976+
settleWriteFailure({ step: dropSizeS, liveLabels: ['size/s'], indeterminate: true }).settled,
977+
false
978+
);
979+
980+
// ④ DIRECTION TWO -- a genuine 4xx stays loud, and stays loud EVEN WHEN the
981+
// board happens to satisfy the post-condition. A determinate refusal is a
982+
// misconfiguration; the label being there by some other hand does not make
983+
// the token work. ⛔ This is the case that must never be "relaxed".
984+
const determinate = settleWriteFailure({
985+
step: addTests,
986+
liveLabels: ['size/s', 'skip-changeset', 'tests'],
987+
indeterminate: false
988+
});
989+
check('a determinate 4xx is NOT settled, even with the label present', determinate.settled, false);
990+
check('…and it says so in those words', /DETERMINATELY/.test(determinate.why), true);
991+
check(
992+
'a step with no readable post-condition settles nothing either',
993+
settleWriteFailure({ step: { method: 'POST', path: '/issues/1/labels' }, liveLabels: ['tests'], indeterminate: true })
994+
.settled,
995+
false
996+
);
997+
758998
// --- config parsing ------------------------------------------------------
759999
battery('config parsing');
7601000
const parsed = parseLabelerConfig(

0 commit comments

Comments
 (0)