Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 6 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ 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).
# Normal PRs retain affected checks and Draft execution policy.
- 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
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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
38 changes: 38 additions & 0 deletions .github/workflows/full-ci-label.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
gansx marked this conversation as resolved.
Resolved
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"
Comment thread
gansx marked this conversation as resolved.
Resolved
printf 'Full CI dispatched for %s\n' "$HEAD_REF" >> "$GITHUB_STEP_SUMMARY"
2 changes: 1 addition & 1 deletion docs/maintenance/ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`、空结果均拒绝。只有分类确认范围外的任务才允许跳过。
- 分类、公共检查、清单校验失败时,汇总门必须失败。文档范围也不能因上游失败而得到成功。
Expand Down
29 changes: 19 additions & 10 deletions scripts/ci/execution-workflow.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
Expand All @@ -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);
Expand All @@ -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", () => {
Expand Down
Loading