feat: Add install-docker-cli #76

Merged
ahmad merged 1 commit from feature/59-install-docker-cli into main 2026-09-09 14:44:12 +00:00
Owner

Issue

Refs #59 — the extraction. It does not close it: the second acceptance
criterion is a fleet census with no inline copies left, and converting the
consumers is per-repo follow-up work the issue's own scope defers.

Problem

The Docker CLI install is copied into every publishing job on the fleet —
sixteen sites — and has already diverged once: ahmad/expiro and
ahmad/larda carry a sed workaround for bullseye's unsigned security
suite and the other ten do not. The conventions forbid exactly this.

The sequencing question this waited on is answered.
amtronics/infrastructure-vps#420 landed the new runner default image,
and it was checked rather than assumed:

docker run --rm node:22-bookworm
  command -v docker  -> NO_DOCKER_CLI
  command -v jq      -> NO_JQ
  command -v curl    -> /usr/bin/curl
  git --version      -> 2.39.5

No Docker CLI, so deleting these steps was never an option and the
extraction branch is the right one.

Solution

install-docker-cli, a composite action with no inputs, wrapping the
proven step from templates/release.yml.

The sed workaround is deliberately not carried across. It existed
because bullseye's security suite stopped being signed; bookworm's is.
Faithful otherwise.

Review notes

One deviation from "extract faithfully", and it should be read as a
decision rather than found later.
The action returns early when
docker is already present
. Whether the runner image ships the CLI is
precisely the question that parked this issue, and the next image may
answer it differently — when it does, this becomes a skip in every
consumer at once instead of a reinstall in each. Three lines and a
comment. Say so if you would rather it were byte-faithful and I will drop
it.

What is behaviour-tested, and what is not, stated exactly. Only the
guard is driven. The rest of the step sources /etc/os-release and writes
to /etc/apt/keyrings and /etc/apt/sources.list.d — absolute paths a
stub harness cannot intercept and must not create. Its shape is covered by
check-actions.py, which now reports it (install-docker-cli/action.yml: ok (1 run step(s) checked)); what it installs is only ever proved by a
job that then runs docker.

Mutation-checked, and one mutation initially escaped, which is why
there are four cases and not three:

guard-never-fires     exit 1   harness aborts: opening line not found
guard-always-fires    exit 1   harness aborts: opening line not found
no-version-fallback   exit 1   1 case fails
drop-early-exit       exit 0   ESCAPED  -> exit 1 after adding a case

Deleting the guard's exit 0 left every case green: the sliced if block
still prints and still ends, so slicing cannot show that nothing after it
runs
. The added case drives the whole step with an empty PATH —
if the guard holds it returns before the first apt-get; if it does not,
apt-get is missing and the step dies 127. Nothing reaches a system path
either way, which is what makes running the full body safe here.

A collision I caused and fixed: my first constant was named
GUARD_OPENING, which deploy-vps's suite already uses, so its
extraction broke rather than mine. The harness failed loudly — "the suite
would test nothing" — which is the behaviour its docstring promises.

Gates, by exit code: check-actions 0, test-check-actions 0,
test-behaviour-harness 0, behaviour-tests 0, README lint 0.

Risks and trade-offs

Nothing consumes it yet, so nothing can regress from merging it. The
twelve consumers convert in their own repositories, and until they do the
inline copies stay — which is why this does not close #59.

A composite action referenced by every repository is production
infrastructure.
It is behind the @v3 major tag like the others, so
consumers adopt it deliberately rather than on the next commit to main.

### Issue Refs #59 — the extraction. It does **not** close it: the second acceptance criterion is a fleet census with no inline copies left, and converting the consumers is per-repo follow-up work the issue's own scope defers. ### Problem The Docker CLI install is copied into every publishing job on the fleet — sixteen sites — and has already diverged once: `ahmad/expiro` and `ahmad/larda` carry a `sed` workaround for bullseye's unsigned security suite and the other ten do not. The conventions forbid exactly this. The sequencing question this waited on is answered. `amtronics/infrastructure-vps#420` landed the new runner default image, and it was checked rather than assumed: ``` docker run --rm node:22-bookworm command -v docker -> NO_DOCKER_CLI command -v jq -> NO_JQ command -v curl -> /usr/bin/curl git --version -> 2.39.5 ``` **No Docker CLI**, so deleting these steps was never an option and the extraction branch is the right one. ### Solution `install-docker-cli`, a composite action with no inputs, wrapping the proven step from `templates/release.yml`. **The `sed` workaround is deliberately not carried across.** It existed because bullseye's security suite stopped being signed; bookworm's is. Faithful otherwise. ### Review notes **One deviation from "extract faithfully", and it should be read as a decision rather than found later.** The action **returns early when `docker` is already present**. Whether the runner image ships the CLI is precisely the question that parked this issue, and the next image may answer it differently — when it does, this becomes a skip in every consumer at once instead of a reinstall in each. Three lines and a comment. Say so if you would rather it were byte-faithful and I will drop it. **What is behaviour-tested, and what is not, stated exactly.** Only the guard is driven. The rest of the step sources `/etc/os-release` and writes to `/etc/apt/keyrings` and `/etc/apt/sources.list.d` — absolute paths a stub harness cannot intercept and must not create. Its shape is covered by `check-actions.py`, which now reports it (`install-docker-cli/action.yml: ok (1 run step(s) checked)`); what it installs is only ever proved by a job that then runs `docker`. **Mutation-checked, and one mutation initially escaped**, which is why there are four cases and not three: ``` guard-never-fires exit 1 harness aborts: opening line not found guard-always-fires exit 1 harness aborts: opening line not found no-version-fallback exit 1 1 case fails drop-early-exit exit 0 ESCAPED -> exit 1 after adding a case ``` Deleting the guard's `exit 0` left every case green: the sliced `if` block still prints and still ends, so slicing cannot show that *nothing after it runs*. The added case drives the **whole** step with an empty `PATH` — if the guard holds it returns before the first `apt-get`; if it does not, `apt-get` is missing and the step dies 127. Nothing reaches a system path either way, which is what makes running the full body safe here. **A collision I caused and fixed:** my first constant was named `GUARD_OPENING`, which `deploy-vps`'s suite already uses, so *its* extraction broke rather than mine. The harness failed loudly — "the suite would test nothing" — which is the behaviour its docstring promises. Gates, by exit code: `check-actions` **0**, `test-check-actions` **0**, `test-behaviour-harness` **0**, `behaviour-tests` **0**, README lint **0**. ### Risks and trade-offs **Nothing consumes it yet**, so nothing can regress from merging it. The twelve consumers convert in their own repositories, and until they do the inline copies stay — which is why this does not close #59. **A composite action referenced by every repository is production infrastructure.** It is behind the `@v3` major tag like the others, so consumers adopt it deliberately rather than on the next commit to `main`.
feat: Add install-docker-cli
All checks were successful
PR / validate (pull_request) Successful in 20s
7e4a2a79e3
The Docker CLI install was copied into every publishing job on the fleet
-- sixteen sites -- and had already diverged: two repositories carried a
workaround for bullseye's unsigned security suite and the rest did not.

Extract it. The runner's default image is node:22-bookworm, which ships
curl and git but not docker, so deleting the steps was never an option;
that was the question this waited on.

It returns early when docker is already present. Whether the runner image
ships the CLI is exactly what may change next, and then this is a skip in
every consumer at once rather than a reinstall in each.

Architecture and suite are read from the machine rather than written
down, which is what the bullseye workaround existed to patch around.

Refs #59
Author
Owner

Verified before marking this ready.

  • "Given a consumer using the action, when its job runs in the current
    default container, then the Docker CLI is available afterwards."

    Half-verified, and the half that is missing is worth naming. What is
    checked: the step is structurally sound (check-actions.py now reports
    install-docker-cli/action.yml: ok (1 run step(s) checked)), its body is
    the proven one from templates/release.yml byte-for-byte apart from the
    guard, and the default image genuinely lacks docker — run, not
    assumed. What is not checked is a job that runs the action and then
    runs docker, because nothing consumes it yet. The first converted
    consumer is where that criterion is actually met.
  • "Given the fleet, when the census is re-run, then no workflow carries
    an inline copy."
    Not met and not claimed. Converting the twelve
    consumers is per-repo follow-up work this issue's own scope defers, so
    #59 stays open on that.

