Versioned Woodpecker CI plugin catalog for the nastoupil estate (W10.3)
  • Shell 40.1%
  • Python 40.1%
  • Go 10.8%
  • Dockerfile 8.9%
  • C 0.1%
Find a file
2026-10-15 15:21:40 +00:00
.woodpecker ci: notify on push failure of the plugin catalog workflow 2026-10-15 09:23:10 -05:00
docs/plans docs(plans): Phase 3 plan-debt clearing — archive notify transport plan 2026-09-01 07:49:30 -05:00
images/rust-tauri feat(images): prebuilt rust-tauri base image — the 56-175s nothing was caching 2026-08-28 15:17:45 -05:00
plugins fix(governor-gate): gate on the union of main's tip and merge-base policy 2026-10-07 08:22:03 -05:00
.gitignore chore: untrack agent-sdlc gate state (#50) 2026-09-17 20:56:53 +00:00
README.md ci: test governor-gate in plugin-tests; catalog row (unreleased) 2026-10-07 07:57:35 -05:00

ci-plugins

Versioned Woodpecker CI plugin catalog for the nastoupil estate.

Woodpecker has no include, extends, import, or remote-config keyword, and YAML anchors are file-local only (verified upstream 2026-08-18). Plugins and extensions are the entire reuse surface. This repo is that surface — ADR-0028 Decision #3, "Shared pipeline logic is packaged as versioned, privately-hosted plugin images" made concrete.

Built by W10.3. One image, referenced from many repos; never the reverse.

Catalog

Plugin Image Status
notify git.nastoupil.org/brandon/notify v0.2.5
cache-restore git.nastoupil.org/brandon/cache-restore v0.3.2
doc-gates git.nastoupil.org/brandon/doc-gates v0.1.0
pr-review git.nastoupil.org/brandon/pr-review v0.1.4
npm-publish git.nastoupil.org/brandon/npm-publish v0.1.0
buildkit-build-push git.nastoupil.org/brandon/buildkit-build-push v0.1.5
trivy-gate git.nastoupil.org/brandon/trivy-gate v0.1.2
promote-by-digest git.nastoupil.org/brandon/promote-by-digest v0.1.3
cosign-sign git.nastoupil.org/brandon/cosign-sign v0.1.4
ghcr-promote-drift git.nastoupil.org/brandon/ghcr-promote-drift unreleased
forgejo-sticky-comment git.nastoupil.org/brandon/forgejo-sticky-comment unreleased
sops-no-duplicates git.nastoupil.org/brandon/sops-no-duplicates v0.3.0
vps-ssh git.nastoupil.org/brandon/vps-ssh v0.2.0 (v0.3.0 reserved)
terraform-stack git.nastoupil.org/brandon/terraform-stack v0.2.2
tailscale-acl git.nastoupil.org/brandon/tailscale-acl unreleased
governor-gate git.nastoupil.org/brandon/governor-gate unreleased

repo-gates was retired before merge after three review rounds found five independent fail-open paths. The existing repository-local validation jobs stay authoritative; duplication is cheaper than a reusable gate that can report a false PASS.

buildkit-build-push, trivy-gate, promote-by-digest, and the deliberately narrow cosign-sign signer are W10.6. cosign-sign exists separately so the image-restricted signing key is unreachable from build, scan, and promote steps. The wave plan's plugin list is the baseline catalog; this signer is the secret-isolation boundary required by the approved signing design.

Golden pipeline archetypes

Archetype A is the repository-local validation shape documented by its first consumers. W10.5 adds two deliberately small variants: B is Python lint, typecheck, and a supported-version test matrix; D is Node package gates plus a main-only registry publish. Gates remain repository-local scripts/package commands; npm-publish is only a credential-constrained publisher, not a gate or a replacement for retired repo-gates.

Base images

Everything under plugins/ is a Woodpecker plugin: an ENTRYPOINT-only container driven by settings:, constrained by Container.IsPlugin(), and eligible for image-filtered secrets. A base image is the opposite — a commands: step runs in it, it consumes no secrets, and it has no entrypoint contract. They live under images/ so they are not in the same namespace the secret extension matches against.

Image Image path Status
rust-tauri git.nastoupil.org/brandon/rust-tauri v0.1.0

The tag discipline below applies to base images unchanged — never re-point a published tag, never publish latest, record the digest.

⛔ images/ has NO CI coverage, and that is a real gap rather than an oversight. Both steps in .woodpecker/ci.yaml enumerate explicit plugins/… paths — there is no Dockerfile lint, no image build, and no glob that would pick up a new tree. So a green pipeline on a change under images/ is evidence of nothing about that change, and everything asserted about a base image here has been verified by hand. Stated plainly because the alternative is a second top-level artifact tree that looks gated and is not. Closing it needs a lint or a build step with its own tuning, which is not this change's scope.

⚠ rust-tauri is amd64-only, a deliberate deviation from rule 5 below. Rule 5's own text concedes the multi-arch requirement is "a cheap hedge against a future arm64 node" rather than a fix for a live scheduling problem — there is no arm64 node in the cluster. The hedge is cheap for a ~20 MB Go plugin. For a Rust/Tauri toolchain image two orders of magnitude larger it means a QEMU-emulated build of the entire dependency set, and the whole point of that image is to remove build cost. Recorded here rather than dropped silently, so that adding an arm64 node surfaces this as a known gap instead of a mystery. Every plugin under plugins/ remains multi-arch.

⛔ Tag discipline — read before publishing anything

Plugin versioning here is plain container image tags. There is no manifest, no lockfile, and no registry-side resolution. Whatever discipline exists, this section is it, and nothing enforces it but review.

  1. Semver, vMAJOR.MINOR.PATCH, and tags are immutable. Never re-point a published tag at a new build. A consumer that pinned v0.1.0 and gets different behaviour tomorrow has no way to discover that happened — there is no digest pin in the consumer's pipeline file to contradict the tag.

  2. Publish the digest alongside the tag, and record it here. The digest is the only identifier in this system that cannot lie.

  3. Never publish latest. A floating tag turns "which version broke this" into an unanswerable question, and Woodpecker gives no way to see what a step actually resolved to after the fact.

  4. Bump the MINOR on any behaviour change, including a defaults change. Consumers have no changelog to read except this repo's git log. ⚠ Behaviour means the contract a consumer depends on: inputs accepted, outputs produced, exit codes, defaults, delivery semantics. Diagnostic text is not behaviour — a reworded error message on a failure path gets a PATCH. Worked examples on this repo: v0.1.6 → v0.2.0 accepted a new input spelling and defined precedence between two, so it was MINOR (and was renumbered from v0.1.7 for exactly that reason). v0.2.0 → v0.2.1 → v0.2.2 only changed what two gates print; same inputs, same exits, same delivery — PATCH. Stated here because the distinction was left implicit and a reviewer correctly flagged the ambiguity rather than the choice.

  5. Every image is multi-arch: linux/amd64 AND linux/arm64. ⛔ The rationale originally given here is FALSE against live cluster state, and the rule is kept on different grounds. It read: "echo (192.168.20.23) is the estate's only arm64 node and carries the GB10 GPU. An amd64-only plugin … silently constrains scheduling to four of five nodes." Measured 2026-08-25:

    NAME      ARCH    IP
    alpha     amd64   192.168.20.13
    bravo     amd64   192.168.20.14
    charlie   amd64   192.168.20.12
    delta     amd64   192.168.20.17
    

    Four nodes, all amd64. echo is not a cluster node at all, and there is no arm64 node to schedule onto — so the "four of five" figure was wrong in both its numerator and its premise.

    The rule stands anyway, because multi-arch costs one --platform flag and an amd64-only image is the kind of constraint that surfaces months later as capacity that mysteriously never gets used. But it is now a cheap hedge against a future arm64 node, not a fix for a live scheduling problem — and if an arm64 node is never added, this rule is worth revisiting rather than obeyed on a rationale that was never true. Assert it with docker buildx imagetools inspect, never by trusting the build command's flags.

Published digests

⚠ cache-restore v0.3.1 was published and immediately superseded by v0.3.2, and the row is kept rather than deleted. v0.3.1 carried the correct fix but a code comment quoting build-freshness numbers that had not been reproduced first-hand. Correcting the comment changed the binary — Go's build ID incorporates source content, so the change is not a no-op even under -trimpath -ldflags='-s -w' (verified: two builds, differing sha256). Reusing the tag is forbidden by the discipline above, and shipping a tag whose source no longer matches is worse than one extra version. No consumer ever pinned v0.3.1.

terraform-stack follows the same immutable supersession rule. v0.2.0 exported the wrong Cloudflare variable name, v0.2.1 corrected the runtime, and v0.2.2 additionally emits and validates the saved-plan SHA-256 needed for W10.9 acceptance. Consumers must use v0.2.2 by its index digest; none of the older tags are moved or overwritten.

Image Tag Index digest Published
doc-gates v0.1.0 sha256:7c398c37b78d04ad2e792fdd4d138b38566dae419e5a0ba66383299f10c05f8e 2026-08-26
notify v0.2.5 sha256:90a891d057f2c90274efa53778c663fa7b5a93312ca59f2f28028cdf0c51b544 2026-08-25
notify v0.2.4 sha256:571bc3bccaf711d671fd20c29412ace9f7db359ebca0a0ff9922d62e6f5840e5 2026-08-25
notify v0.2.3 sha256:c32a14b7310329473a538f13095667332b0142d4915b811c5631ba9fc9c43e71 2026-08-25
notify v0.2.2 sha256:8c9b1e12f72b479732f4f2c5cc61a76a71ab6c918b6c7c6d0dd8343fab282f45 2026-08-25
notify v0.2.1 sha256:6219a93b22b17010def03e87963427e6a1416cf1dafc64caf6102c42d051638f 2026-08-25
notify v0.2.0 sha256:07978e7ec2f0193100ca59f065eaa85efbebbb753e666cd030a97d4cdca046b7 2026-08-25
notify v0.1.6 sha256:9e39806bbc7b00ba74d8777e712b338c294cb74c0e2c856061d4f9a8ffb8d8cc 2026-08-24
cache-restore v0.3.2 sha256:0d136d411bd9e843958024b4fdd7ceb6deeae39f4dc55b4756338329d245e187 2026-08-28
cache-restore v0.3.1 sha256:2024553fe051f277ef8b40542137fd8fdfe7e9b4d1917834a23c46518dce2a6f 2026-08-28
cache-restore v0.3.0 sha256:369a5cd387af669c16c4cc06596b45a263bd7cd7d7f7bce06e5ea0055e1dde43 2026-08-24
pr-review v0.1.0 sha256:42c5094e9add73e8a50d84a718b5deeb83855b85700db4130b7d8d6c47e4c526 2026-08-25
pr-review v0.1.1 sha256:eae8c96bcfb71f1f812e6bd31eeb1854aeb9aed9f63a879704ab1b8bbabbef99 2026-08-25
pr-review v0.1.2 sha256:929bac3e7e4a3b5d7bdd8e16c478d8da5a045d8b303545ee95528392f81db72d 2026-08-25
pr-review v0.1.3 sha256:a5706105f2de8d72945c6a1fef9f053363423f6232e9ac28257b2b0b5ad2db73 2026-08-26
pr-review v0.1.4 sha256:162c072ebc4f2876515f870a827033b0546282f81f3dc6fee454d646907da128 2026-08-26
npm-publish v0.1.0 sha256:cd575d0d8387fbb798c9e75a9aee5a6fd540c716c34ec85adccebf9a4593c727 2026-08-27
npm-publish v0.1.1 sha256:0ec8786d615bd8860a2c3abc4d505d034d7971e35cd24019f1bc4431e23e494e 2026-08-27
buildkit-build-push v0.1.1 sha256:ef734a337ec6c7b42236e8fd567e60744ccfe3425975f9944cca8be9c34a292f 2026-08-27
buildkit-build-push v0.1.2 sha256:dd4d4e60bb6bb7eda950696010a5b7f5260e628d9ea766f2b13642faf91937c4 2026-08-27
buildkit-build-push v0.1.3 sha256:a32f5006319ad92d4f3aaf03e2ccbb4a4576782bbfcc6862b9fa986e02a4e5c3 2026-08-27
buildkit-build-push v0.1.4 sha256:a0c15723220a0b2c727064ec4a5884a9ffbfbb596cedac6f4ec316a75204f087 2026-08-27
buildkit-build-push v0.1.5 sha256:bb8e2f52ef1fa5130c989154eec0cf31c3ee114e5debe80763f41d27469faa46 2026-08-28
trivy-gate v0.1.1 sha256:82d7d57b069842e72ac546c6b34a97f1311057d1cbdb3ce7f0bb10d6e1a97d79 2026-08-27
trivy-gate v0.1.2 sha256:c700133f3f376042eb07bbb9f6ac4823cd64081e42abf8ac895707305a0280ac 2026-08-27
promote-by-digest v0.1.1 sha256:f9510c35522f03a75e487a4d68f286e865e7bf81f7a757b4073feb327a2fb58a 2026-08-27
promote-by-digest v0.1.2 sha256:44827caa0a53d5671f4642648a60ad1bc10e7974078826307d3eb9164ef06087 2026-08-27
promote-by-digest v0.1.3 sha256:6a765f8e19a9cdb8a9139048dd9cd499ce064c0643072cec57af96d4a9d83547 2026-08-28
ghcr-promote-drift v0.1.0 sha256:d9aae110f1dad541adf844ecb108c75d27b310e0d81450312c94a34d1810b2e5 2026-08-28
forgejo-sticky-comment v0.1.0 sha256:593b02522b10e66d996192ecab984e3c43db773b5bfc6dd695a6cdad9aac4ac7 2026-08-28
cosign-sign v0.1.1 sha256:b6be5ff6601d7044553451dc9166b4b2c4965a03158074b30b5141dc343b9a5b 2026-08-27
sops-no-duplicates v0.1.0 sha256:76e36e92c0f5090c1d05b700ffc4991219700700952fef5ca707782f11932784 2026-08-28
sops-no-duplicates v0.2.0 sha256:0629e2be7c4a6fb395f8de1f27dc49ae8821ff06d734fac1d9b1870190c0ff1d 2026-08-28
sops-no-duplicates v0.3.0 sha256:87f406532c7b1500303726640a6939dc4d128a2b38ffc0a3f02e68f00639f6d9 2026-08-28
vps-ssh v0.1.0 sha256:d40bade05756a7da678abba6f52c0dc373b9479b59bab54c2d094510b296b550 2026-08-28
vps-ssh v0.2.0 sha256:4d4f0c16c1d262b947c8d8e2daf13c1b22ef22813fe33a4003479e881160f6b2 2026-08-28
terraform-stack v0.1.0 sha256:cb6638745eb14f6bd8164de62b77255e293719f0c291a06c8fad71e7678c2f02 2026-08-28
terraform-stack v0.2.0 sha256:42a10950db6d05c1dd7ed7c911f16c0f3715eb09a114846c3650445ce0a5a542 2026-08-29
terraform-stack v0.2.1 sha256:12553def1b3d91777c93207e6bbdcf7f72dd4e51d9e572cbcde57d54fa141471 2026-08-29
terraform-stack v0.2.2 sha256:074efffd871ae73d8fb75e1b793e5f7fb63998797e3d231b49bc32653f5d8e83 2026-08-29
rust-tauri (base image) v0.1.0 sha256:f8710bb56e1ffe0c55d603e0d5427c45c3b028221402267b0ced92eed788af1b 2026-08-28

✅ The eight defective named versions are deleted: repo-gates:v0.1.0–v0.1.2 and v0.2.0, plus cache-restore:v0.1.0–v0.1.2 and v0.2.0. Verified 2026-08-24 in both directions: the public Forgejo package API lists none of those versions, and anonymous manifest inspection fails for all eight while notify:v0.1.6 still resolves. Untagged child and attestation digest records remain; those are not named versions and are left for registry garbage collection.

  • repo-gates:v0.1.0 refused to run in any container (git safe.directory rejects a differently-owned workspace). Failed closed, but unusable.
  • repo-gates:v0.1.1 and later versions fail OPEN. Reviews found unquoted expansions undergoing pathname expansion against the gated repo, so an ordinary untracked file — or a committed file named after a settings-table row — silently disabled the gate over a cleartext secret. This is the one that matters.
  • cache-restore:v0.1.0 allowed root command execution via tar option injection, and its repo slug was not injective.
  • cache-restore:v0.1.1 fixed those but shipped a mktemp template busybox rejects, so every save failed in the real image while the host suite was green.
  • cache-restore:v0.2.0 still permits inherited LD_PRELOAD to execute before its shell entrypoint and has an untested load-bearing tar operand guard.

✅ notify's v0.1.0–v0.1.5 and archtest-amd64only are DELETED (2026-08-24, verified by anonymous pull returning not-found for each, not by the API's 204). At the time of that sweep v0.1.6 was notify's only published tag (later tags came after -- see the digest table above, and see the "Do not quote a tag list here" note for why none is quoted); the other plugins' superseded tags were deleted in the same sweep — see the ✅ line above (the "eight defective named versions" block), which this clause contradicted until now.

