Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Auto detect text files and perform LF normalization
* text=auto

# Force LF regardless of platform/core.autocrlf so this script bind-mounted into
# the (Linux) dev container never gets checked out with CRLF endings.
.devcontainer/postCreate.sh text eol=lf
# Force LF regardless of platform/core.autocrlf. These scripts are bind-mounted
# into a Linux container (the dev container, and the CI smoke test) and must
# never be checked out with CRLF endings.
*.sh text eol=lf
61 changes: 61 additions & 0 deletions .github/scripts/smoke-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Smoke-test the "dev" base image by rendering the example report inside it, the
# way a brand-new user would. Run from the repository root, in a container built
# from Dockerfile.base:
#
# docker run --rm -v "$PWD:/project" -w /project <image> \
# bash .github/scripts/smoke-test.sh
#
# Rendering report.qmd exercises nearly everything the base image adds on top of
# R: {pak} installing packages (with its own apt-get sysreqs pass), the knitr
# engine, Quarto's Typst PDF backend, Chrome Headless Shell for the mermaid
# diagrams in qmd/_03_diagrams.qmd, and the Roboto and colour-emoji fonts that
# report.qmd and the {gt} tables ask for. A build that succeeds but produces an
# image where any of that is broken fails here instead of in a devcontainer.
#
# This script writes to the working tree (_output/, _targets/objects/, .quarto/),
# all of which are git-ignored.
set -euo pipefail

# Keep in sync with the "r-packages" arguments to postCreate.sh in
# .devcontainer/devcontainer.json. The dev-only packages listed there (httpgd,
# languageserver) are omitted; knitr and rmarkdown are named explicitly because
# Quarto's knitr engine needs them and the devcontainer only gets them
# transitively.
PACKAGES="'ggplot2', 'gt', 'knitr', 'rmarkdown', 'targets', 'tibble'"

echo "==> Versions"
R --version | head -1
echo "Quarto $(quarto --version)"

echo "==> Installing R packages via pak"
R -q -e "pak::pkg_install(c(${PACKAGES}))"

echo "==> Rendering report.qmd"
# The pre-render hook in _quarto.yml runs targets::tar_make() first, building
# the {gt} table and {ggplot2} plot that the report reads back with tar_read().
# _targets/objects/ is git-ignored, so this always rebuilds from scratch here.
quarto render report.qmd

echo "==> Verifying output"
PDF=_output/report.pdf

if [ ! -f "$PDF" ]; then
echo "FAIL: $PDF was not created"
exit 1
fi

# Check the magic bytes and size rather than mere existence: a Typst backend
# that half-failed could still leave a stub file behind.
if ! head -c 4 "$PDF" | grep -qa '%PDF'; then
echo "FAIL: $PDF exists but is not a PDF"
exit 1
fi

bytes=$(wc -c < "$PDF")
if [ "$bytes" -lt 20000 ]; then
echo "FAIL: $PDF is only ${bytes} bytes; expected a multi-page report"
exit 1
fi

echo "==> OK: rendered $PDF (${bytes} bytes)"
176 changes: 172 additions & 4 deletions .github/workflows/publish-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ on:
# Rebuild when the base definition or this workflow changes.
- Dockerfile.base
- .github/workflows/publish-image.yml
# Publishing a GitHub Release (tagged "vX.Y.Z") stamps the image with
# immutable semver tags. This is a `release` trigger rather than a `push` tag
# filter on purpose: the `paths` filter above would otherwise also apply to
# tag pushes and silently skip releases that don't touch Dockerfile.base.
release:
types: [published]
# Allow manual rebuilds (e.g. to publish a fresh base without a code change).
workflow_dispatch:
# Weekly rebuild so OS/security patches in the base layers stay current.
Expand All @@ -26,13 +32,23 @@ jobs:
permissions:
contents: read
packages: write
# Consumed by the `smoke` job below, so it tests this exact build rather
# than whatever `latest` happens to point at by the time it runs.
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- name: Check out the repository
uses: actions/checkout@v7