The guard is behaviour-tested and mutation-checked, four cases against
the shipped file:

guard-never-fires     exit 1   harness aborts: opening line not found
guard-always-fires    exit 1   harness aborts: opening line not found
no-version-fallback   exit 1   1 case fails
drop-early-exit       exit 1   caught only after adding a case

The last one escaped at first, and the reason is the useful part: a sliced
if … fi still prints and still ends without its exit 0, so slicing
cannot express "and nothing after it runs". The added case drives the
whole step with an empty PATH, where continuing is impossible rather
than merely wrong.

Gates, by exit code: check-actions 0, test-check-actions 0,
test-behaviour-harness 0, behaviour-tests 0, README lint
0. CI green on this head.

The one thing to decide rather than discover: the early return when
docker is already present is a deliberate deviation from "extract
faithfully". It is argued in the description; if you would rather it were
byte-faithful, say so and it comes out in one commit.

Verified before marking this ready. - **"Given a consumer using the action, when its job runs in the current default container, then the Docker CLI is available afterwards."** Half-verified, and the half that is missing is worth naming. What is checked: the step is structurally sound (`check-actions.py` now reports `install-docker-cli/action.yml: ok (1 run step(s) checked)`), its body is the proven one from `templates/release.yml` byte-for-byte apart from the guard, and the default image genuinely lacks `docker` — run, not assumed. What is **not** checked is a job that runs the action and then runs `docker`, because nothing consumes it yet. The first converted consumer is where that criterion is actually met. - **"Given the fleet, when the census is re-run, then no workflow carries an inline copy."** Not met and not claimed. Converting the twelve consumers is per-repo follow-up work this issue's own scope defers, so #59 stays open on that. **The guard is behaviour-tested and mutation-checked**, four cases against the shipped file: ``` guard-never-fires exit 1 harness aborts: opening line not found guard-always-fires exit 1 harness aborts: opening line not found no-version-fallback exit 1 1 case fails drop-early-exit exit 1 caught only after adding a case ``` The last one escaped at first, and the reason is the useful part: a sliced `if … fi` still prints and still ends without its `exit 0`, so slicing cannot express "and nothing after it runs". The added case drives the whole step with an empty `PATH`, where continuing is impossible rather than merely wrong. **Gates, by exit code:** `check-actions` **0**, `test-check-actions` **0**, `test-behaviour-harness` **0**, `behaviour-tests` **0**, README lint **0**. CI green on this head. **The one thing to decide rather than discover:** the early return when `docker` is already present is a deliberate deviation from "extract faithfully". It is argued in the description; if you would rather it were byte-faithful, say so and it comes out in one commit.
ahmad changed title from WIP: feat: Add install-docker-cli to feat: Add install-docker-cli 2026-09-09 13:59:38 +00:00
ahmad_bot approved these changes 2026-09-09 14:31:35 +00:00
ahmad_bot left a comment

Approved. Machine-checkable end to end — a composite action with a behaviour suite — so the bot reviews and approves this normally rather than recusing; I ran the suite and mutated the action myself rather than reading it.