⚠ Do not quote a tag list here. Two successive attempts to state one were wrong within minutes: the first said the superseded tags were not deleted, the second omitted v0.2.2 — the tag the very same commit published and added to the digest table above. A list of live registry state, hand-maintained in a file that does not gate on it, is stale the next time anything is published. Read it from the registry:

skopeo list-tags --no-creds docker://git.nastoupil.org/brandon/notify

Kept as a record because the sequence is the tag-discipline rule working rather than a mess: v0.1.0 shipped with gateway_url defaulting to the public atlas.nastoupil.org, which 302s to Authentik on every path. Rule 1 says a published tag is never re-pointed, so the fix shipped as v0.1.1 — and each later round of review found more, so each fix took a new number rather than quietly replacing the last. v0.1.1 is the one worth naming: it posts to ntfy unauthenticated when the gateway is down, which is why leaving it pullable was not acceptable.

⚠ Untagged child and attestation manifests were deliberately NOT deleted — some are a live tag's own layers, and removing them would break it. The control after deletion was an anonymous pull of v0.1.6 plus a real delivery.

⛔ A count stood here ("the 27 untagged…") and is gone for the same reason the tag list above is gone. It was a hand-maintained snapshot of live registry state, and every publish on this branch moved it — which is the argument, not a number to re-quote. Query it instead:

