Skip to content

Cut PR build time: stop deleting the Hugo image cache, drop GOGC=3 - #21060

Open
CamSoper wants to merge 4 commits into
masterfrom
claude/pull-request-build-times-y9ejmr
Open

Cut PR build time: stop deleting the Hugo image cache, drop GOGC=3#21060
CamSoper wants to merge 4 commits into
masterfrom
claude/pull-request-build-times-y9ejmr

Conversation

@CamSoper

@CamSoper CamSoper commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Proposed changes

Successful Pull Request runs have been landing at 14–17 minutes. Profiling run 32500502599 (17m12s): "Build and deploy" is 15m45s of it, and the Hugo build alone is 9m05s.

Two causes, plus two missing guardrails. Three runs on this branch isolate each fix, because the middle one ran cold with the GC change:

Total Hugo blog/feature-image.html
Cold cache + GOGC=3 (before) 17m12s 9m05s 994ms/call
Cold cache + GOMEMLIMIT (15484) 12m22s 4m56s 561ms/call
Warm cache + GOMEMLIMIT (15480) 6m54s 48.8s 458µs/call

The GC change is worth ~4m50s, the cache fix ~5m28s, and they're close to independent. make ensure also dropped 1m53s → 1m05s from the preserved yarn cache.

1. The Hugo image cache was deleted 0.2s after it was restored

make ci_pull_requestmake ensure, ensure depends on clean, and scripts/clean.sh ran rm -rf resources — the exact directory actions/cache had just restored:

15:59:59.20  Cache restored from key: hugo-resources-342b83e3... (89 MB)
15:59:59.37  ./scripts/clean.sh

Hugo then re-encoded every blog feature image from scratch on every run. The cache still restored and saved cleanly each time, so the waste never showed up in the job log — only in the template metrics, where blog/feature-image.html averaged 994ms per call across 718 calls.

