Skip to content

feat: pin every module image by digest, and give 8 games a version list - #35

Merged
ValgulNecron merged 4 commits into
mainfrom
feat/hermetic-image-pinning
Jul 22, 2026
Merged

ValgulNecron merged 4 commits into
mainfrom
feat/hermetic-image-pinning

Conversation

@ValgulNecron

Copy link
Copy Markdown
Collaborator

Why

Every one of the 16 modules shipped a floating default image (13 a bare :latest), so a pod restart could swap the game binary under a live world with no version bump and no changelog. That is not hypothetical — passivelemon/terraria-docker:terraria-latest moved from Terraria 1.4.4.9 to 1.4.5.6, changed the network protocol, and broke the e2e Terraria bot.

Separately, 9 of 16 modules declared no spec.versions at all, so those games offered users no version choice whatsoever: the Create Server wizard skips its version step when spec.versions is empty, and Settings → Version had nothing to switch between.

Closes the "hermetic module images" item on Gameplane's roadmap to v1.

What changed

Pinning engine (validate.py)

  • resolve_digest() returns the digest of the tag manifest — a multi-arch index digest where upstream publishes one. Deliberately not the per-platform digest fetch_image_config drills down to: pinning that would cut each module to a single architecture and break the arm64 half of the e2e matrix. Verified itzg/minecraft-server:java21 pins to an OCI index covering linux/amd64 + linux/arm64.
  • Registry auth is now the standard OCI bearer challenge, so any registry resolves rather than only Docker Hub. dayz (on a self-hosted GitLab container registry) was previously skipped by every rule; it is now fully checked and pinnable, and validates clean.
  • _parse_ref handles digest refs. It previously left the tag glued to the repo path for repo:tag@sha256:…, and left the registry host on the repo for non-Docker-Hub refs — both would have 404'd every request built from them. Neither could surface before, because both paths were unreachable.

New rule 10 (rule_images_pinned) draws the line at defaults:

  • ERROR on an unpinned spec.image or an unpinned default: true version — what a server runs when the user expressed no preference.
  • WARN on any other unpinned version entry, since a module may legitimately offer a moving channel the user picks by name.

It is pure string inspection with no registry call, so a network blip can never downgrade it the way the image-exists rules deliberately degrade.

validate.py --pin rewrites templates to the readable repo:tag@sha256:… form, line-wise so the templates' extensive comments survive (a YAML round-trip would discard them), and leaves any ref it cannot resolve untouched rather than inventing a digest.

Version lists added to 8 modules. displayNames say "image release" rather than naming a game version wherever the tag is the wrapper's version — following the convention palworld already set with "v2 wrapper (major line)". enshrouded is the exception: its tags really are game versions.

Deliberately floating, marked # gameplane:floating: factorio's "Experimental channel" and terraria's "tModLoader latest preview" — image-carried pre-release channels whose purpose is to track upstream, both non-default, so drift is only ever the user's explicit labelled choice.

garrys-mod deliberately gets no version list. Its only non-latest tags are base-OS variants (debian/ubuntu/-root), which are a packaging choice rather than a version; modelling them as versions would misuse the field.

Monthly refresh-pins.yml re-resolves every pinned tag and opens a PR. It never pushes to main and never auto-merges — a pin refresh means the game binary changes for everyone on next restart, which is exactly the event pinning exists to put a human in front of.

Verification

  • python3 validate.py across all 16 modules: 0 errors (3 clean, 13 warn-only). The remaining warnings are the pre-existing puid-image-needs-fsgroup set plus the two intentional floating-entry warnings.
  • The new rule was observed failing on the unpinned templates before the pin commit and passing after, so the gate is proven live rather than assumed.
  • --pin is idempotent: a second run reports "already pinned / no change" and respects the floating markers.
  • Every template still parses; the version-list commit is purely additive (no deletions).

Every module shipped a floating default image, so a pod restart could swap
the game binary under a live world with no version bump and no changelog.
That is not hypothetical: terraria-latest moved 1.4.4.9 -> 1.4.5.6, changed
the network protocol, and broke the e2e Terraria bot.

Adds the machinery to fix that and a rule to keep it fixed:

