diff --git a/.github/actions/register-run/action.yaml b/.github/actions/register-run/action.yaml index 0e831d21..9dd46ec7 100644 --- a/.github/actions/register-run/action.yaml +++ b/.github/actions/register-run/action.yaml @@ -119,7 +119,7 @@ runs: - name: Upload ledger artifact if: inputs.upload == 'true' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ steps.stage.outputs.artifact-name }} path: ${{ steps.append.outputs.ledger-path }} diff --git a/.github/workflows/drift-comment.yaml b/.github/workflows/drift-comment.yaml index acca83e2..4ae2a6d0 100644 --- a/.github/workflows/drift-comment.yaml +++ b/.github/workflows/drift-comment.yaml @@ -42,7 +42,7 @@ jobs: - name: Post or update sticky comment if: steps.download.outcome == 'success' - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0 with: script: | const fs = require('fs'); diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 0e2c5add..63c1adc1 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -115,7 +115,7 @@ jobs: - name: Upload drift result if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: drift-result path: | diff --git a/internal/generate/action_pins.yaml b/internal/generate/action_pins.yaml index 57c5ff0e..d044e18e 100644 --- a/internal/generate/action_pins.yaml +++ b/internal/generate/action_pins.yaml @@ -12,8 +12,8 @@ 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/download-artifact: { tag: v8, sha: 3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c, version: v8.0.1, emit: true } + actions/upload-artifact: { tag: v7, sha: 043fb46d1a93c77aae656e7c1c64a875d1fc6a0a, version: v7.0.1, 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 } diff --git a/internal/generate/action_pins_manifest_test.go b/internal/generate/action_pins_manifest_test.go index 40a26a64..eaf927e9 100644 --- a/internal/generate/action_pins_manifest_test.go +++ b/internal/generate/action_pins_manifest_test.go @@ -18,8 +18,8 @@ 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"}, + actionDownloadArtifact: {tag: "v8", sha: "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", shaVersion: "v8.0.1"}, + actionUploadArtifact: {tag: "v7", sha: "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", shaVersion: "v7.0.1"}, actionCreateAppToken: {tag: "v3", sha: "bcd2ba49218906704ab6c1aa796996da409d3eb1", shaVersion: "v3.2.0"}, } diff --git a/internal/generate/drift_check_test.go b/internal/generate/drift_check_test.go index fa6ce0bc..447f64f8 100644 --- a/internal/generate/drift_check_test.go +++ b/internal/generate/drift_check_test.go @@ -153,11 +153,11 @@ func TestDriftCheckGenerator_PinModeSHA(t *testing.T) { check, err := g.Generate() require.NoError(t, err) - assert.Contains(t, check, "actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02") + assert.Contains(t, check, "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a") comment, err := g.GenerateComment() require.NoError(t, err) - assert.Contains(t, comment, "actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093") + assert.Contains(t, comment, "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c") assert.Contains(t, comment, "actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b") } diff --git a/internal/generate/generator_test.go b/internal/generate/generator_test.go index cf5a292a..3b170fa7 100644 --- a/internal/generate/generator_test.go +++ b/internal/generate/generator_test.go @@ -1909,11 +1909,11 @@ func TestGenerator_PassthroughArtifact_NoArtifactNoSteps(t *testing.T) { // not accidentally count that. Check that neither passthrough action appears // outside the finalize/release context by verifying they are absent entirely // (no release config is set, so the finalize job emits no artifact steps either). - assert.NotContains(t, result, "actions/upload-artifact@v4", + assert.NotContains(t, result, "actions/upload-artifact@v7", "build without artifact: must not emit upload-artifact") // download-artifact only appears in finalize when HasReleaseArtifacts. It is // absent here because no release artifacts are declared and no passthrough is set. - assert.NotContains(t, result, "actions/download-artifact@v4", + assert.NotContains(t, result, "actions/download-artifact@v8", "build without artifact: must not emit download-artifact") } @@ -1947,7 +1947,7 @@ func TestGenerator_PassthroughArtifact_ReusableWorkflowUploadJob(t *testing.T) { // A post-upload job must be emitted. assert.Contains(t, result, "build-compile-upload:", "reusable-workflow build with artifact.upload must emit a post-upload job") - assert.Contains(t, result, "uses: actions/upload-artifact@v4", + assert.Contains(t, result, "uses: actions/upload-artifact@v7", "post-upload job must use upload-artifact action") assert.Contains(t, result, "name: build-compile", "uploaded artifact must be named build-{build-name}") @@ -1997,7 +1997,7 @@ func TestGenerator_PassthroughArtifact_ReusableWorkflowDownloadJob(t *testing.T) // A pre-download job must be emitted for the sign callback. assert.Contains(t, result, "build-sign-download:", "reusable-workflow build with artifact.downloads must emit a pre-download job") - assert.Contains(t, result, "uses: actions/download-artifact@v4", + assert.Contains(t, result, "uses: actions/download-artifact@v8", "pre-download job must use download-artifact action") assert.Contains(t, result, "name: build-compile", "pre-download step must reference the producer's artifact name build-compile") @@ -2164,7 +2164,7 @@ func TestGenerator_PassthroughArtifact_MatrixUploadCollectsLegs(t *testing.T) { body := uploadJobBody(t, result, "build-image-upload") // The post-job must collect the per-leg artifacts first. - assert.Contains(t, body, "uses: actions/download-artifact@v4", + assert.Contains(t, body, "uses: actions/download-artifact@v8", "matrix upload post-job must download per-leg artifacts before uploading") assert.Contains(t, body, "pattern: image-*", "collect step must use the -* convention pattern") @@ -2225,7 +2225,7 @@ func TestGenerator_PassthroughArtifact_NonMatrixUploadNoCollect(t *testing.T) { body := uploadJobBody(t, result, "build-compile-upload") assert.NotContains(t, body, "actions/download-artifact", "non-matrix upload job must not emit a per-leg collect step") - assert.Contains(t, body, "uses: actions/upload-artifact@v4", + assert.Contains(t, body, "uses: actions/upload-artifact@v7", "non-matrix upload job must still upload directly") } diff --git a/internal/generate/workflow_consistency_test.go b/internal/generate/workflow_consistency_test.go new file mode 100644 index 00000000..e3da98fb --- /dev/null +++ b/internal/generate/workflow_consistency_test.go @@ -0,0 +1,187 @@ +package generate + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// usesRefRe extracts the action path, pinned ref, and trailing version comment +// from a single workflow/composite "uses:" line. It accepts an optional leading +// "- " and optional surrounding quotes so it matches both step-list and +// continuation forms. The ref is everything up to whitespace or "#"; the comment +// is the first token after "# ", if present. +var usesRefRe = regexp.MustCompile(`uses:\s*["']?([^"'\s@]+)@([^"'\s#]+)["']?\s*(?:#\s*(\S+))?`) + +// pinMismatch is one governed "uses:" line whose pinned ref or version comment +// disagrees with the action-pins manifest. file and line locate it; want* hold +// the manifest's canonical values; got* hold what the file actually carries. +type pinMismatch struct { + file string + line int + action string + wantSHA string + wantVersion string + gotRef string + gotComment string +} + +// String renders a mismatch as a single "file:line want ... got ..." row for the +// aggregated failure table. +func (m pinMismatch) String() string { + return fmt.Sprintf("%s:%d %s want %s # %s got %s # %s", + m.file, m.line, m.action, m.wantSHA, m.wantVersion, m.gotRef, m.gotComment) +} + +// scanUsesForPinDrift walks every line of one file's content and returns the +// governed "uses:" lines that diverge from the manifest. A line is governed when +// its action path is a manifest key; local ("./...") and cascade self-action +// refs are never manifest keys and so are skipped implicitly. For a governed +// line the pinned ref must equal the manifest SHA and the trailing comment must +// equal the manifest version, so a silent SHA-or-comment drift is caught. +func scanUsesForPinDrift(file, content string, manifest map[string]actionPinEntry) []pinMismatch { + var mismatches []pinMismatch + for i, line := range strings.Split(content, "\n") { + groups := usesRefRe.FindStringSubmatch(line) + if groups == nil { + continue + } + action, ref, comment := groups[1], groups[2], groups[3] + entry, governed := manifest[action] + if !governed { + continue + } + if ref == entry.SHA && comment == entry.Version { + continue + } + mismatches = append(mismatches, pinMismatch{ + file: file, + line: i + 1, + action: action, + wantSHA: entry.SHA, + wantVersion: entry.Version, + gotRef: ref, + gotComment: comment, + }) + } + return mismatches +} + +// repoGitHubDir walks up from this test file's own source location to the module +// root (the directory holding go.mod) and returns that root's ".github" path. +// It anchors on runtime.Caller rather than os.Getwd so a sibling test that +// chdir's in the shared test binary cannot misdirect the scan, and it keys on +// go.mod rather than the first ".github" found because the package carries +// .github fixtures of its own that must not be mistaken for the repo root. +func repoGitHubDir(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + require.True(t, ok, "runtime.Caller could not locate the test source file") + dir := filepath.Dir(thisFile) + for { + if info, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil && !info.IsDir() { + githubDir := filepath.Join(dir, ".github") + info, statErr := os.Stat(githubDir) + require.NoErrorf(t, statErr, "module root %q has no .github directory", dir) + require.Truef(t, info.IsDir(), "%q is not a directory", githubDir) + return githubDir + } + parent := filepath.Dir(dir) + require.NotEqualf(t, parent, dir, "walked to filesystem root without finding go.mod from %q", dir) + dir = parent + } +} + +// governedFiles returns every hand-written and generated workflow plus every +// composite action definition under .github that the consistency lint locks to +// the manifest. +func governedFiles(t *testing.T, githubDir string) []string { + t.Helper() + var files []string + for _, pattern := range []string{ + filepath.Join(githubDir, "workflows", "*.yml"), + filepath.Join(githubDir, "workflows", "*.yaml"), + filepath.Join(githubDir, "actions", "*", "action.yml"), + filepath.Join(githubDir, "actions", "*", "action.yaml"), + } { + matches, err := filepath.Glob(pattern) + require.NoError(t, err) + files = append(files, matches...) + } + sort.Strings(files) + require.NotEmpty(t, files, "consistency lint found no workflow or composite-action files to scan") + return files +} + +// loadActionPinsManifest parses the embedded action_pins.yaml into its full +// action set (both emit:true and emit:false) so the lint locks every governed +// action across cascade's own .github tree, not just the generator-emitted ones. +func loadActionPinsManifest(t *testing.T) map[string]actionPinEntry { + t.Helper() + var manifest actionPinsManifest + require.NoError(t, yaml.Unmarshal(actionPinsYAML, &manifest)) + require.NotEmpty(t, manifest.Actions, "action_pins.yaml parsed to an empty action set") + return manifest.Actions +} + +// TestWorkflowsConsistentWithActionPins is the merge gate that keeps cascade's +// own hand-written workflows and composite actions locked to action_pins.yaml. +// dependabot edits workflow files but never the manifest, so without this lint a +// piecemeal bump could leave the manifest and the repo silently disagreeing on a +// pinned SHA. It scans every governed "uses:" line and fails once with the full +// file:line want/got table rather than on the first divergence, so a sweep fixes +// every drift in one pass. +func TestWorkflowsConsistentWithActionPins(t *testing.T) { + manifest := loadActionPinsManifest(t) + githubDir := repoGitHubDir(t) + + var mismatches []pinMismatch + for _, file := range governedFiles(t, githubDir) { + content, err := os.ReadFile(file) //nolint:gosec // path comes from a fixed glob under the repo's .github tree. + require.NoError(t, err) + rel, err := filepath.Rel(filepath.Dir(githubDir), file) + require.NoError(t, err) + mismatches = append(mismatches, scanUsesForPinDrift(rel, string(content), manifest)...) + } + + if len(mismatches) > 0 { + var b strings.Builder + b.WriteString("governed uses: refs diverge from internal/generate/action_pins.yaml:\n") + for _, m := range mismatches { + fmt.Fprintf(&b, " %s\n", m) + } + b.WriteString("update the workflow/composite file or the manifest so they agree.") + t.Fatal(b.String()) + } +} + +// TestWorkflowConsistencyLint_DetectsDivergence is the negative control that +// keeps the lint honest: it feeds scanUsesForPinDrift a fixture whose checkout +// SHA is deliberately wrong and asserts the scan reports exactly that line with +// the manifest's canonical SHA as the "want". If the detector ever silently +// stopped flagging drift, this test goes red even though the real repo is clean. +func TestWorkflowConsistencyLint_DetectsDivergence(t *testing.T) { + manifest := loadActionPinsManifest(t) + want, ok := manifest[actionCheckout] + require.True(t, ok, "manifest must define %s for the negative control", actionCheckout) + + const stale = "0000000000000000000000000000000000000000" + fixture := "jobs:\n" + + " build:\n" + + " steps:\n" + + " - uses: actions/checkout@" + stale + " # " + want.Version + "\n" + + mismatches := scanUsesForPinDrift("fixture.yaml", fixture, manifest) + require.Len(t, mismatches, 1, "a flipped checkout SHA must produce exactly one mismatch") + require.Equal(t, actionCheckout, mismatches[0].action) + require.Equal(t, stale, mismatches[0].gotRef) + require.Equal(t, want.SHA, mismatches[0].wantSHA, "want must carry the manifest's canonical SHA") +}