# QEMU + Buildx are required to build the arm64 image on an amd64 runner.
# The base is multi-arch (see TARGETARCH in Dockerfile.base); without
# these steps we would silently publish an amd64-only image.
#
# Emulation is fine for *building* -- apt, the Quarto .deb, and the R
# package installs all work under QEMU. It is only Chrome at runtime that
# doesn't, which is why the `smoke` job below uses native runners instead.
# Splitting the build across a native runner per architecture and merging
# the manifests would be faster still, but that is a larger change.
- name: Set up QEMU
uses: docker/setup-qemu-action@v4

Expand All @@ -46,16 +62,23 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# This job pushes ONLY the sha- tag. The user-facing tags (latest and the
# semver lines) are applied by the `promote` job, after the smoke test
# passes, so a broken image can never become anyone's `latest`. Tagging
# scheme, in full, is in the README's "Versioning & Releases".
#
# Consequence worth knowing: a sha- tag exists for every build, including
# ones that fail the smoke test. Pinning a sha- tag means pinning something
# possibly untested -- pin a released vX.Y.Z instead.
- name: Extract image metadata (tags, labels)
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/ketchbrookanalytics/quarto-pdf-dev
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha
tags: type=sha

- name: Build and push
id: build
uses: docker/build-push-action@v7
with:
context: .
Expand All @@ -64,5 +87,150 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
# The weekly run must build from scratch. With the cache enabled it
# restored every layer -- including `apt-get install` -- and finished in
# 33s, so no OS/security patches ever landed and the "rebuild" only
# republished the same layers under a fresh timestamp label. Building
# cache-less also re-validates that the base builds the way a brand-new
# user builds it: if an external download (the Quarto .deb, an apt
# package) stops resolving, the weekly run fails loudly instead of
# sailing through on cached layers while `latest` quietly rots.
#
# Push and manual builds keep the cache, where fast iteration on
# Dockerfile.base matters more than freshness.
no-cache: ${{ github.event_name == 'schedule' }}
# Re-resolve rocker/r-ver rather than trusting a cached parent.
pull: ${{ github.event_name == 'schedule' }}
# `no-cache` already suppresses cache imports; set explicitly so the
# intent survives future edits. Note the inverted condition -- an
# expression of the form `cond && '' || 'x'` always yields 'x', since
# the empty string is falsy.
cache-from: ${{ github.event_name != 'schedule' && 'type=gha' || '' }}
# Always export, so the next push build starts from patched layers.
cache-to: type=gha,mode=max

# A green build only proves the image assembled, not that anyone can use it.
# Render the example report inside the image that was just pushed, pulled back
# by digest so this tests the exact artifact users will pull rather than a
# separate local build. See .github/scripts/smoke-test.sh for what the render
# covers. This job gates `promote` below, so a failure here leaves `latest`
# pointing at the last image that did pass.
smoke:
needs: publish
permissions:
contents: read
timeout-minutes: 30
# Each architecture tests on its own native runner, not through the QEMU
# layer the build uses. That is not a preference: rendering under emulation
# dies where Quarto drives Chrome Headless Shell ("AssertionError ... Child
# process has already terminated"), while the identical render with the
# mermaid diagrams removed passes, so the emulation layer breaks Chrome, not
# the arm64 image. Public repositories get ubuntu-24.04-arm runners at no
# cost, so test on real hardware rather than working around QEMU. Separate
# runners also mean each architecture starts from a clean workspace, instead
# of the second one inheriting built targets and a rendered PDF from the
# first.
#
# Both architectures run on every build. Native arm64 runners are free for
# public repositories and no slower than amd64, so there is nothing to save
# by testing only one -- and since this job now gates promotion, testing one
# architecture would mean promoting `latest` for an architecture nobody
# checked.
#
# fail-fast is off so a single-architecture failure still reports the other
# one's result; either failure blocks promotion.
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
# Currently, ARM64 runners are version-pinned (there's no `ubuntu-latest` available)
- arch: arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- name: Check out the repository
uses: actions/checkout@v7

