From d837da5f3afcb552b524416d13e800709c99bb0a Mon Sep 17 00:00:00 2001 From: chichuan Date: Wed, 26 Aug 2026 10:35:59 +0800 Subject: [PATCH] ci: fail closed on draft admission state --- .github/workflows/ai-behavior-check.yml | 31 +++++++++++++----- .github/workflows/ci.yml | 6 ++-- .github/workflows/draft-ci.yml | 4 +-- docs/ci-pr-gates.md | 38 ++++++++++++++--------- test/scripts/draft_pr_ci_workflow_test.go | 22 +++++++++++-- 5 files changed, 72 insertions(+), 29 deletions(-) diff --git a/.github/workflows/ai-behavior-check.yml b/.github/workflows/ai-behavior-check.yml index 19141a8f..b6873cb0 100644 --- a/.github/workflows/ai-behavior-check.yml +++ b/.github/workflows/ai-behavior-check.yml @@ -12,12 +12,15 @@ permissions: pull-requests: read statuses: write +concurrency: + # State transitions for one PR supersede older policy evaluations. This + # prevents a stale Ready run from overwriting the fail-closed Draft status. + group: ai-behavior-${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }} + cancel-in-progress: true + jobs: ai-behavior-check: name: AI Behavior - # Draft feedback is intentionally separate from all formal admission - # contexts. Re-evaluate the same head when ready_for_review is emitted. - if: ${{ github.event_name == 'push' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -50,23 +53,37 @@ jobs: try { const expectedHead = pullRequest.head.sha; const expectedBase = pullRequest.base.sha; + const expectedDraft = pullRequest.draft; const currentPull = async (phase) => { const { data: pull } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: context.issue.number, }); - if (pull.head.sha !== expectedHead || pull.base.sha !== expectedBase) { + if ( + pull.head.sha !== expectedHead || + pull.base.sha !== expectedBase || + pull.draft !== expectedDraft + ) { throw new Error( - `Pull request revision changed during ${phase}: ` + - `expected base/head ${expectedBase}/${expectedHead}, ` + - `got ${pull.base.sha}/${pull.head.sha}` + `Pull request identity changed during ${phase}: ` + + `expected base/head/draft ${expectedBase}/${expectedHead}/${expectedDraft}, ` + + `got ${pull.base.sha}/${pull.head.sha}/${pull.draft}` ); } return pull; }; const before = await currentPull('pre-policy check'); + if (before.draft) { + await setStatus('failure', 'Draft PR; mark Ready to run Code Admission'); + core.setFailed( + 'Draft pull requests are not eligible for Code Admission. ' + + 'Mark the pull request ready to run all nine required contexts.' + ); + return; + } + const labels = before.labels.map(({ name }) => name); if (!labels.includes('ai-generated')) { await setStatus('success', 'Not labeled ai-generated'); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7e0399c..42d9aa0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,9 +21,9 @@ concurrency: jobs: lint: name: Lint - # Draft revisions use the isolated Draft CI workflow. Keeping the formal - # admission contexts absent prevents a fast preview on the same commit from - # being mistaken for the nine checks required after ready_for_review. + # Draft revisions use the isolated Draft CI workflow. Skip the heavy + # admission graph; the base-owned AI Behavior status fails Draft revisions + # so these successful skipped checks cannot authorize a merge. if: ${{ github.event_name == 'push' || github.event.pull_request.draft == false }} runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/.github/workflows/draft-ci.yml b/.github/workflows/draft-ci.yml index d2706b80..e2352f00 100644 --- a/.github/workflows/draft-ci.yml +++ b/.github/workflows/draft-ci.yml @@ -2,7 +2,7 @@ name: Draft CI on: pull_request: - types: [opened, synchronize, reopened, converted_to_draft, edited] + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, edited] permissions: contents: read @@ -99,5 +99,5 @@ jobs: run: | printf '%s\n' \ 'Draft Fast Gate is development feedback, not Code Admission.' \ - 'Mark this PR ready for review to run the nine required contexts.' \ + 'AI Behavior remains failed until this PR is Ready and fully admitted.' \ >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index a4a4c4f8..09bcd491 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -22,28 +22,38 @@ the nine contracts above. `AI Behavior` is evaluated by a `pull_request_target` workflow that never checks out or executes PR code. It writes the exact `AI Behavior` status to the current PR head. Its Files API read is bracketed by base/head revision checks, -so a synchronize race fails closed. The same workflow supplies a successful -`AI Behavior` check run on protected `main` pushes for release governance. +and Ready/Draft state transitions cancel older evaluations and verify the live +state, so a synchronize or state race fails closed. A Draft revision publishes +an explicit failing `AI Behavior` status; marking it Ready first replaces that +status with `pending` and only then evaluates the normal policy. The same +workflow supplies a successful `AI Behavior` check run on protected `main` +pushes for release governance. ## Draft pull-request feedback -A Draft pull request runs the independent `Draft CI` workflow instead of the -nine formal admission contexts. Its single `Draft Fast Gate` provides bounded -development feedback: it verifies the synthetic merge identity, package plan, -formatting, `go vet`, Actions syntax, reviewer routing, installer smoke, build, -release-fragment lifecycle, and lightweight repository policy. +A Draft pull request runs the independent `Draft CI` workflow. Its single +`Draft Fast Gate` provides bounded development feedback: it verifies the +synthetic merge identity, package plan, formatting, `go vet`, Actions syntax, +reviewer routing, installer smoke, build, release-fragment lifecycle, and +lightweight repository policy. The Draft result is not Code Admission and is never a substitute for `Lint`, `Test`, `Coverage`, `Policy`, `Edition`, `Interface Integrity`, `AI Behavior`, -`CLI Smoke`, or `Mock MCP`. Those exact contexts remain absent while the pull -request is Draft. Marking it ready for review starts the complete tier-selected -admission on the current head SHA; only that run can satisfy the ruleset. +`CLI Smoke`, or `Mock MCP`. GitHub records conditionally skipped formal jobs as +successful checks, so absence alone is not a safe admission boundary. The +base-owned `AI Behavior` status therefore fails every Draft revision explicitly; +because it is one of the nine required contexts, skipped formal jobs cannot +satisfy the ruleset. Marking the pull request Ready changes that status to +`pending` before policy evaluation and starts complete tier-selected admission +on the current head SHA. Merge remains blocked until all nine contexts succeed. Converting a ready pull request back to Draft creates a new skipped formal CI -run in the same revision concurrency group, cancelling any in-progress heavy -admission work, and starts `Draft Fast Gate`. A later `ready_for_review` event -runs the full admission again. Editing only a Draft title or body does not -consume a runner; changing its base branch does revalidate the synthetic merge. +run in the same PR concurrency group, cancelling any in-progress heavy +admission work; it also replaces `AI Behavior` with a failing Draft status and +starts `Draft Fast Gate`. A later `ready_for_review` event cancels any remaining +Draft validation, marks `AI Behavior` pending, and runs full admission again. +Editing only a Draft title or body does not consume a runner; changing its base +branch revalidates the synthetic merge. ## Exact CHANGELOG-only fast path diff --git a/test/scripts/draft_pr_ci_workflow_test.go b/test/scripts/draft_pr_ci_workflow_test.go index 1c167ab2..4ef10d1b 100644 --- a/test/scripts/draft_pr_ci_workflow_test.go +++ b/test/scripts/draft_pr_ci_workflow_test.go @@ -64,17 +64,33 @@ func TestDraftPRCIWorkflowContract(t *testing.T) { for _, want := range []string{ "ready_for_review", "converted_to_draft", - "if: ${{ github.event_name == 'push' || github.event.pull_request.draft == false }}", + "group: ai-behavior-${{ github.event_name == 'pull_request_target' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}", + "cancel-in-progress: true", + "const expectedDraft = pullRequest.draft;", + "pull.draft !== expectedDraft", + "if (before.draft)", + "await setStatus('failure', 'Draft PR; mark Ready to run Code Admission');", + "Draft pull requests are not eligible for Code Admission.", } { if !strings.Contains(aiBehavior, want) { t.Errorf("AI Behavior workflow missing Draft boundary %q", want) } } + if strings.Contains(aiBehavior, "if: ${{ github.event_name == 'push' || github.event.pull_request.draft == false }}") { + t.Error("AI Behavior must run and publish a failing status for Draft revisions") + } + pendingIndex := strings.Index(aiBehavior, "await setStatus('pending', 'Evaluating AI-generated PR boundaries');") + draftCheckIndex := strings.Index(aiBehavior, "if (before.draft)") + draftFailureIndex := strings.Index(aiBehavior, "await setStatus('failure', 'Draft PR; mark Ready to run Code Admission');") + labelPolicyIndex := strings.Index(aiBehavior, "const labels = before.labels.map") + if pendingIndex < 0 || draftCheckIndex <= pendingIndex || draftFailureIndex <= draftCheckIndex || labelPolicyIndex <= draftFailureIndex { + t.Error("AI Behavior must publish pending, fail Draft, then evaluate Ready-only label policy") + } draft := read(".github/workflows/draft-ci.yml") for _, want := range []string{ "name: Draft CI", - "types: [opened, synchronize, reopened, converted_to_draft, edited]", + "types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, edited]", "format('noop-{0}', github.run_id)", "format('pr-{0}', github.event.pull_request.number)", "cancel-in-progress: true", @@ -91,7 +107,7 @@ func TestDraftPRCIWorkflowContract(t *testing.T) { `git diff --check "$PR_BASE_SHA" HEAD`, `./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD`, "Draft Fast Gate is development feedback, not Code Admission.", - "Mark this PR ready for review to run the nine required contexts.", + "AI Behavior remains failed until this PR is Ready and fully admitted.", } { if !strings.Contains(draft, want) { t.Errorf("Draft CI workflow missing contract %q", want)