Skip to content

Revert to R 4.5.2 and formalize release tagging - #21

Merged
mthomas-ketchbrook merged 7 commits into
mainfrom
revert-r-4.5.2
Jul 28, 2026
Merged

mthomas-ketchbrook merged 7 commits into
mainfrom
revert-r-4.5.2

Conversation

@mthomas-ketchbrook

Copy link
Copy Markdown
Contributor

Closes #20.

The fix

R 4.6.0 breaks the vscode-R session watcher, so View() on a {gt} table fails with unable to start data viewer inside the devcontainer (vscode-R#1696). This pins R_VERSION back to 4.5.2 in Dockerfile.base — the version @dmhhughes confirmed both fixes the viewer and still renders the example report — rather than shipping the .Rprofile workaround. The comment on R_VERSION records the condition for moving back to 4.6.x: the upstream fix reaching the vscode-R marketplace release.

The tagging piece

A dependency revert is exactly the situation where a handoff needs a stable name for the stack it was built against. Today the base image only publishes latest and sha-<commit>, so "the R 4.5.2 base" has no durable name and the README tells you to pin :sha-abc1234.

Publishing a vX.Y.Z GitHub Release now stamps the image with semver tags:

Tag Moves? Use it for
v0.2.0, 0.2.0 Never Handoffs — pins one exact dependency stack
0.2 On patch re-releases Fixes within a minor line
latest Every main build, incl. the weekly rebuild Active development
sha-abc1234 Never Tracing an image to a commit

latest deliberately keeps tracking main, so in-flight projects still pick up the weekly OS/security rebuild. Pinning happens at handoff, not before — the devcontainer stays on :latest.

Two implementation notes worth a reviewer's eye:

  • This uses a release: types: [published] trigger, not a push tag filter. The workflow's existing paths: filter would also apply to tag pushes, which would silently skip publishing for any release that doesn't happen to touch Dockerfile.base — a quiet failure mode where the release exists but the image tag never appears.
  • The type=semver entries are inert on branch and scheduled builds, so main push behavior is unchanged.

Docs:

  • CHANGELOG.md (new) records each release plus the R/Quarto/{renv} versions it ships, backfilled from everything since v0.1.0.
  • README gains a Versioning & Releases section: what each tag means, which dependency changes bump which number (R minor jumps → major, since they invalidate an existing renv.lock; other stack changes → minor; docs/plumbing → patch), and the steps to cut a release.
  • Handoff guidance in the README and Dockerfile now points at :vX.Y.Z instead of :sha-abc1234.

Follow-up after merge

The CHANGELOG entries are under ## [Unreleased] on purpose — nothing is released until someone cuts it. Post-merge, per the README's new "Cutting a release" steps: rename that heading to ## [0.2.0] - <date>, then gh release create v0.2.0. That's what produces the first semver-tagged base image. Minor rather than major under the new policy, since 4.6.0 → 4.5.2 is a patch-level move within the 4.5.x/4.6.x boundary... worth confirming you agree with that read, since it's the first application of the policy.

Testing notes

The image build itself isn't exercised by this PR — the publish workflow only runs on main. Worth confirming the rocker/r-ver:4.5.2 base still builds multi-arch on merge, and that View() on a {gt} table works in a rebuilt devcontainer.

🤖 Generated with Claude Code

mthomas-ketchbrook and others added 2 commits July 28, 2026 10:40
R 4.6.0 breaks the vscode-R session watcher, so View() on a {gt} table
fails with "unable to start data viewer" inside the devcontainer
(REditorSupport/vscode-R#1696). A fix exists in the vscode-R dev release
but has no marketplace ETA, so pin R back to 4.5.2 -- confirmed working
by @dmhhughes -- rather than shipping a .Rprofile workaround. The comment
on R_VERSION records when we can move back to 4.6.x.

Dependency reverts like this one are exactly why handoffs need a stable
name for the stack they were built against. Add that:

- Publishing a "vX.Y.Z" GitHub Release now stamps the base image with
  immutable X.Y.Z / vX.Y.Z tags plus a floating X.Y minor line. This uses
  a `release` trigger rather than a `push` tag filter because the existing
  `paths` filter would also apply to tag pushes, silently skipping any
  release that doesn't touch Dockerfile.base.
- `latest` keeps tracking main, so in-flight projects still get the
  weekly OS/security rebuild. Pinning happens at handoff, not before.
- CHANGELOG.md records each release and the R/Quarto/{renv} versions it
  ships, backfilled from everything since v0.1.0.
- README gains a "Versioning & Releases" section: what each image tag
  means, which dependency changes bump which number, and the steps to
  cut a release. Handoff guidance now points at :vX.Y.Z instead of
  :sha-abc1234.

Closes #20

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The scheduled run was restoring every layer from the GHA cache -- the
apt-get install layer included, on both arches -- and finishing in 33s
against 7m48s for a real build. The weekly rebuild exists to pick up
OS/security patches, so it was accomplishing nothing.

Note the published digest still changed each week, which made this look
like it was working: docker/metadata-action stamps an
org.opencontainers.image.created label, so the config blob (and the
manifest digest) moves even when the filesystem layers are identical.

Scheduled runs now set no-cache and pull; push and manual builds keep the
cache for fast iteration on Dockerfile.base. Building cache-less weekly
also serves as a canary for the from-scratch path a new user takes: if the
Quarto .deb URL or an apt package stops resolving, the weekly run fails
loudly instead of succeeding on cached layers while latest rots.

Document the limit of this in the README -- packages inherited from the
rocker/r-ver parent stay frozen until Rocker republishes the tag or we
bump R_VERSION.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mthomas-ketchbrook

Copy link
Copy Markdown
Contributor Author

Follow-up: the weekly rebuild wasn't rebuilding anything

Pushed 4780c5b after @mthomas-ketchbrook noticed last week's scheduled run finished in seconds. Confirmed — the GHA cache was defeating the entire point of the schedule trigger.

Run Event Duration
30035026018 push (07-23) 7m 48s
30245304274 schedule (07-27) 33s

The scheduled run's log shows every layer CACHED, including the apt-get update && apt-get install layer on both linux/amd64 and linux/arm64. It imported gha:3939254307847461153 and went straight to pushing the manifest. No packages were installed, so no OS/security patches have reached the published image since 07-23.

Worth knowing, because it disguised the problem: the published digest did change each week (sha256:698662… → sha256:c73482…), which reads like evidence of a real rebuild. It isn't. docker/metadata-action stamps an org.opencontainers.image.created label, so the config blob and manifest digest move on every run even when the filesystem layers are byte-identical. Digest churn is not a freshness signal here.

The change

Scheduled runs now build cache-less; push and manual builds keep the cache, where iteration speed on Dockerfile.base matters more than freshness.

no-cache: ${{ github.event_name == 'schedule' }}
pull: ${{ github.event_name == 'schedule' }}
cache-from: ${{ github.event_name != 'schedule' && 'type=gha' || '' }}
cache-to: type=gha,mode=max   # always export, so the next push starts patched

The inverted cache-from condition is deliberate — cond && '' || 'type=gha' would always yield type=gha, since the empty string is falsy in GitHub expressions.

This also addresses the second half of the concern, about new users. Caching never affected what a user pulls — published layers work fine — but it did mask breakage in the from-scratch path. If the pinned Quarto .deb URL 404s or an apt package gets renamed, cached builds keep succeeding while anyone building Dockerfile.base themselves hits a wall. A cache-less weekly build now fails loudly in that case, and latest keeps pointing at the last good image.

One limit, documented in the README

A cache-less rebuild only refreshes layers Dockerfile.base builds itself — the apt-get installs, Quarto, Chrome Headless Shell, {pak}, {renv}. Packages inherited from the rocker/r-ver:4.5.2 parent stay frozen until Rocker republishes that tag or we bump R_VERSION. So this is partial patching, not comprehensive. Closing that gap fully would mean an apt-get upgrade in the base, which I'd rather not add unprompted — it inflates the image and reintroduces exactly the kind of unpinned drift this template exists to prevent.

Possible follow-up, not in this PR

The weekly run still proves only that the image builds, not that it works. A smoke step after the push — R --version, quarto --version, maybe rendering report.qmd — would turn it into a real canary. Happy to add it here or as a separate issue, your call.

A green build only proves the image assembled. Render the example report
inside the image we just pushed -- pulled back by digest, so the test
covers the exact artifact users pull rather than a separate local build.

The render is a good proxy for "does this image work": it exercises pak
(including its apt-get sysreqs pass), the knitr engine, Quarto's Typst
backend, Chrome Headless Shell via the mermaid diagrams, and the Roboto
and colour-emoji fonts. It asserts on the PDF's magic bytes and size, not
just its existence, since a half-failed Typst run can still leave a stub.

The test lives in a script rather than inline YAML so it can be run
locally against any image, which is how it was validated: rendering in
the current published base produced a 358 KB PDF with no warnings. That
run also confirmed quarto render on a single file inside a project does
fire the project's pre-render hook, so targets::tar_make() builds the
objects the report reads back.

Generalize the .gitattributes LF rule to *.sh -- this script is
bind-mounted into a Linux container too, so CRLF from a Windows clone
would break it exactly as it would postCreate.sh.

Note the limits in the workflow comments: the step runs after the push, so
it detects a broken image rather than preventing one, and only the
runner's amd64 architecture is exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mthomas-ketchbrook

Copy link
Copy Markdown
Contributor Author

Follow-up: smoke test added (9c76725)

Every published image now gets report.qmd rendered inside it before the job goes green. The test lives in .github/scripts/smoke-test.sh rather than inline YAML, so it can be run locally against any image — which is also how it was validated.

The render turns out to be a strong proxy for "does this image work," because it touches nearly everything the base image adds on top of R:

Exercised Via
{pak} + its apt-get sysreqs pass installing the devcontainer's package list
knitr engine the {r} chunks
Quarto Typst backend format: typst with the custom template partials
Chrome Headless Shell the mermaid diagrams in qmd/_03_diagrams.qmd
Roboto + colour-emoji fonts gt::tab_options(table.font.names = "Roboto") and the 🔴🟡🟢 list
{targets} pre-render hook tar_make() → tar_read() in the child docs

It asserts on the PDF's magic bytes and byte count, not just its existence — a half-failed Typst run can still leave a stub behind.

Validated locally, not assumed

I ran the script against the currently published base before committing it:

==> Versions
R version 4.6.0 (2026-04-24) -- "Because it was There"
Quarto 1.9.38
==> Installing R packages via pak
==> Rendering report.qmd
> targets::tar_make()
+ mtcars_plt dispatched
+ mtcars_tbl dispatched
==> Verifying output
==> OK: rendered _output/report.pdf (358336 bytes)

Exit 0, no warnings anywhere in the log. Two things worth calling out from that run:

  1. It confirmed an assumption I was otherwise going to ship untested: quarto render report.qmd on a single file does fire the project's pre-render hook, so targets::tar_make() builds the objects the child docs read back with tar_read(). Had it not, the render would have failed on missing targets, since _targets/objects/ is git-ignored.
  2. This validated the script, not the R revert. The published :latest is still the R 4.6.0 image, so what's proven is that the smoke test mechanics work. The 4.5.2 image gets its first real test when this merges and the workflow runs.

Also generalized the .gitattributes LF rule from .devcontainer/postCreate.sh to *.sh — the new script is bind-mounted into a Linux container too, so CRLF from a Windows clone would break it in exactly the same way.

Two limits, documented in the workflow comments

  • Detects rather than prevents. The step runs after the push, so latest will already have moved if the image is broken. Making it a true gate means pushing only the sha- tag, then promoting to latest/semver with docker buildx imagetools create once the test passes — cheap (a manifest copy, no rebuild), but it splits the metadata-action tag logic in two. Say the word if you want that.
  • amd64 only. The arm64 variant could run through the QEMU layer already set up in this job, but installing R packages under emulation is slow enough that I left it out.

mthomas-ketchbrook and others added 2 commits July 28, 2026 12:45
The build has always produced both linux/amd64 and linux/arm64, but only
amd64 was ever exercised, so an image that assembled fine and rendered
nothing on arm64 would publish green. Run the same script through the QEMU
layer already set up in this job.

Restricted to schedule and release rather than every push: pushes to main
keep fast amd64-only feedback, and these are the runs whose output actually
reaches a devcontainer or gets pinned at handoff.

Emulation is the entire cost -- not compilation. P3M serves prebuilt
aarch64 binaries for noble (all 55 packages in {gt}'s dependency tree
resolve to aarch64-unknown-linux-gnu-ubuntu-24.04), so pak installs are
downloads on both architectures. The slow and most failure-prone part is
Chrome Headless Shell rendering the mermaid diagrams under QEMU, so both
smoke steps now carry a timeout-minutes ceiling; a hang fails the job
rather than burning the runner's full allowance.

Reset the workspace between architectures, from inside a container since
the runner user cannot delete the root-owned files the first run leaves
behind. Without it the arm64 run would find _targets/objects/ populated,
the pre-render hook would skip tar_make(), and the {gt}/{ggplot2} coverage
that is much of the reason to test the second architecture would be lost --
and a leftover report.pdf could let a render that produced nothing still
satisfy the script's assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Testing arm64 through QEMU does not work. The render dies where Quarto
drives Chrome Headless Shell:

    ERROR: AssertionError:
    Error occurred during cleanup: TypeError: Child process has already
    terminated

The identical render with the mermaid diagrams removed passes under the
same emulation, so this is QEMU breaking Chrome rather than a defect in the
arm64 image -- everything else on arm64 is healthy: pak installs all 55 {gt}
dependencies from aarch64 binaries, tar_make() builds both targets in 3s,
knitr processes all 11 chunks, and Typst produces the PDF.

Shipping the previous commit as-is would therefore have turned the weekly
build permanently red for a problem that does not exist in the artifact.

This repository is public, so ubuntu-24.04-arm runners are available at no
cost. Move the smoke test into its own job with a matrix over native
runners, one per architecture, consuming the build digest as a job output.
Emulation stays where it works -- building.

Separate runners also make the workspace reset from the previous commit
unnecessary: each architecture gets its own clean checkout instead of
inheriting built targets and a rendered PDF from the run before it.

Validated with actionlint, including the fromJSON matrix expression. The
ubuntu-24.04-arm label itself is not something actionlint can check behind
a `runs-on` expression, so the first scheduled or release run is what
confirms it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mthomas-ketchbrook

Copy link
Copy Markdown
Contributor Author

Follow-up: arm64 smoke test, and why it isn't running under QEMU

Added arm64 coverage, restricted to scheduled and release runs (0655786). Getting there took a detour worth recording, because the obvious implementation is broken.

QEMU can't run this test

I implemented the arm64 test the cheap way first — docker run --platform linux/arm64 through the QEMU layer the build already sets up — and ran it locally before trusting it. It fails:

ERROR: AssertionError:
Error occurred during cleanup: TypeError: Child process has already terminated

That's Quarto losing its Chrome Headless Shell child process while rendering the mermaid diagrams. To confirm the cause rather than guess, I ran the identical smoke test on arm64 with qmd/_03_diagrams.qmd replaced by plain text:

arm64 run under QEMU Result
Full report exit 1 at the Chrome step
Mermaid diagrams removed exit 0, 3m47s

So QEMU breaks Chrome; the arm64 image is fine. Everything else on that architecture is healthy — {pak} installed all 55 of {gt}'s dependencies from prebuilt aarch64-unknown-linux-gnu-ubuntu-24.04 binaries, tar_make() built both targets in 3s, knitr processed all 11 chunks, and Typst produced the PDF.

Had I shipped that version, the weekly build would have gone permanently red for a problem that doesn't exist in the published artifact — worse than having no arm64 test at all.

Native runners instead

This repository is public, so ubuntu-24.04-arm runners are available at no cost. The smoke test is now its own job with a matrix over native runners, one per architecture, consuming the build digest as a job output so it tests exactly the image that was just pushed. Emulation stays where it demonstrably works: building.

  smoke:
    needs: publish
    strategy:
      fail-fast: false
      matrix: >-
        ${{ fromJSON(
          (github.event_name == 'schedule' || github.event_name == 'release')
          && '{"include":[{"arch":"amd64","runner":"ubuntu-latest"},{"arch":"arm64","runner":"ubuntu-24.04-arm"}]}'
          || '{"include":[{"arch":"amd64","runner":"ubuntu-latest"}]}'
        ) }}
    runs-on: ${{ matrix.runner }}

A side benefit: separate runners made the "reset the workspace between architectures" step from the previous commit unnecessary. Each architecture now gets its own clean checkout instead of inheriting built targets and a rendered PDF from the run before it, which would have let the second architecture skip tar_make() and lose most of its {gt}/{ggplot2} coverage.

Worth reconsidering: the gate

You chose "schedule and release only" from options I framed around emulation being expensive. That premise is gone — native arm64 runners are neither slow nor free-tier-limited for public repos. Running arm64 on every push to main is now a one-word change (drop the conditional and keep both matrix entries). I've left it as you specified; say the word if the changed economics changes your answer.

Verification status

actionlint passes clean, including the fromJSON matrix expression. What it can't check is the ubuntu-24.04-arm label itself, since it sits behind a runs-on expression — so the first scheduled or release run is what confirms the runner resolves. Everything else here was verified by execution: the amd64 smoke test passes (358 KB PDF, no warnings), and the arm64 findings above are from actual runs, not inference.

One more thing surfaced while probing: the base image pins its CRAN repo to a dated P3M snapshot (https://p3m.dev/cran/__linux__/noble/2026-06-23), so R package versions shift whenever Rocker republishes that tag or we bump R_VERSION — independent of renv.lock. Not worth acting on in this PR, but it's another reason the release tags earn their keep.

Split publishing into build -> smoke -> promote. The build now pushes only
the sha- tag; a separate promote job moves latest and the semver tags onto
that digest once both architectures have rendered the report. A build that
cannot render therefore never becomes anyone's latest -- those tags simply
stay on the last image that passed.

Promotion uses `docker buildx imagetools create`, a registry-side manifest
copy, so nothing is rebuilt or re-uploaded and the attestation manifests
come along with the index.

Run both architectures on every build rather than only on schedule and
release. The reason for gating them was QEMU cost, and that reason is gone
now that each runs on a native runner; more importantly, promoting latest
off a single-architecture test would mean shipping an architecture nobody
checked.

One consequence to be aware of, documented in the README and the workflow:
sha- tags are pushed before the test runs, so one exists for every build
including failed ones. Pin a released vX.Y.Z, not a sha-.

Verified: actionlint clean (it caught an SC2001 in the first draft of the
promote script), and the tag-parsing loop tested locally across the release,
push-to-main, empty and trailing-newline cases. The empty case matters --
the earlier `[ -n "$tag" ] && args+=(...)` form returned non-zero on a blank
line and would have aborted the step under `bash -e`, which is precisely the
"nothing to promote" path it was meant to handle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mthomas-ketchbrook

Copy link
Copy Markdown
Contributor Author

Follow-up: promotion is now gated, and both architectures always run (d13b352)

Publishing is split into three jobs:

publish  →  pushes ONLY the sha- tag, exposes the digest as a job output
smoke    →  renders report.qmd on amd64 and arm64, native runners, in parallel
promote  →  moves latest + semver tags onto that digest, via imagetools create

docker buildx imagetools create is a registry-side manifest copy, so promotion rebuilds and re-uploads nothing, and the attestation manifests ride along with the index. A build that can't render never becomes anyone's latest — those tags just stay on the last image that passed.

arm64 now runs on every build, not only on schedule/release. The gating rationale was QEMU cost, which disappeared with native runners; and more to the point, promoting latest off a single-architecture test would mean shipping an architecture nobody checked. That also let the fromJSON dynamic matrix go away in favour of a plain static one.

One consequence worth knowing

sha- tags are pushed before the test runs, so a sha- tag exists for every build including failed ones. That's deliberate — it keeps failures pullable for debugging — but it means sha- is no longer a safe thing to pin. The README now says to pin a released vX.Y.Z instead. This supersedes the older handoff advice that pointed at :sha-abc1234.

Verification

  • actionlint clean. It earned its keep: it flagged an SC2001 in my first draft of the promote script.
  • Tag-parsing loop tested locally across four cases — release (three semver tags), push to main (latest only), empty, and trailing newline — all under set -e.

That last case mattered. My first version used [ -n "$tag" ] && args+=(--tag "$tag"), which returns non-zero on a blank line. Since run: steps execute under bash -e, that would have aborted the step with no output on exactly the "nothing to promote" path it was written to handle. Fixed to an if block before pushing.

What's still unproven

The ubuntu-24.04-arm runner label and the imagetools create promotion both work by construction but haven't executed — the workflow only triggers on main. The merge run is the first real exercise. Given the gate, the failure mode is safe: if anything in smoke or promote misbehaves, latest stays where it is and the new image is reachable only by its sha- tag.

On merge ordering

Worth folding this into this PR rather than merging the R revert first: merging then amending would publish latest through the un-gated path — the exact thing the gate prevents — and trigger a second publish when the follow-up landed. One merge, one publish. The exception is if the {gt} viewer breakage is blocking @dmhhughes badly enough to want R 4.5.2 today, in which case merge now and I'll re-land the gate separately.

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.

Accommodate Session Watcher Not Working

1 participant