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
88 changes: 77 additions & 11 deletions internal/generate/action_pins.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package generate

import (
_ "embed"
"fmt"
"regexp"
"strings"

"gopkg.in/yaml.v3"

"github.com/stablekernel/cascade/internal/config"
)

Expand All @@ -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 <action>@<tag>; sha mode emits
// <action>@<sha> # <shaVersion>. 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 <action>@<tag>; sha mode emits
// <action>@<sha> # <shaVersion>. 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
Expand Down
21 changes: 21 additions & 0 deletions internal/generate/action_pins.yaml
Original file line number Diff line number Diff line change
@@ -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 <action>@<tag>; sha mode emits
# <action>@<sha> # <version>.
# 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 }
69 changes: 69 additions & 0 deletions internal/generate/action_pins_manifest_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading