Compare commits

...
Author SHA1 Message Date
chichuan d837da5f3a ci: fail closed on draft admission state 2026-08-26 10:35:59 +08:00
chichuan 7913c3a720 ci: add draft pull request fast gate 2026-08-26 10:24:57 +08:00
6 changed files with 315 additions and 10 deletions
+25 -5
View File
@@ -2,7 +2,7 @@ name: Code Admission — AI Behavior
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled, unlabeled]
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, labeled, unlabeled]
push:
branches:
- main
@@ -12,6 +12,12 @@ 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
@@ -47,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');
+5 -1
View File
@@ -5,7 +5,7 @@ on:
branches:
- main
pull_request:
types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled, auto_merge_disabled]
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, edited, auto_merge_enabled, auto_merge_disabled]
permissions:
contents: read
@@ -21,6 +21,10 @@ concurrency:
jobs:
lint:
name: Lint
# 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
permissions:
+103
View File
@@ -0,0 +1,103 @@
name: Draft CI
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, edited]
permissions:
contents: read
concurrency:
# Draft validation is feedback, not a cache producer. Keep only the newest
# revision for one PR so rapid pushes do not compete with admission runs.
# A title/body-only edited event gets a unique no-op group, so its skipped
# job cannot cancel useful validation already running for the same revision.
group: draft-ci-${{ github.event.action == 'edited' && github.event.changes.base == null && format('noop-{0}', github.run_id) || format('pr-{0}', github.event.pull_request.number) }}
cancel-in-progress: true
jobs:
draft-fast:
name: Draft Fast Gate
# An edited event is relevant only when the base branch changed. Title and
# body edits must not spend a runner or replace useful feedback.
if: >-
github.event.pull_request.draft == true &&
(github.event.action != 'edited' || github.event.changes.base != null)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out authoritative synthetic merge
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Verify draft revision identity
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -eu
test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA" || {
echo "draft merge first parent does not match event base" >&2
exit 1
}
test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA" || {
echo "draft merge second parent does not match event head" >&2
exit 1
}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Verify test package plan
run: make test-plan
- name: Check formatting
run: make format-check
- name: Run Go vet
run: go vet ./...
- name: Check GitHub Actions workflows
run: go run github.com/rhysd/actionlint/cmd/actionlint@v1.7.12
- name: Test reviewer routing policy
run: node .github/reviewer-routing.test.js
- name: Test npm installer smoke
env:
XDG_CONFIG_HOME: ""
run: node test/scripts/install_js_smoke.mjs
- name: Build
run: make build
- name: Validate lightweight repository policy
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -eu
git diff --check "$PR_BASE_SHA" HEAD
./scripts/policy/check-open-source-assets.sh
./scripts/policy/check-release-fragments.sh "$PR_BASE_SHA" HEAD
if git diff --quiet "$PR_BASE_SHA" HEAD -- CHANGELOG.md; then
exit 0
fi
./scripts/policy/check-changelog-pr.sh \
--content-only "$PR_BASE_SHA" HEAD
- name: Test CI workflow contracts
env:
DWS_PACKAGE_VERSION: 0.0.0-test
run: >-
go test -count=1 ./test/scripts
-run '^(TestDraftPRCIWorkflowContract|TestChangelogPRFastPathWorkflowContract)$'
- name: Record admission boundary
run: |
printf '%s\n' \
'Draft Fast Gate is development feedback, not Code Admission.' \
'AI Behavior remains failed until this PR is Ready and fully admitted.' \
>> "$GITHUB_STEP_SUMMARY"
+32 -2
View File
@@ -22,8 +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. 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`. 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 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
+148
View File
@@ -0,0 +1,148 @@
package scripts_test
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDraftPRCIWorkflowContract(t *testing.T) {
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatalf("resolve repository root: %v", err)
}
read := func(path string) string {
t.Helper()
data, readErr := os.ReadFile(filepath.Join(root, path))
if readErr != nil {
t.Fatalf("read %s: %v", path, readErr)
}
return string(data)
}
admission := read(".github/workflows/ci.yml")
for _, want := range []string{
"ready_for_review",
"converted_to_draft",
"if: ${{ github.event_name == 'push' || github.event.pull_request.draft == false }}",
} {
if !strings.Contains(admission, want) {
t.Errorf("formal CI workflow missing Draft boundary %q", want)
}
}
jobsStart := strings.Index(admission, "\njobs:\n")
if jobsStart < 0 {
t.Fatal("formal CI workflow is missing jobs")
}
jobLines := strings.Split(admission[jobsStart+len("\njobs:\n"):], "\n")
jobNames := make([]string, 0)
jobStarts := make([]int, 0)
for index, line := range jobLines {
if !strings.HasPrefix(line, " ") || strings.HasPrefix(line, " ") || !strings.HasSuffix(line, ":") {
continue
}
jobNames = append(jobNames, strings.TrimSuffix(strings.TrimSpace(line), ":"))
jobStarts = append(jobStarts, index)
}
if len(jobNames) == 0 || jobNames[0] != "lint" {
t.Fatalf("formal CI first job = %v, want lint", jobNames)
}
for index := 1; index < len(jobNames); index++ {
end := len(jobLines)
if index+1 < len(jobStarts) {
end = jobStarts[index+1]
}
body := strings.Join(jobLines[jobStarts[index]:end], "\n")
if !strings.Contains(body, "\n needs: lint\n") && !strings.Contains(body, "\n - lint\n") {
t.Errorf("formal CI job %q does not depend on the Draft-gated lint job", jobNames[index])
}
}
aiBehavior := read(".github/workflows/ai-behavior-check.yml")
for _, want := range []string{
"ready_for_review",
"converted_to_draft",
"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, 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",
"name: Draft Fast Gate",
"github.event.pull_request.draft == true",
"github.event.action != 'edited' || github.event.changes.base != null",
`test "$(git rev-parse HEAD^1)" = "$PR_BASE_SHA"`,
`test "$(git rev-parse HEAD^2)" = "$PR_HEAD_SHA"`,
"run: make test-plan",
"run: make format-check",
"run: go vet ./...",
"actionlint@v1.7.12",
"run: make build",
`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.",
"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)
}
}
for _, forbidden := range []string{
"pull_request_target:",
"contents: write",
"pull-requests: write",
"checks: write",
"go test -race",
"make policy",
"coverage-gate",
"macos-latest",
"windows-latest",
} {
if strings.Contains(draft, forbidden) {
t.Errorf("Draft CI workflow contains admission-only behavior %q", forbidden)
}
}
for _, context := range []string{
"Lint",
"Test",
"Coverage",
"Policy",
"Edition",
"Interface Integrity",
"AI Behavior",
"CLI Smoke",
"Mock MCP",
} {
if strings.Contains(draft, "\n name: "+context+"\n") {
t.Errorf("Draft workflow must not emit formal context %q", context)
}
}
}
@@ -709,8 +709,8 @@ func TestCodeAdmissionEnforcesReviewerRouterWriterBoundary(t *testing.T) {
t.Fatalf("ReadFile(ci.yml) error = %v", err)
}
workflow := string(data)
if !strings.Contains(workflow, "pull_request:\n types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled, auto_merge_disabled]") {
t.Error("CI must rerun admission when a draft becomes ready or merge metadata changes")
if !strings.Contains(workflow, "pull_request:\n types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, edited, auto_merge_enabled, auto_merge_disabled]") {
t.Error("CI must rerun admission when Draft state or merge metadata changes")
}
start := strings.Index(workflow, "\n test:\n")
end := strings.Index(workflow, "\n test-darwin:\n")