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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ phases:
acceptance: [all_phases_passed, diff_matches_claims]
publish:
hold_when: "risk == high" # accepted but held for a person; publish retry releases it
ci: {wait: true, timeout: 30m} # accepted only once CI on the PR head is green
```

Rules worth knowing before you write one:
Expand Down Expand Up @@ -221,6 +222,19 @@ Rules worth knowing before you write one:
access to the control plane: jig has one trusted operator and no
authentication (see *Loopback only*), so the operator's retry is the
approval, with no separate approver identity.
- **Publish can wait for CI.** `publish: {ci: {wait: true, timeout: 30m}}`
adds a fourth publish step after proof: the worker polls the check runs
and commit statuses on the branch's head through `gh api`, and the job is
`accepted` only when every one is green — the control plane enforces this
from the frozen definition, not the worker's word. A red check fails fast;
checks still pending at the timeout (default 30m, 1m–6h) fail too; a head
with no checks at all passes after two minutes, since that repository runs
no CI. Any of these ends the job `accepted_unpublished` with the branch and
pull request in place and the red checks named in the result. Fix the
branch — push to it yourself — and the publish-only retry judges CI on
whatever its head is by then. Neutral and skipped checks count as green.
The wait holds a worker slot, and a cancel during it ends the job the same
way. Omit `ci` and publish does not wait, exactly as before.
- **Validation happens before anything runs.** `jig def validate <file>`
is the same check the store applies at save time, offline.

Expand Down
6 changes: 6 additions & 0 deletions docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ purpose: `factory.yaml` scores every change for risk and holds high-risk
work with `publish: held` in its result. Read the risk reviewer's briefing in
the trace, then the publish retry is your sign-off.

`factory.yaml` also sets `publish.ci.wait`, so a published job is
`accepted` only once CI on its pull request is green. Red CI lands it in
`accepted_unpublished` with the failing checks in its result; push a fix to
the `jig/<job-id>/<attempt>` branch and the publish retry re-checks CI on the
new head.

`Ctrl-C` stops the worker in order: no new claims, in-flight attempts
cancelled, terminal states recorded, ephemeral scratch destroyed.

Expand Down
9 changes: 9 additions & 0 deletions examples/definitions/factory.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@
# `risk == high` so they fail closed: work the classifier never scored — a
# missing or unexpected `risk` — is held, not shipped.
#
# THE CI GATE. `publish.ci.wait` makes green CI part of `accepted`: after the
# pull request exists, the worker polls its head's checks for up to 30
# minutes. Red or timed-out CI ends the job accepted_unpublished with the
# failing checks named; push a fix to the branch and the publish retry
# re-judges CI on the new head.
#
# THE ROSTER. Builders and reviewers are `opus`; the planner and the extra
# high-risk reviewer are `sonnet`. `effort` is medium for building to a plan
# and high for reviewers hunting what the build missed. `budget_usd` caps what
Expand Down Expand Up @@ -352,3 +358,6 @@ phases:
acceptance: [all_phases_passed, verdict_consistent, diff_matches_claims]
publish:
hold_when: "risk != low"
ci:
wait: true
timeout: 30m
62 changes: 62 additions & 0 deletions internal/controlplane/migrate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,65 @@ func TestDuplicateAttemptEventSequenceInsertIsIgnored(t *testing.T) {
t.Fatalf("replay overwrote the original payload: %q", payload)
}
}

// 004 rebuilds publish_records to widen its step vocabulary with `ci`. A
// rebuild is only safe if it carries every existing row across, so this
// applies 001–003 by hand, records a proven step under the old schema, then
// applies 004 and checks the row survived and the new vocabulary holds.
func TestPublishCIStepMigrationPreservesRecordsAndWidensTheStepVocabulary(t *testing.T) {
path := filepath.Join(t.TempDir(), "jig.db")
db, err := sql.Open("sqlite", "file:"+path+"?_pragma=foreign_keys(1)")
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
apply := func(name string) {
t.Helper()
body, err := migrations.Files.ReadFile(name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if _, err := db.Exec(string(body)); err != nil {
t.Fatalf("apply %s: %v", name, err)
}
}
for _, name := range []string{"001_core.sql", "002_job_cancellation.sql", "003_admission_triggers.sql"} {
apply(name)
}
jobID := seedJob(t, db)
if err := insertAttempt(db, "attempt-1", jobID, 1, "running"); err != nil {
t.Fatalf("insert attempt: %v", err)
}
insertStep := func(step string) error {
_, err := db.Exec(`INSERT INTO publish_records(attempt_id, step, branch, remote_ref, completed_at)
VALUES ('attempt-1', ?, 'jig/job-1/1', '1111111111111111111111111111111111111111', 7)`, step)
return err
}
if err := insertStep("proof"); err != nil {
t.Fatalf("record proof under the 003 schema: %v", err)
}
if err := insertStep("ci"); err == nil {
t.Fatal("the 003 schema accepted a ci step; the migration under test would be a no-op")
}

apply("004_publish_ci_step.sql")

var branch, ref string
var completedAt int64
if err := db.QueryRow(`SELECT branch, remote_ref, completed_at FROM publish_records
WHERE attempt_id = 'attempt-1' AND step = 'proof'`).Scan(&branch, &ref, &completedAt); err != nil {
t.Fatalf("the proof record did not survive the rebuild: %v", err)
}
if branch != "jig/job-1/1" || completedAt != 7 {
t.Fatalf("proof record = %s %s %d after the rebuild, want it unchanged", branch, ref, completedAt)
}
if err := insertStep("ci"); err != nil {
t.Fatalf("record ci after 004: %v", err)
}
if err := insertStep("deploy"); err == nil {
t.Fatal("the rebuilt table accepted a step outside its vocabulary")
}
if err := insertStep("ci"); err == nil {
t.Fatal("the rebuilt table lost its (attempt_id, step) primary key")
}
}
10 changes: 7 additions & 3 deletions internal/controlplane/publish_ledger.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func loadPublishContext(ctx context.Context, tx *sql.Tx, attemptID string) (publ
func validatePublishStep(step, branch, expectedBranch string) error {
if !protocol.ValidPublishStep(step) {
return invalid("invalid_publish_step",
"step must be one of push, pull_request, proof")
"step must be one of push, pull_request, proof, ci")
}
if len(branch) > protocol.MaxPublishBranchBytes {
return invalid("invalid_publish_branch", "branch exceeds its storage limit")
Expand Down Expand Up @@ -193,10 +193,10 @@ func (s *Store) RecordPublishStep(
return record, invalid("invalid_publish_ref", "remote_ref exceeds its storage limit")
}
switch input.Step {
case protocol.PublishStepPush, protocol.PublishStepProof:
case protocol.PublishStepPush, protocol.PublishStepProof, protocol.PublishStepCI:
if !publishRefPattern.MatchString(input.RemoteRef) {
return record, invalid("invalid_publish_ref",
"push and proof records must carry the pushed commit SHA as remote_ref")
"push, proof, and ci records must carry a commit SHA as remote_ref")
}
case protocol.PublishStepPullRequest:
if !strings.HasPrefix(input.PullRequestURL, "https://") {
Expand Down Expand Up @@ -424,6 +424,10 @@ func (s *Store) RetryPublish(
if err != nil {
return retry, err
}
if err := s.db.QueryRowContext(ctx,
`SELECT snapshot FROM runs WHERE id = ?`, job.RunID).Scan(&retry.Snapshot); err != nil {
return retry, unavailable(err)
}
retry.Attempt = attempt
retry.Job = job
retry.Records = records
Expand Down
114 changes: 114 additions & 0 deletions internal/controlplane/publish_ledger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,3 +313,117 @@ func TestPublishRetryIsRefusedForJobsThatAreNotAcceptedUnpublished(t *testing.T)
t.Fatalf("retry of a failed job: err=%v, want publish_retry_not_allowed", err)
}
}

// ciFixtureSnapshot is fixtureSnapshot with publish.ci opted in.
const ciFixtureSnapshot = fixtureSnapshot + `publish:
ci:
wait: true
`

// optInToCI swaps the run's frozen definition for one whose publish waits
// for CI — the fixture every CI-gate scenario starts from.
func optInToCI(t *testing.T, store *Store, runID string) {
t.Helper()
if _, err := store.db.Exec(`UPDATE runs SET snapshot = ? WHERE id = ?`, ciFixtureSnapshot, runID); err != nil {
t.Fatalf("opt run %s into CI: %v", runID, err)
}
}

// A definition that waits for CI extends R12's "accepted means published"
// to "accepted means published AND green". Like proof, that is checked by
// the store against its own ledger and the run's frozen definition — never
// taken from the worker's word — and the ci step sits after proof in the
// fenced step order.
func TestAcceptedRequiresARecordedCIStepWhenTheDefinitionWaitsForCI(t *testing.T) {
store, _ := newTestStore(t)
registerTestWorker(t, store, 1)
seedRun(t, store, "run-1", protocol.RunTarget{Repository: repoA, BaseSHA: shaA})
optInToCI(t, store, "run-1")
claim := claimAndStart(t, store, "run-1", repoA)
ctx := context.Background()

if _, err := recordStep(store, claim, tokenA, protocol.PublishStepPush, shaB, ""); err != nil {
t.Fatalf("record push: %v", err)
}
if _, err := recordStep(store, claim, tokenA, protocol.PublishStepCI, shaB, ""); err == nil ||
serviceCode(t, err) != "publish_step_out_of_order" {
t.Fatalf("ci before proof: err=%v, want publish_step_out_of_order", err)
}
if _, err := recordStep(store, claim, tokenA, protocol.PublishStepPullRequest, "",
"https://github.com/example/repo-a/pull/3"); err != nil {
t.Fatalf("record pull request: %v", err)
}
if _, err := recordStep(store, claim, tokenA, protocol.PublishStepProof, shaB, ""); err != nil {
t.Fatalf("record proof: %v", err)
}

_, err := store.CompleteAttempt(ctx, claim.Attempt.ID, protocol.CompleteAttemptRequest{
LeaseToken: tokenA, State: protocol.AttemptAccepted,
})
if err == nil || serviceCode(t, err) != "publish_ci_required" {
t.Fatalf("accepted with proof but no ci record: err=%v, want publish_ci_required", err)
}

if _, err := recordStep(store, claim, tokenA, protocol.PublishStepCI, "green", ""); err == nil ||
serviceCode(t, err) != "invalid_publish_ref" {
t.Fatalf("ci with a non-SHA ref: err=%v, want invalid_publish_ref", err)
}
// CI may be judged on a later head than jig pushed (a person's fix on the
// branch), so the ci ref need not equal the push ref.
if _, err := recordStep(store, claim, tokenA, protocol.PublishStepCI, shaA, ""); err != nil {
t.Fatalf("record ci: %v", err)
}
accepted, err := store.CompleteAttempt(ctx, claim.Attempt.ID, protocol.CompleteAttemptRequest{
LeaseToken: tokenA, State: protocol.AttemptAccepted,
})
if err != nil {
t.Fatalf("complete accepted with a ci record: %v", err)
}
if accepted.State != protocol.AttemptAccepted {
t.Fatalf("attempt state = %q, want accepted", accepted.State)
}
}

// A definition that does not opt in is unchanged: proof alone is enough.
func TestAcceptedNeedsNoCIStepWhenTheDefinitionDoesNotWait(t *testing.T) {
store, _ := newTestStore(t)
registerTestWorker(t, store, 1)
seedRun(t, store, "run-1", protocol.RunTarget{Repository: repoA, BaseSHA: shaA})
claim := claimAndStart(t, store, "run-1", repoA)
for _, step := range []struct{ name, ref, url string }{
{protocol.PublishStepPush, shaB, ""},
{protocol.PublishStepPullRequest, "", "https://github.com/example/repo-a/pull/4"},
{protocol.PublishStepProof, shaB, ""},
} {
if _, err := recordStep(store, claim, tokenA, step.name, step.ref, step.url); err != nil {
t.Fatalf("record %s: %v", step.name, err)
}
}
if _, err := store.CompleteAttempt(context.Background(), claim.Attempt.ID, protocol.CompleteAttemptRequest{
LeaseToken: tokenA, State: protocol.AttemptAccepted,
}); err != nil {
t.Fatalf("complete accepted without CI opt-in: %v", err)
}
}

// The publish-only retry hands the worker the run's frozen definition, so a
// retry re-applies the same CI policy the first publish ran under.
func TestPublishRetryCarriesTheRunSnapshot(t *testing.T) {
store, _ := newTestStore(t)
registerTestWorker(t, store, 1)
seedRun(t, store, "run-1", protocol.RunTarget{Repository: repoA, BaseSHA: shaA})
optInToCI(t, store, "run-1")
claim := claimAndStart(t, store, "run-1", repoA)
if _, err := store.CompleteAttempt(context.Background(), claim.Attempt.ID,
protocol.CompleteAttemptRequest{LeaseToken: tokenA, State: protocol.AttemptAcceptedUnpublished}); err != nil {
t.Fatalf("complete accepted_unpublished: %v", err)
}
retry, err := store.RetryPublish(context.Background(), claim.Job.ID,
protocol.PublishRetryRequest{WorkerID: workerA, LeaseToken: tokenB})
if err != nil {
t.Fatalf("publish retry: %v", err)
}
if retry.Snapshot != ciFixtureSnapshot {
t.Fatalf("retry snapshot = %q, want the run's frozen definition", retry.Snapshot)
}
}
42 changes: 42 additions & 0 deletions internal/controlplane/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,28 @@ type leaseState struct {
cancel bool
}

// attemptWaitsForCI reads the attempt's run snapshot — the definition frozen
// at admission (R2) — and reports whether its publish.ci waits. A snapshot
// that no longer parses cannot have been executed, so it is an error, not a
// silent "no".
func attemptWaitsForCI(ctx context.Context, tx *sql.Tx, attemptID string) (bool, error) {
var snapshot string
if err := tx.QueryRowContext(ctx, `
SELECT r.snapshot FROM attempts a
JOIN jobs j ON j.id = a.job_id
JOIN runs r ON r.id = j.run_id
WHERE a.id = ?
`, attemptID).Scan(&snapshot); err != nil {
return false, unavailable(err)
}
spec, err := protocol.ParseDefinition([]byte(snapshot))
if err != nil {
return false, conflict("snapshot_unparseable",
"the run's frozen definition no longer parses: "+err.Error())
}
return spec.WaitsForCI(), nil
}

func loadLease(ctx context.Context, tx *sql.Tx, attemptID string) (leaseState, error) {
var value leaseState
var cancel int
Expand Down Expand Up @@ -527,6 +549,26 @@ func (s *Store) CompleteAttempt(ctx context.Context, attemptID string, input pro
"an attempt is `accepted` only with a recorded proof-of-publish step (R12); "+
"complete as accepted_unpublished when publish did not finish")
}
// The same rule for CI, read from the run's frozen definition rather
// than from anything the worker says: a definition whose publish.ci
// waits is not `accepted` until a green CI run is on the ledger.
waits, err := attemptWaitsForCI(ctx, tx, attemptID)
if err != nil {
return protocol.Attempt{}, err
}
if waits {
var green int
if err := tx.QueryRowContext(ctx, `
SELECT COUNT(*) FROM publish_records WHERE attempt_id = ? AND step = ?
`, attemptID, protocol.PublishStepCI).Scan(&green); err != nil {
return protocol.Attempt{}, unavailable(err)
}
if green == 0 {
return protocol.Attempt{}, conflict("publish_ci_required",
"this definition waits for CI, so the attempt is `accepted` only with a "+
"recorded ci step; complete as accepted_unpublished while CI is not green")
}
}
}
if _, err := tx.ExecContext(ctx, `
UPDATE attempts SET state = ?, result = ?, error = ?, completed_at = ?
Expand Down
Loading
Loading