diff --git a/internal/generate/action_pins.go b/internal/generate/action_pins.go index fc59a1b..0f9cd3f 100644 --- a/internal/generate/action_pins.go +++ b/internal/generate/action_pins.go @@ -1,9 +1,13 @@ package generate import ( + _ "embed" "fmt" + "regexp" "strings" + "gopkg.in/yaml.v3" + "github.com/stablekernel/cascade/internal/config" ) @@ -30,17 +34,79 @@ type actionPin struct { shaVersion string // precise version the SHA corresponds to, for the trailing comment } -// defaultActionPins is the built-in pin table for every third-party action -// cascade emits. tag mode emits @; sha mode emits -// @ # . The SHAs were resolved from the upstream -// repositories at the major tags below; action_pins entries in the manifest -// override any of these without code changes. -var defaultActionPins = map[string]actionPin{ - actionCheckout: {tag: "v6", sha: "df4cb1c069e1874edd31b4311f1884172cec0e10", shaVersion: "v6.0.3"}, - actionGithubScript: {tag: "v7", sha: "f28e40c7f34bde8b3046d885e986cb6290c5673b", shaVersion: "v7.1.0"}, - actionDownloadArtifact: {tag: "v4", sha: "d3f86a106a0bac45b974a628896c90dbdf5c8093", shaVersion: "v4.3.0"}, - actionUploadArtifact: {tag: "v4", sha: "ea165f8d65b6e75b540449e92b4886f43607fa02", shaVersion: "v4.6.2"}, - actionCreateAppToken: {tag: "v3", sha: "bcd2ba49218906704ab6c1aa796996da409d3eb1", shaVersion: "v3.2.0"}, +// actionPinsManifest is the on-disk shape of action_pins.yaml: every action +// cascade pins, keyed by action path, with its tag, resolved commit SHA, +// precise version, and whether the generator emits it. +type actionPinsManifest struct { + Actions map[string]actionPinEntry `yaml:"actions"` +} + +// actionPinEntry is a single action's pin record as authored in the manifest. +// emit distinguishes the actions the generator renders into user workflows from +// the maintainer-only actions recorded only to keep the pin set in one place. +type actionPinEntry struct { + Tag string `yaml:"tag"` + SHA string `yaml:"sha"` + Version string `yaml:"version"` + Emit bool `yaml:"emit"` +} + +// actionPinsYAML is the committed single source of truth for every third-party +// action version cascade pins. It is parsed once at package init; generation +// stays a pure offline function of these committed bytes. +// +//go:embed action_pins.yaml +var actionPinsYAML []byte + +// commitSHAPattern matches a 40-character lowercase hex commit SHA. A manifest +// entry whose sha does not match is rejected at init so a malformed pin can +// never reach generation. +var commitSHAPattern = regexp.MustCompile(`^[0-9a-f]{40}$`) + +// defaultActionPins is the built-in pin table for every third-party action the +// generator emits. tag mode emits @; sha mode emits +// @ # . It is parsed from the embedded action_pins.yaml +// manifest (emit: true entries only) so the values live in one committed file; +// action_pins entries in a user manifest still override any of these without +// code changes. +var defaultActionPins = mustParseActionPins(actionPinsYAML) + +// mustParseActionPins parses the embedded manifest into the generator pin table, +// keeping only emit: true actions (the set the generator renders). It panics on +// a malformed manifest, a non-40-hex SHA, or a missing generator action so the +// failure surfaces at init rather than as silently wrong generated output. +func mustParseActionPins(data []byte) map[string]actionPin { + var manifest actionPinsManifest + if err := yaml.Unmarshal(data, &manifest); err != nil { + panic(fmt.Sprintf("generate: parsing action_pins.yaml: %v", err)) + } + + pins := make(map[string]actionPin, len(manifest.Actions)) + for name, entry := range manifest.Actions { + if !entry.Emit { + continue + } + if !commitSHAPattern.MatchString(entry.SHA) { + panic(fmt.Sprintf("generate: action_pins.yaml: %s sha %q is not a 40-char commit SHA", name, entry.SHA)) + } + if entry.Tag == "" || entry.Version == "" { + panic(fmt.Sprintf("generate: action_pins.yaml: %s is missing a tag or version", name)) + } + pins[name] = actionPin{tag: entry.Tag, sha: entry.SHA, shaVersion: entry.Version} + } + + // Every action the generator references by const must be present and emit:true, + // so a manifest edit can never drop a governed action without a build-time panic. + for _, name := range []string{ + actionCheckout, actionGithubScript, actionDownloadArtifact, + actionUploadArtifact, actionCreateAppToken, + } { + if _, ok := pins[name]; !ok { + panic(fmt.Sprintf("generate: action_pins.yaml is missing emit:true entry for %s", name)) + } + } + + return pins } // actionRef returns the fully-rendered uses: value for a third-party action diff --git a/internal/generate/action_pins.yaml b/internal/generate/action_pins.yaml new file mode 100644 index 0000000..57c5ff0 --- /dev/null +++ b/internal/generate/action_pins.yaml @@ -0,0 +1,21 @@ +# SINGLE SOURCE OF TRUTH for every third-party action version cascade pins. +# +# emit: true -> rendered by the generator into user workflows (governed by +# actionRef). tag mode emits @; sha mode emits +# @ # . +# emit: false -> maintainer-only action used only by cascade's own hand-written +# workflows. Never generated; recorded here so the pin set stays +# in one place. +# +# sha is the 40-hex commit the tag/version resolves to. version is the precise +# release the sha corresponds to, carried as the trailing uses: comment. +actions: + actions/checkout: { tag: v6, sha: df4cb1c069e1874edd31b4311f1884172cec0e10, version: v6.0.3, emit: true } + actions/github-script: { tag: v7, sha: f28e40c7f34bde8b3046d885e986cb6290c5673b, version: v7.1.0, emit: true } + actions/download-artifact: { tag: v4, sha: d3f86a106a0bac45b974a628896c90dbdf5c8093, version: v4.3.0, emit: true } + actions/upload-artifact: { tag: v4, sha: ea165f8d65b6e75b540449e92b4886f43607fa02, version: v4.6.2, emit: true } + actions/create-github-app-token: { tag: v3, sha: bcd2ba49218906704ab6c1aa796996da409d3eb1, version: v3.2.0, emit: true } + actions/setup-go: { tag: v6, sha: 4a3601121dd01d1626a1e23e37211e3254c1c06c, version: v6.4.0, emit: false } + actions/setup-node: { tag: v6, sha: 48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e, version: v6.4.0, emit: false } + actions/upload-pages-artifact: { tag: v5, sha: fc324d3547104276b827a68afc52ff2a11cc49c9, version: v5.0.0, emit: false } + actions/deploy-pages: { tag: v5, sha: cd2ce8fcbc39b97be8ca5fce6e763baed58fa128, version: v5.0.0, emit: false } diff --git a/internal/generate/action_pins_manifest_test.go b/internal/generate/action_pins_manifest_test.go new file mode 100644 index 0000000..40a26a6 --- /dev/null +++ b/internal/generate/action_pins_manifest_test.go @@ -0,0 +1,69 @@ +package generate + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// TestDefaultActionPins_MatchesPriorHardcodedTable is the value-preserving +// contract for extracting the pin table into action_pins.yaml. It pins the +// EXACT values the hardcoded defaultActionPins map carried before the manifest +// existed; if parsing the embedded YAML produces anything different the refactor +// has changed behavior and this test fails. Update the values here only when an +// intentional bump (a later PR) changes them. +func TestDefaultActionPins_MatchesPriorHardcodedTable(t *testing.T) { + priorHardcodedTable := map[string]actionPin{ + actionCheckout: {tag: "v6", sha: "df4cb1c069e1874edd31b4311f1884172cec0e10", shaVersion: "v6.0.3"}, + actionGithubScript: {tag: "v7", sha: "f28e40c7f34bde8b3046d885e986cb6290c5673b", shaVersion: "v7.1.0"}, + actionDownloadArtifact: {tag: "v4", sha: "d3f86a106a0bac45b974a628896c90dbdf5c8093", shaVersion: "v4.3.0"}, + actionUploadArtifact: {tag: "v4", sha: "ea165f8d65b6e75b540449e92b4886f43607fa02", shaVersion: "v4.6.2"}, + actionCreateAppToken: {tag: "v3", sha: "bcd2ba49218906704ab6c1aa796996da409d3eb1", shaVersion: "v3.2.0"}, + } + + // The parsed generator table must contain exactly the prior emit:true set, + // value-for-value, with no extra keys leaking in from emit:false entries. + assert.Len(t, defaultActionPins, len(priorHardcodedTable), + "parsed table must hold exactly the prior emit:true actions") + for action, want := range priorHardcodedTable { + got, ok := defaultActionPins[action] + require.Truef(t, ok, "parsed table is missing %s", action) + assert.Equalf(t, want.tag, got.tag, "tag for %s", action) + assert.Equalf(t, want.sha, got.sha, "sha for %s", action) + assert.Equalf(t, want.shaVersion, got.shaVersion, "shaVersion for %s", action) + } +} + +// TestActionPinsManifest_EmitFlags asserts every action in the embedded manifest +// carries the correct emit flag: the five generator-emitted actions are +// emit:true and the four maintainer-only actions are emit:false. This guards the +// filter mustParseActionPins applies when building the generator table. +func TestActionPinsManifest_EmitFlags(t *testing.T) { + var manifest actionPinsManifest + require.NoError(t, yaml.Unmarshal(actionPinsYAML, &manifest)) + + wantEmit := map[string]bool{ + actionCheckout: true, + actionGithubScript: true, + actionDownloadArtifact: true, + actionUploadArtifact: true, + actionCreateAppToken: true, + "actions/setup-go": false, + "actions/setup-node": false, + "actions/upload-pages-artifact": false, + "actions/deploy-pages": false, + } + + assert.Len(t, manifest.Actions, len(wantEmit), + "manifest must list exactly the known actions") + for action, want := range wantEmit { + entry, ok := manifest.Actions[action] + require.Truef(t, ok, "manifest is missing %s", action) + assert.Equalf(t, want, entry.Emit, "emit flag for %s", action) + assert.NotEmptyf(t, entry.Tag, "tag for %s", action) + assert.NotEmptyf(t, entry.Version, "version for %s", action) + assert.Lenf(t, entry.SHA, 40, "sha for %s must be a 40-char commit SHA", action) + } +}