Skip to content

Commit 8784ccb

Browse files
fix(fleet-write): re-run once the failed jobs of a read-only check workflow, check-links.yml first (#31620)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9Ubb1TRaih3bEfi4rktaCKDryh)_ Fixes #31582 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:席位经中继重跑失败 job 的名单,从只有 `ci.yml` 改为审过的只读检查白名单,首个新成员是 `check-links.yml`(Check Documentation Links);pm-dispatch 读数表写明先读日志、按三步清基础设施红。 - 为什么改:日志自称"一个链接都没查"的基础设施红,过去只能等人手点重跑,挡住入队;维护者要求这类问题席位自己处理。 - 风险与回滚:只读检查重跑一次只多花 runner 时间,五道围栏(只一次、head 等于 PR 当前 head、pull_request 事件、attempt 1、已完成且失败)一道未动;回滚即 revert 本 PR。 - 先看:`scripts/pm/fleet-write/ops.mjs`(名单与只读定义)、`scripts/pm/fleet-write/validate.mjs`(名单与只读自测)、`platform-readings.md` 第 285–291 行。 - 两分钟验证:`node scripts/pm/fleet-write/validate.mjs --self-test`,应见 364 cases pass;`node scripts/pm/rerun-failed.mjs --self-test`,应见 32 cases pass。 - 需要你做的:无,席位落地。 ## What changes - `scripts/pm/fleet-write/ops.mjs`: `RERUN_FAILED_WORKFLOWS` is a reviewed allowlist, `['.github/workflows/ci.yml', '.github/workflows/check-links.yml']`. The docblock states the read-only definition every entry after `ci.yml` meets (no `secrets.*` but `GITHUB_TOKEN`, no `environment:`, a `permissions:` block covering every job, no write scope) and the reading that admitted each entry. The header's workflow and head fence text no longer says the list is `ci.yml` alone. Every fence (workflow, event, once, failed, head, alone, grant) is unchanged. - `scripts/pm/fleet-write/validate.mjs` self-test: the list is pinned exactly; the existing "file on disk that runs on `pull_request`" case is kept; a new case reads each entry after `ci.yml` from its own file and requires zero read-only breaches; a control case shows the definition fails on `ci.yml`, `validate-deps.yml` and `release.yml`; a run of each listed workflow passes all five fences. The battery floor goes 33 to 36. - `scripts/pm/rerun-failed.mjs` self-test: a run of each listed workflow passes the seat-side fences and is dispatched once, with `check-links.yml` listed and `lint.yml` not (floor 11 to 12). The wiring case now refuses any workflow-path array literal in this file, not only the old one-entry spelling. The header and usage say "a listed workflow" instead of `ci.yml`. - `.claude/skills/pm-dispatch/references/platform-readings.md`: the re-run lines now state the seat's duty to read the log first. A red whose own log says it measured nothing is the seat's to clear and never the maintainer's click. The order is (1) one relay re-run, (2) when it is refused, the base lacks a landed fix, or the PR is conflicted, `pr_update_branch` or the dev merges main, (3) only a second red that measured something is real. The landing note names the step taken. The file stays at 458 lines (ceiling 458) with every line at or under 120 bytes. ## Tests <details><summary>Self-tests, green at the final head</summary> ```text ✓ fleet-write/validate self-test: 364 cases pass across 18 batteries ✓ rerun-failed self-test: 32 cases pass across 5 batteries ``` </details> <details><summary>Red with a deliberately wrong entry, through ablation-replace (restore proved: blob equals HEAD, git diff HEAD empty)</summary> ```text + validate-deps.yml (pull_request, issues write): validate exit 1: 2 of 364 failed (the exact list; every entry after ci.yml READ-ONLY) rerun-failed exit 0 (it pins membership of check-links.yml and absence of lint.yml, not the whole list) + release.yml (no pull_request trigger, NPM_TOKEN, environment): validate exit 1: 4 of 364 failed (exact list; file runs on pull_request; READ-ONLY; the all-fences control that uses release.yml as its unlisted path) + lint.yml (turbo cache secrets, a write scope): validate exit 1: 3 of 364 failed (exact list; READ-ONLY; the workflow fence's lint.yml refusal) rerun-failed exit 1: 2 of 32 failed (each listed workflow accepted, lint.yml not listed; the seat-side lint.yml refusal) ``` </details> ## Acceptance notes - Entries measured against the read-only definition (every `pull_request` workflow on `158cc2aa`, read by the self-test's own predicate). These meet it: `check-links.yml` (added), plus `governed-surface-guard.yml`, `pr-body-guards.yml`, `os-create-smoke.yml`, `scaffold-e2e.yml`, `pack-smoke-optin.yml`, `upgrade-harness-optin.yml` and `spec-liveness-check.yml` (not added). The two guards judge live PR state (reviews, body), so a re-run re-judges governance; it does not recover a check that measured nothing. The four smokes and the liveness check have no measured infra-red incident behind them, and the order adds an entry only with a reading. A later PR adds one with its reading. Every other `pull_request` workflow breaches the definition (a write scope, or a secret other than `GITHUB_TOKEN`). - `ci.yml` stays the first entry by the ruling that made the op; it reads the turbo remote-cache secrets, so the read-only definition is checked on the entries after it. - Step (2) reads "behind" as "the base lacks a landed fix". That keeps it consistent with `platform-readings.md` L88, which says being behind alone is no queue veto and never a reason to send `pr_update_branch`. - The acceptance live check (`rerun-failed.mjs --run RUN_ID` on a check-links run dispatches once, a second attempt is refused) is the seat's after landing; this PR dispatches no live re-run. ## Verification Derived gates for this diff (`dispatch-gates.mjs --commands`), each run at the final head with its exit code and reconciled with `--ran`: see the os-dev report on the card. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jTEjfqGjVA7q5WXAGd)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent efbe1a0 commit 8784ccb

4 files changed

Lines changed: 71 additions & 25 deletions

File tree

‎.claude/skills/pm-dispatch/references/platform-readings.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -282,13 +282,13 @@
282282
- auto 档判定随命令形状变,不随能力变 ⇒ 被拒的复合命令拆成裸动作重试再报 blocked。
283283
- 读数:带链接与管道的 `git push` 被拒,裸 `git push origin <分支>` 放行,拒绝文案不点名元素。
284284
- 判定跨天翻面 ⇒ ⛔ 一次拒绝不是能力边界;`permissions.allow` 条目才是仓库侧确定性通道。
285+
- 红检查先读日志:自称一个都没测(基础设施失败)的红归席位清,⛔ 永不请维护者点重跑。
286+
- ① `rerun-failed.mjs` 重跑失败 job 一次(名单即 `RERUN_FAILED_WORKFLOWS`),先在 PR 或卡写明理由。
285287
- 中继重跑(`rerun-failed.mjs` → `run_rerun_failed`)复用原 run 的提交与合并 ref,不拿新 main 重算。
286-
- ⇒ 基上缺已合修复(修复合入晚于该 run 建立)时重跑无效,改 merge main。
287-
- 经 `rerun-failed.mjs` 重跑 PR `ci.yml` 失败 job 仅一次,先在 PR 或卡写明 flake 理由;二败即真红。
288-
- `run_rerun_failed` 只收 `ci.yml`;他 workflow 的 flake 走下两条,⛔ 永不空提交、永不关了重开。
288+
- ② 被拒(如 `lint.yml`)、基缺修复或冲突 ⇒ `pr_update_branch`(齐平即空操作)或 dev 合 main。
289289
- `pr-automation.yml`(`Check Changeset` 取消无后继):席位改正文记下 flake 理由,其 `edited` 即重跑。
290290
- ⇒ 该正文改走 `seat-post-protocol.md` 的 PR 正文行。
291-
- `lint.yml`(检出超时):`main` 前进后中继 `pr_update_branch` 出真新 head 整套重跑;齐平即空操作。
291+
- ③ 测到东西的二败才是真红;⛔ 永不空提交、永不关了重开;落地记录点名所取步骤。
292292
- 同一 head 上轻量兄弟 workflow `success` 加重量级载体 `cancelled` 是普通取代的预期签名。
293293
- 同名 `failure` 也会被带另一诊断的后一次 `failure` 取代 ⇒ 任何判定前先按名取最新一次。
294294
- cancel-in-progress 窗口只罩得住慢载体 ⇒ 先比对 run `head_sha` 与 PR 当前 head,不开调查。

‎scripts/pm/fleet-write/ops.mjs‎

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,10 @@
238238
* it is fenced the same way. These are the seven fences:
239239
*
240240
* - the WORKFLOW: the run's `path` is one of `RERUN_FAILED_WORKFLOWS`, a
241-
* closed list matched whole and joined only by a PR that adds the path
242-
* here — never a request-time input, since the payload carries no key
243-
* that names a workflow;
241+
* reviewed allowlist matched whole and joined only by a PR that adds the
242+
* path here, each entry after `ci.yml` a READ-ONLY check workflow (the
243+
* definition sits on the list) — never a request-time input, since the
244+
* payload carries no key that names a workflow;
244245
* - the EVENT: the run's `event` is `pull_request` — a push, merge-group,
245246
* schedule, release or `workflow_dispatch` run is refused;
246247
* - ONCE: the run's `run_attempt` is 1. A second failure is real, and a
@@ -249,10 +250,11 @@
249250
* - the HEAD: the run's `head_sha` is its pull request's CURRENT head. The
250251
* pull is the run's `pull_requests[0].number`, and its head is read with
251252
* the pull's own `GET /repos/{repo}/pulls/{n}`, never from the copy the
252-
* run embeds. `ci.yml`'s concurrency group is keyed by the pull number
253-
* with `cancel-in-progress: true`, so a stale head's re-run would cancel
254-
* the current head's run. A run with no `pull_requests` entry (a fork
255-
* pull request's run, or the field empty) is refused by name;
253+
* run embeds. Each listed workflow's concurrency group is keyed by the
254+
* pull number and cancels in progress on `pull_request`, so a stale
255+
* head's re-run would cancel the current head's run. A run with no
256+
* `pull_requests` entry (a fork pull request's run, or the field empty)
257+
* is refused by name;
256258
* - ALONE: a stroke carrying it carries exactly that one action (`alone`),
257259
* so the wider token serves one request;
258260
* - the GRANT: `actions: write` is minted for that stroke alone, through the
@@ -828,10 +830,25 @@ export const WORKFLOW_DISPATCH_PATH_SHAPE = /\/actions\/workflows\/[^/]+\/dispat
828830
* own `path`, as `GET /actions/runs/{id}` answers it, matched WHOLE (measured
829831
* on run 39713789453: `.github/workflows/ci.yml`, no ref suffix). Joined only
830832
* by a PR that adds the path here; ⛔ never a key of the payload.
831-
* `validate.mjs --self-test` pins every entry to a workflow on disk that runs
832-
* on `pull_request`.
833+
*
834+
* A reviewed allowlist. `ci.yml` is the entry the op was made for. Every other
835+
* entry is a READ-ONLY check workflow: it uses no `secrets.*` but
836+
* `GITHUB_TOKEN`, deploys nothing (no `environment:`), and its token is read-only (a
837+
* `permissions:` block covering every job, no `write` scope). A re-run of such a run
838+
* can change nothing but its own check, so re-running it once costs only runner time.
839+
* Each entry, and the reading that admitted it:
840+
*
841+
* - `.github/workflows/ci.yml` — the op's own workflow (the measured run above).
842+
* - `.github/workflows/check-links.yml` — `Check Documentation Links`, advisory;
843+
* `contents: read` on its one job, no secret, offline lychee. Its setup step
844+
* can fail having checked ZERO links, and its log says so ("No links were
845+
* checked"); that red is the one a seat re-runs once.
846+
*
847+
* `validate.mjs --self-test` pins the list exactly, every entry to a workflow on
848+
* disk that runs on `pull_request`, and every entry after `ci.yml` to the
849+
* read-only definition read from the file itself.
833850
*/
834-
export const RERUN_FAILED_WORKFLOWS = Object.freeze(['.github/workflows/ci.yml']);
851+
export const RERUN_FAILED_WORKFLOWS = Object.freeze(['.github/workflows/ci.yml', '.github/workflows/check-links.yml']);
835852
/** The one event whose run a `run_rerun_failed` may re-run: a pull request's. */
836853
export const RERUN_FAILED_EVENT = 'pull_request';
837854
/** The one attempt it may re-run — once: a second failure is real. The read-back wants the attempt after it. */
@@ -896,7 +913,7 @@ export const RERUN_FAILED_FENCES = Object.freeze([
896913
fence: 'head',
897914
wants:
898915
"a run whose head_sha is its pull request's CURRENT head (the pull named by pull_requests[0], read with its own GET, " +
899-
"never the run's embedded copy), since ci.yml's concurrency group is keyed by the pull number and a stale head's re-run cancels the current head's run",
916+
"never the run's embedded copy), since each listed workflow's concurrency group is keyed by the pull number and a stale head's re-run cancels the current head's run",
900917
reads: (run, pull) => {
901918
const n = rerunFailedPullNumber(run);
902919
if (n === null) return "no pull_requests entry — a fork pull request's run, or none";

‎scripts/pm/fleet-write/validate.mjs‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1056,7 +1056,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
10561056
'the token scope: ONE repository for every op but transfer, source + target for a transfer stroke, and the workflow mints from that list': 8,
10571057
'the workflow_dispatch row: a closed allowlist of files matched whole, each with its closed inputs, ref main alone, a stroke of exactly that one action, actions write minted for that stroke alone, and the dispatch call and the failed-jobs re-run the ONLY actions paths any row reaches': 18,
10581058
"the release.yml entry: refresh_version_pr true and nothing beside it — force, false, absent, a string or any other input refused by name; the wire spells the table's value as a string; every entry's inputs declared on disk; and the file's own guards, evaluated, start the bookkeeping lane alone": 22,
1059-
"the run_rerun_failed row: run_id alone, a positive integer, nothing that widens it; one bodiless POST to the run's rerun-failed-jobs; a stroke of exactly that one action; actions write minted for that stroke and EMPTY for every other, through the workflow's existing input; the five fences on the run and its pull, each accepted and refused — the head by the pull's own answer, never the run's embedded copy, and a run naming no pull refused by name; the read-back's judgment; and every other actions path still refused": 33,
1059+
"the run_rerun_failed row: run_id alone, a positive integer, nothing that widens it; one bodiless POST to the run's rerun-failed-jobs; a stroke of exactly that one action; actions write minted for that stroke and EMPTY for every other, through the workflow's existing input; the five fences on the run and its pull, each accepted and refused — the head by the pull's own answer, never the run's embedded copy, and a run naming no pull refused by name; the read-back's judgment; and every other actions path still refused": 36,
10601060
"the hierarchy rows: sub_issue_add and sub_issue_remove name a parent and a child by NUMBER — two different positive integers and nothing that widens them; one POST to the parent's sub_issues and one DELETE to its sub_issue, the body left to the executor; issues write, riding any stroke on the literal grants; the fences on the two cards, each accepted and refused — an issue on the repository, never a pull or a transferred card, the child free of any other parent or listed by this one; and the read-back's judgment by the child's id": 23,
10611061
"the event file: the workflow carries client_payload in no step's env, and the payload is read from GITHUB_EVENT_PATH": 6,
10621062
"the advisory rows: advisory_report, advisory_create, advisory_collaborators_add, advisory_versions and advisory_state — closed keys, a malformed id, package or versions entry refused before any request, a refusal that names the key and the byte count and never echoes a value, published reached by advisory_state alone, from draft alone and through the versions fence; one request each, the draft's and the versions' bodies naming no state and the two other PATCH bodies left to the executor; each a stroke of its own, repository-advisories write minted for it and EMPTY for every other; the bare collection reached by the draft's POST alone and every other security-advisories path refused; the fences, the transitions, the merge and the five read-backs, printing nothing of an answer but the id, the state, the logins and counts": 77,
@@ -1600,8 +1600,27 @@ export async function selfTest() {
16001600
const mintsFromOutput = [...workflowText.matchAll(/^\s+permission-actions:\s*\$\{\{\s*steps\.validate\.outputs\.permission_actions\s*\}\}\s*$/gm)].length;
16011601
t('⭐ ruling 3, no workflow edit: fleet-write.yml mints permission-actions from the validator\'s permission_actions output — the one input this row rides — and names neither this op nor its path', [mintsFromOutput, workflowText.includes('run_rerun_failed'), workflowText.includes('rerun-failed-jobs')], [1, false, false]);
16021602
// The five fences on the run and its pull — the closed values, then each accepted and refused on the run's and the pull's own answers.
1603-
t('the fences on the run: the closed workflow list is exactly ci.yml, frozen; the event pull_request; the attempt 1; completed with failure; and the read-back accepts queued and in_progress alone', [[...RERUN_FAILED_WORKFLOWS], Object.isFrozen(RERUN_FAILED_WORKFLOWS), RERUN_FAILED_EVENT, RERUN_FAILED_ATTEMPT, RERUN_FAILED_STATUS, RERUN_FAILED_CONCLUSION, [...RERUN_FAILED_RERUN_STATES], RERUN_FAILED_FENCES.map((f) => f.fence)], [['.github/workflows/ci.yml'], true, 'pull_request', 1, 'completed', 'failure', ['queued', 'in_progress'], ['workflow', 'event', 'once', 'failed', 'head']]);
1603+
t('the fences on the run: the reviewed workflow list is exactly ci.yml and check-links.yml, frozen; the event pull_request; the attempt 1; completed with failure; and the read-back accepts queued and in_progress alone', [[...RERUN_FAILED_WORKFLOWS], Object.isFrozen(RERUN_FAILED_WORKFLOWS), RERUN_FAILED_EVENT, RERUN_FAILED_ATTEMPT, RERUN_FAILED_STATUS, RERUN_FAILED_CONCLUSION, [...RERUN_FAILED_RERUN_STATES], RERUN_FAILED_FENCES.map((f) => f.fence)], [['.github/workflows/ci.yml', '.github/workflows/check-links.yml'], true, 'pull_request', 1, 'completed', 'failure', ['queued', 'in_progress'], ['workflow', 'event', 'once', 'failed', 'head']]);
16041604
t('every listed workflow is a file on disk that runs on pull_request — so the list names a run the fences can accept', RERUN_FAILED_WORKFLOWS.map((p) => [p, existsSync(resolve(repoRoot, p)) && /^ {2}pull_request:/m.test(readFileSync(resolve(repoRoot, p), 'utf8'))]), RERUN_FAILED_WORKFLOWS.map((p) => [p, true]));
1605+
// The read-only definition, read from each file after ci.yml (the op's own workflow): no secret but GITHUB_TOKEN, no
1606+
// environment (no deploy), and a token that is read-only for every job — a top-level permissions block or one on each
1607+
// job, and no write scope anywhere. Full-line comments are dropped first, so prose naming a scope is not read as one.
1608+
const readOnlyBreaches = (p) => {
1609+
const lines = readFileSync(resolve(repoRoot, p), 'utf8').split('\n').filter((l) => !/^\s*#/.test(l));
1610+
const text = lines.join('\n');
1611+
const jobsAt = lines.findIndex((l) => /^jobs:\s*$/.test(l));
1612+
const jobLines = jobsAt < 0 ? [] : lines.slice(jobsAt + 1);
1613+
const jobs = jobLines.filter((l) => /^ {2}[A-Za-z0-9_-]+:\s*$/.test(l)).length;
1614+
const jobPermissions = jobLines.filter((l) => /^ {4}permissions:/.test(l)).length;
1615+
return [
1616+
...new Set([...text.matchAll(/secrets\.([A-Za-z0-9_]+)/g)].map((m) => m[1]).filter((n) => n !== 'GITHUB_TOKEN').map((n) => `secrets.${n}`)),
1617+
/^\s+environment:/m.test(text) && 'an environment (a deploy)',
1618+
/:\s*write\b|write-all/.test(text) && 'a write scope',
1619+
!(/^permissions:/m.test(text) || (jobs > 0 && jobPermissions === jobs)) && 'a job whose token no permissions block covers',
1620+
].filter(Boolean);
1621+
};
1622+
t('every listed workflow after ci.yml is READ-ONLY by its own file: no secret but GITHUB_TOKEN, no environment, a permissions block covering every job, no write scope', RERUN_FAILED_WORKFLOWS.slice(1).map((p) => [p, readOnlyBreaches(p)]), RERUN_FAILED_WORKFLOWS.slice(1).map((p) => [p, []]));
1623+
t('…the definition can fail: ci.yml (turbo cache secrets), validate-deps.yml (issues write) and release.yml each breach it', ['.github/workflows/ci.yml', '.github/workflows/validate-deps.yml', '.github/workflows/release.yml'].map((p) => readOnlyBreaches(p).length > 0), [true, true, true]);
16051624
// The measured run as its attempt 1 read before the maintainer re-ran it (run 39713789453; fields as the run endpoint answers them).
16061625
// It ran on pull #25155's head; PULL is that pull's OWN answer (GET /pulls/25155), which the head fence reads — never the copy the run embeds.
16071626
const HEAD = '25b3a6c4bff905d462d4ff44030e5465c342254a';
@@ -1615,6 +1634,7 @@ export async function selfTest() {
16151634
t('⛔ fence head, refused: a stale head (the pull was pushed to since), an answer for another pull, a run with no head_sha, and no pull answer at all — each naming the head fence alone', [fencedWith({}, { ...PULL, head: { sha: MOVED } }), fencedWith({}, { ...PULL, number: 25156 }), fenced({ head_sha: undefined }), fencedWith({}, null)], [['head'], ['head'], ['head'], ['head']]);
16161635
t("⛔ fence head, refused: the run's EMBEDDED copy of the pull is never read — it agrees with head_sha, and the pull's own answer that moved on still refuses", [MEASURED.pull_requests[0].head.sha === MEASURED.head_sha, fencedWith({}, { ...PULL, head: { sha: MOVED } })], [true, ['head']]);
16171636
t("⛔ fence head, refused by name: a run naming no pull request — pull_requests empty (a fork pull request's run), absent, or an entry with no number — and the sentence says so", [[], undefined, [{ head: { sha: HEAD } }]].map((pull_requests) => [rerunFailedPullNumber({ ...MEASURED, pull_requests }), fencedWith({ pull_requests }, null)]).concat([rerunFailedFenceText(rerunFailedFenceFailures({ ...MEASURED, pull_requests: [] }, null)).includes('fence `head`: the run reads "no pull_requests entry')]), [[null, ['head']], [null, ['head']], [null, ['head']], true]);
1637+
t('accepted: a run of each listed workflow passes all five fences — the list names runs the relay re-runs, check-links.yml among them', RERUN_FAILED_WORKFLOWS.map((path) => [path, rerunFailedFenceFailures({ ...MEASURED, path }, PULL)]), RERUN_FAILED_WORKFLOWS.map((path) => [path, []]));
16181638
t('⛔ fence workflow, refused: another workflow, the bare file name, a ref-suffixed path, a missing path — each naming the workflow fence alone', [{ path: '.github/workflows/lint.yml' }, { path: 'ci.yml' }, { path: '.github/workflows/ci.yml@refs/heads/main' }, { path: undefined }].map(fenced), [['workflow'], ['workflow'], ['workflow'], ['workflow']]);
16191639
t('⛔ fence event, refused: push, merge_group, schedule, release, workflow_dispatch and pull_request_target runs — each naming the event fence alone', ['push', 'merge_group', 'schedule', 'release', 'workflow_dispatch', 'pull_request_target'].map((event) => fenced({ event })), [['event'], ['event'], ['event'], ['event'], ['event'], ['event']]);
16201640
t('⛔ fence once, refused: attempt 2, attempt 3, the string "1" and a missing attempt — each naming the once fence alone', [{ run_attempt: 2 }, { run_attempt: 3 }, { run_attempt: '1' }, { run_attempt: undefined }].map(fenced), [['once'], ['once'], ['once'], ['once']]);

0 commit comments

Comments
 (0)