Răsfoiți Sursa

fix(ci): preserve trusted legacy policy during preflight rollout

Tianyi Cui 2 săptămâni în urmă
părinte
comite
2eeab3e65f

+ 2 - 2
.agents/notes/implemented/process/2026-09-07-selective-issue-policy-evaluation.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-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
+2026-09-07-selective-issue-policy-evaluation.md: a044e1d12c64d1736c0520d8a3f73713f22294d4
+2026-09-07-selective-issue-policy-evaluation.zh.md: 0c4c8d27d214a861f14cd3455f22a1007bf95bab

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

@@ -14,6 +14,8 @@ Lifecycle events have a separate cost: an approval, comment, push, label change,
 
 [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.
 
+The workflow checks the trusted checkout for a selective-preflight capability marker before invoking the command. A checkout without the marker uses full legacy validation for human PRs and preserves the legacy Bot/App exemption. This supports PR workflow YAML running against default-branch code that lacks preflight; execution errors never select the fallback.
+
 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.

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

@@ -14,6 +14,8 @@ Status: implemented
 
 [Issue policy](../../../../.github/workflows/issue-policy.yml)保留必需 job 与受信任的默认分支实现。强制范围判定先于引用读取与 Project App token 创建:草稿 PR、Bot/App 作者,以及既无评审请求也无已提交评审的人类 PR 均不需要策略校验。
 
+工作流在调用命令前检查受信任检出中的选择性预检能力标记。缺少标记的检出对人类 PR 执行完整旧版校验,并保留旧版 Bot/App 豁免。这支持 PR 工作流 YAML 与缺少预检功能的默认分支代码配合执行;执行错误不会触发回退。
+
 强制范围内的 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 评审。请求修改的评审保留其状态命令。

+ 2 - 2
.github/issue-management/README.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 .github/issue-management/README.md
-README.md: b81d8a8255d4fee664b10fd3795aefc71540b4c4
-README.zh.md: ea3e251aa53b9de979c1c30d3e7008001af4285d
+README.md: cd064f28510143ce8e0e5e791b7bd00a0c9cc307
+README.zh.md: a7901a033a71d27ba01cc3bb69a03b3fd8072c24

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

@@ -25,6 +25,8 @@ Contributors can link Issues as context without coupling pull-request validation
 
 [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.
 
+Selective preflight requires [selective-preflight.json](selective-preflight.json) in the trusted checkout. Without that marker, the workflow preserves legacy behavior: human PRs receive a Project token and full legacy validation; Bot/App PRs skip both. A failed supported preflight fails the job rather than falling back.
+
 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.

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

@@ -25,6 +25,8 @@ description: "面向仓库维护者的 Issue 策略强制范围、Project 访问
 
 [Issue policy](../workflows/issue-policy.yml)适用于已请求评审或已有评审、非草稿且由人类创建的 PR。豁免 PR 成功结束,不解析 Issue 引用、不签发 Project App token,也不查询 ProjectV2。工作流在昂贵读取前根据仓库实时状态判断强制范围;订阅事件仍保留必需 job。最终校验重新读取实时状态:预检不是缓存结论,也不是元数据编辑的豁免。
 
+选择性预检要求受信任的检出中存在 [selective-preflight.json](selective-preflight.json)。缺少该标记时,工作流保留旧版行为:人类 PR 获取 Project token 并执行完整旧版校验;Bot/App PR 跳过两者。受支持的预检执行失败时,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。

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

@@ -1,5 +1,6 @@
 import assert from 'node:assert/strict'
-import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs'
+import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
+import { spawnSync } from 'node:child_process'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import test from 'node:test'
@@ -903,13 +904,56 @@ test('keeps trusted preflight before token minting and required policy unconditi
   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.ok(steps[1].includes('node .github/issue-management/policy.mjs pr-preflight'))
+  assert.ok(steps[1].includes('if [ -f .github/issue-management/selective-preflight.json ]; then'))
   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:'))
+  assert.ok(steps[3].includes("if: ${{ steps.preflight.outputs.legacy-automated != 'true' }}"))
+})
+
+test('runs trusted rollout selection with absent and present capability markers', { skip: process.platform === 'win32' ? 'The policy workflow executes under hosted Ubuntu bash' : false }, (t) => {
+  const directory = mkdtempSync(join(tmpdir(), 'dsh-policy-rollout-'))
+  t.after(() => rmSync(directory, { recursive: true, force: true }))
+  const source = readFileSync(new URL('../workflows/issue-policy.yml', import.meta.url), 'utf8')
+  const script = source.split('        run: |\n')[1].split('      - name: Create Project read token')[0]
+    .split('\n').map((line) => line.slice(10)).join('\n')
+  assert.deepEqual(JSON.parse(readFileSync(new URL('./selective-preflight.json', import.meta.url), 'utf8')), { version: 1 })
+  const cases = [
+    { name: 'legacy human draft', type: 'User', draft: true, marker: false, expected: 'legacy-automated=false\nneeds-project=true\n' },
+    { name: 'legacy human ready', type: 'User', draft: false, marker: false, expected: 'legacy-automated=false\nneeds-project=true\n' },
+    { name: 'legacy bot', type: 'Bot', marker: false, expected: 'legacy-automated=true\nneeds-project=false\n' },
+    { name: 'legacy app', type: 'App', marker: false, expected: 'legacy-automated=true\nneeds-project=false\n' },
+    { name: 'modern exempt', type: 'Bot', marker: true, expected: 'exempt=true\nneeds-project=false\n' },
+    { name: 'modern failure', type: 'User', marker: true, failure: true, expected: '' },
+  ]
+  for (const [index, fixture] of cases.entries()) {
+    const cwd = join(directory, String(index))
+    const policyDirectory = join(cwd, '.github', 'issue-management')
+    mkdirSync(policyDirectory, { recursive: true })
+    const eventPath = join(cwd, 'event.json')
+    const outputPath = join(cwd, 'output')
+    writeFileSync(eventPath, JSON.stringify({ pull_request: { user: { type: fixture.type }, draft: fixture.draft } }))
+    writeFileSync(outputPath, '')
+    if (fixture.marker) writeFileSync(join(policyDirectory, 'selective-preflight.json'), '{"version":1}\n')
+    writeFileSync(join(policyDirectory, 'policy.mjs'), fixture.marker && !fixture.failure
+      ? "import fs from 'node:fs'; if (process.argv[2] !== 'pr-preflight') throw Error('wrong command'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'exempt=true\\nneeds-project=false\\n')\n"
+      : "throw new Error('preflight unavailable or failed')\n")
+    const result = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
+      cwd,
+      env: { PATH: process.env.PATH, GITHUB_EVENT_PATH: eventPath, GITHUB_OUTPUT: outputPath },
+      encoding: 'utf8',
+      timeout: 30_000,
+    })
+    assert.equal(result.error, undefined, fixture.name)
+    assert.equal(result.signal, null, fixture.name)
+    assert.equal(result.status, fixture.failure ? 1 : 0, fixture.name + ': ' + result.stderr)
+    assert.equal(readFileSync(outputPath, 'utf8'), fixture.expected, fixture.name)
+    if (fixture.marker) assert.doesNotMatch(result.stdout, /preserving legacy/)
+    else assert.match(result.stdout, /preserving legacy policy enforcement/)
+  }
 })
 
 test('allocates lifecycle runners only for relevant reviews and PR body edits', () => {

+ 1 - 0
.github/issue-management/selective-preflight.json

@@ -0,0 +1 @@
+{ "version": 1 }

+ 14 - 1
.github/workflows/issue-policy.yml

@@ -25,7 +25,19 @@ jobs:
         id: preflight
         env:
           GITHUB_TOKEN: ${{ github.token }}
-        run: node .github/issue-management/policy.mjs pr-preflight
+        shell: bash
+        run: |
+          if [ -f .github/issue-management/selective-preflight.json ]; then
+            node .github/issue-management/policy.mjs pr-preflight
+          else
+            node --input-type=module <<'NODE'
+          import fs from 'node:fs'
+          const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))
+          const automated = ['Bot', 'App'].includes(event.pull_request.user.type)
+          fs.appendFileSync(process.env.GITHUB_OUTPUT, `legacy-automated=${automated}\nneeds-project=${!automated}\n`)
+          console.log('Trusted policy has no selective preflight; preserving legacy policy enforcement.')
+          NODE
+          fi
       - name: Create Project read token
         id: app-token
         if: ${{ steps.preflight.outputs.needs-project == 'true' }}
