Skip to content

Refresh update-time container digests and enforce commit-age cooldowns - #47990

Merged
pelikhan merged 3 commits into
mainfrom
copilot/update-command-digest-sha
Jul 25, 2026
Merged

Refresh update-time container digests and enforce commit-age cooldowns#47990
pelikhan merged 3 commits into
mainfrom
copilot/update-command-digest-sha

Conversation

Copilot AI commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

gh aw update could leave container digest pins stale after refs moved, and it would immediately adopt fresh branch/head commits even when the resolved version and commit were too new. This change makes update resolution keep container pins aligned with updated refs and applies a 3-day cooldown to moving commit-based workflow sources.

  • Commit-based workflow source cooldown

    • Branch-tracked and SHA-tracked workflow sources now resolve commit metadata, not just the head SHA.
    • Updates to moving refs are skipped when the candidate commit is newer than the fixed 3-day cooldown window.
    • The existing release cooldown remains separate; tag-based release updates continue to use release publication time.
  • Container digest refresh during update

    • gh aw update now re-resolves container digests for images already present in actions-lock.json, instead of treating existing pins as always current.
    • This keeps pinned_image entries synchronized when workflow/action updates move a container tag to a new manifest digest.
  • Lockfile image normalization

    • Digest-pinned lockfile refs are normalized back to their base image ref before cache reconciliation.
    • This avoids mismatches between lockfile references like image:tag@sha256:... and cache keys stored as image:tag.
  • Test coverage

    • Added focused coverage for:
      • branch/default-branch commit cooldown behavior
      • digest-pinned lockfile normalization
      • refresh of existing container pins

Example of the normalized pin flow:

base, _, _ := strings.Cut(img, "@sha256:")
imageSet[base] = struct{}{}

pinnedImage := image + "@" + digest
actionCache.SetContainerPin(image, digest, pinnedImage)

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title Fix update command container pin refresh and commit cooldown Refresh update-time container digests and enforce commit-age cooldowns Jul 25, 2026
Copilot AI requested a review from pelikhan July 25, 2026 11:41
@pelikhan
pelikhan marked this pull request as ready for review July 25, 2026 11:49
Copilot AI review requested due to automatic review settings July 25, 2026 11:49

Copilot AI left a comment

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.

Pull request overview

Adds cooldown enforcement for moving workflow refs and refreshes container digest pins during updates.

Changes:

  • Resolves branch commit metadata and applies a three-day cooldown.
  • Normalizes and refreshes container image digest pins.
  • Adds focused unit and integration coverage.
Show a summary per file
File Description
pkg/cli/update_workflows.go Adds commit-aware ref resolution and cooldown checks.
pkg/cli/update_workflows_test.go Tests branch and SHA cooldown behavior.
pkg/cli/update_integration_test.go Verifies commit metadata retrieval.
pkg/cli/update_cooldown.go Defines commit cooldown logic.
pkg/cli/update_cooldown_test.go Tests commit cooldown calculations.
pkg/cli/update_container_pins.go Normalizes and optionally refreshes container pins.
pkg/cli/update_container_pins_test.go Tests normalization and digest refresh.
pkg/cli/update_command.go Enables pin refresh during updates.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comments suppressed due to low confidence (1)

