소스 검색

fix(cic): address gray-check PR review - official build, step-level gate, note sync

Address ds-review-bot findings on PR #2798:

- release-publish.yml: use pnpm run build:official (not build) so the dsh
  pack step's verifyBuildArtifacts (families.ts:327, readClientBuildRecord with
  officialClientBuildEnvironment) finds the official client-build record; build
  would fail Pack release tarballs on a clean runner.
- issue-lifecycle.yml: move the previous job-level if to step level on
  Create project token and Handle repository event, so approved/commented
  reviews pass (job reported success, no gray segment) without minting a
  write-capable App token or touching the board — preserving the original
  least-privilege property.
- ci-workflow.spec.ts: lock the step-level gate on the two lifecycle steps, and
  add a release-workflow invariant test (release.yml/vendor are pack-only;
  release-publish.yml/vendor-publish.yml are workflow_dispatch-only with the
  npm-publish environment and Release-publish group) to prevent #2797 recurrence.
- Update 2026-08-10-event-directed-pr-review-status and 2026-08-10-npm-release-
  sequences notes (en/zh/i18n) to the new split and step-level behavior.

Verification: ci-workflow.spec.ts 14/14, typecheck clean, all five workflows
YAML-parse, verify-translation-pairing consistent, note-format 582.
Chinesezjc 2 주 전
부모
커밋
63d9de0eb3

+ 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: bdaaa07c47d45eb002ed7c026683a800f67d0fca
-2026-08-10-event-directed-pr-review-status.zh.md: 0ca4154a78e82156687b4fc5efb62745f3b2af63
+2026-08-10-event-directed-pr-review-status.md: 3ed6038929d3c2c1e9cd82182978262ee363f5ab
+2026-08-10-event-directed-pr-review-status.zh.md: 1fa8650057e53ab894c597b712720a3ee7a5c46a

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

@@ -22,7 +22,7 @@ The handler resolves only exact same-repository `Fixes`, `Closes`, or `Resolves`
 
 ## 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 absence of a job-level `if` (so approved/commented reviews pass rather than skip), 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 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.
 
 ## Alternatives considered
 

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

@@ -22,7 +22,7 @@ Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review
 
 ## 验证
 
-[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、作业无 job 级 `if`(使 approved/commented 评审以 pass 而非 skip 呈现),以及独立的 `ready_for_review` 策略触发器。
+[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、job 级无 `if` 且 token/看板步骤带 step 级门控(使 approved/commented 评审以 pass 呈现且不铸 token),以及独立的 `ready_for_review` 策略触发器。
 
 ## 考虑过的替代方案
 

+ 2 - 2
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.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-npm-release-sequences.md
-2026-08-10-npm-release-sequences.md: efeda91b6a85e1316c563cc04411878122096953
-2026-08-10-npm-release-sequences.zh.md: d905ac4b58691890d2aad955b87713a278dcb4f8
+2026-08-10-npm-release-sequences.md: c2cf540ff130f2a17a40068a0684c9bbc0e07ee3
+2026-08-10-npm-release-sequences.zh.md: 378c0a007ec30ad05fe9bcb9d068d095ccb658bc

+ 3 - 3
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md

@@ -105,11 +105,11 @@ The entity in this domain is a **release family**: a set of packages sharing one
 
 The dsh family applies the repository's publication payload policy, which rejects sources and declaration maps. The vendored family keeps upstream's payload, because those manifests export `./src/*` and dropping `src` would publish an export map pointing at absent files.
 
-### Workflow shape: pack everything at once, then publish as one set
+### Workflow shape: pack on PR/push, publish from a manual dispatch workflow
 
-The `pack` job walks the whole release set once, packing each member into one directory, writes the upload order, and uploads that directory as one artifact; the `publish` job downloads that artifact and publishes each entry in order. The release set is one unit — half the packages can never reach the registry while the other half is still building.
+The `pack` job walks the whole release set once, packing each member into one directory, writes the upload order, and uploads that directory as one artifact; it lives in `release.yml` / `release-vendor.yml`. The release set is one unit — half the packages can never reach the registry while the other half is still building.
 
-`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. `publish` is a manual dispatch, sits behind the `npm-publish` environment for human approval, and neither builds nor rebuilds — it uploads the bytes pack produced. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the publish job carries the global group, because dist-tags are shared registry state.
+`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. Publication lives in a separate `release-publish.yml` / `release-vendor-publish.yml` workflow that is `workflow_dispatch`-only (so it never appears as a PR check): it repacks the current tree and then publishes each entry in order, behind the `npm-publish` environment for human approval. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the publish workflow carries the global `Release-publish` group, because dist-tags are shared registry state.
 
 A dsh verification installs the vendored family's pack output too. The harness packages declare the vendored framework as a peer, those packages live in another sequence, and the credential-free job cannot fetch them from a private registry — so `release.yml` packs the vendored family for verification while publishing only its own set.
 

+ 3 - 3
.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md

@@ -105,11 +105,11 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间
 
 dsh 族套用仓库的发布 payload 策略(拒绝源码与声明映射)。vendored 族保留上游 payload,因为那些 manifest 导出 `./src/*`,去掉 `src` 会发出一个导出映射指向不存在文件的包。
 
-### workflow 形状:一次性 pack 全部,再统一 publish
+### workflow 形状:PR/push 上 pack,从手动 dispatch 工作流发布
 
-`pack` job 一趟遍历整个发布集,把每个成员打进同一个目录,写出上传顺序,整个目录作为一份 artifact 上传;`publish` job 下载那一份 artifact,按顺序逐个发布。发布集是一个整体——绝不会出现一半的包已经上了 registry、另一半还在构建。
+`pack` job 一趟遍历整个发布集,把每个成员打进同一个目录,写出上传顺序,整个目录作为一份 artifact 上传;它位于 `release.yml` / `release-vendor.yml`。发布集是一个整体——绝不会出现一半的包已经上了 registry、另一半还在构建。
 
-`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。`publish` 是手动 dispatch,挂在 `npm-publish` environment 后面等人工审批,且既不构建也不重建——它上传的就是 pack 产出的字节。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局分组落在 publish job 上,因为 dist-tag 是共享的 registry 状态。
+`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。发布则位于独立的 `release-publish.yml` / `release-vendor-publish.yml` 工作流,仅 `workflow_dispatch`(因此不会作为 PR check 出现):它重新打包当前树,再按顺序逐个发布,挂在 `npm-publish` environment 后面等人工审批。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局 `Release-publish` 分组落在发布工作流上,因为 dist-tag 是共享的 registry 状态。
 
 dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 vendored 框架声明成 peer,而那些包属于另一条序列,无凭据的 job 无法从私有 registry 取到——所以 `release.yml` 为验证而打包 vendored 族,发布的仍只有自己那一份。
 

+ 7 - 4
.github/workflows/issue-lifecycle.yml

@@ -36,10 +36,11 @@ concurrency:
 jobs:
   lifecycle:
     name: Issue lifecycle
-    # Run on every pull_request_review event, not only changes_requested, so the
-    # check shows a passing result instead of a gray "skipped" segment. The
-    # lifecycle handler itself no-ops (returns success) for approved/commented
-    # reviews; only a changes_requested review drives the Project board.
+    # 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.
     runs-on: ubuntu-latest
     steps:
       - name: Check out trusted policy
@@ -49,6 +50,7 @@ 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 }}
@@ -56,6 +58,7 @@ 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

