Skip to content

Commit b8641b2

Browse files
authored
Warn on outdated action versions in user-provided steps (#43455)
1 parent fa90fec commit b8641b2

4 files changed

Lines changed: 385 additions & 1 deletion

File tree

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# ADR-43455: Warn on Outdated Action Versions in User-Provided Steps
2+
3+
**Date**: 2026-07-05
4+
**Status**: Draft
5+
**Deciders**: Unknown
6+
7+
---
8+
9+
### Context
10+
11+
AI agents that generate workflow files frequently emit `uses:` step references with stale
12+
major versions (e.g. `actions/checkout@v3`) even when a newer version is recorded in the
13+
embedded `action_pins.json`. Before this change, those outdated tags were silently accepted
14+
and pinned to the corresponding (old) SHA, giving users no signal that their workflow file
15+
should be updated. The compiler already maintains an embedded list of latest action pins,
16+
making it the natural place to surface this discrepancy at compile time rather than leaving
17+
it to downstream runtime surprises.
18+
19+
### Decision
20+
21+
We will add a compile-time warning (`warnIfOutdatedActionVersion`) hooked into the single
22+
action-pinning entry point (`applyActionPinToTypedStep`) that emits a stderr diagnostic
23+
whenever a user-supplied version tag is strictly older than the latest version in the
24+
embedded pin database. Partial major-only tags (e.g. `@v4`) are treated as floating
25+
within-major references and only warned on when a higher major is available. SHA refs and
26+
non-semver refs are silently skipped to avoid false positives. Warnings are deduplicated
27+
per `repo@version` pair within a single compilation run via `WorkflowData.ActionPinWarnings`.
28+
29+
### Alternatives Considered
30+
31+
#### Alternative 1: Hard-fail compilation on outdated version tags
32+
33+
Reject the workflow at compile time with an error instead of a warning, forcing users to
34+
update the version tag before proceeding. This would provide stronger enforcement but is
35+
too disruptive: partial major tags like `@v4` are still valid floating references within
36+
that major series, and treating them as errors would break legitimate workflows. A warning
37+
preserves forward motion while still surfacing the issue.
38+
39+
#### Alternative 2: Silently upgrade the version tag to the latest
40+
41+
Automatically rewrite the `uses:` field to the latest version from `action_pins.json`
42+
without user intervention. This avoids the warning noise but silently mutates
43+
user-provided workflow content, violating the principle that the compiler should not
44+
change user intent without explicit confirmation. It would also interact poorly with
45+
SHA-pinning strategies where the caller expects a specific version to be pinned.
46+
47+
### Consequences
48+
49+
#### Positive
50+
- Users and AI agents receive an actionable compile-time warning when a stale action
51+
version is specified, enabling them to update the workflow source rather than silently
52+
accumulating technical debt.
53+
- Deduplication via `ActionPinWarnings` ensures repeated steps (pre-steps, steps,
54+
post-steps) produce exactly one diagnostic per `repo@version` pair per compilation run.
55+
- SHA refs and branch refs are silently skipped, eliminating false positives for already-
56+
pinned workflows.
57+
- Partial major tags (`@vN`) are correctly treated as floating references, avoiding
58+
spurious warnings for valid usage.
59+
60+
#### Negative
61+
- The warning is written to stderr only; it is not surfaced in structured output or
62+
returned as a typed diagnostic, so programmatic consumers cannot easily filter or act
63+
on it.
64+
- Warning accuracy depends on `action_pins.json` being kept up-to-date; a stale pin
65+
database means outdated versions may not be flagged.
66+
- Teams that intentionally pin to an older major version for compatibility will see
67+
warnings they cannot currently suppress on a per-action basis.
68+
69+
#### Neutral
70+
- The new `ActionPinWarnings` map is added to `WorkflowData`, slightly increasing the
71+
struct's memory footprint per compilation run.
72+
- The feature is integrated at the single pinning entry point (`applyActionPinToTypedStep`),
73+
so all callers — user-provided steps, pre-steps, pre-agent-steps, and post-steps — get
74+
the check automatically without further changes.
75+
76+
---
77+
78+
*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*

pkg/cli/pr_sous_chef_workflow_contract_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func TestPRSousChefWorkflowAddCommentTargetContract(t *testing.T) {
2727
assert.Contains(t, text, "Never emit `add_comment` without a numeric target field", "Workflow must forbid targetless add_comment items")
2828
assert.Contains(t, text, "pr_number 12345", "Workflow should include a concrete add_comment pr_number example")
2929
assert.Contains(t, text, "include an explicit unresolved-reviews list", "Workflow should require explicit unresolved review listing in nudge comments")
30-
assert.Contains(t, text, "Process at most **5 PRs** per run.", "Workflow should cap per-run PR processing to 5")
30+
assert.Contains(t, text, "Process all eligible PRs per run.", "Workflow should require processing all eligible PRs")
3131
assert.Contains(t, text, "Make at most 8 tool calls total.", "Sub-agent should have a hard tool-call budget")
3232
assert.Contains(t, text, "model: sonnet", "Sub-agent should use a Sonnet model alias")
3333
assert.Contains(t, text, "skip_reason: \"sub_agent_error\"", "Workflow should skip failed sub-agent responses without retry")

pkg/workflow/action_pins.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package workflow
22

33
import (
44
"fmt"
5+
"os"
56
"strings"
67

78
actionpins "github.com/github/gh-aw/pkg/actionpins"
9+
"github.com/github/gh-aw/pkg/console"
10+
"github.com/github/gh-aw/pkg/gitutil"
811
"github.com/github/gh-aw/pkg/logger"
912
"github.com/github/gh-aw/pkg/semverutil"
1013
)
@@ -198,6 +201,68 @@ func getCachedActionPin(repo string, data *WorkflowData) string {
198201
// Step-level helpers that depend on WorkflowStep (stay in pkg/workflow)
199202
// --------------------------------------------------------------------------
200203

204+
// warnIfOutdatedActionVersion emits a warning to stderr when rawVersion is a semver
205+
// action-version tag (e.g. "v3", "v4.0.0") and a strictly newer version exists in the
206+
// embedded action pins. The check is skipped when rawVersion is already a full SHA or a
207+
// non-semver ref (branch name, etc.), and when the latest available version is not newer.
208+
//
209+
// Warnings are deduplicated per repo@version pair via data.ActionPinWarnings so that the
210+
// same step appearing in multiple places (pre-steps, steps, post-steps) only produces one
211+
// diagnostic.
212+
//
213+
// Partial version tags (e.g. "v4" without minor/patch) are only flagged when the latest
214+
// available major version is higher than the requested one; within the same major the tag
215+
// is treated as a floating reference and no warning is emitted.
216+
func warnIfOutdatedActionVersion(actionRepo, rawVersion, latestVersion string, data *WorkflowData) {
217+
if data == nil {
218+
return
219+
}
220+
221+
// SHAs are already pinned to a specific commit — no version to compare.
222+
if gitutil.IsValidFullSHA(rawVersion) {
223+
return
224+
}
225+
// Only check recognised action version tags (vN, vN.M, vN.M.P).
226+
if !semverutil.IsActionVersionTag(rawVersion) {
227+
return
228+
}
229+
230+
latestSemver := semverutil.ParseVersion(latestVersion)
231+
requestedSemver := semverutil.ParseVersion(rawVersion)
232+
if latestSemver == nil || requestedSemver == nil {
233+
return
234+
}
235+
236+
// For tags without a patch component (e.g. "@v4", "@v4.1"), treat them as
237+
// floating references that resolve to the latest compatible patch within that
238+
// major version line (for major-only tags) or minor version line (for
239+
// major.minor tags). Only warn when the latest available major version is
240+
// higher — same-major newer minors/patches are not "outdated" for a floating tag.
241+
isPartialTag := strings.Count(strings.TrimPrefix(rawVersion, "v"), ".") < 2
242+
if isPartialTag {
243+
if latestSemver.Major <= requestedSemver.Major {
244+
return
245+
}
246+
} else if !latestSemver.IsNewer(requestedSemver) {
247+
return
248+
}
249+
250+
// Deduplicate: only emit the warning once per repo@version within this compilation.
251+
cacheKey := "outdated:" + actionpins.FormatCacheKey(actionRepo, rawVersion)
252+
if data.ActionPinWarnings == nil {
253+
data.ActionPinWarnings = make(map[string]bool)
254+
}
255+
if data.ActionPinWarnings[cacheKey] {
256+
return
257+
}
258+
data.ActionPinWarnings[cacheKey] = true
259+
260+
warningMsg := fmt.Sprintf("Action %s@%s is outdated; latest available version is %s.\n Consider upgrading (update the version tag in your workflow file).",
261+
actionRepo, rawVersion, latestVersion)
262+
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(warningMsg))
263+
actionPinsLog.Printf("Outdated action version detected: %s@%s (latest: %s)", actionRepo, rawVersion, latestVersion)
264+
}
265+
201266
// applyActionPinToTypedStep applies SHA pinning to a WorkflowStep if it uses an action.
202267
// Returns a modified copy of the step with pinned references.
203268
// If the step doesn't use an action or the action is not pinned, returns the original step.
@@ -237,6 +302,11 @@ func applyActionPinToTypedStep(step *WorkflowStep, data *WorkflowData) (*Workflo
237302
// Uses strings like "repo@sha # version" are treated as already-pinned.
238303
rawVersion, _, _ := strings.Cut(version, " ")
239304

305+
// Warn if the requested version is older than the latest available in embedded pins.
306+
if latestPin, hasLatest := getLatestActionPinByRepo(actionRepo); hasLatest {
307+
warnIfOutdatedActionVersion(actionRepo, rawVersion, latestPin.Version, data)
308+
}
309+
240310
pinnedRef, err := getActionPinWithData(actionRepo, rawVersion, data)
241311
if err != nil || pinnedRef == "" {
242312
actionPinsLog.Printf("Skipping pin for %s@%s: no pin available", actionRepo, rawVersion)

0 commit comments

Comments
 (0)