diff --git a/cmd/cascade/main.go b/cmd/cascade/main.go index 95d7714..744a506 100644 --- a/cmd/cascade/main.go +++ b/cmd/cascade/main.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" + "github.com/stablekernel/cascade/internal/branchprotection" "github.com/stablekernel/cascade/internal/changelog" "github.com/stablekernel/cascade/internal/changes" "github.com/stablekernel/cascade/internal/config" @@ -69,6 +70,7 @@ change detection, and changelog generation.`, rootCmd.PersistentFlags().BoolVar(&flagJSON, "json", false, "Output structured JSON for workflow consumption") // Add subcommands + rootCmd.AddCommand(branchprotection.NewCommand()) rootCmd.AddCommand(config.NewCommand()) rootCmd.AddCommand(changes.NewCommand()) rootCmd.AddCommand(changelog.NewCommand()) diff --git a/docs/src/content/docs/cli-reference.md b/docs/src/content/docs/cli-reference.md index 1fd07f7..efc373c 100644 --- a/docs/src/content/docs/cli-reference.md +++ b/docs/src/content/docs/cli-reference.md @@ -267,6 +267,45 @@ cascade verify Rather than wire this job by hand, set `drift_check.enabled: true` in the manifest and `generate-workflow` emits the drift-check workflow for you. See [Drift-check workflow](/configuration/#drift-check-workflow-opt-in). +### branch-protection + +Emit the JSON body an operator applies to GitHub's branch-protection API for a cascade-managed trunk. cascade emits the file; the operator applies it. cascade never calls the GitHub API. + +```bash +cascade branch-protection +``` + +The output is a wrapper with two top-level keys: + +- `protection` is the exact body to PUT to the branches protection API. +- `operator_todo` is companion guidance and is NOT part of the PUT body. + +Apply it by sending only the `.protection` object: + +```bash +cascade branch-protection | jq .protection | \ + gh api -X PUT repos/my-org/my-app/branches/main/protection --input - +``` + +#### What ends up required, and why it is safe + +The required status checks contain only the cascade-controlled `Setup` and `Finalize` jobs. These are the orchestrate workflow's two steps jobs; cascade knows their exact check-run names and both run on every pipeline run. Because of that, `.protection` applied as-is never creates a required check that can never report, so it never blocks a pull request on its own. + +The reusable-workflow caller jobs (validate, build, deploy) are deliberately left out of the required contexts. cascade knows each caller's display-name prefix (for example `Build (my-app)`) but not the inner job name that GitHub appends to form the real check-run context, which is ` / `. That inner job lives in your reusable workflow, which cascade does not author. Requiring a bare prefix would never match and would block every pull request, so cascade lists those prefixes under `operator_todo.complete_these_contexts` as ` / ` placeholders instead. Replace `` with the job name inside each reusable workflow, then add the completed strings to `required_status_checks.contexts` when you want them required. + +The `--branch` flag only labels the guidance note. The required contexts are the same across branches and environments because they are the orchestrate-workflow steps jobs, so `--env` would not change them and is not offered. + +This command complements the hotfix branch-protection advisory (see [Hotfix workflow](/workflows/#hotfix-workflow)): the advisory prints ready-to-run `gh` commands for env branches, while `branch-protection` emits the full PUT body for the trunk. + +#### Flags + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--config`, `-c` | string | auto-detect | Path to manifest file | +| `--manifest-key` | string | `ci` | Top-level key inside the manifest | +| `--branch` | string | `main` | Branch the protection targets (labels the guidance note only; does not change the required contexts) | +| `--output`, `-o` | string | stdout | Write to this path instead of stdout (`-` also means stdout) | + ### manage-release Manage GitHub releases. diff --git a/docs/src/content/docs/workflows.md b/docs/src/content/docs/workflows.md index 646ea6c..fdfcb5e 100644 --- a/docs/src/content/docs/workflows.md +++ b/docs/src/content/docs/workflows.md @@ -342,6 +342,8 @@ Prod is a valid hotfix target. The deploy job binds to the GitHub `environment:` Branch protection on `env/*` is the operator's responsibility: cascade never creates protection rules itself, because it does not assume an admin token. When no required status checks are configured on the target `env/*` branch, the workflow **warns** rather than blocks, and the `plan` verb prints ready-to-run `gh` and `gh api` command suggestions an operator can paste to put the protections in place. +For the trunk branch, `cascade branch-protection` emits the full JSON body to PUT to the branches protection API in one step, with only the safe-to-require `Setup` and `Finalize` contexts pre-filled. See [branch-protection](/cli-reference/#branch-protection). + > The `rollback_sha` output in the generated workflow is a disclosed placeholder today: the deploy and rollback jobs mirror the promote workflow's shape, and the rollback path activates once a CLI output supplies the prior SHA. ## Workflow Permissions diff --git a/internal/branchprotection/command.go b/internal/branchprotection/command.go new file mode 100644 index 0000000..bd2f764 --- /dev/null +++ b/internal/branchprotection/command.go @@ -0,0 +1,120 @@ +package branchprotection + +import ( + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + + "github.com/stablekernel/cascade/internal/config" +) + +// Options configures a branch-protection emit run. +type Options struct { + // ConfigPath is the manifest path; empty means auto-detect. + ConfigPath string + // ManifestKey is the key in the manifest file holding the CI config. + ManifestKey string + // Branch labels the protection target. It affects only the operator_todo + // note; the PUT body itself is branch-agnostic. The required contexts (Setup + // and Finalize) are the orchestrate-workflow steps jobs, identical across + // branches and environments, so the branch never changes them. + Branch string + // Output is the destination path. Empty or "-" writes to stdout. + Output string +} + +// NewCommand creates the branch-protection command. It emits the JSON body an +// operator applies to GitHub's branch-protection API for a cascade-managed +// trunk. cascade emits the file; the operator applies it. cascade never calls +// the GitHub API. +func NewCommand() *cobra.Command { + var o Options + + cmd := &cobra.Command{ + Use: "branch-protection", + Short: "Emit the branch-protection JSON body for an operator to apply", + Long: `Emit the JSON body an operator applies to GitHub's branch-protection API for +a cascade-managed trunk. cascade emits the file; the operator applies it. cascade +never calls the GitHub API. + +The output is a wrapper with two top-level keys: + + protection the EXACT body to PUT to the branches protection API + operator_todo companion guidance that is NOT part of the PUT body + +Apply it by sending only the .protection object, for example: + + cascade branch-protection | jq .protection | \ + gh api -X PUT repos/OWNER/REPO/branches/main/protection --input - + +The required status checks contain only the cascade-controlled Setup and Finalize +jobs, which run on every pipeline run, so .protection applied as-is never blocks a +pull request. The reusable-workflow caller jobs (validate, build, deploy) are not +required directly because cascade knows their display-name prefix but not the +inner job name GitHub appends to form the real check-run context. Those prefixes +are listed under operator_todo.complete_these_contexts as " / +" placeholders for you to complete. + +The --branch flag only labels the guidance note; the required contexts are the +same across branches and environments because they are the orchestrate-workflow +steps jobs.`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return Run(o, cmd.OutOrStdout()) + }, + } + + cmd.Flags().StringVarP(&o.ConfigPath, "config", "c", "", "Path to config file (default: auto-detect .github/manifest.yaml)") + cmd.Flags().StringVar(&o.ManifestKey, "manifest-key", config.DefaultManifestKey, "Key in manifest file containing CI config") + cmd.Flags().StringVar(&o.Branch, "branch", "main", "Branch the protection targets (labels the guidance note only; does not change the required contexts)") + cmd.Flags().StringVarP(&o.Output, "output", "o", "", "Write to this path instead of stdout ('-' also means stdout)") + + return cmd +} + +// Run resolves the manifest, builds the payload, and writes it. When Options.Output +// is empty or "-", it writes to stdout (w); otherwise it writes to that file path. +func Run(o Options, stdout io.Writer) error { + configPath := o.ConfigPath + if configPath == "" { + configPath = config.FindConfigFile("") + } + + manifestKey := o.ManifestKey + if manifestKey == "" { + manifestKey = config.DefaultManifestKey + } + + cfg, err := config.ParseWithKey(configPath, manifestKey) + if err != nil { + return fmt.Errorf("parsing config: %w", err) + } + + if errs := config.Validate(cfg); len(errs) > 0 { + return fmt.Errorf("config validation failed: %s", errs[0]) + } + + branch := o.Branch + if branch == "" { + branch = "main" + } + + out, err := Marshal(Build(cfg, branch)) + if err != nil { + return err + } + + if o.Output == "" || o.Output == "-" { + if _, werr := stdout.Write(out); werr != nil { + return fmt.Errorf("writing branch-protection payload: %w", werr) + } + return nil + } + + if werr := os.WriteFile(o.Output, out, 0o644); werr != nil { + return fmt.Errorf("writing branch-protection payload to %s: %w", o.Output, werr) + } + return nil +} diff --git a/internal/branchprotection/command_test.go b/internal/branchprotection/command_test.go new file mode 100644 index 0000000..7990aea --- /dev/null +++ b/internal/branchprotection/command_test.go @@ -0,0 +1,96 @@ +package branchprotection + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/generate" +) + +// minimalManifest is a hand-written manifest with validate, two builds, and a +// deploy so the integration path exercises reusable-caller handling end to end. +const minimalManifest = `ci: + config: + trunk_branch: main + environments: + - staging + - production + validate: + workflow: .github/workflows/validate.yaml + builds: + - name: app + workflow: .github/workflows/build.yaml + deploys: + - name: services + workflow: .github/workflows/deploy.yaml +` + +// writeManifest writes minimalManifest into a temp .github/manifest.yaml and +// returns its path. +func writeManifest(t *testing.T) string { + t.Helper() + dir := t.TempDir() + ghDir := filepath.Join(dir, ".github") + require.NoError(t, os.MkdirAll(ghDir, 0o755)) + path := filepath.Join(ghDir, "manifest.yaml") + require.NoError(t, os.WriteFile(path, []byte(minimalManifest), 0o644)) + return path +} + +// TestCommand_EmitsSafePayloadToStdout runs the REAL branch-protection command +// against a temp manifest, captures stdout, and asserts the emitted JSON parses +// and satisfies the safety invariant end to end. +// +// This change emits no workflow and no schema field, so a Docker e2e/scenarios +// scenario (testcontainers + gitea + act over committed workflow fixtures) does +// not fit. This Go integration test follows the verify_clean_test.go precedent of +// driving the real command path instead. +func TestCommand_EmitsSafePayloadToStdout(t *testing.T) { + path := writeManifest(t) + + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--config", path}) + require.NoError(t, cmd.Execute()) + + var p Payload + require.NoError(t, json.Unmarshal(out.Bytes(), &p)) + + // Safety invariant: required contexts are exactly Setup and Finalize. + assert.ElementsMatch(t, + []string{generate.SetupJobName, generate.FinalizeJobName}, + p.Protection.RequiredStatusChecks.Contexts) + + // No bare reusable-caller DisplayName is required. + for _, bare := range []string{"Validate (validate)", "Build (app)", "Deploy (services)"} { + assert.NotContains(t, p.Protection.RequiredStatusChecks.Contexts, bare) + assert.Contains(t, p.OperatorTodo.CompleteTheseContexts, bare+" / "+innerJobPlaceholder) + } +} + +// TestCommand_WritesToOutputFile confirms --output writes the payload to a file. +func TestCommand_WritesToOutputFile(t *testing.T) { + path := writeManifest(t) + outPath := filepath.Join(t.TempDir(), "branch-protection.json") + + cmd := NewCommand() + cmd.SetArgs([]string{"--config", path, "--output", outPath}) + require.NoError(t, cmd.Execute()) + + data, err := os.ReadFile(outPath) + require.NoError(t, err) + + var p Payload + require.NoError(t, json.Unmarshal(data, &p)) + assert.ElementsMatch(t, + []string{generate.SetupJobName, generate.FinalizeJobName}, + p.Protection.RequiredStatusChecks.Contexts) +} diff --git a/internal/branchprotection/payload.go b/internal/branchprotection/payload.go new file mode 100644 index 0000000..6a6b477 --- /dev/null +++ b/internal/branchprotection/payload.go @@ -0,0 +1,182 @@ +// Package branchprotection emits the JSON body an operator applies to GitHub's +// branch-protection API for a cascade-managed trunk. cascade EMITS the file; the +// operator APPLIES it. cascade never calls the GitHub API. +// +// The emitted artifact is a small wrapper with two top-level keys: +// +// { +// "protection": { ... }, // the EXACT body to PUT to the branches API +// "operator_todo": { ... } // companion guidance, NOT part of the PUT body +// } +// +// An operator applies it with, for example: +// +// cascade branch-protection | jq .protection | \ +// gh api -X PUT repos/OWNER/REPO/branches/main/protection --input - +// +// The safety invariant is that the ".protection" object, applied verbatim, never +// creates a required status check that can never report. Only the two cascade +// controlled steps jobs (Setup and Finalize) are required, because cascade knows +// their exact check-run names and both run unconditionally on every run. The +// reusable-workflow caller jobs (validate, build, deploy) are deliberately left +// out of the required contexts: cascade knows their display-name prefix but not +// the inner job name that GitHub appends to form the real context, so requiring a +// bare prefix would block every pull request. Those prefixes are surfaced under +// operator_todo.complete_these_contexts as " / " +// placeholders for the operator to complete once they know their reusable +// workflow's inner job names. +package branchprotection + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/generate" +) + +// Default protection settings. These mirror the hotfix branch-protection +// advisory (required_pull_request_reviews.required_approving_review_count=1, +// enforce_admins=true) so cascade never emits two contradictory recommendations. +const ( + defaultRequiredApprovingReviewCount = 1 + defaultStrictStatusChecks = true + defaultDismissStaleReviews = true + defaultRequireCodeOwnerReviews = false + defaultEnforceAdmins = true + defaultRequiredLinearHistory = true + defaultAllowForcePushes = false + defaultAllowDeletions = false + defaultRequiredConversationResolve = true +) + +// innerJobPlaceholder is the token appended to a reusable-caller DisplayName so +// the operator can see exactly what to fill in. GitHub forms the real check-run +// context as " / ", and cascade does not author the +// reusable workflow, so it cannot know the inner job name. +const innerJobPlaceholder = "" + +// Payload is the full emitted artifact. The "protection" object is the exact PUT +// body; "operator_todo" is sibling guidance that must never be sent to GitHub. +type Payload struct { + Protection Protection `json:"protection"` + OperatorTodo OperatorTodo `json:"operator_todo"` +} + +// Protection is the body an operator PUTs to +// /repos/{owner}/{repo}/branches/{branch}/protection. Every field is explicit so +// the marshaled output is byte-stable for a given manifest. +type Protection struct { + RequiredStatusChecks RequiredStatusChecks `json:"required_status_checks"` + EnforceAdmins bool `json:"enforce_admins"` + RequiredPullRequestReviews RequiredPullRequestReviews `json:"required_pull_request_reviews"` + Restrictions *Restrictions `json:"restrictions"` + RequiredLinearHistory bool `json:"required_linear_history"` + AllowForcePushes bool `json:"allow_force_pushes"` + AllowDeletions bool `json:"allow_deletions"` + RequiredConversationResolution bool `json:"required_conversation_resolution"` +} + +// RequiredStatusChecks lists the checks GitHub requires before merge. contexts +// holds ONLY cascade-controlled steps-job names whose check-run context cascade +// knows exactly, kept sorted for deterministic output. +type RequiredStatusChecks struct { + Strict bool `json:"strict"` + Contexts []string `json:"contexts"` +} + +// RequiredPullRequestReviews mirrors the GitHub API review-protection block. +type RequiredPullRequestReviews struct { + RequiredApprovingReviewCount int `json:"required_approving_review_count"` + DismissStaleReviews bool `json:"dismiss_stale_reviews"` + RequireCodeOwnerReviews bool `json:"require_code_owner_reviews"` +} + +// Restrictions models the push-restriction block. GitHub requires the key to be +// present; a nil *Restrictions marshals to null, meaning no push restriction. +type Restrictions struct { + Users []string `json:"users"` + Teams []string `json:"teams"` + Apps []string `json:"apps"` +} + +// OperatorTodo is companion guidance emitted alongside the PUT body. It is NOT +// part of the body GitHub accepts, so it is carried as a sibling key the operator +// strips off (jq .protection) before applying. +type OperatorTodo struct { + Note string `json:"note"` + CompleteTheseContexts []string `json:"complete_these_contexts"` +} + +// Build assembles the Payload from a resolved manifest. The required contexts are +// exactly the cascade-controlled steps jobs (Setup and Finalize), referenced from +// generate.SetupJobName and generate.FinalizeJobName so a rename in the generator +// updates both the workflow and these contexts together. Every reusable-workflow +// caller job (validate, build, deploy) is surfaced under operator_todo as a +// " / " placeholder rather than required directly, because +// requiring a context cascade cannot fully name would block every pull request. +func Build(cfg *config.TrunkConfig, branch string) Payload { + contexts := []string{generate.SetupJobName, generate.FinalizeJobName} + sort.Strings(contexts) + + graph := generate.BuildDependencyGraph(cfg) + todo := make([]string, 0, len(graph.Order)) + for _, jobID := range graph.Order { + node := graph.Nodes[jobID] + todo = append(todo, fmt.Sprintf("%s / %s", node.DisplayName, innerJobPlaceholder)) + } + sort.Strings(todo) + + return Payload{ + Protection: Protection{ + RequiredStatusChecks: RequiredStatusChecks{ + Strict: defaultStrictStatusChecks, + Contexts: contexts, + }, + EnforceAdmins: defaultEnforceAdmins, + RequiredPullRequestReviews: RequiredPullRequestReviews{ + RequiredApprovingReviewCount: defaultRequiredApprovingReviewCount, + DismissStaleReviews: defaultDismissStaleReviews, + RequireCodeOwnerReviews: defaultRequireCodeOwnerReviews, + }, + Restrictions: nil, + RequiredLinearHistory: defaultRequiredLinearHistory, + AllowForcePushes: defaultAllowForcePushes, + AllowDeletions: defaultAllowDeletions, + RequiredConversationResolution: defaultRequiredConversationResolve, + }, + OperatorTodo: OperatorTodo{ + Note: operatorNote(branch), + CompleteTheseContexts: todo, + }, + } +} + +// operatorNote returns the human guidance string for the given branch. It states +// the safe-by-construction contract and tells the operator how to apply the body. +func operatorNote(branch string) string { + return fmt.Sprintf( + "PUT only the .protection object to "+ + "repos/{owner}/{repo}/branches/%s/protection (for example: "+ + "jq .protection | gh api -X PUT ... --input -). The required contexts "+ + "contain only the cascade-controlled Setup and Finalize jobs, which run "+ + "on every pipeline run, so .protection applied as-is never blocks a pull "+ + "request. complete_these_contexts lists the reusable-workflow caller jobs "+ + "as \" / \" placeholders. Replace with "+ + "the job name inside each reusable workflow and add the completed context "+ + "strings to required_status_checks.contexts when you want them required.", + branch, + ) +} + +// Marshal renders the payload as indented JSON with exactly one trailing newline. +// The same manifest always produces byte-identical output. +func Marshal(p Payload) ([]byte, error) { + out, err := json.MarshalIndent(p, "", " ") + if err != nil { + return nil, fmt.Errorf("marshaling branch-protection payload: %w", err) + } + out = append(out, '\n') + return out, nil +} diff --git a/internal/branchprotection/payload_test.go b/internal/branchprotection/payload_test.go new file mode 100644 index 0000000..747a173 --- /dev/null +++ b/internal/branchprotection/payload_test.go @@ -0,0 +1,137 @@ +package branchprotection + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/generate" +) + +// fullConfig returns a manifest with validate, two builds, and a deploy so the +// reusable-caller handling has something to enumerate. +func fullConfig() *config.TrunkConfig { + return &config.TrunkConfig{ + Validate: &config.ValidateConfig{ + Workflow: ".github/workflows/validate.yaml", + }, + Builds: []config.BuildConfig{ + {Name: "app", Workflow: ".github/workflows/build.yaml"}, + {Name: "services", Workflow: ".github/workflows/build-services.yaml"}, + }, + Deploys: []config.DeployConfig{ + {Name: "services", Workflow: ".github/workflows/deploy.yaml"}, + }, + } +} + +// TestBuild_Contexts_MatchGeneratorSafeChecks asserts the required contexts equal +// the generator's real safe check names, referenced from the same constants the +// generator emits so the test cannot drift from the workflow. +func TestBuild_Contexts_MatchGeneratorSafeChecks(t *testing.T) { + p := Build(fullConfig(), "main") + + want := []string{generate.FinalizeJobName, generate.SetupJobName} // sorted + assert.Equal(t, want, p.Protection.RequiredStatusChecks.Contexts) +} + +// TestBuild_Safety_NoBareDisplayNameRequired is the safety invariant: a manifest +// with validate+build+deploy must never put a bare reusable-caller DisplayName +// into required contexts (it would never match and would block every PR). Only +// Setup and Finalize may appear, and the DisplayNames are surfaced under +// operator_todo as " / " placeholders instead. +func TestBuild_Safety_NoBareDisplayNameRequired(t *testing.T) { + cfg := fullConfig() + p := Build(cfg, "main") + + contexts := p.Protection.RequiredStatusChecks.Contexts + assert.ElementsMatch(t, []string{generate.SetupJobName, generate.FinalizeJobName}, contexts) + + // Every reusable-caller DisplayName must be absent from required contexts. + graph := generate.BuildDependencyGraph(cfg) + require.NotEmpty(t, graph.Order) + for _, jobID := range graph.Order { + display := graph.Nodes[jobID].DisplayName + assert.NotContains(t, contexts, display, + "bare DisplayName %q must never be a required context", display) + // And it must appear as a completion placeholder in operator_todo. + assert.Contains(t, p.OperatorTodo.CompleteTheseContexts, display+" / "+innerJobPlaceholder) + } + + // Spot-check the concrete DisplayNames the generator produces. + for _, bare := range []string{"Validate (validate)", "Build (app)", "Build (services)", "Deploy (services)"} { + assert.NotContains(t, contexts, bare) + } +} + +// TestBuild_NoCallbacks_EmptyTodoStable confirms complete_these_contexts is an +// emitted (non-nil) empty array when there are no validate/build/deploy nodes. +func TestBuild_NoCallbacks_EmptyTodoStable(t *testing.T) { + p := Build(&config.TrunkConfig{}, "main") + + assert.NotNil(t, p.OperatorTodo.CompleteTheseContexts) + assert.Empty(t, p.OperatorTodo.CompleteTheseContexts) + + out, err := Marshal(p) + require.NoError(t, err) + assert.Contains(t, string(out), `"complete_these_contexts": []`) +} + +// TestMarshal_Deterministic asserts the same manifest yields byte-identical output. +func TestMarshal_Deterministic(t *testing.T) { + cfg := fullConfig() + a, err := Marshal(Build(cfg, "main")) + require.NoError(t, err) + b, err := Marshal(Build(cfg, "main")) + require.NoError(t, err) + assert.Equal(t, a, b) + assert.True(t, strings.HasSuffix(string(a), "}\n"), "exactly one trailing newline") +} + +// TestMarshal_RoundTrips confirms the emitted bytes are valid JSON with the +// expected wrapper shape. +func TestMarshal_RoundTrips(t *testing.T) { + out, err := Marshal(Build(fullConfig(), "main")) + require.NoError(t, err) + + var generic map[string]json.RawMessage + require.NoError(t, json.Unmarshal(out, &generic)) + assert.Contains(t, generic, "protection") + assert.Contains(t, generic, "operator_todo") + + // .protection must round-trip back into the typed struct. + var prot Protection + require.NoError(t, json.Unmarshal(generic["protection"], &prot)) + assert.ElementsMatch(t, []string{generate.SetupJobName, generate.FinalizeJobName}, + prot.RequiredStatusChecks.Contexts) +} + +// TestBuild_DefaultsWellFormed asserts the protection defaults match the decided +// contract and the hotfix advisory (enforce_admins true, one approval, strict). +func TestBuild_DefaultsWellFormed(t *testing.T) { + p := Build(fullConfig(), "main") + prot := p.Protection + + assert.True(t, prot.EnforceAdmins) + assert.True(t, prot.RequiredStatusChecks.Strict) + assert.Equal(t, 1, prot.RequiredPullRequestReviews.RequiredApprovingReviewCount) + assert.True(t, prot.RequiredPullRequestReviews.DismissStaleReviews) + assert.False(t, prot.RequiredPullRequestReviews.RequireCodeOwnerReviews) + assert.True(t, prot.RequiredLinearHistory) + assert.False(t, prot.AllowForcePushes) + assert.False(t, prot.AllowDeletions) + assert.True(t, prot.RequiredConversationResolution) + assert.Nil(t, prot.Restrictions) +} + +// TestMarshal_RestrictionsNullPresent confirms restrictions is present as null in +// the emitted JSON (GitHub requires the key; null means no push restriction). +func TestMarshal_RestrictionsNullPresent(t *testing.T) { + out, err := Marshal(Build(fullConfig(), "main")) + require.NoError(t, err) + assert.Contains(t, string(out), `"restrictions": null`) +} diff --git a/internal/generate/generator.go b/internal/generate/generator.go index ebd311a..6b996a4 100644 --- a/internal/generate/generator.go +++ b/internal/generate/generator.go @@ -18,6 +18,20 @@ import ( // hold a runner for six hours. Override per manifest via config.job_timeout_minutes. const DefaultJobTimeoutMinutes = 30 +// SetupJobName and FinalizeJobName are the GitHub Actions check-run names of the +// orchestrate workflow's two cascade-controlled steps jobs. GitHub records a +// job's check-run context under its name:, so these constants are the exact +// contexts a branch-protection rule can require with certainty. Both jobs are +// unconditional (the setup job has no if:, and finalize uses always()), so they +// always report on every run, which is what makes them safe to require. The +// branch-protection emitter references these same constants, so a rename here +// updates both the generated workflow and the emitted protection contexts in +// lockstep and never lets them drift apart. +const ( + SetupJobName = "Setup" + FinalizeJobName = "Finalize" +) + // normalizeWorkflowPath returns a GitHub-valid workflow path for a local callback. // Cross-repo external refs (containing "@") are returned unchanged. // Paths already under ./.github/workflows/ are returned unchanged. @@ -769,7 +783,7 @@ func (g *Generator) changelogJobEnabled() bool { func (g *Generator) writeSetupJob(sb *strings.Builder) { sb.WriteString(" setup:\n") - sb.WriteString(" name: Setup\n") + fmt.Fprintf(sb, " name: %s\n", SetupJobName) sb.WriteString(" runs-on: ubuntu-latest\n") g.writeOwnedTimeout(sb, " ") sb.WriteString(" outputs:\n") @@ -1310,7 +1324,7 @@ func (g *Generator) writeFinalizeJob(sb *strings.Builder, sorted []string) { } sb.WriteString(" finalize:\n") - sb.WriteString(" name: Finalize\n") + fmt.Fprintf(sb, " name: %s\n", FinalizeJobName) fmt.Fprintf(sb, " needs: [%s]\n", strings.Join(allJobs, ", ")) // Run finalize whenever setup succeeded, regardless of how the callbacks // ended. always() makes finalize fire even when a callback failed OR was