diff --git a/docs/adr/47990-refresh-container-digests-and-commit-cooldown.md b/docs/adr/47990-refresh-container-digests-and-commit-cooldown.md new file mode 100644 index 00000000000..5c9dd95163e --- /dev/null +++ b/docs/adr/47990-refresh-container-digests-and-commit-cooldown.md @@ -0,0 +1,62 @@ +# ADR-47990: Refresh Container Digests on Update and Enforce Commit-Age Cooldown + +**Date**: 2026-07-25 +**Status**: Draft +**Deciders**: Unknown + +--- + +### Context + +The `gh aw update` command manages two categories of pinned references: container image digest pins stored in `actions-lock.json`, and workflow source refs (branch, commit SHA, or semantic-version tag). Two independent problems were discovered: + +1. **Stale container pins**: Container image tags (e.g., `image:tag`) can move to a new manifest digest after the initial pin is written. The previous implementation treated existing `pinned_image` entries as always current and skipped re-resolving them, leaving lock files referencing outdated digests when upstream images were republished. + +2. **Unsafe moving-ref adoption**: Branch-tracked and commit-SHA-tracked workflow sources had no commit-age gate. A freshly pushed commit (seconds or minutes old) could be adopted immediately during an update run, creating operational risk analogous to the release cooldown problem already solved for tag-based sources. + +Additionally, lock files compiled with digest-pinned references (`image:tag@sha256:...`) were not being normalized back to their base image ref before cache key lookups, causing reconciliation mismatches. + +### Decision + +We decided to (a) always re-resolve container digests for images already present in `actions-lock.json` during `gh aw update` runs (controlled by a new `refreshExisting` option on the internal `updateContainerPins` function), and (b) enforce a fixed 3-day commit-age cooldown on branch-tracked and commit-SHA-tracked workflow sources, gated by the same cooldown flag that controls release cooldowns. The commit cooldown is non-configurable (always 3 days) except when the user explicitly disables cooldowns entirely. Digest-pinned lock file image refs are normalized to their base tag before cache reconciliation to eliminate key mismatches. + +### Alternatives Considered + +#### Alternative 1: Manual Digest Refresh (opt-in flag) + +Expose a `--refresh-pins` flag so users can explicitly request container digest re-resolution rather than doing it on every update run. This avoids extra `docker manifest inspect` (or equivalent) calls in normal update flows and gives users explicit control. + +Not chosen because the goal of `gh aw update` is to keep the lock file fully synchronized. Silent staleness — where a container digest is out of date but nothing prompts the user to notice — is the worse failure mode. The Docker call is already non-fatal (fails gracefully when Docker is unavailable), so the cost of always refreshing is low. + +#### Alternative 2: Time-Based Cache Invalidation for Existing Pins + +Instead of always re-resolving, introduce a TTL (e.g., 24 hours) on cached container pins: refresh a pin only if it was last resolved more than TTL ago, storing a timestamp alongside the digest. + +Not chosen because it adds persistent timestamp state to `actions-lock.json`, complicating the lock file schema and migration. It also creates a window of guaranteed staleness equal to the TTL, whereas always re-resolving on update provides the freshest state at no additional schema cost. + +#### Alternative 3: Per-Ref Commit Cooldown Configured by the User + +Allow the commit cooldown to be configured separately from the release cooldown (e.g., `--commit-cooldown 2d`), giving fine-grained control over branch vs. release cadence. + +Not chosen because the immediate need is to close a safety gap (too-fresh commits being adopted), and a fixed 3-day constant is already well-understood from the existing release cooldown precedent. Adding a second configurable flag increases UX complexity without clear current demand; this can be revisited if operators need different thresholds. + +### Consequences + +#### Positive +- Container pins in `actions-lock.json` stay synchronized with upstream image manifest changes across update runs, removing a category of silent staleness. +- Branch-tracked and commit-SHA-tracked workflow sources are subject to the same stability guarantee as tag-based releases: at least 3 days must elapse before a moving ref is adopted. +- Digest-pinned image refs in lock files are normalized before cache key lookup, eliminating reconciliation mismatches between `image:tag@sha256:...` and `image:tag` keys. +- Dependency-injected `containerPinUpdateDeps` and extended `workflowUpdateDeps` structs make both behaviors unit-testable without Docker or live GitHub API calls. + +#### Negative +- Every `gh aw update` run now makes one additional `docker manifest inspect` (or equivalent) call per pinned container image, even when the digest has not changed. In environments where Docker is unavailable this is already non-fatal, but it does add latency and noise in verbose output. +- The commit-age cooldown is not user-configurable independently of the release cooldown. Users who want aggressive commit tracking (e.g., CI environments where they own both sides of the workflow repo) cannot lower the 3-day window without disabling all cooldowns. +- The `getLatestBranchCommitSHA` API call was changed to the `/repos/{repo}/commits/{ref}` endpoint (which returns richer JSON) rather than `/repos/{repo}/branches/{branch}` with a `--jq .commit.sha` filter. This increases response payload size and may surface different error shapes for non-existent refs. + +#### Neutral +- The public `UpdateContainerPins` function retains its existing signature and defaults (`refreshExisting: false`) for callers outside of `RunUpdateWorkflows`, preserving backward compatibility for any other call sites. +- Branch-commit cache (`branchCommitCache`) now stores `latestBranchCommitInfo` structs (SHA + CommittedAt) instead of plain strings, which is a breaking change to the in-memory cache type — harmless since the cache is process-scoped and cleared at the start of each update run. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/cli/update_command.go b/pkg/cli/update_command.go index a7922bbdc02..617935dbaeb 100644 --- a/pkg/cli/update_command.go +++ b/pkg/cli/update_command.go @@ -232,7 +232,7 @@ func RunUpdateWorkflows(ctx context.Context, opts UpdateWorkflowsOptions) error // already reflect the current AWF version; stale pins from superseded versions are pruned // and new versions are resolved in a single pass. updateLog.Print("Updating container image digest pins") - newContainerPins, err := UpdateContainerPins(ctx, opts.WorkflowsDir, opts.Verbose) + newContainerPins, err := updateContainerPins(ctx, defaultContainerPinUpdateDeps(), opts.WorkflowsDir, opts.Verbose, containerPinUpdateOptions{refreshExisting: true}) if err != nil { // Non-fatal: Docker may not be available in all environments. fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Warning: Failed to update container pins: %v", err))) diff --git a/pkg/cli/update_container_pins.go b/pkg/cli/update_container_pins.go index cf53ebfa3d4..32a8d22e006 100644 --- a/pkg/cli/update_container_pins.go +++ b/pkg/cli/update_container_pins.go @@ -43,6 +43,18 @@ var dockerImagesArgPattern = regexp.MustCompile(`download_docker_images\.sh"?\s+ // in practice and are not expected here. var buildxDigestPattern = regexp.MustCompile(`(?m)^Digest:\s+(sha256:[a-f0-9]{64})`) +type containerPinUpdateDeps struct { + fetchDigest func(ctx context.Context, image string, verbose bool) (string, error) +} + +type containerPinUpdateOptions struct { + refreshExisting bool +} + +func defaultContainerPinUpdateDeps() containerPinUpdateDeps { + return containerPinUpdateDeps{fetchDigest: fetchContainerDigest} +} + // UpdateContainerPins resolves SHA-256 digests for all container images referenced in // the compiled lock files under workflowDir and stores the pins in // .github/aw/actions-lock.json. @@ -57,6 +69,10 @@ var buildxDigestPattern = regexp.MustCompile(`(?m)^Digest:\s+(sha256:[a-f0-9]{64 // Returns true when new container pins were added and lock files should be // recompiled to embed the digest references. func UpdateContainerPins(ctx context.Context, workflowDir string, verbose bool) (bool, error) { + return updateContainerPins(ctx, defaultContainerPinUpdateDeps(), workflowDir, verbose, containerPinUpdateOptions{}) +} + +func updateContainerPins(ctx context.Context, deps containerPinUpdateDeps, workflowDir string, verbose bool, opts containerPinUpdateOptions) (bool, error) { containerPinsLog.Print("Starting container pin update") if verbose { @@ -92,18 +108,10 @@ func UpdateContainerPins(ctx context.Context, workflowDir string, verbose bool) } } - // Build a set of base image tags (without @sha256: digest suffix) currently - // referenced in the compiled lock files so that stale entries (e.g. superseded - // AWF versions) can be pruned. Lock files that were previously compiled may - // already embed pinned references (image:tag@sha256:...), so we strip the - // digest before comparing against container pin keys, which always use the - // base tag as the key. - imageSet := make(map[string]struct { - }, len(images)) + imageSet := make(map[string]struct{}, len(images)) for _, img := range images { base, _, _ := strings.Cut(img, "@sha256:") - imageSet[base] = struct { - }{} + imageSet[base] = struct{}{} } // Remove any container pin entries that are no longer referenced by the @@ -124,23 +132,16 @@ func UpdateContainerPins(ctx context.Context, workflowDir string, verbose bool) var skippedImages []string for _, image := range images { - // Images already containing @sha256: are immutably pinned — skip them. - if strings.Contains(image, "@sha256:") { - skippedImages = append(skippedImages, image) - continue - } - - // Check if we already have a valid pin for this image in the cache. - if pin, ok := actionCache.GetContainerPin(image); ok && pin.Digest != "" { + existingPin, hasExistingPin := actionCache.GetContainerPin(image) + if hasExistingPin && existingPin.Digest != "" && !opts.refreshExisting { if verbose { - fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("%s already pinned: %s", image, pin.Digest))) + fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("%s already pinned: %s", image, existingPin.Digest))) } skippedImages = append(skippedImages, image) continue } - // Attempt to resolve the digest without pulling. - digest, resolveErr := fetchContainerDigest(ctx, image, verbose) + digest, resolveErr := deps.fetchDigest(ctx, image, verbose) if resolveErr != nil { containerPinsLog.Printf("Failed to resolve digest for %s: %v", image, resolveErr) failedImages = append(failedImages, imageFailure{image: image, reason: resolveErr.Error()}) @@ -148,6 +149,13 @@ func UpdateContainerPins(ctx context.Context, workflowDir string, verbose bool) } pinnedImage := image + "@" + digest + if hasExistingPin && existingPin.Digest == digest && existingPin.PinnedImage == pinnedImage { + if verbose { + fmt.Fprintln(os.Stderr, console.FormatVerboseMessage(fmt.Sprintf("%s already pinned: %s", image, digest))) + } + skippedImages = append(skippedImages, image) + continue + } actionCache.SetContainerPin(image, digest, pinnedImage) updatedImages = append(updatedImages, pinnedEntry{image: image, pinnedImage: pinnedImage}) @@ -160,7 +168,7 @@ func UpdateContainerPins(ctx context.Context, workflowDir string, verbose bool) fmt.Fprintln(os.Stderr, "") if len(updatedImages) > 0 { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Pinned %d container image(s):", len(updatedImages)))) + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Updated %d container image pin(s):", len(updatedImages)))) for _, entry := range updatedImages { fmt.Fprintln(os.Stderr, console.FormatListItem(entry.pinnedImage)) } @@ -231,9 +239,8 @@ func collectImagesFromLockFiles(workflowDir string) ([]string, error) { continue } for img := range strings.FieldsSeq(matches[1]) { - if img != "" { - imageSet[img] = struct { - }{} + if base, _, _ := strings.Cut(img, "@sha256:"); base != "" { + imageSet[base] = struct{}{} } } } diff --git a/pkg/cli/update_container_pins_test.go b/pkg/cli/update_container_pins_test.go index 76b77bda10c..b910b027d95 100644 --- a/pkg/cli/update_container_pins_test.go +++ b/pkg/cli/update_container_pins_test.go @@ -47,6 +47,20 @@ jobs: "node:lts-alpine", }, }, + { + name: "digest pinned images normalized to base ref", + lockFileContent: `name: test +jobs: + setup: + steps: + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/github-mcp-server:v0.32.0@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa node:lts-alpine@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb +`, + expectedImages: []string{ + "ghcr.io/github/github-mcp-server:v0.32.0", + "node:lts-alpine", + }, + }, { name: "no docker images in lock file", lockFileContent: `name: test @@ -312,3 +326,55 @@ jobs: assert.NotContains(t, string(data), "0.27.0", "stale version should not appear in saved file") assert.Contains(t, string(data), "0.27.2", "current version should be in saved file") } + +func TestUpdateContainerPins_RefreshExistingPin(t *testing.T) { + tmpDir := t.TempDir() + + actionsLockDir := filepath.Join(tmpDir, ".github", "aw") + require.NoError(t, os.MkdirAll(actionsLockDir, 0755)) + actionsLockContent := `{ + "entries": {}, + "containers": { + "ghcr.io/github/github-mcp-server:v0.32.0": { + "image": "ghcr.io/github/github-mcp-server:v0.32.0", + "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "pinned_image": "ghcr.io/github/github-mcp-server:v0.32.0@sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + } +} +` + require.NoError(t, os.WriteFile(filepath.Join(actionsLockDir, "actions-lock.json"), []byte(actionsLockContent), 0644)) + + workflowsDir := filepath.Join(tmpDir, ".github", "workflows") + require.NoError(t, os.MkdirAll(workflowsDir, 0755)) + lockFileContent := `name: test +jobs: + setup: + steps: + - name: Download container images + run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/github-mcp-server:v0.32.0@sha256:1111111111111111111111111111111111111111111111111111111111111111 +` + require.NoError(t, os.WriteFile(filepath.Join(workflowsDir, "test.lock.yml"), []byte(lockFileContent), 0644)) + + originalDir, _ := os.Getwd() + defer os.Chdir(originalDir) //nolint:errcheck + require.NoError(t, os.Chdir(tmpDir)) + + deps := defaultContainerPinUpdateDeps() + const refreshedDigest = "sha256:2222222222222222222222222222222222222222222222222222222222222222" + deps.fetchDigest = func(_ context.Context, image string, verbose bool) (string, error) { + require.Equal(t, "ghcr.io/github/github-mcp-server:v0.32.0", image) + return refreshedDigest, nil + } + + changed, err := updateContainerPins(context.Background(), deps, workflowsDir, false, containerPinUpdateOptions{refreshExisting: true}) + require.NoError(t, err) + assert.True(t, changed, "refreshing an existing pin should report a change") + + cache := workflow.NewActionCache(tmpDir) + require.NoError(t, cache.Load()) + pin, ok := cache.GetContainerPin("ghcr.io/github/github-mcp-server:v0.32.0") + require.True(t, ok) + assert.Equal(t, refreshedDigest, pin.Digest) + assert.Equal(t, "ghcr.io/github/github-mcp-server:v0.32.0@"+refreshedDigest, pin.PinnedImage) +} diff --git a/pkg/cli/update_cooldown.go b/pkg/cli/update_cooldown.go index 4a05a93030c..b2362013c13 100644 --- a/pkg/cli/update_cooldown.go +++ b/pkg/cli/update_cooldown.go @@ -16,7 +16,8 @@ import ( var cooldownLog = logger.New("cli:update_cooldown") -const coolDownFlagUsage = "Cool-down period before applying a new release (e.g., 7d, 24h, 0 to disable). Does not apply to actions/* or github/* repositories" +const coolDownFlagUsage = "Cool-down period before applying a new release (e.g., 7d, 24h, 0 to disable). Does not apply to actions/* or github/* repositories. Commit-tracked sources use a fixed 3d cooldown when cool-down is enabled." +const commitRefCoolDown = 3 * 24 * time.Hour // parseCoolDownFlag parses a cooldown duration string. // Accepts day-suffix notation ("7d") or Go duration format ("168h", "0"). @@ -105,6 +106,35 @@ func checkReleaseCoolDownWithDate(repo, tag string, publishedAt time.Time, coolD return coolDownCheckResult{InCoolDown: true, Message: msg} } +// effectiveCommitCoolDown returns the commit-age cooldown used for branch- and +// commit-tracked workflow sources. Commit cooldown defaults to 3 days and can +// be disabled only when the user explicitly disables cooldowns entirely. +func effectiveCommitCoolDown(coolDown time.Duration) time.Duration { + if coolDown <= 0 { + return 0 + } + return commitRefCoolDown +} + +// checkCommitCoolDownWithDate checks whether a moving branch/default-branch +// commit is old enough to adopt during update resolution. +func checkCommitCoolDownWithDate(repo, ref string, committedAt time.Time, coolDown time.Duration) coolDownCheckResult { + if coolDown <= 0 { + return coolDownCheckResult{} + } + age := max(time.Since(committedAt), 0) + if age >= coolDown { + return coolDownCheckResult{} + } + remaining := coolDown - age + ageStr := formatCoolDownDuration(age) + remainingStr := formatCoolDownDuration(remaining) + periodStr := formatCoolDownDuration(coolDown) + msg := fmt.Sprintf("%s@%s latest commit is %s old and needs to cool down (%s remaining out of %s cooldown period)", + repo, ref, ageStr, remainingStr, periodStr) + return coolDownCheckResult{InCoolDown: true, Message: msg} +} + // checkReleaseCoolDown returns a result indicating whether a release tag is // within the cooldown window and should be skipped. // The result always includes the fetched PublishedAt date (when available) so that diff --git a/pkg/cli/update_cooldown_test.go b/pkg/cli/update_cooldown_test.go index 152b98afe73..e4b8d6463a0 100644 --- a/pkg/cli/update_cooldown_test.go +++ b/pkg/cli/update_cooldown_test.go @@ -230,3 +230,38 @@ func TestFormatCoolDownDuration(t *testing.T) { }) } } + +func TestEffectiveCommitCoolDown(t *testing.T) { + tests := []struct { + name string + input time.Duration + expected time.Duration + }{ + {name: "disabled when zero", input: 0, expected: 0}, + {name: "disabled when negative", input: -1 * time.Hour, expected: 0}, + {name: "fixed three day cooldown", input: 7 * 24 * time.Hour, expected: 3 * 24 * time.Hour}, + {name: "explicit shorter release cooldown still uses three days", input: 24 * time.Hour, expected: 3 * 24 * time.Hour}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, effectiveCommitCoolDown(tt.input)) + }) + } +} + +func TestCheckCommitCoolDownWithDate(t *testing.T) { + committedAt := time.Now().Add(-2 * 24 * time.Hour) + result := checkCommitCoolDownWithDate("owner/repo", "main", committedAt, 3*24*time.Hour) + assert.True(t, result.InCoolDown, "commit 2d old should be in cooldown with 3d window") + assert.Contains(t, result.Message, "owner/repo") + assert.Contains(t, result.Message, "main") + assert.Contains(t, result.Message, "cool down") +} + +func TestCheckCommitCoolDownWithDate_NotInCoolDown(t *testing.T) { + committedAt := time.Now().Add(-4 * 24 * time.Hour) + result := checkCommitCoolDownWithDate("owner/repo", "main", committedAt, 3*24*time.Hour) + assert.False(t, result.InCoolDown, "commit 4d old should not be in cooldown with 3d window") + assert.Empty(t, result.Message) +} diff --git a/pkg/cli/update_integration_test.go b/pkg/cli/update_integration_test.go index ca534119034..916d2f096b7 100644 --- a/pkg/cli/update_integration_test.go +++ b/pkg/cli/update_integration_test.go @@ -440,13 +440,14 @@ func TestGetRepoDefaultBranch_Integration(t *testing.T) { assert.Equal(t, "main", branch, "actions/checkout default branch should be 'main'") } -// TestGetLatestBranchCommitSHA_Integration verifies fetching the latest commit SHA for a branch. -func TestGetLatestBranchCommitSHA_Integration(t *testing.T) { +// TestGetLatestBranchCommitInfo_Integration verifies fetching the latest commit info for a branch. +func TestGetLatestBranchCommitInfo_Integration(t *testing.T) { skipWithoutGitHubAuth(t) - sha, err := getLatestBranchCommitSHA(context.Background(), "actions/checkout", "main") - require.NoError(t, err, "Should fetch latest commit SHA for actions/checkout main branch") - assert.True(t, IsCommitSHA(sha), "Result should be a 40-char commit SHA, got: %s", sha) + info, err := getLatestBranchCommitInfo(context.Background(), "actions/checkout", "main") + require.NoError(t, err, "Should fetch latest commit info for actions/checkout main branch") + assert.True(t, IsCommitSHA(info.SHA), "Result should be a 40-char commit SHA, got: %s", info.SHA) + assert.False(t, info.CommittedAt.IsZero(), "CommittedAt should be populated") } // --- Org Dry-Run Integration Tests --- diff --git a/pkg/cli/update_manifest.go b/pkg/cli/update_manifest.go index 2ef306cf2c6..eb93a559664 100644 --- a/pkg/cli/update_manifest.go +++ b/pkg/cli/update_manifest.go @@ -83,7 +83,7 @@ func updateManifestWorkflowGroup(ctx context.Context, source string, grouped []* if currentRef == "" { currentRef = "main" } - latestRef, err := resolveLatestRefFn(ctx, repoSpec.RepoSlug, currentRef, opts.AllowMajor, opts.Verbose, opts.CoolDown) + latestRefResult, err := resolveLatestRefFn(ctx, repoSpec.RepoSlug, currentRef, opts.AllowMajor, opts.Verbose, opts.CoolDown) if err != nil { updateManifestLog.Printf("Failed to resolve latest manifest ref for %s: %v", repoSpec.RepoSlug, err) for _, wf := range grouped { @@ -91,6 +91,11 @@ func updateManifestWorkflowGroup(ctx context.Context, source string, grouped []* } return successes, failures } + if latestRefResult.CoolDownBlocked { + updateManifestLog.Printf("Skipping manifest update for %s due to commit cooldown", repoSpec.RepoSlug) + return successes, failures + } + latestRef := latestRefResult.Ref updateManifestLog.Printf("Resolved manifest refs: current=%s, latest=%s", currentRef, latestRef) sourceFieldRef := latestRef // Preserve branch-tracking behavior: when source points to a branch, keep the diff --git a/pkg/cli/update_manifest_test.go b/pkg/cli/update_manifest_test.go index 3e6850cd24b..1bea735c6bb 100644 --- a/pkg/cli/update_manifest_test.go +++ b/pkg/cli/update_manifest_test.go @@ -33,8 +33,8 @@ func TestUpdateManifestWorkflowGroup_AddsUpdatesRemoves(t *testing.T) { listPackageDirFilesForHost = originalDirFiles }) - resolveLatestRefFn = func(ctx context.Context, repo, currentRef string, allowMajor, verbose bool, coolDown time.Duration) (string, error) { - return "v2.0.0", nil + resolveLatestRefFn = func(ctx context.Context, repo, currentRef string, allowMajor, verbose bool, coolDown time.Duration) (latestRefResolution, error) { + return latestRefResolution{Ref: "v2.0.0"}, nil } getRepositoryPackageDefaultBranch = func(repoSlug, host string) (string, error) { return "main", nil diff --git a/pkg/cli/update_redirects.go b/pkg/cli/update_redirects.go index b490c4297b2..3a57e941f35 100644 --- a/pkg/cli/update_redirects.go +++ b/pkg/cli/update_redirects.go @@ -17,7 +17,7 @@ var updateRedirectsLog = logger.New("cli:update_redirects") const maxRedirectDepth = 20 -var resolveLatestRefFn = resolveLatestRef +var resolveLatestRefFn = resolveLatestRefWithResult var downloadWorkflowContentFn = downloadWorkflowContent type resolvedUpdateLocation struct { @@ -27,6 +27,7 @@ type resolvedUpdateLocation struct { sourceFieldRef string content []byte redirectHistory []string + coolDownBlocked bool } func resolveRedirectedUpdateLocation(ctx context.Context, workflowName string, initialSource *SourceSpec, allowMajor, verbose bool, noRedirect bool, coolDown time.Duration) (*resolvedUpdateLocation, error) { @@ -52,10 +53,21 @@ func resolveRedirectedUpdateLocation(ctx context.Context, workflowName string, i } visited[locationKey] = struct{}{} - latestRef, err := resolveLatestRefFn(ctx, current.Repo, currentRef, allowMajor, verbose, coolDown) + latestRefResult, err := resolveLatestRefFn(ctx, current.Repo, currentRef, allowMajor, verbose, coolDown) if err != nil { return nil, fmt.Errorf("failed to resolve latest ref for %s: %w", sourceSpecWithRef(current, currentRef), err) } + if latestRefResult.CoolDownBlocked { + return &resolvedUpdateLocation{ + sourceSpec: current, + currentRef: currentRef, + latestRef: currentRef, + sourceFieldRef: currentRef, + redirectHistory: history, + coolDownBlocked: true, + }, nil + } + latestRef := latestRefResult.Ref content, err := downloadWorkflowContentFn(ctx, current.Repo, current.Path, latestRef, verbose) if err != nil { diff --git a/pkg/cli/update_redirects_test.go b/pkg/cli/update_redirects_test.go index 4dd0160a99e..e481ad2d7cf 100644 --- a/pkg/cli/update_redirects_test.go +++ b/pkg/cli/update_redirects_test.go @@ -4,6 +4,7 @@ package cli import ( "context" + "errors" "fmt" "testing" "time" @@ -40,8 +41,8 @@ func TestResolveRedirectedUpdateLocation(t *testing.T) { }) t.Run("follows redirect chain", func(t *testing.T) { - resolveLatestRefFn = func(_ context.Context, _ string, currentRef string, _ bool, _ bool, _ time.Duration) (string, error) { - return currentRef, nil + resolveLatestRefFn = func(_ context.Context, _ string, currentRef string, _ bool, _ bool, _ time.Duration) (latestRefResolution, error) { + return latestRefResolution{Ref: currentRef}, nil } downloadWorkflowContentFn = func(_ context.Context, repo, path, ref string, _ bool) ([]byte, error) { key := fmt.Sprintf("%s/%s@%s", repo, path, ref) @@ -77,8 +78,8 @@ func TestResolveRedirectedUpdateLocation(t *testing.T) { }) t.Run("detects redirect loops", func(t *testing.T) { - resolveLatestRefFn = func(_ context.Context, _ string, currentRef string, _ bool, _ bool, _ time.Duration) (string, error) { - return currentRef, nil + resolveLatestRefFn = func(_ context.Context, _ string, currentRef string, _ bool, _ bool, _ time.Duration) (latestRefResolution, error) { + return latestRefResolution{Ref: currentRef}, nil } downloadWorkflowContentFn = func(_ context.Context, repo, path, ref string, _ bool) ([]byte, error) { key := fmt.Sprintf("%s/%s@%s", repo, path, ref) @@ -106,8 +107,8 @@ func TestResolveRedirectedUpdateLocation(t *testing.T) { }) t.Run("refuses redirect when no-redirect is enabled", func(t *testing.T) { - resolveLatestRefFn = func(_ context.Context, _ string, currentRef string, _ bool, _ bool, _ time.Duration) (string, error) { - return currentRef, nil + resolveLatestRefFn = func(_ context.Context, _ string, currentRef string, _ bool, _ bool, _ time.Duration) (latestRefResolution, error) { + return latestRefResolution{Ref: currentRef}, nil } downloadWorkflowContentFn = func(_ context.Context, repo, path, ref string, _ bool) ([]byte, error) { key := fmt.Sprintf("%s/%s@%s", repo, path, ref) @@ -139,9 +140,9 @@ func TestResolveRedirectedUpdateLocation(t *testing.T) { t.Cleanup(func() { defaultBranchCache.Delete("owner/repo") }) var seenRef string - resolveLatestRefFn = func(_ context.Context, _ string, currentRef string, _ bool, _ bool, _ time.Duration) (string, error) { + resolveLatestRefFn = func(_ context.Context, _ string, currentRef string, _ bool, _ bool, _ time.Duration) (latestRefResolution, error) { seenRef = currentRef - return currentRef, nil + return latestRefResolution{Ref: currentRef}, nil } downloadWorkflowContentFn = func(_ context.Context, repo, path, ref string, _ bool) ([]byte, error) { key := fmt.Sprintf("%s/%s@%s", repo, path, ref) @@ -165,4 +166,28 @@ func TestResolveRedirectedUpdateLocation(t *testing.T) { assert.Equal(t, "trunk", result.currentRef, "current ref should reflect the resolved default branch") assert.Equal(t, "trunk", result.sourceFieldRef, "default branch should be preserved in source field") }) + + t.Run("stops before download when cooldown is blocked", func(t *testing.T) { + resolveLatestRefFn = func(_ context.Context, _ string, currentRef string, _ bool, _ bool, _ time.Duration) (latestRefResolution, error) { + return latestRefResolution{Ref: currentRef, CoolDownBlocked: true}, nil + } + downloaded := false + downloadWorkflowContentFn = func(_ context.Context, _, _, _ string, _ bool) ([]byte, error) { + downloaded = true + return nil, errors.New("should not download when cooldown blocks") + } + + result, err := resolveRedirectedUpdateLocation( + context.Background(), + "blocked-workflow", + &SourceSpec{Repo: "owner/repo", Path: "workflows/original.md", Ref: "main"}, + false, + false, + false, + 7*24*time.Hour, + ) + require.NoError(t, err) + assert.True(t, result.coolDownBlocked) + assert.False(t, downloaded, "cooldown-blocked resolution should not download workflow content") + }) } diff --git a/pkg/cli/update_workflows.go b/pkg/cli/update_workflows.go index f83cc473cba..40af6eb73f6 100644 --- a/pkg/cli/update_workflows.go +++ b/pkg/cli/update_workflows.go @@ -37,6 +37,11 @@ type repoBranchKey struct { branch string } +type latestBranchCommitInfo struct { + SHA string + CommittedAt time.Time +} + // clearUpdateResolutionCaches clears per-run ref-resolution caches so update // operations always start from fresh repository state. func clearUpdateResolutionCaches() { @@ -255,7 +260,24 @@ func findWorkflowsWithSource(workflowsDir string, filterNames []string, verbose } // resolveLatestRef resolves the latest ref for a workflow source +type latestRefResolution struct { + Ref string + CoolDownBlocked bool +} + func resolveLatestRef(ctx context.Context, repo, currentRef string, allowMajor, verbose bool, coolDown time.Duration) (string, error) { + result, err := resolveLatestRefWithDeps(ctx, defaultWorkflowUpdateDeps(), repo, currentRef, allowMajor, verbose, coolDown) + if err != nil { + return "", err + } + return result.Ref, nil +} + +func resolveLatestRefWithResult(ctx context.Context, repo, currentRef string, allowMajor, verbose bool, coolDown time.Duration) (latestRefResolution, error) { + return resolveLatestRefWithDeps(ctx, defaultWorkflowUpdateDeps(), repo, currentRef, allowMajor, verbose, coolDown) +} + +func resolveLatestRefWithDeps(ctx context.Context, deps workflowUpdateDeps, repo, currentRef string, allowMajor, verbose bool, coolDown time.Duration) (latestRefResolution, error) { updateLog.Printf("Resolving latest ref: repo=%s, currentRef=%s, allowMajor=%v", repo, currentRef, allowMajor) if verbose { @@ -265,7 +287,8 @@ func resolveLatestRef(ctx context.Context, repo, currentRef string, allowMajor, // Check if current ref is a tag (looks like a semantic version) if isSemanticVersionTag(currentRef) { updateLog.Print("Current ref is semantic version tag, resolving latest release") - return resolveLatestRelease(ctx, repo, currentRef, allowMajor, verbose, coolDown) + ref, err := resolveLatestReleaseWithDeps(ctx, deps, repo, currentRef, allowMajor, verbose, coolDown) + return latestRefResolution{Ref: ref}, err } // Check if current ref is a commit SHA (40-character hex string) @@ -273,7 +296,7 @@ func resolveLatestRef(ctx context.Context, repo, currentRef string, allowMajor, updateLog.Printf("Current ref is a commit SHA: %s, fetching latest from default branch", currentRef) // The source field only contains a pinned SHA with no branch information. // Fetch the latest commit from the default branch to check for updates. - return resolveLatestCommitFromDefaultBranch(ctx, repo, currentRef, verbose) + return resolveLatestCommitFromDefaultBranchWithDeps(ctx, deps, repo, currentRef, verbose, effectiveCommitCoolDown(coolDown)) } // Otherwise, treat as branch and get latest commit @@ -282,28 +305,37 @@ func resolveLatestRef(ctx context.Context, repo, currentRef string, allowMajor, } // Get the latest commit SHA for the branch - latestSHA, err := getLatestBranchCommitSHACached(ctx, repo, currentRef) + latestCommit, err := deps.getLatestBranchCommit(ctx, repo, currentRef) if err != nil { - return "", fmt.Errorf("failed to get latest commit for branch %s: %w", currentRef, err) + return latestRefResolution{}, fmt.Errorf("failed to get latest commit for branch %s: %w", currentRef, err) } - updateLog.Printf("Latest commit for branch %s: %s", currentRef, latestSHA) + updateLog.Printf("Latest commit for branch %s: %s", currentRef, latestCommit.SHA) + + commitCoolDown := effectiveCommitCoolDown(coolDown) + if !isExemptFromCoolDown(repo) && commitCoolDown > 0 { + 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 latestRefResolution{Ref: currentRef, CoolDownBlocked: true}, nil + } + } // Return the SHA for comparison so we can detect upstream changes. // The caller (updateWorkflow) preserves the branch name in the source // field to avoid SHA-pinning — see isBranchRef() usage there. - return latestSHA, nil + return latestRefResolution{Ref: latestCommit.SHA}, nil } -// resolveLatestCommitFromDefaultBranch fetches the latest commit SHA from +// resolveLatestCommitFromDefaultBranchWithDeps fetches the latest commit SHA from // the default branch of a repo. This is used when the source field is pinned // to a commit SHA with no branch information — in that case we can only // logically track the default branch. -func resolveLatestCommitFromDefaultBranch(ctx context.Context, repo, currentSHA string, verbose bool) (string, error) { +func resolveLatestCommitFromDefaultBranchWithDeps(ctx context.Context, deps workflowUpdateDeps, repo, currentSHA string, verbose bool, coolDown time.Duration) (latestRefResolution, error) { // Get the default branch name - defaultBranch, err := getRepoDefaultBranchCached(ctx, repo) + defaultBranch, err := deps.getRepoDefaultBranch(ctx, repo) if err != nil { - return "", fmt.Errorf("failed to get default branch for %s: %w", repo, err) + return latestRefResolution{}, fmt.Errorf("failed to get default branch for %s: %w", repo, err) } updateLog.Printf("Source is pinned to commit SHA, tracking default branch %q of %s", defaultBranch, repo) @@ -312,14 +344,21 @@ func resolveLatestCommitFromDefaultBranch(ctx context.Context, repo, currentSHA } // Get the latest commit SHA from the default branch - latestSHA, err := getLatestBranchCommitSHACached(ctx, repo, defaultBranch) + latestCommit, err := deps.getLatestBranchCommit(ctx, repo, defaultBranch) if err != nil { - return "", fmt.Errorf("failed to get latest commit for default branch %s: %w", defaultBranch, err) + return latestRefResolution{}, fmt.Errorf("failed to get latest commit for default branch %s: %w", defaultBranch, err) } - updateLog.Printf("Latest commit on default branch %s: %s (current: %s)", defaultBranch, latestSHA, currentSHA) + updateLog.Printf("Latest commit on default branch %s: %s (current: %s)", defaultBranch, latestCommit.SHA, currentSHA) + if !isExemptFromCoolDown(repo) && coolDown > 0 { + 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 latestRefResolution{Ref: currentSHA, CoolDownBlocked: true}, nil + } + } - return latestSHA, nil + return latestRefResolution{Ref: latestCommit.SHA}, nil } // getRepoDefaultBranchCached wraps getRepoDefaultBranch with a cache to avoid @@ -339,22 +378,22 @@ func getRepoDefaultBranchCached(ctx context.Context, repo string) (string, error return branch, nil } -// getLatestBranchCommitSHACached wraps getLatestBranchCommitSHA with a cache +// getLatestBranchCommitInfoCached wraps getLatestBranchCommitInfo with a cache // keyed by repo+branch to reduce repeated branch-head API lookups. -func getLatestBranchCommitSHACached(ctx context.Context, repo, branch string) (string, error) { +func getLatestBranchCommitInfoCached(ctx context.Context, repo, branch string) (latestBranchCommitInfo, error) { key := repoBranchKey{repo: repo, branch: branch} if cached, ok := branchCommitCache.Load(key); ok { - if sha, isString := cached.(string); isString { - return sha, nil + if info, ok := cached.(latestBranchCommitInfo); ok { + return info, nil } } - sha, err := getLatestBranchCommitSHA(ctx, repo, branch) + info, err := getLatestBranchCommitInfo(ctx, repo, branch) if err != nil { - return "", err + return latestBranchCommitInfo{}, err } - branchCommitCache.Store(key, sha) - return sha, nil + branchCommitCache.Store(key, info) + return info, nil } // fetchPublicGitHubAPI makes an unauthenticated GET request to the GitHub public @@ -447,50 +486,66 @@ func getRepoDefaultBranch(ctx context.Context, repo string) (string, error) { return branch, nil } -// getLatestBranchCommitSHA fetches the latest commit SHA for a given branch. -func getLatestBranchCommitSHA(ctx context.Context, repo, branch string) (string, error) { +// getLatestBranchCommitInfo fetches the latest commit SHA and commit date for a given branch. +func getLatestBranchCommitInfo(ctx context.Context, repo, branch string) (latestBranchCommitInfo, error) { // URL-encode the branch name since it may contain slashes (e.g. "feature/foo") - endpoint := fmt.Sprintf("/repos/%s/branches/%s", repo, url.PathEscape(branch)) - output, err := workflow.RunGHContext(ctx, "Fetching branch info...", "api", endpoint, "--jq", ".commit.sha") + endpoint := fmt.Sprintf("/repos/%s/commits/%s", repo, url.PathEscape(branch)) + output, err := workflow.RunGHContext(ctx, "Fetching commit info...", "api", endpoint) if err != nil && gitutil.IsAuthError(err.Error()) { updateLog.Printf("GitHub API auth failed for branch %s of %s, retrying without token", branch, repo) body, fallbackErr := fetchPublicGitHubAPI(ctx, endpoint) if fallbackErr != nil { - return "", fmt.Errorf("failed (with token: %w; without token: %w)", err, fallbackErr) - } - var result struct { - Commit struct { - SHA string `json:"sha"` - } `json:"commit"` + return latestBranchCommitInfo{}, fmt.Errorf("failed (with token: %w; without token: %w)", err, fallbackErr) } - if fallbackErr = json.Unmarshal(body, &result); fallbackErr != nil { - return "", fmt.Errorf("failed to parse branch response: %w", fallbackErr) - } - if result.Commit.SHA == "" { - return "", fmt.Errorf("empty commit SHA returned for branch %s", branch) - } - return result.Commit.SHA, nil + return parseLatestBranchCommitInfo(branch, body) } if err != nil { - return "", err + return latestBranchCommitInfo{}, err } + return parseLatestBranchCommitInfo(branch, output) +} - sha := strings.TrimSpace(string(output)) - if sha == "" { - return "", fmt.Errorf("empty commit SHA returned for branch %s", branch) +func parseLatestBranchCommitInfo(branch string, data []byte) (latestBranchCommitInfo, error) { + var result struct { + SHA string `json:"sha"` + Commit struct { + Committer struct { + Date string `json:"date"` + } `json:"committer"` + Author struct { + Date string `json:"date"` + } `json:"author"` + } `json:"commit"` + } + if err := json.Unmarshal(data, &result); err != nil { + return latestBranchCommitInfo{}, fmt.Errorf("failed to parse commit response: %w", err) + } + if result.SHA == "" { + return latestBranchCommitInfo{}, fmt.Errorf("empty commit SHA returned for branch %s", branch) + } + dateStr := result.Commit.Committer.Date + if dateStr == "" { + return latestBranchCommitInfo{SHA: result.SHA}, nil + } + committedAt, err := time.Parse(time.RFC3339, dateStr) + if err != nil { + return latestBranchCommitInfo{}, fmt.Errorf("invalid commit date for branch %s: %w", branch, err) } - - return sha, nil + return latestBranchCommitInfo{SHA: result.SHA, CommittedAt: committedAt}, nil } type workflowUpdateDeps struct { - runReleasesAPI func(ctx context.Context, repo string) ([]byte, error) - checkCoolDown func(ctx context.Context, repo, tag string, coolDown time.Duration) coolDownCheckResult + runReleasesAPI func(ctx context.Context, repo string) ([]byte, error) + checkCoolDown func(ctx context.Context, repo, tag string, coolDown time.Duration) coolDownCheckResult + getRepoDefaultBranch func(ctx context.Context, repo string) (string, error) + getLatestBranchCommit func(ctx context.Context, repo, branch string) (latestBranchCommitInfo, error) } func defaultWorkflowUpdateDeps() workflowUpdateDeps { return workflowUpdateDeps{ - checkCoolDown: checkReleaseCoolDown, + checkCoolDown: checkReleaseCoolDown, + getRepoDefaultBranch: getRepoDefaultBranchCached, + getLatestBranchCommit: getLatestBranchCommitInfoCached, runReleasesAPI: func(ctx context.Context, repo string) ([]byte, error) { endpoint := fmt.Sprintf("/repos/%s/releases", repo) output, err := workflow.RunGHContext(ctx, "Fetching releases...", "api", "--paginate", endpoint, "--jq", ".[].tag_name") @@ -507,11 +562,6 @@ func defaultWorkflowUpdateDeps() workflowUpdateDeps { } } -// resolveLatestRelease resolves the latest compatible release for a workflow source -func resolveLatestRelease(ctx context.Context, repo, currentRef string, allowMajor, verbose bool, coolDown time.Duration) (string, error) { - return resolveLatestReleaseWithDeps(ctx, defaultWorkflowUpdateDeps(), repo, currentRef, allowMajor, verbose, coolDown) -} - func resolveLatestReleaseWithDeps(ctx context.Context, deps workflowUpdateDeps, repo, currentRef string, allowMajor, verbose bool, coolDown time.Duration) (string, error) { updateLog.Printf("Resolving latest release for repo %s (current: %s, allowMajor=%v)", repo, currentRef, allowMajor) @@ -641,6 +691,10 @@ func updateWorkflow(ctx context.Context, wf *workflowWithSource, opts UpdateWork sourceFieldRef := resolvedLocation.sourceFieldRef newContent := resolvedLocation.content + if resolvedLocation.coolDownBlocked { + return nil + } + if opts.Verbose { fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("Current ref: "+currentRef)) fmt.Fprintln(os.Stderr, console.FormatVerboseMessage("Latest ref: "+latestRef)) diff --git a/pkg/cli/update_workflows_test.go b/pkg/cli/update_workflows_test.go index 583da159f0e..02d4ef20db6 100644 --- a/pkg/cli/update_workflows_test.go +++ b/pkg/cli/update_workflows_test.go @@ -137,3 +137,105 @@ func TestResolveLatestRelease_CooldownAllInWindow(t *testing.T) { require.NoError(t, err) assert.Equal(t, "v1.0.0", result, "should return currentRef when all upgrade candidates are in cooldown") } + +func TestResolveLatestRef_BranchCommitCooldownReturnsCurrentBranch(t *testing.T) { + t.Parallel() + + deps := defaultWorkflowUpdateDeps() + deps.getLatestBranchCommit = func(_ context.Context, repo, branch string) (latestBranchCommitInfo, error) { + require.Equal(t, "owner/repo", repo) + require.Equal(t, "main", branch) + return latestBranchCommitInfo{ + SHA: "abc123def456abc123def456abc123def456abc1", + CommittedAt: time.Now().Add(-2 * 24 * time.Hour), + }, nil + } + + result, err := resolveLatestRefWithDeps(context.Background(), deps, "owner/repo", "main", false, false, 7*24*time.Hour) + require.NoError(t, err) + assert.Equal(t, "main", result.Ref, "branch update should be skipped while latest commit is in cooldown") + assert.True(t, result.CoolDownBlocked, "branch update should report cooldown block") +} + +func TestResolveLatestRef_CommitCooldownReturnsCurrentSHA(t *testing.T) { + t.Parallel() + + deps := defaultWorkflowUpdateDeps() + deps.getRepoDefaultBranch = func(_ context.Context, repo string) (string, error) { + require.Equal(t, "owner/repo", repo) + return "main", nil + } + deps.getLatestBranchCommit = func(_ context.Context, repo, branch string) (latestBranchCommitInfo, error) { + require.Equal(t, "owner/repo", repo) + require.Equal(t, "main", branch) + return latestBranchCommitInfo{ + SHA: "abc123def456abc123def456abc123def456abc1", + CommittedAt: time.Now().Add(-1 * 24 * time.Hour), + }, nil + } + + currentSHA := "abcdefabcdefabcdefabcdefabcdefabcdefabcd" + result, err := resolveLatestRefWithDeps(context.Background(), deps, "owner/repo", currentSHA, false, false, 7*24*time.Hour) + require.NoError(t, err) + assert.Equal(t, currentSHA, result.Ref, "commit-pinned update should be skipped while latest default-branch commit is in cooldown") + assert.True(t, result.CoolDownBlocked, "commit-pinned update should report cooldown block") +} + +func TestResolveLatestRef_ExemptRepoBypassesBranchCommitCooldown(t *testing.T) { + t.Parallel() + + deps := defaultWorkflowUpdateDeps() + deps.getLatestBranchCommit = func(_ context.Context, repo, branch string) (latestBranchCommitInfo, error) { + require.Equal(t, "actions/checkout", repo) + require.Equal(t, "main", branch) + return latestBranchCommitInfo{ + SHA: "abc123def456abc123def456abc123def456abc1", + CommittedAt: time.Now().Add(-1 * time.Hour), + }, nil + } + + result, err := resolveLatestRefWithDeps(context.Background(), deps, "actions/checkout", "main", false, false, 7*24*time.Hour) + require.NoError(t, err) + assert.Equal(t, "abc123def456abc123def456abc123def456abc1", result.Ref) + assert.False(t, result.CoolDownBlocked) +} + +func TestResolveLatestRef_ExemptRepoBypassesDefaultBranchCommitCooldown(t *testing.T) { + t.Parallel() + + deps := defaultWorkflowUpdateDeps() + deps.getRepoDefaultBranch = func(_ context.Context, repo string) (string, error) { + require.Equal(t, "github/codeql-action", repo) + return "main", nil + } + deps.getLatestBranchCommit = func(_ context.Context, repo, branch string) (latestBranchCommitInfo, error) { + require.Equal(t, "github/codeql-action", repo) + require.Equal(t, "main", branch) + return latestBranchCommitInfo{ + SHA: "abc123def456abc123def456abc123def456abc1", + CommittedAt: time.Now().Add(-1 * time.Hour), + }, nil + } + + currentSHA := "abcdefabcdefabcdefabcdefabcdefabcdefabcd" + result, err := resolveLatestRefWithDeps(context.Background(), deps, "github/codeql-action", currentSHA, false, false, 7*24*time.Hour) + require.NoError(t, err) + assert.Equal(t, "abc123def456abc123def456abc123def456abc1", result.Ref) + assert.False(t, result.CoolDownBlocked) +} + +func TestParseLatestBranchCommitInfo_MissingCommitterDateIsAllowed(t *testing.T) { + t.Parallel() + + data := []byte(`{ + "sha":"abc123def456abc123def456abc123def456abc1", + "commit":{ + "committer":{"date":""}, + "author":{"date":"2026-07-20T10:00:00Z"} + } + }`) + info, err := parseLatestBranchCommitInfo("main", data) + require.NoError(t, err) + assert.Equal(t, "abc123def456abc123def456abc123def456abc1", info.SHA) + assert.True(t, info.CommittedAt.IsZero()) +}