Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .claude/skills/pm-dispatch/references/platform-readings.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,13 +282,13 @@
- auto 档判定随命令形状变,不随能力变 ⇒ 被拒的复合命令拆成裸动作重试再报 blocked。
- 读数:带链接与管道的 `git push` 被拒,裸 `git push origin <分支>` 放行,拒绝文案不点名元素。
- 判定跨天翻面 ⇒ ⛔ 一次拒绝不是能力边界;`permissions.allow` 条目才是仓库侧确定性通道。
- 红检查先读日志:自称一个都没测(基础设施失败)的红归席位清,⛔ 永不请维护者点重跑。
- ① `rerun-failed.mjs` 重跑失败 job 一次(名单即 `RERUN_FAILED_WORKFLOWS`),先在 PR 或卡写明理由。
- 中继重跑(`rerun-failed.mjs` → `run_rerun_failed`)复用原 run 的提交与合并 ref,不拿新 main 重算。
- ⇒ 基上缺已合修复(修复合入晚于该 run 建立)时重跑无效,改 merge main。
- 经 `rerun-failed.mjs` 重跑 PR `ci.yml` 失败 job 仅一次,先在 PR 或卡写明 flake 理由;二败即真红。
- `run_rerun_failed` 只收 `ci.yml`;他 workflow 的 flake 走下两条,⛔ 永不空提交、永不关了重开。
- ② 被拒(如 `lint.yml`)、基缺修复或冲突 ⇒ `pr_update_branch`(齐平即空操作)或 dev 合 main。
- `pr-automation.yml`(`Check Changeset` 取消无后继):席位改正文记下 flake 理由,其 `edited` 即重跑。
- ⇒ 该正文改走 `seat-post-protocol.md` 的 PR 正文行。
- `lint.yml`(检出超时):`main` 前进后中继 `pr_update_branch` 出真新 head 整套重跑;齐平即空操作。
- ③ 测到东西的二败才是真红;⛔ 永不空提交、永不关了重开;落地记录点名所取步骤。
- 同一 head 上轻量兄弟 workflow `success` 加重量级载体 `cancelled` 是普通取代的预期签名。
- 同名 `failure` 也会被带另一诊断的后一次 `failure` 取代 ⇒ 任何判定前先按名取最新一次。
- cancel-in-progress 窗口只罩得住慢载体 ⇒ 先比对 run `head_sha` 与 PR 当前 head,不开调查。
Expand Down
39 changes: 28 additions & 11 deletions scripts/pm/fleet-write/ops.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,10 @@
* it is fenced the same way. These are the seven fences:
*
* - the WORKFLOW: the run's `path` is one of `RERUN_FAILED_WORKFLOWS`, a
* closed list matched whole and joined only by a PR that adds the path
* here — never a request-time input, since the payload carries no key
* that names a workflow;
* reviewed allowlist matched whole and joined only by a PR that adds the
* path here, each entry after `ci.yml` a READ-ONLY check workflow (the
* definition sits on the list) — never a request-time input, since the
* payload carries no key that names a workflow;
* - the EVENT: the run's `event` is `pull_request` — a push, merge-group,
* schedule, release or `workflow_dispatch` run is refused;
* - ONCE: the run's `run_attempt` is 1. A second failure is real, and a
Expand All @@ -249,10 +250,11 @@
* - the HEAD: the run's `head_sha` is its pull request's CURRENT head. The
* pull is the run's `pull_requests[0].number`, and its head is read with
* the pull's own `GET /repos/{repo}/pulls/{n}`, never from the copy the
* run embeds. `ci.yml`'s concurrency group is keyed by the pull number
* with `cancel-in-progress: true`, so a stale head's re-run would cancel
* the current head's run. A run with no `pull_requests` entry (a fork
* pull request's run, or the field empty) is refused by name;
* run embeds. Each listed workflow's concurrency group is keyed by the
* pull number and cancels in progress on `pull_request`, so a stale
* head's re-run would cancel the current head's run. A run with no
* `pull_requests` entry (a fork pull request's run, or the field empty)
* is refused by name;
* - ALONE: a stroke carrying it carries exactly that one action (`alone`),
* so the wider token serves one request;
* - the GRANT: `actions: write` is minted for that stroke alone, through the
Expand Down Expand Up @@ -828,10 +830,25 @@ export const WORKFLOW_DISPATCH_PATH_SHAPE = /\/actions\/workflows\/[^/]+\/dispat
* own `path`, as `GET /actions/runs/{id}` answers it, matched WHOLE (measured
* on run 39713789453: `.github/workflows/ci.yml`, no ref suffix). Joined only
* by a PR that adds the path here; ⛔ never a key of the payload.
* `validate.mjs --self-test` pins every entry to a workflow on disk that runs
* on `pull_request`.
*
* A reviewed allowlist. `ci.yml` is the entry the op was made for. Every other
* entry is a READ-ONLY check workflow: it uses no `secrets.*` but
* `GITHUB_TOKEN`, deploys nothing (no `environment:`), and its token is read-only (a
* `permissions:` block covering every job, no `write` scope). A re-run of such a run
* can change nothing but its own check, so re-running it once costs only runner time.
* Each entry, and the reading that admitted it:
*
* - `.github/workflows/ci.yml` — the op's own workflow (the measured run above).
* - `.github/workflows/check-links.yml` — `Check Documentation Links`, advisory;
* `contents: read` on its one job, no secret, offline lychee. Its setup step
* can fail having checked ZERO links, and its log says so ("No links were
* checked"); that red is the one a seat re-runs once.
*
* `validate.mjs --self-test` pins the list exactly, every entry to a workflow on
* disk that runs on `pull_request`, and every entry after `ci.yml` to the
* read-only definition read from the file itself.
*/
export const RERUN_FAILED_WORKFLOWS = Object.freeze(['.github/workflows/ci.yml']);
export const RERUN_FAILED_WORKFLOWS = Object.freeze(['.github/workflows/ci.yml', '.github/workflows/check-links.yml']);
/** The one event whose run a `run_rerun_failed` may re-run: a pull request's. */
export const RERUN_FAILED_EVENT = 'pull_request';
/** The one attempt it may re-run — once: a second failure is real. The read-back wants the attempt after it. */
Expand Down Expand Up @@ -896,7 +913,7 @@ export const RERUN_FAILED_FENCES = Object.freeze([
fence: 'head',
wants:
"a run whose head_sha is its pull request's CURRENT head (the pull named by pull_requests[0], read with its own GET, " +
"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",
"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",
reads: (run, pull) => {
const n = rerunFailedPullNumber(run);
if (n === null) return "no pull_requests entry — a fork pull request's run, or none";
Expand Down
24 changes: 22 additions & 2 deletions scripts/pm/fleet-write/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1056,7 +1056,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
'the token scope: ONE repository for every op but transfer, source + target for a transfer stroke, and the workflow mints from that list': 8,
'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,
"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,
"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,
"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,
"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,
"the event file: the workflow carries client_payload in no step's env, and the payload is read from GITHUB_EVENT_PATH": 6,
"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,
Expand Down Expand Up @@ -1600,8 +1600,27 @@ export async function selfTest() {
const mintsFromOutput = [...workflowText.matchAll(/^\s+permission-actions:\s*\$\{\{\s*steps\.validate\.outputs\.permission_actions\s*\}\}\s*$/gm)].length;
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]);
// 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.
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']]);
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']]);
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]));
// The read-only definition, read from each file after ci.yml (the op's own workflow): no secret but GITHUB_TOKEN, no
// environment (no deploy), and a token that is read-only for every job — a top-level permissions block or one on each
// job, and no write scope anywhere. Full-line comments are dropped first, so prose naming a scope is not read as one.
const readOnlyBreaches = (p) => {
const lines = readFileSync(resolve(repoRoot, p), 'utf8').split('\n').filter((l) => !/^\s*#/.test(l));
const text = lines.join('\n');
const jobsAt = lines.findIndex((l) => /^jobs:\s*$/.test(l));
const jobLines = jobsAt < 0 ? [] : lines.slice(jobsAt + 1);
const jobs = jobLines.filter((l) => /^ {2}[A-Za-z0-9_-]+:\s*$/.test(l)).length;
const jobPermissions = jobLines.filter((l) => /^ {4}permissions:/.test(l)).length;
return [
...new Set([...text.matchAll(/secrets\.([A-Za-z0-9_]+)/g)].map((m) => m[1]).filter((n) => n !== 'GITHUB_TOKEN').map((n) => `secrets.${n}`)),
/^\s+environment:/m.test(text) && 'an environment (a deploy)',
/:\s*write\b|write-all/.test(text) && 'a write scope',
!(/^permissions:/m.test(text) || (jobs > 0 && jobPermissions === jobs)) && 'a job whose token no permissions block covers',
].filter(Boolean);
};
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, []]));
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]);
// The measured run as its attempt 1 read before the maintainer re-ran it (run 39713789453; fields as the run endpoint answers them).
// 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.
const HEAD = '25b3a6c4bff905d462d4ff44030e5465c342254a';
Expand All @@ -1615,6 +1634,7 @@ export async function selfTest() {
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']]);
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']]);
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]);
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, []]));
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']]);
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']]);
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']]);
Expand Down
Loading
Loading