Verified

  • The extraction is faithful. Parsed templates/release.yml and install-docker-cli/action.yml at 7e4a2a79 and diffed the two step bodies: the only differences are the guard, added comments, and two line continuations. apt-get install -y ca-certificates curl gnupg, the curl | gpg --dearmor pipeline, the chmod, the deb line and apt-get install -y docker-ce-cli are unchanged, and no sed is carried across. The description's claim holds exactly.

  • check-actions.py reports it, run at the PR head: install-docker-cli/action.yml: ok (1 run step(s) checked), alongside the other eight.

  • behaviour-tests.py passes at the head — 29 cases, exit 0, including the four new ones.

  • The mutation table reproduces. I re-ran the suite against four mutations of action.yml:

    drop the guard's `exit 0`   -> exit 1, FAIL "the guard returns before any apt work,
                                   not merely alongside it": exit 127, apt-get: command not found
    drop the `|| echo 'version unavailable'` fallback -> exit 1
    invert the guard (`if ! command -v docker`)       -> exit 1
    restored                                          -> exit 0
    

    The fourth case does the work claimed for it: with the exit 0 gone the sliced-guard cases stay green and only the whole-body-with-empty-PATH case catches it, by dying 127 on the first apt-get. That is the right way to prove "nothing after it runs", and it is worth keeping in mind for the next extraction — a sliced if block cannot show that control left the script.

  • Dropping the sed is safe for every current consumer, and I checked the fleet rather than the reasoning. All 19 copies live in jobs with no pinned container:, so every one of them takes the runner default, and the default is node:22-bookworm with no override anywhere: ansible/roles/forgejo/defaults/main.yml:49 sets forgejo_runner_job_image: "node:22-bookworm" and lines 51-54 map ubuntu-latest, ubuntu-22.04, linux and self-hosted all to it. Bookworm's security suite is signed, so the workaround has nothing left to work around.

  • Nothing consumes the action yet, confirmed: no uses: of install-docker-cli anywhere on the fleet. Merging it cannot regress a pipeline.

Two corrections to the record, neither a change request

  1. The sequencing evidence is amtronics/infrastructure-vps#429 ("fix: Move the runner's default job image to bookworm", a5b4bde), not #420 — #420 is not that change. The substance is right and I verified it independently, as above; the number just will not lead the next reader to it.

  2. The census is worse than the description says, which strengthens the case rather than weakening it. Not sixteen sites diverged once — 19 sites across 13 repositories, in 8 distinct bodies:

    308f292b 6 namey, bayan-web, kidpoints, suhba-web, suhba-docs, khitta (release.yml:publish)
    8427d3e0 4 larda (deploy:deploy, pr:image, release:publish, release:deploy)
    49b375e1 3 trip, portfolio, launchpad (release.yml:publish)
    fd0818d2 2 expiro (deploy:deploy, release:deploy)
    1209b5f8 1 imamah (release.yml:publish, named "Install Docker CLI (Debian)")
    acbf437e 1 expiro (pr.yml:image)
    f7986b5c 1 expiro (release.yml:publish)
    62b9d0a0 1 stem (release.yml:publish)

    expiro alone carries three different bodies in four jobs. That is the census #59's second criterion asks for, as of main today — worth pasting onto #59 so the conversion work has a checklist and a finish line.

On the deviation you flagged: keep the early return. It is the one part of the step with a decision in it, it is the part the suite drives, and it is what makes the next runner-image change a no-op in every consumer at once instead of thirteen follow-ups. Byte-faithful would have been the wrong call here.

