Переглянути джерело

fix(ci): avoid unnecessary issue policy Project reads and lifecycle runs

Tianyi Cui 2 тижнів тому
батько
коміт
108c87008b

+ 2 - 2
.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md
-2026-08-10-event-directed-pr-review-status.md: de4dc0700f2083772321fdf5f26c05fdf39928be
-2026-08-10-event-directed-pr-review-status.zh.md: b8a8fbaa25673a376542965700e864f82ab0d739
+2026-08-10-event-directed-pr-review-status.md: 37b8086a081145e8c16ceb29d489fbc38a7f0d43
+2026-08-10-event-directed-pr-review-status.zh.md: cc0dad8554f869d51965c30d2648937c54dc2b75

+ 2 - 2
.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md

@@ -12,7 +12,7 @@ A monotonic projection also cannot return an automation-owned Issue from `In rev
 
 ## Decision
 
-The Issue lifecycle workflow treats review webhooks as commands. `pull_request.review_requested`, including a repeated request, targets `In review`. `pull_request_review.submitted` targets `In progress` only when `review.state` is `changes_requested`; the submitted event remains necessary because a reviewer can request changes without an earlier review-request event. Approved and commented submissions run their lifecycle job but no-op (they never reach the Project token step), while dismissed reviews are not subscribed.
+The Issue lifecycle workflow treats review webhooks as commands. `pull_request.review_requested`, including a repeated request, targets `In review`. `pull_request_review.submitted` targets `In progress` only when `review.state` is `changes_requested`; the submitted event remains necessary because a reviewer can request changes without an earlier review-request event. Approved and commented submissions do not allocate a lifecycle runner, while dismissed reviews are not subscribed. [Selective policy evaluation](2026-09-07-selective-issue-policy-evaluation.md) owns lifecycle scheduling.
 
 Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status.
 
@@ -22,7 +22,7 @@ The status projection resolves only exact same-repository `Fixes`, `Closes`, or
 
 ## Verification
 
-[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the job-level absence of `if` plus the step-level gate on the token/board steps (so approved/commented reviews pass without minting a token), and the separate `ready_for_review` policy trigger.
+[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the job-level condition that excludes approved/commented reviews before runner allocation, and the separate `ready_for_review` policy trigger.
 
 ## Alternatives considered
 

+ 2 - 2
.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md

@@ -12,7 +12,7 @@ Issue 所在 Project 中的状态记录了解决工作的下一步由谁负责
 
 ## 决策
 
-Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review_requested`(包括重复请求)将目标状态指定为 `In review`。`pull_request_review.submitted` 将目标状态指定为 `In progress`,但仅在 `review.state` 为 `changes_requested` 时生效;submitted 事件仍不可省略,因为评审人即使没有先触发 review-request 事件,也可以直接提出修改要求。对于 approved 和 commented 提交,生命周期作业会运行但空操作(不会走到创建 Project token 一步);dismissed 评审则不在订阅范围内
+Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review_requested`(包括重复请求)将目标状态指定为 `In review`。`pull_request_review.submitted` 将目标状态指定为 `In progress`,但仅在 `review.state` 为 `changes_requested` 时生效;submitted 事件仍不可省略,因为评审人即使没有先触发 review-request 事件,也可以直接提出修改要求。对于 approved 和 commented 提交,工作流不分配生命周期 runner;dismissed 评审则不在订阅范围内。[选择性策略求值](2026-09-07-selective-issue-policy-evaluation.zh.md)拥有生命周期调度规则
 
 工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox`、`Backlog` 或 `Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。
 
@@ -22,7 +22,7 @@ Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review
 
 ## 验证
 
-[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、job 级无 `if` 且 token/看板步骤带 step 级门控(使 approved/commented 评审以 pass 呈现且不铸 token),以及独立的 `ready_for_review` 策略触发器。
+[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、在 runner 分配前排除 approved/commented 评审的 job 级条件,以及独立的 `ready_for_review` 策略触发器。
 
 ## 考虑过的替代方案
 

+ 6 - 0
.agents/notes/implemented/process/2026-09-07-selective-issue-policy-evaluation.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-07-selective-issue-policy-evaluation.md
+2026-09-07-selective-issue-policy-evaluation.md: 72f217a64a841eff99d3314e52dd6e6f547d4a4f
+2026-09-07-selective-issue-policy-evaluation.zh.md: 0caca4554f2f29493b9c778675a53125256fa94d

+ 41 - 0
.agents/notes/implemented/process/2026-09-07-selective-issue-policy-evaluation.md

@@ -0,0 +1,41 @@
+# Agent Note: Selective Issue policy evaluation
+
+Status: implemented
+
+English | [中文](2026-09-07-selective-issue-policy-evaluation.zh.md)
+
+## Problem
+
+Informational Issue references provide context, while resolving references carry a Priority obligation. Requiring Project access for both makes unrelated board configuration or App availability block context-only PRs. Looking up referenced Issues before determining enforcement eligibility also spends credentials and API requests on PRs that cannot fail policy.
+
+Lifecycle events have a separate cost: an approval, comment, push, label change, or assignment change need not perform a Project status handoff. Allocating a runner for a known no-op consumes capacity without changing the Issue.
+
+## Decision
+
+[Issue policy](../../../../.github/workflows/issue-policy.yml) keeps its required job and trusted default-branch implementation. Enforcement eligibility precedes reference reads and Project App token creation: draft PRs, Bot/App authors, and human PRs with neither review requests nor submitted reviews do not require policy validation.
+
+Eligible PRs resolve references through repository REST reads. Informational references prove Issue identity without Project access. Only actual Issues named by resolving references require Project Priority reads; a PR number cannot satisfy the Issue requirement or cause a Project query. [The owner reference](../../../../.github/issue-management/README.md) defines metadata validation and failure behavior.
+
+[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) subscribes to status-relevant PR events and filters title-only edits. It does not subscribe to PR pushes or label changes, or Issue assignment changes. Its job condition rejects approved/commented reviews before runner allocation. Changes-requested reviews retain their status command.
+
+This scheduling decision partially supersedes the no-op-job scheduling in [event-directed review status](2026-08-10-event-directed-pr-review-status.md), not its handoff semantics or human-ownership protection. [Project-local planning fields](2026-09-02-project-local-issue-planning-fields.md) still own opened-only, empty-only Start Date initialization for every referenced Issue, including informational references. The validation read exemption does not exempt that lifecycle mutation.
+
+## Alternatives considered
+
+**Read every referenced Issue's Project fields.** Informational references do not constrain Priority, so these queries add failure dependencies without contributing a validation result.
+
+**Keep successful no-op lifecycle jobs for approvals and comments.** That preserves a green job presentation but allocates a runner for an event with no lifecycle command. Lifecycle is separate from the retained required policy job.
+
+**Remove the required policy job or redesign check authority.** Selective reads and lifecycle scheduling can reduce avoidable work without changing which required check GitHub expects. Check-authority redesign is not part of this decision.
+
+## Consequences
+
+Informational-only validation needs repository access but not Project credentials. Resolving validation still fails when required Project reads or field checks fail. Preflight and final validation each read live REST state, duplicating repository requests rather than caching a verdict. Avoiding Project reads and token creation does not guarantee fewer total API requests. The required policy job still allocates a runner; this is not a zero-cost required check.
+
+Maintainers manually manage the Project custom Priority field. Native Issue-field skill guidance does not update that value. There is no Priority synchronization, field migration, label repair, or change to [presentation-neutral policy](2026-09-03-semantic-issue-templates-and-policy.md).
+
+Omitted lifecycle events cannot repair stale Project state. Event replay and concurrent writes retain the races documented by the lifecycle and planning-field owners. Actual Actions-minute savings and live GitHub App access require operational observation, not inference from a mocked API test.
+
+## Verification
+
+[Policy tests](../../../../.github/issue-management/policy.test.mjs) verify early exemptions, REST-only informational references, actual-Issue filtering, resolving Priority reads and failures, and the lifecycle command selection. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) verify Project-token conditions, the retained required job, pruned subscriptions, and runner-level lifecycle filtering. Local fixtures do not establish live webhook delivery or billing outcomes.

+ 41 - 0
.agents/notes/implemented/process/2026-09-07-selective-issue-policy-evaluation.zh.md

@@ -0,0 +1,41 @@
+# Agent Note: Issue 策略的选择性求值
+
+Status: implemented
+
+[English](2026-09-07-selective-issue-policy-evaluation.md) | 中文
+
+## 问题
+
+信息型 Issue 引用提供背景,解决型引用则带有 Priority 义务。两者都要求 Project 访问,会让无关的看板配置或 App 可用性阻止仅提供背景的 PR(Pull Request)。在确定强制范围之前读取被引用 Issue,也会为不可能因策略而失败的 PR 消耗凭据与 API 请求。
+
+生命周期事件有独立成本:批准、评论、推送、标签变更或指派变更不一定需要执行 Project 状态交接。为已知无操作的事件分配 runner,会占用容量而不改变 Issue。
+
+## 决策
+
+[Issue policy](../../../../.github/workflows/issue-policy.yml)保留必需 job 与受信任的默认分支实现。强制范围判定先于引用读取与 Project App token 创建:草稿 PR、Bot/App 作者,以及既无评审请求也无已提交评审的人类 PR 均不需要策略校验。
+
+强制范围内的 PR 通过仓库 REST 读取解析引用。信息型引用无需 Project 访问即可证明 Issue 身份。只有解决型引用指向的实际 Issue 需要读取 Project Priority;PR 编号既不能满足 Issue 引用要求,也不会引发 Project 查询。[所属参考文档](../../../../.github/issue-management/README.zh.md)定义元数据校验与失败行为。
+
+[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml)订阅与状态相关的 PR 事件,并过滤仅标题编辑。它不订阅 PR 推送、PR 标签变更或 Issue 指派变更。job 条件在 runner 分配前排除 approved/commented 评审。请求修改的评审保留其状态命令。
+
+本调度决策部分取代[事件驱动评审状态](2026-08-10-event-directed-pr-review-status.zh.md)中无操作 job 的调度方式,但不取代交接语义或人工状态归属保护。[Project 局部规划字段](2026-09-02-project-local-issue-planning-fields.zh.md)仍拥有对每个被引用 Issue(包括信息型引用)仅在 PR 打开时、仅对空值初始化 Start Date 的规则。校验读取豁免不豁免该生命周期 mutation。
+
+## 考虑过的替代方案
+
+**读取每个被引用 Issue 的 Project 字段。** 信息型引用不约束 Priority,因此这些查询只增加失败依赖,不贡献校验结果。
+
+**为批准与评论保留成功的无操作生命周期 job。** 这可以保持绿色 job 展示,却会为没有生命周期命令的事件分配 runner。生命周期与保留的必需策略 job 相互独立。
+
+**删除必需策略 job 或重新设计检查权威来源。** 选择性读取和生命周期调度可以减少可避免的工作,无需改变 GitHub 期待的必需检查。检查权威来源的重新设计不属于本决策。
+
+## 影响
+
+仅含信息型引用的校验需要仓库访问,但不需要 Project 凭据。解决型校验在所需 Project 读取或字段检查失败时仍会失败。预检与最终校验各自读取实时 REST 状态,重复仓库请求而不缓存结论。避免 Project 读取与 token 创建不保证减少 API 请求总数。必需策略 job 仍分配 runner;它并非零成本的必需检查。
+
+维护者手动管理 Project 自定义 Priority 字段。原生 Issue 字段的 skill 指引不会更新该值。不提供 Priority 同步、字段迁移或标签修复,也不改变[不检查展示形式的策略](2026-09-03-semantic-issue-templates-and-policy.zh.md)。
+
+被省略的生命周期事件不能修复过时的 Project 状态。事件重放和并发写入仍有生命周期与规划字段文档记录的竞态。实际 Actions 分钟节省与 GitHub App 实际访问权限需要运营观察,不能从模拟 API 测试推断。
+
+## 验证
+
+[策略测试](../../../../.github/issue-management/policy.test.mjs)验证早期豁免、仅使用 REST 的信息型引用、实际 Issue 过滤、解决型 Priority 读取及失败,以及生命周期命令选择。[工作流测试](../../../../scripts/ci-workflow.spec.ts)验证 Project token 条件、保留的必需 job、精简后的订阅及 runner 级生命周期过滤。本地 fixture 不能证明实际 webhook 交付或计费结果。

+ 6 - 0
.github/issue-management/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .github/issue-management/README.md
+README.md: b81d8a8255d4fee664b10fd3795aefc71540b4c4
+README.zh.md: ea3e251aa53b9de979c1c30d3e7008001af4285d

+ 74 - 0
.github/issue-management/README.md

@@ -0,0 +1,74 @@
+---
+description: "Issue policy enforcement, Project access, and lifecycle events for repository maintainers."
+---
+
+# Issue management
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Contributors can link Issues as context without coupling pull-request validation to Project availability. Resolving references additionally enforce Project Priority. The required `Issue policy` job and the separate lifecycle workflow use trusted default-branch code.
+
+## Table of Contents
+
+- [Pull-request policy](#pull-request-policy)
+- [Lifecycle events](#lifecycle-events)
+- [Configuration and limitations](#configuration-and-limitations)
+- [Verification](#verification)
+- [Dev Note](#dev-note)
+
+-----
+
+<a id="pull-request-policy"></a>
+## Pull-request policy
+
+[Issue policy](../workflows/issue-policy.yml) applies to non-draft, human-authored PRs with a requested review or submitted review. Exempt PRs finish successfully without resolving Issue references, minting a Project App token, or querying ProjectV2. Eligibility uses live repository state before expensive reads; the required job remains present for subscribed events. Final validation re-reads live state: preflight is not a cached verdict or an exemption for metadata edits.
+
+Eligible PRs need at least one same-repository Issue reference, exactly one canonical `kind/*`, at least one `area/*`, and at most one `p0`–`p3` label. Unsupported kinds, retired aliases, and `source/*` labels fail validation; [label taxonomy](../../.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md) owns their meanings.
+
+- Informational references, such as `Refs #3624`, establish context. Validation uses REST to distinguish Issues from PR numbers and does not read their Project fields. An informational-only PR can carry its own Priority without matching the referenced Issue.
+- Resolving references use closing keywords such as `Fixes #123`, `Closes #123`, or `Resolves #123`. Only references that resolve to actual Issues require Project reads during validation. A PR Priority must match the highest resolving-Issue Priority; a resolving PR with a Priority label requires every resolving Issue to have Priority. If all resolving Priorities are empty, the PR may omit Priority.
+- References inside HTML comments, code fences, or inline code do not count. Cross-repository references and references to PRs do not satisfy the Issue requirement.
+
+REST reads use the repository `GITHUB_TOKEN`. Project validation uses a separate App token with Issues and organization Projects read permissions. Missing required Project access or invalid field configuration fails validation rather than bypassing resolving-Issue Priority checks.
+
+-----
+
+<a id="lifecycle-events"></a>
+## Lifecycle events
+
+[Issue lifecycle](../workflows/issue-lifecycle.yml) mutates Project data independently of PR validation eligibility. PR opened/reopened events and body edits can advance resolving Issues to `In progress`; title-only edits do not. Review requests target `In review`. Changes-requested reviews target `In progress`, with the [human-ownership and terminal-status protections](../../.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md).
+
+Approval-only and comment-only reviews do not allocate a lifecycle runner. PR pushes and label changes, and Issue assignment changes, do not trigger lifecycle work. Other subscribed Issue events maintain membership, state, and audit comments; exact subscriptions live in the workflow.
+
+PR opening initializes an empty Project `Start Date` for every referenced Issue, including informational references, using the PR creation date in the configured time zone. This lifecycle operation can add Project membership and needs Project write access; the informational-reference read exemption applies only to PR validation. [Planning-field ownership](../../.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md) defines date preservation.
+
+-----
+
+<a id="configuration-and-limitations"></a>
+## Configuration and limitations
+
+[config.json](config.json) selects the repository, Project, field names, statuses, lifecycle actor, and time zone. The policy reads the Project custom single-select `Priority` field, not a native organization Issue Priority field. Maintainers set Project Priority manually; skill guidance that directs edits to native Issue fields does not populate this value. There is no field migration, Priority synchronization, or metadata auto-repair.
+
+Lifecycle processing is event-driven, not a reconciler. Omitted events do not repair Project state, and concurrent Project mutations have no atomic compare-and-swap. Selective evaluation does not redesign required-check authority or guarantee measured Actions-minute savings. The [selective-evaluation decision](../../.agents/notes/implemented/process/2026-09-07-selective-issue-policy-evaluation.md) records the trade-offs.
+
+-----
+
+<a id="verification"></a>
+## Verification
+
+The focused, keyless policy suite runs from the repository root:
+
+```sh
+node --test .github/issue-management/policy.test.mjs
+```
+
+[Workflow tests](../../scripts/ci-workflow.spec.ts) verify trigger and permission declarations. Local tests do not establish live GitHub delivery, App installation access, or actual runner cost; repository maintainers verify those in Actions.
+
+-----
+
+<a id="dev-note"></a>
+## Dev Note
+
+None.

+ 74 - 0
.github/issue-management/README.zh.md

@@ -0,0 +1,74 @@
+---
+description: "面向仓库维护者的 Issue 策略强制范围、Project 访问与生命周期事件参考。"
+---
+
+# Issue 管理
+
+[English](README.md) | 中文
+
+## 摘要
+
+贡献者可以引用 Issue 作为背景,而无需让 PR(Pull Request)校验依赖 Project 可用性。解决型引用还会强制检查 Project Priority。必需的 `Issue policy` job 与独立的生命周期工作流使用受信任的默认分支代码。
+
+## 目录
+
+- [PR 策略](#pull-request-policy)
+- [生命周期事件](#lifecycle-events)
+- [配置与限制](#configuration-and-limitations)
+- [验证](#verification)
+- [开发备注](#dev-note)
+
+-----
+
+<a id="pull-request-policy"></a>
+## PR 策略
+
+[Issue policy](../workflows/issue-policy.yml)适用于已请求评审或已有评审、非草稿且由人类创建的 PR。豁免 PR 成功结束,不解析 Issue 引用、不签发 Project App token,也不查询 ProjectV2。工作流在昂贵读取前根据仓库实时状态判断强制范围;订阅事件仍保留必需 job。最终校验重新读取实时状态:预检不是缓存结论,也不是元数据编辑的豁免。
+
+强制范围内的 PR 至少需要一个同仓库 Issue 引用、恰好一个规范的 `kind/*`、至少一个 `area/*`,以及最多一个 `p0`–`p3` 标签。不支持的 kind、退役别名和 `source/*` 标签会使校验失败;[标签分类](../../.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md)定义其含义。
+
+- 信息型引用(如 `Refs #3624`)提供背景。校验通过 REST 区分 Issue 与 PR 编号,不读取这些引用的 Project 字段。仅含信息型引用的 PR 可以使用自己的 Priority,无需匹配所引用的 Issue。
+- 解决型引用使用关闭关键词,如 `Fixes #123`、`Closes #123` 或 `Resolves #123`。校验期间只有解析为实际 Issue 的解决型引用需要读取 Project。PR Priority 必须匹配被解决 Issue 中的最高 Priority;带 Priority 标签的解决型 PR 要求每个被解决 Issue 均有 Priority。若所有被解决 Issue 的 Priority 均为空,PR 可以省略 Priority。
+- HTML 注释、代码围栏或行内代码中的引用不计入。跨仓库引用与指向 PR 的引用不能满足 Issue 引用要求。
+
+REST 读取使用仓库 `GITHUB_TOKEN`。Project 校验使用独立的 App token,具有 Issues 和组织 Projects 读取权限。缺少所需 Project 访问权限或字段配置无效时,校验失败,而不是绕过解决型 Issue 的 Priority 检查。
+
+-----
+
+<a id="lifecycle-events"></a>
+## 生命周期事件
+
+[Issue lifecycle](../workflows/issue-lifecycle.yml)独立于 PR 校验强制范围修改 Project 数据。PR 打开、重新打开和正文编辑可将解决型 Issue 推进至 `In progress`;仅编辑标题不会。请求评审以 `In review` 为目标。请求修改的评审以 `In progress` 为目标,并遵守[人工状态归属与终态保护](../../.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md)。
+
+仅批准或仅评论的评审不分配生命周期 runner。PR 推送与标签变更,以及 Issue 指派变更,不触发生命周期工作。其他已订阅的 Issue 事件维护 Project 归属、状态及审计评论;精确订阅列表由工作流定义。
+
+PR 打开时,工作流按配置时区中的 PR 创建日期,为每个被引用 Issue(包括信息型引用)初始化空的 Project `Start Date`。此生命周期操作可以添加 Project 归属,并需要 Project 写权限;信息型引用的读取豁免仅适用于 PR 校验。[规划字段归属](../../.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md)定义日期保留规则。
+
+-----
+
+<a id="configuration-and-limitations"></a>
+## 配置与限制
+
+[config.json](config.json)选择仓库、Project、字段名、状态、生命周期操作者和时区。策略读取 Project 自定义单选 `Priority` 字段,而非组织原生 Issue Priority 字段。维护者手动设置 Project Priority;指引编辑原生 Issue 字段的 skill 不会填充该值。不提供字段迁移、Priority 同步或元数据自动修复。
+
+生命周期处理由事件驱动,不是协调器。被省略的事件不会修复 Project 状态,并发 Project mutation 也没有原子比较并交换保护。选择性求值不重新设计必需检查的权威来源,也不保证已测得的 Actions 分钟节省。[选择性求值决策](../../.agents/notes/implemented/process/2026-09-07-selective-issue-policy-evaluation.zh.md)记录取舍。
+
+-----
+
+<a id="verification"></a>
+## 验证
+
+在仓库根目录运行聚焦的无密钥策略测试:
+
+```sh
+node --test .github/issue-management/policy.test.mjs
+```
+
+[工作流测试](../../scripts/ci-workflow.spec.ts)验证触发器与权限声明。本地测试不能证明 GitHub 实际事件交付、App 安装访问权限或实际 runner 成本;仓库维护者在 Actions 中验证这些内容。
+
+-----
+
+<a id="dev-note"></a>
+## 开发备注
+
+无。

+ 72 - 23
.github/issue-management/policy.mjs

@@ -38,10 +38,7 @@ const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSE
 const IMPLEMENTATION_PULL_REQUEST_ACTIONS = new Set([
   'opened',
   'edited',
-  'synchronize',
   'reopened',
-  'labeled',
-  'unlabeled',
 ])
 
 for (const status of ['In progress', 'In review']) {
@@ -79,11 +76,12 @@ export function requiresPullRequestPolicy({
 /**
  * Translate a repository event into one resolving-Issue lifecycle command.
  * @param {string} eventName GitHub event name.
- * @param {{action?: string, review?: {state?: string}}} event GitHub event payload.
+ * @param {{action?: string, changes?: {body?: object}, review?: {state?: string}}} event GitHub event payload.
  * @returns {'implementation'|'review-requested'|'changes-requested'|null} Lifecycle command.
  */
 export function resolvingIssueStatusCommand(eventName, event) {
   if (eventName === 'pull_request') {
+    if (event.action === 'edited' && !event.changes?.body) return null
     if (event.action === 'review_requested') return 'review-requested'
     return IMPLEMENTATION_PULL_REQUEST_ACTIONS.has(event.action) ? 'implementation' : null
   }
@@ -264,7 +262,9 @@ export function validatePullRequest(input) {
   const priorities = input.labels.filter((label) => PRIORITIES.includes(label))
   const areas = input.labels.filter((label) => label.startsWith('area/'))
 
-  if (input.references.all.length === 0) errors.push('PR 正文必须引用至少一个同仓库 Issue')
+  if (input.references.all.length === 0) {
+    errors.push('PR 正文必须引用至少一个同仓库 Issue;PR 编号(包括堆叠依赖 PR)不算 Issue 引用')
+  }
   if (kinds.length !== 1) {
     errors.push(`PR 必须恰好有一个允许的 kind/*,当前为 ${kinds.length}`)
   }
@@ -650,8 +650,10 @@ async function resolvingReferencesSnapshot(number, pull) {
   })
   const issues = new Map()
   for (const issueNumber of references.all) {
-    const issue = await issueSnapshot(issueNumber, null)
-    if (issue) issues.set(issueNumber, issue)
+    const issue = await api(
+      `/repos/${config.organization}/${config.repository}/issues/${issueNumber}`,
+    )
+    if (!issue.pull_request) issues.set(issueNumber, { priority: null })
   }
   return {
     number,
@@ -660,21 +662,34 @@ async function resolvingReferencesSnapshot(number, pull) {
   }
 }
 
-async function pullRequestSnapshot(number) {
-  const [pull, reviewRequests, reviews] = await Promise.all([
-    api(`/repos/${config.organization}/${config.repository}/pulls/${number}`),
-    api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`),
-    api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`),
-  ])
-  const resolving = await resolvingReferencesSnapshot(number, pull)
-  return {
-    ...resolving,
+async function pullRequestSnapshot(number, includeProject = true) {
+  const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
+  const snapshot = {
+    number,
     isDraft: pull.draft,
     authorType: pull.user?.type ?? 'User',
-    reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length,
-    reviewCount: reviews.length,
+    reviewRequestCount: 0,
+    reviewCount: 0,
     labels: pull.labels.map((label) => label.name),
+    references: { all: [], resolving: [], related: [] },
+    issues: new Map(),
+  }
+  if (snapshot.isDraft || ['Bot', 'App'].includes(snapshot.authorType)) return snapshot
+  const [reviewRequests, reviews] = await Promise.all([
+    api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`),
+    api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`),
+  ])
+  snapshot.reviewRequestCount = reviewRequests.users.length + reviewRequests.teams.length
+  snapshot.reviewCount = reviews.length
+  if (!requiresPullRequestPolicy(snapshot)) return snapshot
+  Object.assign(snapshot, await resolvingReferencesSnapshot(number, pull))
+  if (includeProject) {
+    for (const issueNumber of snapshot.references.resolving) {
+      const context = await projectContext(issueNumber)
+      snapshot.issues.get(issueNumber).priority = context.item?.priorityValue?.name ?? null
+    }
   }
+  return snapshot
 }
 
 async function lifecyclePullRequestSnapshot(number) {
@@ -701,7 +716,34 @@ async function transitionResolvingIssues(pull, command) {
   }
 }
 
-async function runPullRequestCheck(event) {
+const EXEMPT_MESSAGE =
+  'Issue policy exempt:当前 PR 不在强制范围(Draft、Bot/App 或尚无 review request/review)。\n'
+
+/**
+ * Determine current policy eligibility and Project access needs without Project credentials.
+ * @param {{pull_request: {number: number}}} event GitHub event identifying the PR.
+ * @returns {Promise<{eligible: boolean, needsProject: boolean}>} Trusted workflow decisions.
+ */
+export async function runPullRequestPreflight(event) {
+  const pull = await pullRequestSnapshot(event.pull_request.number, false)
+  const eligible = requiresPullRequestPolicy(pull)
+  const needsProject = eligible && pull.references.resolving.length > 0
+  if (process.env.GITHUB_OUTPUT) {
+    fs.appendFileSync(
+      process.env.GITHUB_OUTPUT,
+      `eligible=${eligible}\nexempt=${!eligible}\nneeds-project=${needsProject}\n`,
+    )
+  }
+  process.stdout.write(eligible ? 'Issue policy applicable;执行完整校验。\n' : EXEMPT_MESSAGE)
+  return { eligible, needsProject }
+}
+
+/**
+ * Enforce all PR rules against current GitHub state, independently of preflight.
+ * @param {{pull_request: {number: number}}} event GitHub event identifying the PR.
+ * @returns {Promise<void>} Resolves on success or exemption; rejects policy failures.
+ */
+export async function runPullRequestCheck(event) {
   const pull = await pullRequestSnapshot(event.pull_request.number)
   const errors = validatePullRequest(pull)
   if (errors.length > 0) {
@@ -709,11 +751,17 @@ async function runPullRequestCheck(event) {
     throw new Error(`Issue policy 未通过,共 ${errors.length} 项`)
   }
   process.stdout.write(
-    requiresPullRequestPolicy(pull) ? 'Issue policy 通过。\n' : 'PR 尚未进入 Issue policy 强制范围。\n',
+    requiresPullRequestPolicy(pull) ? 'Issue policy 通过。\n' : EXEMPT_MESSAGE,
   )
 }
 
-async function runLifecycle(eventName, event) {
+/**
+ * Apply repository lifecycle events; irrelevant PR events perform no requests.
+ * @param {string} eventName GitHub event name.
+ * @param {object} event GitHub event payload.
+ * @returns {Promise<void>} Resolves after lifecycle updates and audits.
+ */
+export async function runLifecycle(eventName, event) {
   if (eventName === 'issues') {
     const number = event.issue.number
     if (event.action === 'opened') await setStatus(number, 'Inbox')
@@ -747,9 +795,10 @@ function readEvent() {
 
 async function main(argv) {
   const [command] = argv
-  if (command === 'pr') await runPullRequestCheck(readEvent())
+  if (command === 'pr-preflight') await runPullRequestPreflight(readEvent())
+  else if (command === 'pr') await runPullRequestCheck(readEvent())
   else if (command === 'lifecycle') await runLifecycle(process.env.GITHUB_EVENT_NAME, readEvent())
-  else throw new Error('用法:policy.mjs pr|lifecycle')
+  else throw new Error('用法:policy.mjs pr-preflight|pr|lifecycle')
 }
 
 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {

+ 182 - 3
.github/issue-management/policy.test.mjs

@@ -1,5 +1,7 @@
 import assert from 'node:assert/strict'
-import { readFileSync, readdirSync } from 'node:fs'
+import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
 import test from 'node:test'
 
 import {
@@ -14,6 +16,9 @@ import {
   retainIssueReferences,
   resolvingIssueStatusCommand,
   requiresPullRequestPolicy,
+  runLifecycle,
+  runPullRequestCheck,
+  runPullRequestPreflight,
   validateIssue,
   validatePullRequest,
 } from './policy.mjs'
@@ -636,10 +641,21 @@ test('maps only explicit review handoffs to review status commands', () => {
   )
 })
 
-test('keeps ordinary pull request events as forward-only implementation signals', () => {
-  for (const action of ['opened', 'edited', 'synchronize', 'reopened', 'labeled', 'unlabeled']) {
+test('keeps PR opening, reopening, and body edits as implementation signals', () => {
+  for (const action of ['opened', 'reopened']) {
     assert.equal(resolvingIssueStatusCommand('pull_request', { action }), 'implementation')
   }
+  assert.equal(
+    resolvingIssueStatusCommand('pull_request', { action: 'edited', changes: { body: { from: '' } } }),
+    'implementation',
+  )
+  for (const action of ['synchronize', 'labeled', 'unlabeled', 'edited']) {
+    assert.equal(resolvingIssueStatusCommand('pull_request', { action }), null)
+  }
+  assert.equal(
+    resolvingIssueStatusCommand('pull_request', { action: 'edited', changes: { title: { from: '' } } }),
+    null,
+  )
   assert.equal(
     resolvingIssueStatusCommand('pull_request', { action: 'review_request_removed' }),
     null,
@@ -749,6 +765,169 @@ test('rejects multiple, unknown, legacy, and Issue-source PR labels', () => {
   )
 })
 
+const mockPolicyApi = (t, { pull = {}, requested = true, reviews = [], issues = {}, priority = 'P1', projectError = false } = {}) => {
+  const environment = ['GH_TOKEN', 'GITHUB_TOKEN', 'PROJECT_TOKEN', 'GITHUB_API_URL', 'GITHUB_OUTPUT']
+  const previous = new Map(environment.map((key) => [key, process.env[key]]))
+  const directory = mkdtempSync(join(tmpdir(), 'dsh-policy-'))
+  t.after(() => {
+    for (const [key, value] of previous) {
+      if (value === undefined) delete process.env[key]
+      else process.env[key] = value
+    }
+    rmSync(directory, { recursive: true, force: true })
+  })
+  for (const key of environment) delete process.env[key]
+  process.env.GITHUB_TOKEN = 'repository-token'
+  process.env.GITHUB_OUTPUT = join(directory, 'output')
+  const requests = []
+  const output = []
+  t.mock.method(process.stdout, 'write', (text) => { output.push(text); return true })
+  t.mock.method(globalThis, 'fetch', async (url, options) => {
+    const path = new URL(url).pathname + new URL(url).search
+    requests.push(path)
+    if (path.endsWith('/pulls/10')) return Response.json({
+      draft: false, user: { type: 'User' }, body: 'Refs #2',
+      labels: [{ name: 'kind/cleanup' }, { name: 'area/infra' }], ...pull,
+    })
+    if (path.endsWith('/requested_reviewers')) {
+      return Response.json({ users: requested ? [{}] : [], teams: [] })
+    }
+    if (path.endsWith('/reviews?per_page=100')) return Response.json(reviews)
+    if (path === '/graphql') {
+      assert.equal(options.headers.Authorization, 'Bearer repository-token')
+      if (projectError) return Response.json({ errors: [{ message: 'Project access denied' }] })
+      return Response.json({ data: projectGraphqlData({ priority }) })
+    }
+    const number = Number(path.match(/\/issues\/(\d+)$/)?.[1])
+    assert.ok(Object.hasOwn(issues, number), 'Unexpected request: ' + path)
+    const issue = issues[number]
+    return Response.json(issue ?? { message: 'Not Found' }, { status: issue === null ? 404 : 200 })
+  })
+  return { requests, output, workflowOutput: () => readFileSync(process.env.GITHUB_OUTPUT, 'utf8') }
+}
+
+for (const [name, pull, requested, count] of [
+  ['draft', { draft: true }, true, 1],
+  ['Bot', { user: { type: 'Bot' } }, true, 1],
+  ['App', { user: { type: 'App' } }, true, 1],
+  ['not reviewed', {}, false, 3],
+]) {
+  test('reads no Issue or Project for a currently exempt ' + name + ' PR', async (t) => {
+    const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #999', ...pull }, requested })
+    const event = { pull_request: { number: 10, draft: false, user: { type: 'User' } } }
+    assert.deepEqual(await runPullRequestPreflight(event), { eligible: false, needsProject: false })
+    assert.equal(fixture.requests.length, count)
+    assert.equal(fixture.workflowOutput(), 'eligible=false\nexempt=true\nneeds-project=false\n')
+    await runPullRequestCheck(event)
+    assert.equal(fixture.requests.length, count * 2)
+    assert.ok(fixture.output.every((text) => text.includes('Issue policy exempt')))
+  })
+}
+
+test('validates informational Issues and ignores PR numbers without Project reads', async (t) => {
+  const fixture = mockPolicyApi(t, { pull: { body: 'Refs #2; Fixes #3' }, issues: { 2: {}, 3: { pull_request: {} } } })
+  const event = { pull_request: { number: 10, draft: true, body: 'Fixes #999' } }
+  assert.deepEqual(await runPullRequestPreflight(event), { eligible: true, needsProject: false })
+  assert.equal(fixture.requests.length, 5)
+  assert.equal(fixture.workflowOutput(), 'eligible=true\nexempt=false\nneeds-project=false\n')
+  await runPullRequestCheck(event)
+  assert.equal(fixture.requests.length, 10)
+  assert.ok(!fixture.requests.includes('/graphql'))
+})
+
+test('requires a real Issue and explains why stacked PR references do not qualify', async (t) => {
+  const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #3' }, issues: { 3: { pull_request: {} } } })
+  await assert.rejects(runPullRequestCheck({ pull_request: { number: 10 } }), /Issue policy 未通过/)
+  assert.equal(fixture.requests.length, 4)
+  assert.match(fixture.output.join(''), /PR 编号(包括堆叠依赖 PR)不算 Issue 引用/)
+})
+
+test('fetches Project Priority only for resolving Issues and enforces mismatch', async (t) => {
+  const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #2; Refs #4; Fixes #3' }, issues: { 2: {}, 4: {}, 3: { pull_request: {} } } })
+  const event = { pull_request: { number: 10 } }
+  assert.deepEqual(await runPullRequestPreflight(event), { eligible: true, needsProject: true })
+  assert.equal(fixture.requests.length, 6)
+  assert.equal(fixture.workflowOutput(), 'eligible=true\nexempt=false\nneeds-project=true\n')
+  assert.ok(!fixture.requests.includes('/graphql'))
+  await assert.rejects(runPullRequestCheck(event), /Issue policy 未通过/)
+  assert.equal(fixture.requests.length, 13)
+  assert.equal(fixture.requests.filter((path) => path === '/graphql').length, 1)
+  assert.match(fixture.output.join(''), /PR Priority 应为 p1/)
+})
+
+test('enforces current metadata on title edits and prior reviews without requested reviewers', async (t) => {
+  const fixture = mockPolicyApi(t, { requested: false, reviews: [{}], pull: { labels: [] }, issues: { 2: {} } })
+  await assert.rejects(runPullRequestCheck({ action: 'edited', changes: { title: { from: 'old' } }, pull_request: { number: 10 } }), /Issue policy 未通过/)
+  assert.equal(fixture.requests.length, 4)
+  assert.match(fixture.output.join(''), /PR 必须至少有一个 area/)
+})
+
+test('fails closed on missing referenced numbers and unavailable Project access', async (t) => {
+  const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #2' }, issues: { 2: null } })
+  await assert.rejects(runPullRequestPreflight({ pull_request: { number: 10 } }), /404/)
+  assert.equal(fixture.requests.length, 4)
+})
+
+test('fails closed when current resolving Issues need a Project token preflight did not mint', async (t) => {
+  const pull = { draft: true, body: 'Fixes #2' }
+  const fixture = mockPolicyApi(t, { pull, issues: { 2: {} }, projectError: true })
+  const event = { pull_request: { number: 10 } }
+  assert.deepEqual(await runPullRequestPreflight(event), { eligible: false, needsProject: false })
+  pull.draft = false
+  await assert.rejects(runPullRequestCheck(event), /Project access denied/)
+  assert.equal(fixture.requests.length, 6)
+})
+
+test('performs no lifecycle requests for removed signals or title-only edits', async (t) => {
+  const fixture = mockPolicyApi(t)
+  for (const action of ['synchronize', 'labeled', 'unlabeled']) {
+    await runLifecycle('pull_request', { action, pull_request: { number: 10 } })
+  }
+  await runLifecycle('pull_request', { action: 'edited', changes: { title: { from: '' } }, pull_request: { number: 10 } })
+  for (const state of ['approved', 'commented']) {
+    await runLifecycle('pull_request_review', { action: 'submitted', review: { state }, pull_request: { number: 10 } })
+  }
+  assert.deepEqual(fixture.requests, [])
+})
+
+test('keeps trusted preflight before token minting and required policy unconditional', () => {
+  const source = readFileSync(new URL('../workflows/issue-policy.yml', import.meta.url), 'utf8')
+  const job = source.slice(source.indexOf('  policy:'))
+  assert.ok(job.includes('    name: Issue policy'))
+  assert.ok(!job.slice(0, job.indexOf('    steps:')).includes('    if:'))
+  assert.ok(source.includes('types: [opened, edited, synchronize, reopened, labeled, unlabeled, ready_for_review, review_requested]'))
+  const steps = job.split('      - name: ').slice(1)
+  assert.equal(steps.length, 4)
+  assert.ok(steps[0].includes('ref: ${{ github.event.repository.default_branch }}'))
+  assert.ok(steps[0].includes('persist-credentials: false'))
+  assert.doesNotMatch(source, /pull_request\.head|pull_request_target/)
+  assert.ok(steps[1].includes('id: preflight'))
+  assert.ok(steps[1].includes('GITHUB_TOKEN: ${{ github.token }}'))
+  assert.ok(steps[1].includes('run: node .github/issue-management/policy.mjs pr-preflight'))
+  assert.doesNotMatch(steps[1], /secrets\.|PROJECT_TOKEN|if:/)
+  assert.ok(steps[2].includes("if: ${{ steps.preflight.outputs.needs-project == 'true' }}"))
+  assert.ok(steps[2].includes('permission-organization-projects: read'))
+  assert.ok(steps[3].includes('PROJECT_TOKEN: ${{ steps.app-token.outputs.token }}'))
+  assert.ok(steps[3].includes('run: node .github/issue-management/policy.mjs pr'))
+  assert.ok(!steps[3].includes('if:'))
+})
+
+test('allocates lifecycle runners only for relevant reviews and PR body edits', () => {
+  const source = readFileSync(new URL('../workflows/issue-lifecycle.yml', import.meta.url), 'utf8')
+  const issues = source.split('  issues:')[1].split('  pull_request:')[0]
+  const pulls = source.split('  pull_request:')[1].split('  pull_request_review:')[0]
+  const actions = (block) => [...block.matchAll(/^      - (\w+)$/gm)].map((match) => match[1])
+  assert.deepEqual(actions(issues), ['opened', 'edited', 'labeled', 'unlabeled', 'closed', 'reopened', 'typed', 'untyped', 'field_added', 'field_removed'])
+  assert.deepEqual(actions(pulls), ['opened', 'edited', 'reopened', 'review_requested'])
+  const job = source.slice(source.indexOf('  lifecycle:'))
+  const beforeSteps = job.slice(0, job.indexOf('    steps:'))
+  assert.ok(beforeSteps.includes('    if: >-'))
+  assert.ok(beforeSteps.includes("(github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested') &&"))
+  assert.ok(beforeSteps.includes("(github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.body != null)"))
+  assert.ok(source.includes('ref: ${{ github.event.repository.default_branch }}'))
+  assert.ok(source.includes('persist-credentials: false'))
+})
+
 test('allows missing Priority only when resolving Issues are also unprioritized', () => {
   const pull = {
     isDraft: false,

+ 5 - 12
.github/workflows/issue-lifecycle.yml

@@ -5,22 +5,19 @@ on:
     types:
       - opened
       - edited
-      - assigned
-      - unassigned
       - labeled
       - unlabeled
       - closed
       - reopened
+      - typed
+      - untyped
       - field_added
       - field_removed
   pull_request:
     types:
       - opened
       - edited
-      - synchronize
       - reopened
-      - labeled
-      - unlabeled
       - review_requested
   pull_request_review:
     types:
@@ -36,11 +33,9 @@ concurrency:
 jobs:
   lifecycle:
     name: Issue lifecycle
-    # Runs on every pull_request_review event so the check reports success rather
-    # than a gray "skipped" segment. The token-creating and board-mutating steps
-    # are gated at step level (a skipped step does not gray the job): only a
-    # changes_requested review drives the Project board; approved/commented
-    # reviews never mint a write-capable App token.
+    if: >-
+      (github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested') &&
+      (github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.body != null)
     runs-on: ubuntu-latest
     steps:
       - name: Check out trusted policy
@@ -50,7 +45,6 @@ jobs:
           persist-credentials: false
       - name: Create project token
         id: app-token
-        if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}
         uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
         with:
           client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }}
@@ -58,7 +52,6 @@ jobs:
           owner: deepseek-harness
           repositories: deepseek-harness
       - name: Handle repository event
-        if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}
         env:
           GH_TOKEN: ${{ steps.app-token.outputs.token }}
         run: node .github/issue-management/policy.mjs lifecycle

+ 7 - 2
.github/workflows/issue-policy.yml

@@ -21,9 +21,14 @@ jobs:
         with:
           ref: ${{ github.event.repository.default_branch }}
           persist-credentials: false
+      - name: Determine policy eligibility
+        id: preflight
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+        run: node .github/issue-management/policy.mjs pr-preflight
       - name: Create Project read token
         id: app-token
-        if: ${{ github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'App' }}
+        if: ${{ steps.preflight.outputs.needs-project == 'true' }}
         uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
         with:
           client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }}
@@ -32,8 +37,8 @@ jobs:
           repositories: deepseek-harness
           permission-issues: read
           permission-organization-projects: read
+      # Re-read current state even after an exempt preflight; never replace a failure with an edit skip.
       - name: Validate pull request
-        if: ${{ github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'App' }}
         env:
           GITHUB_TOKEN: ${{ github.token }}
           PROJECT_TOKEN: ${{ steps.app-token.outputs.token }}

+ 20 - 14
scripts/ci-workflow.spec.ts

@@ -935,19 +935,16 @@ describe('Weighted approval workflow', () => {
 })
 
 describe('Issue lifecycle workflow', () => {
-  it('runs the lifecycle job on every PR/review event but gates token and board steps', () => {
+  it('allocates lifecycle runners only for events that can change the board', () => {
     const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml')
     const policy = loadWorkflow('.github/workflows/issue-policy.yml')
     const lifecycleJob = workflowJob(lifecycle, 'lifecycle')
     if (!Array.isArray(lifecycleJob.steps)) throw new TypeError('Issue lifecycle job must define steps')
 
-    // The job has no job-level `if`, so it is listed on every pull_request /
-    // pull_request_review event and reports success instead of a gray skip. The
-    // write-capable steps are gated at step level so approved/commented reviews
-    // never mint a Project/Issue App token nor touch the board.
     expect(lifecycle.on).toHaveProperty('pull_request')
     expect(lifecycle.on).toHaveProperty('pull_request_review')
-    expect(lifecycleJob.if).toBeUndefined()
+    expect(lifecycleJob.if).toContain("github.event.review.state == 'changes_requested'")
+    expect(lifecycleJob.if).toContain('github.event.changes.body != null')
     // Keep the subscription-type gates: issue-lifecycle does not re-subscribe
     // ready_for_review (issue-policy owns that) and only reacts to submitted
     // review events.
@@ -957,31 +954,41 @@ describe('Issue lifecycle workflow', () => {
     expect(lifecyclePullRequest.types).not.toContain('ready_for_review')
     expect(lifecyclePullRequest.types).toContain('review_requested')
     expect(lifecycleReview.types).toEqual(['submitted'])
-    const gated = "${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}"
+    expect(lifecyclePullRequest.types).not.toContain('synchronize')
+    expect(lifecyclePullRequest.types).not.toContain('labeled')
+    expect(lifecyclePullRequest.types).not.toContain('unlabeled')
+    const issueEvents = workflowEvent(lifecycle, 'issues')
+    expect(issueEvents.types).not.toContain('assigned')
+    expect(issueEvents.types).not.toContain('unassigned')
+    expect(issueEvents.types).toContain('typed')
+    expect(issueEvents.types).toContain('untyped')
     const steps = lifecycleJob.steps.filter(isRecord)
     const tokenStep = steps.find(s => s.name === 'Create project token')
     const handleStep = steps.find(s => s.name === 'Handle repository event')
-    expect(tokenStep).toMatchObject({ if: gated })
-    expect(handleStep).toMatchObject({ if: gated })
+    expect(tokenStep?.if).toBeUndefined()
+    expect(handleStep?.if).toBeUndefined()
 
     // issue-policy owns PR validation; it is read-only and a real gate.
     const policyPullRequest = workflowEvent(policy, 'pull_request')
     expect(policyPullRequest.types).toContain('ready_for_review')
   })
 
-  it('uses a read-only Project token only for human pull request policy metadata', () => {
+  it('mints Project credentials only after preflight and always revalidates current metadata', () => {
     const policy = loadWorkflow('.github/workflows/issue-policy.yml')
     const policyJob = workflowJob(policy, 'policy')
     if (!Array.isArray(policyJob.steps)) throw new TypeError('Issue policy job must define steps')
     const steps = policyJob.steps.filter(isRecord)
     const tokenStep = steps.find(step => step.name === 'Create Project read token')
     const validateStep = steps.find(step => step.name === 'Validate pull request')
-    const humanPullRequest =
-      "${{ github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'App' }}"
+    const preflightStep = steps.find(step => step.id === 'preflight')
+    expect(preflightStep).toMatchObject({ run: 'node .github/issue-management/policy.mjs pr-preflight' })
+    expect(preflightStep?.if).toBeUndefined()
+    expect(policyJob.if).toBeUndefined()
+    expect(validateStep?.if).toBeUndefined()
 
     expect(tokenStep).toMatchObject({
       id: 'app-token',
-      if: humanPullRequest,
+      if: "${{ steps.preflight.outputs.needs-project == 'true' }}",
       uses: 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1',
       with: {
         'client-id': '${{ vars.DSH_ISSUE_APP_CLIENT_ID }}',
@@ -993,7 +1000,6 @@ describe('Issue lifecycle workflow', () => {
       },
     })
     expect(validateStep).toMatchObject({
-      if: humanPullRequest,
       env: {
         GITHUB_TOKEN: '${{ github.token }}',
         PROJECT_TOKEN: '${{ steps.app-token.outputs.token }}',