diff --git a/.claude/skills/pm-dispatch/references/landing-operations.md b/.claude/skills/pm-dispatch/references/landing-operations.md index 08d28263d61..1ef5de79cd2 100644 --- a/.claude/skills/pm-dispatch/references/landing-operations.md +++ b/.claude/skills/pm-dispatch/references/landing-operations.md @@ -64,7 +64,7 @@ - ready + 全绿 ≠ 已入队:队列从不主动拉 PR,入队是显式动作。 - 零 `enqueued` 事件按序查三条:① `mergeable_state` 是否 `dirty`。 - ② enable-auto-merge 调用根本没落地,重发与效果验证序列见 `platform-readings.md`。 -- ③ PR 碰 `.github/workflows/**` 而 token 缺 workflows 权限。 +- ③ PR 碰 `.github/workflows/**`:令牌常驻 `workflows: write`(裁决评论 6104822731),仍拒即授权漂移。 - 确认 MERGED 同一动作里给 `Part of` 卡收口、`Fixes` 卡代关但标也须摘,⛔ 不拆到下轮巡检。 - 卡以 completed 关闭且带 `Adopts:` 行 ⇒ 同一动作在所指 fork PR 贴致谢与落地链接并关闭它。 - `Part of` 卡开着不摘 `pm:dispatched`,无在飞物的卡就永远算进 `label:pm:dispatched is:open`。 diff --git a/.github/workflows/fleet-write.yml b/.github/workflows/fleet-write.yml index 9a7f3672eec..ad585a79022 100644 --- a/.github/workflows/fleet-write.yml +++ b/.github/workflows/fleet-write.yml @@ -20,10 +20,11 @@ # alone, that transfer's one target too: `transferIssue` needs issues write on # both ends) and to the permissions the op table declares (`issues: write`, # `pull-requests: write`, the `contents: write` GitHub's auto-merge mutations -# require, and the `metadata: read` the sender gate's permission read needs; +# require, the `workflows: write` that arming auto-merge on a pull changing a +# workflow file requires, and the `metadata: read` the sender gate's permission read needs; # plus, for a `workflow_dispatch` stroke ALONE, the `actions: write` the # dispatch endpoint needs — minted from the validator's per-stroke output, so -# every other run's token is exactly the four grants above) +# every other run's token is exactly the five grants above) # — and revoked by that action's post step. The workflow's own `GITHUB_TOKEN` gets nothing # (`permissions: {}` below); the job grants it `contents: read` for the # checkout and nothing else, so the run's ONLY write path is the App token, @@ -123,13 +124,20 @@ jobs: # The op table in `scripts/pm/fleet-write/ops.mjs` has NO contents op — no file, ref # or branch write — so this grant is spent only by those two mutations. permission-contents: write + # ONE consumer: `automerge_enable` on a pull that changes `.github/workflows/**` — + # GitHub refuses to enqueue such a pull for an App token without `workflows` (measured: + # "refusing to allow a GitHub App to create or update workflow … without `workflows` + # permission"). Literal on every stroke by the maintainer's ruling (comment 6104822731). + # It also reaches workflow-file writes through contents and git endpoints; the op table + # has no row for either, and `validate.mjs --self-test` pins that none is reachable. + permission-workflows: write permission-metadata: read # ONE consumer: the `workflow_dispatch` row — `POST .../actions/workflows/{file}/dispatches` # on an allowlisted file at `main`. Minted ONLY for a stroke carrying that op: the validator # writes `permission_actions=write` for such a stroke and an EMPTY value for every other, # and `actions/create-github-app-token` skips an empty `permission-*` input (its # `lib/get-permissions-from-inputs.js`: an empty value leaves the set unchanged, measured - # at the v3 tag), so every other run keeps exactly the four grants above. `actions: write` + # at the v3 tag), so every other run keeps exactly the five grants above. `actions: write` # also reaches cancel, re-run, delete, cache and artifact endpoints; the op table has no # row for any of them, and `validate.mjs --self-test` pins that none is reachable. permission-actions: ${{ steps.validate.outputs.permission_actions }} diff --git a/scripts/pm/fleet-write/ops.mjs b/scripts/pm/fleet-write/ops.mjs index 79ac78e25f6..c334c9b9d83 100644 --- a/scripts/pm/fleet-write/ops.mjs +++ b/scripts/pm/fleet-write/ops.mjs @@ -91,10 +91,21 @@ * "Resource not accessible by integration" — and those two rows are the ONLY * spenders of it: the table has no contents op (no direct file, ref or branch * write; `pr_update_branch` is the platform's own merge, `pull_requests: write` - * in that ledger); the one workflow-run dispatch (`workflow_dispatch`, below) + * in that ledger); arming auto-merge on a pull that changes + * `.github/workflows/**` also requires `workflows: write` — measured: the + * relay's `automerge_enable` on such a pull answered "refusing to allow a + * GitHub App to create or update workflow … without `workflows` permission" — + * so that row spends it (`alsoSpends`), the one row that does; it is a + * literal grant on every stroke, and a seat may enqueue a pull that changes a + * workflow file. The human floor of pm-dispatch `SKILL.md` (security and + * permission boundaries stay a human's) is lowered for this ONE boundary by + * the maintainer's own ruling, comment 6104822731 (「22708 A」), so this grant + * is not a drift. The grant also reaches workflow-file writes through + * `/contents/**` and `/git/**`; `REFUSED_PATH_FAMILIES` refuses both for every + * row. The one workflow-run dispatch (`workflow_dispatch`, below) * is `actions: write`, the one row spending it; the sender gate's * `GET /repos/{o}/{r}/collaborators/{login}/permission` is `metadata: read`. - * `PERMISSIONS` is that set and nothing wider. The workflow declares the four + * `PERMISSIONS` is that set and nothing wider. The workflow declares the five * grants every stroke gets as literal `permission-*` inputs on * `actions/create-github-app-token`, and the one grant a stroke gets only when * it spends it (`STROKE_SCOPED_PERMISSIONS`) as an input read from the @@ -130,7 +141,7 @@ * endpoint) needs `issues: write` on the repository the card is in AND on the * one it moves to, so a transfer stroke's token is minted for both. That is * the only widening of the token's repository reach the relay makes (the one - * widening of its permissions is the next section), and it is fenced four ways: the target is + * per-stroke widening of its permissions is `workflow_dispatch`'s, above), and it is fenced four ways: the target is * a closed enum — `TRANSFER_TARGETS`, the fleet's governed-repository roster * reused from `check-governed-merges.mjs`, never a second roster and never * free text; the target is never the source; a stroke carrying a transfer @@ -284,14 +295,18 @@ export const FIELDS = Object.freeze({ /** * The App permissions the relay token is narrowed to — the union of every - * row's `permission`, plus the sender gate's read. `contents: write` is spent - * by the two auto-merge rows alone (GitHub's requirement for those mutations); - * no row writes a file, a ref or a branch itself — `pr_update_branch`'s merge - * is the platform's, under `pull-requests`. `actions: write` is spent by the - * `workflow_dispatch` row alone, and minted only for a stroke carrying it - * (`STROKE_SCOPED_PERMISSIONS`). + * row's `permission` and `alsoSpends`, plus the sender gate's read. + * `contents: write` is spent by the two auto-merge rows alone (GitHub's + * requirement for those mutations); no row writes a file, a ref or a branch + * itself — `pr_update_branch`'s merge is the platform's, under + * `pull-requests`. `workflows: write` is spent by the `automerge_enable` row + * alone (`alsoSpends`: a pull changing `.github/workflows/**` is enqueued only + * by a token carrying it), a literal grant on every stroke by the + * maintainer's ruling, comment 6104822731 (header). `actions: write` is spent + * by the `workflow_dispatch` row alone, and minted only for a stroke carrying + * it (`STROKE_SCOPED_PERMISSIONS`). */ -export const PERMISSIONS = Object.freeze({ issues: 'write', 'pull-requests': 'write', contents: 'write', metadata: 'read', actions: 'write' }); +export const PERMISSIONS = Object.freeze({ issues: 'write', 'pull-requests': 'write', contents: 'write', workflows: 'write', metadata: 'read', actions: 'write' }); /** * The permissions minted ONLY for a stroke carrying an op that spends them — @@ -406,10 +421,11 @@ export function transferRemedy(source, target) { * must reach — `transfer`'s alone (header above); * `alone` (optional) true for a row whose stroke carries exactly that * one action — `workflow_dispatch`'s alone (header above); - * `alsoSpends` (optional) grants a LATER request of the row spends beside - * `permission` — each a literal mint input, never a - * stroke-scoped one (`validate.mjs --self-test` pins it) — - * `pr_create`'s assignees leg alone (header above); + * `alsoSpends` (optional) grants the row spends beside `permission` — + * each a literal mint input, never a stroke-scoped one + * (`validate.mjs --self-test` pins it): `pr_create`'s + * assignees leg, a LATER request, and `automerge_enable` on + * a pull that changes `.github/workflows/**` (header above); * `requests` the request descriptors the executor issues, in order: * `{ verb, path, body?, idempotent404?, graphql?, updateBranch?, fromAnswer?, assigned? }` * — a `graphql` descriptor names the mutation and the pull (or @@ -537,8 +553,11 @@ export const OPS = Object.freeze({ }), // The two auto-merge mutations spend `contents` — GitHub requires `contents: write` // on the token for enable/disablePullRequestAutoMerge, over and above `pull-requests`. + // A pull changing `.github/workflows/**` needs `workflows: write` on the enqueuing token + // too — a literal grant on every stroke, by the maintainer's ruling (comment 6104822731). automerge_enable: Object.freeze({ permission: 'contents', + alsoSpends: Object.freeze(['workflows']), required: Object.freeze(['pull']), optional: Object.freeze([]), requests: (a) => [{ verb: 'POST', path: '/graphql', graphql: { mutation: 'enablePullRequestAutoMerge', query: PR_MUTATIONS.automerge_enable, pull: a.pull, landed: PR_LANDED_STATE.automerge_enable } }], diff --git a/scripts/pm/fleet-write/validate.mjs b/scripts/pm/fleet-write/validate.mjs index 5ef0180fccf..444f7547f18 100644 --- a/scripts/pm/fleet-write/validate.mjs +++ b/scripts/pm/fleet-write/validate.mjs @@ -255,7 +255,7 @@ export function tokenRepositoriesOf(payload) { /** * The grants the relay's token must carry for a JUDGED payload: every entry of * `PERMISSIONS` but the stroke-scoped ones, plus each stroke-scoped grant some - * action's op spends. `{ …four grants }` for every stroke but a + * action's op spends. `{ …five grants }` for every stroke but a * `workflow_dispatch` one, which adds `actions: write`. Pure. */ export function tokenPermissionsOf(payload) { @@ -454,7 +454,7 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'the fields: request_id shape and cap, the one organization, the session id': 8, 'the actions: count, the closed op list, closed keys per op, typed values': 30, 'the platform ceilings: ten top-level properties and under 64KB, pinned with their source': 6, - 'refused by construction: no row reaches a merge, a review, a ref, contents, a workflow, a release or an org endpoint': 20, + 'refused by construction: no row reaches a merge, a review, a ref, contents, a workflow, a release or an org endpoint': 22, 'the normalised payload: only judged keys travel': 3, 'the CLI: a file, the environment, GitHub outputs, and the exit ladder': 10, 'the transfer row: an issue never a pull, a target from the governed roster never the source, a stroke of its own to one target, labels never created': 12, @@ -633,14 +633,18 @@ export async function selfTest() { t("⛔ exactly ONE descriptor reads an earlier answer — pr_create's assignees leg: a POST to the created pull's issue-assignees path at the placeholder the executor fills from the create's number, carrying the logins the answer must list", legs.map((r) => [r.op, r.verb, r.path, r.fromAnswer, r.body, r.assigned]), [['pr_create', 'POST', `/repos/${TARGET_OWNER}/objectstack/issues/${answerPlaceholder('number')}/assignees`, 'number', { assignees: ['u'] }, ['u']]]); t('…and it is never the FIRST request of its action — the answer it reads exists before it is sent', OP_NAMES.filter((op) => { const a = { op }; for (const k of [...OPS[op].required, ...OPS[op].optional]) a[k] = sample[k]; return OPS[op].requests(a, 'o/r')[0].fromAnswer !== undefined; }), []); t('…its placeholder is the one spelling the executor fills, spelled once, and no path without fromAnswer carries a brace', [answerPlaceholder('number'), everyRequest.filter((r) => r.fromAnswer === undefined && /[{}]/.test(r.path)).map((r) => `${r.op} ${r.path}`)], ['{number}', []]); - t('every grant a row ALSO spends is a literal mint input of PERMISSIONS, never a stroke-scoped one — so the mint widens nothing for it; pr_create\'s issues write is the one such row', [OP_NAMES.flatMap((op) => OPS[op].alsoSpends ?? []).every((name) => name in PERMISSIONS && !STROKE_SCOPED_PERMISSIONS.includes(name)), OP_NAMES.filter((op) => OPS[op].alsoSpends).map((op) => [op, [...OPS[op].alsoSpends]])], [true, [['pr_create', ['issues']]]]); + t('every grant a row ALSO spends is a literal mint input of PERMISSIONS, never a stroke-scoped one — so the mint widens nothing for it; pr_create\'s issues write and automerge_enable\'s workflows write are the two such rows', [OP_NAMES.flatMap((op) => OPS[op].alsoSpends ?? []).every((name) => name in PERMISSIONS && !STROKE_SCOPED_PERMISSIONS.includes(name)), OP_NAMES.filter((op) => OPS[op].alsoSpends).map((op) => [op, [...OPS[op].alsoSpends]])], [true, [['pr_create', ['issues']], ['automerge_enable', ['workflows']]]]); t('pr_create without assignees is the one create request it always was — no second leg', OPS.pr_create.requests({ op: 'pr_create', title: 't', head: 'h', base: 'b' }, 'o/r').length, 1); t('labels_remove is one directed DELETE per name, URL-encoded, idempotent on 404', OPS.labels_remove.requests({ op: 'labels_remove', issue: 1, labels: ['a b', 'c'] }, 'o/r').map((r) => [r.verb, r.path, r.idempotent404]), [['DELETE', '/repos/o/r/issues/1/labels/a%20b', true], ['DELETE', '/repos/o/r/issues/1/labels/c', true]]); t('every op names a permission the token is narrowed to', OP_NAMES.every((op) => OPS[op].permission in PERMISSIONS)); // The permission map, pinned entry by entry: the auto-merge mutations need `contents: write` on the // App token (measured live: without it, "Resource not accessible by integration"), and NOTHING else may - // spend it — the table has no contents op, so the grant reaches exactly two mutations. - t('the map is exactly issues/pull-requests/contents/actions write and metadata read', PERMISSIONS, { issues: 'write', 'pull-requests': 'write', contents: 'write', metadata: 'read', actions: 'write' }); + // spend it — the table has no contents op, so the grant reaches exactly two mutations. `workflows: write` + // is the maintainer's ruling (comment 6104822731): a literal grant on every stroke, spent by the + // `automerge_enable` row alone (`alsoSpends`) when the pull it arms changes a workflow file. + t('the map is exactly issues/pull-requests/contents/workflows/actions write and metadata read', PERMISSIONS, { issues: 'write', 'pull-requests': 'write', contents: 'write', workflows: 'write', metadata: 'read', actions: 'write' }); + t('workflows is spent by automerge_enable alone, as alsoSpends, and is no row\'s permission', [OP_NAMES.filter((op) => (OPS[op].alsoSpends ?? []).includes('workflows')), OP_NAMES.filter((op) => OPS[op].permission === 'workflows')], [['automerge_enable'], []]); + t('nothing wider: every PERMISSIONS key but metadata (the sender gate\'s read) is spent by some row as its permission or an alsoSpends, and the two sets are exactly actions/contents/issues/pull-requests/workflows', [Object.keys(PERMISSIONS).filter((name) => name !== 'metadata').sort(), [...new Set(OP_NAMES.flatMap((op) => [OPS[op].permission, ...(OPS[op].alsoSpends ?? [])]))].sort()], [['actions', 'contents', 'issues', 'pull-requests', 'workflows'], ['actions', 'contents', 'issues', 'pull-requests', 'workflows']]); t('the two auto-merge rows spend contents', [OPS.automerge_enable.permission, OPS.automerge_disable.permission], ['contents', 'contents']); t('⛔ …and no other row does: contents reaches the two auto-merge mutations and nothing else', OP_NAMES.filter((op) => OPS[op].permission === 'contents').sort(), ['automerge_disable', 'automerge_enable']); t('every row spending contents is a GraphQL auto-merge mutation, never a REST path', OP_NAMES.filter((op) => OPS[op].permission === 'contents').every((op) => OPS[op].requests({ op, pull: 1 }).every((r) => r.graphql && /PullRequestAutoMerge$/.test(r.graphql.mutation)))); @@ -783,7 +787,7 @@ export async function selfTest() { t('…and actions is spent by the workflow_dispatch row and NOTHING else', OP_NAMES.filter((op) => OPS[op].permission === 'actions'), ['workflow_dispatch']); const plain = validatePayload(fixturePayload()).payload; const run = validatePayload(one(start())).payload; - t('the token grants: a comment stroke carries the four literal grants and no actions; a workflow_dispatch stroke adds actions write', [tokenPermissionsOf(plain), tokenPermissionsOf(run)], [{ issues: 'write', 'pull-requests': 'write', contents: 'write', metadata: 'read' }, { issues: 'write', 'pull-requests': 'write', contents: 'write', metadata: 'read', actions: 'write' }]); + t('the token grants: a comment stroke carries the five literal grants and no actions; a workflow_dispatch stroke adds actions write', [tokenPermissionsOf(plain), tokenPermissionsOf(run)], [{ issues: 'write', 'pull-requests': 'write', contents: 'write', workflows: 'write', metadata: 'read' }, { issues: 'write', 'pull-requests': 'write', contents: 'write', workflows: 'write', metadata: 'read', actions: 'write' }]); t('…and the GitHub output spells that as permission_actions=write for the run stroke, EMPTY for every other — the mint action skips an empty input', [githubOutputLines(run).split('\n').at(-2), githubOutputLines(plain).split('\n').at(-2), [...STROKE_SCOPED_PERMISSIONS]], ['permission_actions=write', 'permission_actions=', ['actions']]); // The fence on the grant: every path every row can spell, against the actions families — the dispatch call is the ONE // actions path reached; and a POSITIVE CONTROL that the families would catch what actions: write otherwise reaches.