curl -s 'https://git.nastoupil.org/api/v1/packages/brandon?type=container&q=notify&limit=50' \
  | jq '[.[] | select(.version | startswith("sha256:"))] | length'

⛔ q= and limit= are both load-bearing, omitting either fails SILENTLY, and limit=50 is itself a bound that will eventually bite. Forgejo caps this endpoint at 50 regardless of what you ask for. The notify package grows by 5 items per publish, so it is a small number of releases from truncating the page — silently, with no error, returning a wrong number by the same mechanism this paragraph describes. ⚠ A live item count stood here and is deliberately gone: a hand-maintained registry snapshot inside the paragraph arguing against hand-maintained registry snapshots. Run the command to see where it stands. When it truncates, paginate; do not raise the number. A hardcoded bound against a monotonically growing set is the same shape as the hand-maintained count this paragraph replaced. The first version of this command had neither and returned 26 against the 39 stated in the paragraph it replaced — in the paragraph arguing that hand-maintained snapshots go stale. Forgejo pages this endpoint at 30 by default with a hard cap of 50, and returns a truncated page with no error; without q= it also aggregates every package under brandon, not just notify. So the replacement for a stale number was a command that reported a different wrong number with no signal it had truncated. Deleting a duplicated fact only helps if what replaces it actually answers the question.

