From d4f44d423116fdffd0f923bc136c9a42271392d9 Mon Sep 17 00:00:00 2001 From: gansx <798622053@qq.com> Date: Mon, 26 Oct 2026 21:25:34 +0800 Subject: [PATCH 1/2] ci: keep label events out of the required CI gate Label-triggered CI runs renamed the aggregate gate to a label marker, so the newest CI run on a Dependabot head lacked the required 'Typecheck, unit tests, and Web build' context and the protect-main ruleset reported it as expected, blocking merges on fully green PRs. Move label handling to a dedicated full-ci-label workflow that dispatches a full validation run for the full-ci label and produces no CI runs for unrelated labels. --- .github/workflows/ci.yml | 20 ++++++------------ .github/workflows/full-ci-label.yml | 29 ++++++++++++++++++++++++++ docs/maintenance/ci.md | 2 +- scripts/ci/execution-workflow.test.mjs | 23 ++++++++++++-------- 4 files changed, 50 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/full-ci-label.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7476f9cb1..920970ae1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: pull_request: - types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, labeled] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] workflow_dispatch: schedule: # Daily full Linux/macOS regression at 11:17 Asia/Shanghai (03:17 UTC). @@ -10,9 +10,9 @@ on: - cron: "17 3 * * *" concurrency: - # Unrelated labels must neither cancel validation nor replace its check result. - group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name != 'full-ci' && github.run_id || 'validation' }} - cancel-in-progress: ${{ github.event_name != 'pull_request' || github.event.action != 'labeled' || github.event.label.name == 'full-ci' }} + # Label handling lives in full-ci-label.yml so the latest CI run on every head always publishes the required gate. + group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true permissions: contents: read @@ -23,7 +23,6 @@ env: jobs: scope: name: Determine CI scope - if: github.event_name != 'pull_request' || github.event.action != 'labeled' || github.event.label.name == 'full-ci' runs-on: ubuntu-latest timeout-minutes: 5 outputs: @@ -60,7 +59,7 @@ jobs: shell: bash env: BASE_SHA: ${{ github.event.pull_request.base.sha }} - FORCE_FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') || (github.event.action == 'labeled' && github.event.label.name == 'full-ci') }} + FORCE_FULL: ${{ contains(github.event.pull_request.labels.*.name, 'full-ci') }} run: | set -euo pipefail if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then @@ -578,28 +577,25 @@ jobs: path: ${{ runner.temp }}/macos-bootstrap-diagnostic.json quality: - name: ${{ github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name != 'full-ci' && 'CI label event (no validation)' || 'Typecheck, unit tests, and Web build' }} + name: Typecheck, unit tests, and Web build needs: [scope, common-fast, dependency-policy, build, typecheck, unit-tests, daemon-tests, daemon-linux-contracts, restricted-command-macos] if: always() runs-on: ubuntu-latest timeout-minutes: 5 steps: - name: Check out quality policy without retained credentials - if: github.event_name != 'pull_request' || github.event.action != 'labeled' || github.event.label.name == 'full-ci' uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 1 persist-credentials: false - name: Set up Node.js - if: github.event_name != 'pull_request' || github.event.action != 'labeled' || github.event.label.name == 'full-ci' uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 with: node-version: 22.22.1 - name: Require every applicable CI lane to pass env: - IGNORED_LABEL_EVENT: ${{ github.event_name == 'pull_request' && github.event.action == 'labeled' && github.event.label.name != 'full-ci' }} EXECUTION_TIER: ${{ needs.scope.outputs.execution-tier }} SCOPE_MODE: ${{ needs.scope.outputs.mode }} SCOPE_RESULT: ${{ needs.scope.result }} @@ -615,8 +611,4 @@ jobs: shell: bash run: | set -euo pipefail - if [[ "${IGNORED_LABEL_EVENT:-false}" == "true" ]]; then - echo "Unrelated label event; no validation check result is replaced." - exit 0 - fi node scripts/ci/execution-policy.mjs --check-quality diff --git a/.github/workflows/full-ci-label.yml b/.github/workflows/full-ci-label.yml new file mode 100644 index 000000000..a31776980 --- /dev/null +++ b/.github/workflows/full-ci-label.yml @@ -0,0 +1,29 @@ +name: Full CI label + +# Keeps label events out of ci.yml: the required gate resolves against the +# latest CI run on each head, and label runs must never exist without it. +on: + pull_request: + types: [labeled] + +permissions: + contents: read + +jobs: + dispatch: + name: Dispatch full CI for full-ci label + if: github.event.label.name == 'full-ci' + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + actions: write + contents: read + steps: + - name: Trigger full CI on the PR head + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + gh workflow run ci.yml --ref "$HEAD_SHA" + printf 'Full CI dispatched for %s\n' "$HEAD_SHA" >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/maintenance/ci.md b/docs/maintenance/ci.md index 27f648499..11aa3d539 100644 --- a/docs/maintenance/ci.md +++ b/docs/maintenance/ci.md @@ -110,7 +110,7 @@ Draft 减少执行不改变改动范围。不能把大 PR 的范围伪装成 `do PR 工作流保留稳定入口,在 job 层选择任务;不要用顶层 `paths` 跳过受保护的整个工作流。以下事件和质量门规则已在 P1 接入,其余平台分组仍待 P2 验证。 -- 覆盖 `opened`、`synchronize`、`reopened`、`ready_for_review`、`converted_to_draft` 和 `labeled`。新增 `full-ci` 立即升级检查;无关标签使用独立并发组,汇总任务改名为 `CI label event (no validation)`,不取消或覆盖当前完整验证。`unlabeled` 不触发工作流,移除标签只影响下一次正常运行。 +- 覆盖 `opened`、`synchronize`、`reopened`、`ready_for_review`、`converted_to_draft`。标签事件不进入 CI 工作流:必需门按名称在该 head 最新一次 CI 运行中解析,任何缺门 job 的运行都会被规则判为缺失并拦截合并。`full-ci` 标签由独立工作流 `full-ci-label.yml` 接管,对 PR head 分发一次完整 `workflow_dispatch` 运行;无关标签不产生任何 CI 运行。`unlabeled` 不触发工作流,移除标签只影响下一次正常运行。 - Draft 使用明确的轻量反馈结果。稳定的完整质量门不能通过 `if` 跳过来表示完成,也不能把延后的任务视为范围外。`quality` 明确失败并说明 `deferred_by_draft_policy`,轻量任务单独显示结果;转正式评审或显式完整运行后补齐。 - 汇总门使用 `always()` 和显式的必需任务集合。选中的任务只有 `success` 可通过;`failure`、`cancelled`、`skipped`、空结果均拒绝。只有分类确认范围外的任务才允许跳过。 - 分类、公共检查、清单校验失败时,汇总门必须失败。文档范围也不能因上游失败而得到成功。 diff --git a/scripts/ci/execution-workflow.test.mjs b/scripts/ci/execution-workflow.test.mjs index 4d89cef3c..8c112b1ba 100644 --- a/scripts/ci/execution-workflow.test.mjs +++ b/scripts/ci/execution-workflow.test.mjs @@ -5,6 +5,7 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; const workflow = readFileSync(new URL("../../.github/workflows/ci.yml", import.meta.url), "utf8"); +const labelWorkflow = readFileSync(new URL("../../.github/workflows/full-ci-label.yml", import.meta.url), "utf8"); const root = fileURLToPath(new URL("../../", import.meta.url)); const lanes = ["SCOPE", "COMMON", "DEPENDENCY", "BUILD", "TYPECHECK", "UNIT", "DAEMON", "LINUX_CONTRACT", "MACOS_NATIVE"]; const platformJobs = ["daemon-tests", "daemon-linux-contracts", "restricted-command-macos"]; @@ -29,7 +30,9 @@ function runQuality(overrides = {}) { } test("PR transitions and full-ci labels trigger validation while full regression is daily", () => { - assert.match(workflow, /types: \[opened, synchronize, reopened, ready_for_review, converted_to_draft, labeled\]/u); + assert.match(workflow, /types: \[opened, synchronize, reopened, ready_for_review, converted_to_draft\]/u); + assert.match(labelWorkflow, /types: \[labeled\]/u); + assert.match(labelWorkflow, /gh workflow run ci\.yml --ref "\$HEAD_SHA"/u); assert.match(workflow, /cron: "17 3 \* \* \*"/u); assert.doesNotMatch(workflow, /^ (?:push|paths|paths-ignore):/mu); assert.match(jobSource("scope"), /--event-file "\$GITHUB_EVENT_PATH"/u); @@ -55,14 +58,16 @@ test("every expensive job requires complete execution and retains its scope sele assert.match(dependencyPolicy, /name: Check production dependency update policy\n run: node --test scripts\/production-dependency-policy\.test\.mjs/u); }); -test("unrelated labels cannot cancel validation or publish the required context", () => { - assert.match(workflow, /group: .*github\.event\.label\.name != 'full-ci'.*github\.run_id/u); - assert.match(workflow, /cancel-in-progress: .*github\.event\.label\.name == 'full-ci'/u); - assert.match(jobSource("scope"), /^ if: .*github\.event\.label\.name == 'full-ci'/mu); - assert.match(jobSource("quality"), /name: .*github\.event\.label\.name != 'full-ci'.*CI label event \(no validation\).*Typecheck, unit tests, and Web build/u); - const ignored = runQuality({ IGNORED_LABEL_EVENT: "true", SCOPE_RESULT: "skipped", EXECUTION_TIER: "" }); - assert.equal(ignored.status, 0, ignored.stderr); - assert.match(ignored.stdout, /Unrelated label event/u); +test("label events never create CI runs so the latest run always publishes the required context", () => { + // The required gate resolves against the newest CI run on a head; a label run + // without the gate job makes the ruleset report it as expected and block merges. + assert.doesNotMatch(workflow, /labeled/u); + assert.match(jobSource("quality"), /^ name: Typecheck, unit tests, and Web build$/mu); + assert.doesNotMatch(jobSource("quality"), /IGNORED_LABEL_EVENT/u); + assert.doesNotMatch(jobSource("scope"), /^ if:/mu); + assert.match(labelWorkflow, /if: github\.event\.label\.name == 'full-ci'/u); + assert.match(labelWorkflow, /actions: write/u); + assert.match(labelWorkflow, /pull_request\.head\.sha/u); }); test("long-running jobs respond to cancellation while quality still reports incomplete validation", () => { From e02fa3abfea2405a6afad50b6b6f91d1142697ec Mon Sep 17 00:00:00 2001 From: gansx <798622053@qq.com> Date: Mon, 26 Oct 2026 21:40:03 +0800 Subject: [PATCH 2/2] ci: dispatch the full-ci label through a branch ref on pull_request_target Address two P1 review findings on full-ci-label.yml: - workflow_dispatch --ref requires a branch or tag ref, not the head commit SHA; use the pull-request head branch ref instead. - fork pull requests (including Dependabot) get a read-only GITHUB_TOKEN on pull_request events regardless of the permissions block; trigger via pull_request_target so the dispatch job keeps an actions:write token. The job stays a pure API caller: it never checks out or runs pull-request code, and it fails explicitly for forked head branches, which have no ref in this repository for workflow_dispatch to address. --- .github/workflows/full-ci-label.yml | 19 ++++++++++++++----- docs/maintenance/ci.md | 2 +- scripts/ci/execution-workflow.test.mjs | 12 ++++++++---- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/.github/workflows/full-ci-label.yml b/.github/workflows/full-ci-label.yml index a31776980..8574c915a 100644 --- a/.github/workflows/full-ci-label.yml +++ b/.github/workflows/full-ci-label.yml @@ -2,8 +2,10 @@ name: Full CI label # Keeps label events out of ci.yml: the required gate resolves against the # latest CI run on each head, and label runs must never exist without it. +# pull_request_target keeps a workflow-dispatch-capable token for fork pull +# requests; this workflow must never check out or execute pull-request code. on: - pull_request: + pull_request_target: types: [labeled] permissions: @@ -19,11 +21,18 @@ jobs: actions: write contents: read steps: - - name: Trigger full CI on the PR head + - name: Trigger full CI on the PR head branch env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} run: | set -euo pipefail - gh workflow run ci.yml --ref "$HEAD_SHA" - printf 'Full CI dispatched for %s\n' "$HEAD_SHA" >> "$GITHUB_STEP_SUMMARY" + # workflow_dispatch addresses a branch or tag ref, not a commit SHA, + # and a forked head branch has no ref in this repository. + if [[ "$HEAD_REPO" != "$GITHUB_REPOSITORY" ]]; then + echo "::error::The full-ci label cannot dispatch CI for a forked branch: workflow_dispatch requires a ref in this repository. Push a commit to rerun the pull request with full scope." >&2 + exit 1 + fi + gh workflow run ci.yml --ref "$HEAD_REF" + printf 'Full CI dispatched for %s\n' "$HEAD_REF" >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/maintenance/ci.md b/docs/maintenance/ci.md index 11aa3d539..7a87decb8 100644 --- a/docs/maintenance/ci.md +++ b/docs/maintenance/ci.md @@ -110,7 +110,7 @@ Draft 减少执行不改变改动范围。不能把大 PR 的范围伪装成 `do PR 工作流保留稳定入口,在 job 层选择任务;不要用顶层 `paths` 跳过受保护的整个工作流。以下事件和质量门规则已在 P1 接入,其余平台分组仍待 P2 验证。 -- 覆盖 `opened`、`synchronize`、`reopened`、`ready_for_review`、`converted_to_draft`。标签事件不进入 CI 工作流:必需门按名称在该 head 最新一次 CI 运行中解析,任何缺门 job 的运行都会被规则判为缺失并拦截合并。`full-ci` 标签由独立工作流 `full-ci-label.yml` 接管,对 PR head 分发一次完整 `workflow_dispatch` 运行;无关标签不产生任何 CI 运行。`unlabeled` 不触发工作流,移除标签只影响下一次正常运行。 +- 覆盖 `opened`、`synchronize`、`reopened`、`ready_for_review`、`converted_to_draft`。标签事件不进入 CI 工作流:必需门按名称在该 head 最新一次 CI 运行中解析,任何缺门 job 的运行都会被规则判为缺失并拦截合并。`full-ci` 标签由独立工作流 `full-ci-label.yml` 接管:以 `pull_request_target` 触发(fork PR 才能拿到可 dispatch 的 token),用 head 分支名(而非 SHA)分发一次完整 `workflow_dispatch` 运行;fork 的 head 分支在本仓库没有可寻址 ref,该任务显式失败并提示推送新提交;无关标签不产生任何 CI 运行。`full-ci-label.yml` 只调用 API 分发,绝不 checkout 或执行 PR 代码。`unlabeled` 不触发工作流,移除标签只影响下一次正常运行。 - Draft 使用明确的轻量反馈结果。稳定的完整质量门不能通过 `if` 跳过来表示完成,也不能把延后的任务视为范围外。`quality` 明确失败并说明 `deferred_by_draft_policy`,轻量任务单独显示结果;转正式评审或显式完整运行后补齐。 - 汇总门使用 `always()` 和显式的必需任务集合。选中的任务只有 `success` 可通过;`failure`、`cancelled`、`skipped`、空结果均拒绝。只有分类确认范围外的任务才允许跳过。 - 分类、公共检查、清单校验失败时,汇总门必须失败。文档范围也不能因上游失败而得到成功。 diff --git a/scripts/ci/execution-workflow.test.mjs b/scripts/ci/execution-workflow.test.mjs index 8c112b1ba..343d3391d 100644 --- a/scripts/ci/execution-workflow.test.mjs +++ b/scripts/ci/execution-workflow.test.mjs @@ -25,14 +25,14 @@ function runQuality(overrides = {}) { cwd: root, encoding: "utf8", env: { ...process.env, EXECUTION_TIER: "complete", SCOPE_MODE: "full", DAEMON_SELECTED: "true", - IGNORED_LABEL_EVENT: "false", ...Object.fromEntries(lanes.map((lane) => [`${lane}_RESULT`, "success"])), ...overrides }, + ...Object.fromEntries(lanes.map((lane) => [`${lane}_RESULT`, "success"])), ...overrides }, }); } test("PR transitions and full-ci labels trigger validation while full regression is daily", () => { assert.match(workflow, /types: \[opened, synchronize, reopened, ready_for_review, converted_to_draft\]/u); - assert.match(labelWorkflow, /types: \[labeled\]/u); - assert.match(labelWorkflow, /gh workflow run ci\.yml --ref "\$HEAD_SHA"/u); + assert.match(labelWorkflow, /pull_request_target:\n types: \[labeled\]/u); + assert.match(labelWorkflow, /gh workflow run ci\.yml --ref "\$HEAD_REF"/u); assert.match(workflow, /cron: "17 3 \* \* \*"/u); assert.doesNotMatch(workflow, /^ (?:push|paths|paths-ignore):/mu); assert.match(jobSource("scope"), /--event-file "\$GITHUB_EVENT_PATH"/u); @@ -67,7 +67,11 @@ test("label events never create CI runs so the latest run always publishes the r assert.doesNotMatch(jobSource("scope"), /^ if:/mu); assert.match(labelWorkflow, /if: github\.event\.label\.name == 'full-ci'/u); assert.match(labelWorkflow, /actions: write/u); - assert.match(labelWorkflow, /pull_request\.head\.sha/u); + assert.match(labelWorkflow, /pull_request\.head\.ref/u); + // The dispatch job holds a write token on pull_request_target; it must stay a + // pure API caller and refuse forked heads that workflow_dispatch cannot address. + assert.doesNotMatch(labelWorkflow, /actions\/checkout/u); + assert.match(labelWorkflow, /"\$HEAD_REPO" != "\$GITHUB_REPOSITORY"/u); }); test("long-running jobs respond to cancellation while quality still reports incomplete validation", () => {