From 00babc7adf66efa9c9b0130f1e107e00fc09bdae Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 29 Jun 2026 13:55:06 -0400 Subject: [PATCH] feat(branch-protection): add --apply flag for direct branch-protection PUT Adds opt-in --apply path to cascade branch-protection. Default behavior (emit JSON to stdout) is unchanged. With --apply the .protection body is PUT to {api-url}/repos/{repo}/branches/{branch}/protection using a caller-supplied token (--token or GITHUB_TOKEN), removing the repo-admin adoption blocker that required the operator to pipe the output manually. New flags (all additive, all optional without --apply): --apply opt into the PUT instead of emitting JSON --token repo-admin PAT (default GITHUB_TOKEN) --repo owner/repo (default GITHUB_REPOSITORY) --branch apply target (unchanged: also labels guidance note) --api-url REST API base for testability (default GITHUB_API_URL then https://api.github.com) Non-2xx responses are surfaced with status and a bounded snippet of the GitHub rejection body so a 403 from an under-scoped token is legible. Signed-off-by: Joshua Temple --- internal/branchprotection/apply.go | 120 ++++++++++++++ internal/branchprotection/apply_test.go | 207 ++++++++++++++++++++++++ internal/branchprotection/command.go | 84 +++++++++- 3 files changed, 403 insertions(+), 8 deletions(-) create mode 100644 internal/branchprotection/apply.go create mode 100644 internal/branchprotection/apply_test.go diff --git a/internal/branchprotection/apply.go b/internal/branchprotection/apply.go new file mode 100644 index 00000000..854373da --- /dev/null +++ b/internal/branchprotection/apply.go @@ -0,0 +1,120 @@ +package branchprotection + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// defaultGitHubAPIURL is the public GitHub REST API base used when neither the +// --api-url flag nor the GITHUB_API_URL environment variable is set. +const defaultGitHubAPIURL = "https://api.github.com" + +// applyTimeout bounds a single branch-protection PUT so a stalled apply cannot +// hang the CLI indefinitely. +const applyTimeout = 30 * time.Second + +// applier issues the branch-protection PUT against a GitHub-compatible REST API. +// It is intentionally small so the apply path can be exercised hermetically by +// pointing baseURL at an httptest server. +type applier struct { + client *http.Client + baseURL string + token string +} + +// newApplier builds an applier for the given API base URL and token. The base +// URL has any trailing slash trimmed so endpoint joining stays unambiguous. +func newApplier(baseURL, token string) *applier { + return &applier{ + client: &http.Client{Timeout: applyTimeout}, + baseURL: strings.TrimSuffix(baseURL, "/"), + token: token, + } +} + +// resolveAPIURL picks the API base URL in precedence order: the explicit flag +// value, then GITHUB_API_URL, then the public GitHub default. A blank result is +// never returned. +func resolveAPIURL(flagValue string) string { + if flagValue != "" { + return flagValue + } + if env := os.Getenv("GITHUB_API_URL"); env != "" { + return env + } + return defaultGitHubAPIURL +} + +// resolveRepo picks the target repository in precedence order: the explicit flag +// value, then GITHUB_REPOSITORY (the owner/repo GitHub Actions injects). +func resolveRepo(flagValue string) string { + if flagValue != "" { + return flagValue + } + return os.Getenv("GITHUB_REPOSITORY") +} + +// resolveToken picks the apply token in precedence order: the explicit flag +// value, then GITHUB_TOKEN. The apply path requires a token, so a blank result +// is surfaced as an error by the caller. +func resolveToken(flagValue string) string { + if flagValue != "" { + return flagValue + } + return os.Getenv("GITHUB_TOKEN") +} + +// apply PUTs the protection body to +// {baseURL}/repos/{repo}/branches/{branch}/protection with a Bearer token. Only +// the Protection object is sent; the operator_todo guidance is never part of the +// request. A non-2xx response is returned as an error that includes the status +// and a bounded slice of the response body so a 403 from an under-scoped token +// is legible to the operator. +func (a *applier) apply(ctx context.Context, repo, branch string, protection Protection) error { + body, err := json.Marshal(protection) + if err != nil { + return fmt.Errorf("encoding protection body: %w", err) + } + + url := fmt.Sprintf("%s/repos/%s/branches/%s/protection", a.baseURL, repo, branch) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("building branch-protection request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Authorization", "Bearer "+a.token) + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + req.Header.Set("Content-Type", "application/json") + + resp, err := a.client.Do(req) + if err != nil { + return fmt.Errorf("applying branch protection to %s: %w", url, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + return fmt.Errorf("applying branch protection to %s: %s: %s", + url, resp.Status, snippet(resp.Body)) +} + +// snippet reads up to 512 bytes of a response body for inclusion in an error so +// GitHub's own rejection message (for example a 403 "Resource not accessible") +// reaches the operator without risking an unbounded read. +func snippet(r io.Reader) string { + const max = 512 + data, err := io.ReadAll(io.LimitReader(r, max)) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} diff --git a/internal/branchprotection/apply_test.go b/internal/branchprotection/apply_test.go new file mode 100644 index 00000000..1908a0d9 --- /dev/null +++ b/internal/branchprotection/apply_test.go @@ -0,0 +1,207 @@ +package branchprotection + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stablekernel/cascade/internal/config" + "github.com/stablekernel/cascade/internal/generate" +) + +// parseManifest parses the manifest at path so a test can compare the applied +// body against Build's output for the same config. +func parseManifest(t *testing.T, path string) *config.TrunkConfig { + t.Helper() + cfg, err := config.ParseWithKey(path, config.DefaultManifestKey) + require.NoError(t, err) + return cfg +} + +// TestCommand_Apply_PutsProtectionBody stands up a mock GitHub API with httptest +// and drives the REAL command with --apply. It is the faithful hermetic proof of +// the apply path: it asserts cascade hits the exact branch-protection endpoint +// with the Bearer token and sends only the .protection object (never the +// operator_todo guidance), honoring --api-url. +// +// The act+gitea e2e harness cannot host this. Gitea does not implement GitHub's +// PUT /repos/{owner}/{repo}/branches/{branch}/protection; its branch-protection +// API is a different endpoint and JSON shape (/api/v1/repos/{owner}/{repo}/ +// branch_protections). The same divergence is already acknowledged in the release +// package's isGitHubHost helper. So this mock-server integration test, not an act +// scenario, is the correct hermetic coverage for the apply. +func TestCommand_Apply_PutsProtectionBody(t *testing.T) { + manifest := writeManifest(t) + + var ( + gotMethod string + gotPath string + gotAuth string + gotAccept string + gotBody []byte + ) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotMethod = r.Method + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + gotAccept = r.Header.Get("Accept") + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + gotBody = body + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"url":"https://example/protection"}`)) + })) + defer srv.Close() + + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{ + "--config", manifest, + "--apply", + "--token", "scoped-pat", + "--repo", "octo/repo", + "--branch", "main", + "--api-url", srv.URL, + }) + require.NoError(t, cmd.Execute()) + + // Correct verb and endpoint. + assert.Equal(t, http.MethodPut, gotMethod) + assert.Equal(t, "/repos/octo/repo/branches/main/protection", gotPath) + + // Scoped token is sent as a Bearer credential. + assert.Equal(t, "Bearer scoped-pat", gotAuth) + assert.Equal(t, "application/vnd.github+json", gotAccept) + + // The request body is exactly the Protection object, never the wrapper or the + // operator_todo guidance. Unmarshaling into the strict Protection type and + // re-comparing against Build proves shape and content. + var sentProtection Protection + require.NoError(t, json.Unmarshal(gotBody, &sentProtection)) + + cfg := parseManifest(t, manifest) + want := Build(cfg, "main").Protection + assert.Equal(t, want, sentProtection) + + // The guidance key must not leak into the PUT body. + var raw map[string]json.RawMessage + require.NoError(t, json.Unmarshal(gotBody, &raw)) + assert.NotContains(t, raw, "operator_todo") + assert.Contains(t, raw, "required_status_checks") + + // Confirmation is printed; the JSON wrapper is not dumped to stdout on apply. + assert.Contains(t, out.String(), "applied branch protection to octo/repo/branches/main") + assert.NotContains(t, out.String(), "operator_todo") +} + +// TestCommand_Apply_SurfacesForbidden proves a 403 from an under-scoped token is +// surfaced as an error that includes the status and GitHub's own message, rather +// than being swallowed. +func TestCommand_Apply_SurfacesForbidden(t *testing.T) { + manifest := writeManifest(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Resource not accessible by personal access token"}`)) + })) + defer srv.Close() + + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{ + "--config", manifest, + "--apply", + "--token", "under-scoped", + "--repo", "octo/repo", + "--api-url", srv.URL, + }) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "403") + assert.Contains(t, err.Error(), "Resource not accessible") +} + +// TestCommand_Apply_RequiresToken proves the apply fails fast with a usage error +// when no token is available, before any network call. +func TestCommand_Apply_RequiresToken(t *testing.T) { + manifest := writeManifest(t) + t.Setenv("GITHUB_TOKEN", "") + + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{ + "--config", manifest, + "--apply", + "--repo", "octo/repo", + "--api-url", "http://127.0.0.1:0", + }) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--apply requires a token") +} + +// TestCommand_Apply_RequiresRepo proves the apply fails fast with a usage error +// when no repository is available, before any network call. +func TestCommand_Apply_RequiresRepo(t *testing.T) { + manifest := writeManifest(t) + t.Setenv("GITHUB_REPOSITORY", "") + + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{ + "--config", manifest, + "--apply", + "--token", "scoped-pat", + "--api-url", "http://127.0.0.1:0", + }) + + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--apply requires a repository") +} + +// TestCommand_NoApply_MakesNoRequest proves the default path is unchanged: with no +// --apply flag cascade emits the JSON wrapper to stdout and never calls the API, +// even when an --api-url is provided. +func TestCommand_NoApply_MakesNoRequest(t *testing.T) { + manifest := writeManifest(t) + + called := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + cmd := NewCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--config", manifest, "--api-url", srv.URL}) + require.NoError(t, cmd.Execute()) + + assert.False(t, called, "default path must not call the API") + + var p Payload + require.NoError(t, json.Unmarshal(out.Bytes(), &p)) + assert.ElementsMatch(t, + []string{generate.SetupJobName, generate.FinalizeJobName}, + p.Protection.RequiredStatusChecks.Contexts) +} diff --git a/internal/branchprotection/command.go b/internal/branchprotection/command.go index bd2f7649..76a2b691 100644 --- a/internal/branchprotection/command.go +++ b/internal/branchprotection/command.go @@ -1,6 +1,7 @@ package branchprotection import ( + "context" "fmt" "io" "os" @@ -23,6 +24,22 @@ type Options struct { Branch string // Output is the destination path. Empty or "-" writes to stdout. Output string + // Apply opts into calling GitHub: instead of emitting the JSON to stdout, + // cascade PUTs the .protection body to the branches protection API. The emit + // default (Apply false) is unchanged. When Output is also set, the JSON file + // is still written for the operator's records before the apply runs. + Apply bool + // Token is the credential used for the apply. It should be a scoped PAT the + // operator supplies that carries repo-admin; the workflow's GITHUB_TOKEN does + // not need admin. Empty falls back to the GITHUB_TOKEN environment variable. + Token string + // Repo is the owner/repo the apply targets. Empty falls back to the + // GITHUB_REPOSITORY environment variable. + Repo string + // APIURL is the REST API base for the apply. Empty falls back to GITHUB_API_URL + // and then https://api.github.com. It exists so the apply is testable against a + // mock server. + APIURL string } // NewCommand creates the branch-protection command. It emits the JSON body an @@ -59,7 +76,14 @@ are listed under operator_todo.complete_these_contexts as " / 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.`, +steps jobs. + +By default cascade only emits the JSON; it makes no API call. Pass --apply to PUT +the .protection body for you. Applying branch protection requires repo-admin, so +supply a scoped token with --token (or GITHUB_TOKEN) rather than relying on the +workflow's GITHUB_TOKEN. The --branch here is the real apply target, --repo is the +owner/repo (default GITHUB_REPOSITORY), and --api-url overrides the API base +(default GITHUB_API_URL, then https://api.github.com).`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { return Run(o, cmd.OutOrStdout()) @@ -68,14 +92,22 @@ steps jobs.`, 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().StringVar(&o.Branch, "branch", "main", "Branch the protection targets (with --apply this is the apply target; otherwise it only labels the guidance note)") cmd.Flags().StringVarP(&o.Output, "output", "o", "", "Write to this path instead of stdout ('-' also means stdout)") + cmd.Flags().BoolVar(&o.Apply, "apply", false, "Apply the .protection body to GitHub instead of emitting JSON (requires --token with repo-admin)") + cmd.Flags().StringVar(&o.Token, "token", "", "Token used for --apply (a scoped repo-admin PAT; default GITHUB_TOKEN)") + cmd.Flags().StringVar(&o.Repo, "repo", "", "owner/repo the apply targets (default GITHUB_REPOSITORY)") + cmd.Flags().StringVar(&o.APIURL, "api-url", "", "REST API base for --apply (default GITHUB_API_URL, then https://api.github.com)") 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. +// Run resolves the manifest and builds the payload, then either emits it or +// applies it. By default (Options.Apply false) the behavior is unchanged: when +// Options.Output is empty or "-" it writes the JSON to stdout, otherwise to that +// file path, and cascade makes no API call. When Options.Apply is set it PUTs the +// .protection body to GitHub; a non-empty Output still writes the JSON file first +// for the operator's records. func Run(o Options, stdout io.Writer) error { configPath := o.ConfigPath if configPath == "" { @@ -101,20 +133,56 @@ func Run(o Options, stdout io.Writer) error { branch = "main" } - out, err := Marshal(Build(cfg, branch)) + payload := Build(cfg, branch) + out, err := Marshal(payload) if err != nil { return err } + // A real Output path is always honored so an operator can keep the emitted + // JSON on disk whether or not they also apply it. + if o.Output != "" && o.Output != "-" { + if werr := os.WriteFile(o.Output, out, 0o644); werr != nil { + return fmt.Errorf("writing branch-protection payload to %s: %w", o.Output, werr) + } + } + + if o.Apply { + return runApply(o, branch, payload.Protection, stdout) + } + + // Emit-to-stdout default: unchanged from the original command behavior. if o.Output == "" || o.Output == "-" { if _, werr := stdout.Write(out); werr != nil { return fmt.Errorf("writing branch-protection payload: %w", werr) } - return nil + } + return nil +} + +// runApply resolves the apply inputs, PUTs the protection body to GitHub, and +// prints a one-line confirmation. A missing token or repository is a usage error +// surfaced before any network call. +func runApply(o Options, branch string, protection Protection, stdout io.Writer) error { + token := resolveToken(o.Token) + if token == "" { + return fmt.Errorf("--apply requires a token: pass --token or set GITHUB_TOKEN") + } + + repo := resolveRepo(o.Repo) + if repo == "" { + return fmt.Errorf("--apply requires a repository: pass --repo owner/repo or set GITHUB_REPOSITORY") + } + + apiURL := resolveAPIURL(o.APIURL) + + if err := newApplier(apiURL, token).apply(context.Background(), repo, branch, protection); err != nil { + return err } - if werr := os.WriteFile(o.Output, out, 0o644); werr != nil { - return fmt.Errorf("writing branch-protection payload to %s: %w", o.Output, werr) + _, err := fmt.Fprintf(stdout, "applied branch protection to %s/branches/%s\n", repo, branch) + if err != nil { + return fmt.Errorf("writing apply confirmation: %w", err) } return nil }