Consuming a plugin

Plugins are settings-only by construction. ⛔ A plugin step cannot also carry commands:, entrypoint:, OR environment: — three conditions, not two, and the third is the one that gets missed. Container.IsPlugin() (pipeline/frontend/yaml/types/container.go, read at v3.17.0):

return len(c.Commands) == 0 && len(c.Entrypoint) == 0 && len(c.Environment) == 0

⚠ Why the third one matters more than it looks. A secret with a non-empty image allow-list is refused to a NON-plugin step, hard enough to fail the pipeline compile — "secret %q is only allowed to be used by plugins …" (truncated; upstream continues "(a filter has been set on the secret). Note: Image filters do not work for normal steps"). So adding an environment: block to pass a token silently disqualifies the step and breaks any image-filtered secret it uses. Pass credentials under settings: instead; notify reads both spellings as of v0.2.0 precisely so this is possible. (An earlier version of this paragraph listed only two conditions, and that omission is what made notify:v0.1.6 incompatible with an image-filtered secret.)

A plugin also runs non-privileged unless its image is listed in WOODPECKER_PLUGINS_PRIVILEGED on the server.

steps:
  - name: notify-on-failure
    image: git.nastoupil.org/brandon/notify:v0.2.5@sha256:90a891d057f2c90274efa53778c663fa7b5a93312ca59f2f28028cdf0c51b544
    settings:
      title: "${CI_REPO} failed"
      # ⛔ SET `topic:` EXPLICITLY. The default is `alerts`, and the estate's
      # CI credential is write-only on `ci` — publishing to `alerts` with it is
      # REFUSED, and per ADR-0021 §1 a refused delivery is a counted drop that
      # still exits 0. So omitting this line does not fail the build, it just
      # silently stops notifying: the exact failure this plugin exists to close,
      # reintroduced inside its own usage example. Use whatever topic YOUR
      # credential is granted; the point is that it is stated, not defaulted.
      topic: ci
      # ⚠ Under settings:, NOT environment: — an environment: block makes the
      # step a non-plugin and breaks any image-filtered secret. Secret names
      # are DASHED; the underscored form cannot exist as a K8s Secret.
      #
      # ⛔ ntfy ONLY in this example, and NOT because one credential is enough
      # in general. W10.3 PR11 retires `atlas-internal-hook-token` from the
      # estate's CI secret plane (cluster-gitops PR #328); once that lands,
      # copying such a line names a secret the plane will not serve — a COMPILE
      # failure (status=error, zero steps). Check the inventory rather than this
      # comment for what is served right now.
      #
      # ⚠ It was retired for two reasons, and only the first is about topics.
      # The gateway's allow-list does not admit `ci`, the topic the CI
      # credential is granted, so that pairing returned 400 every time. AND, on
      # an ADMITTED topic, the gateway's own metrics show every dispatch dropped
      # with `transport_error` and zero deliveries — measured, on seven
      # consecutive days, the full window those pods have existed. Do not add a gateway credential on the theory that a different
      # topic fixes it: verify a delivery first, and if you do, mint a
      # notify-scoped credential rather than reusing a shared bearer.
      #
      # ⚠ If you DO supply the gateway token, supply the ntfy one too. With only
      # the gateway token a gateway outage leaves the fallback uncredentialled
      # and the notification is refused rather than sent unauthenticated — a
      # counted drop, not a delivery.
      ntfy_token:
        from_secret: ntfy-token
    when:
      status: [failure]