- name: Render report.qmd inside the published image
env:
IMAGE: ghcr.io/ketchbrookanalytics/quarto-pdf-dev@${{ needs.publish.outputs.digest }}
run: |
docker run --rm \
--platform linux/${{ matrix.arch }} \
--volume "$PWD:/project" \
--workdir /project \
"$IMAGE" \
bash .github/scripts/smoke-test.sh

# Only now, with both architectures proven to render, do the tags anyone
# actually pulls start pointing at this build. `imagetools create` copies the
# existing multi-arch index (attestations included) under new tags -- it is a
# registry-side manifest operation, so nothing is rebuilt or re-uploaded.
#
# Because this job is what moves `latest`, a smoke-test failure leaves users on
# the last image that passed. The trade-off is that `latest` lags a failing
# build rather than tracking HEAD, which is the point.
promote:
needs: [publish, smoke]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Log in to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4

# The semver entries are no-ops unless the ref is a "vX.Y.Z" tag, and
# `latest` only applies on the default branch -- so a release run promotes
# the version tags and a push to main promotes `latest`.
- name: Work out which tags to promote
id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/ketchbrookanalytics/quarto-pdf-dev
tags: |
type=semver,pattern={{version}}
type=semver,pattern=v{{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}

- name: Promote the tested image
env:
# Newline-separated, passed through the environment rather than
# interpolated into the script, so tag values can't break quoting.
TAGS: ${{ steps.meta.outputs.tags }}
SOURCE: ghcr.io/ketchbrookanalytics/quarto-pdf-dev@${{ needs.publish.outputs.digest }}
run: |
tags=()
while IFS= read -r tag; do
# An `if` rather than `[ -n "$tag" ] && ...`: the && form returns
# non-zero on the blank line, and these steps run under `bash -e`.
if [ -n "$tag" ]; then
tags+=("$tag")
fi
done <<< "$TAGS"

# Nothing to promote when neither rule matched -- e.g. a manual run on
# a non-default branch, which should stay reachable only by sha- tag.
if [ "${#tags[@]}" -eq 0 ]; then
echo "No promotion tags for this ref; leaving the sha- tag as the only reference."
exit 0
fi

args=()
for tag in "${tags[@]}"; do
args+=(--tag "$tag")
done

docker buildx imagetools create "${args[@]}" "$SOURCE"
echo "Promoted $SOURCE to:"
printf ' %s\n' "${tags[@]}"
86 changes: 86 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Changelog

All notable changes to this template are recorded here. The format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
adheres to the versioning policy documented in the
[README](README.md#versioning--releases).

Because the pinned dependency stack (R, Quarto, `{renv}`) *is* the product here,
every release note calls out the versions it ships.

## [Unreleased]

Dependency stack: **R 4.5.2**, **Quarto 1.9.38**, **`{renv}` 1.2.3**

### Added

- Reusable `dev` base image, defined in `Dockerfile.base` and published to
`ghcr.io/ketchbrookanalytics/quarto-pdf-dev` by
[.github/workflows/publish-image.yml](.github/workflows/publish-image.yml).
New projects pull the pre-built base instead of rebuilding R and Quarto from
scratch. Rebuilt weekly so OS/security patches stay current.
- Semver release tagging. 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, so a handoff can pin a known-good dependency stack by version instead of
by commit SHA.
- A smoke test ([.github/scripts/smoke-test.sh](.github/scripts/smoke-test.sh))
that renders [report.qmd](report.qmd) inside every published base image, so a
build that assembles but can't actually render fails in CI rather than in
someone's devcontainer. The render covers `{pak}`, the knitr engine, Quarto's
Typst backend, Chrome Headless Shell (the mermaid diagrams), and the Roboto and
emoji fonts. Both `linux/amd64` and `linux/arm64` are tested on every build,
each on a native runner rather than through the QEMU layer used to build them,
because Chrome Headless Shell cannot render under emulation.
- The smoke test gates publication. The build pushes only a `sha-` tag; `latest`
and the semver tags are moved onto that image by a separate `promote` job once
both architectures have rendered the report, so a build that can't render never
becomes anyone's `latest`.
- Multi-architecture base image (`linux/amd64` and `linux/arm64`), built with
QEMU + Buildx.
- Chrome Headless Shell, installed via `quarto install`, so mermaid and graphviz
diagrams render without a system Chrome/Chromium package.
- Emoji support in rendered PDFs, via `fonts-noto-color-emoji`.
- Devcontainer tooling: the [arf](https://github.com/eitsupi/arf) R terminal,
[Air](https://posit-dev.github.io/air/) for R code formatting, and the
[Claude Code](https://code.claude.com/docs/en/vs-code) VSCode extension.
- Example report scaffolding: a [{targets}](https://docs.ropensci.org/targets/)
pipeline, [Quarto child documents](qmd/), and a `references.bib` bibliography.

### Changed

- **Reverted R from 4.6.0 back to 4.5.2.** R 4.6.0 breaks the vscode-R session
watcher, which makes `View()` fail on `{gt}` tables and other HTML widgets
inside the devcontainer
([#20](https://github.com/ketchbrookanalytics/quarto-pdf-dev/issues/20),
upstream [vscode-R#1696](https://github.com/REditorSupport/vscode-R/issues/1696)).
A fix exists in the vscode-R development release; R can move back to 4.6.x once
that ships to the marketplace.
- Bumped `{renv}` to 1.2.3.
- Collapsed the project `Dockerfile` from a multi-stage build to a single stage
built `FROM` the published base image.
- Bumped the workflow's GitHub Actions to their Node 24 majors.
- Figures may now break across pages in Typst PDFs.

### Fixed

- `View()` on a `{gt}` table no longer errors with "unable to start data viewer"
in the devcontainer (see the R revert above).
- The weekly base-image rebuild now actually rebuilds. It was restoring every
layer from the GitHub Actions cache -- `apt-get install` included -- and
finishing in 33 seconds, so no OS/security patch ever reached the published
image despite the manifest digest changing each week. Scheduled runs now build
cache-less, which also verifies the base still builds from scratch the way a
new user builds it. Push and manual builds keep the cache.
- `arf` and the REditorSupport extension now share a session, so objects created
in the `arf` terminal appear in the VSCode R workspace viewer.
- `arf` and `pak::pkg_install()` no longer run in parallel during
`postCreateCommand`, which could race.
- `PostCreate.sh` is forced to LF line endings, so the devcontainer builds from a
Windows clone.

## [0.1.0] - 2026-04-14

Initial release.

[Unreleased]: https://github.com/ketchbrookanalytics/quarto-pdf-dev/compare/v0.1.0...HEAD
[0.1.0]: https://github.com/ketchbrookanalytics/quarto-pdf-dev/releases/tag/v0.1.0
9 changes: 5 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
# renv) is pre-built from Dockerfile.base and published to ghcr.io by
# .github/workflows/publish-image.yml, so new repos no longer rebuild it.
#
# ":latest" always tracks the newest base. For a fully reproducible build,
# pin this to an immutable digest tag (e.g. ":sha-abc1234"); the publish
# workflow tags every build with its commit SHA. Do this at handoff, alongside
# locking renv.lock (see the README's "Pre-Deployment Steps").
# ":latest" always tracks the newest base, so an in-flight project picks up
# OS/security patches from the weekly rebuild. For a fully reproducible build,
# pin this to a released version tag (e.g. ":v0.2.0") at handoff, alongside
# locking renv.lock. See the README's "Pre-Deployment Steps" and
# "Versioning & Releases" sections.
FROM ghcr.io/ketchbrookanalytics/quarto-pdf-dev:latest

# Set the working directory for the project
Expand Down
Loading