Sfoglia il codice sorgente

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

Tianyi Cui 1 mese fa
parent
commit
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;
 # 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:
 # 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
 #   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.
 [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.
 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.
 [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 均不需要策略校验。
 [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)定义元数据校验与失败行为。
 强制范围内的 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 评审。请求修改的评审保留其状态命令。
 [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;
 # 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:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .github/issue-management/README.md
 #   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.
 [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.
 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.
 - 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。最终校验重新读取实时状态:预检不是缓存结论,也不是元数据编辑的豁免。
 [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)定义其含义。
 强制范围内的 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。
 - 信息型引用(如 `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 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 { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { join } from 'node:path'
 import test from 'node:test'
 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.doesNotMatch(source, /pull_request\.head|pull_request_target/)
   assert.ok(steps[1].includes('id: preflight'))
   assert.ok(steps[1].includes('id: preflight'))
   assert.ok(steps[1].includes('GITHUB_TOKEN: ${{ github.token }}'))
   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.doesNotMatch(steps[1], /secrets\.|PROJECT_TOKEN|if:/)
   assert.ok(steps[2].includes("if: ${{ steps.preflight.outputs.needs-project == 'true' }}"))
   assert.ok(steps[2].includes("if: ${{ steps.preflight.outputs.needs-project == 'true' }}"))
   assert.ok(steps[2].includes('permission-organization-projects: read'))
   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('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('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', () => {
 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
         id: preflight
         env:
         env:
           GITHUB_TOKEN: ${{ github.token }}
           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
       - name: Create Project read token
         id: app-token
         id: app-token
         if: ${{ steps.preflight.outputs.needs-project == 'true' }}
         if: ${{ steps.preflight.outputs.needs-project == 'true' }}
@@ -39,6 +51,7 @@ jobs:
           permission-organization-projects: read
           permission-organization-projects: read
       # Re-read current state even after an exempt preflight; never replace a failure with an edit skip.
       # Re-read current state even after an exempt preflight; never replace a failure with an edit skip.
       - name: Validate pull request
       - name: Validate pull request
+        if: ${{ steps.preflight.outputs.legacy-automated != 'true' }}
         env:
         env:
           GITHUB_TOKEN: ${{ github.token }}
           GITHUB_TOKEN: ${{ github.token }}
           PROJECT_TOKEN: ${{ steps.app-token.outputs.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 tokenStep = steps.find(step => step.name === 'Create Project read token')
     const validateStep = steps.find(step => step.name === 'Validate pull request')
     const validateStep = steps.find(step => step.name === 'Validate pull request')
     const preflightStep = steps.find(step => step.id === 'preflight')
     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(preflightStep?.if).toBeUndefined()
     expect(policyJob.if).toBeUndefined()
     expect(policyJob.if).toBeUndefined()
-    expect(validateStep?.if).toBeUndefined()
+    expect(validateStep?.if).toBe("${{ steps.preflight.outputs.legacy-automated != 'true' }}")
 
 
     expect(tokenStep).toMatchObject({
     expect(tokenStep).toMatchObject({
       id: 'app-token',
       id: 'app-token',