Registry

git.nastoupil.org — the Forgejo instance on the VPS (ADR-0029), speaking OCI registry/2.0.

⚠ This repo is public so its packages are anonymously pullable, which is what lets a GitHub-hosted runner consume doc-gates without a credential (W10.3 D1, option B).

✅ PROVEN 2026-08-26, W10.3 PR5 signal 15. A GitHub-hosted ubuntu-latest runner pulled a digest-pinned image from this registry anonymously:

Digest: sha256:90a891d057f2c90274efa53778c663fa7b5a93312ca59f2f28028cdf0c51b544
Status: Downloaded newer image for git.nastoupil.org/brandon/notify@sha256:90a891d0...

⛔ AND IT DOES NOT FLIP TO PRIVATE IN PR5. This paragraph used to say it did, and that two credentials would appear with it — a GitHub secret on cluster-gitops plus a dockerconfigjson wired via WOODPECKER_BACKEND_K8S_PULL_SECRET_NAMES. The plan's D1 caveat 2 is explicit that a credential is minted only if anonymous pull fails. It did not fail, so PR5 minted nothing and cluster-gitops still holds four GitHub secrets, not five. Flipping this repo private later is still possible, but it is a new decision that re-incurs both credentials — not something already scheduled.

Testing a plugin

Every plugin carries tests alongside its implementation. cache-restore also has a production-image suite because its retired shell version twice diverged between the host and Alpine/busybox.

