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
Original file line number Diff line number Diff line change
Expand Up @@ -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`。
Expand Down
14 changes: 11 additions & 3 deletions .github/workflows/fleet-write.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }}
Expand Down
47 changes: 33 additions & 14 deletions scripts/pm/fleet-write/ops.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 } }],
Expand Down
16 changes: 10 additions & 6 deletions scripts/pm/fleet-write/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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))));
Expand Down Expand Up @@ -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.
Expand Down
Loading