diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 69630a8..195d15f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,9 +14,25 @@ updates: labels: - dependencies + # cascade pins every third-party action by commit SHA in one source of truth, + # internal/generate/action_pins.yaml. Dependabot cannot read that file, so + # .github/workflows/action-pins.yml is a non-executing anchor that lists every + # pinned action; Dependabot opens its github-actions bump PRs there (and in any + # hand-written workflow using the same action). A maintainer then copies the new + # pin into action_pins.yaml and regenerates the workflows. The action-pins.yml + # header documents the full flow. + # + # orchestrate.yaml and promote.yaml are generated by `cascade generate-workflow` + # from action_pins.yaml. Dependabot must not edit them: a bump there is + # overwritten on the next regeneration and fails the Workflow Drift Check. + # exclude-paths keeps Dependabot out of the generated files; their pins move + # only when the manifest is regenerated. - package-ecosystem: github-actions directory: / schedule: interval: weekly labels: - dependencies + exclude-paths: + - .github/workflows/orchestrate.yaml + - .github/workflows/promote.yaml diff --git a/.github/workflows/action-pins.yml b/.github/workflows/action-pins.yml new file mode 100644 index 0000000..f7d8a72 --- /dev/null +++ b/.github/workflows/action-pins.yml @@ -0,0 +1,64 @@ +# Dependabot anchor for cascade's pinned third-party actions. +# +# This workflow never runs. It exists only so Dependabot's github-actions +# updater has one well-known file that references every action cascade pins, and +# opens its version-bump pull requests against it. The real source of truth is +# internal/generate/action_pins.yaml, which Dependabot cannot read. +# +# How an action gets bumped now: +# 1. Dependabot bumps a `uses:` line here (and in any hand-written workflow +# that uses the same action). +# 2. CI turns red: the consistency lint (internal/generate, +# TestWorkflowsConsistentWithActionPins) and TestActionPinsAnchorCoversManifest +# report that the anchor and action_pins.yaml disagree. +# 3. A maintainer copies the new sha and version into action_pins.yaml, then +# runs `cascade generate-workflow --config .github/manifest.yaml --force` to +# refresh orchestrate.yaml and promote.yaml from the updated manifest. +# 4. CI turns green: the pin now agrees across the manifest, this anchor, the +# hand-written workflows, and the generated workflows. +# +# The generated workflows (orchestrate.yaml, promote.yaml) are excluded from +# Dependabot in .github/dependabot.yml, so a bump never lands there directly and +# trips the Workflow Drift Check. They move only through regeneration in step 3. +# +# Keep exactly one step per action key in action_pins.yaml. +# TestActionPinsAnchorCoversManifest fails if this list drifts out of sync. +name: Action Pins (dependabot anchor) + +on: + workflow_dispatch: + inputs: + run_anchor: + description: 'Leave disabled. This workflow is a Dependabot parse anchor and performs no work.' + type: boolean + default: false + +permissions: + contents: read + +jobs: + anchor: + name: Pinned action references (never executes) + # Gated off by a default-false dispatch input so the workflow runs no steps + # under normal operation. Dependabot parses `uses:` statically and ignores + # both the trigger set and this condition, so the bump entrypoint still works + # while the job stays inert. + if: ${{ inputs.run_anchor }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: return + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + path: . + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: '0' + private-key: '' + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + - uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/internal/generate/action_pins_anchor_test.go b/internal/generate/action_pins_anchor_test.go new file mode 100644 index 0000000..bed1fbc --- /dev/null +++ b/internal/generate/action_pins_anchor_test.go @@ -0,0 +1,55 @@ +package generate + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// anchorWorkflowPath is the dependabot anchor workflow, relative to the repo's +// .github directory. It gives the github-actions updater one well-known file +// that references every action pinned in action_pins.yaml. +const anchorWorkflowPath = "workflows/action-pins.yml" + +// TestActionPinsAnchorCoversManifest keeps the dependabot anchor a complete and +// correct mirror of action_pins.yaml. +// +// Completeness matters because Dependabot only bumps actions it can see in a +// scanned file. The generated workflows (orchestrate.yaml, promote.yaml) are +// excluded from Dependabot in .github/dependabot.yml, so an action that appears +// only there (for example actions/create-github-app-token) would never get a +// bump PR without a tracked home in the anchor. +// +// Correctness (each anchor ref matches the manifest sha and version) is also +// enforced repo-wide by TestWorkflowsConsistentWithActionPins; asserting it here +// too keeps the anchor's purpose self-contained, so a stale anchor is reported +// against the manifest directly rather than only as one row in the repo sweep. +func TestActionPinsAnchorCoversManifest(t *testing.T) { + manifest := loadActionPinsManifest(t) + githubDir := repoGitHubDir(t) + + content, err := os.ReadFile(filepath.Join(githubDir, anchorWorkflowPath)) //nolint:gosec // fixed path under the repo's .github tree. + require.NoError(t, err) + + // Correctness: no governed uses: line in the anchor may diverge from the manifest. + mismatches := scanUsesForPinDrift(anchorWorkflowPath, string(content), manifest) + require.Emptyf(t, mismatches, "dependabot anchor refs diverge from action_pins.yaml: %v", mismatches) + + // Completeness: every manifest action must appear in the anchor so Dependabot + // has a tracked bump entrypoint for it. + anchored := make(map[string]bool) + for _, line := range strings.Split(string(content), "\n") { + if groups := usesRefRe.FindStringSubmatch(line); groups != nil { + anchored[groups[1]] = true + } + } + for action := range manifest { + require.Truef(t, anchored[action], + "action %q is in action_pins.yaml but missing from the dependabot anchor %s; "+ + "add a `uses: %s@ # ` step so Dependabot can bump it", + action, anchorWorkflowPath, action) + } +}