⚠ Nothing runs them automatically yet. This repo has no .woodpecker/ pipeline — a real gap for a catalog whose thesis is CI reuse, and a deliberate one: adding a pipeline here first would make this repo the estate's first real pipeline, which is the precondition PR7's OM-9 re-run is scheduled against.

That precondition was unspent when this was written: no .woodpecker/ config existed anywhere in the estate, and PR1 publishes an image and adds no pipeline. ⛔ No longer true — homelab-bootstrap/.woodpecker/validate.yaml landed with W10.3 PR3 and is the estate's only live Woodpecker CI, and the only CI of any kind among the archived-mirror repos.

⚠ Not "the estate's only live CI" full stop, and ADR-0035 is the authority that refutes it, not the one that supports it. That ADR's own table lists cluster-gitops and nastoupil-home as live mirrors — D2 still binds, and cluster-gitops' GitHub Actions validate workflow was running successfully at the time of writing. An earlier version of this sentence made the broader claim and cited ADR-0035 for it. The ⛔ paragraph just below already said the wave would add it; this one had not caught up.

⛔ And nothing in W10.3 closes this repo's gap. The wave adds .woodpecker/ to homelab-bootstrap (PR3) and dotfiles-dev (PR4), and adds one Actions job to cluster-gitops (PR5). None of those runs the suites below. So the present condition is simply that they run when a human types the command, and closing that is unowned follow-up work — not something already scheduled that a reader can wait for.

