diff --git a/.github/workflows/release-rust.yml b/.github/workflows/release-rust.yml
index e3a6cb3..4f92e1d 100644
--- a/.github/workflows/release-rust.yml
+++ b/.github/workflows/release-rust.yml
@@ -71,8 +71,49 @@ jobs:
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: block
+ # The reviewed mirror hosts below (all of .github/zig-mirrors.txt)
+ # must be probed for liveness before one can be pinned into the
+ # build matrix (see compose_build_matrix) — this list can't be
+ # derived from that file at runtime, since harden_runner is the
+ # very first step and runs before checkout_release_workflows.
+ # Keep in sync by hand: adding a mirror to zig-mirrors.txt means
+ # adding its host here too, or the probe can never reach it.
allowed-endpoints: >
+ github.com:443
ziglang.org:443
+ fs.liujiacai.net:443
+ pkg.earth:443
+ pkg.hexops.org:443
+ zig-mirror.tsimnet.eu:443
+ zig.bcr.ist:443
+ zig.chainsafe.dev:443
+ zig.karearl.com:443
+ zig.linus.dev:443
+ zig.mirror.mschae23.de:443
+ zig.savalione.com:443
+ zig.squirl.dev:443
+ zig.tilok.dev:443
+ zig.vortan.dev:443
+ ziglang.freetls.fastly.net:443
+ zigmirror.com:443
+ zigmirror.hryx.net:443
+
+ - name: checkout_release_workflows
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ # This reusable workflow's own repo content (the reviewed mirror
+ # list, see #31) — not the caller's, which is what a bare
+ # checkout would default to, and not github.workflow_sha either
+ # (that identifies the *caller's* workflow file, which won't
+ # exist as a commit in this repo at all). job.workflow_repository
+ # + job.workflow_sha are the ones documented for a reusable
+ # workflow checking out its own source.
+ repository: ${{ job.workflow_repository }}
+ ref: ${{ job.workflow_sha }}
+ persist-credentials: false
+ sparse-checkout: |
+ .github/zig-mirrors.txt
+ sparse-checkout-cone-mode: false
- name: require_tag_ref
env:
@@ -99,20 +140,67 @@ jobs:
# zigbuild legs additionally need taiki-e/install-action fetching
# the cargo-zigbuild binary from GitHub releases, and zig itself.
- # mlugg/setup-zig picks a mirror at random per run (confirmed:
- # two real runs picked two different ones), so rather than
- # allowlisting whichever one we happened to observe, fetch the
- # complete, authoritative list it draws from and allow all of it,
- # plus ziglang.org as the documented last-resort fallback.
- zig_mirrors=$(curl -fsSL https://ziglang.org/download/community-mirrors.txt \
- | sed -E 's#^https://##; s#/.*$##' \
- | sed 's/$/:443/' \
- | tr '\n' ' ')
- zigbuild_endpoints="${common_endpoints} release-assets.githubusercontent.com:443 ziglang.org:443 ${zig_mirrors}"
+ # community-mirrors.txt is mutable, externally maintained content,
+ # not something we've reviewed; trusting it directly would let an
+ # upstream compromise silently authorize a new build-time egress
+ # target with no review on our side (#31). Only trust a mirror
+ # that's BOTH currently live upstream AND already reviewed into
+ # .github/zig-mirrors.txt: a mirror going away upstream drops out
+ # with no PR needed; a new one only becomes trusted once someone
+ # reviews and commits it. Compare full base URLs, not just
+ # hostnames — a path change on an already-approved host (e.g. a
+ # hijacked /zig subpath) is exactly the kind of unreviewed change
+ # this is meant to catch, and it would slip through a
+ # hostname-only comparison.
+ live_mirrors=$(curl -fsSL https://ziglang.org/download/community-mirrors.txt \
+ | sed -E 's#/+$##' | sort -u)
+ reviewed_mirrors=$(sed -E 's#/+$##' .github/zig-mirrors.txt | sort -u)
+ approved_mirrors=$(comm -12 <(echo "$live_mirrors") <(echo "$reviewed_mirrors"))
+
+ if [ -z "$approved_mirrors" ]; then
+ echo "::error::no zig mirrors are both live upstream and reviewed in .github/zig-mirrors.txt — the reviewed list has likely gone stale, update it before retrying"
+ exit 1
+ fi
+
+ # Pin one specific, reviewed mirror for setup_zig's `mirror:`
+ # override rather than letting it pick at random from the live
+ # list (which would bypass the review above entirely). Probe
+ # candidates in random order and commit to the first that actually
+ # responds for the exact tarball this run needs: when `mirror:` is
+ # set, setup-zig does not retry or fall back to any other mirror
+ # on failure (confirmed against its source), so pinning one we
+ # haven't confirmed is reachable risks failing the whole leg with
+ # no in-run recovery. Both zigbuild legs run on ubuntu-24.04
+ # runners regardless of the cross-compile target, so there's only
+ # ever one filename to check. Reviewed mirrors serve it flat, at
+ # / — most do NOT mirror ziglang.org/download's own
+ # // layout, confirmed by probing all of
+ # them (9 of 16 404 with a version segment). HEAD, not a ranged
+ # GET: one reviewed mirror (zigmirror.com) rejects HEAD with 405,
+ # but a ranged GET fails on more (mirrors that ignore Range and
+ # stream the whole ~55MB file, timing out) — HEAD has fewer
+ # false negatives overall across the reviewed set.
+ zig_tarball="zig-x86_64-linux-${ZIG_VERSION}.tar.xz"
+ zig_mirror=""
+ while IFS= read -r candidate; do
+ if curl -fsSL --head --max-time 10 "${candidate}/${zig_tarball}" >/dev/null 2>&1; then
+ zig_mirror="$candidate"
+ break
+ fi
+ done <<<"$(printf '%s\n' "$approved_mirrors" | shuf)"
+
+ if [ -z "$zig_mirror" ]; then
+ echo "::error::none of the approved zig mirrors responded for ${zig_tarball} — check mirror availability or review .github/zig-mirrors.txt"
+ exit 1
+ fi
+
+ zig_mirror_host=$(printf '%s' "$zig_mirror" | sed -E 's#^https://##; s#/.*$##')
+ zigbuild_endpoints="${common_endpoints} release-assets.githubusercontent.com:443 ${zig_mirror_host}:443"
if ! matrix=$(jq -c \
--arg common "$common_endpoints" \
--arg zigbuild_endpoints "$zigbuild_endpoints" \
+ --arg zig_mirror "$zig_mirror" \
'
if type != "array" then
error("targets must be a JSON array of strings")
@@ -125,8 +213,8 @@ jobs:
else . end
| [ .[] | . as $t |
{ target: $t } +
- ( { "x86_64-unknown-linux-musl": { "runs-on": "ubuntu-24.04", "zigbuild": true, "allowed-endpoints": $zigbuild_endpoints },
- "aarch64-unknown-linux-musl": { "runs-on": "ubuntu-24.04", "zigbuild": true, "allowed-endpoints": $zigbuild_endpoints },
+ ( { "x86_64-unknown-linux-musl": { "runs-on": "ubuntu-24.04", "zigbuild": true, "allowed-endpoints": $zigbuild_endpoints, "zig-mirror": $zig_mirror },
+ "aarch64-unknown-linux-musl": { "runs-on": "ubuntu-24.04", "zigbuild": true, "allowed-endpoints": $zigbuild_endpoints, "zig-mirror": $zig_mirror },
"x86_64-apple-darwin": { "runs-on": "macos-15", "zigbuild": false, "allowed-endpoints": $common },
"aarch64-apple-darwin": { "runs-on": "macos-15", "zigbuild": false, "allowed-endpoints": $common }
}[$t] // error("unsupported target: \($t)") )
@@ -179,12 +267,12 @@ jobs:
with:
version: ${{ env.ZIG_VERSION }}
use-cache: false
- # No mirror override: ziglang.org is explicitly disallowed as one
- # by the action itself (protects the official site from being
- # hammered by CI). Default behaviour — a randomised community
- # mirror, falling back to ziglang.org only as a last resort — is
- # correct here; see #23 for tracking a move to a pinned mirror +
- # egress-policy: block once we have enough audit data.
+ # Pinned to the specific mirror the plan job already probed and
+ # reviewed (#31) — without this, setup-zig fetches the live
+ # community-mirrors.txt itself and picks at random, which neither
+ # this workflow's review process nor its egress allowlist would
+ # ever see.
+ mirror: ${{ matrix.zig-mirror }}
- name: install_cargo_zigbuild
if: ${{ matrix.zigbuild }}
diff --git a/.github/zig-mirrors.md b/.github/zig-mirrors.md
new file mode 100644
index 0000000..fab612e
--- /dev/null
+++ b/.github/zig-mirrors.md
@@ -0,0 +1,40 @@
+# Keeping `zig-mirrors.txt` in sync
+
+`zig-mirrors.txt` is the reviewed set of Zig community mirrors trusted as a build-job egress target for zigbuild legs. `release-rust.yml` only allows a mirror through if it's *both* in this file *and* currently live upstream (see [issue #31](https://github.com/purpleclay/release-workflows/issues/31) for why it isn't just fetched from upstream directly).
+
+This is a deliberately manual process — a bot opening a routine PR risks training reviewers to rubber-stamp it, which defeats the point of requiring review at all. Automating this is tracked as a possible future issue if the manual upkeep ever becomes a real burden; it isn't expected to.
+
+## When to check
+
+There's no fixed schedule. Worth checking when:
+
+- A zigbuild release fails with "no zig mirrors are both live upstream and reviewed" (`compose_build_matrix`'s hard-failure path) — this means the two lists have diverged enough to share nothing at all, and needs attention immediately.
+- Roughly every few months, or whenever you're touching this workflow for another reason anyway.
+
+## How to check
+
+```bash
+diff <(curl -fsSL https://ziglang.org/download/community-mirrors.txt | sort) \
+ <(sort .github/zig-mirrors.txt)
+```
+
+Lines prefixed `<` are new upstream entries — this is the part that needs actual scrutiny, not a rubber stamp. Lines prefixed `>` are in the committed file but no longer live upstream — safe to drop, no review needed, they can't be reached anyway.
+
+## Reviewing a new entry
+
+For each newly-added host, actually verify it before adding it — don't just accept it because it's on the official list. At minimum:
+
+1. Use the mirror's exact base URL as listed upstream, including its path — the path is part of what's being trusted, not incidental, and `release-rust.yml` compares the full URL when deciding what to approve (see #31). Most reviewed mirrors serve the archive flat, at `/` — unlike `ziglang.org/download` itself, which nests it under `//`; try the flat form first (only a minority of mirrors need the version segment). Download the archive *and* its `.minisig` sibling, then verify against the [Zig Software Foundation's public key](https://ziglang.org/download/) — a `200` response alone proves nothing, since a malicious mirror could serve any tarball it likes:
+
+ ```sh
+ archive=zig-x86_64-linux-.tar.xz
+ curl -fsSL "https:///$archive" -o "$archive"
+ curl -fsSL "https:///$archive.minisig" -o "$archive.minisig"
+ minisign -Vm "$archive" -P RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
+ ```
+
+ `minisign` only checks that the signature matches the downloaded bytes — it does not check that the signed file is the one you actually asked for. A mirror could substitute the signature and archive for a *different*, still-genuinely-signed release. Verify that manually: the command above prints a `Trusted comment` line containing a `file:` field — confirm it reads exactly `file:zig-x86_64-linux-.tar.xz`, matching the archive name you requested.
+
+2. Check who operates it, if that's discoverable (project README, DNS WHOIS, whether it's referenced elsewhere in the Zig community). Prefer mirrors run by identifiable people/organizations over anonymous ones.
+
+Then update `.github/zig-mirrors.txt` to match upstream, **and** add the new host to the `plan` job's `harden_runner` `allowed-endpoints` in `release-rust.yml` — `compose_build_matrix` probes reviewed mirrors for liveness before pinning one, and that probe can't reach a host harden-runner hasn't been told about. Commit both together as a normal PR, reviewed the same as any other change to this workflow.
diff --git a/.github/zig-mirrors.txt b/.github/zig-mirrors.txt
new file mode 100644
index 0000000..d43254d
--- /dev/null
+++ b/.github/zig-mirrors.txt
@@ -0,0 +1,16 @@
+https://fs.liujiacai.net/zigbuilds
+https://pkg.earth/zig
+https://pkg.hexops.org/zig
+https://zig-mirror.tsimnet.eu/zig
+https://zig.bcr.ist
+https://zig.chainsafe.dev
+https://zig.karearl.com/zig
+https://zig.linus.dev/zig
+https://zig.mirror.mschae23.de/zig
+https://zig.savalione.com
+https://zig.squirl.dev
+https://zig.tilok.dev
+https://zig.vortan.dev/zig
+https://ziglang.freetls.fastly.net
+https://zigmirror.com
+https://zigmirror.hryx.net/zig
diff --git a/.github/zizmor.yml b/.github/zizmor.yml
index a88cfdc..0174135 100644
--- a/.github/zizmor.yml
+++ b/.github/zizmor.yml
@@ -1,13 +1,13 @@
rules:
superfluous-actions:
ignore:
- # release-rust.yml:169 — dtolnay/rust-toolchain: explicit toolchain +
+ # release-rust.yml:250 — dtolnay/rust-toolchain: explicit toolchain +
# cross-target install; hand-rolling rustup here would just reimplement
# this action with less auditability.
- - release-rust.yml:169
+ - release-rust.yml:250
stale-action-refs:
ignore:
- # release-rust.yml:169 — dtolnay/rust-toolchain intentionally ships no
+ # release-rust.yml:250 — dtolnay/rust-toolchain intentionally ships no
# tags: master/stable/beta/nightly are branch aliases by design, so
# this pin is the tip of "master", not a stale reference.
- - release-rust.yml:169
+ - release-rust.yml:250
diff --git a/.gitignore b/.gitignore
index ff91a0f..8a37c15 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,8 @@
!.envrc
!.github/renovate.json
!.github/workflows/*.yml
+!.github/zig-mirrors.md
+!.github/zig-mirrors.txt
!.github/zizmor.yml
!.gitignore
!.zed/settings.json
@@ -15,6 +17,7 @@
!README.md
!RELEASE.md
!SECURITY.md
+!typos.toml
# Recurse through sub-directories applying the same patterns
!*/
diff --git a/flake.nix b/flake.nix
index 090ffb4..3b319a9 100644
--- a/flake.nix
+++ b/flake.nix
@@ -52,6 +52,7 @@
buildInputs =
[
alejandra
+ minisign
nil
typos
zizmor
diff --git a/typos.toml b/typos.toml
new file mode 100644
index 0000000..df4df60
--- /dev/null
+++ b/typos.toml
@@ -0,0 +1,12 @@
+[files]
+# A list of third-party hostnames, not prose — spellcheck will keep
+# producing false positives here as mirrors are added/removed over time
+# (e.g. "zig.bcr.ist", "zig.squirl.dev" are real domains, not typos).
+extend-exclude = [".github/zig-mirrors.txt"]
+
+[default.extend-words]
+# The same mirror hostnames also appear in release-rust.yml's harden_runner
+# allowed-endpoints (can't be excluded like zig-mirrors.txt above, since
+# that's a real code file). Keep in sync with zig-mirrors.txt.
+ist = "ist"
+squirl = "squirl"