Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/cascade/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())
Expand Down
39 changes: 39 additions & 0 deletions docs/src/content/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<DisplayName> / <inner-job>`. 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 `<DisplayName> / <inner-job>` placeholders instead. Replace `<inner-job>` 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.
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions internal/branchprotection/command.go
Original file line number Diff line number Diff line change
@@ -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 "<DisplayName> /
<inner-job>" 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
}
96 changes: 96 additions & 0 deletions internal/branchprotection/command_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading