Skip to content

Commit 048f389

Browse files
feat(pm/fleet-write): the relay token carries workflows write on every stroke, spent by automerge_enable on a pull that changes a workflow file (#22827)
Fixes #22708 Clause-②: no **Ruling A** on the card (comment 6104822731; maintainer 「22708 A」): the fleet relay's App token carries `workflows: write` on every stroke, so a seat may enqueue a pull that changes `.github/workflows/**`. This PR is that change, and it is itself the last workflow-file pull a human enqueues: it changes `fleet-write.yml`, so the token it widens cannot land it. **Who wrote it, and under which rule.** The PM seat itself (skills seat 2, `session_011u73oxZ5X95qrARPTeoU6v`), not an os-dev, under the PM red line's one exception: a maintainer's explicit per-PR authorization of an internal-tooling PR. The authorization, verbatim, from the maintainer in this seat's session chat at 2026-10-11T10:52Z: 「22708 你可以处理:执行 #22708 裁决 A:按该卡开发报告(评论 6106463015)里 deviations D1、D2 的说明,修改 scripts/pm/fleet-write/ops.mjs、.github/workflows/fleet-write.yml、scripts/pm/fleet-write/validate.mjs。跑 validate.mjs、execute.mjs、dispatch.mjs 的 --self-test 和相关 gates,然后开 draft PR。」 The first dev's attempt (report 6106463015) was stopped by the session's permission classifier at the `validate.mjs` pins; its measured premises and its prepared `ops.mjs` + `fleet-write.yml` patch are used here unchanged. ## What changed - `scripts/pm/fleet-write/ops.mjs`: `PERMISSIONS` gains `workflows: 'write'` between `contents` and `metadata` (the order the mint's literal list and the pins compare); the `automerge_enable` row gains `alsoSpends: Object.freeze(['workflows'])`; the header's permission paragraph, the `PERMISSIONS` JSDoc, the `alsoSpends` field doc and the transfer section's "one widening" sentence are updated, and the lowered human floor is recorded beside the grant with the ruling's comment id. `STROKE_SCOPED_PERMISSIONS` is unchanged: the grant is literal, not per stroke. - `.github/workflows/fleet-write.yml`: the mint step declares the literal `permission-workflows: write` after `permission-contents`, with a ONE-consumer comment; the header's grant list and the two "four grants" sentences read five. - `scripts/pm/fleet-write/validate.mjs`: the alsoSpends pin expects `[['pr_create', ['issues']], ['automerge_enable', ['workflows']]]`; the map pin and the token-grants pin carry the fifth grant; two new cases in the refused-by-construction battery (floor 20 to 22): `workflows` is spent by `automerge_enable` alone as `alsoSpends` and is no row's `permission`; `PERMISSIONS` minus `metadata` equals the union of every row's `permission` and `alsoSpends`, both exactly `actions / contents / issues / pull-requests / workflows`. The `tokenPermissionsOf` doc comment says five. - `.claude/skills/pm-dispatch/references/landing-operations.md:67` (Tier S; one line for one line, 117 bytes; the file stays 101/101): diagnostic step ③ now reads the token as carrying `workflows: write` by the ruling, so a refusal there is a grant drift (the App's accepted permissions), not a missing grant. - ⛔ No new gate. `.claude/skills/pm-dispatch/SKILL.md:263` (the human-floor sentence) is untouched. ## Verification (head `db16cfc7d`; base `b7cd1af9d`, `origin/main` when the worktree was cut) - Red-before control: with only `PERMISSIONS` widened and the pins at base, `node scripts/pm/fleet-write/validate.mjs --self-test` exits 1, "3 of 128 case(s) failed" (the alsoSpends pin, the map pin, the token-grants pin). After the pins: 130 cases pass across 10 batteries. `execute.mjs --self-test` 111 cases and `dispatch.mjs --self-test` 216 cases, exit 0; `pnpm check:pm-fleet-write-validate`, `-execute`, `-dispatch` exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` from the worktree with no paths derived 61 commands at `b7cd1af9d`; every one ran alone with its exit captured before any pipe (the tool's documented idiom); the reconciliation line: `✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 61 run, 0 NOT-MEASURED (a DERIVED zero — all 61 recorded an exit code and none of them is 3).` - `pnpm --filter @objectstack/lint run check:doc-formula-expressions` first answered exit 3 (PREREQUISITE NOT MET: `@objectstack/formula` and `@objectstack/lint` unbuilt); the `@objectstack/lint...` closure was built under the verify lock (VERDICT command-exit 0, 141 s) and the rerun exits 0. `pnpm check:pm-dispatch-gates` (the long battery) exit 0. - `check-skill-line-ratchet`: `landing-operations.md is 101 lines (ceiling 101; headroom 0)`. `check:pm-skill-id-lint`, `check:nul-bytes`, `check:pm-governed-merges`, `check:pm-governed-prose`, `check:skill-frame-sync`, `check:required-contexts`, `check-self-test-wired`, `check-self-test-workflow-commands`, `check-whole-set-label-write`, `check-scripts-symbol-anchors`, `check-ci-filter-parity` exit 0 (all in the 61). - No package source changes, so no package build or test is owed beyond the gates. Changeset: none; `scripts/pm/**`, `.github/**` and `.claude/**` ship in no package's `files[]`, so the PR takes `skip-changeset`. ## Premises (report 6106463015, re-read on this base) - A1: `actions/create-github-app-token@v3` declares the `permission-workflows` input (`action.yml:143`, read at the tag ref; the sha read was refused by the proxy). - A3: `execute.mjs` has no token-grant surface (`PERMISSIONS` imported at `:164`, used nowhere), so the pin lives in `validate.mjs`. - `REFUSED_PATH_FAMILIES` carries `/\/git\//` and `/\/contents\//`, so the wider token reaches no file write through any row; the refused-by-construction battery pins that over every path every op can spell. ## Risk the maintainer checks before this merges (NOT MEASURED from the seat) The mint step requests `permission-workflows: write` on EVERY relay stroke. If the objectstack-fleet App's registration, or the organization installation's accepted permissions, lacks **Workflows: Read and write**, `actions/create-github-app-token` fails on every stroke: every relay write stops, not only this class of pull. `/app/**` is unreachable from seat egress, so this cannot be read here. Check: the App's Repository permissions show Workflows = Read and write, and the installation on `objectstack-ai` has accepted that change. The first relay stroke after the merge is the measurement; if the mint fails, revert this PR. ## Acceptance notes - NOT MEASURED, owed on #22708 after the merge: the first pull the widened token enqueues. - `pr_update_branch` on a pull whose base brought workflow-file changes may also need `workflows` (the platform's merge commit changes workflow files on the head); the literal grant covers it either way, so no `alsoSpends` beyond the ruling's row. - `scripts/pm/fleet-write/execute.mjs:164` imports `PERMISSIONS` and never uses it (a dead import; noted, not filed). ## 维护者速读(草稿) **改了什么**:车队中继(fleet-write)铸出的 App 令牌在每一笔写上常驻 `workflows: write`;`automerge_enable` 行登记它花费这项授权(改 `.github/workflows/**` 的 PR 入队需要);validate 自测钉死:授权集与 workflow 字面输入一致、`workflows` 只由该行花费、授权集不比各行所花更宽。另把 `landing-operations.md` 里「token 缺 workflows 权限」那条诊断改成「令牌常驻该权限,仍拒即授权漂移」。不加门禁。 **为什么改**:你裁了 A(「22708 A」):以后改 CI 工作流文件的 PR 由席位中继入队,不再每张等人点一下。本 PR 自己也改了工作流文件,所以它是最后一张需要人点入队的。 **风险与代价(含回滚)**:最大风险在 App 侧:铸币会在每一笔申请 `permission-workflows: write`;若 App 注册或组织安装没接受 Workflows 读写权限,每一笔中继写都在铸币处失败,整个中继停摆,不只这一类 PR。席位测不了这项(到不了 `/app/**`)。回滚 = revert 本 PR。代价:令牌权限面永久多一项(可改写工作流文件的能力),安全/权限边界的人工地板为这一项降低,出处已写在代码旁。 **席位意见**:(席位在评论里定稿) **你要做的**:两步。① 先确认 objectstack-fleet App 的 Repository permissions 里 Workflows 为 Read and write,且组织安装已接受;② 然后点一次 ready + auto-merge(或入队)。合并后第一笔中继写就是实测,失败即 revert。 --- _Generated by [Claude Code](https://claude.ai/code/session_011u73oxZ5X95qrARPTeoU6v)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 990341a commit 048f389

4 files changed

Lines changed: 55 additions & 24 deletions

File tree

‎.claude/skills/pm-dispatch/references/landing-operations.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
- ready + 全绿 ≠ 已入队:队列从不主动拉 PR,入队是显式动作。
6565
- 零 `enqueued` 事件按序查三条:① `mergeable_state` 是否 `dirty`。
6666
- ② enable-auto-merge 调用根本没落地,重发与效果验证序列见 `platform-readings.md`。
67-
- ③ PR 碰 `.github/workflows/**` 而 token 缺 workflows 权限。
67+
- ③ PR 碰 `.github/workflows/**`:令牌常驻 `workflows: write`(裁决评论 6104822731),仍拒即授权漂移。
6868
- 确认 MERGED 同一动作里给 `Part of` 卡收口、`Fixes` 卡代关但标也须摘,⛔ 不拆到下轮巡检。
6969
- 卡以 completed 关闭且带 `Adopts:` 行 ⇒ 同一动作在所指 fork PR 贴致谢与落地链接并关闭它。
7070
- `Part of` 卡开着不摘 `pm:dispatched`,无在飞物的卡就永远算进 `label:pm:dispatched is:open`。

‎.github/workflows/fleet-write.yml‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,11 @@
2020
# alone, that transfer's one target too: `transferIssue` needs issues write on
2121
# both ends) and to the permissions the op table declares (`issues: write`,
2222
# `pull-requests: write`, the `contents: write` GitHub's auto-merge mutations
23-
# require, and the `metadata: read` the sender gate's permission read needs;
23+
# require, the `workflows: write` that arming auto-merge on a pull changing a
24+
# workflow file requires, and the `metadata: read` the sender gate's permission read needs;
2425
# plus, for a `workflow_dispatch` stroke ALONE, the `actions: write` the
2526
# dispatch endpoint needs — minted from the validator's per-stroke output, so
26-
# every other run's token is exactly the four grants above)
27+
# every other run's token is exactly the five grants above)
2728
# — and revoked by that action's post step. The workflow's own `GITHUB_TOKEN` gets nothing
2829
# (`permissions: {}` below); the job grants it `contents: read` for the
2930
# checkout and nothing else, so the run's ONLY write path is the App token,
@@ -123,13 +124,20 @@ jobs:
123124
# The op table in `scripts/pm/fleet-write/ops.mjs` has NO contents op — no file, ref
124125
# or branch write — so this grant is spent only by those two mutations.
125126
permission-contents: write
127+
# ONE consumer: `automerge_enable` on a pull that changes `.github/workflows/**` —
128+
# GitHub refuses to enqueue such a pull for an App token without `workflows` (measured:
129+
# "refusing to allow a GitHub App to create or update workflow … without `workflows`
130+
# permission"). Literal on every stroke by the maintainer's ruling (comment 6104822731).
131+
# It also reaches workflow-file writes through contents and git endpoints; the op table
132+
# has no row for either, and `validate.mjs --self-test` pins that none is reachable.
133+
permission-workflows: write
126134
permission-metadata: read
127135
# ONE consumer: the `workflow_dispatch` row — `POST .../actions/workflows/{file}/dispatches`
128136
# on an allowlisted file at `main`. Minted ONLY for a stroke carrying that op: the validator
129137
# writes `permission_actions=write` for such a stroke and an EMPTY value for every other,
130138
# and `actions/create-github-app-token` skips an empty `permission-*` input (its
131139
# `lib/get-permissions-from-inputs.js`: an empty value leaves the set unchanged, measured
132-
# at the v3 tag), so every other run keeps exactly the four grants above. `actions: write`
140+
# at the v3 tag), so every other run keeps exactly the five grants above. `actions: write`
133141
# also reaches cancel, re-run, delete, cache and artifact endpoints; the op table has no
134142
# row for any of them, and `validate.mjs --self-test` pins that none is reachable.
135143
permission-actions: ${{ steps.validate.outputs.permission_actions }}

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

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,21 @@
9191
* "Resource not accessible by integration" — and those two rows are the ONLY
9292
* spenders of it: the table has no contents op (no direct file, ref or branch
9393
* write; `pr_update_branch` is the platform's own merge, `pull_requests: write`
94-
* in that ledger); the one workflow-run dispatch (`workflow_dispatch`, below)
94+
* in that ledger); arming auto-merge on a pull that changes
95+
* `.github/workflows/**` also requires `workflows: write` — measured: the
96+
* relay's `automerge_enable` on such a pull answered "refusing to allow a
97+
* GitHub App to create or update workflow … without `workflows` permission" —
98+
* so that row spends it (`alsoSpends`), the one row that does; it is a
99+
* literal grant on every stroke, and a seat may enqueue a pull that changes a
100+
* workflow file. The human floor of pm-dispatch `SKILL.md` (security and
101+
* permission boundaries stay a human's) is lowered for this ONE boundary by
102+
* the maintainer's own ruling, comment 6104822731 (「22708 A」), so this grant
103+
* is not a drift. The grant also reaches workflow-file writes through
104+
* `/contents/**` and `/git/**`; `REFUSED_PATH_FAMILIES` refuses both for every
105+
* row. The one workflow-run dispatch (`workflow_dispatch`, below)
95106
* is `actions: write`, the one row spending it; the sender gate's
96107
* `GET /repos/{o}/{r}/collaborators/{login}/permission` is `metadata: read`.
97-
* `PERMISSIONS` is that set and nothing wider. The workflow declares the four
108+
* `PERMISSIONS` is that set and nothing wider. The workflow declares the five
98109
* grants every stroke gets as literal `permission-*` inputs on
99110
* `actions/create-github-app-token`, and the one grant a stroke gets only when
100111
* it spends it (`STROKE_SCOPED_PERMISSIONS`) as an input read from the
@@ -130,7 +141,7 @@
130141
* endpoint) needs `issues: write` on the repository the card is in AND on the
131142
* one it moves to, so a transfer stroke's token is minted for both. That is
132143
* the only widening of the token's repository reach the relay makes (the one
133-
* widening of its permissions is the next section), and it is fenced four ways: the target is
144+
* per-stroke widening of its permissions is `workflow_dispatch`'s, above), and it is fenced four ways: the target is
134145
* a closed enum — `TRANSFER_TARGETS`, the fleet's governed-repository roster
135146
* reused from `check-governed-merges.mjs`, never a second roster and never
136147
* free text; the target is never the source; a stroke carrying a transfer
@@ -284,14 +295,18 @@ export const FIELDS = Object.freeze({
284295

285296
/**
286297
* The App permissions the relay token is narrowed to — the union of every
287-
* row's `permission`, plus the sender gate's read. `contents: write` is spent
288-
* by the two auto-merge rows alone (GitHub's requirement for those mutations);
289-
* no row writes a file, a ref or a branch itself — `pr_update_branch`'s merge
290-
* is the platform's, under `pull-requests`. `actions: write` is spent by the
291-
* `workflow_dispatch` row alone, and minted only for a stroke carrying it
292-
* (`STROKE_SCOPED_PERMISSIONS`).
298+
* row's `permission` and `alsoSpends`, plus the sender gate's read.
299+
* `contents: write` is spent by the two auto-merge rows alone (GitHub's
300+
* requirement for those mutations); no row writes a file, a ref or a branch
301+
* itself — `pr_update_branch`'s merge is the platform's, under
302+
* `pull-requests`. `workflows: write` is spent by the `automerge_enable` row
303+
* alone (`alsoSpends`: a pull changing `.github/workflows/**` is enqueued only
304+
* by a token carrying it), a literal grant on every stroke by the
305+
* maintainer's ruling, comment 6104822731 (header). `actions: write` is spent
306+
* by the `workflow_dispatch` row alone, and minted only for a stroke carrying
307+
* it (`STROKE_SCOPED_PERMISSIONS`).
293308
*/
294-
export const PERMISSIONS = Object.freeze({ issues: 'write', 'pull-requests': 'write', contents: 'write', metadata: 'read', actions: 'write' });
309+
export const PERMISSIONS = Object.freeze({ issues: 'write', 'pull-requests': 'write', contents: 'write', workflows: 'write', metadata: 'read', actions: 'write' });
295310

296311
/**
297312
* The permissions minted ONLY for a stroke carrying an op that spends them —
@@ -406,10 +421,11 @@ export function transferRemedy(source, target) {
406421
* must reach — `transfer`'s alone (header above);
407422
* `alone` (optional) true for a row whose stroke carries exactly that
408423
* one action — `workflow_dispatch`'s alone (header above);
409-
* `alsoSpends` (optional) grants a LATER request of the row spends beside
410-
* `permission` — each a literal mint input, never a
411-
* stroke-scoped one (`validate.mjs --self-test` pins it) —
412-
* `pr_create`'s assignees leg alone (header above);
424+
* `alsoSpends` (optional) grants the row spends beside `permission` —
425+
* each a literal mint input, never a stroke-scoped one
426+
* (`validate.mjs --self-test` pins it): `pr_create`'s
427+
* assignees leg, a LATER request, and `automerge_enable` on
428+
* a pull that changes `.github/workflows/**` (header above);
413429
* `requests` the request descriptors the executor issues, in order:
414430
* `{ verb, path, body?, idempotent404?, graphql?, updateBranch?, fromAnswer?, assigned? }`
415431
* — a `graphql` descriptor names the mutation and the pull (or
@@ -537,8 +553,11 @@ export const OPS = Object.freeze({
537553
}),
538554
// The two auto-merge mutations spend `contents` — GitHub requires `contents: write`
539555
// on the token for enable/disablePullRequestAutoMerge, over and above `pull-requests`.
556+
// A pull changing `.github/workflows/**` needs `workflows: write` on the enqueuing token
557+
// too — a literal grant on every stroke, by the maintainer's ruling (comment 6104822731).
540558
automerge_enable: Object.freeze({
541559
permission: 'contents',
560+
alsoSpends: Object.freeze(['workflows']),
542561
required: Object.freeze(['pull']),
543562
optional: Object.freeze([]),
544563
requests: (a) => [{ verb: 'POST', path: '/graphql', graphql: { mutation: 'enablePullRequestAutoMerge', query: PR_MUTATIONS.automerge_enable, pull: a.pull, landed: PR_LANDED_STATE.automerge_enable } }],

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

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ export function tokenRepositoriesOf(payload) {
255255
/**
256256
* The grants the relay's token must carry for a JUDGED payload: every entry of
257257
* `PERMISSIONS` but the stroke-scoped ones, plus each stroke-scoped grant some
258-
* action's op spends. `{ …four grants }` for every stroke but a
258+
* action's op spends. `{ …five grants }` for every stroke but a
259259
* `workflow_dispatch` one, which adds `actions: write`. Pure.
260260
*/
261261
export function tokenPermissionsOf(payload) {
@@ -454,7 +454,7 @@ const SELF_TEST_BATTERIES = Object.freeze({
454454
'the fields: request_id shape and cap, the one organization, the session id': 8,
455455
'the actions: count, the closed op list, closed keys per op, typed values': 30,
456456
'the platform ceilings: ten top-level properties and under 64KB, pinned with their source': 6,
457-
'refused by construction: no row reaches a merge, a review, a ref, contents, a workflow, a release or an org endpoint': 20,
457+
'refused by construction: no row reaches a merge, a review, a ref, contents, a workflow, a release or an org endpoint': 22,
458458
'the normalised payload: only judged keys travel': 3,
459459
'the CLI: a file, the environment, GitHub outputs, and the exit ladder': 10,
460460
'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() {
633633
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']]]);
634634
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; }), []);
635635
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}', []]);
636-
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']]]]);
636+
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']]]]);
637637
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);
638638
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]]);
639639
t('every op names a permission the token is narrowed to', OP_NAMES.every((op) => OPS[op].permission in PERMISSIONS));
640640
// The permission map, pinned entry by entry: the auto-merge mutations need `contents: write` on the
641641
// App token (measured live: without it, "Resource not accessible by integration"), and NOTHING else may
642-
// spend it — the table has no contents op, so the grant reaches exactly two mutations.
643-
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' });
642+
// spend it — the table has no contents op, so the grant reaches exactly two mutations. `workflows: write`
643+
// is the maintainer's ruling (comment 6104822731): a literal grant on every stroke, spent by the
644+
// `automerge_enable` row alone (`alsoSpends`) when the pull it arms changes a workflow file.
645+
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' });
646+
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'], []]);
647+
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']]);
644648
t('the two auto-merge rows spend contents', [OPS.automerge_enable.permission, OPS.automerge_disable.permission], ['contents', 'contents']);
645649
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']);
646650
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() {
783787
t('…and actions is spent by the workflow_dispatch row and NOTHING else', OP_NAMES.filter((op) => OPS[op].permission === 'actions'), ['workflow_dispatch']);
784788
const plain = validatePayload(fixturePayload()).payload;
785789
const run = validatePayload(one(start())).payload;
786-
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' }]);
790+
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' }]);
787791
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']]);
788792
// The fence on the grant: every path every row can spell, against the actions families — the dispatch call is the ONE
789793
// actions path reached; and a POSITIVE CONTROL that the families would catch what actions: write otherwise reaches.

0 commit comments

Comments
 (0)