Repository navigation
Commit dee7692
feat(pm/fleet-write): a workflow_dispatch op behind a closed allowlist, actions write minted for that stroke alone (#22460)
Fixes #22369
Maintainer ruling, verbatim: 「22369 允许 actions: write」 (ruling record
6078120063; the triage seat's relay of the same answer and the build
direction: 6077935878). This PR builds that option A: ONE relay op that
starts a run of an allowlisted workflow at `main`, `actions: write`
minted for exactly the stroke that carries it, a self-test pinning that
no other `actions/*` path is reachable from the op table, and one line
in the skill table saying when a seat may start a run.
## What changed
- `scripts/pm/fleet-write/ops.mjs` — ONE new row `workflow_dispatch` at
the END of `OPS` (after `transfer`): `workflow` and `ref` required,
nothing optional, permission `actions`, `alone: true`.
`WORKFLOW_DISPATCH_ALLOWLIST =
Object.freeze(['shard-timings-refresh.yml'])`, `WORKFLOW_DISPATCH_REF =
'main'`, both wired as closed enums in `FIELDS`. `PERMISSIONS` gains
`actions: 'write'`; `STROKE_SCOPED_PERMISSIONS = ['actions']` names it
as the grant minted only when spent. `REFUSED_PATH_FAMILIES` replaces
the blanket `/actions/workflows` family with a catch-all that excepts
exactly the dispatch call, plus the run / job / cache / artifact /
enable-disable / secret-variable-runner families named one by one.
- `scripts/pm/fleet-write/validate.mjs` — the row is judged like every
other op (closed keys, enums), plus the stroke rule: a stroke carrying a
`workflow_dispatch` carries exactly that one action.
`tokenPermissionsOf(payload)` computes the stroke's grant set;
`--github-output` now also writes `permission_actions=write` (a run
stroke) or `permission_actions=` (every other stroke). The self-test
gains one battery (18 cases) and the mint pin now holds both spellings.
- `scripts/pm/fleet-write/execute.mjs` — no new branch: the row is an
ordinary REST POST. A bodiless 204 now reads `accepted (HTTP 204, no
body)` in the row instead of `ok`. One new battery (7 cases): the POST
and its body, the bearer, 204 landing, 422/403 failing, no other
`actions/*` request leaving, no annotation.
- `scripts/pm/fleet-write/dispatch.mjs` — the started run is read back
on the SEAT side after a success run: `RUN_OPS` (derived from the table:
every op naming a `workflow`), `readBackRuns` lists `GET
/repos/{repo}/actions/workflows/{file}/runs?event=workflow_dispatch&branch=main&per_page=5`
and takes the newest run created since the dispatch, re-reading on the
poll interval for `RUN_READ_BACK_WINDOW_MS` (60 s) before `unfound`;
found is `landed` and travels in `result.runs` and `--json` `runs`;
`unread` or `unfound` is `unverified`, exit 6 (UNCONFIRMED, the existing
vocabulary — a second dispatch is a second run). One new battery (11
cases).
- `.github/workflows/fleet-write.yml` — the mint step gains
`permission-actions: ${{ steps.validate.outputs.permission_actions }}`
and nothing else; header and step comments say why.
- `.claude/skills/pm-dispatch/references/rest-channel.md` — ONE line
added in the write-side table (when a seat may start a run; a workflow
joins the allowlist only by its own PR), paid by deleting ONE line that
restated the quota readings whose home is `platform-readings.md`. 82
lines before, 82 after (ceiling 82/82 untouched).
## Least exposure — the mint is conditioned per stroke (assumption 1,
measured feasible)
`actions/create-github-app-token@v3` collects its `permission-*` inputs
in `lib/get-permissions-from-inputs.js`, whose line 11 reads `if
(!value) return permissions;` — an EMPTY input is skipped. Measured on a
read-only clone of `actions/create-github-app-token`: tag `v3` resolves
to commit `bcd2ba49218906704ab6c1aa796996da409d3eb1` (package version
3.2.0), the file is byte-identical at `v3` and at the default branch. So
the validator's `$GITHUB_OUTPUT` line `permission_actions=write` (a
`workflow_dispatch` stroke) or `permission_actions=` (every other
stroke) is enough: one mint step, one expression input, every other run
keeps exactly today's four grants. No second mint step, no `if:` fork.
Four-axis reading of the two shapes considered (A: one step with an
expression input, chosen; B: two mint steps selected by `if:`):
- Business need: both serve the same measured pull (a seat whose unblock
is a run); A costs one line, B duplicates the whole mint block.
- Long-run soundness: A keeps the permission table in ONE place
(`ops.mjs`), read by the validator and pinned against the workflow file
by the self-test; B would put the grant set in two YAML blocks the pin
has to reconcile.
- Preventing AI error: A's failure mode (the action's empty-input
handling changing) is pinned at the one line that depends on it and
would surface as a loud 403 on the dispatch, never as a wider token on a
plain stroke; B's failure mode (the two steps drifting) is silent
widening.
- Startup-stage focus: A adds no new mechanism; B adds a second mint
path to keep green.
## The four fences on `actions: write`
1. `workflow` is a closed enum of bare file names (one entry today),
matched whole — a path, a dot-dot path, a glob and an off-list name are
each refused by name (pinned).
2. `ref` is a closed enum of exactly `main`; it is required on every
request, never defaulted.
3. A stroke carrying `workflow_dispatch` carries exactly that one action
— the wider token serves one request and nothing beside it (the same
shape `transfer` already has for its second repository).
4. The grant is minted only for such a stroke (above), and the op table
reaches no other `actions/*` path: the catch-all family plus the named
families, with a positive control of 19 cancel / re-run / delete / log /
approve / cache / artifact / enable / disable / secret / variable /
runner / sub-path spellings each caught.
`inputs` is deliberately NOT a key (assumption 6, falsified on its
optional half): the one allowlisted workflow declares a bare
`workflow_dispatch:` (`.github/workflows/shard-timings-refresh.yml`, no
`inputs:`), and GitHub answers 422 to inputs on a workflow that declares
none — so an `inputs` key today has zero consumers and one failure mode.
A workflow with inputs joins the allowlist by the PR that also spells
its inputs' closed shape here. An `inputs` key on the action is refused
by name (pinned).
## Assumptions 2–5, as measured
- 2 (table position): appended after `transfer`, the table's end. Shared
files with the in-flight sibling: #22052 is not addressed here; its
branch is at `origin/main` with no PR open at the time of writing, so
there is nothing yet to merge behind — serial landing stays as
dispatched (this PR merges `origin/main` after that PR lands and before
its ready flip).
- 3 (read-back): `ANNOTATED_OPS` is NOT used for the run id. Its
contract is a number and url read from the platform's ANSWER to the
request, and `POST …/dispatches` answers 204 with no body — there is no
answer to annotate from; its parser also admits issue urls only. The
seat lists the workflow's runs itself (a read the seat container can
make: `GET …/actions/runs` is on the readable list), bounded,
UNCONFIRMED when not listed. Exit semantics reuse `exitForResult`
unchanged (`unverified` → 6).
- 4 (permission field): `actions` joins `PERMISSIONS`; `execute.mjs` has
no per-permission branch to extend (it imports `PERMISSIONS` only for
its header's sake, pre-existing), and the mint step reads the table
through the validator's output. The permission vocabulary stays the
validator's pin: `every op names a permission the token is narrowed to`
still holds with the fifth entry.
- 5 (line budget): rest-channel.md 82 → 82. Added: `- ✓ 启动 run 走 op
workflow_dispatch:解锁物是白名单 run 才启;入表只经 workflow 自己的 PR。` (114 bytes,
under the 120-byte line rule). Deleted: the former line 10, `两只桶:MCP
记链接用户 5000/时,兄弟会话共享同桶;REST/CCR 记 App 安装 15000/时。` — a restatement of
readings that live in `platform-readings.md`'s quota section (5000/h at
its line 99 and 137, 15000/h at its lines 118 and 131; per-account
bucketing at its line 93), which that file's own line 139 says is the
one home. No re-wrap; no ceiling moved; no issue number in the operative
text (`check:pm-skill-id-lint` green).
## Verification
Self-tests (each `node scripts/pm/fleet-write/{file}.mjs --self-test`,
exit captured before any pipe):
- validate: `✓ 114 cases pass across 10 batteries` (was 93 across 9)
- execute: `✓ 90 cases pass across 13 batteries` (was 83 across 12)
- dispatch: `✓ 211 cases pass across 19 batteries` (was 200 across 18)
Reverse verification — four ablations through
`scripts/ablation-replace.mjs` on the committed tree (each: anchor hit
exactly once, blob hash changed on disk, the command run under the
mutation, restore proven by blob == HEAD and an empty `git diff HEAD`):
| leg | mutation | observed |
|---|---|---|
| A | delete `alone: true` on the row | validate self-test red, 4 of 114
— the two stroke refusals, the row pin, the alone/widen roster |
| B | delete the `actions/*` catch-all family | validate red, 1 of 114 —
the positive control names `workflows/shard-timings-refresh.yml` and the
`…/dispatches/x` sub-path as uncaught |
| C | spell the mint input as a literal `permission-actions: write` |
validate red, 2 of 114 — the literal map now carries `actions`, and no
input reads the validator's output |
| D | make the run read-back never match a candidate | dispatch red, 5
of 211 — found/once/candidate, the lagging list, the CLI `--json` run |
Gate union derived with `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` (no paths; the change set from the
merge base) on head `094be7b5`: 62 commands, each run byte for byte with
its exit recorded, then reconciled with `--ran`: `Run reconciliation —
62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. — ✓ dispatch-gates --ran:
62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED
zero — all 62 recorded an exit code and none of them is 3).`.
`check:doc-formula-expressions` first answered exit 3 (PREREQUISITE:
`@objectstack/formula` and `@objectstack/lint` not built), was rebuilt
under the verify lock and re-run green. The battery
`check:pm-dispatch-gates` (nohup, waited on with tail --pid): exit 0, `✓
dispatch-gates self-test: 2011 cases pass.`, `the battery took 1072.7s
on this box` (three agents' batteries ran concurrently). eslint over the
four scripts: exit 0.
NOT MEASURED: a live relay run of the new op. The relay executes from
`main`, so the first live `workflow_dispatch` stroke is the first
post-merge measurement (the seat that needs the run sends it; the
read-back names the run or answers UNCONFIRMED).
## Acceptance notes
- `execute.mjs` imports `PERMISSIONS` and never reads it (pre-existing,
lint-clean under the repo config); noted, not touched.
- The started-run read-back takes the NEWEST run created since the
dispatch and SAYS when more than one was created in that window; it does
not choose between them silently. A human click on the same workflow
within the same minute is the one case that could name the wrong run,
and the row carries the run's actor so a seat can see it.
- `scripts/pm/dispatch-gates.mjs` is frozen and untouched.
## 维护者速读(草稿)
### 改了什么
舰队中继多了一个 op `workflow_dispatch`:席位可以让 `objectstack-fleet[bot]` 启动白名单上的一个
workflow(目前只有 `shard-timings-refresh.yml`)在 `main`
上的一次运行。白名单是代码里的闭集常量,只能由改白名单的 PR 扩;`ref` 只认
`main`;一次请求只能带这一个动作。中继令牌只在带这个动作的那次运行里多铸 `actions:
write`,其它运行的令牌和今天完全一样。席位侧发完会自己回读「启动了哪个 run」,60 秒内没列出来就报 UNCONFIRMED、不重发。
### 为什么改
你裁了 A(「22369 允许 actions: write」)。此前只有 workflow run
才能产出的解锁物(如分片耗时数据集刷新)要等维护者手点或每周一的定时,席位无法自己启动;`actions: write`
又是一把能取消、重跑、删 run 的宽权限,所以这单的重点是把它关在一个 op、一个文件名、一个 ref、一次请求里,并用自测钉死 op
表碰不到任何别的 `actions/*` 路径。
### 风险与代价(含回滚)
风险:令牌多了 `actions: write`——但只在带 `workflow_dispatch` 的那一次中继运行里铸,且自测钉住它只能到达
dispatch 端点;若 `create-github-app-token` 改掉「空输入即跳过」的行为,后果是普通 stroke
也带上该权限,自测钉在 workflow 文件和 op 表上,但那一行行为改变本身只会在 action 升级时显现——值得在
dependabot 升该 action 时看一眼。代价:无发布面变动(`skip-changeset`),不改任何包。回滚:revert 本
PR 即可,白名单、权限、技能行一并回到今天;已启动的 run 不受影响。
### 席位意见
(留空,待席位定稿)
### 你要做的
这是 Tier S(触及 `.claude/**`):席内达档契约复核 PASS 后由席位入队,不需要你的点击。可选:dependabot
下次升 `actions/create-github-app-token` 时,确认
`lib/get-permissions-from-inputs.js` 仍对空输入直接返回(本 PR 在 v3 = 3.2.0 实测)。
---
_Generated by [Claude
Code](https://claude.ai/code/session_01JmWtcHfGbC4ncw4GFKWuRA)_
---------
Co-authored-by: objectstack-fleet[bot] <noreply@anthropic.com>
Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>1 parent f66c440 commit dee7692
6 files changed
Lines changed: 434 additions & 56 deletions
File tree
- .claude/skills/pm-dispatch/references
- .github/workflows
- scripts/pm/fleet-write
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
7 | 7 | | |
8 | 8 | | |
9 | 9 | | |
10 | | - | |
11 | 10 | | |
12 | 11 | | |
13 | 12 | | |
| |||
49 | 48 | | |
50 | 49 | | |
51 | 50 | | |
| 51 | + | |
52 | 52 | | |
53 | 53 | | |
54 | 54 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
23 | | - | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
24 | 27 | | |
25 | 28 | | |
26 | 29 | | |
| |||
84 | 87 | | |
85 | 88 | | |
86 | 89 | | |
87 | | - | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
88 | 93 | | |
89 | 94 | | |
90 | 95 | | |
| |||
119 | 124 | | |
120 | 125 | | |
121 | 126 | | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
122 | 136 | | |
123 | 137 | | |
124 | 138 | | |
| |||
0 commit comments