diff --git a/.github/agent-guides/validation.md b/.github/agent-guides/validation.md index a1b88ccb..e2b46aa3 100644 --- a/.github/agent-guides/validation.md +++ b/.github/agent-guides/validation.md @@ -2,6 +2,16 @@ Read this guide before changing catalogue validation, to-do forwarding, publication or release recovery. Validation includes naming, shell pipelines and retired links. Commands and code paths are relative to the repository root. +**CI run budgets:** `check-ci-run-budget` is default-off and metadata-only. +Enabled calls bind one completed run, repository, full head, workflow path, +event and stable attempt. Complete latest-job pages determine actual completion; +mutable workflow updates never do. Reruns retain original creation time. +Below 900 seconds passes; native failures and over-budget runs fail, while +incomplete or changed evidence is unknown. Disabled and unsuccessful calls expose +no validation output. Keep the Linux/macOS shipped-step and native-command +controls in required CI. This is a single-run primitive, not whole-PR enforcement; +consumer adoption, aggregation and flag retirement remain under #654. + **Production to-do input forwarding:** the reusable scanner exposes the composite's optional-project integration choices while retaining its existing defaults. The generated read-only workflow runs the exact production steps with an empty diff --git a/.github/scripts/check-ci-run-budget/go.mod b/.github/scripts/check-ci-run-budget/go.mod new file mode 100644 index 00000000..d3c980c5 --- /dev/null +++ b/.github/scripts/check-ci-run-budget/go.mod @@ -0,0 +1,3 @@ +module github.com/devantler-tech/dotgithub/check-ci-run-budget + +go 1.26.0 diff --git a/.github/scripts/check-ci-run-budget/main.go b/.github/scripts/check-ci-run-budget/main.go new file mode 100644 index 00000000..6ac94f1e --- /dev/null +++ b/.github/scripts/check-ci-run-budget/main.go @@ -0,0 +1,275 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "time" +) + +type options struct { + repository, head, workflow, event string + runID, attempt uint64 +} + +type reader interface { + read(context.Context, string) ([]byte, error) +} + +type receipt struct { + Schema int `json:"schema"` + State string `json:"state"` + Scope string `json:"scope"` + Reason string `json:"reason"` + Repository string `json:"repository,omitempty"` + RunID uint64 `json:"run_id,omitempty"` + Attempt uint64 `json:"attempt,omitempty"` + Head string `json:"head_sha,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + CompletedAt string `json:"completed_at,omitempty"` + Jobs int `json:"jobs,omitempty"` + BudgetSeconds int `json:"budget_seconds"` + ElapsedSeconds *float64 `json:"elapsed_seconds"` +} + +type workflowRun struct { + ID uint64 `json:"id"` + Attempt uint64 `json:"run_attempt"` + Repository struct { + Name string `json:"full_name"` + } `json:"repository"` + Head string `json:"head_sha"` + Path string `json:"path"` + Event string `json:"event"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + CreatedAt string `json:"created_at"` +} + +type job struct { + ID uint64 `json:"id"` + RunID uint64 `json:"run_id"` + Attempt uint64 `json:"run_attempt"` + Head string `json:"head_sha"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + StartedAt *string `json:"started_at"` + CompletedAt *string `json:"completed_at"` +} + +type jobsPage struct { + Total *uint64 `json:"total_count"` + Jobs []job `json:"jobs"` +} + +var repositoryPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9_.-]{1,100}$`) +var shaPattern = regexp.MustCompile(`^[a-f0-9]{40}$`) +var workflowPattern = regexp.MustCompile(`^\.github/workflows/[A-Za-z0-9_-][A-Za-z0-9_.-]*\.ya?ml$`) + +// valid admits only complete native identities and supported CI events. +func (o options) valid() bool { + return repositoryPattern.MatchString(o.repository) && + o.repository[len(o.repository)-1:] != "/" && + o.repository[len(o.repository)-2:] != "/." && + o.repository[len(o.repository)-3:] != "/.." && + shaPattern.MatchString(o.head) && workflowPattern.MatchString(o.workflow) && + (o.event == "pull_request" || o.event == "merge_group") && o.runID > 0 && o.attempt > 0 +} + +// readJSON rejects failed, oversized or malformed metadata without exposing it. +func readJSON(ctx context.Context, api reader, path string, out any) error { + data, err := api.read(ctx, path) + if err != nil { + return fmt.Errorf("metadata read failed") + } + if len(data) > 8<<20 { + return fmt.Errorf("metadata response too large") + } + if err := json.Unmarshal(data, out); err != nil { + return fmt.Errorf("invalid metadata") + } + return nil +} + +// inspect binds complete native evidence to one stable run and its original clock. +// Exit codes distinguish an under-budget success, a failed verdict and unknown data. +func inspect(ctx context.Context, o options, api reader, now time.Time) (receipt, int) { + result := receipt{Schema: 1, State: "unknown", Scope: "single_workflow_run", Reason: "invalid_arguments", BudgetSeconds: 900} + unknown := func(reason string) (receipt, int) { result.Reason = reason; return result, 2 } + if !o.valid() { + return result, 2 + } + result.Repository = o.repository + result.RunID = o.runID + result.Attempt = o.attempt + result.Head = o.head + path := fmt.Sprintf("repos/%s/actions/runs/%d", o.repository, o.runID) + var run workflowRun + if err := readJSON(ctx, api, path, &run); err != nil { + return unknown("run_read_failed") + } + if run.ID != o.runID || run.Attempt != o.attempt || run.Repository.Name != o.repository || + run.Head != o.head || run.Path != o.workflow || run.Event != o.event || run.Status != "completed" { + return unknown("run_identity_or_state_mismatch") + } + created, err := time.Parse(time.RFC3339Nano, run.CreatedAt) + if err != nil || created.Year() < 2008 || created.After(now.Add(5*time.Second)) { + return unknown("invalid_creation_time") + } + if run.Conclusion == "failure" || run.Conclusion == "cancelled" || run.Conclusion == "timed_out" || run.Conclusion == "action_required" { + result.State = "ci_failed" + result.Reason = "workflow_did_not_succeed" + return result, 1 + } + if run.Conclusion != "success" { + return unknown("workflow_result_unknown") + } + var expected uint64 + seen := map[uint64]bool{} + var ended time.Time + successful := false + for page := 1; page <= 100; page++ { + var data jobsPage + if err := readJSON(ctx, api, fmt.Sprintf("%s/jobs?filter=latest&per_page=100&page=%d", path, page), &data); err != nil { + return unknown("jobs_read_failed") + } + if data.Total == nil || *data.Total == 0 || *data.Total > 10000 { + return unknown("invalid_job_count") + } + if page == 1 { + expected = *data.Total + } + if *data.Total != expected { + return unknown("job_count_changed") + } + remaining := expected - uint64(len(seen)) + wanted := remaining + if wanted > 100 { + wanted = 100 + } + if uint64(len(data.Jobs)) != wanted { + return unknown("incomplete_job_page") + } + for _, j := range data.Jobs { + if j.ID == 0 || seen[j.ID] || j.RunID != o.runID || j.Attempt == 0 || j.Attempt > o.attempt || j.Head != o.head || j.Status != "completed" { + return unknown("job_identity_or_state_mismatch") + } + seen[j.ID] = true + if j.Conclusion != "success" && j.Conclusion != "skipped" { + return unknown("job_did_not_succeed") + } + if j.CompletedAt == nil { + return unknown("missing_job_times") + } + end, err := time.Parse(time.RFC3339Nano, *j.CompletedAt) + if err != nil || end.Before(created) || end.After(now.Add(5*time.Second)) { + return unknown("invalid_job_times") + } + if j.Conclusion == "success" { + if j.StartedAt == nil { + return unknown("missing_job_times") + } + start, err := time.Parse(time.RFC3339Nano, *j.StartedAt) + if err != nil || start.Before(created) || end.Before(start) { + return unknown("invalid_job_times") + } + successful = true + } + // A native skip has no execution interval. GitHub may stamp its + // synthetic start after completion; only its terminal time counts. + if end.After(ended) { + ended = end + } + } + if uint64(len(seen)) == expected { + break + } + } + if uint64(len(seen)) != expected || ended.IsZero() || !successful { + return unknown("incomplete_jobs") + } + var final workflowRun + if err := readJSON(ctx, api, path, &final); err != nil { + return unknown("final_run_read_failed") + } + if final != run { + return unknown("run_changed_during_read") + } + elapsed := ended.Sub(created) + seconds := elapsed.Seconds() + result.CreatedAt = created.UTC().Format(time.RFC3339Nano) + result.CompletedAt = ended.UTC().Format(time.RFC3339Nano) + result.Jobs = len(seen) + result.ElapsedSeconds = &seconds + result.State = "within_budget" + result.Reason = "completed_workflow_below_900_seconds" + if elapsed >= 15*time.Minute { + result.State = "over_budget" + result.Reason = "optimize_or_redesign_workflow" + return result, 1 + } + return result, 0 +} + +type commandAPI struct{ program string } + +type boundedBuffer struct{ bytes.Buffer } + +// Write limits captured metadata so a child command cannot grow memory unboundedly. +func (b *boundedBuffer) Write(p []byte) (int, error) { + if b.Len()+len(p) > 8<<20 { + return 0, fmt.Errorf("metadata response too large") + } + return b.Buffer.Write(p) +} + +// read performs a bounded GitHub-only GET and discards incomplete or failed output. +func (a commandAPI) read(ctx context.Context, path string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, a.program, "api", "--hostname", "github.com", "--method", "GET", "--", path) + cmd.Env = append(os.Environ(), "GH_PROMPT_DISABLED=1", "GH_DEBUG=") + cmd.WaitDelay = 2 * time.Second + var output boundedBuffer + cmd.Stdout = &output + cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("metadata command failed") + } + return output.Bytes(), nil +} + +// execute validates CLI arguments and writes one sanitized single-run receipt. +func execute(args []string, out io.Writer, api reader, now time.Time) int { + flags := flag.NewFlagSet("check-ci-run-budget", flag.ContinueOnError) + flags.SetOutput(io.Discard) + var o options + flags.StringVar(&o.repository, "repository", "", "") + flags.StringVar(&o.head, "head-sha", "", "") + flags.StringVar(&o.workflow, "workflow-path", "", "") + flags.StringVar(&o.event, "event", "", "") + flags.Uint64Var(&o.runID, "run-id", 0, "") + flags.Uint64Var(&o.attempt, "attempt", 0, "") + result := receipt{Schema: 1, State: "unknown", Scope: "single_workflow_run", Reason: "invalid_arguments", BudgetSeconds: 900} + code := 2 + if err := flags.Parse(args); err == nil && flags.NArg() == 0 { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + result, code = inspect(ctx, o, api, now) + } + if err := json.NewEncoder(out).Encode(result); err != nil { + return 2 + } + return code +} + +// main connects the native CLI reader to the process exit status. +func main() { + os.Exit(execute(os.Args[1:], os.Stdout, commandAPI{program: "gh"}, time.Now())) +} diff --git a/.github/scripts/check-ci-run-budget/main_test.go b/.github/scripts/check-ci-run-budget/main_test.go new file mode 100644 index 00000000..ea7a2df3 --- /dev/null +++ b/.github/scripts/check-ci-run-budget/main_test.go @@ -0,0 +1,331 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +const head = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + +type fixtureAPI struct { + run []byte + page []byte + final []byte + pages map[int][]byte + failPage int + reads int +} + +// read serves exact fixture routes and models partial output followed by failure. +func (f *fixtureAPI) read(_ context.Context, path string) ([]byte, error) { + switch path { + case "repos/example/repo/actions/runs/42": + f.reads++ + if f.reads > 1 && f.final != nil { + return f.final, nil + } + return f.run, nil + default: + prefix := "repos/example/repo/actions/runs/42/jobs?filter=latest&per_page=100&page=" + if strings.HasPrefix(path, prefix) { + page, err := strconv.Atoi(strings.TrimPrefix(path, prefix)) + if err != nil { + return nil, err + } + data := f.page + if f.pages != nil { + data = f.pages[page] + } + if page == f.failPage { + return data, errors.New("fixture secret: plausible partial output before failure") + } + if data != nil { + return data, nil + } + } + return nil, fmt.Errorf("unexpected route") + } +} + +// fixture supplies one successful run with a controlled actual completion time. +func fixture(seconds int) fixtureAPI { + end := time.Date(2026, 10, 19, 0, 0, seconds, 0, time.UTC).Format(time.RFC3339) + return fixtureAPI{ + run: []byte(`{"id":42,"run_attempt":1,"repository":{"full_name":"example/repo"},"head_sha":"` + head + `","path":".github/workflows/ci.yaml","event":"pull_request","status":"completed","conclusion":"success","created_at":"2026-10-19T00:00:00Z","updated_at":"2026-10-19T05:00:00Z"}`), + page: []byte(`{"total_count":1,"jobs":[{"id":101,"run_id":42,"run_attempt":1,"head_sha":"` + head + `","status":"completed","conclusion":"success","started_at":"2026-10-19T00:01:00Z","completed_at":"` + end + `"}]}`), + } +} + +// TestBudgetUsesJobCompletionAndStrictBoundary pins 899/900/901-second verdicts. +func TestBudgetUsesJobCompletionAndStrictBoundary(t *testing.T) { + for _, tc := range []struct { + name string + seconds int + state string + code int + }{ + {"below", 899, "within_budget", 0}, + {"at", 900, "over_budget", 1}, + {"above", 901, "over_budget", 1}, + } { + t.Run(tc.name, func(t *testing.T) { + o := options{"example/repo", head, ".github/workflows/ci.yaml", "pull_request", 42, 1} + api := fixture(tc.seconds) + got, code := inspect(context.Background(), o, &api, time.Date(2026, 10, 19, 6, 0, 0, 0, time.UTC)) + if code != tc.code || got.State != tc.state || got.ElapsedSeconds == nil || *got.ElapsedSeconds != float64(tc.seconds) { + t.Fatalf("got %+v code=%d; want %s code=%d elapsed=%d", got, code, tc.state, tc.code, tc.seconds) + } + }) + } +} + +// change mutates a complete fixture document while preserving valid JSON. +func change(t *testing.T, raw []byte, mutate func(map[string]any)) []byte { + t.Helper() + var value map[string]any + if err := json.Unmarshal(raw, &value); err != nil { + t.Fatal(err) + } + mutate(value) + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return data +} + +// TestRefusesIncompleteOrMismatchedEvidence rejects unsafe run and page observations. +func TestRefusesIncompleteOrMismatchedEvidence(t *testing.T) { + for _, tc := range []struct { + name string + mutate func(*fixtureAPI) + code int + }{ + {"empty jobs", func(f *fixtureAPI) { f.page = []byte(`{"total_count":0,"jobs":[]}`) }, 2}, + {"short page", func(f *fixtureAPI) { f.page = change(t, f.page, func(v map[string]any) { v["total_count"] = 2 }) }, 2}, + {"missing count", func(f *fixtureAPI) { f.page = change(t, f.page, func(v map[string]any) { delete(v, "total_count") }) }, 2}, + {"fractional count", func(f *fixtureAPI) { f.page = change(t, f.page, func(v map[string]any) { v["total_count"] = 1.5 }) }, 2}, + {"partial success then error", func(f *fixtureAPI) { f.failPage = 1 }, 2}, + {"invalid JSON", func(f *fixtureAPI) { f.page = []byte("{") }, 2}, + {"trailing JSON", func(f *fixtureAPI) { f.page = append(f.page, []byte("{}")...) }, 2}, + {"run mismatch", func(f *fixtureAPI) { f.run = change(t, f.run, func(v map[string]any) { v["id"] = 43 }) }, 2}, + {"repository mismatch", func(f *fixtureAPI) { + f.run = change(t, f.run, func(v map[string]any) { v["repository"] = map[string]any{"full_name": "other/repo"} }) + }, 2}, + {"head mismatch", func(f *fixtureAPI) { + f.run = change(t, f.run, func(v map[string]any) { v["head_sha"] = strings.Repeat("b", 40) }) + }, 2}, + {"workflow mismatch", func(f *fixtureAPI) { + f.run = change(t, f.run, func(v map[string]any) { v["path"] = ".github/workflows/fast.yaml" }) + }, 2}, + {"event mismatch", func(f *fixtureAPI) { f.run = change(t, f.run, func(v map[string]any) { v["event"] = "push" }) }, 2}, + {"attempt mismatch", func(f *fixtureAPI) { f.run = change(t, f.run, func(v map[string]any) { v["run_attempt"] = 2 }) }, 2}, + {"run pending", func(f *fixtureAPI) { f.run = change(t, f.run, func(v map[string]any) { v["status"] = "in_progress" }) }, 2}, + {"run failed", func(f *fixtureAPI) { f.run = change(t, f.run, func(v map[string]any) { v["conclusion"] = "failure" }) }, 1}, + {"run cancelled", func(f *fixtureAPI) { + f.run = change(t, f.run, func(v map[string]any) { v["conclusion"] = "cancelled" }) + }, 1}, + {"invalid creation", func(f *fixtureAPI) { f.run = change(t, f.run, func(v map[string]any) { v["created_at"] = "invalid" }) }, 2}, + {"creation after jobs", func(f *fixtureAPI) { + f.run = change(t, f.run, func(v map[string]any) { v["created_at"] = "2026-10-19T00:15:00Z" }) + }, 2}, + {"changed attempt at final read", func(f *fixtureAPI) { f.final = change(t, f.run, func(v map[string]any) { v["run_attempt"] = 2 }) }, 2}, + {"changed head at final read", func(f *fixtureAPI) { + f.final = change(t, f.run, func(v map[string]any) { v["head_sha"] = strings.Repeat("b", 40) }) + }, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + f := fixture(899) + tc.mutate(&f) + got, code := inspect(context.Background(), options{"example/repo", head, ".github/workflows/ci.yaml", "pull_request", 42, 1}, &f, time.Date(2026, 10, 19, 6, 0, 0, 0, time.UTC)) + if code != tc.code || got.State == "within_budget" || got.ElapsedSeconds != nil { + t.Fatalf("invalid observation was accepted: %+v code=%d", got, code) + } + }) + } +} + +// TestRejectsUnboundOrUnfinishedJobs binds every job to terminal, consistent metadata. +func TestRejectsUnboundOrUnfinishedJobs(t *testing.T) { + for _, tc := range []struct { + key string + value any + }{ + {"id", 0}, {"run_id", 43}, {"run_attempt", 0}, {"run_attempt", 2}, {"head_sha", strings.Repeat("b", 40)}, + {"status", "queued"}, {"conclusion", "failure"}, {"conclusion", "cancelled"}, {"conclusion", "abandoned"}, + {"started_at", "invalid"}, {"completed_at", nil}, {"completed_at", "2026-10-19T00:00:01Z"}, + {"completed_at", "2026-10-20T00:00:00Z"}, + } { + t.Run(fmt.Sprintf("%s=%v", tc.key, tc.value), func(t *testing.T) { + f := fixture(899) + f.page = change(t, f.page, func(v map[string]any) { v["jobs"].([]any)[0].(map[string]any)[tc.key] = tc.value }) + got, code := inspect(context.Background(), options{"example/repo", head, ".github/workflows/ci.yaml", "pull_request", 42, 1}, &f, time.Date(2026, 10, 19, 6, 0, 0, 0, time.UTC)) + if code != 2 || got.ElapsedSeconds != nil { + t.Fatalf("job mismatch accepted: %+v code=%d", got, code) + } + }) + } +} + +// TestCompletePaginationAndStableAttempt requires all unique pages despite read errors. +func TestCompletePaginationAndStableAttempt(t *testing.T) { + f := fixture(899) + var one map[string]any + if err := json.Unmarshal(f.page, &one); err != nil { + t.Fatal(err) + } + job := one["jobs"].([]any)[0].(map[string]any) + var first []any + for i := 0; i < 100; i++ { + copy := map[string]any{} + for k, v := range job { + copy[k] = v + } + copy["id"] = i + 1 + first = append(first, copy) + } + page1, err := json.Marshal(map[string]any{"total_count": 101, "jobs": first}) + if err != nil { + t.Fatal(err) + } + page2 := change(t, f.page, func(v map[string]any) { v["total_count"] = 101 }) + f.pages = map[int][]byte{1: page1, 2: page2} + o := options{"example/repo", head, ".github/workflows/ci.yaml", "pull_request", 42, 1} + got, code := inspect(context.Background(), o, &f, time.Date(2026, 10, 19, 6, 0, 0, 0, time.UTC)) + if code != 0 || got.ElapsedSeconds == nil || *got.ElapsedSeconds != 899 { + t.Fatalf("complete pages failed: %+v code=%d", got, code) + } + for _, mutate := range []func(*fixtureAPI){ + func(f *fixtureAPI) { f.failPage = 2 }, + func(f *fixtureAPI) { f.pages[2] = change(t, page2, func(v map[string]any) { v["total_count"] = 102 }) }, + func(f *fixtureAPI) { + f.pages[2] = change(t, page2, func(v map[string]any) { v["jobs"].([]any)[0].(map[string]any)["id"] = 1 }) + }, + } { + f.pages = map[int][]byte{1: page1, 2: page2} + f.failPage = 0 + mutate(&f) + got, code = inspect(context.Background(), o, &f, time.Date(2026, 10, 19, 6, 0, 0, 0, time.UTC)) + if code != 2 || got.ElapsedSeconds != nil { + t.Fatalf("partial pages accepted: %+v code=%d", got, code) + } + } +} + +// TestRerunRetainsOriginalCreationClock prevents a partial rerun resetting the budget. +func TestRerunRetainsOriginalCreationClock(t *testing.T) { + f := fixture(901) + f.run = change(t, f.run, func(v map[string]any) { v["run_attempt"] = 2; v["run_started_at"] = "2026-10-19T00:14:59Z" }) + // Successful jobs reused by a partial rerun still belong to attempt1. + got, code := inspect(context.Background(), options{"example/repo", head, ".github/workflows/ci.yaml", "pull_request", 42, 2}, &f, time.Date(2026, 10, 19, 6, 0, 0, 0, time.UTC)) + if code != 1 || got.ElapsedSeconds == nil || *got.ElapsedSeconds != 901 { + t.Fatalf("rerun reset budget: %+v code=%d", got, code) + } +} + +// TestSkippedJobsCannotHideLateCompletionOrEmptyExecution counts every terminal time. +func TestSkippedJobsCannotHideLateCompletionOrEmptyExecution(t *testing.T) { + f := fixture(899) + f.page = change(t, f.page, func(v map[string]any) { + v["total_count"] = 2 + v["jobs"] = append(v["jobs"].([]any), map[string]any{ + "id": 102, "run_id": 42, "run_attempt": 1, "head_sha": head, "status": "completed", "conclusion": "skipped", + "started_at": "2026-10-19T00:15:01Z", "completed_at": "2026-10-19T00:15:01Z", + }) + }) + got, code := inspect(context.Background(), options{"example/repo", head, ".github/workflows/ci.yaml", "pull_request", 42, 1}, &f, time.Date(2026, 10, 19, 6, 0, 0, 0, time.UTC)) + if code != 1 || got.ElapsedSeconds == nil || *got.ElapsedSeconds != 901 { + t.Fatalf("late skip hid duration: %+v code=%d", got, code) + } + f = fixture(899) + f.page = change(t, f.page, func(v map[string]any) { v["jobs"].([]any)[0].(map[string]any)["conclusion"] = "skipped" }) + got, code = inspect(context.Background(), options{"example/repo", head, ".github/workflows/ci.yaml", "pull_request", 42, 1}, &f, time.Date(2026, 10, 19, 6, 0, 0, 0, time.UTC)) + if code != 2 || got.ElapsedSeconds != nil { + t.Fatalf("empty execution passed: %+v code=%d", got, code) + } +} + +// TestNativeSkippedJobHasNoExecutionStartClock accepts native synthetic skip starts. +func TestNativeSkippedJobHasNoExecutionStartClock(t *testing.T) { + // GitHub reports these placeholder skip timestamps in catalogue run39263825120. + f := fixture(899) + f.page = change(t, f.page, func(v map[string]any) { + v["total_count"] = 2 + v["jobs"] = append(v["jobs"].([]any), map[string]any{ + "id": 102, "run_id": 42, "run_attempt": 1, "head_sha": head, "status": "completed", "conclusion": "skipped", + "started_at": "2026-10-19T00:00:03Z", "completed_at": "2026-10-19T00:00:02Z", + }) + }) + got, code := inspect(context.Background(), options{"example/repo", head, ".github/workflows/ci.yaml", "pull_request", 42, 1}, &f, time.Date(2026, 10, 19, 6, 0, 0, 0, time.UTC)) + if code != 0 || got.ElapsedSeconds == nil || *got.ElapsedSeconds != 899 { + t.Fatalf("native skip placeholder rejected: %+v code=%d", got, code) + } +} + +// TestCLIEmitsScopedReceipts verifies verdicts and refuses invalid flags without reads. +func TestCLIEmitsScopedReceipts(t *testing.T) { + args := []string{"--repository", "example/repo", "--run-id", "42", "--attempt", "1", "--head-sha", head, "--workflow-path", ".github/workflows/ci.yaml", "--event", "pull_request"} + for _, tc := range []struct { + seconds, code int + state string + }{ + {899, 0, "within_budget"}, {900, 1, "over_budget"}, {901, 1, "over_budget"}, + } { + f := fixture(tc.seconds) + var out bytes.Buffer + code := execute(args, &out, &f, time.Date(2026, 10, 19, 6, 0, 0, 0, time.UTC)) + var receipt struct { + State, Scope string + Elapsed *float64 `json:"elapsed_seconds"` + } + if err := json.Unmarshal(out.Bytes(), &receipt); err != nil { + t.Fatal(err) + } + if code != tc.code || receipt.State != tc.state || receipt.Scope != "single_workflow_run" || receipt.Elapsed == nil || *receipt.Elapsed != float64(tc.seconds) { + t.Fatalf("CLI: %s code=%d", out.String(), code) + } + } + for _, bad := range [][]string{ + {}, {"--run-id", "42"}, {"--repository", "../../secret"}, {"--budget", "999999"}, {"--head-sha", "secret-token"}, {"--run-id", "-1"}, + } { + var out bytes.Buffer + f := fixture(899) + code := execute(bad, &out, &f, time.Now()) + if code != 2 || f.reads != 0 || strings.Contains(out.String(), "secret-token") { + t.Fatalf("bad flags admitted or echoed: %s code=%d reads=%d", out.String(), code, f.reads) + } + } +} + +// TestNativeCommandIsReadOnlyAndRejectsPlausibleFailedOutput exercises the subprocess. +func TestNativeCommandIsReadOnlyAndRejectsPlausibleFailedOutput(t *testing.T) { + dir := t.TempDir() + program := filepath.Join(dir, "gh") + for _, tc := range []struct { + exit string + wantError bool + }{{"0", false}, {"17", true}} { + script := "#!/bin/sh\n[ \"$#\" -eq 7 ] && [ \"$1\" = api ] && [ \"$2\" = --hostname ] && [ \"$3\" = github.com ] && [ \"$4\" = --method ] && [ \"$5\" = GET ] && [ \"$6\" = -- ] && [ \"$7\" = repos/example/repo/actions/runs/42 ] || exit 99\nprintf '%s' '{\"id\":42}'\nprintf '%s' 'synthetic-secret' >&2\nexit " + tc.exit + "\n" + if err := os.WriteFile(program, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + api := commandAPI{program: program} + data, err := api.read(context.Background(), "repos/example/repo/actions/runs/42") + if tc.wantError { + if err == nil || data != nil || strings.Contains(err.Error(), "synthetic-secret") { + t.Fatalf("accepted partial command result: %q %v", data, err) + } + } else if err != nil || string(data) != `{"id":42}` { + t.Fatalf("native GET failed: %q %v", data, err) + } + } +} diff --git a/.github/scripts/ci-selection/inventory.json b/.github/scripts/ci-selection/inventory.json index 047bd357..5a99be34 100644 --- a/.github/scripts/ci-selection/inventory.json +++ b/.github/scripts/ci-selection/inventory.json @@ -18,6 +18,7 @@ "test-validate-shell-pipelines", "test-validate-shell-pipelines-scope", "test-zizmor-action-lockstep", "test-zizmor-blocks" ], "preserved": { + "test-check-ci-run-budget": {"if": "${{ github.event_name != 'merge_group' && !startsWith(github.event.head_commit.message, 'chore(main): release ') }}"}, "test-apply-signed-fixes-verifies-without-a-patch": {"if": "${{ github.event_name != 'merge_group' && !startsWith(github.event.head_commit.message, 'chore(main): release ') }}"}, "test-dependency-review-workflow": {"if": "${{ github.event_name != 'merge_group' && !startsWith(github.event.head_commit.message, 'chore(main): release ') }}"}, "test-apply-signed-fixes-suppresses-bots": {"if": "${{ github.event_name != 'merge_group' && !startsWith(github.event.head_commit.message, 'chore(main): release ') }}"}, @@ -176,6 +177,7 @@ "test-prepare-fixes": [], "test-lint-gate-lockstep": ["actions/upload-coverage/"], "test-apply-signed-fixes-suppresses-bots": [], - "test-apply-signed-fixes-verifies-without-a-patch": [] + "test-apply-signed-fixes-verifies-without-a-patch": [], + "test-check-ci-run-budget": ["actions/check-ci-run-budget/"] } } diff --git a/.github/tests/test-ci-run-budget.sh b/.github/tests/test-ci-run-budget.sh new file mode 100644 index 00000000..7b982e5e --- /dev/null +++ b/.github/tests/test-ci-run-budget.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Exercise shipped action steps and native Go/CLI refusal paths without network. +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" +action="$root/actions/check-ci-run-budget/action.yaml" +[[ -f "$action" ]] || { echo 'CI run budget action is missing' >&2; exit 1; } +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" "$work/runner" +yq -o=json . "$action" > "$work/action.json" +jq -e ' + .inputs.enabled.default == "false" and + .outputs.validated.value == "${{ steps.check.outputs.validated }}" and + .outputs.receipt.value == "${{ steps.check.outputs.receipt }}" and + (.runs.steps | length == 4) and + (.runs.steps[0].id == "admit") and + ([.runs.steps[1:][] | .if] | all(. == "steps.admit.outputs.enabled == '\''true'\''")) and + (.runs.steps[1].uses | test("^actions/setup-go@[a-f0-9]{40}$")) and + .runs.steps[1].with.cache == false and + .runs.steps[1].with["go-version-file"] == "${{ github.action_path }}/../../.github/scripts/check-ci-run-budget/go.mod" and + .runs.steps[2].env == {GOWORK:"off",GOFLAGS:"",GOTOOLCHAIN:"local",CGO_ENABLED:"0"} and + .runs.steps[2].id == "build" and + .runs.steps[3].id == "check" and + .runs.steps[3].env.GH_TOKEN == "${{ inputs.github-token || github.token }}" and + ([.runs.steps[] | .["continue-on-error"]] | all(. == null or . == false)) +' "$work/action.json" >/dev/null +for id in admit build check; do + yq -r ".runs.steps[] | select(.id == \"$id\") | .run" "$action" > "$work/$id.sh" +done +for enabled in '' false; do + ENABLED="$enabled" GITHUB_OUTPUT="$work/off" bash "$work/admit.sh" + [[ "$(cat "$work/off")" == enabled=false ]] + rm "$work/off" +done +if ENABLED=invalid GITHUB_OUTPUT="$work/invalid" bash "$work/admit.sh" >/dev/null 2>&1; then + echo 'invalid flag was admitted' >&2; exit 1 +fi +[[ ! -e "$work/invalid" ]] +go -C "$root/.github/scripts/check-ci-run-budget" test -race -cover ./... +go -C "$root/.github/scripts/check-ci-run-budget" vet ./... +cat > "$work/bin/gh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +[[ "$#" == 7 && "$1" == api && "$2" == --hostname && "$3" == github.com && + "$4" == --method && "$5" == GET && "$6" == -- ]] || exit 99 +case "$7" in + repos/example/repo/actions/runs/42) cat "$BUDGET_FIXTURE/run.json" ;; + 'repos/example/repo/actions/runs/42/jobs?filter=latest&per_page=100&page=1') cat "$BUDGET_FIXTURE/jobs.json" ;; + *) exit 98 ;; +esac +if [[ "${FAIL_METADATA:-false}" == true ]]; then exit 17; fi +SH +chmod +x "$work/bin/gh" +REAL_JQ="$(command -v jq)" +export REAL_JQ +cat > "$work/bin/jq" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +if [[ "${FAIL_RECEIPT_SERIALIZATION:-false}" == true && "$#" == 3 && "$1" == -c && "$2" == . ]]; then + exit 17 +fi +exec "$REAL_JQ" "$@" +SH +chmod +x "$work/bin/jq" +export PATH="$work/bin:$PATH" BUDGET_FIXTURE="$work" +head=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +printf '%s\n' '{"id":42,"run_attempt":1,"repository":{"full_name":"example/repo"},"head_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","path":".github/workflows/ci.yaml","event":"pull_request","status":"completed","conclusion":"success","created_at":"2020-01-01T00:00:00Z","updated_at":"2020-01-02T00:00:00Z"}' > "$work/run.json" +for scenario in below at above failed-read failed-serialization; do + case "$scenario" in + below|failed-read|failed-serialization) end=00:14:59; expected=within_budget; code=0 ;; + at) end=00:15:00; expected=over_budget; code=1 ;; + above) end=00:15:01; expected=over_budget; code=1 ;; + esac + fail_metadata=false + fail_serialization=false + if [[ "$scenario" == failed-read ]]; then fail_metadata=true; expected=unknown; code=2; fi + if [[ "$scenario" == failed-serialization ]]; then fail_serialization=true; code=17; fi + printf '{"total_count":1,"jobs":[{"id":1,"run_id":42,"run_attempt":1,"head_sha":"%s","status":"completed","conclusion":"success","started_at":"2020-01-01T00:01:00Z","completed_at":"2020-01-01T%sZ"}]}\n' "$head" "$end" > "$work/jobs.json" + ENABLED=true GITHUB_OUTPUT="$work/on" bash "$work/admit.sh" + [[ "$(cat "$work/on")" == enabled=true ]] + rm "$work/on" + GITHUB_ACTION_PATH="$root/actions/check-ci-run-budget" RUNNER_TEMP="$work/runner" GITHUB_OUTPUT="$work/build-output" \ + GOWORK=off GOFLAGS="" GOTOOLCHAIN=local CGO_ENABLED=0 bash "$work/build.sh" + binary="$(sed -n 's/^binary=//p' "$work/build-output")" + rm "$work/build-output" + actual=0 + BINARY="$binary" REPOSITORY=example/repo RUN_ID=42 RUN_ATTEMPT=1 HEAD_SHA="$head" \ + WORKFLOW_PATH=.github/workflows/ci.yaml EVENT_NAME=pull_request FAIL_METADATA="$fail_metadata" \ + FAIL_RECEIPT_SERIALIZATION="$fail_serialization" \ + RUNNER_TEMP="$work/runner" GITHUB_OUTPUT="$work/validated" \ + bash "$work/check.sh" > "$work/receipt.json" || actual=$? + if [[ "$actual" != "$code" ]]; then + printf '%s: expected action exit %s, got %s\n' "$scenario" "$code" "$actual" >&2 + exit 1 + fi + jq -e --arg state "$expected" '.scope == "single_workflow_run" and .state == $state' "$work/receipt.json" >/dev/null + [[ ! -e "$binary" ]] + [[ ! -e "${binary%/*}" ]] + if [[ "$code" == 0 ]]; then + grep -qx 'validated=true' "$work/validated" + published_receipt="$(sed -n 's/^receipt=//p' "$work/validated")" + [[ -n "$published_receipt" && "$published_receipt" == "$(jq -c . "$work/receipt.json")" ]] + rm "$work/validated" + else + [[ ! -e "$work/validated" ]] + fi +done +echo 'PASS: disabled, enabled, exact deadline, native read-failure and output-serialization paths' diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 39b6ce6f..5d79dc59 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -980,6 +980,47 @@ jobs: - name: ✅ Verify GHCR login run: cat ~/.docker/config.json | grep -q "ghcr.io" + test-check-ci-run-budget: + name: "[Test] CI Run Budget (${{ matrix.os }})" + if: "${{ github.event_name != 'merge_group' && !startsWith(github.event.head_commit.message, 'chore(main): release ') }}" + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + permissions: + contents: read + steps: + - name: Harden runner + uses: step-security/harden-runner@ccd8616d44fd3846e67624a50d5aad6d37bf2d25 # v2.22.1 + with: + egress-policy: audit + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Exercise omitted opt-in without API authority + id: disabled + uses: ./actions/check-ci-run-budget + - name: Assert disabled calls publish no validation + env: + VALIDATED: ${{ steps.disabled.outputs.validated }} + RECEIPT: ${{ steps.disabled.outputs.receipt }} + run: | + test -z "$VALIDATED" + test -z "$RECEIPT" + - name: Set up offline test toolchain + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: .github/scripts/check-ci-run-budget/go.mod + cache: false + - name: Exercise enabled, disabled and refusal paths + env: + GOWORK: "off" + GOFLAGS: "" + GOTOOLCHAIN: local + run: bash .github/tests/test-ci-run-budget.sh + # ── aggregate-job-checks ──────────────────────────────────── test-aggregate-job-checks-empty: needs: [select-ci-tests] @@ -6329,6 +6370,7 @@ jobs: - test-free-disk-space - test-free-disk-space-keep - test-login-to-ghcr + - test-check-ci-run-budget - test-aggregate-job-checks-empty - test-aggregate-job-checks-success - test-aggregate-job-checks-failure @@ -6472,6 +6514,7 @@ jobs: ${{ needs.test-free-disk-space.result }} ${{ needs.test-free-disk-space-keep.result }} ${{ needs.test-login-to-ghcr.result }} + ${{ needs.test-check-ci-run-budget.result }} ${{ needs.test-aggregate-job-checks-empty.result }} ${{ needs.test-aggregate-job-checks-success.result }} ${{ needs.test-aggregate-job-checks-failure.result }} diff --git a/README.md b/README.md index 57d72c06..600f4793 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ flowchart TD |--------|-------------| | [aggregate-job-checks](actions/aggregate-job-checks/README.md) | Aggregate multiple job results into a single required check | | [approve-pr](actions/approve-pr/README.md) | Approve a PR using a GitHub App identity | +| [check-ci-run-budget](actions/check-ci-run-budget/README.md) | Opt-in, metadata-only 15-minute budget check for one exact completed workflow run | | [cleanup-ghcr-packages](actions/cleanup-ghcr-packages/README.md) | Clean up old GHCR packages | | [create-issues-from-todos](actions/create-issues-from-todos/README.md) | Create GitHub issues from TODO comments, with optional project integration | | [dependency-review](actions/dependency-review/README.md) | Scan a PR's dependency changes for vulnerabilities and disallowed licenses | diff --git a/actions/check-ci-run-budget/README.md b/actions/check-ci-run-budget/README.md new file mode 100644 index 00000000..a1bfe77c --- /dev/null +++ b/actions/check-ci-run-budget/README.md @@ -0,0 +1,64 @@ +# Check CI Run Budget + +Reject a completed CI workflow that took 15 minutes or longer, including runner +waits, setup, dependent jobs and teardown. The check reads native GitHub metadata; +it does not execute the inspected revision or download its logs or artifacts. + +This is a **single-workflow** check, not a whole-PR budget gate. Independent +required workflows must also be assessed. It is disabled unless explicitly enabled. + +## Inputs + +| Name | Description | Required | Default | +|---|---|---|---| +| `enabled` | Enable metadata reads and validation | No | `false` | +| `run-id` | Completed run in the calling repository | When enabled | — | +| `run-attempt` | Expected current attempt | When enabled | — | +| `head-sha` | Full lowercase source commit SHA | When enabled | — | +| `workflow-path` | Expected path such as `.github/workflows/ci.yaml` | When enabled | — | +| `event` | `pull_request` or `merge_group` | When enabled | — | +| `github-token` | Token with `actions: read` | No | Calling job's token | + +The caller needs GitHub CLI, `jq` and a Bash-capable Linux or macOS runner. +The action sets up the Go version declared by its inspector module. Keep job +permissions read-only; no App or write permission is needed. + +## Outputs + +| Name | Description | +|---|---| +| `validated` | `true` only after complete successful evidence below 900 seconds | +| `receipt` | JSON result with exact identity, original creation, actual completion and elapsed seconds | + +Disabled and failed calls expose neither output. Failure logs contain a sanitized +JSON receipt: `over_budget` means optimize or redesign the workflow; +`ci_failed` means the native workflow did not succeed; `unknown` means the +evidence cannot support validation. + +## Usage + +Inspect an already completed run; do not point this action at its own running +workflow. Resolve the expected identity from a trusted observation, not a PR comment. + +```yaml +permissions: + actions: read +steps: + - uses: devantler-tech/.github/actions/check-ci-run-budget@ # vX.Y.Z + with: + enabled: "true" + run-id: "123456789" + run-attempt: "1" + head-sha: "" + workflow-path: .github/workflows/ci.yaml + event: pull_request +``` + +Replace the action version and subject placeholders before running. The deadline +is fixed: strictly less than 900 seconds. A rerun keeps the original workflow +creation time rather than receiving another 15 minutes. Mutable workflow update +timestamps never determine completion. + +See the [measurement contract](../../docs/ci-feedback-budget.md) for scope and +failure behavior, and [portfolio rollout](https://github.com/devantler-tech/.github/issues/654) +for whole-PR aggregation and required enforcement. diff --git a/actions/check-ci-run-budget/action.yaml b/actions/check-ci-run-budget/action.yaml new file mode 100644 index 00000000..fa068125 --- /dev/null +++ b/actions/check-ci-run-budget/action.yaml @@ -0,0 +1,99 @@ +name: Check CI Run Budget +description: Check one exact completed workflow run against the fixed 15-minute feedback budget +author: devantler-tech +inputs: + enabled: + description: Explicit opt-in; disabled calls perform no metadata reads or validation + required: false + default: "false" + run-id: + description: Native completed workflow run ID in the calling repository + required: false + run-attempt: + description: Expected current run attempt; reruns retain the original creation clock + required: false + head-sha: + description: Expected full lowercase commit SHA + required: false + workflow-path: + description: Expected repository-root workflow path + required: false + event: + description: Expected pull_request or merge_group event + required: false + github-token: + description: Token with actions-read access to the calling repository + required: false +outputs: + validated: + description: True only for a complete successful single-workflow run below 900 seconds + value: ${{ steps.check.outputs.validated }} + receipt: + description: Single-workflow JSON receipt; never a whole-PR compliance result + value: ${{ steps.check.outputs.receipt }} +runs: + using: composite + steps: + - name: Admit explicit opt-in + id: admit + shell: bash + env: + ENABLED: ${{ inputs.enabled }} + run: | + set -euo pipefail + case "$ENABLED" in + ''|false) printf 'enabled=false\n' >> "$GITHUB_OUTPUT" ;; + true) printf 'enabled=true\n' >> "$GITHUB_OUTPUT" ;; + *) echo '::error::enabled must be true or false' >&2; exit 2 ;; + esac + - name: Set up inspector toolchain + if: steps.admit.outputs.enabled == 'true' + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: ${{ github.action_path }}/../../.github/scripts/check-ci-run-budget/go.mod + cache: false + - name: Build metadata-only inspector + if: steps.admit.outputs.enabled == 'true' + id: build + shell: bash + env: + GOWORK: "off" + GOFLAGS: "" + GOTOOLCHAIN: local + CGO_ENABLED: "0" + run: | + set -euo pipefail + scratch="$(mktemp -d "$RUNNER_TEMP/ci-budget.XXXXXX")" + trap 'rm -rf "$scratch"' EXIT + go -C "$GITHUB_ACTION_PATH/../../.github/scripts/check-ci-run-budget" build -trimpath -o "$scratch/check-ci-run-budget" . + printf 'binary=%s/check-ci-run-budget\n' "$scratch" >> "$GITHUB_OUTPUT" + trap - EXIT + - name: Check complete native evidence + if: steps.admit.outputs.enabled == 'true' + id: check + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token || github.token }} + REPOSITORY: ${{ github.repository }} + RUN_ID: ${{ inputs.run-id }} + RUN_ATTEMPT: ${{ inputs.run-attempt }} + HEAD_SHA: ${{ inputs.head-sha }} + WORKFLOW_PATH: ${{ inputs.workflow-path }} + EVENT_NAME: ${{ inputs.event }} + BINARY: ${{ steps.build.outputs.binary }} + run: | + set -euo pipefail + [[ "$BINARY" == "$RUNNER_TEMP"/ci-budget.*/check-ci-run-budget && -f "$BINARY" ]] + scratch="${BINARY%/*}" + trap 'rm -rf "$scratch"' EXIT + status=0 + "$BINARY" --repository "$REPOSITORY" --run-id "$RUN_ID" --attempt "$RUN_ATTEMPT" \ + --head-sha "$HEAD_SHA" --workflow-path "$WORKFLOW_PATH" --event "$EVENT_NAME" \ + > "$scratch/receipt.json" || status=$? + cat "$scratch/receipt.json" + if [[ "$status" != 0 ]]; then exit "$status"; fi + receipt="$(jq -ce '.state == "within_budget" and .scope == "single_workflow_run"' "$scratch/receipt.json")" + [[ "$receipt" == true ]] + receipt="$(jq -c . "$scratch/receipt.json")" + [[ -n "$receipt" ]] + printf 'receipt=%s\nvalidated=true\n' "$receipt" >> "$GITHUB_OUTPUT" diff --git a/docs/ci-feedback-budget.md b/docs/ci-feedback-budget.md new file mode 100644 index 00000000..b483778b --- /dev/null +++ b/docs/ci-feedback-budget.md @@ -0,0 +1,32 @@ +# CI feedback budget + +The single-run check accepts one completed workflow run and an expected repository, +head commit, workflow path, event and attempt. It reads GitHub metadata only. +It does not certify an entire pull request: independent required workflows and +current-head reporting are separate concerns tracked in issue #654. + +The clock starts at the original workflow creation and ends at the latest actual +job completion. Runner waits, setup, job dependencies and teardown count. Workflow +update times do not determine completion. A rerun retains its original creation +time. A duration strictly below 900 seconds passes; 900 seconds or more fails. + +Every page must advertise the same complete job count. Job identities are unique +and bound to the expected run and head. Successful reused jobs from earlier +attempts may appear in GitHub's latest-job view, but no job may belong to a future +attempt. All jobs must have terminal completion timestamps and report success or +an intentional native skip; at least one successful job must exist. Successful +jobs require chronological execution times. Native skips have no execution +interval: their completion counts, but a synthetic start stamped after completion +does not invalidate it. Failed commands, malformed responses, +partial pages, impossible timestamps and a changed final run identity produce +unknown evidence, never a pass. + +The inspector uses bounded, explicit GET requests through GitHub CLI to +github.com. Candidate source, logs and artifacts are never fetched or executed. +API errors expose no response body or credential diagnostics. Receipts identify +their single-workflow scope; disabled and unsuccessful action calls publish no +validation output. + +The catalogue action is opt-in and default-off. Whole-PR aggregation, race-safe +current-head reporting, managed required enforcement, consumer adoption and +rollout-switch retirement remain distinct delivery gates under issue #654.