Skip to content
Open
78 changes: 78 additions & 0 deletions docs/adr/43455-warn-on-outdated-action-versions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# ADR-43455: Warn on Outdated Action Versions in User-Provided Steps

**Date**: 2026-07-05
**Status**: Draft
**Deciders**: Unknown

---

### Context

AI agents that generate workflow files frequently emit `uses:` step references with stale
major versions (e.g. `actions/checkout@v3`) even when a newer version is recorded in the
embedded `action_pins.json`. Before this change, those outdated tags were silently accepted
and pinned to the corresponding (old) SHA, giving users no signal that their workflow file
should be updated. The compiler already maintains an embedded list of latest action pins,
making it the natural place to surface this discrepancy at compile time rather than leaving
it to downstream runtime surprises.

### Decision

We will add a compile-time warning (`warnIfOutdatedActionVersion`) hooked into the single
action-pinning entry point (`applyActionPinToTypedStep`) that emits a stderr diagnostic
whenever a user-supplied version tag is strictly older than the latest version in the
embedded pin database. Partial major-only tags (e.g. `@v4`) are treated as floating
within-major references and only warned on when a higher major is available. SHA refs and
non-semver refs are silently skipped to avoid false positives. Warnings are deduplicated
per `repo@version` pair within a single compilation run via `WorkflowData.ActionPinWarnings`.

### Alternatives Considered

#### Alternative 1: Hard-fail compilation on outdated version tags

Reject the workflow at compile time with an error instead of a warning, forcing users to
update the version tag before proceeding. This would provide stronger enforcement but is
too disruptive: partial major tags like `@v4` are still valid floating references within
that major series, and treating them as errors would break legitimate workflows. A warning
preserves forward motion while still surfacing the issue.

#### Alternative 2: Silently upgrade the version tag to the latest

Automatically rewrite the `uses:` field to the latest version from `action_pins.json`
without user intervention. This avoids the warning noise but silently mutates
user-provided workflow content, violating the principle that the compiler should not
change user intent without explicit confirmation. It would also interact poorly with
SHA-pinning strategies where the caller expects a specific version to be pinned.

### Consequences

#### Positive
- Users and AI agents receive an actionable compile-time warning when a stale action
version is specified, enabling them to update the workflow source rather than silently
accumulating technical debt.
- Deduplication via `ActionPinWarnings` ensures repeated steps (pre-steps, steps,
post-steps) produce exactly one diagnostic per `repo@version` pair per compilation run.
- SHA refs and branch refs are silently skipped, eliminating false positives for already-
pinned workflows.
- Partial major tags (`@vN`) are correctly treated as floating references, avoiding
spurious warnings for valid usage.

#### Negative
- The warning is written to stderr only; it is not surfaced in structured output or
returned as a typed diagnostic, so programmatic consumers cannot easily filter or act
on it.
- Warning accuracy depends on `action_pins.json` being kept up-to-date; a stale pin
database means outdated versions may not be flagged.
- Teams that intentionally pin to an older major version for compatibility will see
warnings they cannot currently suppress on a per-action basis.