- resolve_digest() returns the digest of the TAG manifest, which for a
  multi-arch image is the index digest covering every published platform.
  Deliberately NOT the per-platform digest fetch_image_config drills down
  to -- pinning that would cut each module to a single architecture and
  break the arm64 half of the e2e matrix.
- Registry auth is now the standard OCI bearer challenge, so any registry
  resolves instead of only Docker Hub. dayz (on a self-hosted GitLab
  container registry) was previously skipped by every rule; it is now
  checked and pinnable like the rest.
- _parse_ref handles digest refs. It previously left the tag glued to the
  repo path for repo:tag@sha256:..., which would have 404'd every request
  built from it, and left the registry host on the repo for non-Docker-Hub
  refs. Neither could surface before, because both paths were unreachable.
- Rule 10 (rule_images_pinned) draws the line at defaults: ERROR on an
  unpinned spec.image or an unpinned `default: true` version, WARN on any
  other unpinned version entry. A module may legitimately offer a moving
  channel (factorio "Experimental", valheim "Public Test") -- picking one
  is the user's explicit, labelled choice. Pure string inspection, so a
  network blip can never downgrade it the way the image-exists rules degrade.
- `validate.py --pin` rewrites templates to repo:tag@sha256:... line-wise,
  preserving the comments a YAML round-trip would discard, and leaves any
  ref it cannot resolve untouched rather than inventing a digest.

Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
Runs `validate.py --pin` across all 16 modules, rewriting each image ref to
the readable repo:tag@sha256:... form. Every digest is the tag manifest's
own digest -- a multi-arch index where upstream publishes one -- so the
arm64 half of the e2e matrix keeps resolving.

Two entries stay deliberately floating, marked `# gameplane:floating`:
factorio's "Experimental channel" and terraria's "tModLoader latest
preview". Both are image-carried pre-release channels whose purpose is to
track upstream, and both are non-default, so drift is only ever the user's
explicit, labelled choice. terraria's vanilla-latest default -- the exact
ref whose 1.4.4.9 -> 1.4.5.6 drift broke the e2e bot -- is now pinned.

Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
Nine modules declared no spec.versions at all, so those games offered users
no version choice whatsoever -- the Create Server wizard skipped its version
step entirely (CreateServer.tsx only inserts it when spec.versions is
non-empty) and Settings had nothing to switch between.

Adds a pinned version list to 8 of them: 7-days-to-die, ark-survival-ascended,
cs2, dayz, dont-starve-together, enshrouded, project-zomboid and v-rising.
Each carries a `default: true` current entry plus older releases, and a
`latest` entry marked `# gameplane:floating` so the moving tag stays
available as an explicit, labelled choice.

displayNames say "image release" rather than naming a game version wherever
the tag is the wrapper's version rather than the game's -- following the
convention palworld already set with "v2 wrapper (major line)". enshrouded
is the exception: its tags really are game versions.

garrys-mod is deliberately left without a list. Its only non-latest tags are
base-OS variants (debian/ubuntu/-root), which are a packaging choice rather
than a version, and modelling them as versions would misuse the field.

Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
Pinning stops upstream releases from reaching users on their own, so there
has to be a deliberate path for them to reach users at all. This workflow is
that path: it re-resolves every pinned tag and opens a PR with the diff.

It never pushes to main and never auto-merges -- a pin refresh means the game
binary changes for everyone on next restart, which is exactly the event
pinning exists to put a human in front of. Monthly rather than nightly, since
a PR nobody has time to review is worse than no PR.

The validator runs against the NEW digests and its output is attached to the
PR body, because a fresh upstream build tripping a rule is the single most
useful thing a reviewer can see. Documented in the workflow: a PR opened with
the default GITHUB_TOKEN does not trigger other workflows, so `validate
modules` needs a nudge before merging.

Signed-off-by: valgulnecron <39313199+ValgulNecron@users.noreply.github.com>
@ValgulNecron
ValgulNecron merged commit f2247c2 into main Jul 22, 2026
1 check passed
@ValgulNecron
ValgulNecron deleted the feat/hermetic-image-pinning branch July 22, 2026 20:02
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.

1 participant