Skip to content

feat(ci): cache-bust image refs when only an asset changes - #8

Merged
kanywst merged 2 commits into
mainfrom
feat/asset-cache-busting
Aug 30, 2026
Merged

feat(ci): cache-bust image refs when only an asset changes#8
kanywst merged 2 commits into
mainfrom
feat/asset-cache-busting

Conversation

@kanywst

@kanywst kanywst commented Aug 30, 2026

Copy link
Copy Markdown
Member

Problem

Re-rendering a diagram in place never reached a reader. Two independent things had to be worked around by hand:

  1. devto-cli only re-pushes an article whose markdown differs from what is live. Re-rendering 01-foo.png changes no markdown, so the push was a no-op.
  2. dev.to's Bunny CDN serves every image with cache-control: public, max-age=31536000, immutable. Even when a push did go out, the old PNG kept being served for a year.

publish.yml also only triggered on articles/**/*.md, so an asset-only commit never started the workflow at all.

The documented workaround was to hand-bump ?v=2 on every image ref in the article and increment it on each re-render. Two articles (aws-iam-deep-dive, aws-sts-deep-dive) carry that manual token today.

Change

scripts/bump_asset_versions.py rewrites ?v=<sha256(bytes)[:8]> on every reference to a changed asset. The markdown then differs (so devto-cli pushes) and the URL differs (so the CDN refetches).

publish.yml now:

  • triggers on articles/assets/** as well as markdown,
  • diffs the changed assets, runs the script, and folds the articles it touched into the same single dev push batch,
  • commits the rewritten refs alongside the id / date writeback.

make bump-assets does the same locally against $(BASE); --dry-run previews without writing.

Notes on the implementation

Ownership is resolved by reference, not by directory name. Deriving articles/<slug>.md from articles/assets/<slug>/ looks obvious but is wrong here: 40 asset directories have no same-named article. assets/spire/ backs spiffe-spire-deep-dive.md, assets/IssueHub/ backs issuehub.md. The script scans every top-level article for a reference to each changed asset instead, which also handles an image shared by two articles.

Coverage. Markdown images, <img src>, and the absolute cover_image URL. Fenced blocks (backtick and tilde) and inline code are skipped, using the same line-by-line fence tracking as validate_articles.py::strip_code rather than a .*? regex, so ~~~ and four-backtick fences behave identically in both scripts. cover_image is only matched inside the frontmatter span, so a YAML sample in the body (an article documenting this very pipeline, say) cannot be corrupted. URLs pointing at other repos are left alone.

The token is a content hash, not a counter. Reruns are idempotent, and a run that bumps but then fails before committing recomputes the same value on the next attempt.

Relative paths with a query survive devto-cli's rewriting. Confirmed by reading devto-cli@1.4.0's lib/util.js, and corroborated by aws-iam-deep-dive and aws-sts-deep-dive, which have shipped ?v=2 through this pipeline already. Details under Verification.

Verification

Full-corpus run, not just a smoke test

Cloned the branch, ran the script for real against every tracked image asset in articles/assets/ at once, and checked the result four ways:

Check Result
Articles rewritten 104 of 106, 691 tokens, exit 0
Nothing but the token changed strip every ?v= from old and new: all 104 files byte-identical
No token landed inside code 691 checked, 0 inside code (independent oracle, see below)
Idempotent second run over the same corpus rewrites 0 articles
validate_articles.py --all 0 errors (unchanged)
markdownlint-cli2 over all 104 0 issues

The "inside code" oracle deliberately does not reuse code_mask. It uses validate_articles.py::strip_code, a separate pre-existing implementation, as a second opinion. Worth recording that the first version of that oracle was itself wrong: it compared character offsets, but strip_code preserves line count, not offsets (a fenced line becomes "", not spaces). It reported 326 false positives until it was rewritten to compare per line.

The 7 "no article references this" warnings are genuine orphans, confirmed by grep.

Collect step, simulated in a throwaway repo

Case Result
Asset change + unrelated markdown edit batch = 2 (owning article folded in)
Asset change only, no markdown touched batch = 1 (article republished on its own)
Markdown only, no assets touched batch = 1 (no extra articles pulled in)
Rerun on an unchanged asset no write, byte-identical

devto-cli behaviour, read from the source

The one assumption left was whether a relative path carrying a query survives devto-cli's rewriting. Confirmed against @sinedied/devto-cli@1.4.0 rather than inferred:

  • lib/util.js:11 getFullImagePath is path.normalize(path.join(basePath, imagePath)), which treats ?v=abc as part of the filename and passes it through. ./assets/demo/foo.png?v=2d711642 becomes https://raw.githubusercontent.com/0-draft/dev.to/main/articles/assets/demo/foo.png?v=2d711642, verified by running the same expression under node.
  • lib/util.js:25 guards cover_image rewriting with isUrl(...), so an absolute cover URL is passed through untouched, tokens included.
  • updateRelativeImageUrls uses String.replace(link, newLink), which only rewrites the first occurrence of a byte-identical link. Checked that the bump introduces no duplicate-identical link pair that did not already exist: 0 across the corpus.

Fixtures

Markdown image, <img> following a bumped markdown image, backtick fence, tilde fence, inline code, inline code sharing a line with a real image (both orders), image with a title, image in a table cell, absolute this-repo URL in the body, foreign-repo raw URL, cover_image in single and double quotes, and an article under articles/TIL/. Only the real references change; TIL/ is left alone because it never publishes.

Not verified

The workflow has not been executed on GitHub. publish.yml only runs on main, and dev push --dry-run needs DEVTO_API_KEY, which is a repo secret and is not available locally. The collect step is covered by the simulation above and by actionlint (which runs shellcheck over the run: blocks); the first real exercise will be the merge commit.

Known limitation

code_mask shares strip_code's blind spot: a four-space indented code block is not recognised as code. Both image refs in this corpus that look indented turned out to be list-item continuations, which render as images and do want bumping, so the gap is not currently exercised. Noted in the docstring.

Reviewer note

The first draft of _rewrite called pattern.sub once per pattern against a code mask indexed on the original text. The first pattern's insertions shifted the text out from under the mask, so the second pattern read the wrong mask byte and, once the text grew past the mask, raised IndexError. articles/shogi-on-github-profile.md has exactly that shape (markdown image at line 20, <img> at line 91) and would have failed the publish step. It now collects all edits in one pass and splices once. The regression fixture is the <img>-after-markdown-images case above.

Re-rendering a diagram in place reached nobody. devto-cli skips an article
whose markdown is byte-identical to what is live, and dev.to's Bunny CDN
serves every image `immutable` for a year, so even a push that did go out
left readers on the old PNG.

scripts/bump_asset_versions.py rewrites `?v=<sha256(bytes)[:8]>` on every
reference to a changed asset: the markdown now differs (devto-cli pushes) and
the URL now differs (the CDN refetches). publish.yml also triggers on
articles/assets/** and folds the affected articles into the same dev push
batch, so an asset-only commit republishes its articles on its own.

Ownership is resolved by reference, not by directory name. 40 asset
directories have no same-named article (assets/spire/ backs
spiffe-spire-deep-dive.md, assets/IssueHub/ backs issuehub.md), so every
top-level article is scanned for a reference to each changed asset. Markdown
images, <img src>, and the absolute cover_image URL are all covered; fenced
blocks and inline code are not, and cover_image is only matched inside
frontmatter so a YAML sample in the body cannot be corrupted.

The token is a content hash rather than a counter, which makes reruns
idempotent and self-healing: a run that bumps but then fails before the
commit recomputes the same value next time. This replaces the manual `?v=2`
bump the docs used to prescribe.
@coderabbitai

coderabbitai Bot commented Aug 30, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: dc60750f-214f-444e-90c8-368f0024790f


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@kanywst
kanywst merged commit 4473ff7 into main Aug 30, 2026
2 checks passed
@kanywst
kanywst deleted the feat/asset-cache-busting branch August 30, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant