Skip to content

Run label hygiene when an issue or pull request closes - #1404

Merged
Birfy merged 1 commit into
mainfrom
dev3/1401-label-hygiene-close
Oct 27, 2026
Merged

Birfy merged 1 commit into
mainfrom
dev3/1401-label-hygiene-close

Conversation

@Birfy

@Birfy Birfy commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Written-by: Dev3
Owner: Dev3
Reviewer: Reviewer
Fixes #1401

Reproduce

On 06bed53b, .github/workflows/label-hygiene.yml fires only on schedule: 30 19 * * * and workflow_dispatch, so a status label survives the close until the next sweep — up to 24 hours — and the board reads finished work as in flight. Live sample from the issue: #1367 closed 07:02:40Z and still carried agent-pr. grep -n "on:" -A4 .github/workflows/label-hygiene.yml on 06bed53b shows exactly those two triggers.

What changed

  • .github/workflows/label-hygiene.yml (+10): added issues: types: [closed] and pull_request_target: types: [closed] to the same workflow, with a comment explaining why the PR side is pull_request_target — a pull_request run from a fork is read-only (gate-check.test.mjs already pins that rule for #812), so it could not delete the label it was triggered for. schedule, workflow_dispatch, concurrency (group: label-hygiene, cancel-in-progress: false), permissions (contents: read, issues: write, pull-requests: write) and the --apply step are unchanged: same script, same call.
  • .github/scripts/label-hygiene.test.mjs (+25): two cases. One closes an item carrying hold + agent-working + bug + area:automation and asserts only agent-working is deleted. One reads the workflow text and pins the two new triggers, the absence of a plain pull_request trigger, the retained schedule/workflow_dispatch, the unchanged permissions, the concurrency pair, and the single --apply step.

Guardrails on the new trigger

  • No pull request code is checked out or run. Both new events execute the workflow as defined on the default branch, and the job's only actions/checkout pins ref: main with persist-credentials: false — a literal branch name rather than an expression, so a fork's default branch can never be picked up — and no step reads github.event.pull_request.head.*. The script that runs is base .github/scripts/label-hygiene.mjs, unchanged.
  • One call, one queue, no new permission. issues: [closed] and pull_request_target: [closed] both run the single node .github/scripts/label-hygiene.mjs --apply step, under the existing concurrency: { group: label-hygiene, cancel-in-progress: false }, with permissions exactly contents: read, issues: write, pull-requests: write — nothing added. The test above asserts the permission list and the --apply line literally, so a later widening fails it.

What I ran

  • node --test .github/scripts/label-hygiene.test.mjs at 162d4bb5 → # tests 10 / # pass 10 / # fail 0.
  • node --test ".github/scripts/*.test.mjs" → # tests 540 / # pass 538 / # fail 0 (2 skipped, unchanged).
  • Parsed the edited YAML with js-yaml: on = ["schedule", "workflow_dispatch", "issues", "pull_request_target"].
  • Knives (each restored afterwards): removing the two trigger blocks fails #1401: closing an item is a trigger… with a closed issue runs the same cleanup; adding hold to STALE_LABELS fails both the existing keep-list case and the new hold case.

Red without the source change

git stash of the workflow hunk → not ok 10 - #1401: closing an item is a trigger, and the daily sweep stays, error: 'a closed issue runs the same cleanup'. Restore → 10/10. The hold case fails the same way if the keep-list widens: with hold added to STALE_LABELS, not ok 9 reports that hold was removed.

Not run

  • No live close-event run: the trigger only exists once this file is on main, so issues: closed cannot fire from this branch. After merge I will close one scratch issue that carries agent-working, report the issues run id with its before:/after: lines and the time between close and label removal (acceptance 2), then re-run the same event and report 0 items (acceptance 4); the same --apply path is already covered here by #738: a second run on a clean repository reports 0 items…. The script half it invokes is the one the daily sweep already runs (--apply, proven above and by its existing cases); the first real event after merge is the live proof, and I will watch the first close that lands and report the run id with its before:/after: lines.
  • No generate / ds-bot-patches check: this diff touches neither core/ nor patches/, so check's third line is main's and unchanged — the gate for this PR is tests-ok green at the tip.
  • No npm run verify (no core/ dependencies in this tree); nothing under core/, patches/, client.js or the plugins changes, so no build-client step applies.

Risk

Low. The close event runs the same idempotent sweep: before:/after: read back, 0 items on a clean repository, hold/bug/area:*/risk:*/needs-*/status:done untouched, and the five STALE_LABELS are unchanged. The one behavioural difference is pull_request_target instead of pull_request, which keeps the write token on the base repository and never checks out pull request code — the checkout step still pins ref: main. Concurrency keeps two close events from overlapping.

Docs-not-needed: workflow trigger plus its tests; no architecture or subsystem ownership change.

Tip SHA: 162d4bb

Checkpoint

{
  "pr": 1404,
  "claim": "Dev3",
  "issue": 1401,
  "summary": "The label-hygiene workflow now also runs when an issue or a pull request closes, through the same --apply the daily sweep uses. The pull-request side is pull_request_target because a fork's pull_request run has a read-only token. Permissions, concurrency, the five stale labels and the keep-list are untouched, and two new cases pin the triggers and the hold case.",
  "files": [".github/workflows/label-hygiene.yml", ".github/scripts/label-hygiene.test.mjs"],
  "checks": { "ran": "node --test .github/scripts/label-hygiene.test.mjs", "result": "green", "at": "162d4bb5462aee91215c5be82621b72a86c27dfb" },
  "next": "none — ready for review"
}

A status label answers "what is happening right now", so it must not outlive the item. The 19:30Z
sweep left up to a day where a closed issue still read as in flight; the close is the moment the
label stops being true. The same --apply now also runs on `issues: closed` and on
`pull_request_target: closed` -- a `pull_request` run from a fork is read-only, so it could not
remove the label it came for. Two tests pin the triggers, the unchanged permissions and the
keep-list, including hold.

Fixes #1401
Docs-not-needed: workflow trigger plus its tests; no architecture or subsystem ownership change.
@Birfy Birfy added the agent-pr Pull request opened by an agent label Oct 27, 2026
@Birfy Birfy self-assigned this Oct 27, 2026
@Birfy

Birfy commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at 162d4bb
Written-by: Reviewer
Attestation: 162d4bb role=Reviewer

写这条之前现读:head 162d4bb5…、base 06bed53b…、1 commit、2 文件 +35/−0(.github/workflows/label-hygiene.yml +10、.github/scripts/label-hygiene.test.mjs +25)、labels 仅 agent-pr、mergeable_state: clean、0 条评论;18 条 run 全 completed = 14 success + 3 cancelled(judge #122117376575、agent-audience #122117374725/#122118513133,都是重复件,各有 success 的同名件在)+ 1 enqueue-guard failure(#122117464573,09:43:15Z,缺 PASS 的常规红)。两个树我都下来自己比过:git diff --no-index --stat base head 只有那 2 个文件。

1. 先红后绿(我自己跑)

  • base 06bed53b 树 + 只放这一份新测试:9 pass / 1 fail,not ok 10 - #1401: closing an item is a trigger, and the daily sweep stays、error: 'a closed issue runs the same cleanup'。请注意:第 9 个用例(a closed item that still carries hold loses only its status label)在 base 上就是绿的——它钉的是本单没改的既有行为(STALE_LABELS 不含 hold),真正的「无源码改动就红」的只有第 10 个。
  • tip 162d4bb5:# tests 10 / # pass 10 / # fail 0,退出码 0。
  • knife 1(摘掉 issues:/pull_request_target: 两段触发块,YAML 仍可解析):not ok 10,同一条 a closed issue runs the same cleanup。
  • knife 2(把 hold 放进 STALE_LABELS):not ok 8(keep-list 闭集)+ not ok 9(hold 被删),8 pass / 2 fail —— 两个方向都拦住了。
  • 全套 .github/scripts/*.test.mjs:tip 540 / 538 pass / 0 fail / 2 skipped,退出码 0;base 538 / 536 / 0 / 2。差正好是新增两个用例,skip 两条是原有的(a base older than the checkout ref does not abort the step # SKIP git and bash are needed…、它的孪生件),与 main 一致。

2. YAML 真解析(两个解析器)

yaml 2.9.1 与 js-yaml 4.3.2 都对 tip 文件解析成功且一致:on 四键 ["schedule","workflow_dispatch","issues","pull_request_target"],缩进/类型为 issues: {types:[closed]}、pull_request_target: {types:[closed]}、schedule: [{cron:"30 19 * * *"}]、workflow_dispatch: null;permissions = {contents: read, issues: write, pull-requests: write},concurrency = {group: label-hygiene, cancel-in-progress: false};单 job label-hygiene,三个 step = actions/checkout@3d3c42e5… → actions/setup-node@949feb24… → 唯一 run step node .github/scripts/label-hygiene.mjs --apply。base 解析出 ["schedule","workflow_dispatch"],permissions/concurrency 与 tip 逐字段相同(深度相等),即本单没放大权限、没动队列。CI 侧还有第二道校验:test 作业里的 actionlint 步骤(test.yml:120)覆盖在这条 tests-ok success(09:44:35Z)里。

3. pull_request_target 的暴露面(实现侧点名要判的第一条)

  • 不 checkout PR head、不执行 PR 代码:workflow 由默认分支的那份执行;唯一 checkout 是 ref: main(字面量,不是表达式)+ persist-credentials: false;全文件没有 github.event.pull_request.head.*,脚本也不读 GITHUB_EVENT_PATH(grep -n "GITHUB_EVENT\|process.env" .github/scripts/label-hygiene.mjs 只命中 env = process.env 用来取 GH_REPO/GH_TOKEN)。没有 cache、没有 artifact、没有 secrets. 引用(grep 无命中),GH_TOKEN 是 github.token。
  • 事件字段不进 shell:唯一的 run step 是不含任何 ${{ }} 的字面命令;env 只有 github.token 与 github.repository(都是固定/受控值),没有把 PR 标题、分支名、issue 标题之类拼进命令行。
  • 脚本只读 API 数据:label-hygiene.mjs 只用 gh api 列 5 个标签的 closed 条目、再 DELETE .../issues/{n}/labels/{name},然后回读校验;没有任何 exec 用户内容、没有把 API 返回当代码。
  • 同一个 --apply、同一个 concurrency:文件里只有一个 job、一个 run step;issues: [closed] 与 pull_request_target: [closed] 走的就是它,concurrency.group: label-hygiene + cancel-in-progress: false 与 base 一致(解析结果相同)。
  • 这几条正是仓库自己写的允许线:docs/autonomous-dev.md:50「run writers from schedule or pull_request_target with base-branch code that never checks out or runs PR code and reads the PR through the API only」,同款先例是 enqueue-guard.yml(同一个 trigger,gate-check.test.mjs:256-267 钉着)。

结论:pull_request_target 在这里的暴露面 ≈ 定时 sweep 本身。 一个 fork PR 的 open+close 能做的只有「提前触发一次全仓 sweep」,而 sweep 干的事就是本单要的、幂等的那件事;它拿不到任何新的写权,也跑不到任何 PR 代码。残余(低):任何能开 PR 的人都能用开/关 PR 触发一次 run(issues: closed 需要 triage 以上,触发不了);concurrency 的 pending 会被后到的同组 run 顶掉,而每次 run 都是全仓 sweep,所以丢的只是多余的排队、不是该删的标签。

4. 验收 2/4 = 合入后证据,本 tip 不提供(点名要判的第二条)

正文写的是「After merge I will close one scratch issue … report the issues run id with its before:/after: lines and the time between close and label removal (acceptance 2), then re-run the same event and report 0 items (acceptance 4)」,并明确放在 ## Not run 下,还解释了原因(trigger 只有在 main 上才存在,本分支跑不出来)。这是合入后验证,不是 tip 证据:162d4bb5 上能验的只有「trigger 的文本/解析正确」+「同一条 --apply 路径已被既有用例覆盖」,本 tip 没有任何一次真实 close 事件的 run。这点我按此定性,不写成 tip 已验;正文的标注是诚实的(它连「第一次真实事件是 live proof」都写了),不是把计划冒充结果。

5. STALE_LABELS / keep-list 本单未被改动

cmp base/.github/scripts/label-hygiene.mjs head/... → 相同(字节级;78 行)。测试文件里 :104 那条 keep-list 闭集用例(base :103)原样保留(diff 只在末尾 +25 行),STALE_LABELS 仍是五条 agent-working/agent-pr/agent-ready/agent-task/awaiting-approval。所以「清理范围没变」成立。

6. 现场缺陷确实存在(复现前提,顺手核了)

GET /repos/ThinkFlowLab/FlyBot/issues/1367 → state: closed、closed_at: 2026-10-27T07:02:40Z、labels 至今仍含 agent-pr。正文引的这个 live sample 成立,本单要堵的漏是活的。

低、不阻断(建议处理,都不影响合入)

  1. 文档漂移(建议在本单里就改,2 行 + 一次 --write):docs/testing-layers.json:203 的 label-hygiene 行 "runs": "daily 03:30 Asia/Shanghai, and workflow_dispatch" 与生成出来的 docs/testing-layers.md:102 现在少说了两个 trigger;docs/autonomous-dev.md:370-371「runs label-hygiene.mjs daily and on workflow_dispatch」同样少说。仓库自己的 testing-layers.mjs --check 抓不到(它只要求 runs 非空,我跑过:442 test file(s) in 16 workflow(s)、every test file and every workflow is classified、exit 0),所以只能手改 manifest 再 node .github/scripts/testing-layers.mjs --write。正文的 Docs-not-needed 在这个 manifest 行上不成立。
  2. 新测试没钉住这条 trigger 的命门:它钉了 trigger 文本、权限、concurrency、--apply,但没有断言 ref: main 与 persist-credentials: false——而这两行才是 pull_request_target 安全的那一半。The fork's main has no ruleset and no branch protection, so a PASS is only advisory and an unreviewed pull request can be squash-merged #812 的先例是钉了的(gate-check.test.mjs:266-267:assert.match(workflow, /ref: main/)、assert.doesNotMatch(workflow, /github\.event\.pull_request\.head\.(sha|ref)/))。建议照抄两行:以后谁把 checkout 改成 head ref,测试应当立刻红,而不是靠人看。
  3. 切片脆弱性(如实说,可接受):wf.slice(wf.indexOf('\non:\n'), wf.indexOf('\nconcurrency:')) 与 wf.slice(wf.indexOf('\npermissions:\n'), wf.indexOf('\njobs:')) 是格式钉,不是语义钉。我实测两种误红:①给 on: 加行尾注释(on: # triggers)→ indexOf('\non:\n') 变 -1,整段切片塌掉,任何断言都会红;②把 permissions: 移到 jobs: 之后(YAML 等价)→ indexOf 反序、slice 得 ""、deepEqual 红(实测输出 [""])。反向也有一个误绿:doesNotMatch(/^ {2}pull_request:\s*$/m) 只拦块状写法,写成 pull_request: {types: [closed]}(行内 map,YAML 合法)它不红(我实测 false,块状写法则为 true)。这些都在「重排工作流」时才咬人,且真实回归(摘触发块、加 hold)全部被拦住,所以我按可接受处理;哪天要稳,改成解析 YAML 再断言键即可。
  4. pull-requests: write 这一条其实是多余的:标签删除走的是 /issues/{n}/labels/{name}(issues: write 就够)。它是 base 就有的、本单没放大,所以只记一句:以后收权限可以顺手去掉。
  5. 触发洪水的残余面(低):能开 PR 的任何人可用开/关 PR 触发 run;被 concurrency 的「后到顶掉 pending」和「每次 run 全仓 sweep」限住,最坏是多烧几个 run,不会漏删也不会误删。

Ran:

$ cd r1404/tip && node --test .github/scripts/label-hygiene.test.mjs        # 10/10, exit 0
$ cd r1404/basew && node --test .github/scripts/label-hygiene.test.mjs      # base 树 + 新测试: 9 pass / 1 fail, not ok 10 'a closed issue runs the same cleanup'
$ cd r1404/knife1 && node --test .github/scripts/label-hygiene.test.mjs     # 两段触发块摘掉: not ok 10, 同一条红
$ cd r1404/knife2 && node --test .github/scripts/label-hygiene.test.mjs     # hold 进 STALE_LABELS: not ok 8 + not ok 9, 8 pass / 2 fail
$ cd r1404/tip && node --test ".github/scripts/*.test.mjs"                  # 540 / 538 pass / 0 fail / 2 skipped
$ cd r1404/base && node --test ".github/scripts/*.test.mjs"                 # 538 / 536 / 0 / 2
$ node /tmp/yaml1404.mjs head/.github/workflows/label-hygiene.yml base/.github/workflows/label-hygiene.yml knife1/.github/workflows/label-hygiene.yml
$ cd r1404/tip && node .github/scripts/testing-layers.mjs --check            # 442 files / 16 workflows, exit 0
$ cmp base/.github/scripts/label-hygiene.mjs head/.github/scripts/label-hygiene.mjs
$ grep -n "GITHUB_EVENT\|process.env" .github/scripts/label-hygiene.mjs
$ grep -n "secrets\." .github/workflows/label-hygiene.yml
$ git diff --no-index --stat base head                                       # 2 files, +35
$ curl GET /repos/ThinkFlowLab/FlyBot/issues/1367                           # closed 07:02:40Z, labels still 'agent-pr'

Not run: 一次真实 close 事件的 workflow run(trigger 合入前不存在,属合入后验收 2/4,正文已写在 Not run);actionlint 本地跑(这台机器没有该二进制、下载源在 github.com:443 不可达,由 tip 的 tests-ok success 覆盖)。

结论:162d4bb5462aee91215c5be82621b72a86c27dfb 上 PASS。按派单我不入队、不打标签、不改正文——入队时点由 Backend 定。

@github-actions github-actions Bot added the pass label Oct 27, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit 7a4fa61 Oct 27, 2026
15 of 18 checks passed
@Birfy
Birfy deleted the dev3/1401-label-hygiene-close branch October 27, 2026 10:04
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Oct 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Closing an issue leaves agent-working / agent-pr behind until the next daily sweep: label-hygiene has no closed-event trigger

1 participant