bash plugins/notify/test/notify.test.sh
bash plugins/cache-restore/test/image.test.sh

⛔ Every case must be capable of failing. Three of W10.2's verification steps were not, and each cost real time: docker exec woodpecker-server env (the image has no env binary — use docker inspect), SOPS_AGE_KEY_FILE without HOME isolation (sops falls back to the default path, so an empty file and a wrong key both return 0), and count(metric) < 2 (an empty vector yields no series, so the rule never fires — use sum(...) or vector(0)).

Prove a suite can fail by mutating the property it claims to test and watching it go red — and treat the mutation score as the real number, not the case count.

notify's first suite scored 40%: 32 of 53 mutations survived a green 32/32 run. It tested the control flow thoroughly and the security hardenings not at all — every hardening this catalog advertises was guarded by an assertion that could not fail, including one whose own comment claimed it inspected what the stub received. Three independent reviews converged on it.

Six review rounds took it from 40% to 152 cases with every reported mutation killed — 172 cases as of v0.2.2. "Every reported" is the honest phrasing, not "mutation-clean". Each round found real defects the previous round's fixes had missed, twice in the same shape: a hardening applied to one value and not its siblings, or to one leg and not the other.

What the suite now kills: the @-body swap, both Authorization headers, CRLF stripping on every header-bound value and the log-bound marker fields, topic validation, the gateway payload's shape and each field, both default endpoints and their trailing-slash trimming, the marker's escaping and each of its fields, the failover credential check, allow_anonymous exact-matching, the gateway-202 dedup branch, the payload-build-failure branch (reached by running with jq off PATH), and the timeout-vs-transport-failure distinction on both legs.