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..8574c915a --- /dev/null +++ b/.github/workflows/full-ci-label.yml @@ -0,0 +1,38 @@ +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_target: + 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 branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + run: | + set -euo pipefail + # 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 27f648499..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` 和 `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` 接管:以 `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 4d89cef3c..343d3391d 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"]; @@ -24,12 +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, labeled\]/u); + assert.match(workflow, /types: \[opened, synchronize, reopened, ready_for_review, converted_to_draft\]/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); @@ -55,14 +58,20 @@ 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\.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", () => {