- Status: Accepted
- Phase: H (0.1.16 candidate, develop branch)
- Supersedes: the implicit
apt-get install zsh+ reliance onubuntu-latesthaving pwsh / nu for thetest-linuxjob in 0.1.13–0.1.14. - Authors: ShortArrow, with Claude Code collaboration on alternatives analysis (and Codex-driven review).
- Date: 2026-05-10
Phase A–G left the test suite reproducible in the small but non-reproducible in the large:
- The
test-linuxjob onubuntu-latestapt install-edzshinline and depended onpwsh/nubeing on the runner image for the rest. Each GHA runner respin could shift bash to a new patch, swapnubetween 0.105 and 0.111, or quietly removexclip. We had no record of the tooling matrix the gate ran on. - Local hand-checks on WSL Arch / Fedora / openSUSE showed
diverging behaviour from CI for shell-integration and PTY tests
(
expectrlagainst bash 4 vs 5,xclipvswl-pastevsxsel,clipboard.rsprovider chain order). When CI broke, isolating whether the cause was runex, the runner image, or the developer's dotfiles cost real time. - WSL spawn latency from inside a developer's terminal added ~3-5 s
to every Linux-only test invocation, and
miseshim drift (Phase G's root cause) demonstrated that PATH resolution alone could change behaviour between two ostensibly-identical machines.
The user's framing — "次のタスクは ci や e2e を container でも実行 可能にすること" — was scoped explicitly:
- Linux only. macOS / Windows stay on native runners; Apple Silicon containers and Server Core are not workable as a CI baseline.
- Debian/Ubuntu LTS as the starting distro. Future Arch / Fedora / openSUSE images come later (Phase H5, separate ADR).
apt-installed shell tooling. Don't reach for non-distro package sources unless the upstream forces it (pwsh from MS apt,nufrom GitHub releases — both pinned).- Use GitHub Actions'
jobs.<id>.containerfield. Don't roll a custom runner image or spin up a sidecar.
Build one ghcr.io/shortarrow/runex-ci:<tag> image from
containers/ci/ubuntu.Dockerfile, push it from a dedicated
build-ci-image.yml workflow, and consume it from ci.yml's
test-linux job by manifest-list digest. Hand-checks on
developer machines use the same image via
docker run --user 1001 ....
Concretely:
- Single image, two consumers. The same image runs CI and dev
hand-checks. The only difference is the volume mount (
/workspacebind-mounts the repo) and the host caches. There is no separatedev.Dockerfile. - GHCR, public, OCI manifest-list.
ghcr.io/shortarrow/runex-cipushed bybuild-ci-image.yml.latesttag for unversioned consumers (developerdocker run);sha-<git_sha>tag for traceability back to the source commit;@sha256:<index_digest>pin inci.ymlso a re-built image cannot silently change what the gate runs against. - Build-time pinning, not runtime. Every floating input is
either a
DockerfileARG (NU_VERSION=0.112.2,RUST_TOOLCHAIN=stable,NODE_MAJOR=20) or a digest (FROM ubuntu:24.04@sha256:...). The pwsh apt feed, NodeSource apt repo, and rustup installer are validated by GPG / TLS at build time and the resulting binaries land in the layer cache. Bumping any of these is a one-line commit visible ingit log -p. - Sanity check in the build, not at CI runtime.
containers/ci/sanity.shruns as the lastRUNof the Dockerfile and asserts every tool the test suite reaches for (bash 4+, zsh, pwsh, nu, xclip, wl-paste, xsel, git, curl, node, cargo, rustc) is present and prints its version. A missing tool failsdocker build, notcargo test. - Non-root user, fixed UID.
useradd --uid 1001 runexmatches the--user 1001GitHub Actions passes when a container is declared on aubuntu-latestrunner.RUSTUP_HOMEandCARGO_HOMEare pinned to absolute paths under/home/runexso they remain discoverable even when the consumer overrides$HOME(which GHA does —HOME=/github/home). - PR-trigger build-only path.
build-ci-image.ymlruns onpull_requestpaths as a build-only check (push: false, GHCR login skipped). A broken Dockerfile fails CI before it can land ondevelopormain.
The path of least resistance. We were already doing this for zsh.
Rejected:
- Doesn't pin upstream tool versions. A
numinor bump on the runner image silently changes test behaviour. - Doesn't help dev hand-checks at all; the runner image is opaque to developers.
- Each
ci.ymlstep incurs network and apt-cache cost on every CI run, even though the tools have not changed.
Spin up ubuntu, arch, fedora, opensuse Dockerfiles in
parallel right away and matrix ci.yml over them.
Rejected for now:
- Triples the surface area on the very first roll-out, before we know whether a containerized Linux gate even works for our PTY tests.
- The package-name and repo-source differences across distros warrant their own per-distro design discussion. Land Phase H on Ubuntu first; Phase H5 (separate ADR) adds the matrix.
- Single-distro is enough to get the reproducibility win. The matrix is a follow-on, not a prerequisite.
Use image: ghcr.io/shortarrow/runex-ci:latest and let the rebuild
cycle in build-ci-image.yml propagate.
Rejected:
- Defeats the reproducibility goal. A re-built image is a silent behavioural change to every running PR. We already saw the failure mode where a CI run kicks off before the image push completes, leaves the consumer pulling an unrelated image version, and the failure looks like a runex regression.
- The two-tag scheme (
:latestfor dev,@sha256:...for CI) splits the responsibility cleanly: developers get convenience, CI gets determinism.
container-native runners
Genuinely considered as a way to avoid GHA's HOME-override quirk.
Rejected:
- New auth surface, new billing surface, new YAML dialect. The
GHA quirk is a one-line
ENV RUSTUP_HOME=/home/runex/.rustupfix in the Dockerfile (and now ADR-documented), not a vendor switch. - Loses the cross-cut with
release.ymlandbuild-ci-image.ymlthat share the same SHA-pinned-actions /persist-credentials: falseposture.
These are the choices a future maintainer must not silently undo.
FROMline includes a digest. Floating tags re-introduce the reproducibility hole. Re-resolve withdocker buildx imagetools inspect ubuntu:24.04when bumping.ARG NU_VERSION=.../ARG RUST_TOOLCHAIN=.../ARG NODE_MAJOR=...are explicit=-defaulted. A bump must be a commit, not a workflow input override.- Sanity script runs as the last
RUNof the Dockerfile. Move it earlier and a missing tool only fails at CI runtime, after a push to GHCR. RUSTUP_HOME/CARGO_HOMEareENVlines, notARG. The GHA HOME-override depends on these being set in the image's environment, not just at build time.build-ci-image.ymlcontext: containers/ci. Withcontext: .Docker reads the repo-root.dockerignore(absent), ships the entire workspace to buildx, and ignores the localcontainers/ci/.dockerignore. The local context keeps build cache hits stable across unrelated edits.ci.ymltest-linux pins by@sha256:...digest, not tag. See alternative C above.- Both workflows use
persist-credentials: falseonactions/checkout. Matchesrelease.yml. TheGITHUB_TOKENmust not linger on disk for build / test code to read. build-ci-image.ymlpull_requesttrigger usespush: falseand skips GHCR login. Fork PRs lackpackages: writeand we do not want untrusted code pushing to the registry image.
- Edit
containers/ci/ubuntu.Dockerfileand / or its sanity script. Commit ondevelop(or a feature branch). The path filter triggersbuild-ci-image.ymlon push todevelop/main. - Open the resulting workflow run. Its Step Summary prints
the image's
@sha256:...digest. - Commit a one-line edit to
.github/workflows/ci.ymlreplacing theimage:digest undertest-linux.container. Commit message format:ci(linux): bump pinned runex-ci digest to .... - Push. The next CI run uses the new image; any regression in the bump is bisectable to that single commit.
For PR validation of a Dockerfile change, the pull_request
trigger on build-ci-image.yml runs the build with
push: false — a green PR build is sufficient evidence that
the Dockerfile compiles and the sanity script passes.
- arch / fedora / opensuse Dockerfiles (Phase H5, separate ADR).
- arm64 image build. linux/amd64 only for now.
- macOS / Windows containerization. Apple Silicon / Server Core are not workable as CI baselines.
- Pinning rust to a specific minor (e.g.
1.83.0) viarust-toolchain.toml.RUST_TOOLCHAIN=stablematchesdtolnay/rust-toolchain@stableon the native runners; tightening this would also force the macOS / Windows jobs to drop thedtolnayaction, which is its own design discussion. - Caching
target/inside the image.Swatinem/rust-cache@v2on the consumer side already covers this and stays branch / commit aware in a way an image-baked cache cannot.
The Phase H roll-out validated the design end-to-end on develop:
docker build -f containers/ci/ubuntu.Dockerfile -t runex-ci-test containers/cifinishes locally with the sanity script printing the full tool version banner.cargo test --locked --workspaceinside the image (matching the CI invocation) on WSL Arch matches the green CI gate (run 25623276155 ondevelop): all jobs (linux/macos/windows) pass.- A simulated GHA HOME override (
docker run -e HOME=/github/home --user 1001 ...) printscargo --versioncleanly, confirming theRUSTUP_HOME/CARGO_HOMEpin works.