#### Neutral
- The new `ActionPinWarnings` map is added to `WorkflowData`, slightly increasing the
struct's memory footprint per compilation run.
- The feature is integrated at the single pinning entry point (`applyActionPinToTypedStep`),
so all callers — user-provided steps, pre-steps, pre-agent-steps, and post-steps — get
the check automatically without further changes.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
2 changes: 1 addition & 1 deletion pkg/cli/pr_sous_chef_workflow_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestPRSousChefWorkflowAddCommentTargetContract(t *testing.T) {
assert.Contains(t, text, "Never emit `add_comment` without a numeric target field", "Workflow must forbid targetless add_comment items")
assert.Contains(t, text, "pr_number 12345", "Workflow should include a concrete add_comment pr_number example")
assert.Contains(t, text, "include an explicit unresolved-reviews list", "Workflow should require explicit unresolved review listing in nudge comments")
assert.Contains(t, text, "Process at most **5 PRs** per run.", "Workflow should cap per-run PR processing to 5")
assert.Contains(t, text, "Process all eligible PRs per run.", "Workflow should require processing all eligible PRs")
assert.Contains(t, text, "Make at most 8 tool calls total.", "Sub-agent should have a hard tool-call budget")
assert.Contains(t, text, "model: sonnet", "Sub-agent should use a Sonnet model alias")
assert.Contains(t, text, "skip_reason: \"sub_agent_error\"", "Workflow should skip failed sub-agent responses without retry")
Expand Down
70 changes: 70 additions & 0 deletions pkg/workflow/action_pins.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ package workflow

import (
"fmt"
"os"
"strings"

actionpins "github.com/github/gh-aw/pkg/actionpins"
"github.com/github/gh-aw/pkg/console"
"github.com/github/gh-aw/pkg/gitutil"
"github.com/github/gh-aw/pkg/logger"
"github.com/github/gh-aw/pkg/semverutil"
)
Expand Down Expand Up @@ -198,6 +201,68 @@ func getCachedActionPin(repo string, data *WorkflowData) string {
// Step-level helpers that depend on WorkflowStep (stay in pkg/workflow)
// --------------------------------------------------------------------------

// warnIfOutdatedActionVersion emits a warning to stderr when rawVersion is a semver
// action-version tag (e.g. "v3", "v4.0.0") and a strictly newer version exists in the
// embedded action pins. The check is skipped when rawVersion is already a full SHA or a
// non-semver ref (branch name, etc.), and when the latest available version is not newer.
//
// Warnings are deduplicated per repo@version pair via data.ActionPinWarnings so that the
// same step appearing in multiple places (pre-steps, steps, post-steps) only produces one
// diagnostic.
//
// Partial version tags (e.g. "v4" without minor/patch) are only flagged when the latest
// available major version is higher than the requested one; within the same major the tag
// is treated as a floating reference and no warning is emitted.
func warnIfOutdatedActionVersion(actionRepo, rawVersion, latestVersion string, data *WorkflowData) {
if data == nil {
return
}

// SHAs are already pinned to a specific commit — no version to compare.
if gitutil.IsValidFullSHA(rawVersion) {
return
}
// Only check recognised action version tags (vN, vN.M, vN.M.P).
if !semverutil.IsActionVersionTag(rawVersion) {
return
}

latestSemver := semverutil.ParseVersion(latestVersion)
requestedSemver := semverutil.ParseVersion(rawVersion)
if latestSemver == nil || requestedSemver == nil {
return
}

// For tags without a patch component (e.g. "@v4", "@v4.1"), treat them as
// floating references that resolve to the latest compatible patch within that
// major version line (for major-only tags) or minor version line (for
// major.minor tags). Only warn when the latest available major version is
// higher — same-major newer minors/patches are not "outdated" for a floating tag.
isPartialTag := strings.Count(strings.TrimPrefix(rawVersion, "v"), ".") < 2
if isPartialTag {
if latestSemver.Major <= requestedSemver.Major {
return
}
} else if !latestSemver.IsNewer(requestedSemver) {
return
}

// Deduplicate: only emit the warning once per repo@version within this compilation.
cacheKey := "outdated:" + actionpins.FormatCacheKey(actionRepo, rawVersion)
if data.ActionPinWarnings == nil {
data.ActionPinWarnings = make(map[string]bool)
}
if data.ActionPinWarnings[cacheKey] {
return
}
data.ActionPinWarnings[cacheKey] = true

warningMsg := fmt.Sprintf("Action %s@%s is outdated; latest available version is %s.\n Consider upgrading (update the version tag in your workflow file).",
actionRepo, rawVersion, latestVersion)
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(warningMsg))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/grill-with-docs] The warning message emitted to stderr is formatted as a single long sentence with no newline separation from the "Consider upgrading" suggestion. The PR's example output in the description shows a two-line format — the implementation collapses it into one line, which differs from what's advertised.

💡 Match the PR's documented output format

The PR description shows:

⚠ Action actions/checkout@v3 is outdated; latest available version is v7.0.0.
  Consider upgrading (update the version tag in your workflow file).

Splitting the message at the period keeps the warning scannable:

warningMsg := fmt.Sprintf("Action %s@%s is outdated; latest available version is %s.\n  Consider upgrading (update the version tag in your workflow file).",
    actionRepo, rawVersion, latestVersion)

@copilot please address this.

actionPinsLog.Printf("Outdated action version detected: %s@%s (latest: %s)", actionRepo, rawVersion, latestVersion)
}

// applyActionPinToTypedStep applies SHA pinning to a WorkflowStep if it uses an action.
// Returns a modified copy of the step with pinned references.
// If the step doesn't use an action or the action is not pinned, returns the original step.
Expand Down Expand Up @@ -237,6 +302,11 @@ func applyActionPinToTypedStep(step *WorkflowStep, data *WorkflowData) (*Workflo
// Uses strings like "repo@sha # version" are treated as already-pinned.
rawVersion, _, _ := strings.Cut(version, " ")

// Warn if the requested version is older than the latest available in embedded pins.
if latestPin, hasLatest := getLatestActionPinByRepo(actionRepo); hasLatest {
warnIfOutdatedActionVersion(actionRepo, rawVersion, latestPin.Version, data)
}

pinnedRef, err := getActionPinWithData(actionRepo, rawVersion, data)
if err != nil || pinnedRef == "" {
actionPinsLog.Printf("Skipping pin for %s@%s: no pin available", actionRepo, rawVersion)
Expand Down
Loading