+ 1 - 1
.github/workflows/release-publish.yml

@@ -58,7 +58,7 @@ jobs:
         run: pnpm run release:verify --family dsh
 
       - name: Build
-        run: pnpm run build
+        run: pnpm run build:official
 
       - name: Pack release tarballs
         run: pnpm run release:pack --family dsh --out dist/npm

+ 35 - 4
scripts/ci-workflow.spec.ts

@@ -417,17 +417,25 @@ describe('Python release workflows', () => {
 })
 
 describe('Issue lifecycle workflow', () => {
-  it('runs the lifecycle job on every PR/review event so it passes instead of skipping', () => {
+  it('runs the lifecycle job on every PR/review event but gates token and board steps', () => {
     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 lifecycle job has no workflow-level `if`, so it is listed on every
-    // pull_request / pull_request_review event and reports success (the handler
-    // no-ops for non-changes-requested reviews) instead of a gray "skipped" check.
+    // 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()
+    const gated = "${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}"
+    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 })
 
     // issue-policy owns PR validation; it is read-only and a real gate.
     const policyPullRequest = workflowEvent(policy, 'pull_request')
@@ -435,6 +443,29 @@ describe('Issue lifecycle workflow', () => {
   })
 })
 
+describe('npm release workflows', () => {
+  it('keeps publication dispatch-only and pack in the PR workflow', () => {
+    // pack stays in the PR/master release workflows so a PR proves the set packs.
+    for (const file of ['release.yml', 'release-vendor.yml']) {
+      const workflow = loadWorkflow(`.github/workflows/${file}`)
+      if (!isRecord(workflow.jobs)) throw new TypeError(`${file} must define jobs`)
+      expect(Object.keys(workflow.jobs).sort()).toEqual(['pack'])
+    }
+
+    // publication is workflow_dispatch-only (never a PR check) and keeps the
+    // npm-publish environment plus the shared dist-tag group.
+    for (const file of ['release-publish.yml', 'release-vendor-publish.yml']) {
+      const workflow = loadWorkflow(`.github/workflows/${file}`)
+      if (!isRecord(workflow.on) || !isRecord(workflow.jobs)) throw new TypeError(`${file} must define on and jobs`)
+      expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch'])
+      const publish = workflow.jobs.publish
+      if (!isRecord(publish)) throw new TypeError(`${file} must define a publish job`)
+      expect(publish.environment).toBe('npm-publish')
+      expect(publish.concurrency).toMatchObject({ group: 'Release-publish' })
+    }
+  })
+})
+
 describe('Git hooks', () => {
   it('leaves frozen Agent Note sidecars to the archive verifier', () => {
     const lefthook = loadWorkflow('lefthook.yml')