Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d837da5f3a | ||
|
|
7913c3a720 |
@@ -2,7 +2,7 @@ name: Code Admission — AI Behavior
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request_target:
|
pull_request_target:
|
||||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, labeled, unlabeled]
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -12,6 +12,12 @@ permissions:
|
|||||||
pull-requests: read
|
pull-requests: read
|
||||||
statuses: write
|
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:
|
jobs:
|
||||||
ai-behavior-check:
|
ai-behavior-check:
|
||||||
name: AI Behavior
|
name: AI Behavior
|
||||||
@@ -47,23 +53,37 @@ jobs:
|
|||||||
try {
|
try {
|
||||||
const expectedHead = pullRequest.head.sha;
|
const expectedHead = pullRequest.head.sha;
|
||||||
const expectedBase = pullRequest.base.sha;
|
const expectedBase = pullRequest.base.sha;
|
||||||
|
const expectedDraft = pullRequest.draft;
|
||||||
const currentPull = async (phase) => {
|
const currentPull = async (phase) => {
|
||||||
const { data: pull } = await github.rest.pulls.get({
|
const { data: pull } = await github.rest.pulls.get({
|
||||||
owner: context.repo.owner,
|
owner: context.repo.owner,
|
||||||
repo: context.repo.repo,
|
repo: context.repo.repo,
|
||||||
pull_number: context.issue.number,
|
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(
|
throw new Error(
|
||||||
`Pull request revision changed during ${phase}: ` +
|
`Pull request identity changed during ${phase}: ` +
|
||||||
`expected base/head ${expectedBase}/${expectedHead}, ` +
|
`expected base/head/draft ${expectedBase}/${expectedHead}/${expectedDraft}, ` +
|
||||||
`got ${pull.base.sha}/${pull.head.sha}`
|
`got ${pull.base.sha}/${pull.head.sha}/${pull.draft}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return pull;
|
return pull;
|
||||||
};
|
};
|
||||||
|
|
||||||
const before = await currentPull('pre-policy check');
|
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);
|
const labels = before.labels.map(({ name }) => name);
|
||||||
if (!labels.includes('ai-generated')) {
|
if (!labels.includes('ai-generated')) {
|
||||||
await setStatus('success', 'Not labeled ai-generated');
|
await setStatus('success', 'Not labeled ai-generated');
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
pull_request:
|
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:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
@@ -21,6 +21,10 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
name: 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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
permissions:
|
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
|
`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
|
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,
|
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
|
and Ready/Draft state transitions cancel older evaluations and verify the live
|
||||||
`AI Behavior` check run on protected `main` pushes for release governance.
|
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
|
## 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)
|
t.Fatalf("ReadFile(ci.yml) error = %v", err)
|
||||||
}
|
}
|
||||||
workflow := string(data)
|
workflow := string(data)
|
||||||
if !strings.Contains(workflow, "pull_request:\n types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled, auto_merge_disabled]") {
|
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 a draft becomes ready or merge metadata changes")
|
t.Error("CI must rerun admission when Draft state or merge metadata changes")
|
||||||
}
|
}
|
||||||
start := strings.Index(workflow, "\n test:\n")
|
start := strings.Index(workflow, "\n test:\n")
|
||||||
end := strings.Index(workflow, "\n test-darwin:\n")
|
end := strings.Index(workflow, "\n test-darwin:\n")
|
||||||
|
|||||||
Reference in New Issue
Block a user