Approved. Machine-checkable end to end — a composite action with a behaviour suite — so the bot reviews and approves this normally rather than recusing; I ran the suite and mutated the action myself rather than reading it. **Verified** - **The extraction is faithful.** Parsed `templates/release.yml` and `install-docker-cli/action.yml` at `7e4a2a79` and diffed the two step bodies: the only differences are the guard, added comments, and two line continuations. `apt-get install -y ca-certificates curl gnupg`, the `curl | gpg --dearmor` pipeline, the `chmod`, the `deb` line and `apt-get install -y docker-ce-cli` are unchanged, and no `sed` is carried across. The description's claim holds exactly. - **`check-actions.py` reports it**, run at the PR head: `install-docker-cli/action.yml: ok (1 run step(s) checked)`, alongside the other eight. - **`behaviour-tests.py` passes at the head** — 29 cases, exit 0, including the four new ones. - **The mutation table reproduces.** I re-ran the suite against four mutations of `action.yml`: drop the guard's `exit 0` -> exit 1, FAIL "the guard returns before any apt work, not merely alongside it": exit 127, apt-get: command not found drop the `|| echo 'version unavailable'` fallback -> exit 1 invert the guard (`if ! command -v docker`) -> exit 1 restored -> exit 0 The fourth case does the work claimed for it: with the `exit 0` gone the sliced-guard cases stay green and only the whole-body-with-empty-`PATH` case catches it, by dying 127 on the first `apt-get`. That is the right way to prove "nothing after it runs", and it is worth keeping in mind for the next extraction — a sliced `if` block cannot show that control left the script. - **Dropping the `sed` is safe for every current consumer, and I checked the fleet rather than the reasoning.** All 19 copies live in jobs with **no pinned `container:`**, so every one of them takes the runner default, and the default is `node:22-bookworm` with no override anywhere: `ansible/roles/forgejo/defaults/main.yml:49` sets `forgejo_runner_job_image: "node:22-bookworm"` and lines 51-54 map `ubuntu-latest`, `ubuntu-22.04`, `linux` and `self-hosted` all to it. Bookworm's security suite is signed, so the workaround has nothing left to work around. - Nothing consumes the action yet, confirmed: no `uses:` of `install-docker-cli` anywhere on the fleet. Merging it cannot regress a pipeline. **Two corrections to the record, neither a change request** 1. The sequencing evidence is `amtronics/infrastructure-vps#429` ("fix: Move the runner's default job image to bookworm", `a5b4bde`), not `#420` — `#420` is not that change. The substance is right and I verified it independently, as above; the number just will not lead the next reader to it. 2. **The census is worse than the description says, which strengthens the case rather than weakening it.** Not sixteen sites diverged once — **19 sites across 13 repositories, in 8 distinct bodies**: 308f292b 6 namey, bayan-web, kidpoints, suhba-web, suhba-docs, khitta (release.yml:publish) 8427d3e0 4 larda (deploy:deploy, pr:image, release:publish, release:deploy) 49b375e1 3 trip, portfolio, launchpad (release.yml:publish) fd0818d2 2 expiro (deploy:deploy, release:deploy) 1209b5f8 1 imamah (release.yml:publish, named "Install Docker CLI (Debian)") acbf437e 1 expiro (pr.yml:image) f7986b5c 1 expiro (release.yml:publish) 62b9d0a0 1 stem (release.yml:publish) `expiro` alone carries three different bodies in four jobs. That is the census `#59`'s second criterion asks for, as of `main` today — worth pasting onto `#59` so the conversion work has a checklist and a finish line. **On the deviation you flagged**: keep the early return. It is the one part of the step with a decision in it, it is the part the suite drives, and it is what makes the next runner-image change a no-op in every consumer at once instead of thirteen follow-ups. Byte-faithful would have been the wrong call here.
ahmad_bot left a comment

Correcting my own first point above, which was wrong: amtronics/infrastructure-vps#420 is the issue ("Runner default job image is bullseye; security suite is dead post-LTS", closed), and #429 is the pull request that closed it. Citing #420 was a legitimate reference to the work, not a wrong number — I checked /pulls/420, got a 404, and concluded too quickly.

The verification underneath it stands unchanged: forgejo_runner_job_image: "node:22-bookworm" in ansible/roles/forgejo/defaults/main.yml:49, all four runner labels derived from it, no override anywhere, and all 19 copies of the install step in jobs with no pinned container:. Dropping the sed is safe.

The approval stands, and the census in the first comment is unaffected.

Correcting my own first point above, which was wrong: `amtronics/infrastructure-vps#420` is the **issue** ("Runner default job image is bullseye; security suite is dead post-LTS", closed), and `#429` is the pull request that closed it. Citing #420 was a legitimate reference to the work, not a wrong number — I checked `/pulls/420`, got a 404, and concluded too quickly. The verification underneath it stands unchanged: `forgejo_runner_job_image: "node:22-bookworm"` in `ansible/roles/forgejo/defaults/main.yml:49`, all four runner labels derived from it, no override anywhere, and all 19 copies of the install step in jobs with no pinned `container:`. Dropping the `sed` is safe. The approval stands, and the census in the first comment is unaffected.
ahmad merged commit c3ec85e9ee into main 2026-09-09 14:44:12 +00:00
ahmad deleted branch feature/59-install-docker-cli 2026-09-09 14:44:12 +00:00
ahmad referenced this pull request from a commit 2026-09-09 14:44:14 +00:00
Sign in to join this conversation.
No description provided.