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: 1 addition & 1 deletion .github/actions/register-run/action.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/drift-comment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
4 changes: 2 additions & 2 deletions internal/generate/action_pins.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
4 changes: 2 additions & 2 deletions internal/generate/action_pins_manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
}

Expand Down
4 changes: 2 additions & 2 deletions internal/generate/drift_check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down
12 changes: 6 additions & 6 deletions internal/generate/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}

Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 <build-name>-* convention pattern")
Expand Down Expand Up @@ -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")
}

Expand Down
187 changes: 187 additions & 0 deletions internal/generate/workflow_consistency_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading