Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d837da5f3a | ||
|
|
7913c3a720 |
@@ -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,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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user