@@ -39,6 +51,7 @@ jobs:
           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: ${{ steps.preflight.outputs.legacy-automated != 'true' }}
         env:
           GITHUB_TOKEN: ${{ github.token }}
           PROJECT_TOKEN: ${{ steps.app-token.outputs.token }}

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

@@ -981,10 +981,12 @@ describe('Issue lifecycle workflow', () => {
     const tokenStep = steps.find(step => step.name === 'Create Project read token')
     const validateStep = steps.find(step => step.name === 'Validate pull request')
     const preflightStep = steps.find(step => step.id === 'preflight')
-    expect(preflightStep).toMatchObject({ run: 'node .github/issue-management/policy.mjs pr-preflight' })
+    expect(preflightStep).toMatchObject({ shell: 'bash' })
+    expect(preflightStep?.run).toContain('if [ -f .github/issue-management/selective-preflight.json ]; then')
+    expect(preflightStep?.run).toContain('node .github/issue-management/policy.mjs pr-preflight')
     expect(preflightStep?.if).toBeUndefined()
     expect(policyJob.if).toBeUndefined()
-    expect(validateStep?.if).toBeUndefined()
+    expect(validateStep?.if).toBe("${{ steps.preflight.outputs.legacy-automated != 'true' }}")
 
     expect(tokenStep).toMatchObject({
       id: 'app-token',