pkg/cli/update_workflows.go:341

  • The default-branch path also omits the documented actions/* and github/* cooldown exemption, so SHA-tracked workflows from those namespaces no longer update immediately as promised by the shared flag contract. Guard this check with !isExemptFromCoolDown(repo) as the release paths do.
	if result := checkCommitCoolDownWithDate(repo, defaultBranch, latestCommit.CommittedAt, coolDown); result.InCoolDown {
		cooldownLog.Printf("Workflow source %s default branch %s: %s", repo, defaultBranch, result.Message)
		fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping commit candidate %s@%s: %s", repo, defaultBranch, result.Message)))
		return currentSHA, nil
	}
  • Files reviewed: 8/8 changed files
  • Comments generated: 2
  • Review effort level: Medium

Comment thread pkg/cli/update_workflows.go Outdated
Comment on lines +302 to +305
if result := checkCommitCoolDownWithDate(repo, currentRef, latestCommit.CommittedAt, commitCoolDown); result.InCoolDown {
cooldownLog.Printf("Workflow source %s branch %s: %s", repo, currentRef, result.Message)
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping commit candidate %s@%s: %s", repo, currentRef, result.Message)))
return currentRef, nil

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d9d7bb3: cooldown-blocked refs now return an explicit blocked result, and redirect resolution exits before any workflow download/redirect-following when blocked.

Comment on lines +301 to +306
commitCoolDown := effectiveCommitCoolDown(coolDown)
if result := checkCommitCoolDownWithDate(repo, currentRef, latestCommit.CommittedAt, commitCoolDown); result.InCoolDown {
cooldownLog.Printf("Workflow source %s branch %s: %s", repo, currentRef, result.Message)
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Skipping commit candidate %s@%s: %s", repo, currentRef, result.Message)))
return currentRef, nil
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d9d7bb3: commit cooldown checks now apply the existing actions/* and github/* exemption before evaluating branch/default-branch commit age.

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions github-actions Bot left a comment

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.

Review: Refresh update-time container digests and enforce commit-age cooldowns

The overall design is solid and well-tested. Two pre-existing inline comments already flag the two blocking issues:

1. Stale branch ref returned instead of current SHA on cooldown (update_workflows.go ~line 305)
When the current ref is a commit SHA and the latest default-branch commit is in cooldown, resolveLatestCommitFromDefaultBranchWithDeps correctly returns currentSHA. But in the branch path of resolveLatestRefWithDeps, the function returns the branch name string (e.g. main) on cooldown rather than the callers original SHA — the mutable branch ref is written back to the source, which effectively adopts the branch as the new ref despite the cooldown.

2. Commit cooldown skips the actions/* / github/* exemption (update_workflows.go ~line 306)
effectiveCommitCoolDown applies the 3-day window unconditionally. isExemptFromCoolDown, which exempts actions/* and github/* repos per the --cool-down flag documentation, is never consulted — this breaks the existing contract for those namespaces.

Both issues need to be resolved before merge.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 · 27.2 AIC · ⌖ 7.4 AIC · ⊞ 5K

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 85/100 — Excellent

Analyzed 25 test(s): 18 design, 7 implementation, 0 violation(s).

📊 Metrics (25 tests)
Metric Value
Analyzed 25 (Go: 25, JS: 0)
✅ Design 18 (72%)
⚠️ Implementation 7 (28%)
Edge/error coverage 16 (64%)
Duplicate clusters 0
Inflation No (avg 1.25:1)
🚨 Violations 0
✅ Highlights

Behavioral contracts enforced:

  • Container pin preservation on digest-pinned updates (TestUpdateContainerPins_PinnedLockFilesPreserveContainerPins) — regression test for prior bug where digests were being pruned incorrectly
  • Stale entry pruning (TestUpdateContainerPins_PrunesStaleEntries) — ensures old pins don't accumulate
  • Release cooldown fallback logic (TestResolveLatestRelease_CooldownFallback) — version selection respects time windows
  • Prerelease filtering (TestResolveLatestRelease_PrereleaseTagsSkipped, others) — semver contract: stable always preferred
  • Commit age cooldown (TestCheckCommitCoolDownWithDate_*) — time-based update gating

Strengths:

  • 15/25 table-driven tests (60%) for maintainability and scenario coverage
  • Zero mock library violations (all deps use acceptable patterns: dependency injection + closure mocks for I/O)
  • All Go test files have correct //go:build !integration tags
  • 16 tests with explicit error/edge-case assertions
  • New update_cooldown_test.go file with comprehensive cooldown logic coverage (10 tests)

Edge cases covered:

  • Digest normalization (base vs @sha256:pinned formats)
  • Clock skew in future timestamps
  • Negative/zero durations treated as disabled
  • Missing directories handled gracefully
  • Prerelease ordering with invalid current versions

Verdict

Passed. 28% implementation tests (threshold: 30%). Zero violations. High-confidence behavioral coverage across six key design contracts.

🧪 Test quality analysis by Test Quality Sentinel · haiku45 · 23.2 AIC · ⌖ 7.65 AIC · ⊞ 8.1K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

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.

✅ Test Quality Sentinel: 85/100 Excellent. 72% design tests (18/25), 28% implementation tests (threshold: 30%). Zero violations. Strong behavioral coverage across container pin preservation, cooldown logic, and semver handling.

@github-actions

Copy link
Copy Markdown
Contributor

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (296 new lines in pkg/) but does not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/47990-refresh-container-digests-and-commit-cooldown.md — review and complete it before merging.

🔒 This PR cannot merge until an ADR is linked in the PR body.

📋 What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff
  2. Complete the missing sections — add context the AI could not infer, refine the decision rationale, and list real alternatives you considered
  3. Commit the finalized ADR to docs/adr/ on your branch
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-47990: Refresh Container Digests and Commit Cooldown

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

❓ Why ADRs Matter

"AI made me procrastinate on key design decisions. Because refactoring was cheap, I could always say 'I'll deal with this later.' Deferring decisions corroded my ability to think clearly."

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you.

📋 Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number (e.g., 47990-refresh-container-digests-and-commit-cooldown.md for PR #47990).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · sonnet46 · 53.4 AIC · ⌖ 10.1 AIC · ⊞ 8.5K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

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.

REQUEST_CHANGES — Two issues need addressing before merge.

Blocking findings

1. Hard error on missing commit date (resilience regression)

parseLatestBranchCommitInfo returns a hard error when the GitHub API returns a commit with no date. The old code only needed the SHA and always succeeded. This can break gh aw update for any workflow source whose repo returns a dateless commit. The fix is to treat a missing date as CommittedAt = zero (which the cooldown check handles correctly — zero time produces a very large age and passes immediately). See inline comment on line 513.

2. effectiveCommitCoolDown silently ignores user's --cooldown value

Any positive --cooldown value (even 1h) is silently overridden to the hardcoded 3-day window for branch/commit sources. --cooldown 0 is the only way to reduce it. This violates the documented contract of the flag and surprises users who set shorter cooldowns expecting them to apply everywhere. See inline comment on line 116.

🔎 Code quality review by PR Code Quality Reviewer · sonnet46 · 69.9 AIC · ⌖ 4.85 AIC · ⊞ 5.7K
Comment /review to run again

Comment thread pkg/cli/update_workflows.go Outdated
dateStr = result.Commit.Author.Date
}
if dateStr == "" {
return latestBranchCommitInfo{}, fmt.Errorf("empty commit date returned for branch %s", branch)

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.

Hard error on missing commit date will break workflow updates for any commit lacking date fields.

💡 Suggested fix

If both committer and author dates are absent, treat it as CommittedAt = zero and skip the cooldown check rather than returning an error. A zero CommittedAt will produce a very large age in checkCommitCoolDownWithDate, so the commit will always pass cooldown — safe and graceful.

if dateStr == "" {
    // No date available: skip cooldown, return just the SHA.
    return latestBranchCommitInfo{SHA: result.SHA}, nil
}

Returning a hard error here means that any workflow sourced from a repo where GitHub returns a commit with no date (e.g., certain merge-queue commits, squash commits from some forges, or API edge cases) will cause the entire gh aw update to fail for that source. The old getLatestBranchCommitSHA only required the SHA and always succeeded — this is a clear resilience regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d9d7bb3: missing committer date no longer hard-fails update resolution. We now return SHA-only metadata and allow update flow to continue without applying commit cooldown for that commit date.

if coolDown <= 0 {
return 0
}
return commitRefCoolDown

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.

effectiveCommitCoolDown silently overrides the user-configured cooldown, always applying the hardcoded 3-day window regardless of what --cooldown was set to.

💡 Details

A user who sets --cooldown 1h expecting hourly updates everywhere will get 1-hour release cooldowns but 3-day commit cooldowns with no warning or documentation at the call site. The coolDownFlagUsage string says the flag controls the cooldown period, and actions/*/github/* repos are documented as the only exemptions — the user has no way to know their value is silently ignored for branch/commit sources.

This is especially surprising when --cooldown 0 disables cooldowns (handled correctly) but any nonzero value, including values shorter than 3 days, still locks to 3 days.

Consider either:

  1. Using min(coolDown, commitRefCoolDown) so the user can shorten but not eliminate the minimum safety window, or
  2. Documenting the override prominently in coolDownFlagUsage so users know commit sources always use the 3-day floor.
// Option 1: respect user intent but enforce a floor
func effectiveCommitCoolDown(coolDown time.Duration) time.Duration {
    if coolDown <= 0 {
        return 0
    }
    return max(coolDown, commitRefCoolDown)
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d9d7bb3: kept the fixed 3-day commit cooldown behavior and documented it directly in coolDownFlagUsage so the override is explicit when cool-down is enabled.

@github-actions github-actions Bot left a comment

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.

Skills-Based Review 🧠

Applied /diagnosing-bugs and /codebase-design — requesting changes on two correctness issues.

📋 Key Themes & Findings

Issues

  1. Author-date fallback bypasses cooldown (update_workflows.go:528) — cherry-picked commits carry an old author date that can be years behind the committer date, silently defeating the 3-day window. Prefer committer date or error when absent.

  2. imageSet normalization mismatch (update_container_pins.go:113) — imageSet built from raw images (which may contain @sha256: suffixes) is passed to PruneStaleContainerPins, whose keys are base-tag-only. A digest-pinned lock file will cause valid pins to be pruned incorrectly.

  3. effectiveCommitCoolDown interface surprise (update_cooldown.go:116) — the function accepts any duration but only <= 0 is meaningful; every positive value maps to the fixed 3-day constant, which is unintuitive. The signature implies the caller's value is respected.

Positive Highlights

  • ✅ Clean workflowUpdateDeps injection for testability — makes branch/default-branch paths fully unit-testable without network calls.
  • ✅ Solid test coverage for the new cooldown paths (TestResolveLatestRef_BranchCommitCooldown, TestResolveLatestRef_CommitCooldown).
  • ✅ Correct API endpoint switch from /branches/{branch} to /commits/{branch} to retrieve commit metadata.
  • ✅ Lockfile normalization in collectImagesFromLockFiles is the right place to strip digest suffixes before collecting image names.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 · 54.4 AIC · ⌖ 4.93 AIC · ⊞ 6.7K
Comment /matt to run again

getRepoDefaultBranch func(ctx context.Context, repo string) (string, error)
getLatestBranchCommit func(ctx context.Context, repo, branch string) (latestBranchCommitInfo, error)
}

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.

[/codebase-design] Silently falling back to author date can bypass the cooldown for cherry-picks — the author date may be years older than the committer date.

💡 Suggested fix

Return an error when committer.date is absent, or document the fallback contract with a test case where author date ≪ committer date:

dateStr := result.Commit.Committer.Date
if dateStr == "" {
    return latestBranchCommitInfo{}, fmt.Errorf("empty committer date returned for branch %s", branch)
}

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d9d7bb3: removed author-date fallback for commit cooldown. Resolution now uses committer.date only; when it is absent we return SHA-only info (no hard failure) and added test coverage for the missing-committer-date case.

Comment thread pkg/cli/update_container_pins.go Outdated
base, _, _ := strings.Cut(img, "@sha256:")
imageSet[base] = struct {
}{}
imageSet[img] = struct{}{}

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.

[/diagnosing-bugs] imageSet is built from raw (un-normalized) images entries but PruneStaleContainerPins uses it to match against cache keys that are stored without the @sha256: suffix — if any image in collectImagesFromLockFiles still returns a digest-pinned ref this prune step will never match and stale pins will accumulate.

💡 Suggested fix

Normalize here exactly like collectImagesFromLockFiles does:

for _, img := range images {
    base, _, _ := strings.Cut(img, "`@sha256`:")
    imageSet[base] = struct{}{}
}

Add a unit test with a pre-seeded cache entry whose key matches the base tag, then call updateContainerPins with a lock file that only references the digest-pinned form, and assert the entry is retained (not pruned).

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d9d7bb3: imageSet normalization now strips @sha256 suffixes before prune matching, and coverage includes the digest-pinned lockfile retention path.

if coolDown <= 0 {
return 0
}
return commitRefCoolDown

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.

[/codebase-design] effectiveCommitCoolDown silently ignores the user-supplied --cool-down value and always returns the hard-coded 3-day constant — this is a surprising interface: the function signature implies the input duration affects the result, but only <= 0 is significant.

💡 Suggestion

Either rename to commitCoolDownPeriod(disabled bool) to make the binary nature explicit, or document in coolDownFlagUsage that commit cooldowns are always fixed at 3 days regardless of the flag value. A test asserting the user passes 24h but gets 72h would make this contract visible.

@copilot please address this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in d9d7bb3: documented the fixed 3-day commit cooldown contract in coolDownFlagUsage and kept the existing test that asserts nonzero user cooldown values map to the fixed commit cooldown.

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

…ted repos

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
@pelikhan
pelikhan merged commit ec4cabb into main Jul 25, 2026
23 checks passed
@pelikhan
pelikhan deleted the copilot/update-command-digest-sha branch July 25, 2026 13:56
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.83.4

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants