Repository: github/gh-aw (2026-03-26T16:58:29Z)

Issue:
BUG: 'setup-cli' action referenced by mutable tag instead of commit SHA in maintenance workflow
## Summary

The `agentics-maintenance.yml` workflow compiled by gh-aw v0.64.0 references `github/gh-aw-actions/setup-cli` using the mutable tag `@v0.64.0` rather than a pinned full commit SHA. Every other action in this repo (and in `copilot-setup-steps.yml`) is pinned to a commit SHA for supply-chain integrity and reproducibility. This inconsistency weakens the security posture of the workflow.

### What failed
- `uses: github/gh-aw-actions/setup-cli@v0.64.0` — mutable tag, not a SHA pin.
- All other action references in the same repository use SHA-pinned references.

### Why it failed
- The compiler's action pinning logic (via `GetActionPin()` and `.github/aw/actions-lock.json`) did not resolve and emit the commit SHA for `setup-cli` in the context of the maintenance workflow generation.

### Root cause
- Gap in the action pinning/lock resolution logic in the compiler when generating maintenance workflow references to `setup-cli`.
- The correct SHA is known (`51c65948c64ab6752536ead71fba1fc2c20ed0bc`) and is used elsewhere; it simply wasn't applied here.

### Supporting evidence / References
- [PR Review Copilot observation 11](https://github.com/grahame-org/gnu-tools-for-stm32/pull/682#pullrequestreview-4013076953)
- Compiler pinning code: `GetActionPin()` and `.github/aw/actions-lock.json`
- Example of correct SHA pinning in `copilot-setup-steps.yml` in same repo

### Proposed fix
- Ensure `GetActionPin()` (or equivalent) correctly resolves and emits the SHA for `setup-cli` in all compiler code paths, including maintenance workflow generation.
- The suggestion from review: `uses: github/gh-aw-actions/setup-cli@51c65948c64ab6752536ead71fba1fc2c20ed0bc # v0.64.0`
- Add a compiler test asserting that all emitted action references are SHA-pinned (no bare tags).

### Implementation plan
1. Audit compiler workflow generators for any `setup-cli` or other action references that bypass the `GetActionPin()` / `actions-lock.json` resolution path.
2. Fix the identified code path to use the SHA from the lock.
3. Add a test asserting no mutable tag references in any compiled `.lock.yml` output.



Discussion:

Patch:
diff --git a/pkg/workflow/maintenance_workflow.go b/pkg/workflow/maintenance_workflow.go
index 48a1d81f89c..bceef12a29c 100644
--- a/pkg/workflow/maintenance_workflow.go
+++ b/pkg/workflow/maintenance_workflow.go
@@ -16,7 +16,8 @@ var maintenanceLog = logger.New("workflow:maintenance_workflow")
 // In dev mode: builds from source using Setup Go + Build gh-aw (./gh-aw binary available)
 // In release mode: installs the released CLI via the setup-cli action (gh aw available)
 // In action mode: installs the released CLI via the gh-aw-actions/setup-cli action (gh aw available)
-func generateInstallCLISteps(actionMode ActionMode, version string, actionTag string) string {
+// When resolver is non-nil, attempts to resolve the setup-cli action to a SHA-pinned reference.
+func generateInstallCLISteps(actionMode ActionMode, version string, actionTag string, resolver ActionSHAResolver) string {
 	if actionMode == ActionModeDev {
 		return `      - name: Setup Go
         uses: ` + GetActionPin("actions/setup-go") + `
@@ -37,8 +38,10 @@ func generateInstallCLISteps(actionMode ActionMode, version string, actionTag st
 
 	// Action mode: use setup-cli action from external gh-aw-actions repository
 	if actionMode == ActionModeAction {
+		actionRepo := GitHubActionsOrgRepo + "/setup-cli"
+		ref := resolveActionRef(actionRepo, cliTag, resolver)
 		return `      - name: Install gh-aw
-        uses: github/gh-aw-actions/setup-cli@` + cliTag + `
+        uses: ` + ref + `
         with:
           version: ` + cliTag + `
 
@@ -46,14 +49,31 @@ func generateInstallCLISteps(actionMode ActionMode, version string, actionTag st
 	}
 
 	// Release mode: use setup-cli action (consistent with copilot-setup-steps.yml)
+	actionRepo := GitHubOrgRepo + "/actions/setup-cli"
+	ref := resolveActionRef(actionRepo, cliTag, resolver)
 	return `      - name: Install gh-aw
-        uses: github/gh-aw/actions/setup-cli@` + cliTag + `
+        uses: ` + ref + `
         with:
           version: ` + cliTag + `
 
 `
 }
 
+// resolveActionRef attempts to resolve an action repo@tag to a SHA-pinned reference
+// using the provided resolver. If the resolver is nil or resolution fails, it returns
+// the tag-based reference (repo@tag).
+func resolveActionRef(actionRepo, tag string, resolver ActionSHAResolver) string {
+	if resolver != nil && tag != "" && tag != "dev" {
+		sha, err := resolver.ResolveSHA(actionRepo, tag)
+		if err != nil {
+			maintenanceLog.Printf("Failed to resolve SHA for %s@%s: %v, falling back to tag reference", actionRepo, tag, err)
+		} else if sha != "" {
+			return formatActionReference(actionRepo, sha, tag)
+		}
+	}
+	return actionRepo + "@" + tag
+}
+
 // getCLICmdPrefix returns the CLI command prefix based on action mode.
 // In dev mode: "./gh-aw" (local binary built from source)
 // In release mode: "gh aw" (installed via gh extension)
@@ -312,7 +332,7 @@ jobs:
 
 `)
 
-	yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag))
+	yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver))
 	yaml.WriteString(`      - name: Run operation
         uses: ` + GetActionPin("actions/github-script") + `
         env:
@@ -399,7 +419,7 @@ jobs:
 
 `)
 
-		yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag))
+		yaml.WriteString(generateInstallCLISteps(actionMode, version, actionTag, resolver))
 		yaml.WriteString(`      - name: Compile workflows
         run: |
           ` + getCLICmdPrefix(actionMode) + ` compile --validate --verbose


Test patch:
diff --git a/pkg/workflow/maintenance_workflow_test.go b/pkg/workflow/maintenance_workflow_test.go
index f7ddc36398d..3947d0b928c 100644
--- a/pkg/workflow/maintenance_workflow_test.go
+++ b/pkg/workflow/maintenance_workflow_test.go
@@ -425,7 +425,7 @@ func TestGenerateMaintenanceWorkflow_ActionTag(t *testing.T) {
 
 func TestGenerateInstallCLISteps(t *testing.T) {
 	t.Run("dev mode generates Setup Go and Build gh-aw steps", func(t *testing.T) {
-		result := generateInstallCLISteps(ActionModeDev, "v1.0.0", "")
+		result := generateInstallCLISteps(ActionModeDev, "v1.0.0", "", nil)
 		if !strings.Contains(result, "Setup Go") {
 			t.Errorf("Dev mode should include Setup Go step, got:\n%s", result)
 		}
@@ -438,7 +438,7 @@ func TestGenerateInstallCLISteps(t *testing.T) {
 	})
 
 	t.Run("release mode generates setup-cli action step", func(t *testing.T) {
-		result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "")
+		result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "", nil)
 		if !strings.Contains(result, "github/gh-aw/actions/setup-cli@v1.0.0") {
 			t.Errorf("Release mode should use setup-cli action with version, got:\n%s", result)
 		}
@@ -451,11 +451,52 @@ func TestGenerateInstallCLISteps(t *testing.T) {
 	})
 
 	t.Run("release mode uses actionTag over version", func(t *testing.T) {
-		result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "v2.0.0")
+		result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "v2.0.0", nil)
 		if !strings.Contains(result, "setup-cli@v2.0.0") {
 			t.Errorf("Release mode should use actionTag v2.0.0, got:\n%s", result)
 		}
 	})
+
+	t.Run("release mode with resolver uses SHA-pinned setup-cli reference", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		cache := NewActionCache(tmpDir)
+		cache.Set("github/gh-aw/actions/setup-cli", "v1.0.0", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
+		resolver := NewActionResolver(cache)
+
+		result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "", resolver)
+		expectedRef := "github/gh-aw/actions/setup-cli@aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa # v1.0.0"
+		if !strings.Contains(result, expectedRef) {
+			t.Errorf("Release mode with resolver should use SHA-pinned setup-cli reference %q, got:\n%s", expectedRef, result)
+		}
+		// Must not contain the bare mutable tag
+		if strings.Contains(result, "setup-cli@v1.0.0") {
+			t.Errorf("Release mode with resolver must not use mutable tag setup-cli@v1.0.0, got:\n%s", result)
+		}
+	})
+
+	t.Run("action mode with resolver uses SHA-pinned setup-cli reference", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		cache := NewActionCache(tmpDir)
+		cache.Set("github/gh-aw-actions/setup-cli", "v1.0.0", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
+		resolver := NewActionResolver(cache)
+
+		result := generateInstallCLISteps(ActionModeAction, "v1.0.0", "", resolver)
+		expectedRef := "github/gh-aw-actions/setup-cli@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb # v1.0.0"
+		if !strings.Contains(result, expectedRef) {
+			t.Errorf("Action mode with resolver should use SHA-pinned setup-cli reference %q, got:\n%s", expectedRef, result)
+		}
+		// Must not contain the bare mutable tag
+		if strings.Contains(result, "setup-cli@v1.0.0") {
+			t.Errorf("Action mode with resolver must not use mutable tag setup-cli@v1.0.0, got:\n%s", result)
+		}
+	})
+
+	t.Run("release mode without resolver falls back to tag reference", func(t *testing.T) {
+		result := generateInstallCLISteps(ActionModeRelease, "v1.0.0", "", nil)
+		if !strings.Contains(result, "github/gh-aw/actions/setup-cli@v1.0.0") {
+			t.Errorf("Release mode without resolver should fall back to tag reference, got:\n%s", result)
+		}
+	})
 }
 
 func TestGetCLICmdPrefix(t *testing.T) {
@@ -541,3 +582,51 @@ func TestGenerateMaintenanceWorkflow_RunOperationCLICodegen(t *testing.T) {
 		}
 	})
 }
+
+func TestGenerateMaintenanceWorkflow_SetupCLISHAPinning(t *testing.T) {
+	setupCLISHA := "cccccccccccccccccccccccccccccccccccccccc"
+
+	workflowDataListWithResolver := func(resolver *ActionResolver) []*WorkflowData {
+		return []*WorkflowData{
+			{
+				Name:              "test-workflow",
+				ActionResolver:    resolver,
+				ActionPinWarnings: make(map[string]bool),
+				SafeOutputs: &SafeOutputsConfig{
+					CreateIssues: &CreateIssuesConfig{
+						Expires: 48,
+					},
+				},
+			},
+		}
+	}
+
+	t.Run("release mode with resolver SHA-pins setup-cli in run_operation", func(t *testing.T) {
+		tmpDir := t.TempDir()
+		cache := NewActionCache(tmpDir)
+		cache.Set("github/gh-aw/actions/setup-cli", "v1.0.0", setupCLISHA)
+		// Also seed the setup action to keep the test hermetic (GenerateMaintenanceWorkflow
+		// calls ResolveSetupActionReference with the same resolver, which would otherwise
+		// attempt a real gh api call on a cache miss).
+		cache.Set("github/gh-aw/actions/setup", "v1.0.0", "dddddddddddddddddddddddddddddddddddddddd")
+		resolver := NewActionResolver(cache)
+
+		err := GenerateMaintenanceWorkflow(workflowDataListWithResolver(resolver), tmpDir, "v1.0.0", ActionModeRelease, "v1.0.0", false)
+		if err != nil {
+			t.Fatalf("Unexpected error: %v", err)
+		}
+		content, err := os.ReadFile(filepath.Join(tmpDir, "agentics-maintenance.yml"))
+		if err != nil {
+			t.Fatalf("Expected maintenance workflow to be generated: %v", err)
+		}
+		yaml := string(content)
+		expectedRef := "github/gh-aw/actions/setup-cli@" + setupCLISHA + " # v1.0.0"
+		if !strings.Contains(yaml, expectedRef) {
+			t.Errorf("Expected SHA-pinned setup-cli reference %q in generated workflow, got:\n%s", expectedRef, yaml)
+		}
+		// Bare tag must not appear
+		if strings.Contains(yaml, "setup-cli@v1.0.0") {
+			t.Errorf("Generated workflow must not use mutable tag setup-cli@v1.0.0; got:\n%s", yaml)
+		}
+	})
+}