clean.sh now skips yarn cache clean, rm -rf resources, and hugo mod clean when CI is set. The rest are no-ops on a fresh clone, and local behavior is unchanged. hugo mod clean tested clean on the pinned 0.157.0 (exits 0, preserves every resources/_gen entry, against this repo's real module: config) but is inside the guard anyway — it's a no-op that early on a fresh runner, so CI loses nothing and the question can't come back.

Ruled out along the way, so they don't muddy future diagnosis: the per-run preview baseURL does not bust the cache, nor do fresh-clone mtimes. And Processed images │ 2755 counts .Process calls, not encodes — it reads 2755 both before and after this change, so it is not a cache-health signal.

2. GOGC=3

build-site.sh ran Hugo under GOGC=3, collecting once the heap grows 3% over live heap. Measured on a 6,549-page Hugo 0.157.0 harness with a warm cache:

Config wall peak RSS
GOGC=3 (before) 9.4–10.0s 143 MB
GOGC=100 5.6–5.8s 223 MB
GOGC=100 + GOMEMLIMIT 6.1s 224 MB

1.7–3× slower for ~35% peak RSS saved, and the penalty grows with heap size. GOMEMLIMIT=12GiB expresses the actual intent — don't exhaust the 16GB runner — without paying a full GC behind every allocation. Set only under CI, since build-site.sh is also the local build path (make build, scripts/laptop-deploy.sh) where a 12GiB ceiling would be no ceiling at all on a 16GB laptop; an explicit GOMEMLIMIT always wins.

--templateMetrics was also measured and costs nothing detectable, so it stays.

3 & 4. Two guardrails this workflow was missing

  • Concurrency group with cancel-in-progress. Pushing three commits in a row ran three full builds to completion; branches did exactly that four times in one day. Already demonstrated on this PR — run 15483 was cancelled when the next commit landed. Preview buckets are keyed by PR number and head sha, so a cancelled run can only orphan its own — pr-closed.yml deletes every *-pr-<num>-* bucket on close and bucket-cleanup-testing.yml sweeps the www-testing-* previews daily. Both cache steps run with save-always: false, so a cancelled run cannot save a partial cache.
  • ci-build-duration-alert.sh, which build-and-deploy.yml already has and PR builds did not. That is how these drifted to ~17 minutes without anyone being told; the script's own header notes the build grew ~60% over three weeks in July the same way. Threshold 15m — it reported Build and deploy took 10m (655s) ... threshold is 15m on the cold run, quiet as intended, but would have caught the old 15m45s.

5 & 6. Cache hardening (added after review)

Making the cache actually live activates two latent issues that didn't matter while it was being nuked. Both verified, both fixed here:

  • The Hugo version is now in the cache key. Hugo 0.156.0 reuses 0.157.0's cached output byte-for-byte, so after an upgrade images stay encoded by whatever version first produced them. Exactly four workflows cache path: resourcespull-request.yml, build-and-deploy.yml, testing-build-and-deploy.yml, pulumi-cli-docs.yml — and all four now key and restore on hugo-resources-0.157.0- and pin hugo-version: '0.157.0'. They must move together or they stop sharing a cache; the comment in each names the full set. Kept as a literal rather than interpolating the pin: a failed interpolation there would silently install the wrong Hugo, a worse failure than key drift.
  • hugo --gc prunes entries the build no longer references, which is what bounds growth — nothing else reclaims superseded ones. Applied to every non-preview build under CI: the two deploys plus any CI job running make build (pulumi-cli-docs.yml does, and it's a full-site build, so it prunes against the same reference set). PR preview builds are the deliberate exclusion — they share the namespace, and a narrower view could drop entries the others need.

Blast radius

build-and-deploy.yml restores the same hugo-resources cache and runs the same clean via make ci_push, so master deploys were hitting both problems too and get the same fix.

⚠️ Expect one slow build per workflow after this merges. Nothing has written a hugo-resources-0.157.0-* entry yet, so the first run of each of the four repopulates from cold. That's the 12m22s column above, not a regression; the run after returns to ~7 minutes.

New profile

Hugo is no longer the bottleneck (48.8s, 12% of the run). What's left, roughly evenly: runner setup 1m01s, make ensure 1m05s, generate-docs-content.js 1m01s, then S3 sync 20s / Cypress 23s / search index 21s / pulumi preview 35s / redirects 18s. There's no single large win left — going below ~5 minutes means chipping at several things.

Unreleased product version (optional)

N/A

Related issues (optional)

N/A

Successful `Pull Request` runs have been landing at 14-17 minutes. The
"Build and deploy" step is 15m45s of a 17m12s run, and the Hugo build
alone is 9m05s of that.

Two causes, plus two missing guardrails.

Hugo's processed-image cache was being deleted 0.2s after it was
restored. `make ci_pull_request` runs `make ensure`, `ensure` depends on
`clean`, and `scripts/clean.sh` ran `rm -rf resources` -- the exact
directory actions/cache had just restored:

    15:59:59.20  Cache restored from key: hugo-resources-342b83e3 (89 MB)
    15:59:59.37  ./scripts/clean.sh

Hugo then re-encoded every blog feature image from scratch on every run.
The cache still restored and saved cleanly each time, so the waste never
showed up in the job log -- only in the template metrics, where
blog/feature-image.html averaged 994ms per call across 718 calls
(11m53s cumulative). Measured against a 6,549-page Hugo 0.157.0 harness
built from the real partial and all 149 real feature.png bundles, that
partial costs 593ms/call cold and 343us/call warm. `clean.sh` now skips
`rm -rf resources` and `yarn cache clean` when CI is set; the remaining
operations are no-ops on a fresh clone, and local behavior is unchanged.
`hugo mod clean` was verified not to touch resources/ under this repo's
module config, so the guard is sufficient.

Hugo also ran under GOGC=3, which collects once the heap grows 3% over
live heap. On the same harness with a warm cache that costs 1.7-3x in
wall time (9.4-10.0s vs 5.6-5.8s) to save ~35% peak RSS, and the penalty
grows with heap size. GOMEMLIMIT=12GiB expresses the actual intent -- do
not exhaust the 16GB runner -- without paying for a full GC behind every
allocation. Fixing the image cache also lowers peak memory, since image
decoding was the allocation-heavy part.

build-and-deploy.yml restores the same hugo-resources cache and runs the
same clean via `make ci_push`, so master deploys were hitting both
problems too and get the same fix. The cached contents were always valid
-- they were just discarded before use -- so PRs benefit immediately
without a warm-up period.

Also adds two guardrails this workflow was missing:

- A concurrency group with cancel-in-progress. Pushing three commits in
  a row ran three full builds to completion; branches did exactly that
  four times today. Preview buckets are keyed by PR number and head sha,
  so a cancelled run can only orphan its own, which pr-closed.yml and
  bucket-cleanup.yml already reap.
- The ci-build-duration-alert.sh check that build-and-deploy.yml already
  has. PR builds had none, which is how they drifted to ~17 minutes
  without anyone being told. Threshold 15m, above the expected new
  baseline; worth tightening once that baseline settles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5pkk2XuQW6T84W6XHFiyM
@pulumi-bot

pulumi-bot commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

@CamSoper
CamSoper marked this pull request as ready for review August 21, 2026 21:39
@github-actions github-actions Bot added review:triaging Claude Triage is currently classifying the PR domain:infra PR touches workflows, scripts, infra, Makefile, or build config review:in-progress Claude review is currently running and removed review:triaging Claude Triage is currently classifying the PR labels Aug 21, 2026

@unblocked unblocked 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.

✅ No issues found

About Unblocked

Unblocked has been set up to automatically review your team's pull requests to identify genuine bugs and issues.

📖 Documentation — Learn more in our docs.

💬 Ask questions — Mention @unblocked to request a review or summary, or ask follow-up questions.

👍 Give feedback — React to comments with 👍 or 👎 to help us improve.

⚙️ Customize — Adjust settings in your preferences.

@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Pre-merge Review — Last updated 2026-08-24T21:58:51Z

Tip

Summary: This is a CI/build-infrastructure PR, not a content change: it stops scripts/clean.sh from deleting the just-restored yarn and Hugo resources/ caches when running under CI, replaces GOGC=3 with a CI-only GOMEMLIMIT=12GiB soft ceiling in scripts/build-site.sh, adds per-PR concurrency cancellation plus a 15-minute slow-build Slack alert to .github/workflows/pull-request.yml, and version-stamps the shared hugo-resources cache — now from the installed binary rather than a literal — while pruning the cache with hugo --gc on CI builds. The failure modes that matter here are a change that silently doesn't take effect, a knob that leaks from CI into developer laptops, and a version stamp that can degrade to an unversioned namespace without anyone noticing. This pass re-verified ff95472e — the load-bearing part is the sed -nE/[ -n "$ver" ] guard, which was checked against real banner shapes rather than read — plus step ordering, the extended: consistency the new key depends on, and that no literal-keyed cache site survives. Nothing is outstanding.

Independently confirmed for this commit: hugo is installed by peaceiris/actions-hugo@v3 earlier in the same job as the new resolve step in all four workflows (pull-request.yml L47 → L96 → L112; build-and-deploy.yml L61 → L110 → L128; testing-build-and-deploy.yml L38 → L87 → L105; pulumi-cli-docs.yml L112 → L148 → L165), and all four pass extended: true, so all four resolve to the same 0.157.0-extended and stay in one namespace. grep -rn "hugo-resources-" .github/workflows/ returns exactly eight lines — four key:, four restore-keys: — every one interpolated; no literal survives. mise is the only other Hugo pin that could shadow the binary, and scripts/ensure.sh L10-L18 activates it only when mise is on PATH, which CI is not — so the resolved version is the one that will actually encode the images.

Review confidence:

Dimension Level Notes
mechanics HIGH
facts MEDIUM The headline timing is corroborated — run 32528763915 on this branch ran 6m55s wall clock (21:29:48Z → 21:36:43Z). The GOGC=3 1.7–3x multiplier and the ~2,400-image count remain the author's own measurements.
Investigation log
  • Cross-sibling reads: not run (not in a templated section)
  • External claim verification: 2 of 12 claims verified (2 unverifiable, 3 contradicted) · 4 specialists (numerical, cross-reference, capability, framing); 0 cross-specialist corroborations · routed: 12 inline, 0 Pass 1, 0 Pass 2, 0 Pass 3.
  • Cited-claim spot-checks: not run (no cited claims)
  • Frontmatter sweep: not run (no frontmatter in diff)
  • Temporal-trigger sweep: ran (recency words present in diff; spot-check in-review)
  • Code execution: not run (no static/programs/ change)
  • Code-examples checks: not run (no fenced code blocks in content files)
  • Editorial-balance pass: not run (not under content/blog/)
🚨 Outstanding ⚠️ Low-confidence 💡 Pre-existing ✅ Resolved
0 0 0 2

🔍 Verification trail

12 claims extracted · 2 verified · 2 unverifiable · 3 contradicted · 1 detector findings
  • L116 in .github/workflows/pull-request.yml "the four workflows that share this cache (pull-request.yml, build-and-deploy.yml, testing-build-and-deploy.yml, pulumi-cli-docs.yml)" → ✅ verified (evidence: grep -rn "path: resources" .github/workflows/ returns exactly those four — build-and-deploy.yml L129, pull-request.yml L115, pulumi-cli-docs.yml L166, testing-build-and-deploy.yml L106 — and grep -rn "hugo-resources-" returns eight interpolated lines across the same four, no literals; source: .github/workflows/ (4 files))
  • L92 in .github/workflows/pull-request.yml "Derive the cache key from the Hugo installed above rather than a literal" → ✅ verified (evidence: the peaceiris/actions-hugo@v3 step precedes the id: hugo-version step, which precedes the path: resources cache step, in the same job in all four — L47/L96/L112, L61/L110/L128, L38/L87/L105, L112/L148/L165 — and scripts/ensure.sh L10-L18 confirms mise doesn't shadow it in CI; source: .github/workflows/{pull-request,build-and-deploy,testing-build-and-deploy,pulumi-cli-docs}.yml, scripts/ensure.sh L10-L18)
  • L124 in .github/workflows/pull-request.yml "# how they drifted to ~17 minutes without anyone being told. 15 minutes" → ➖ not-a-claim (evidence: This is a code comment inside a CI workflow file describing the PR author's own observation/rationale about CI timing drift, not a falsifiable external claim.; source: .github/workflows/pull-request.yml (L124))
  • L30 in scripts/build-site.sh "# Hugo previously ran under GOGC=3, which collects once the heap grows 3% over" → ➖ not-a-claim (evidence: This is a code comment in the PR author's own build script explaining their own GOGC configuration choice for Hugo, not a third-party factual assertion requiring external verification.; source: scripts/build-site.sh)
  • L31 in scripts/build-site.sh "# live heap. That capped memory but cost 1.7-3x in wall time, since every" → ➖ not-a-claim (evidence: This is a code comment in the PR author's own build script explaining their own observed tradeoff from tuning GC/memory settings (capped heap vs wall time cost), not a falsifiable third-party claim.; source: scripts/build-site.sh L31 (PR's own file))
  • L35 in scripts/build-site.sh "# approaches the limit. The runner has 16GB; 12GiB leaves room for the Node and" → ➖ not-a-claim (evidence: This is a code comment in the PR author's own build script explaining their rationale for a memory limit setting (12GiB out of a 16GB runner), not a falsifiable third-party claim about an external fact.; source: scripts/build-site.sh L35 (comment describing own CI configuration))
  • L37 in scripts/build-site.sh "export GOMEMLIMIT=12GiB" → ➖ not-a-claim (evidence: This is a line from the PR author's own build script setting a GOMEMLIMIT environment variable for their own pipeline's Go processes; it is a configuration choice, not a falsifiable external assertion.; source: scripts/build-site.sh)
  • L13 in .github/workflows/pull-request.yml "bucket-cleanup.yml sweeps daily [the orphaned PR preview buckets]" → ⚔️ mismatch (evidence: PR preview buckets are www-testing-pulumi-docs-origin-pr-<num>-<sha>, created under environment: testing; bucket-cleanup.yml runs under environment: production, and the daily sweep for testing buckets is bucket-cleanup-testing.yml; source: .github/workflows/bucket-cleanup.yml L15, .github/workflows/bucket-cleanup-testing.yml L16) — fixed in dda8e00
  • L37 in scripts/build-site.sh "GOGC is no longer set by the build" → ⚔️ mismatch (evidence: BUILD-AND-DEPLOY.md still lists | **GOGC** | Go GC tuning | \3` | Workflow |in its environment-variable reference table, which this PR makes untrue; source: BUILD-AND-DEPLOY.md L4010) — **fixed in dda8e00** (row replaced withGOMEMLIMIT … (CI only) … build-site.sh`)
  • L37 in scripts/build-site.sh "GOMEMLIMIT=12GiB is exported unconditionally, including for local builds" → 🤷 unverifiable (evidence: make build (Makefile L79) and scripts/laptop-deploy.sh L54 both invoke build-site.sh, so the ceiling applies off-CI; whether 12GiB is safe on a given developer machine can't be determined from the repo; source: Makefile L79, scripts/laptop-deploy.sh L54) — fixed in dda8e00 (now if [ -n "${CI:-}" ] with ${GOMEMLIMIT:-12GiB})
  • L20 in scripts/clean.sh "hugo mod clean runs unconditionally after the CI skip block and does not prune resources/" → 🤷 unverifiable (evidence: the CI guard preserves resources/, but hugo mod clean still runs on the next line; Hugo's cache pruning behavior for the assets/images caches under resources/_gen could not be exercised in this review environment; source: scripts/clean.sh L15-L20) — fixed in dda8e00 (moved inside the if [ -z "${CI:-}" ] guard, making the question moot)
  • L105 in .github/workflows/pull-request.yml "The version prefix … must … be identical in pull-request.yml and build-and-deploy.yml" → ⚔️ mismatch (evidence: two further workflows cache the same resources path on the unversioned key and were not updated — testing-build-and-deploy.yml L91/L93 and pulumi-cli-docs.yml L151/L153; source: .github/workflows/testing-build-and-deploy.yml L91-93, .github/workflows/pulumi-cli-docs.yml L151-153) — fixed in 9437eb1, and superseded in ff95472e (no workflow states a version at all now)
  • L48 in scripts/build-site.sh "Applied on the deploy branches below and only in CI" → 🚩 flagged (cross-reference: the else branch is every non-preview invocation, so any CI job running make build with DEPLOYMENT_ENVIRONMENT unset also gets --gcpulumi-cli-docs.yml L246 is one; source: .github/workflows/pulumi-cli-docs.yml L246, Makefile L79) — fixed in 9437eb1 (comment now states the predicate the code tests and names pulumi-cli-docs.yml)

🚨 Outstanding in this PR

No outstanding findings.

⚠️ Low-confidence

No low-confidence findings.

📋 Triaged verifier findings

I double-checked these and realized they weren't real findings — click to expand

No triaged findings.

💡 Pre-existing issues in touched files (optional)

No pre-existing issues in touched files.

✅ Resolved since last review

  • [L105] .github/workflows/pull-request.yml — the version stamp covered two of the four workflows sharing the resources cache namespace. Resolved in 9437eb1: testing-build-and-deploy.yml (L98/L100) and pulumi-cli-docs.yml (L158/L160) now key and restore on the same prefix as the other two. ff95472e then went further and removed the literal entirely, so the class of drift this finding was about — a prefix that disagrees with a pin, or a workflow left behind on a rev — can no longer be introduced by forgetting a site: no workflow states a version at all. Re-enumerated independently at ff95472e: path: resources appears in exactly four workflows, all eight hugo-resources- lines are interpolated, and no literal remains.

  • [L48] scripts/build-site.sh — the --gc comment described a narrower scope ("the deploy branches") than the else-on-preview guard actually has. Resolved in 9437eb1: the comment now states the predicate the code tests and names pulumi-cli-docs.yml as the non-deploy CI caller. Spot-checked the reasoning it adds rather than taking it as given — pulumi-cli-docs.yml L252 runs a full make build in the same job as its cache step (build-pulumi-cli-docs, L96–L266) and sets no DEPLOYMENT_ENVIRONMENT, so it takes the production branch and prunes against the same full-site reference set as a deploy. That holds, which is what makes the shared namespace safe now that all four caches are one.

📜 Review history

  • 2026-08-21T21:41:05Z — Initial review: 2 outstanding (comment names the wrong bucket-cleanup workflow; BUILD-AND-DEPLOY.md still documents the removed GOGC), 2 low-confidence (GOMEMLIMIT leaks into local builds; confirm hugo mod clean doesn't re-prune resources/). (f636ab4)
  • 2026-08-21T22:03:53Z — Re-reviewed after fix push (1 new commit, dda8e00) at @CamSoper's request. All 4 previous findings verified fixed and moved to ✅ Resolved. Reviewed the two added follow-ups: 1 new outstanding (the hugo-resources-0.157.0- stamp covers pull-request.yml and build-and-deploy.yml but not testing-build-and-deploy.yml L91/L93 or pulumi-cli-docs.yml L151/L153, which cache the same resources path on the unversioned key), 1 new low-confidence (the --gc comment says "deploy branches" but the guard is on the non-preview else, which any CI make build also takes — pulumi-cli-docs.yml L246). Also confirmed the empty-array expansion "${hugo_gc[@]}" is safe (build-site.sh sets errexit/pipefail, not nounset), that both updated workflows pin hugo-version: '0.157.0' matching the new key prefix (pull-request.yml L49, build-and-deploy.yml L63), and that clean.sh still deletes _vendor under CI without hugo mod clean — correct on a fresh clone. On the previous facts: MEDIUM note: run 32528763915 on this branch is confirmed successful at 6m55s wall clock (21:29:48Z → 21:36:43Z), which corroborates the headline number; the GOGC=3 1.7–3x multiplier and the ~2,400-image count remain author measurements, so the dimension stays MEDIUM. (9437eb1)
  • 2026-08-21T22:11:11Z — Re-reviewed after fix push (1 new commit, 9437eb1) at @CamSoper's request. Both open findings verified fixed and moved to ✅ Resolved; 0 outstanding, 0 low-confidence. Verified independently rather than on the author's account: grep -rn "path: resources" .github/workflows/ returns exactly four workflows, all four now key hugo-resources-0.157.0-${{ github.sha }} and restore on hugo-resources-0.157.0-, and each pins hugo-version: '0.157.0' above its cache step (pull-request.yml L49/L99, build-and-deploy.yml L63/L113, testing-build-and-deploy.yml L40/L90, pulumi-cli-docs.yml L114/L150) — so no unversioned key survives and no prefix disagrees with a pin. On the --gc scope question the author raised: agreed it isn't a bug. pulumi-cli-docs.yml runs a full make build at L252, inside the same job as its cache step (L96–L266), with no DEPLOYMENT_ENVIRONMENT set — so it takes the production branch of build-site.sh and prunes against the same full-site reference set the deploys do, which is exactly the property the shared namespace needs. The rewritten comment matches the guard it documents. No new findings in this commit; the diff is the two cache keys plus comment text. Confirming the author's expectation: the first run of each of the four workflows after merge is a cold resources/ build, since nothing has written a hugo-resources-0.157.0-* entry yet. (ff95472 supersedes this — see below.)
  • 2026-08-24T21:58:51Z — Re-reviewed after fix push (1 new commit, ff95472) at @CamSoper's request. No new findings; 0 outstanding, 0 low-confidence. The author asked for a close look at the guard, which is the right thing to have asked for, so it was exercised rather than read: sed -nE 's/^hugo v([0-9]+\.[0-9]+\.[0-9]+).*/\1/p' was run against hugo v0.157.0-<hash>+extended linux/amd64, hugo v0.157.0+extended+withdeploy linux/amd64, hugo v0.156.0 darwin/arm64, the pre-0.55 Hugo Static Site Generator v0.55.6 … banner, a garbage string, and empty input — it emits nothing for the last three, so [ -n "$ver" ] fires and the step exits 1 rather than keying on a fragment. The described sed -E bug is real and the fix is the right one: without -n, a non-match echoes the input back, ver is non-empty garbage, and ordering the emptiness check before the case-append is what keeps the guard from passing on a value that is only -extended. Two things the diff depends on that the mention didn't claim, checked because the key is only as good as them: hugo is installed by peaceiris/actions-hugo@v3 earlier in the same job as the resolve step in all four (pull-request.yml L47→L96→L112, build-and-deploy.yml L61→L110→L128, testing-build-and-deploy.yml L38→L87→L105, pulumi-cli-docs.yml L112→L148→L165), and all four pass extended: true, so all four resolve to 0.157.0-extended and share one namespace — had one of them omitted extended:, the four would have silently split into two namespaces, which the single literal could not do. Also confirmed mise can't shadow the resolved binary in CI (scripts/ensure.sh L10-L18 activates it only when mise is on PATH), that hv="$(hugo version)" under GitHub's default bash -eo pipefail aborts the step if hugo is missing rather than keying on empty, and that the moved "Persist Hugo's processed-image cache" comment sits directly above the cache step it describes in all four. Empirically, the resolve step completed successfully in the live run for this head (32781866562). One correction to the follow-up scope in the mention, in the direction of more work rather than less: the residual hardcoded 0.157.0 is 22 sites, not ~17 — 19 hugo-version: pins across workflows (not 15; check-search-urls.yml, scheduled-test.yml, scheduled-upgrade-programs.yml, check-links.yml, content-review-article.yml, review-existing-content.yml, nine SDK-docs workflows, and the four this PR touches), mise.toml twice, and scripts/ensure.sh L40, which the mention didn't count. Agreed that consolidating those is separate work, and agreed on not interpolating into the hugo-version: pins — a failed interpolation there installs the wrong Hugo silently, which is strictly worse than a stale encoder. Finally, one behavior worth expecting rather than debugging: the key namespace moves from hugo-resources-0.157.0- to hugo-resources-0.157.0-extended-, so the first run of each of the four workflows after merge is still a cold resources/ build — same conclusion as the last review, new reason. (ff95472)

  • Refresh this review — comment @claude #update-review. Say what you fixed, or which finding you dispute and why; both work in the same mention.
  • Ask for anything else — comment @claude with no hashtag (questions, one-off fixes). Leaves this review untouched.

Important

Please don't hide, resolve, or delete this comment! It breaks things!

📖 How pre-merge review works — the full lifecycle, short-circuits, and escape hatches.

@github-actions github-actions Bot added review:outstanding-issues Claude review completed; outstanding has author-actionable findings and removed review:in-progress Claude review is currently running labels Aug 21, 2026
Review findings (all four verified against the repo before changing
anything):

Outstanding — the concurrency comment named the wrong sweeper. PR preview
buckets are `www-testing-*`, created by this workflow's `environment:
testing` job. `bucket-cleanup.yml` runs under `environment: production`;
the daily sweep for testing buckets is `bucket-cleanup-testing.yml`.
Corrected.

Outstanding — BUILD-AND-DEPLOY.md L4010 still documented `GOGC | 3 |
Workflow`, which no build path sets after the previous commit. Replaced
with the GOMEMLIMIT row.

Low-confidence — GOMEMLIMIT leaked into local builds. Correct and worth
fixing: `make build` (Makefile L79) and scripts/laptop-deploy.sh L54 both
invoke build-site.sh, and a 12GiB ceiling sized for the CI runner is no
ceiling at all on a 16GB laptop. Now set only under CI, and an explicit
GOMEMLIMIT always wins so a constrained machine can cap it further. Local
builds fall back to Go's defaults.

Low-confidence — whether `hugo mod clean` undoes the new guard. Tested
both ways: with a populated resources/_gen it exits 0 and preserves every
entry, in a bare harness and against this repo's real `module:` config on
the pinned 0.157.0. Rather than rest on that, moved it inside the guard.
It is a no-op on a fresh runner anyway (nothing is in the module cache
that early), so CI loses nothing and the question stops being askable.

Plus the two follow-ups the previous commit only documented:

- Stamp the pinned Hugo version into the hugo-resources cache key.
  Verified that 0.156.0 reuses 0.157.0's cached output byte-for-byte, so
  without this an upgrade keeps serving images encoded by the older
  version indefinitely. Applied identically in pull-request.yml and
  build-and-deploy.yml -- they must match or PRs stop inheriting master's
  cache. Kept as a literal rather than interpolating the version pin: a
  failed interpolation there would silently install the wrong Hugo, a far
  worse failure than key drift, which only costs a stale encoder.
- Add `hugo --gc` to prune cache entries the build no longer references,
  since nothing else reclaims superseded ones. Deploy path only, and only
  in CI, so a PR build cannot evict entries master still needs.

Note: the new cache key means the first run after this lands finds no
`hugo-resources-0.157.0-*` and repopulates from cold, so expect one
~17-minute build before the ~7-minute baseline returns.

Verified by extracting the real conditional block and running it against
a stub hugo: PR path gets no --gc, both deploy paths get it, local builds
get neither --gc nor GOMEMLIMIT, and a preset GOMEMLIMIT survives. clean.sh
under CI preserves resources/ and runs neither yarn cache clean nor hugo
mod clean; unset, it still does all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5pkk2XuQW6T84W6XHFiyM
@github-actions github-actions Bot added review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention and removed review:outstanding-issues Claude review completed; outstanding has author-actionable findings labels Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

@claude #update-review

All four findings addressed in dda8e00, plus the two follow-ups the PR body had listed as out of scope. I verified each finding against the repo before changing anything — all four were correct.

🚨 [L13] pull-request.yml — wrong sweeper. Fixed. Confirmed: bucket-cleanup.yml is environment: production (L15), bucket-cleanup-testing.yml is environment: testing (L16), and PR preview buckets are www-testing-* created by this workflow's environment: testing job. Comment now names bucket-cleanup-testing.yml.

🚨 [L37] build-site.sh — stale GOGC row. Fixed. BUILD-AND-DEPLOY.md L4010 now reads:

| **GOMEMLIMIT** | Go soft memory ceiling for Hugo (CI only) | `12GiB` | build-site.sh |

⚠️ [L37] GOMEMLIMIT leaks into local builds. Fixed — good catch. make build (Makefile L79) and scripts/laptop-deploy.sh L54 both call build-site.sh, so a ceiling sized for the CI runner was being applied to laptops. Now gated on CI, and written as ${GOMEMLIMIT:-12GiB} so an explicit value always wins. Local builds fall back to Go's defaults.

⚠️ [L20] Does hugo mod clean undo the guard? Tested, then made moot. With a populated resources/_gen on the pinned 0.157.0 it exits 0 and preserves every entry — in a bare harness and against this repo's real module: config. Rather than rest on that, I moved it inside the guard as you suggested. It's a no-op on a fresh runner anyway (nothing is in the module cache that early), so CI loses nothing and the question stops being askable.

Follow-ups now included:

  • Hugo version stamped into the cache key. Verified 0.156.0 reuses 0.157.0's cached output byte-for-byte, so without this an upgrade keeps serving images encoded by the old version indefinitely. Applied identically in both workflows — they must match or PRs stop inheriting master's cache. Kept as a literal rather than interpolating the hugo-version pin: a failed interpolation there would silently install the wrong Hugo, which is a much worse failure than key drift.
  • hugo --gc to prune entries the build no longer references, since nothing else reclaims superseded ones. Deploy path only and CI only, so a PR build can't evict entries master still needs.

⚠️ Expect one slow build. The new cache key means the next run finds no hugo-resources-0.157.0-* and repopulates from cold — roughly a 17-minute build before the ~7-minute baseline returns. Not a regression.

Verification: I extracted the real conditional block from build-site.sh and ran it against a stub hugo. PR path gets no --gc; both deploy paths get it; local builds get neither --gc nor GOMEMLIMIT; a preset GOMEMLIMIT survives. clean.sh under CI preserves resources/ and runs neither yarn cache clean nor hugo mod clean; with CI unset it still does all three.

On the facts: MEDIUM note — correct that the numbers aren't reproducible from CI, but the headline ones now are: run 32528763915 took 6m54s against a 17m12s baseline, with Hugo at 48,847 ms vs 545,004 ms and blog/feature-image.html at 458µs/call vs 994ms/call.


Generated by Claude Code

@github-actions github-actions Bot added review:in-progress Claude review is currently running and removed review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention labels Aug 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review updated on @CamSoper's request.

@github-actions github-actions Bot added review:outstanding-issues Claude review completed; outstanding has author-actionable findings and removed review:in-progress Claude review is currently running labels Aug 21, 2026
…ment

Second review pass found the version stamp covered two of the four
workflows that cache `path: resources`. It was right, and the miss had
two consequences: testing-build-and-deploy.yml (L91/L93) and
pulumi-cli-docs.yml (L151/L153) kept the stale-encoder bug the stamp
exists to fix, and their saves landed in a namespace the two updated
workflows no longer restore from, so that cache work was written and
never read. The comment also entrenched the wrong list for whoever
upgrades Hugo next.

All four now use `hugo-resources-0.157.0-`, and all four pin
hugo-version 0.157.0. The comment names the full set so the next upgrade
bumps them together.

Also corrected the --gc comment, which claimed a narrower scope than the
code has. The guard is on the non-preview `else`, so it covers every
non-preview CI build -- the two deploy workflows plus any CI job running
`make build`, which pulumi-cli-docs.yml L246 does. That is fine rather
than a bug: all of them build the full site and reference the same set of
entries. PR preview builds remain the deliberate exclusion. The comment
now describes the predicate the code actually tests.

Verified by re-running the extracted conditional against a stub hugo,
including the pulumi-cli-docs case (CI set, DEPLOYMENT_ENVIRONMENT unset)
the review identified: it takes the production branch and gets --gc, as
the new comment says. PR preview gets none; local builds get neither --gc
nor GOMEMLIMIT. All four workflows parse and their hugo pins match the
new key prefix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5pkk2XuQW6T84W6XHFiyM
@github-actions github-actions Bot added review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention and removed review:outstanding-issues Claude review completed; outstanding has author-actionable findings labels Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

@claude #update-review

Both findings from the second pass addressed in 9437eb1. The outstanding one was a real miss on my part, not a comment problem.

🚨 [L105] Version stamp covered two of four workflows. Fixed properly. Confirmed exactly four workflows cache path: resourcespull-request.yml, build-and-deploy.yml, testing-build-and-deploy.yml (L91/L93), pulumi-cli-docs.yml (L151/L153) — and all four pin hugo-version: '0.157.0'. All four now use hugo-resources-0.157.0-. You're right about both consequences: the two I missed kept the stale-encoder bug, and their saves were landing in a namespace the updated two no longer restore from, so that cache work was written and never read. I've used your suggested comment naming the full set, in all four, so the next upgrade bumps them together.

⚠️ [L48] --gc comment claimed a narrower scope than the code has. Fixed. Also correct — the guard is on the non-preview else, so it covers every non-preview CI build, and pulumi-cli-docs.yml L246 does run make build. I checked whether that's a bug before rewriting the comment, and I don't think it is: that workflow runs a full make build (its own cache comment at L145 says "for the full make build below"), so it references the same set of entries as a deploy and its prune is equivalent. That matters more now that all four share one namespace — a partial-site build pruning here would have been a genuine problem. The comment now describes the predicate the code tests:

# The guard below is on the non-preview branches, so this covers every
# non-preview build under CI: the two deploy workflows, plus any CI job that runs
# `make build` (pulumi-cli-docs.yml does). That is fine because all of them build
# the full site and so reference the same set of entries. PR preview builds are
# the ones deliberately excluded -- they share the same cache namespace, and a
# build pruning against a narrower view could drop entries the others still need.

Verification: re-ran the extracted conditional against a stub hugo, including the exact case you identified. CI set with DEPLOYMENT_ENVIRONMENT unset takes the production branch and gets --gc; PR preview gets none; local builds get neither --gc nor GOMEMLIMIT. All four workflows parse and their pins match the new prefix.

Thanks for the empty-array check on "${hugo_gc[@]}" — I'd relied on nounset being off without saying so, and it's worth having that written down for the macOS bash-3.2 case.

Still expect one cold build after this lands, now across all four workflows rather than two, since nothing has written a hugo-resources-0.157.0-* entry yet.


Generated by Claude Code

@github-actions github-actions Bot added review:in-progress Claude review is currently running and removed review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention labels Aug 21, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review updated on @CamSoper's request.

@github-actions github-actions Bot added review:no-blockers Claude review completed cleanly; outstanding is empty and removed review:in-progress Claude review is currently running labels Aug 21, 2026

@jkodroff jkodroff left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I like faster builds. LGTM.

The literal `hugo-resources-0.157.0-` prefix moved the drift risk rather
than removing it: revving Hugo meant remembering to bump the version in
eight more places (four keys plus four restore-keys), and forgetting one
reintroduces exactly the bug the stamp exists to prevent -- silently
serving images encoded by the previous Hugo.

peaceiris/actions-hugo already runs well before the cache step in all
four workflows (verified: hugo@3<resolve@9<cache@10 in pull-request,
4<10<11 in build-and-deploy and testing-build-and-deploy, 9<13<14 in
pulumi-cli-docs), so the key can be derived from the binary that will
actually produce the cache. Revving hugo-version now updates the key on
its own, and the four workflows cannot drift apart because none of them
states the version.

Deliberately does not interpolate into the `hugo-version:` pins
themselves. A failed interpolation there would silently install the
wrong Hugo, which is worse than a stale encoder; deriving downstream
keeps that risk at zero while removing the drift.

The guard is load-bearing and was wrong on the first attempt. `sed -E`
echoes its input back unchanged when the pattern does not match, so
`[ -n "$ver" ]` passed on garbage -- a build with an unrecognizable
version banner would have keyed every workflow into one unversioned
`hugo-resources--` namespace, the exact failure being defended against.
Fixed with `sed -nE ... p` (non-match yields empty) and by checking
emptiness before appending `-extended`, so the guard cannot pass on a
value that is only the suffix. Exercised against Hugo 0.157.0 and
0.156.0 (both -> `<semver>-extended`), a garbage banner, and empty
output; the last two now exit 1.

Note this does not make revving Hugo a one-line change. `0.157.0` is
still hardcoded in ~17 places -- mise.toml twice (Linux and macOS pins)
and 15 `hugo-version:` pins across workflows, including several this PR
never touched. Collapsing those onto a single source of truth is worth
doing separately; this change just stops the cache keys from being part
of that problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5pkk2XuQW6T84W6XHFiyM
@github-actions github-actions Bot added review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention and removed review:no-blockers Claude review completed cleanly; outstanding is empty labels Aug 24, 2026

Copy link
Copy Markdown
Contributor Author

@claude #update-review

Pushed ff95472, replacing the literal hugo-resources-0.157.0- prefix with one derived from the installed Hugo.

The literal moved the drift risk rather than removing it — revving Hugo meant remembering eight more places (four keys plus four restore-keys), and missing one silently reintroduces exactly the bug the stamp exists to prevent. peaceiris/actions-hugo already runs well before the cache step in all four workflows, so the key can come from the binary that will actually produce the cache:

      - name: Resolve Hugo version for the cache key
        id: hugo-version
        run: |
          hv="$(hugo version)"
          ver="$(printf '%s' "$hv" | sed -nE 's/^hugo v([0-9]+\.[0-9]+\.[0-9]+).*/\1/p')"
          [ -n "$ver" ] || { echo "could not parse hugo version from: $hv" >&2; exit 1; }
          case "$hv" in *+extended*) ver="$ver-extended" ;; esac
          echo "version=$ver" >> "$GITHUB_OUTPUT"

Revving hugo-version now updates the key on its own, and the four workflows can't drift apart because none of them states the version. This deliberately does not interpolate into the hugo-version: pins — a failed interpolation there would silently install the wrong Hugo, which is worse than a stale encoder.

One thing worth a close look, since it's the load-bearing part and I got it wrong first. My initial guard was sed -E plus [ -n "$ver" ]. sed -E echoes its input back unchanged on a non-match, so ver was non-empty garbage and the guard passed — a build with an unrecognizable version banner would have keyed every workflow into one unversioned hugo-resources-- namespace, precisely the failure being defended against. Fixed by using sed -nE ... p (non-match yields empty) and checking emptiness before appending -extended, so the guard can't pass on a value that is only the suffix.

Verification: step ordering asserted programmatically from the parsed YAML in all four (hugo@3 < resolve@9 < cache@10 in pull-request.yml; 4<10<11 in build-and-deploy and testing-build-and-deploy; 9<13<14 in pulumi-cli-docs). The snippet was executed against real Hugo 0.157.0 and 0.156.0 binaries (both → <semver>-extended), a garbage banner, and empty output — the last two exit 1.

Also moved the pre-existing "Persist Hugo's processed-image cache" comment back down to the cache step, since the new step had come between it and what it describes.

Not fixed here, and worth knowing: this does not make revving Hugo a one-line change. 0.157.0 is still hardcoded in ~17 places — mise.toml twice (Linux and macOS pins) and 15 hugo-version: pins across workflows, several of which this PR never touched (check-search-urls.yml, scheduled-test.yml, the SDK docs workflows). Collapsing those onto a single source of truth is a separate piece of work; this change just stops the cache keys from being part of that problem.


Generated by Claude Code

@github-actions github-actions Bot added review:in-progress Claude review is currently running and removed review:stale New commits since last Claude review; refresh on next ready-transition or @claude mention labels Aug 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Review updated on @CamSoper's request.

@github-actions github-actions Bot added review:no-blockers Claude review completed cleanly; outstanding is empty and removed review:in-progress Claude review is currently running labels Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

domain:infra PR touches workflows, scripts, infra, Makefile, or build config review:no-blockers Claude review completed cleanly; outstanding is empty

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants