Repository navigation
Revert to R 4.5.2 and formalize release tagging - #21
Conversation
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>
Follow-up: the weekly rebuild wasn't rebuilding anythingPushed 4780c5b after @mthomas-ketchbrook noticed last week's scheduled run finished in seconds. Confirmed — the GHA cache was defeating the entire point of the
The scheduled run's log shows every layer Worth knowing, because it disguised the problem: the published digest did change each week ( The changeScheduled runs now build cache-less; push and manual builds keep the cache, where iteration speed on 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 patchedThe inverted 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 One limit, documented in the READMEA cache-less rebuild only refreshes layers Possible follow-up, not in this PRThe weekly run still proves only that the image builds, not that it works. A smoke step after the push — |
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>
Follow-up: smoke test added (9c76725)Every published image now gets 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:
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 assumedI ran the script against the currently published base before committing it: Exit 0, no warnings anywhere in the log. Two things worth calling out from that run:
Also generalized the Two limits, documented in the workflow comments
|
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>
Follow-up: arm64 smoke test, and why it isn't running under QEMUAdded 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 testI implemented the arm64 test the cheap way first — 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
So QEMU breaks Chrome; the arm64 image is fine. Everything else on that architecture is healthy — 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 insteadThis repository is public, so 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 Worth reconsidering: the gateYou 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 Verification status
One more thing surfaced while probing: the base image pins its CRAN repo to a dated P3M snapshot ( |
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>
Follow-up: promotion is now gated, and both architectures always run (d13b352)Publishing is split into three jobs:
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 One consequence worth knowing
Verification
That last case mattered. My first version used What's still unprovenThe On merge orderingWorth folding this into this PR rather than merging the R revert first: merging then amending would publish |
Closes #20.
The fix
R 4.6.0 breaks the vscode-R session watcher, so
View()on a{gt}table fails withunable to start data viewerinside the devcontainer (vscode-R#1696). This pinsR_VERSIONback to 4.5.2 inDockerfile.base— the version @dmhhughes confirmed both fixes the viewer and still renders the example report — rather than shipping the.Rprofileworkaround. The comment onR_VERSIONrecords 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
latestandsha-<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.ZGitHub Release now stamps the image with semver tags:v0.2.0,0.2.00.2latestmainbuild, incl. the weekly rebuildsha-abc1234latestdeliberately keeps trackingmain, 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:
release: types: [published]trigger, not apushtag filter. The workflow's existingpaths:filter would also apply to tag pushes, which would silently skip publishing for any release that doesn't happen to touchDockerfile.base— a quiet failure mode where the release exists but the image tag never appears.type=semverentries are inert on branch and scheduled builds, somainpush behavior is unchanged.Docs:
CHANGELOG.md(new) records each release plus the R/Quarto/{renv}versions it ships, backfilled from everything sincev0.1.0.renv.lock; other stack changes → minor; docs/plumbing → patch), and the steps to cut a release.Dockerfilenow points at:vX.Y.Zinstead 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>, thengh 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 the4.5.x/4.6.xboundary... 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 therocker/r-ver:4.5.2base still builds multi-arch on merge, and thatView()on a{gt}table works in a rebuilt devcontainer.🤖 Generated with Claude Code