release: nix flake with tag-pinned hashes, reproducible tarballs (milestone 40)

flake.nix fetches the release tarballs and carries their SRI hashes in a generated block. The cut tool builds the release locally with the toolchain gates.yml pins, in a normalized nine-variable environment, writes the hashes into flake.nix, and commits it with build.zig.zon as the single bump commit. The package job verifies the pins on the bump commit and the publish job verifies them again on the tag, before anything is uploaded.

The tarballs are written by dist_stage (std.tar.Writer, flate gzip) instead of the runner's tar and gzip, and -ffile-prefix-map keeps checkout paths out of the C objects; two checkouts at different absolute paths produce byte-identical archives. nxdns version, /api/version and the admin footer report the version only: the bump commit cannot know its own sha.
This commit is contained in:
2026-09-08 21:45:22 +02:00
parent 3e57f43e08
commit 22abcd9b7b
41 changed files with 1739 additions and 135 deletions
+74 -4
View File
@@ -44,6 +44,11 @@ env:
# Exact patch, not a floating "24" (milestone-14 ruling 12): the bundled npm # Exact patch, not a floating "24" (milestone-14 ruling 12): the bundled npm
# and the emitted bundle change under a floating major. # and the emitted bundle change under a floating major.
NODE_VERSION: "24.19.0" NODE_VERSION: "24.19.0"
# The npm the cut runs and the npm CI runs must be one version: npm writes the
# admin bundle whose bytes the release hashes are pinned to before CI ever
# rebuilds them. setup-node installs the npm that ships with the node above,
# so this pin is asserted, not installed.
NPM_VERSION: "11.17.0"
# There is deliberately no CI_VERSION literal here. Ruling 2 allows the # There is deliberately no CI_VERSION literal here. Ruling 2 allows the
# version to exist in the tag and in build.zig.zon and nowhere else, and # version to exist in the tag and in build.zig.zon and nowhere else, and
# ruling 5 makes verify-dist fail when the version under build disagrees with # ruling 5 makes verify-dist fail when the version under build disagrees with
@@ -126,9 +131,25 @@ jobs:
cache: npm cache: npm
cache-dependency-path: admin/package-lock.json cache-dependency-path: admin/package-lock.json
# The bundle is a release input whose hash is pinned before this run
# exists, so a runner on a different node or npm must fail here rather
# than emit different bytes further down.
- name: Assert the pinned Node and npm
run: |
test "$(node --version)" = "v${NODE_VERSION:?}"
test "$(npm --version)" = "${NPM_VERSION:?}"
test ! -e /nonexistent/npmrc-user
test ! -e /nonexistent/npmrc-global
# `npm ci` and `npm run build` run under the same normalized environment
# the cut builds the bundle in: exactly nine variables, a file mode from
# the umask, a C locale, UTC, and a zero build timestamp. Each of those
# can move the bytes the release hashes cover. The format, lint,
# typecheck and test steps below stay ambient on purpose: only the
# bundle's bytes are pinned, and those checks emit nothing that ships.
- name: Install dependencies - name: Install dependencies
working-directory: admin working-directory: admin
run: npm ci run: env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=true npm_config_userconfig=/nonexistent/npmrc-user npm_config_globalconfig=/nonexistent/npmrc-global sh -c 'umask 022 && npm ci'
- name: Check formatting - name: Check formatting
working-directory: admin working-directory: admin
@@ -148,7 +169,7 @@ jobs:
- name: Build - name: Build
working-directory: admin working-directory: admin
run: npm run build run: env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=true npm_config_userconfig=/nonexistent/npmrc-user npm_config_globalconfig=/nonexistent/npmrc-global sh -c 'umask 022 && npm run build'
# The licence inventory has to cover every package whose bytes ship, and # The licence inventory has to cover every package whose bytes ship, and
# the lockfile does not answer that question: it lists what could be # the lockfile does not answer that question: it lists what could be
@@ -191,7 +212,12 @@ jobs:
version: ${{ steps.zon-version.outputs.version }} version: ${{ steps.zon-version.outputs.version }}
steps: steps:
# Depth 2, not the default 1: the pin check at the foot of this job runs
# only on the commit that changed build.zig.zon, and answering that
# question needs HEAD's first parent. No other job here reads a parent.
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
fetch-depth: 2
- name: Set up Zig - name: Set up Zig
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
@@ -243,7 +269,6 @@ jobs:
set -euo pipefail set -euo pipefail
zig build dist \ zig build dist \
-Dversion-string="$CI_VERSION" \ -Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \
-Dadmin-dist=admin-dist-ci \ -Dadmin-dist=admin-dist-ci \
-Doptimize=ReleaseSafe -Doptimize=ReleaseSafe
@@ -259,10 +284,55 @@ jobs:
set -euo pipefail set -euo pipefail
zig build verify-dist \ zig build verify-dist \
-Dversion-string="$CI_VERSION" \ -Dversion-string="$CI_VERSION" \
-Dgit-commit="$GITHUB_SHA" \
-Dadmin-dist=admin-dist-ci \ -Dadmin-dist=admin-dist-ci \
-Doptimize=ReleaseSafe -Doptimize=ReleaseSafe
# The cut writes the release hashes into flake.nix before it makes the
# bump commit, so the bump commit is the one commit whose pins nothing has
# verified yet — the tag's own run (release.yml) is the next chance, and by
# then the tag is public. This step is that first chance.
#
# It is deliberately NOT part of verify-dist. Every other commit on master
# builds the same build.zig.zon version from a different tree, so its bytes
# legitimately differ from the pins and a check there would fail the whole
# branch. The commit is identified by the version it declares, not by its
# message: a message is a string anyone can write, and the pins follow
# the manifest.
#
# A root commit has no first parent. That is an error rather than a skip:
# this repository has history, so `HEAD^` failing means the checkout is
# shallower than the depth 2 declared above and the question went
# unanswered, which must never read as "nothing to check".
#
# The predicate is the declared VERSION, not the file: build.zig.zon also
# carries the dependency pins, and updating a sqlite or mbedTLS hash
# changes the file without cutting a release. Such a commit builds the
# same version from a different tree, so its bytes are not the pinned
# ones and this check would fail it.
#
# HEAD's version is CI_VERSION, parsed out of the working tree by the
# container gate tool. The parent's is read with `sed`, because that tool
# reads `build.zig.zon` at a fixed path and has no mode for a blob out of
# history. An empty parse is a failure, not a bump: it means the manifest
# moved and the question went unanswered.
- name: Verify the flake pins on a version bump
run: |
set -euo pipefail
parent="$(git rev-parse --verify HEAD^)"
parent_version="$(git show "$parent":build.zig.zon | sed -n 's/^[[:space:]]*\.version = "\([^"]*\)".*/\1/p')"
if [ -z "$parent_version" ]; then
echo "cannot read .version out of $parent:build.zig.zon" >&2
exit 1
fi
if [ "$parent_version" != "$CI_VERSION" ]; then
zig build verify-pins \
-Dversion-string="$CI_VERSION" \
-Dadmin-dist=admin-dist-ci \
-Doptimize=ReleaseSafe
else
echo "skipped: $GITHUB_SHA declares version $CI_VERSION and $parent already declared $parent_version, so it is not a version bump and its bytes are not the ones flake.nix pins"
fi
# deploy/docker/Dockerfile copies both of these trees and nothing else # deploy/docker/Dockerfile copies both of these trees and nothing else
# out of zig-out/dist: the binary comes from dist/bin/<triple>/, and # out of zig-out/dist: the binary comes from dist/bin/<triple>/, and
# /LICENSE and /THIRD-PARTY-NOTICES come from the matching dist/stage/ # /LICENSE and /THIRD-PARTY-NOTICES come from the matching dist/stage/
+29 -5
View File
@@ -62,6 +62,7 @@ env:
ZIG_VERSION: "0.16.0" ZIG_VERSION: "0.16.0"
# Exact patch, not a floating "24" (ruling 12). # Exact patch, not a floating "24" (ruling 12).
NODE_VERSION: "24.19.0" NODE_VERSION: "24.19.0"
NPM_VERSION: "11.17.0"
# The author's commit- and tag-signing key. `git verify-tag` alone proves # The author's commit- and tag-signing key. `git verify-tag` alone proves
# only that *some* key in the keyring signed the tag, so the signature's # only that *some* key in the keyring signed the tag, so the signature's
@@ -271,18 +272,29 @@ jobs:
cache: npm cache: npm
cache-dependency-path: admin/package-lock.json cache-dependency-path: admin/package-lock.json
# The same pinned toolchain and normalized environment as the frontend
# job in gates.yml: the cut pinned the bundle's bytes into flake.nix
# before this run existed, and the pin check below compares against them.
- name: Assert the pinned Node and npm
run: |
test "$(node --version)" = "v${NODE_VERSION:?}"
test "$(npm --version)" = "${NPM_VERSION:?}"
test ! -e /nonexistent/npmrc-user
test ! -e /nonexistent/npmrc-global
# The same normalized environment the cut builds the bundle in: exactly
# nine variables, a file mode from the umask, a C locale, UTC and a zero
# build timestamp. Each of those can move the bytes the release hashes
# cover, and this job's bundle has to reproduce the one the cut pinned.
- name: Build the web UI - name: Build the web UI
working-directory: admin working-directory: admin
run: | run: env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=true npm_config_userconfig=/nonexistent/npmrc-user npm_config_globalconfig=/nonexistent/npmrc-global sh -c 'umask 022 && npm ci && npm run build'
npm ci
npm run build
# Step 8. # Step 8.
- name: Build the release artifacts - name: Build the release artifacts
run: > run: >
zig build dist zig build dist
-Dversion-string="$VERSION" -Dversion-string="$VERSION"
-Dgit-commit="$TAG_COMMIT"
-Dadmin-dist=admin/dist -Dadmin-dist=admin/dist
-Doptimize=ReleaseSafe -Doptimize=ReleaseSafe
@@ -290,7 +302,19 @@ jobs:
run: > run: >
zig build verify-dist zig build verify-dist
-Dversion-string="$VERSION" -Dversion-string="$VERSION"
-Dgit-commit="$TAG_COMMIT" -Dadmin-dist=admin/dist
-Doptimize=ReleaseSafe
# Unconditional, and before the image push, the draft and every upload:
# the tag's tree IS the bump commit's tree, so the flake block must pin
# these exact bytes and name this exact version. A consumer who resolves
# the tag through the flake gets hashes that were written before this run
# existed; this is where the claim is proved, while nothing has yet left
# the runner.
- name: Verify the flake pins
run: >
zig build verify-pins
-Dversion-string="$VERSION"
-Dadmin-dist=admin/dist -Dadmin-dist=admin/dist
-Doptimize=ReleaseSafe -Doptimize=ReleaseSafe
+12
View File
@@ -4,6 +4,18 @@ All notable changes to nxdns are recorded here. The format follows [Keep a Chang
Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does. Sections are written by hand. Nothing here is generated from commit messages: the point of the file is to say what changed for an operator, which a commit subject rarely does.
## [Unreleased]
### Added
- **A Nix flake with tag-pinned hashes.** `flake.nix` at the repository root builds `nxdns` for `aarch64-linux` and `x86_64-linux` from the release tarballs, and carries their hashes in a generated block. A consumer pins the flake to a release tag and gets the exact bytes that tag published; `docs/how-to/install-with-nix.md` covers the input, the `nixpkgs` follows line, and Renovate.
### Changed
- **The release archive is written by the project's own tool.** `zig build dist` no longer shells out to the runner's `tar` and `gzip`; it writes the tar stream and the gzip container itself, with sorted entries, fixed modes, and zero timestamps. The tarball bytes now depend on the source tree, the compiler, and the admin bundle, and on nothing the host supplies — which is what lets a hash be pinned before CI rebuilds it.
- **The cut pins the release hashes before it commits.** `zig build cut` asserts the local Node, npm, and Zig match the versions CI pins, builds the release from the bumped manifest in a normalized environment, writes the resulting hashes into `flake.nix`, and commits that file alongside `build.zig.zon` as one commit. CI reverifies the pins on the bump commit and again on the tag, before anything is uploaded.
- **`nxdns version` no longer reports a git commit.** The commit is gone from the command's output, from `GET /api/version`, and from the admin footer, which now shows `nxdns v<version>`. A release identifies itself by version, and the bytes are reproducible from the tag, so a commit embedded in the binary told a reader nothing the tag did not.
## [0.0.16] - 2026-09-07 ## [0.0.16] - 2026-09-07
The Overview page is redesigned around the two dashboards people already know — Pi-hole's layout, NextDNS's charts — and every number in the admin is spelled one way. The Overview page is redesigned around the two dashboards people already know — Pi-hole's layout, NextDNS's charts — and every number in the admin is spelled one way.
+1 -1
View File
@@ -571,7 +571,7 @@ Requirements: responsive desktop/mobile; route loaders for initial fetch; TanSta
- `nxdns check` — validate config, probe upstreams, load each enabled listener's certificate and verify its key pairs with it; exit 2 on failure, 0 with warnings. - `nxdns check` — validate config, probe upstreams, load each enabled listener's certificate and verify its key pairs with it; exit 2 on failure, 0 with warnings.
- `nxdns export [--out file.zon]` - `nxdns export [--out file.zon]`
- `nxdns import <file.zon> [--force]` - `nxdns import <file.zon> [--force]`
- `nxdns version` — app version, Zig version string, git commit. No build date: the version and the commit identify a build exactly, and a date is one more input a reproducible build would have to pin. - `nxdns version` — app version and Zig version string. No commit and no build date: the version names the release, the release tarballs are reproducible from the tag with the pinned toolchain, and `flake.nix` pins their hashes. A commit or a date would be one more build input that reproducibility has to pin.
--- ---
+3 -3
View File
@@ -70,15 +70,15 @@ The release artifacts come out of the same build graph, so the whole release bui
```sh ```sh
(cd admin && npm ci && npm run build) # required: dist refuses the placeholder (cd admin && npm ci && npm run build) # required: dist refuses the placeholder
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon) VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
zig build dist -Dversion-string="$VERSION" -Dgit-commit=$(git rev-parse HEAD) \ zig build dist -Dversion-string="$VERSION" \
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe # tarballs -> zig-out/dist/ -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe # tarballs -> zig-out/dist/
zig build verify-dist -Dversion-string="$VERSION" -Dgit-commit=$(git rev-parse HEAD) \ zig build verify-dist -Dversion-string="$VERSION" \
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe # the release checks -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe # the release checks
``` ```
The version comes from `build.zig.zon` because `verify-dist` asserts the two agree; a tag sets both. The version comes from `build.zig.zon` because `verify-dist` asserts the two agree; a tag sets both.
That is not a claim that your tarball will hash the same as a published one. Nothing in this project measures whether two builds of the same commit on two different machines land on the same bytes, so no document here describes the build as reproducible. The gate that would settle it is a recorded deferral — `specs/milestone-14.md` ruling 12 — and [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) explains what a matching or differing hash is worth in the meantime. On the toolchain versions pinned at the top of `.gitea/workflows/gates.yml`, your tarballs hash the same as the published ones of that version. [docs/how-to/verify-a-release.md](docs/how-to/verify-a-release.md) has the rebuild recipe and what to check when a hash differs.
## Documentation ## Documentation
+1 -1
View File
@@ -52,7 +52,7 @@
}); });
fetch("/api/version") fetch("/api/version")
.then((r) => r.json()) .then((r) => r.json())
.then((v) => { el("version").textContent = v.version + " (" + v.git_commit + ")"; }) .then((v) => { el("version").textContent = v.version; })
.catch(() => {}); .catch(() => {});
</script> </script>
</body> </body>
@@ -16,7 +16,7 @@ let responses: Record<string, unknown>;
beforeEach(() => { beforeEach(() => {
responses = { responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, "/api/version": { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 },
"/api/health": health(), "/api/health": health(),
}; };
vi.stubGlobal( vi.stubGlobal(
@@ -86,7 +86,7 @@ function json(payload: unknown): Response {
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } }); return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
} }
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }; const VERSION = { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 };
/** The shell's own requests, which every test serves the same way. */ /** The shell's own requests, which every test serves the same way. */
function stubFetch(handler: (url: string) => Response | Promise<Response>) { function stubFetch(handler: (url: string) => Response | Promise<Response>) {
@@ -48,7 +48,7 @@ const CLIENTS: Client[] = [
client("192.0.2.12", "", ""), client("192.0.2.12", "", ""),
]; ];
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }; const VERSION = { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 };
let sources: FakeEventSource[]; let sources: FakeEventSource[];
let fetchMock: ReturnType<typeof vi.fn>; let fetchMock: ReturnType<typeof vi.fn>;
+1 -1
View File
@@ -50,7 +50,7 @@ export const PREFIXES = {
client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }], client_prefixes: [{ id: 1, prefix: "192.168.1.0/24", group_id: 2, group: "kids", priority: 100 }],
}; };
const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }; const VERSION = { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 };
export const DATABASE = { authority: "database", path: null, reconciled_at: null, restart_pending: false }; export const DATABASE = { authority: "database", path: null, reconciled_at: null, restart_pending: false };
export const MANAGED_FILE = { export const MANAGED_FILE = {
@@ -194,7 +194,7 @@ function defaultResponses(status: ConfigStatus): Record<string, unknown> {
return { return {
"GET /api/config/status": status, "GET /api/config/status": status,
"GET /api/health": health(), "GET /api/health": health(),
"GET /api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, "GET /api/version": { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 },
"GET /api/groups": { groups: GROUPS }, "GET /api/groups": { groups: GROUPS },
"GET /api/groups/1/sources": { source_ids: [1] }, "GET /api/groups/1/sources": { source_ids: [1] },
"GET /api/groups/2/sources": { source_ids: [] }, "GET /api/groups/2/sources": { source_ids: [] },
@@ -35,7 +35,7 @@ let requested: string[];
beforeEach(() => { beforeEach(() => {
requested = []; requested = [];
responses = { responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, "/api/version": { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 },
// The shell reads health for the Diagnostics nav badge on every route. // The shell reads health for the Diagnostics nav badge on every route.
"/api/health": health(), "/api/health": health(),
}; };
@@ -77,7 +77,7 @@ let requested: string[];
beforeEach(() => { beforeEach(() => {
requested = []; requested = [];
responses = { responses = {
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, "/api/version": { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 },
// The health strip at the top of the page; quiet on a healthy box, which is // The health strip at the top of the page; quiet on a healthy box, which is
// what every test below wants it to be. // what every test below wants it to be.
"/api/health": health(), "/api/health": health(),
@@ -42,7 +42,7 @@ beforeEach(() => {
return healthFails ? json({ error: "health unavailable" }, 400) : json(healthBody); return healthFails ? json({ error: "health unavailable" }, 400) : json(healthBody);
} }
if (url === "/api/version") if (url === "/api/version")
return json({ version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }); return json({ version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 });
if (url.startsWith("/api/diagnostics")) if (url.startsWith("/api/diagnostics"))
return json({ events: [], next_before: null, active: { warnings: 0, errors: 0 } }); return json({ events: [], next_before: null, active: { warnings: 0, errors: 0 } });
return json({ error: "not stubbed" }, 404); return json({ error: "not stubbed" }, 404);
@@ -113,7 +113,7 @@ beforeEach(() => {
} }
if (url === "/api/health") return json(healthBody); if (url === "/api/health") return json(healthBody);
if (url === "/api/version") if (url === "/api/version")
return json({ version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }); return json({ version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 });
if (url.startsWith("/api/diagnostics")) { if (url.startsWith("/api/diagnostics")) {
return json({ events: [], next_before: null, active: { warnings: 0, errors: 0 } }); return json({ events: [], next_before: null, active: { warnings: 0, errors: 0 } });
} }
-1
View File
@@ -70,7 +70,6 @@ export const sample_get_health: Health = {
}; };
export const sample_get_version: Version = { export const sample_get_version: Version = {
git_commit: "<build>",
uptime_seconds: 0, uptime_seconds: 0,
version: "w10-test", version: "w10-test",
zig_version: "<build>", zig_version: "<build>",
-1
View File
@@ -63,7 +63,6 @@ export interface Health {
export interface Version { export interface Version {
version: string; version: string;
git_commit: string;
zig_version: string; zig_version: string;
uptime_seconds: number; uptime_seconds: number;
} }
+1 -1
View File
@@ -33,7 +33,7 @@ const RESPONSES: Record<string, unknown> = {
coverage: { complete: true, available_since: 0 }, coverage: { complete: true, available_since: 0 },
}, },
"/api/diagnostics?state=active": { events: [], next_before: null, active: { warnings: 0, errors: 0 } }, "/api/diagnostics?state=active": { events: [], next_before: null, active: { warnings: 0, errors: 0 } },
"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 }, "/api/version": { version: "0.0.0-test", zig_version: "0.16.0", uptime_seconds: 1 },
}; };
/** Null makes the health poll fail, which the nav badge has to treat as unknown. */ /** Null makes the health poll fail, which the nav badge has to treat as unknown. */
+1 -1
View File
@@ -318,7 +318,7 @@ function VersionFooter() {
const { data } = useQuery(versionQuery()); const { data } = useQuery(versionQuery());
return ( return (
<footer {...stylex.props(styles.versionFooter)}> <footer {...stylex.props(styles.versionFooter)}>
{data === undefined ? "nxdns" : `nxdns v${data.version} (${data.git_commit.slice(0, 7)})`} {data === undefined ? "nxdns" : `nxdns v${data.version}`}
</footer> </footer>
); );
} }
+91 -46
View File
@@ -43,7 +43,6 @@ pub fn build(b: *std.Build) void {
// the default, so `zig build` and `zig build test` need no flag. // the default, so `zig build` and `zig build test` need no flag.
const version_option = b.option([]const u8, "version-string", "Version reported by `nxdns version` (required by `dist`)"); const version_option = b.option([]const u8, "version-string", "Version reported by `nxdns version` (required by `dist`)");
const version_string = version_option orelse "0.1.0-dev"; const version_string = version_option orelse "0.1.0-dev";
const git_commit = b.option([]const u8, "git-commit", "Git commit reported by `nxdns version`") orelse "unknown";
const admin_dist = b.option( const admin_dist = b.option(
[]const u8, []const u8,
"admin-dist", "admin-dist",
@@ -77,7 +76,6 @@ pub fn build(b: *std.Build) void {
options.addOption(bool, "integration", integration); options.addOption(bool, "integration", integration);
options.addOption(bool, "live", live); options.addOption(bool, "live", live);
options.addOption([]const u8, "version_string", version_string); options.addOption([]const u8, "version_string", version_string);
options.addOption([]const u8, "git_commit", git_commit);
options.addOption([]const u8, "zig_version_string", builtin.zig_version_string); options.addOption([]const u8, "zig_version_string", builtin.zig_version_string);
options.addOption([]const u8, "contract_samples_out", contract_samples_out); options.addOption([]const u8, "contract_samples_out", contract_samples_out);
@@ -319,10 +317,22 @@ pub fn build(b: *std.Build) void {
cut_tests_run.setCwd(b.path(".")); cut_tests_run.setCwd(b.path("."));
test_step.dependOn(&cut_tests_run.step); test_step.dependOn(&cut_tests_run.step);
// `dist_stage` owns the release archive bytes, and its reproducibility is
// the property the flake pins depend on, so it is tested like any other
// decision this build makes.
const dist_stage_tests = b.addTest(.{
.name = "dist-stage-tool",
.root_module = b.createModule(.{
.root_source_file = b.path("tools/dist_stage.zig"),
.target = b.graph.host,
.optimize = optimize,
}),
});
test_step.dependOn(&b.addRunArtifact(dist_stage_tests).step);
addDist(b, options, admin_assets, .{ addDist(b, options, admin_assets, .{
.version = version_option, .version = version_option,
.version_string = version_string, .version_string = version_string,
.git_commit = git_commit,
.admin_dist = admin_dist, .admin_dist = admin_dist,
}); });
} }
@@ -355,14 +365,18 @@ const DistOptions = struct {
/// from one that happens to equal the default. /// from one that happens to equal the default.
version: ?[]const u8, version: ?[]const u8,
version_string: []const u8, version_string: []const u8,
git_commit: []const u8,
admin_dist: []const u8, admin_dist: []const u8,
}; };
/// `dist` builds everything releasable; `verify-dist` asserts the result. /// `dist` builds everything releasable; `verify-dist` asserts the result;
/// Both run on a laptop exactly as they run on the runner, which is the point: /// `pin-flake` writes the resulting hashes into `flake.nix` and `verify-pins`
/// release checks that only exist in CI shell are the brittleness milestone 14 /// asserts that they still describe the bytes under `zig-out/dist`.
/// set out to remove. ///
/// All four run on a laptop exactly as they run on the runner, which is the
/// point: release checks that only exist in CI shell are the brittleness
/// milestone 14 set out to remove. The pins depend on it twice over — the cut
/// writes them here and CI recomputes them there, and the two only agree
/// because it is one build graph rather than two scripts.
fn addDist( fn addDist(
b: *std.Build, b: *std.Build,
options: *std.Build.Step.Options, options: *std.Build.Step.Options,
@@ -371,11 +385,15 @@ fn addDist(
) void { ) void {
const dist_step = b.step("dist", "Build the release tarballs, checksums and staged payloads"); const dist_step = b.step("dist", "Build the release tarballs, checksums and staged payloads");
const verify_step = b.step("verify-dist", "Verify the release artifacts under zig-out/dist"); const verify_step = b.step("verify-dist", "Verify the release artifacts under zig-out/dist");
const pin_step = b.step("pin-flake", "Write the release hashes under zig-out/dist into flake.nix");
const verify_pins_step = b.step("verify-pins", "Check flake.nix pins the hashes of the release under zig-out/dist");
if (distPreflight(b, dist_options)) |problem| { if (distPreflight(b, dist_options)) |problem| {
const fail = b.addFail(problem); const fail = b.addFail(problem);
dist_step.dependOn(&fail.step); dist_step.dependOn(&fail.step);
verify_step.dependOn(&fail.step); verify_step.dependOn(&fail.step);
pin_step.dependOn(&fail.step);
verify_pins_step.dependOn(&fail.step);
return; return;
} }
@@ -401,7 +419,6 @@ fn addDist(
verify_run.addArgs(&.{ "--dist-dir", b.getInstallPath(.prefix, "dist") }); verify_run.addArgs(&.{ "--dist-dir", b.getInstallPath(.prefix, "dist") });
verify_run.addArgs(&.{ "--work-dir", b.getInstallPath(.prefix, "dist-verify") }); verify_run.addArgs(&.{ "--work-dir", b.getInstallPath(.prefix, "dist-verify") });
verify_run.addArgs(&.{ "--version", dist_options.version_string }); verify_run.addArgs(&.{ "--version", dist_options.version_string });
verify_run.addArgs(&.{ "--git-commit", dist_options.git_commit });
verify_run.addArg("--zon"); verify_run.addArg("--zon");
verify_run.addFileArg(b.path("build.zig.zon")); verify_run.addFileArg(b.path("build.zig.zon"));
verify_run.addArgs(&.{ "--max-bytes", b.fmt("{d}", .{max_binary_bytes}) }); verify_run.addArgs(&.{ "--max-bytes", b.fmt("{d}", .{max_binary_bytes}) });
@@ -447,36 +464,19 @@ fn addDist(
stage_run.addArg("--licenses"); stage_run.addArg("--licenses");
stage_run.addDirectoryArg(staged_licenses); stage_run.addDirectoryArg(staged_licenses);
// Two commands, never one: `addSystemCommand` executes argv directly // The tarball is written by our own tool rather than by the runner's
// and does not interpret `|`, and a shell pipeline without `pipefail` // `tar` and `gzip`: the release hashes are pinned in `flake.nix` before
// would report only gzip's status while a failed tar passed silently. // CI rebuilds them, so the bytes may depend on the staged tree and on
const tar_run = b.addSystemCommand(&.{ // nothing else the host supplies.
"tar", const archive_run = b.addRunArtifact(stage_tool);
"--format=gnu", archive_run.addArg("archive");
"--sort=name", archive_run.addArg("--root");
"--mtime=@0",
"--owner=0",
"--group=0",
"--numeric-owner",
"-c",
"-f",
});
setReproducibleEnv(tar_run);
const tar_file = tar_run.addOutputFileArg(b.fmt("{s}.tar", .{name}));
tar_run.addArg("-C");
// The staged payload is the sole entry of its cache directory, so its // The staged payload is the sole entry of its cache directory, so its
// parent is what `-C` needs and declaring it declares the payload. // parent is what `--root` needs and declaring it declares the payload.
tar_run.addDirectoryArg(staged.dirname()); archive_run.addDirectoryArg(staged.dirname());
tar_run.addArg(name); archive_run.addArgs(&.{ "--payload", name });
archive_run.addArg("--out");
// `-n` is required because `--mtime=@0` normalises the tar member times const tarball = archive_run.addOutputFileArg(b.fmt("{s}.tar.gz", .{name}));
// but not the timestamp gzip writes into its own header. `-c` is
// required because plain `gzip <file>` rewrites its input in place, and
// the input here is a content-addressed cache entry.
const gzip_run = b.addSystemCommand(&.{ "gzip", "-n", "-9", "-c" });
setReproducibleEnv(gzip_run);
gzip_run.addFileArg(tar_file);
const tarball = gzip_run.captureStdOut(.{ .basename = b.fmt("{s}.tar.gz", .{name}) });
const install_binary = b.addInstallFile( const install_binary = b.addInstallFile(
staged.path(b, "nxdns"), staged.path(b, "nxdns"),
@@ -513,6 +513,38 @@ fn addDist(
verify_run.step.dependOn(dist_step); verify_run.step.dependOn(dist_step);
verify_step.dependOn(&verify_run.step); verify_step.dependOn(&verify_run.step);
// The pins in `flake.nix` are written before CI ever builds the release, so
// the run that rebuilds it has to prove they describe its own bytes.
//
// It is a step of its own and NOT part of `verify-dist`. An ordinary commit
// between two cuts builds the `build.zig.zon` version from a tree that
// differs from the released one, so its bytes never match the pins and
// checking them there would fail every such build. The two CI jobs that may
// not skip it call it by name: the package job on the bump commit, and the
// publish job unconditionally before any upload.
const pin_check_run = b.addRunArtifact(stage_tool);
pin_check_run.has_side_effects = true;
pin_check_run.addArg("pin-check");
pin_check_run.addArgs(&.{ "--sums", b.getInstallPath(.prefix, "dist/SHA256SUMS") });
pin_check_run.addArg("--flake");
pin_check_run.addFileArg(b.path("flake.nix"));
pin_check_run.addArgs(&.{ "--version", dist_options.version_string });
pin_check_run.step.dependOn(dist_step);
verify_pins_step.dependOn(&pin_check_run.step);
// The write half, run by the cut and by nothing else. `flake.nix` is named
// as a plain path rather than a `LazyPath`: this run edits the source file
// in place, and a file argument would declare it an input of a step that is
// in fact its author.
const pin_run = b.addRunArtifact(stage_tool);
pin_run.has_side_effects = true;
pin_run.addArg("pin");
pin_run.addArgs(&.{ "--sums", b.getInstallPath(.prefix, "dist/SHA256SUMS") });
pin_run.addArgs(&.{ "--flake", b.pathFromRoot("flake.nix") });
pin_run.addArgs(&.{ "--version", dist_options.version_string });
pin_run.step.dependOn(dist_step);
pin_step.dependOn(&pin_run.step);
} }
/// The one message `dist` and `verify-dist` fail with when the release inputs /// The one message `dist` and `verify-dist` fail with when the release inputs
@@ -564,13 +596,6 @@ fn hostTool(b: *std.Build, name: []const u8) *std.Build.Step.Compile {
}); });
} }
/// Locale and time zone leak into archive metadata and into tool output.
/// Pinning both is the cheap half of reproducibility (milestone-14 ruling 12).
fn setReproducibleEnv(run: *std.Build.Step.Run) void {
run.setEnvironmentVariable("LC_ALL", "C");
run.setEnvironmentVariable("TZ", "UTC");
}
/// Milestone-15 ruling 4: every `*.zig` under `src/` must appear in /// Milestone-15 ruling 4: every `*.zig` under `src/` must appear in
/// `src/tests.zig` as a line that trims to exactly `_ = @import("<path>");`, /// `src/tests.zig` as a line that trims to exactly `_ = @import("<path>");`,
/// where `<path>` is relative to `src/`. Whole-line equality, not a substring /// where `<path>` is relative to `src/`. Whole-line equality, not a substring
@@ -744,7 +769,10 @@ fn addExecutable(
exe.root_module.addAnonymousImport("admin_assets", .{ .root_source_file = admin_assets }); exe.root_module.addAnonymousImport("admin_assets", .{ .root_source_file = admin_assets });
exe.root_module.linkLibrary(sqliteLibrary(b, target, optimize)); exe.root_module.linkLibrary(sqliteLibrary(b, target, optimize));
exe.root_module.linkLibrary(mbedtlsLibrary(b, target, optimize)); exe.root_module.linkLibrary(mbedtlsLibrary(b, target, optimize));
exe.root_module.addCSourceFile(.{ .file = b.path("src/platform/mbedtls_shim.c") }); exe.root_module.addCSourceFile(.{
.file = b.path("src/platform/mbedtls_shim.c"),
.flags = &.{filePrefixMap(b, .build_root)},
});
addMbedtlsThreadingMacros(exe.root_module); addMbedtlsThreadingMacros(exe.root_module);
return exe; return exe;
} }
@@ -810,6 +838,8 @@ fn sqliteLibrary(
"-DSQLITE_THREADSAFE=1", "-DSQLITE_THREADSAFE=1",
"-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1", "-DSQLITE_DEFAULT_WAL_SYNCHRONOUS=1",
"-DSQLITE_OMIT_LOAD_EXTENSION", "-DSQLITE_OMIT_LOAD_EXTENSION",
filePrefixMap(b, .build_root),
filePrefixMap(b, .global_cache),
}, },
}); });
return lib; return lib;
@@ -846,19 +876,34 @@ fn mbedtlsLibrary(
lib.root_module.addIncludePath(dep.path(include_dir)); lib.root_module.addIncludePath(dep.path(include_dir));
} }
const c_flags = [_][]const u8{ filePrefixMap(b, .build_root), filePrefixMap(b, .global_cache) };
lib.root_module.addCSourceFiles(.{ lib.root_module.addCSourceFiles(.{
.root = dep.path("library"), .root = dep.path("library"),
.files = &mbedtls_library_sources, .files = &mbedtls_library_sources,
.flags = &c_flags,
}); });
lib.root_module.addCSourceFiles(.{ lib.root_module.addCSourceFiles(.{
.root = dep.path("3rdparty"), .root = dep.path("3rdparty"),
.files = &mbedtls_3rdparty_sources, .files = &mbedtls_3rdparty_sources,
.flags = &c_flags,
}); });
lib.installHeadersDirectory(dep.path("include/mbedtls"), "mbedtls", .{}); lib.installHeadersDirectory(dep.path("include/mbedtls"), "mbedtls", .{});
lib.installHeadersDirectory(dep.path("include/psa"), "psa", .{}); lib.installHeadersDirectory(dep.path("include/psa"), "psa", .{});
return lib; return lib;
} }
/// `__FILE__` in the C sources (mbedTLS debug and assertion macros) would
/// otherwise embed the absolute checkout path into the release binary, and the
/// flake pins require the bytes to be the same on every machine that builds
/// the tag. Both roots a dependency can be stored under are mapped.
fn filePrefixMap(b: *std.Build, root: enum { build_root, global_cache }) []const u8 {
const path = switch (root) {
.build_root => b.build_root.path orelse ".",
.global_cache => b.graph.global_cache_root.path orelse ".",
};
return b.fmt("-ffile-prefix-map={s}=.", .{path});
}
/// Context sizes change with threading enabled, so every compilation unit that /// Context sizes change with threading enabled, so every compilation unit that
/// includes mbedTLS headers (the library itself and `mbedtls_shim.c`) must see /// includes mbedTLS headers (the library itself and `mbedtls_shim.c`) must see
/// the same macros. Concurrent handshakes share `ssl_config`, the CTR-DRBG, and /// the same macros. Concurrent handshakes share `ssl_config`, the CTR-DRBG, and
+1 -1
View File
@@ -1,7 +1,7 @@
# The binary is NOT compiled here. Build it first, from the repository root: # The binary is NOT compiled here. Build it first, from the repository root:
# #
# (cd admin && npm ci && npm run build) # (cd admin && npm ci && npm run build)
# zig build dist -Dversion-string=<V> -Dgit-commit=<SHA> \ # zig build dist -Dversion-string=<V> \
# -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe # -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe
# #
# then build the image with the repository root as context: # then build the image with the repository root as context:
+1
View File
@@ -22,6 +22,7 @@ Steps for a goal you already have. They assume you know what nxdns is.
- [how-to/verify-a-release.md](how-to/verify-a-release.md) — check the signature and the checksums before you run anything, and what they prove. - [how-to/verify-a-release.md](how-to/verify-a-release.md) — check the signature and the checksums before you run anything, and what they prove.
- [how-to/install-with-systemd.md](how-to/install-with-systemd.md) — a real install as a system service, including the Raspberry Pi 5 aarch64 binary. - [how-to/install-with-systemd.md](how-to/install-with-systemd.md) — a real install as a system service, including the Raspberry Pi 5 aarch64 binary.
- [how-to/install-with-docker.md](how-to/install-with-docker.md) — the published container image and the compose file. - [how-to/install-with-docker.md](how-to/install-with-docker.md) — the published container image and the compose file.
- [how-to/install-with-nix.md](how-to/install-with-nix.md) — the flake input pinned to a release tag, and Renovate for tag bumps.
- [how-to/upgrade.md](how-to/upgrade.md) — move to a new release without losing state. - [how-to/upgrade.md](how-to/upgrade.md) — move to a new release without losing state.
- [how-to/troubleshoot.md](how-to/troubleshoot.md) — what to do when it does not answer, does not block, or will not start. - [how-to/troubleshoot.md](how-to/troubleshoot.md) — what to do when it does not answer, does not block, or will not start.
- [how-to/enable-doh-and-dot.md](how-to/enable-doh-and-dot.md) — serve encrypted DNS with certificates. - [how-to/enable-doh-and-dot.md](how-to/enable-doh-and-dot.md) — serve encrypted DNS with certificates.
+2
View File
@@ -11,6 +11,7 @@ pub const tutorial_first_run_md = @embedFile("tutorial/first-run.md");
pub const howto_back_up_and_restore_md = @embedFile("how-to/back-up-and-restore.md"); pub const howto_back_up_and_restore_md = @embedFile("how-to/back-up-and-restore.md");
pub const howto_enable_doh_and_dot_md = @embedFile("how-to/enable-doh-and-dot.md"); pub const howto_enable_doh_and_dot_md = @embedFile("how-to/enable-doh-and-dot.md");
pub const howto_install_with_docker_md = @embedFile("how-to/install-with-docker.md"); pub const howto_install_with_docker_md = @embedFile("how-to/install-with-docker.md");
pub const howto_install_with_nix_md = @embedFile("how-to/install-with-nix.md");
pub const howto_install_with_systemd_md = @embedFile("how-to/install-with-systemd.md"); pub const howto_install_with_systemd_md = @embedFile("how-to/install-with-systemd.md");
pub const howto_measure_performance_md = @embedFile("how-to/measure-performance.md"); pub const howto_measure_performance_md = @embedFile("how-to/measure-performance.md");
pub const howto_set_up_admin_authentication_md = @embedFile("how-to/set-up-admin-authentication.md"); pub const howto_set_up_admin_authentication_md = @embedFile("how-to/set-up-admin-authentication.md");
@@ -34,6 +35,7 @@ pub const pages: []const Page = &.{
.{ .path = "docs/how-to/back-up-and-restore.md", .text = howto_back_up_and_restore_md }, .{ .path = "docs/how-to/back-up-and-restore.md", .text = howto_back_up_and_restore_md },
.{ .path = "docs/how-to/enable-doh-and-dot.md", .text = howto_enable_doh_and_dot_md }, .{ .path = "docs/how-to/enable-doh-and-dot.md", .text = howto_enable_doh_and_dot_md },
.{ .path = "docs/how-to/install-with-docker.md", .text = howto_install_with_docker_md }, .{ .path = "docs/how-to/install-with-docker.md", .text = howto_install_with_docker_md },
.{ .path = "docs/how-to/install-with-nix.md", .text = howto_install_with_nix_md },
.{ .path = "docs/how-to/install-with-systemd.md", .text = howto_install_with_systemd_md }, .{ .path = "docs/how-to/install-with-systemd.md", .text = howto_install_with_systemd_md },
.{ .path = "docs/how-to/measure-performance.md", .text = howto_measure_performance_md }, .{ .path = "docs/how-to/measure-performance.md", .text = howto_measure_performance_md },
.{ .path = "docs/how-to/set-up-admin-authentication.md", .text = howto_set_up_admin_authentication_md }, .{ .path = "docs/how-to/set-up-admin-authentication.md", .text = howto_set_up_admin_authentication_md },
+1 -1
View File
@@ -220,7 +220,7 @@ The Dockerfile does not compile anything. It assembles a filesystem around binar
```sh ```sh
(cd admin && npm ci && npm run build) (cd admin && npm ci && npm run build)
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon) VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)" \ zig build dist -Dversion-string="$VERSION" \
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe
DOCKER_BUILDKIT=1 docker build -t nxdns -f deploy/docker/Dockerfile . DOCKER_BUILDKIT=1 docker build -t nxdns -f deploy/docker/Dockerfile .
``` ```
+78
View File
@@ -0,0 +1,78 @@
# Install nxdns with Nix
Adds nxdns to a NixOS machine as a flake input pinned to a release tag. At the end `pkgs`-style references to `inputs.nxdns.packages.${system}.default` resolve to the published release binary, and Renovate opens a pull request when a new tag appears.
nxdns publishes its own `flake.nix`. Its packages do not build nxdns from source: each one fetches the release tarball for the target and pins its SHA-256 hash, so a changed byte fails the build. The two supported systems are `aarch64-linux` and `x86_64-linux`, both static musl builds that need nothing on the host.
For the signature and checksum checks a human does once, see [verify a release](verify-a-release.md). For what each configuration field means, see [the configuration reference](../reference/configuration.md).
> Verification: the commands in step 1 and step 2 were run on the machine that wrote this page, against the flake at nxdns 0.0.16. `nix flake check --no-build` passed and `nix build` produced a binary that printed `nxdns 0.0.16`. The consumer snippets in step 3 and step 4 are copied from the shape `rpi.mial.net` already uses for another flake input of the same author; they were not evaluated from this checkout, which is not a NixOS configuration.
## 1. Add the input, pinned to a tag
In the consuming flake:
```nix
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nxdns = {
url = "git+https://git.mial.net/mokhtar/nxdns.git?ref=refs/tags/v0.0.16";
inputs.nixpkgs.follows = "nixpkgs";
};
};
```
Pin a tag, not a branch. A branch pin moves the version under you at the next `nix flake update`, and the hashes in the flake belong to whatever release that branch last cut.
The `follows` line is not cosmetic. Without it Nix fetches and evaluates nxdns's own nixpkgs as a second nixpkgs, which costs a download and an evaluation for a package that only needs `stdenv`, `fetchurl`, and `lib`.
Write the lock entry:
```sh
nix flake lock
```
## 2. Check what you pinned
```sh
nix flake check --no-build
nix eval .#packages.x86_64-linux.default.outPath
```
Evaluation does not fetch the tarball. The download happens at build time, and the hash in nxdns's flake is what the build asserts the bytes against.
## 3. Use the package in a NixOS configuration
Pass the flake inputs to the module system, then reference the package:
```nix
{ inputs, pkgs, ... }:
{
environment.systemPackages = [ inputs.nxdns.packages.${pkgs.stdenv.hostPlatform.system}.default ];
}
```
The derivation installs `bin/nxdns`, plus `LICENSE` and `THIRD-PARTY-NOTICES` under `share/doc/nxdns`. It sets `meta.mainProgram`, so `lib.getExe` resolves to the binary. Run it as a service with the unit from [install with systemd](install-with-systemd.md), or write your own module around it.
## 4. Let Renovate bump the tag
Enable the Nix manager in the consuming repository's `renovate.json`:
```json
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"nix": {
"enabled": true
}
}
```
Renovate reads the flake inputs and the lock file, sees the `refs/tags/v0.0.16` ref, and opens a pull request when a newer tag exists. It proposes only tags that already exist, so it never pins a release that has not been cut.
Between the tag push and the asset upload there is a window in which the tag exists and the release tarballs do not. A pin written by hand during that window evaluates, then fails at build time with a 404 from the release download URL. Wait for the release to be published, or re-run the build once it is. Renovate's own pull requests are not affected by the window in practice, because it runs on a schedule rather than on the tag push.
## Upgrading
Change the `?ref=refs/tags/vX.Y.Z` in the input, run `nix flake lock --update-input nxdns`, and rebuild. Read the release notes first: nxdns is pre-0.1 and breaks on purpose, and [upgrade](upgrade.md) lists what state a version change touches.
+3 -3
View File
@@ -377,13 +377,13 @@ Requires Zig 0.16.0 and Node.js. From the repository root:
```sh ```sh
(cd admin && npm ci && npm run build) (cd admin && npm ci && npm run build)
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon) VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)" \ zig build dist -Dversion-string="$VERSION" \
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe
``` ```
The first command builds the admin interface into `admin/dist`; the last one embeds that directory in the binary. Build the interface every time, before the binary: a stale `admin/dist` ships an admin UI that does not match the API it talks to. `dist` refuses to run against the `admin/dist-placeholder` default for exactly that reason, so there is no way to skip it by accident. The first command builds the admin interface into `admin/dist`; the last one embeds that directory in the binary. Build the interface every time, before the binary: a stale `admin/dist` ships an admin UI that does not match the API it talks to. `dist` refuses to run against the `admin/dist-placeholder` default for exactly that reason, so there is no way to skip it by accident.
`-Dversion-string` is required and has no default. It is what `nxdns version` prints. Take it from `build.zig.zon` rather than inventing one: `verify-dist` asserts that the version under build equals `.version` there, so a made-up string like `0.0.0-local` builds but then fails verification. `-Dgit-commit` is what distinguishes your build from the published one of the same version. `-Dversion-string` is required and has no default. It is what `nxdns version` prints. Take it from `build.zig.zon` rather than inventing one: `verify-dist` asserts that the version under build equals `.version` there, so a made-up string like `0.0.0-local` builds but then fails verification.
What comes out under `zig-out/dist/` is the same set a release publishes, minus the signature and the image digest: What comes out under `zig-out/dist/` is the same set a release publishes, minus the signature and the image digest:
@@ -397,7 +397,7 @@ The two targets are `x86_64-linux-musl` and `aarch64-linux-musl`. Both binaries
Check the result the same way the release pipeline does: Check the result the same way the release pipeline does:
```sh ```sh
zig build verify-dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)" \ zig build verify-dist -Dversion-string="$VERSION" \
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe
``` ```
+3 -3
View File
@@ -218,7 +218,7 @@ OK: no problems found
> >
> ``` > ```
> $ nxdns version > $ nxdns version
> nxdns <version> (unknown) > nxdns <version>
> zig 0.16.0 > zig 0.16.0
> ``` > ```
> >
@@ -338,13 +338,13 @@ If you are running something you built rather than a release, step 2 is a build
```sh ```sh
(cd admin && npm ci && npm run build) (cd admin && npm ci && npm run build)
VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon) VERSION=$(sed -n 's/^[[:space:]]*\.version[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' build.zig.zon)
zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)" \ zig build dist -Dversion-string="$VERSION" \
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe
``` ```
Rebuild `admin/dist` before the binary on every upgrade. The admin interface is embedded at build time, and an old bundle against a new API is a broken System page. `dist` refuses the `admin/dist-placeholder` default outright, so the only way to ship a stale bundle is to leave an old `admin/dist` in place. Rebuild `admin/dist` before the binary on every upgrade. The admin interface is embedded at build time, and an old bundle against a new API is a broken System page. `dist` refuses the `admin/dist-placeholder` default outright, so the only way to ship a stale bundle is to leave an old `admin/dist` in place.
The staged payload for each target is under `zig-out/dist/stage/nxdns-<version>-<triple>/`, and step 3 continues from there with that path in place of the extracted one. The version string has to equal `.version` in `build.zig.zon``verify-dist` asserts it, so a made-up one builds and then fails verification. What tells your build apart from the published release of the same version is `-Dgit-commit`, which `nxdns version` prints beside the version. The staged payload for each target is under `zig-out/dist/stage/nxdns-<version>-<triple>/`, and step 3 continues from there with that path in place of the extracted one. The version string has to equal `.version` in `build.zig.zon``verify-dist` asserts it, so a made-up one builds and then fails verification. Your build reports the same two lines as the published release of that version, so compare the tarball hashes rather than the `nxdns version` output when you need to tell them apart.
Under Docker, build the image and name it instead of pulling: Under Docker, build the image and name it instead of pulling:
+21 -21
View File
@@ -197,13 +197,11 @@ tar -xzf nxdns-$VERSION-x86_64-linux-musl.tar.gz
./nxdns-$VERSION-x86_64-linux-musl/nxdns version ./nxdns-$VERSION-x86_64-linux-musl/nxdns version
``` ```
`version` prints the version and the git commit it was built from, then the Zig version. The version has to match the tag you downloaded, and the commit has to match the commit the tag points at. `version` prints the version, then the Zig version the binary was built with. The version has to match the tag you downloaded. The binary carries no commit sha, so the check that ties a release to its source is the rebuild below rather than a string in this output.
> Verified against `v0.0.1`: both tarballs listed exactly the one directory and > Verified against `v0.0.1`: both tarballs listed exactly the one directory and
> six files with the stated modes, no symlinks and no absolute or `..` paths, > six files with the stated modes, no symlinks and no absolute or `..` paths,
> and the extracted binary printed `nxdns 0.0.1 > and the extracted binary printed `nxdns 0.0.1` then `zig 0.16.0`.
> (3c2d0d41f04570038e805b759da4541e198eae17)` — the commit `v0.0.1` points at —
> then `zig 0.16.0`.
## 6. Verify the container image ## 6. Verify the container image
@@ -262,22 +260,31 @@ It proves two things:
It does not prove that the binary in the tarball was built from the source in this repository. The machine that ran the build also held the signing key, so a compromise of that machine produces an artifact that is signed, verifies cleanly, and contains whatever the attacker put in it. The signature is a statement about origin and integrity in transit. It is not a statement about provenance from source. It does not prove that the binary in the tarball was built from the source in this repository. The machine that ran the build also held the signing key, so a compromise of that machine produces an artifact that is signed, verifies cleanly, and contains whatever the attacker put in it. The signature is a statement about origin and integrity in transit. It is not a statement about provenance from source.
Closing that gap needs a reproducibility gate — an independent build, run somewhere else, that lands on the same bytes — and this project does not have one. It is a recorded deferral, not an oversight: see `specs/milestone-14.md` ruling 12. Until it exists, nothing here claims the build is reproducible, because nobody has measured whether it is. Closing that gap needs a reproducible build — an independent build, run somewhere else, that lands on the same bytes. The release tarballs are reproducible: the binary carries no commit sha, the archive is written by the project's own tool with fixed modes, zero timestamps and sorted entries, and CI rebuilds the same bytes and compares them against the pinned hashes before it uploads anything. The rebuild below is how you check that for yourself.
The signing key is a subkey rather than the primary key, which limits the damage of the case above: a leaked release subkey is revoked on its own and the identity, the commit signatures and everyone's existing trust in the key survive. The signing key is a subkey rather than the primary key, which limits the damage of the case above: a leaked release subkey is revoked on its own and the identity, the commit signatures and everyone's existing trust in the key survive.
## Rebuild it yourself ## Rebuild it yourself
You can still build the same version from source and compare. That gets you a binary whose provenance you know, and the comparison is worth making — read the paragraph after the recipe before you draw a conclusion from it. Build the same version from source and compare the hashes. Match the toolchain first: `ZIG_VERSION`, `NODE_VERSION` and `NPM_VERSION` at the top of `.gitea/workflows/gates.yml` are the exact versions the release used, and a different patch release of any of them changes the bytes.
The host matters too: the admin bundle is built with host-native Rolldown and Lightning CSS bindings, which the lockfile ships per platform and libc, so the recipe reproduces the release only on x86_64 Linux with glibc, the runner CI uses. The two `npm_config_*` paths must not exist on your machine; the recipe relies on npm finding no config file there. The environment matters as much as the toolchain. The release cut and CI both build the bundle under exactly nine variables and a `022` umask, so the recipe below does the same: a locale, a time zone, a build timestamp or a file mode picked up from your shell each move the bytes. The Zig build gets a private `--cache-dir` so a stale local cache cannot leak into them; the global Zig cache stays shared, because it is content-addressed and a fresh one refetches every dependency (see `specs/release-cut.md`).
```sh ```sh
git clone https://git.mial.net/mokhtar/nxdns git clone https://git.mial.net/mokhtar/nxdns
cd nxdns cd nxdns
git checkout "v$VERSION" git checkout "v$VERSION"
git verify-tag "v$VERSION" git verify-tag "v$VERSION"
(cd admin && npm ci && npm run build)
zig build dist -Dversion-string="$VERSION" -Dgit-commit="$(git rev-parse HEAD)" \ env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=true npm_config_userconfig=/nonexistent/npmrc-user npm_config_globalconfig=/nonexistent/npmrc-global \
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe sh -c 'cd admin && umask 022 && npm ci && npm run build'
env -i PATH="$PATH" HOME="$HOME" LC_ALL=C LANG=C TZ=UTC SOURCE_DATE_EPOCH=0 CI=true npm_config_userconfig=/nonexistent/npmrc-user npm_config_globalconfig=/nonexistent/npmrc-global \
sh -c 'umask 022 && exec zig build dist \
-Dversion-string="'"$VERSION"'" \
-Dadmin-dist=admin/dist -Doptimize=ReleaseSafe \
--cache-dir "$(mktemp -d)"'
sha256sum zig-out/dist/nxdns-"$VERSION"-*.tar.gz sha256sum zig-out/dist/nxdns-"$VERSION"-*.tar.gz
``` ```
@@ -285,20 +292,13 @@ sha256sum zig-out/dist/nxdns-"$VERSION"-*.tar.gz
`zig build dist` writes `zig-out/dist/`: the two tarballs, a staging directory per target under `stage/`, the stripped binaries under `bin/<triple>/`, and a `SHA256SUMS` covering the two tarballs. The published `SHA256SUMS.txt` is that file with a third line for `IMAGE-DIGEST.txt` appended by the release job, so the two tarball lines should match and the local file has no third line to compare. `zig build dist` writes `zig-out/dist/`: the two tarballs, a staging directory per target under `stage/`, the stripped binaries under `bin/<triple>/`, and a `SHA256SUMS` covering the two tarballs. The published `SHA256SUMS.txt` is that file with a third line for `IMAGE-DIGEST.txt` appended by the release job, so the two tarball lines should match and the local file has no third line to compare.
Now the caveat, and it is the whole reason this section is last. **A hash that differs does not mean the release was tampered with.** Nothing in this project measures whether two builds of the same commit on two different machines produce the same bytes, and there are several ordinary reasons they would not: a different Zig patch release, a different Node version, a different path to the build directory, a different npm lockfile resolution. A hash that matches is real evidence. A hash that does not match tells you only that something about the two builds differed, and finding out what is on you. The tarball is written by `zig build dist` itself rather than by the host's `tar` and `gzip`, so its layout is fixed and a rebuild on a matching toolchain reproduces it byte for byte. Entries are sorted by their full path as bytes, with the payload directory first. The payload directory and `nxdns` carry mode `0755`; every other file carries `0644`. Every entry has a zero modification time, uid 0, gid 0, and no user or group name. The gzip wrapper carries no original filename and a zero header timestamp, which is what makes two archives of the same tree compare equal.
If you want the comparison to mean as much as it can, match the toolchain the release used. The Zig version is the second line of `nxdns version`, and both it and the Node version are pinned to exact patch releases at the top of `.gitea/workflows/gates.yml`, which is the workflow the release runs. Compare your two tarball hashes against two things: the published `SHA256SUMS.txt`, and the `hashes` block of `flake.nix` at the tag, which carries the same digests in SRI form. All three agree on a matching toolchain, and the release pipeline fails before it uploads anything if they do not.
> Verified against `v0.0.1`, and the result is the caveat above in action. The A hash that differs is a signal to check the toolchain and the environment first. An unpinned Zig, Node or npm version is the ordinary explanation, and a build run outside the normalized environment above is the next one. Rule both out before you conclude anything about the release itself.
> whole recipe ran from a fresh clone: `git verify-tag v0.0.1` printed
> `Good signature` under the same signing subkey as the release, and > Verified against `v0.0.1`, before the build was reproducible: the recipe ran from a fresh clone, `git verify-tag v0.0.1` printed `Good signature` under the release subkey, and the rebuilt tarball hashes did not match the published `SHA256SUMS.txt` (Node 24.14.1 against the pinned 24.19.0, a different build path, and an archive written by the host's `tar`). Releases from 0.0.17 on are built and checked by the pinned pipeline this page describes, and the cut records the local hashes in `flake.nix` before CI rebuilds them.
> `zig build dist` produced both tarballs. The hashes did **not** match the
> published `SHA256SUMS.txt` — the binaries themselves already differ. The Zig
> version matched the pin exactly; the Node version did not (24.14.1 against
> the pinned 24.19.0) and the build path differed, two of the ordinary causes
> listed above. That is a measurement of what an unpinned rebuild gives you,
> not evidence of tampering: the signature, checksum and image checks earlier
> on this page all passed against the same release.
## If a check fails ## If a check fails
+6 -1
View File
@@ -202,7 +202,12 @@ If a restart and an import race for the write lock, one of them simply wins: bot
## `version` ## `version`
Prints two lines: the nxdns version with the git commit, then the Zig version the binary was built with. Takes no flags and no arguments. Prints two lines: the nxdns version, then the Zig version the binary was built with. Takes no flags and no arguments.
```
nxdns <version>
zig 0.16.0
```
## `help` ## `help`
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1788752844,
"narHash": "sha256-VaWGJ6+cIYN2erfSecbRV+4ljI185Ty2wUrXyvQbgOw=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "dc5d91f840324650bac8c379428c7037a416959a",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+114
View File
@@ -0,0 +1,114 @@
{
description = "nxdns: DNS sinkhole with per-client policy groups";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs =
{ nixpkgs, ... }:
let
# `zig build cut` rewrites the lines between the delimiters by text replacement.
# BEGIN GENERATED BY zig build cut
version = "0.0.16";
hashes = {
"aarch64-linux" = "sha256-C//3SW4mvRDNF1p+HpPizpTBosPvojNId2tiWPRKu38=";
"x86_64-linux" = "sha256-xoL2gBqs4+FSm5peGBq/wk+j9CE5W8wDruGfm8xskI4=";
};
# END GENERATED BY zig build cut
triples = {
"aarch64-linux" = "aarch64-linux-musl";
"x86_64-linux" = "x86_64-linux-musl";
};
systems = builtins.attrNames hashes;
package =
system:
let
pkgs = nixpkgs.legacyPackages.${system};
lib = pkgs.lib;
triple = triples.${system};
in
pkgs.stdenv.mkDerivation {
pname = "nxdns";
inherit version;
src = pkgs.fetchurl {
url = "https://git.mial.net/mokhtar/nxdns/releases/download/v${version}/nxdns-${version}-${triple}.tar.gz";
hash = hashes.${system};
};
# Static musl binary: the install check asserts that no interpreter was patched in.
dontPatchELF = true;
dontStrip = true;
installPhase = ''
runHook preInstall
install -Dm755 nxdns $out/bin/nxdns
install -Dm644 LICENSE $out/share/doc/nxdns/LICENSE
install -Dm644 THIRD-PARTY-NOTICES $out/share/doc/nxdns/THIRD-PARTY-NOTICES
runHook postInstall
'';
doInstallCheck = true;
# GNU readelf prints `INTERP`, not `PT_INTERP`; `${READELF:?}` keeps an unset variable from turning the grep into a pass.
installCheckPhase = ''
runHook preInstallCheck
# Captured first: piping readelf straight into grep hides its exit
# status, so a readelf that failed to read the file at all would
# print nothing, match nothing, and pass as "static".
if ! segments="$(''${READELF:?} -l "$out/bin/nxdns")"; then
echo "readelf -l failed on $out/bin/nxdns" >&2
exit 1
fi
if printf '%s' "$segments" | grep -q INTERP; then
echo "nxdns has an INTERP segment: it is dynamically linked, not static" >&2
exit 1
fi
if ! dynamic="$(''${READELF:?} -d "$out/bin/nxdns")"; then
echo "readelf -d failed on $out/bin/nxdns" >&2
exit 1
fi
if printf '%s' "$dynamic" | grep -q NEEDED; then
echo "nxdns has DT_NEEDED entries: it links against shared libraries" >&2
exit 1
fi
if ! reported="$($out/bin/nxdns version)"; then
echo "nxdns version exited non-zero: the binary does not run here" >&2
exit 1
fi
# Prefix match: releases before 0.0.17 print a commit sha after the version.
case "$reported" in
"nxdns ${version}"*) echo "nxdns version reports: $reported" ;;
*)
echo "nxdns version reports '$reported'; expected it to start with 'nxdns ${version}'" >&2
exit 1
;;
esac
runHook postInstallCheck
'';
meta = {
description = "DNS sinkhole with per-client policy groups";
homepage = "https://git.mial.net/mokhtar/nxdns";
license = lib.licenses.eupl12;
mainProgram = "nxdns";
platforms = systems;
sourceProvenance = [ lib.sourceTypes.binaryNativeCode ];
};
};
in
{
packages = nixpkgs.lib.genAttrs systems (system: {
default = package system;
});
};
}
+91
View File
@@ -0,0 +1,91 @@
# Milestone 40: Nix flake with tag-pinned hashes, one commit per cut
nxdns publishes a `flake.nix` whose package derivations fetch the release tarballs and carry their SRI hashes. A consumer pins `git+https://git.mial.net/mokhtar/nxdns.git?ref=refs/tags/vX.Y.Z` and Renovate bumps the tag. The hashes for a release are known before the release exists because the cut tool builds the same bytes locally that CI builds later, and CI verifies that equality before it uploads anything.
Owner rulings (2026-09-08): the consumer pins a tag, not a branch. A cut stays at two commits and two CI runs (the bump commit runs ci.yml; the tag runs release.yml). No follow-up commit, no extra run, no post-release hash edit. Design agreed with Codex (thread 2026-09-08) and its findings are folded in below.
## Why the hashes can be known in advance
The tarball bytes depend on: the Zig compiler version, the source tree at the bump commit, the admin bundle, the archive format, and nothing else. Today two things break that: the binary embeds the git commit sha (which the bump commit cannot know about itself) and the archive is produced by the runner's `tar` and `gzip`. This milestone removes both. Reproducibility is then a property the repo enforces on every CI run, not a hope.
## Session A: version identity without a commit sha
The release binary reports its version only. The git commit is gone from every surface; it is not replaced by a tag name or by a sentinel.
- `build.zig`: delete the `git-commit` option and the `git_commit` build option. Nothing else in `build.zig` changes in this session (Session B owns the rest of the file).
- `src/version.zig` and `src/cli.zig` `runVersion`: print `nxdns {version}\nzig {zig_version}\n`.
- `src/web/handlers/version.zig`: the `Body` loses `git_commit`. `admin/src/lib/types.ts`, `admin/src/lib/contractSamples.gen.ts` (regenerate with `zig build test -Dintegration -Dcontract-samples-out=<path>`, then copy), `admin/src/shell/AppShell.tsx` footer shows `nxdns v{version}`, and `AppShell.test.tsx` follow.
- `tools/verify_dist.zig`: drop the `--git-commit` argument and the commit check; the `version` check stays.
- `.gitea/workflows/gates.yml` package job and `.gitea/workflows/release.yml` publish job: drop `-Dgit-commit=...` from every `zig build dist` and `zig build verify-dist` line.
- Docs: `docs/reference/cli.md`, `docs/how-to/verify-a-release.md`, `docs/how-to/upgrade.md`, `docs/how-to/install-with-docker.md` show the new `nxdns version` output. `verify-a-release.md` replaces the commit-matching step with the reproduction check from Session D (rebuild at the tag with the same toolchain, compare `SHA256SUMS` and `flake.nix`).
- Tests: every test that asserted the commit in the CLI output, the API body, or the footer asserts the new shape.
## Session B: Zig-owned archive and pin check
`tools/dist_stage.zig` gains two modes and `build.zig` stops calling system `tar` and `gzip`.
- `archive --root <stage dir> --payload <dir name> --out <file.tar.gz>`: walks the payload directory, sorts entries by full path (bytes), writes a GNU tar stream with `std.tar.Writer` (`writeDir` for the payload directory, `writeFile` for regular files, mode 0755 for the directory and the `nxdns` binary, 0644 for every other file, mtime 0, uid 0, gid 0, empty user and group names), finishes with `finishPedantically`, and compresses with `std.compress.flate.Compress` in the `.gzip` container at a fixed level. The output bytes must not depend on the absolute path of the stage directory, the umask, the clock, the locale, or the host's tar/gzip.
- `pin-check --sums <SHA256SUMS> --flake <flake.nix> --version <x.y.z>`: parses the generated block in `flake.nix` (see Session C), converts each `sha256` SRI value to hex, and fails with a per-target message when the tarball entry in `SHA256SUMS` differs or the block's version differs. Only tarball entries are compared.
- `pin --sums <SHA256SUMS> --flake <flake.nix> --version <x.y.z>`: rewrites the generated block from the sums file. Used by the cut tool (Session D).
- `build.zig`: the cross-targets loop replaces `tar_run` and `gzip_run` with one `dist_stage archive` run. A separate `verify-pins` step runs `dist_stage pin-check` against `flake.nix` and `zig-out/dist/SHA256SUMS`. It is not part of `verify-dist`: an ordinary commit between two cuts builds the `build.zig.zon` version from a tree that differs from the released one, so its bytes never match the pins and must not be checked against them. Correction (2026-09-08): Session B first put the check inside `verify-dist`; Session D moves it out.
- Reproducibility test (`zig build test`): stage the same fixture tree under two different absolute directories with different mtimes and file order on disk, archive both, assert byte equality, then read the archive back with `std.tar` and assert names, modes, and sizes.
- `.gitea/workflows/gates.yml`: add `NPM_VERSION` to the top-level `env` next to `NODE_VERSION`; the frontend job asserts `node --version` and `npm --version` equal the pinned values before `npm ci`. The frontend job runs `npm run build` with `umask 022`, `LC_ALL=C`, `LANG=C`, `TZ=UTC`, `SOURCE_DATE_EPOCH=0`. `admin/vite.config.ts` reads no other environment (verified: only `VITEST`).
- `docs/how-to/verify-a-release.md` describes the archive layout (fixed modes, zero timestamps, gzip without a name or mtime) so a reader can reproduce it.
Deviation (Session B): the reproducibility test stages its two trees under two `std.testing.tmpDir` directories rather than two absolute paths. Zig 0.16.0's std exposes no way to read the current working directory, so an absolute path would need a raw `getcwd` syscall. The two roots still differ in path text, file creation order and mtimes, which is what the assertion is about.
## Session C: flake.nix
- `flake.nix` at the repo root with one input, `nixpkgs`, and `packages.{aarch64-linux,x86_64-linux}.default` built from the derivation that `rpi.mial.net` carries in `nixos/pkgs/nxdns.nix` today: `fetchurl` of `https://git.mial.net/mokhtar/nxdns/releases/download/v${version}/nxdns-${version}-${triple}.tar.gz`, `dontPatchELF`, `dontStrip`, install `nxdns`, `LICENSE`, `THIRD-PARTY-NOTICES`, and an `installCheckPhase` that asserts no `INTERP` and no `NEEDED` with `readelf` and that `nxdns version` output starts with `nxdns ${version}`. `meta.license = eupl12`, `meta.mainProgram = "nxdns"`.
- The version and the two hashes live in one delimited block that Session B's `pin` mode rewrites and that nothing else edits by hand:
```nix
# BEGIN GENERATED BY zig build cut
version = "0.0.16";
hashes = {
"aarch64-linux" = "sha256-...";
"x86_64-linux" = "sha256-...";
};
# END GENERATED BY zig build cut
```
The tool finds the two delimiter lines by their text after leading whitespace, keeps the begin line's indentation, and writes the six inner lines at that indentation (the hash lines two spaces deeper). A file with zero or two begin lines is an error.
- `flake.lock` is committed. The consumer sets `inputs.nxdns.inputs.nixpkgs.follows = "nixpkgs"`.
- `nix flake check --no-build` passes locally against the committed file. Evaluation does not fetch the tarball.
- `docs/how-to/install-with-nix.md` documents the consumer side: the flake input with a tag ref, the `follows` line, and the Renovate `nix` manager. It states the window between the tag push and the asset upload during which a fresh pin fetches a 404, and that Renovate only proposes tags that already exist.
- `flake.nix` is not a build input of the tarballs; the pins do not feed back into the bytes they describe.
## Session D: the cut tool pins before it commits
`tools/cut.zig` gains a `pin` stage between the changelog gates and the bump commit. The bump commit's exact diff becomes `build.zig.zon` plus `flake.nix`.
1. Toolchain parity: read `NODE_VERSION`, `NPM_VERSION`, and `ZIG_VERSION` from `.gitea/workflows/gates.yml` and fail unless `node --version`, `npm --version`, and `zig version` match exactly, and unless the host is x86_64 Linux with glibc (host-native bundler bindings, shipped per platform and libc). The check runs before the manifest write so a refusal leaves a clean tree.
2. Admin bundle: in `admin/`, run `npm ci` (npm replaces `node_modules` itself) then `npm run build` with the same normalized environment as the CI frontend job (`umask 022`, `LC_ALL=C`, `LANG=C`, `TZ=UTC`, `SOURCE_DATE_EPOCH=0`, `CI=true`, `npm_config_userconfig=/nonexistent/npmrc-user`, `npm_config_globalconfig=/nonexistent/npmrc-global`, `PATH` and `HOME` passed through, nothing else). Fail on a dirty `admin/dist` that the build did not produce.
3. Dist: run `zig build dist -Dversion-string=<x.y.z> -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe` with the same normalized environment and a private `--cache-dir` under the scratch directory, so a stale local cache cannot leak into the bytes. The global cache stays shared: it is content-addressed, and a fresh one refetches every dependency and trips on zig 0.16.0's unzip, which expects `<global>/tmp` to exist.
4. Pin: run `dist_stage pin` on `zig-out/dist/SHA256SUMS` and `flake.nix`, then `zig build verify-dist` and `zig build verify-pins` with the same flags, then `nix flake check --no-build`.
5. Commit: the existing bump commit now includes `flake.nix`; the clean-tree gate accepts `admin/dist` and `zig-out` as ignored paths only.
Where CI runs the pin check:
- `.gitea/workflows/gates.yml` package job: after `verify-dist`, run `zig build verify-pins` only when the pushed commit changed `build.zig.zon` (compare `HEAD` with its first parent; the checkout needs depth 2). That is the bump commit, the one commit whose pins are otherwise unverified before the tag. Every other commit skips the step, and the skip is printed, not silent.
- `.gitea/workflows/release.yml` publish job: after `verify-dist` and before the image push, the draft, and every upload, run `zig build verify-pins` unconditionally. The tag's tree is the bump commit's tree, and the flake block's version must equal the tag.
The remaining stages (push, ci.yml wait, tag, release.yml wait) are unchanged. CI rebuilds the same bytes from the same inputs; `verify-pins` in the package job fails the run before the tag if the bytes differ, and the same step in the publish job fails before any image push, draft, or upload. `specs/release-cut.md` gains the pin stage and the parity requirement.
Cut tool tests: the `.gitea` version parser, the environment builder (asserts the allowed keys and nothing else), and the pin stage on a fixture repo.
Deviation (Session D): the pin stage is tested in pieces rather than on a fixture repository. There is no fixture-repo helper in `tools/cut.zig` to extend, and the stage's remaining content is four process spawns whose fixture would have to run `npm ci` and two cross-compiled release builds inside `zig build test`. What is tested instead is the parser (against a fixture and against the real `gates.yml`), the environment builder, and the `sh` wrapper — asserting it applies the umask, keeps the real command as the direct child, and reports its exit code. The spawns themselves follow the file's existing rule that process plumbing lives behind thin call sites and is not mocked. Two facts the spec left open: `umask(2)` is not in zig 0.16.0's `std.posix` (only `std.c.umask`, which these tools do not link), so the wrapper is the mechanism; and `build.zig` had no `pin-flake` step, so Session D added one beside `verify-pins`.
## Out of scope
Signing the flake outputs, a Hydra or cachix binary cache, Darwin packages, and a NixOS module. Renovate tag discovery on Gitea is a consumer-side acceptance test run once on `rpi.mial.net` after the first tagged release with a flake.
## Acceptance
- `zig build test` green, including the two-path reproducibility test.
- `nxdns version` prints two lines and no commit; `/api/version` has no `git_commit`; the admin footer shows `nxdns v<version>`.
- `zig build dist` produces tarballs byte-identical across two clean checkouts on the same toolchain; `verify-pins` fails when `flake.nix` disagrees with `SHA256SUMS`; `verify-dist` passes on any tree.
- `nix flake check --no-build` passes.
- `zig build cut -- patch` on a fixture reaches the bump commit with `build.zig.zon` and `flake.nix` as its only diff.
- CHANGELOG `## [Unreleased]` records the flake, the archive change, and the removal of the commit sha from the version surfaces.
+29
View File
@@ -73,3 +73,32 @@ Pure functions unit-tested: semver validation (accept/reject table incl. leading
Every step that cannot answer — the `ls-remote`, the `git show`, the extraction, an unreadable CHANGELOG.md — is a soft FAIL naming the step. A gate that does not know whether the schema moved must never report that it did not. Every step that cannot answer — the `ls-remote`, the `git show`, the extraction, an unreadable CHANGELOG.md — is a soft FAIL naming the step. A gate that does not know whether the schema moved must never report that it did not.
Fixing a FAIL is a sentence in the changelog, not a flag: there is no override, because the only thing the gate asks for is that the release notes be true. Fixing a FAIL is a sentence in the changelog, not a flag: there is no override, because the only thing the gate asks for is that the release notes be true.
## Addendum: the pin stage (milestone 40)
`flake.nix` carries the SHA256 of each release tarball, and a consumer pins the flake to a tag. The hashes therefore have to be written into the file that the tag points at, which means before the bump commit — the only commit the cut makes. The cut builds the release locally, pins what it built, and CI proves the pins describe the bytes it rebuilds.
This amends step 3 of the sequence above. What was one write and one commit is now three moves, in this order and no other:
1. **Toolchain parity.** Read `ZIG_VERSION`, `NODE_VERSION` and `NPM_VERSION` out of the top-level `env:` block of `.gitea/workflows/gates.yml` — a line parser, not a YAML library — and refuse unless `zig version`, `node --version` (less its leading `v`) and `npm --version` report those exact strings, and unless the host is x86_64 Linux with glibc (the admin bundle uses host-native Rolldown and Lightning CSS bindings, shipped per platform and libc, and CI is an x86_64 Ubuntu runner). The cut and CI must build with one toolchain, because the cut writes the hashes and CI recomputes them. A mismatch found here costs nothing; found in the release run it costs a public tag. The pins live in `gates.yml` and are read from there rather than copied, so the two can never drift. Both halves run before the manifest is rewritten, so a refusal leaves the working tree clean.
2. **Write `build.zig.zon`.** The manifest declares the new version, atomically, and is reparsed off disk. Nothing is committed yet.
3. **Build the release.** In `admin/`, `npm ci` then `npm run build`; then `zig build dist -Dversion-string=<next> -Dadmin-dist=admin/dist -Doptimize=ReleaseSafe`, with `--cache-dir` under a scratch directory that is deleted and recreated first (the global cache stays shared: it is content-addressed, and a fresh one refetches every dependency and trips on zig 0.16.0's unzip, which expects `<global>/tmp` to exist). Every one of these runs under a constructed environment holding exactly nine variables — `PATH` and `HOME` from the parent, and `LC_ALL=C`, `LANG=C`, `TZ=UTC`, `SOURCE_DATE_EPOCH=0`, `CI=true`, `npm_config_userconfig=/nonexistent/npmrc-user`, `npm_config_globalconfig=/nonexistent/npmrc-global` (so neither `~/.npmrc` under the passed-through `HOME` nor the node install's `etc/npmrc` is read; the parity check refuses to run if either path exists, and CI asserts the same) — with `umask 022`. The umask arrives through an `sh -c 'umask 022 && exec "$@"'` wrapper because zig 0.16.0 exposes `umask(2)` only as a libc extern that these tools do not link, and `SpawnOptions` has no field for it. The wrapper's `exec` resolves the real command through the child's `PATH`, which is why `PATH` is a passthrough and not a pinned value.
4. **Pin and verify.** `zig build pin-flake` rewrites the generated block of `flake.nix` from `zig-out/dist/SHA256SUMS`; then `zig build verify-dist` and `zig build verify-pins` with the same flags and cache directories, then `nix flake check --no-build` — evaluation only, because the tarballs the block now names do not exist until the release run uploads them.
5. **Commit.** One `git commit -S`, taking `build.zig.zon` and `flake.nix`. The working-tree diff is asserted to be a subset of those two paths and to contain the manifest.
The bump must precede the build because `verify-dist` refuses a build whose `-Dversion-string` disagrees with the manifest; the build must precede the commit because the pins belong to the commit. There is no ordering that satisfies both differently.
On a resumed cut — the bump commit exists and its tag does not — the pin stage still runs in full, and the diff it leaves must be EMPTY. That emptiness is the check: the committed hashes reproduce on this machine today. A non-empty diff is a refusal, because the release CI is about to rebuild would not match the committed pins either.
`admin/dist` and `zig-out` are ignored paths, so the preflight's clean-tree gate is unaffected by the trees this stage writes; the build caches live in the scratch directory and never touch the repository.
### Where CI checks the pins
`verify-pins` is a step of its own and deliberately not part of `verify-dist`. An ordinary commit between two cuts builds the same `build.zig.zon` version from a different tree, so its bytes legitimately differ from the pins, and checking them inside `verify-dist` would fail every such build.
- `gates.yml`, package job: after `verify-dist`, run `verify-pins` only when `HEAD` changes `build.zig.zon` against its first parent. That commit is the bump commit, the one commit whose pins nothing has checked yet. The job's checkout takes `fetch-depth: 2` for the comparison. Every other commit prints why it skipped. A missing first parent is an error, not a skip: it means the checkout is shallower than declared and the question went unanswered.
- `release.yml`, publish job: after `verify-dist` and before the image push, the draft and every upload, run `verify-pins` unconditionally. The tag's tree is the bump commit's tree, so the block must pin these bytes and name this version.
### Tests
The `gates.yml` env parser against a fixture and against the real file; the environment builder, asserting the nine keys and nothing else; and the `sh` wrapper, asserting it applies the umask, keeps the real command as the direct child, and reports its exit code. The stage's remaining steps are process spawns of `npm`, `zig` and `nix`, which are not mocked — the same rule the rest of this file follows.
+1 -2
View File
@@ -453,9 +453,8 @@ pub fn runHelp(r: Runner) u8 {
} }
pub fn runVersion(r: Runner) u8 { pub fn runVersion(r: Runner) u8 {
r.out.print("nxdns {s} ({s})\nzig {s}\n", .{ r.out.print("nxdns {s}\nzig {s}\n", .{
version.string, version.string,
version.git_commit,
version.zig_version_string, version.zig_version_string,
}) catch return finish(r, exit_runtime); }) catch return finish(r, exit_runtime);
return finish(r, exit_ok); return finish(r, exit_ok);
-1
View File
@@ -1,5 +1,4 @@
const build_options = @import("build_options"); const build_options = @import("build_options");
pub const string: []const u8 = build_options.version_string; pub const string: []const u8 = build_options.version_string;
pub const git_commit: []const u8 = build_options.git_commit;
pub const zig_version_string: []const u8 = build_options.zig_version_string; pub const zig_version_string: []const u8 = build_options.zig_version_string;
-4
View File
@@ -11,7 +11,6 @@ const version = @import("../../version.zig");
pub const Body = struct { pub const Body = struct {
version: []const u8, version: []const u8,
git_commit: []const u8,
zig_version: []const u8, zig_version: []const u8,
/// Seconds since the process started. Zero until `started_unix` is wired, /// Seconds since the process started. Zero until `started_unix` is wired,
/// and never negative: a clock stepped backwards must not report a /// and never negative: a clock stepped backwards must not report a
@@ -31,7 +30,6 @@ pub fn handle(
pub fn body(version_string: []const u8, started_unix: i64, now_unix: i64) Body { pub fn body(version_string: []const u8, started_unix: i64, now_unix: i64) Body {
return .{ return .{
.version = if (version_string.len == 0) version.string else version_string, .version = if (version_string.len == 0) version.string else version_string,
.git_commit = version.git_commit,
.zig_version = version.zig_version_string, .zig_version = version.zig_version_string,
.uptime_seconds = uptime(started_unix, now_unix), .uptime_seconds = uptime(started_unix, now_unix),
}; };
@@ -47,7 +45,6 @@ const testing = std.testing;
test "the body carries the build strings and the elapsed time" { test "the body carries the build strings and the elapsed time" {
const out = body("", 1_000, 1_060); const out = body("", 1_000, 1_060);
try testing.expectEqualStrings(version.string, out.version); try testing.expectEqualStrings(version.string, out.version);
try testing.expectEqualStrings(version.git_commit, out.git_commit);
try testing.expectEqualStrings(version.zig_version_string, out.zig_version); try testing.expectEqualStrings(version.zig_version_string, out.zig_version);
try testing.expectEqual(@as(u64, 60), out.uptime_seconds); try testing.expectEqual(@as(u64, 60), out.uptime_seconds);
} }
@@ -66,7 +63,6 @@ test "the body serializes with snake_case field names" {
var writer: std.Io.Writer = .fixed(&buffer); var writer: std.Io.Writer = .fixed(&buffer);
try std.json.Stringify.value(body("1.2.3", 10, 20), .{}, &writer); try std.json.Stringify.value(body("1.2.3", 10, 20), .{}, &writer);
const text = writer.buffered(); const text = writer.buffered();
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"git_commit\":"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"zig_version\":")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"zig_version\":"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"uptime_seconds\":10")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"uptime_seconds\":10"));
} }
+1 -2
View File
@@ -1913,10 +1913,9 @@ components:
Version: Version:
type: object type: object
required: [version, git_commit, zig_version, uptime_seconds] required: [version, zig_version, uptime_seconds]
properties: properties:
version: { type: string } version: { type: string }
git_commit: { type: string }
zig_version: { type: string } zig_version: { type: string }
uptime_seconds: { type: integer } uptime_seconds: { type: integer }
+4 -5
View File
@@ -4223,11 +4223,10 @@ const rate_limited_sample: ContractSample = .{
const ts_print_width = 120; const ts_print_width = 120;
const ts_tab_width = 4; const ts_tab_width = 4;
/// Build identity, not contract data: `git_commit` comes from `-Dgit-commit` /// Build identity, not contract data: `zig_version` comes from the compiler
/// and `zig_version` from the compiler that built the test, so keeping either /// that built the test, so keeping it verbatim would pin the golden to one
/// verbatim would pin the golden to one machine. Neither name occurs anywhere /// machine. The name occurs nowhere else in the contract.
/// else in the contract. const volatile_string_keys = [_][]const u8{"zig_version"};
const volatile_string_keys = [_][]const u8{ "git_commit", "zig_version" };
fn writeTabs(w: *std.Io.Writer, depth: usize) !void { fn writeTabs(w: *std.Io.Writer, depth: usize) !void {
for (0..depth) |_| try w.writeByte('\t'); for (0..depth) |_| try w.writeByte('\t');
+573 -8
View File
@@ -48,6 +48,7 @@
//! this can target is a constant, because there is exactly one. //! this can target is a constant, because there is exactly one.
const std = @import("std"); const std = @import("std");
const builtin = @import("builtin");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const Io = std.Io; const Io = std.Io;
const http = std.http; const http = std.http;
@@ -1414,6 +1415,308 @@ fn attemptBudgetNs(started_ns: i96, now_ns: i96, budget_ns: u64, ceiling_ns: u64
return @max(std.time.ns_per_s, clamped); return @max(std.time.ns_per_s, clamped);
} }
// ---------------------------------------------------------------------------
// The pin stage: toolchain parity, the bundle, the release bytes, the pins
// ---------------------------------------------------------------------------
/// The workflow file whose top-level `env` block is the single record of which
/// toolchain builds a release. The cut reads the same literals CI reads rather
/// than carrying its own copy: two pins that can drift are not a pin.
const gates_workflow_path = ".gitea/workflows/gates.yml";
/// The three toolchain versions `gates.yml` declares.
const ToolchainPins = struct {
zig: []const u8,
node: []const u8,
npm: []const u8,
};
const ToolchainKey = enum { ZIG_VERSION, NODE_VERSION, NPM_VERSION };
/// The three pins out of the top-level `env:` block of `gates.yml`.
///
/// A line parser and not a YAML library: the one shape this has to read is
/// ` KEY: "value"` under a column-zero `env:`, and a dependency that can parse
/// anchors, flow mappings and multi-line scalars would be a liability bought to
/// read three string literals. The block ends at the first non-blank,
/// non-comment line that is not indented, which is how the file's own `jobs:`
/// key terminates it.
///
/// Null when any of the three is missing, because a parity check that silently
/// dropped one of them would report parity it never established.
fn parseToolchainPins(source: []const u8) ?ToolchainPins {
var found: [3]?[]const u8 = .{ null, null, null };
var lines = std.mem.splitScalar(u8, source, '\n');
var inside = false;
while (lines.next()) |raw| {
const line = std.mem.trimEnd(u8, raw, "\r");
if (!inside) {
if (std.mem.eql(u8, line, "env:")) inside = true;
continue;
}
const trimmed = std.mem.trim(u8, line, " \t");
if (trimmed.len == 0 or trimmed[0] == '#') continue;
// Column zero ends the block: a top-level key of the workflow, not an
// entry of this mapping.
if (line[0] != ' ' and line[0] != '\t') break;
const colon = std.mem.indexOfScalar(u8, trimmed, ':') orelse continue;
const key = trimmed[0..colon];
const value = std.mem.trim(u8, std.mem.trim(u8, trimmed[colon + 1 ..], " \t"), "\"'");
const which = std.meta.stringToEnum(ToolchainKey, key) orelse continue;
found[@intFromEnum(which)] = value;
}
return .{
.zig = found[@intFromEnum(ToolchainKey.ZIG_VERSION)] orelse return null,
.node = found[@intFromEnum(ToolchainKey.NODE_VERSION)] orelse return null,
.npm = found[@intFromEnum(ToolchainKey.NPM_VERSION)] orelse return null,
};
}
/// Every variable a release build is allowed to see, and nothing else.
///
/// The bundle and the tarballs are hashed into `flake.nix` before CI rebuilds
/// them, so anything in the operator's shell that can move a byte has to be
/// either pinned here or absent. `PATH` and `HOME` are passed through because a
/// build with neither cannot find `node` or its cache; the other seven are fixed
/// values, and the same seven `gates.yml`'s frontend job sets. `HOME` locates
/// `~/.npmrc` and the node install carries an `etc/npmrc`, which is why both
/// npm config paths point at files that do not exist. Two distinct paths: npm
/// refuses to load one file as both user and global config.
///
/// The umask is not here — it is not an environment variable — and is set by
/// the `sh` wrapper in `runPinned`.
const passthrough_environment = [_][]const u8{ "PATH", "HOME" };
const pinned_environment = [_]struct { key: []const u8, value: []const u8 }{
.{ .key = "LC_ALL", .value = "C" },
.{ .key = "LANG", .value = "C" },
.{ .key = "TZ", .value = "UTC" },
.{ .key = "SOURCE_DATE_EPOCH", .value = "0" },
.{ .key = "CI", .value = "true" },
.{ .key = "npm_config_userconfig", .value = "/nonexistent/npmrc-user" },
.{ .key = "npm_config_globalconfig", .value = "/nonexistent/npmrc-global" },
};
fn normalizedEnvironment(gpa: Allocator, parent: *const std.process.Environ.Map) !std.process.Environ.Map {
var map: std.process.Environ.Map = .init(gpa);
errdefer map.deinit();
for (passthrough_environment) |key| {
try map.put(key, parent.get(key) orelse "");
}
for (pinned_environment) |entry| {
try map.put(entry.key, entry.value);
}
return map;
}
/// The private build caches this stage uses, so a stale entry in the operator's
/// own cache cannot become a release byte. The name is a constant prefix and a
/// version this program has already parsed as a bare semver, so it can be
/// neither empty nor a path outside the temporary directory.
fn scratchRoot(ctx: *Ctx, version: []const u8) []const u8 {
const tmp = ctx.get("TMPDIR");
return ctx.fmt("{s}/nxdns-cut-{s}", .{ if (tmp.len == 0) "/tmp" else tmp, version });
}
/// One command of the pin stage, under the normalized environment.
///
/// stdio is inherited: these commands take minutes and an operator watching a
/// cut needs to see npm and zig make progress. Their success is read from the
/// termination state, exactly as `gitInherit` reads it.
///
/// The umask arrives through `sh` rather than through this process: zig 0.16.0's
/// standard library exposes `umask(2)` only as a libc extern (`std.c.umask`),
/// which these tools do not link, and `std.process.SpawnOptions` has no field
/// for it. `exec "$@"` keeps the real command as the direct child, so its
/// termination state is the one reported here.
fn runPinned(
ctx: *Ctx,
comptime check: []const u8,
environ: *const std.process.Environ.Map,
cwd: []const u8,
argv: []const []const u8,
) !void {
var wrapped: std.ArrayList([]const u8) = .empty;
try wrapped.appendSlice(ctx.arena, &.{ "sh", "-c", "umask 022 && exec \"$@\"", "sh" });
try wrapped.appendSlice(ctx.arena, argv);
const shown = std.mem.join(ctx.arena, " ", argv) catch @panic("OOM");
ctx.note("{s}: running `{s}` in {s}", .{ check, shown, cwd });
var child = std.process.spawn(ctx.io, .{
.argv = wrapped.items,
.cwd = .{ .path = cwd },
.environ_map = environ,
.stdin = .ignore,
.stdout = .inherit,
.stderr = .inherit,
}) catch |err| {
ctx.soft(check, "cannot run `{s}`: {t}", .{ shown, err });
return CheckFailed;
};
const term = child.wait(ctx.io) catch |err| {
ctx.soft(check, "cannot wait for `{s}`: {t}", .{ shown, err });
return CheckFailed;
};
switch (term) {
.exited => |code| if (code != 0) {
ctx.soft(check, "`{s}` exited {d}", .{ shown, code });
return CheckFailed;
},
.signal => |signal| {
ctx.soft(check, "`{s}` was killed by {t}", .{ shown, signal });
return CheckFailed;
},
else => {
ctx.soft(check, "`{s}` did not exit normally", .{shown});
return CheckFailed;
},
}
}
/// Asserts the toolchain that is about to write the release bytes is the one
/// `gates.yml` pins, so the hashes this stage computes are the hashes CI will
/// recompute. A mismatch is a refusal and not a warning: the alternative is a
/// tag whose flake pins bytes no CI run can reproduce, which is only discovered
/// after the tag is public.
fn toolchainParity(ctx: *Ctx) !void {
// The admin bundle is built by Rolldown and Lightning CSS, whose native
// bindings are chosen per host; CI builds it on an x86_64 Ubuntu runner.
// A bundle built on another architecture is a different set of bytes, so
// the hashes this stage writes would be hashes no CI run can reproduce.
if (builtin.cpu.arch != .x86_64 or builtin.os.tag != .linux or builtin.abi != .gnu) {
ctx.soft("toolchain", "this host is {t}-{t}-{t}, and the cut has to run on x86_64 Linux with glibc: the admin bundle uses host-native Rolldown and Lightning CSS bindings, which the lockfile ships per libc, and CI rebuilds it on an x86_64 Ubuntu runner", .{
builtin.cpu.arch, builtin.os.tag, builtin.abi,
});
return CheckFailed;
}
const source = Io.Dir.cwd().readFileAlloc(ctx.io, gates_workflow_path, ctx.arena, .limited(max_input_bytes)) catch |err| {
ctx.soft("toolchain", "cannot read {s}: {t}", .{ gates_workflow_path, err });
return CheckFailed;
};
const pins = parseToolchainPins(source) orelse {
ctx.soft("toolchain", "{s} does not declare ZIG_VERSION, NODE_VERSION and NPM_VERSION in its top-level `env:` block", .{gates_workflow_path});
return CheckFailed;
};
// `node --version` prints `v24.19.0`; the workflow pins the number the way
// setup-node takes it, without the `v`. `npm --version` and `zig version`
// print the bare number already.
try assertVersion(ctx, "node", &.{ "node", "--version" }, "v", pins.node);
try assertVersion(ctx, "npm", &.{ "npm", "--version" }, "", pins.npm);
try assertVersion(ctx, "zig", &.{ "zig", "version" }, "", pins.zig);
// The npm config paths in the release environment silence `~/.npmrc` and
// the node install's `etc/npmrc` only while nothing exists at them.
for (pinned_environment) |entry| {
if (!std.mem.startsWith(u8, entry.key, "npm_config_")) continue;
Io.Dir.accessAbsolute(ctx.io, entry.value, .{}) catch |err| switch (err) {
error.FileNotFound => continue,
else => {
ctx.soft("toolchain", "cannot probe {s}: {t}", .{ entry.value, err });
return CheckFailed;
},
};
ctx.soft("toolchain", "{s} exists, and npm would read it as {s}: the release environment relies on that path being absent", .{ entry.value, entry.key });
return CheckFailed;
}
ctx.pass("toolchain", "x86_64 Linux glibc with zig {s}, node {s} and npm {s}, as {s} pins them", .{ pins.zig, pins.node, pins.npm, gates_workflow_path });
}
fn assertVersion(
ctx: *Ctx,
comptime tool: []const u8,
argv: []const []const u8,
comptime prefix: []const u8,
pinned: []const u8,
) !void {
const run = try capture(ctx, "toolchain", argv, git_local_timeout_s);
if (!run.ok()) {
ctx.soft("toolchain", "`" ++ tool ++ "` exited {d}: {s}", .{
run.code, std.mem.trimEnd(u8, run.combined(ctx.arena), "\n"),
});
return CheckFailed;
}
const reported = run.trimmedStdout();
const want = ctx.fmt(prefix ++ "{s}", .{pinned});
if (!std.mem.eql(u8, reported, want)) {
ctx.soft("toolchain", "`" ++ tool ++ "` reports '{s}', and {s} pins '{s}'; the release bytes are hashed into flake.nix here and rebuilt by CI there, so the two toolchains have to be one", .{
reported, gates_workflow_path, want,
});
return CheckFailed;
}
}
/// Builds the release for `version` and writes its hashes into `flake.nix`.
///
/// Runs AFTER `build.zig.zon` declares `version` and BEFORE the bump commit.
/// The toolchain parity check and the environment are the caller's, because
/// both can refuse and neither reads the manifest: refusing after the manifest
/// was rewritten would leave a dirty tree that the next run's clean-tree gate
/// rejects.
/// The order is forced from both ends: `verify-dist` refuses a build whose
/// `-Dversion-string` disagrees with the manifest, so the manifest has to be
/// bumped first; and `flake.nix` is part of the bump commit's diff, so the pins
/// have to exist before the commit is made.
fn pinStage(ctx: *Ctx, version: []const u8, environ: *const std.process.Environ.Map) !void {
// A fresh local cache each run: the one input of these bytes that lives
// outside the repository and the toolchain. The global cache stays shared:
// it is content-addressed, and a fresh one would refetch every dependency
// and trip on zig 0.16.0's unzip, which expects `<global>/tmp` to exist.
const scratch_root = scratchRoot(ctx, version);
Io.Dir.cwd().deleteTree(ctx.io, scratch_root) catch |err| {
ctx.soft("pin", "cannot clear the build cache at {s}: {t}", .{ scratch_root, err });
return CheckFailed;
};
Io.Dir.cwd().makePath(ctx.io, scratch_root) catch |err| {
ctx.soft("pin", "cannot create the build cache at {s}: {t}", .{ scratch_root, err });
return CheckFailed;
};
const cache_dir = ctx.fmt("{s}/zig-cache", .{scratch_root});
const repo_root = ".";
const admin_root = "admin";
// npm replaces `node_modules` itself, which is why there is no delete here:
// `npm ci` is defined as removing the tree before installing the lockfile.
try runPinned(ctx, "admin-bundle", environ, admin_root, &.{ "npm", "ci" });
try runPinned(ctx, "admin-bundle", environ, admin_root, &.{ "npm", "run", "build" });
ctx.pass("admin-bundle", "admin/dist is built from the lockfile under the release environment", .{});
const version_flag = ctx.fmt("-Dversion-string={s}", .{version});
const dist_flags = [_][]const u8{ version_flag, "-Dadmin-dist=admin/dist", "-Doptimize=ReleaseSafe" };
const cache_flags = [_][]const u8{ "--cache-dir", cache_dir };
try runPinned(ctx, "dist", environ, repo_root, try zigBuild(ctx, "dist", &dist_flags, &cache_flags));
ctx.pass("dist", "the {s} tarballs and SHA256SUMS are under zig-out/dist", .{version});
try runPinned(ctx, "pin", environ, repo_root, try zigBuild(ctx, "pin-flake", &dist_flags, &cache_flags));
try runPinned(ctx, "verify-dist", environ, repo_root, try zigBuild(ctx, "verify-dist", &dist_flags, &cache_flags));
try runPinned(ctx, "verify-pins", environ, repo_root, try zigBuild(ctx, "verify-pins", &dist_flags, &cache_flags));
ctx.pass("verify-pins", "flake.nix pins the hashes of the {s} tarballs this machine just built", .{version});
// Evaluation only: `--no-build` keeps this from fetching the tarballs the
// block now names, which do not exist until the release run uploads them.
try runPinned(ctx, "flake-check", environ, repo_root, &.{ "nix", "flake", "check", "--no-build" });
ctx.pass("flake-check", "`nix flake check --no-build` accepts the rewritten flake.nix", .{});
}
fn zigBuild(
ctx: *Ctx,
step: []const u8,
dist_flags: []const []const u8,
cache_flags: []const []const u8,
) ![]const []const u8 {
var argv: std.ArrayList([]const u8) = .empty;
try argv.appendSlice(ctx.arena, &.{ "zig", "build", step });
try argv.appendSlice(ctx.arena, dist_flags);
try argv.appendSlice(ctx.arena, cache_flags);
return argv.items;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Process plumbing // Process plumbing
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1440,6 +1743,14 @@ const Run = struct {
/// command has no terminal to prompt at and a hung `ls-remote` would otherwise /// command has no terminal to prompt at and a hung `ls-remote` would otherwise
/// wedge the cut. /// wedge the cut.
fn gitCapture(ctx: *Ctx, argv: []const []const u8, timeout_s: u64) !Run { fn gitCapture(ctx: *Ctx, argv: []const []const u8, timeout_s: u64) !Run {
return capture(ctx, "git", argv, timeout_s);
}
/// The same bounded capture under a different check name, for the commands the
/// pin stage reads answers out of (`node --version` and its two siblings). The
/// check name is what an operator greps for, so a `node` that is missing must
/// not report itself as a git failure.
fn capture(ctx: *Ctx, comptime check: []const u8, argv: []const []const u8, timeout_s: u64) !Run {
const result = std.process.run(ctx.gpa, ctx.io, .{ const result = std.process.run(ctx.gpa, ctx.io, .{
.argv = argv, .argv = argv,
.stdout_limit = .limited(max_input_bytes), .stdout_limit = .limited(max_input_bytes),
@@ -1447,13 +1758,13 @@ fn gitCapture(ctx: *Ctx, argv: []const []const u8, timeout_s: u64) !Run {
.timeout = .{ .duration = .{ .raw = .fromSeconds(@intCast(timeout_s)), .clock = .awake } }, .timeout = .{ .duration = .{ .raw = .fromSeconds(@intCast(timeout_s)), .clock = .awake } },
}) catch |err| switch (err) { }) catch |err| switch (err) {
error.Timeout => { error.Timeout => {
ctx.soft("git", "`{s} {s}` produced no answer within {d}s", .{ ctx.soft(check, "`{s} {s}` produced no answer within {d}s", .{
argv[0], if (argv.len > 1) argv[1] else "", timeout_s, argv[0], if (argv.len > 1) argv[1] else "", timeout_s,
}); });
return CheckFailed; return CheckFailed;
}, },
else => { else => {
ctx.soft("git", "cannot run `{s}`: {t}", .{ argv[0], err }); ctx.soft(check, "cannot run `{s}`: {t}", .{ argv[0], err });
return CheckFailed; return CheckFailed;
}, },
}; };
@@ -1796,7 +2107,25 @@ fn cut(ctx: *Ctx, kind_text: []const u8) !void {
// check's failure was recorded. // check's failure was recorded.
const token = checked.authorization orelse return CheckFailed; const token = checked.authorization orelse return CheckFailed;
if (bump_needed) try bump(ctx, version, zon_source); // The one commit this cut makes is assembled in three moves, in this order
// and no other: the manifest declares the version, the pin stage builds that
// version and writes its hashes into `flake.nix`, and the commit takes both
// files. `verify-dist` refuses a build whose `-Dversion-string` disagrees
// with the manifest, so the pins cannot be computed before the bump; and the
// pins belong to the commit, so the commit cannot be made before the pins.
// Both of these run before the manifest is rewritten: they can refuse, and
// a refusal after the rewrite leaves a dirty build.zig.zon that the next
// run's clean-tree gate rejects.
try toolchainParity(ctx);
var environ = normalizedEnvironment(ctx.gpa, ctx.env) catch |err| {
ctx.soft("pin", "cannot build the release environment: {t}", .{err});
return CheckFailed;
};
defer environ.deinit();
if (bump_needed) try writeZonVersion(ctx, version, zon_source);
try pinStage(ctx, version, &environ);
try commitBump(ctx, version, bump_needed);
const sha = try headSha(ctx); const sha = try headSha(ctx);
@@ -2479,7 +2808,18 @@ fn verifyOriginTag(ctx: *Ctx, tag: []const u8, tag_ref: []const u8, commit: []co
} }
/// Rewrites `build.zig.zon` and commits it, and nothing else. /// Rewrites `build.zig.zon` and commits it, and nothing else.
fn bump(ctx: *Ctx, version: []const u8, source: [:0]const u8) !void { /// The two files the bump commit may touch, and the only two. `flake.nix` joins
/// `build.zig.zon` because the pin stage writes the release hashes into it
/// between the manifest write and the commit.
const bump_paths = [_][]const u8{ "build.zig.zon", "flake.nix" };
/// Writes the new version into `build.zig.zon` and proves the file still parses.
///
/// Split from the commit because the pin stage runs between them: `verify-dist`
/// refuses a build whose `-Dversion-string` disagrees with the manifest, so the
/// manifest is bumped first, and `flake.nix` is part of the commit, so the
/// commit is last.
fn writeZonVersion(ctx: *Ctx, version: []const u8, source: [:0]const u8) !void {
const rewritten = rewriteZonVersion(ctx.arena, source, version) catch |err| { const rewritten = rewriteZonVersion(ctx.arena, source, version) catch |err| {
ctx.soft("bump", "cannot rewrite the .version field of build.zig.zon: {t}", .{err}); ctx.soft("bump", "cannot rewrite the .version field of build.zig.zon: {t}", .{err});
return CheckFailed; return CheckFailed;
@@ -2527,22 +2867,75 @@ fn bump(ctx: *Ctx, version: []const u8, source: [:0]const u8) !void {
ctx.soft("bump", "the rewritten build.zig.zon declares '{s}', expected '{s}'", .{ reparsed, version }); ctx.soft("bump", "the rewritten build.zig.zon declares '{s}', expected '{s}'", .{ reparsed, version });
return CheckFailed; return CheckFailed;
} }
ctx.pass("bump", "build.zig.zon declares {s}", .{version});
}
/// Commits the manifest bump and the pins as one commit, after asserting the
/// working tree holds those two files and nothing else.
///
/// `bump_needed` is false on a resumed cut: the commit already exists, so the
/// pin stage has just recomputed pins that are already in it. An empty diff is
/// then the proof that the bytes reproduced, and a non-empty one is a refusal —
/// the committed hashes do not describe what this machine builds today, and
/// resuming would tag a release whose flake lies about it.
fn commitBump(ctx: *Ctx, version: []const u8, bump_needed: bool) !void {
const staged = try gitCapture(ctx, &.{ "git", "diff", "--name-only", "--cached" }, git_local_timeout_s); const staged = try gitCapture(ctx, &.{ "git", "diff", "--name-only", "--cached" }, git_local_timeout_s);
if (!staged.ok() or staged.trimmedStdout().len != 0) { if (!staged.ok() or staged.trimmedStdout().len != 0) {
ctx.soft("bump", "the index is not empty:\n{s}", .{staged.trimmedStdout()}); ctx.soft("bump", "the index is not empty:\n{s}", .{staged.trimmedStdout()});
return CheckFailed; return CheckFailed;
} }
const changed = try gitCapture(ctx, &.{ "git", "diff", "--name-only" }, git_local_timeout_s); const changed = try gitCapture(ctx, &.{ "git", "diff", "--name-only" }, git_local_timeout_s);
if (!changed.ok() or !std.mem.eql(u8, changed.trimmedStdout(), "build.zig.zon")) { if (!changed.ok()) {
ctx.soft("bump", "the bump changed '{s}', expected build.zig.zon and nothing else", .{changed.trimmedStdout()}); ctx.soft("bump", "`git diff --name-only` exited {d}", .{changed.code});
return CheckFailed;
}
const report = changed.trimmedStdout();
var manifest_changed = false;
var lines = std.mem.tokenizeScalar(u8, report, '\n');
while (lines.next()) |line| {
const path = std.mem.trim(u8, line, " \t\r");
if (path.len == 0) continue;
if (std.mem.eql(u8, path, bump_paths[0])) manifest_changed = true;
for (bump_paths) |allowed| {
if (std.mem.eql(u8, path, allowed)) break;
} else {
ctx.soft("bump", "the cut changed '{s}'; the bump commit is {s} and {s} and nothing else", .{
path, bump_paths[0], bump_paths[1],
});
return CheckFailed;
}
}
if (!bump_needed) {
// A resumed cut: the bump commit exists, so the pin stage has just
// rewritten a `flake.nix` that is already committed. Reproducing byte
// for byte leaves nothing to commit, and that emptiness is the check.
if (report.len != 0) {
ctx.soft("bump", "{s} declares {s} already, so its bump commit is made, but the pin stage changed:\n{s}\nThe committed hashes do not describe what this machine builds today; the release CI is about to rebuild would not match them either", .{
bump_paths[0], version, report,
});
return CheckFailed;
}
ctx.pass("bump", "the bump commit for {s} is already made and its pins still describe today's bytes", .{version});
return;
}
if (!manifest_changed) {
ctx.soft("bump", "this cut bumps to {s} and {s} is unchanged", .{ version, bump_paths[0] });
return CheckFailed; return CheckFailed;
} }
try gitInherit(ctx, "bump", &.{ try gitInherit(ctx, "bump", &.{
"git", "commit", "-S", "-m", ctx.fmt("build: bump version to {s}", .{version}), "--", "build.zig.zon", "git", "commit",
"-S", "-m",
ctx.fmt("build: bump version to {s}", .{version}), "--",
bump_paths[0], bump_paths[1],
});
ctx.pass("bump", "one commit: {s} declares {s} and {s} pins its release hashes", .{
bump_paths[0], version, bump_paths[1],
}); });
ctx.pass("bump", "build.zig.zon declares {s}", .{version});
} }
fn headSha(ctx: *Ctx) ![]const u8 { fn headSha(ctx: *Ctx) ![]const u8 {
@@ -4255,3 +4648,175 @@ test "restore instructions need a heading and something under it" {
try testing.expect(!disclosesRestoreInstructions("### Restoring\nbody\n")); try testing.expect(!disclosesRestoreInstructions("### Restoring\nbody\n"));
try testing.expect(disclosesRestoreInstructions("intro\n" ++ restore_heading ++ "\n- move it back\n")); try testing.expect(disclosesRestoreInstructions("intro\n" ++ restore_heading ++ "\n- move it back\n"));
} }
test "the toolchain pins come out of the top-level env block of gates.yml" {
const source =
\\on:
\\ workflow_call:
\\
\\env:
\\ ZIG_VERSION: "0.16.0"
\\ # A comment between two entries, which the file has.
\\ NODE_VERSION: "24.19.0"
\\ NPM_VERSION: "11.17.0"
\\
\\jobs:
\\ frontend:
\\ env:
\\ ZIG_VERSION: "9.9.9"
\\
;
const pins = parseToolchainPins(source).?;
try testing.expectEqualStrings("0.16.0", pins.zig);
try testing.expectEqualStrings("24.19.0", pins.node);
try testing.expectEqualStrings("11.17.0", pins.npm);
}
test "a gates.yml missing any one pin yields no pins at all" {
// Parity established for two of three tools is not parity, so there is no
// partial answer to return.
try testing.expect(parseToolchainPins(
\\env:
\\ ZIG_VERSION: "0.16.0"
\\ NODE_VERSION: "24.19.0"
\\
) == null);
try testing.expect(parseToolchainPins("jobs:\n package:\n") == null);
}
test "the release environment is exactly nine variables" {
var parent: std.process.Environ.Map = .init(testing.allocator);
defer parent.deinit();
try parent.put("PATH", "/usr/bin");
try parent.put("HOME", "/home/someone");
// The kind of thing a developer shell carries that must not reach a build
// whose bytes are about to be hashed into flake.nix.
try parent.put("LANG", "en_GB.UTF-8");
try parent.put("NODE_OPTIONS", "--max-old-space-size=8192");
try parent.put("SOURCE_DATE_EPOCH", "1757289600");
var environ = try normalizedEnvironment(testing.allocator, &parent);
defer environ.deinit();
try testing.expectEqualStrings("/usr/bin", environ.get("PATH").?);
try testing.expectEqualStrings("/home/someone", environ.get("HOME").?);
try testing.expectEqualStrings("C", environ.get("LC_ALL").?);
try testing.expectEqualStrings("C", environ.get("LANG").?);
try testing.expectEqualStrings("UTC", environ.get("TZ").?);
try testing.expectEqualStrings("0", environ.get("SOURCE_DATE_EPOCH").?);
try testing.expectEqualStrings("true", environ.get("CI").?);
try testing.expectEqualStrings("/nonexistent/npmrc-user", environ.get("npm_config_userconfig").?);
try testing.expectEqualStrings("/nonexistent/npmrc-global", environ.get("npm_config_globalconfig").?);
try testing.expect(environ.get("NODE_OPTIONS") == null);
try testing.expectEqual(
@as(usize, passthrough_environment.len + pinned_environment.len),
environ.count(),
);
}
test "a passed-through variable the parent does not set becomes empty, not absent" {
// An absent HOME would make `npm ci` pick a cache directory of its own
// choosing; an empty one fails loudly instead.
var parent: std.process.Environ.Map = .init(testing.allocator);
defer parent.deinit();
var environ = try normalizedEnvironment(testing.allocator, &parent);
defer environ.deinit();
try testing.expectEqualStrings("", environ.get("HOME").?);
try testing.expectEqualStrings("", environ.get("PATH").?);
}
test "this repository's gates.yml pins the toolchain the cut asserts" {
// The parser reads the real file, not only a fixture: a rename of one of
// the three keys would otherwise turn the parity check into a refusal that
// nothing here noticed.
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const source = try Io.Dir.cwd().readFileAlloc(
threaded.io(),
gates_workflow_path,
arena_state.allocator(),
.limited(max_input_bytes),
);
const pins = parseToolchainPins(source) orelse return error.NoToolchainPins;
try testing.expect(pins.zig.len != 0);
try testing.expect(pins.node.len != 0);
try testing.expect(pins.npm.len != 0);
}
test "release.yml pins the same toolchain as gates.yml" {
// The publish job builds the bundle and the tarballs itself, so a pin that
// drifts between the two files would let the tag's run emit different bytes
// from the ones the cut pinned.
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const io = threaded.io();
const gates = try Io.Dir.cwd().readFileAlloc(io, gates_workflow_path, arena, .limited(max_input_bytes));
const release = try Io.Dir.cwd().readFileAlloc(io, ".gitea/workflows/release.yml", arena, .limited(max_input_bytes));
const expected = parseToolchainPins(gates) orelse return error.NoToolchainPins;
const found = parseToolchainPins(release) orelse return error.NoToolchainPins;
try testing.expectEqualStrings(expected.zig, found.zig);
try testing.expectEqualStrings(expected.node, found.node);
try testing.expectEqualStrings(expected.npm, found.npm);
}
test "the sh wrapper sets the umask, keeps the real command and reports its exit code" {
// The umask is the one release input this program cannot set on itself:
// zig 0.16.0 exposes `umask(2)` only through libc, which these tools do not
// link. It arrives through `sh` instead, so what `sh` actually does with
// that argument vector is worth proving rather than assuming.
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var parent: std.process.Environ.Map = .init(testing.allocator);
defer parent.deinit();
var environ = try normalizedEnvironment(testing.allocator, &parent);
defer environ.deinit();
// The wrapper's own `exec` resolves the real command through the CHILD's
// PATH, not the parent's, so the release commands reach `npm`, `zig` and
// `nix` through the PATH this map carries. That is what makes PATH a
// passthrough rather than a pinned value, and this test needs one too.
try environ.put("PATH", "/bin:/usr/bin");
var sink: Io.Writer.Allocating = .init(arena);
var ctx: Ctx = .{
.arena = arena,
.gpa = testing.allocator,
.io = threaded.io(),
.env = &parent,
.out = &sink.writer,
};
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const dir = try arena.dupe(u8, &tmp.sub_path);
const report = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}/umask.txt", .{dir});
// `.` is the test's own working directory, which build.zig pins to the
// repository root for this test binary.
try runPinned(&ctx, "wrapper", &environ, ".", &.{
"sh", "-c", try std.fmt.allocPrint(arena, "umask > '{s}'", .{report}),
});
const written = try Io.Dir.cwd().readFileAlloc(threaded.io(), report, arena, .limited(max_input_bytes));
try testing.expectEqualStrings("0022", std.mem.trim(u8, written, " \t\r\n"));
// And a failing command is a refusal, not a pass: `exec` makes the real
// command the direct child, so its status is the one `wait` returns.
try testing.expectError(error.CheckFailed, runPinned(&ctx, "wrapper", &environ, ".", &.{
"sh", "-c", "exit 3",
}));
try testing.expect(std.mem.indexOf(u8, sink.written(), "exited 3") != null);
}
+559 -3
View File
@@ -1,6 +1,6 @@
//! Release payload staging for `zig build dist` (milestone-14 rulings 3 and 4). //! Release payload staging for `zig build dist` (milestone-14 rulings 3 and 4).
//! //!
//! Two modes, both writing only into paths the build system handed them: //! Five modes, all writing only into paths the build system handed them:
//! //!
//! dist_stage stage --out <dir> --binary <path> --service <path> //! dist_stage stage --out <dir> --binary <path> --service <path>
//! --sysusers <path> --license <path> --install-md <path> //! --sysusers <path> --license <path> --install-md <path>
@@ -12,6 +12,27 @@
//! scraped from the dependency tree, because a generated notices file that //! scraped from the dependency tree, because a generated notices file that
//! nobody reads rots silently into a false statement. //! nobody reads rots silently into a false statement.
//! //!
//! dist_stage archive --root <dir> --payload <name> --out <file.tar.gz>
//!
//! Writes the release tarball itself, so that the bytes depend on the
//! staged tree and nothing else. The host's `tar` and `gzip` cannot supply
//! that: their member order, their padding and their gzip header all vary
//! with the implementation, the locale and the clock.
//!
//! dist_stage pin --sums <SHA256SUMS> --flake <flake.nix> --version <x.y.z>
//! dist_stage pin-check --sums <SHA256SUMS> --flake <flake.nix> --version <x.y.z>
//!
//! Rewrite, or assert, the generated block of `flake.nix` from the sums
//! file. `pin` backs `zig build pin-flake`, which the cut runs before its
//! bump commit; `pin-check` backs `zig build verify-pins`, which CI runs on
//! that commit and again on the tag, so a release whose pinned hashes do not
//! describe its own bytes is refused before anything is uploaded.
//!
//! `verify-pins` is a step of its own and NOT part of `verify-dist`: an
//! ordinary commit between two cuts builds the same `build.zig.zon` version
//! from a different tree, so its bytes never match the pins and checking
//! them there would fail every such build.
//!
//! dist_stage sums --out <file> [--entry <name> <path>]... //! dist_stage sums --out <file> [--entry <name> <path>]...
//! //!
//! Writes `sha256sum`-format lines, one per `--entry`, in argument order. //! Writes `sha256sum`-format lines, one per `--entry`, in argument order.
@@ -55,11 +76,17 @@ pub fn main(init: std.process.Init) !void {
const arena = init.arena.allocator(); const arena = init.arena.allocator();
const io = init.io; const io = init.io;
const args = try init.minimal.args.toSlice(arena); const args = try init.minimal.args.toSlice(arena);
if (args.len < 2) std.process.fatal("usage: dist_stage <stage|sums> ...", .{}); if (args.len < 2) std.process.fatal("usage: dist_stage <stage|sums|archive|pin|pin-check> ...", .{});
if (std.mem.eql(u8, args[1], "stage")) return stage(arena, io, args[2..]); if (std.mem.eql(u8, args[1], "stage")) return stage(arena, io, args[2..]);
if (std.mem.eql(u8, args[1], "sums")) return sums(arena, io, args[2..]); if (std.mem.eql(u8, args[1], "sums")) return sums(arena, io, args[2..]);
std.process.fatal("unknown mode '{s}': expected `stage` or `sums`", .{args[1]}); if (std.mem.eql(u8, args[1], "archive")) return archive(arena, io, args[2..]);
if (std.mem.eql(u8, args[1], "pin")) return pin(arena, io, args[2..], .rewrite);
if (std.mem.eql(u8, args[1], "pin-check")) return pin(arena, io, args[2..], .check);
std.process.fatal(
"unknown mode '{s}': expected `stage`, `sums`, `archive`, `pin` or `pin-check`",
.{args[1]},
);
} }
fn stage(arena: Allocator, io: Io, args: []const []const u8) !void { fn stage(arena: Allocator, io: Io, args: []const []const u8) !void {
@@ -255,3 +282,532 @@ fn parseInventory(arena: Allocator, source: [:0]const u8) ![]const Component {
error.ParseZon => std.process.fatal("licenses/inventory.zon:\n{f}", .{&diagnostics}), error.ParseZon => std.process.fatal("licenses/inventory.zon:\n{f}", .{&diagnostics}),
}; };
} }
// --- archive -----------------------------------------------------------------
const binary_name = "nxdns";
/// gzip level 9, the level the release archives were produced at before this
/// tool owned them. The level is part of the output bytes, so it is fixed here
/// rather than read from anywhere.
const compression: std.compress.flate.Compress.Options = .level_9;
const ArchiveEntry = struct {
/// Path inside the tarball, payload directory included.
path: []const u8,
kind: std.Io.File.Kind,
fn lessThan(_: void, a: ArchiveEntry, b: ArchiveEntry) bool {
return std.mem.order(u8, a.path, b.path) == .lt;
}
};
fn archive(arena: Allocator, io: Io, args: []const []const u8) !void {
var root_path: ?[]const u8 = null;
var payload_name: ?[]const u8 = null;
var out_path: ?[]const u8 = null;
var i: usize = 0;
while (i < args.len) : (i += 2) {
if (i + 1 >= args.len) std.process.fatal("'{s}' needs a value", .{args[i]});
const value = args[i + 1];
if (std.mem.eql(u8, args[i], "--root")) {
root_path = value;
} else if (std.mem.eql(u8, args[i], "--payload")) {
payload_name = value;
} else if (std.mem.eql(u8, args[i], "--out")) {
out_path = value;
} else {
std.process.fatal("unknown flag '{s}'", .{args[i]});
}
}
const root = required(root_path, "--root");
const name = required(payload_name, "--payload");
const out = required(out_path, "--out");
const bytes = buildArchive(arena, io, root, name) catch |err| {
std.process.fatal("cannot archive '{s}/{s}': {t}", .{ root, name, err });
};
try writeFileWithMode(io, Io.Dir.cwd(), out, bytes, 0o644);
}
/// The tarball bytes for `<root>/<payload>`. Every field a tar member carries
/// is fixed here — order, mode, mtime, uid, gid, user and group names — so two
/// runs over the same file contents produce the same bytes whatever the
/// absolute path, the umask, the clock or the locale is.
fn buildArchive(arena: Allocator, io: Io, root: []const u8, name: []const u8) ![]const u8 {
const payload_path = try std.fs.path.join(arena, &.{ root, name });
var payload_dir = Io.Dir.cwd().openDir(io, payload_path, .{ .iterate = true }) catch |err| {
std.process.fatal("cannot open the payload directory '{s}': {t}", .{ payload_path, err });
};
defer payload_dir.close(io);
var entries: std.ArrayList(ArchiveEntry) = .empty;
var walker = try payload_dir.walk(arena);
defer walker.deinit();
while (walker.next(io) catch |err| {
std.process.fatal("cannot walk '{s}': {t}", .{ payload_path, err });
}) |entry| {
switch (entry.kind) {
.file, .directory => {},
// A payload that grew a symlink or a device node would ship
// something the verifier rejects; say so here instead.
else => std.process.fatal(
"'{s}/{s}' is a {t}: the release payload holds only files and directories",
.{ payload_path, entry.path, entry.kind },
),
}
try entries.append(arena, .{
.path = try std.fs.path.join(arena, &.{ name, entry.path }),
.kind = entry.kind,
});
}
std.mem.sort(ArchiveEntry, entries.items, {}, ArchiveEntry.lessThan);
var tar_bytes: Io.Writer.Allocating = try .initCapacity(arena, 1 << 20);
var tar_writer: std.tar.Writer = .{ .underlying_writer = &tar_bytes.writer };
try tar_writer.writeDir(name, .{ .mode = payload_dir_mode, .mtime = 0 });
for (entries.items) |entry| {
switch (entry.kind) {
.directory => try tar_writer.writeDir(entry.path, .{ .mode = payload_dir_mode, .mtime = 0 }),
.file => {
const relative = entry.path[name.len + 1 ..];
const bytes = payload_dir.readFileAlloc(io, relative, arena, .limited(max_input_bytes)) catch |err| {
std.process.fatal("cannot read '{s}/{s}': {t}", .{ payload_path, relative, err });
};
const mode: std.posix.mode_t = if (std.mem.eql(u8, relative, binary_name)) 0o755 else 0o644;
try tar_writer.writeFileBytes(entry.path, bytes, .{ .mode = mode, .mtime = 0 });
},
else => unreachable,
}
}
try tar_writer.finishPedantically();
var gz_bytes: Io.Writer.Allocating = try .initCapacity(arena, 1 << 20);
const window = try arena.alloc(u8, std.compress.flate.max_window_len);
var compress: std.compress.flate.Compress = try .init(&gz_bytes.writer, window, .gzip, compression);
try compress.writer.writeAll(tar_bytes.written());
try compress.finish();
return gz_bytes.written();
}
// --- pin / pin-check ---------------------------------------------------------
/// The two release systems, and the target triple each one's tarball carries.
/// Nix names the system, the release names the triple; the block is written in
/// this order.
const pin_targets = [_]struct { system: []const u8, triple: []const u8 }{
.{ .system = "aarch64-linux", .triple = "aarch64-linux-musl" },
.{ .system = "x86_64-linux", .triple = "x86_64-linux-musl" },
};
const begin_marker = "# BEGIN GENERATED BY zig build cut";
const end_marker = "# END GENERATED BY zig build cut";
const PinMode = enum { rewrite, check };
const digest_length = std.crypto.hash.sha2.Sha256.digest_length;
fn pin(arena: Allocator, io: Io, args: []const []const u8, mode: PinMode) !void {
var sums_path: ?[]const u8 = null;
var flake_path: ?[]const u8 = null;
var version: ?[]const u8 = null;
var i: usize = 0;
while (i < args.len) : (i += 2) {
if (i + 1 >= args.len) std.process.fatal("'{s}' needs a value", .{args[i]});
const value = args[i + 1];
if (std.mem.eql(u8, args[i], "--sums")) {
sums_path = value;
} else if (std.mem.eql(u8, args[i], "--flake")) {
flake_path = value;
} else if (std.mem.eql(u8, args[i], "--version")) {
version = value;
} else {
std.process.fatal("unknown flag '{s}'", .{args[i]});
}
}
const sums_file = required(sums_path, "--sums");
const flake_file = required(flake_path, "--flake");
const version_string = required(version, "--version");
const sums_text = Io.Dir.cwd().readFileAlloc(io, sums_file, arena, .limited(max_input_bytes)) catch |err| {
std.process.fatal("cannot read '{s}': {t}", .{ sums_file, err });
};
const flake_text = Io.Dir.cwd().readFileAlloc(io, flake_file, arena, .limited(max_input_bytes)) catch |err| {
std.process.fatal("cannot read '{s}': {t}", .{ flake_file, err });
};
var expected: [pin_targets.len][]const u8 = undefined;
for (pin_targets, &expected) |target, *slot| {
const asset = try std.fmt.allocPrint(arena, "nxdns-{s}-{s}.tar.gz", .{ version_string, target.triple });
slot.* = hashFromSums(sums_text, asset) orelse
std.process.fatal("'{s}' has no line for '{s}'", .{ sums_file, asset });
}
switch (mode) {
.rewrite => {
const rewritten = try rewriteBlock(arena, flake_text, flake_file, version_string, expected);
try writeFileWithMode(io, Io.Dir.cwd(), flake_file, rewritten, 0o644);
},
.check => {
const block = try readBlock(arena, flake_text, flake_file);
var failed = false;
if (!std.mem.eql(u8, block.version, version_string)) {
std.debug.print(
"{s}: the generated block pins version {s}, the release is {s}\n",
.{ flake_file, block.version, version_string },
);
failed = true;
}
for (pin_targets, block.hashes, expected) |target, found, want| {
const want_sri = try sriFromHex(arena, want);
if (!std.mem.eql(u8, found, want_sri)) {
std.debug.print(
"{s}: {s} is pinned to {s}, the release tarball hashes to {s}\n",
.{ flake_file, target.system, found, want_sri },
);
failed = true;
}
}
if (failed) std.process.fatal(
"flake.nix does not describe this release; run `zig build cut`, which pins before it commits",
.{},
);
},
}
}
/// The hex digest `sha256sum` prints for `asset`, or null when the file has no
/// such line. Lines are `<hex> <filename>`.
fn hashFromSums(text: []const u8, asset: []const u8) ?[]const u8 {
var lines = std.mem.splitScalar(u8, text, '\n');
while (lines.next()) |line| {
const separator = std.mem.indexOf(u8, line, " ") orelse continue;
if (!std.mem.eql(u8, std.mem.trimEnd(u8, line[separator + 2 ..], "\r"), asset)) continue;
return line[0..separator];
}
return null;
}
fn sriFromHex(arena: Allocator, hex: []const u8) ![]const u8 {
if (hex.len != digest_length * 2) std.process.fatal("'{s}' is not a sha256 hex digest", .{hex});
var raw: [digest_length]u8 = undefined;
_ = std.fmt.hexToBytes(&raw, hex) catch std.process.fatal("'{s}' is not a sha256 hex digest", .{hex});
const encoder = std.base64.standard.Encoder;
const out = try arena.alloc(u8, "sha256-".len + encoder.calcSize(raw.len));
@memcpy(out[0.."sha256-".len], "sha256-");
_ = encoder.encode(out["sha256-".len..], &raw);
return out;
}
const Block = struct {
version: []const u8,
hashes: [pin_targets.len][]const u8,
};
/// Byte offsets of the generated block in `text`, marker lines included.
const BlockSpan = struct {
start: usize,
end: usize,
indent: []const u8,
};
/// Why `locateBlock` refused. The generated block is rewritten in place, so a
/// file that does not delimit exactly one block has no unambiguous region to
/// rewrite and every reading of it is a guess.
const BlockError = error{
NotOneBegin,
NotOneEnd,
EndBeforeBegin,
};
fn locateBlock(text: []const u8) BlockError!BlockSpan {
var begins: usize = 0;
var ends: usize = 0;
var offset: usize = 0;
var begin_at: usize = 0;
var end_at: usize = 0;
var indent: []const u8 = "";
while (offset < text.len) {
const line_end = std.mem.indexOfScalarPos(u8, text, offset, '\n') orelse text.len;
const line = text[offset..line_end];
const trimmed = std.mem.trimStart(u8, line, " \t");
if (std.mem.eql(u8, trimmed, begin_marker)) {
begins += 1;
begin_at = offset;
indent = line[0 .. line.len - trimmed.len];
} else if (std.mem.eql(u8, trimmed, end_marker)) {
ends += 1;
end_at = @min(line_end + 1, text.len);
}
offset = line_end + 1;
}
if (begins != 1) return error.NotOneBegin;
if (ends != 1) return error.NotOneEnd;
if (end_at <= begin_at) return error.EndBeforeBegin;
return .{ .start = begin_at, .end = end_at, .indent = indent };
}
fn findBlock(text: []const u8, path: []const u8) BlockSpan {
return locateBlock(text) catch |err| switch (err) {
error.NotOneBegin => std.process.fatal(
"{s}: expected exactly one '{s}' line",
.{ path, begin_marker },
),
error.NotOneEnd => std.process.fatal(
"{s}: expected exactly one '{s}' line",
.{ path, end_marker },
),
error.EndBeforeBegin => std.process.fatal(
"{s}: the '{s}' line comes before the '{s}' line",
.{ path, end_marker, begin_marker },
),
};
}
fn readBlock(arena: Allocator, text: []const u8, path: []const u8) !Block {
const span = findBlock(text, path);
var block: Block = .{ .version = "", .hashes = @splat("") };
var lines = std.mem.splitScalar(u8, text[span.start..span.end], '\n');
while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, " \t");
if (std.mem.startsWith(u8, trimmed, "version = ")) {
block.version = try quoted(arena, trimmed, path);
}
for (pin_targets, &block.hashes) |target, *slot| {
const prefix = try std.fmt.allocPrint(arena, "\"{s}\" = ", .{target.system});
if (std.mem.startsWith(u8, trimmed, prefix)) {
slot.* = try quoted(arena, trimmed[prefix.len..], path);
}
}
}
if (block.version.len == 0) std.process.fatal("{s}: the generated block has no `version` line", .{path});
for (pin_targets, block.hashes) |target, hash| {
if (hash.len == 0) std.process.fatal(
"{s}: the generated block has no hash for {s}",
.{ path, target.system },
);
}
return block;
}
/// The contents of the last double-quoted string on `line`.
fn quoted(arena: Allocator, line: []const u8, path: []const u8) ![]const u8 {
const open = std.mem.indexOfScalar(u8, line, '"') orelse
std.process.fatal("{s}: '{s}' has no quoted value", .{ path, line });
const close = std.mem.indexOfScalarPos(u8, line, open + 1, '"') orelse
std.process.fatal("{s}: '{s}' has no closing quote", .{ path, line });
return arena.dupe(u8, line[open + 1 .. close]);
}
fn rewriteBlock(
arena: Allocator,
text: []const u8,
path: []const u8,
version: []const u8,
hex: [pin_targets.len][]const u8,
) ![]const u8 {
const span = findBlock(text, path);
var out: Io.Writer.Allocating = try .initCapacity(arena, text.len + 512);
const w = &out.writer;
try w.writeAll(text[0..span.start]);
try w.print("{s}{s}\n", .{ span.indent, begin_marker });
try w.print("{s}version = \"{s}\";\n", .{ span.indent, version });
try w.print("{s}hashes = {{\n", .{span.indent});
for (pin_targets, hex) |target, digest| {
try w.print("{s} \"{s}\" = \"{s}\";\n", .{ span.indent, target.system, try sriFromHex(arena, digest) });
}
try w.print("{s}}};\n", .{span.indent});
try w.print("{s}{s}\n", .{ span.indent, end_marker });
try w.writeAll(text[span.end..]);
return out.written();
}
// --- tests -------------------------------------------------------------------
const testing = std.testing;
/// The release payload, in the byte order the archive must list it in.
const fixture = [_]struct { name: []const u8, contents: []const u8, mode: std.posix.mode_t }{
.{ .name = "INSTALL.md", .contents = "# install\n", .mode = 0o644 },
.{ .name = "LICENSE", .contents = "EUPL\n", .mode = 0o644 },
.{ .name = "THIRD-PARTY-NOTICES", .contents = "notices\n", .mode = 0o644 },
.{ .name = binary_name, .contents = "\x7fELF not really", .mode = 0o755 },
.{ .name = "nxdns.conf", .contents = "u nxdns\n", .mode = 0o644 },
.{ .name = "nxdns.service", .contents = "[Unit]\n", .mode = 0o644 },
};
/// Stages the fixture under `<root>/<name>`, writing the files in `order` and
/// stamping each one with `mtime` so the two staged trees differ in everything
/// but their contents.
fn stageFixture(
io: Io,
root: Io.Dir,
name: []const u8,
order: []const usize,
mtime: i96,
) !void {
try root.createDir(io, name, .fromMode(payload_dir_mode));
var dir = try root.openDir(io, name, .{ .iterate = true });
defer dir.close(io);
for (order) |index| {
const file = fixture[index];
var handle = try dir.createFile(io, file.name, .{});
defer handle.close(io);
try handle.writeStreamingAll(io, file.contents);
try handle.setPermissions(io, .fromMode(file.mode));
try handle.setTimestamps(io, .{
.access_timestamp = .{ .new = .{ .nanoseconds = mtime } },
.modify_timestamp = .{ .new = .{ .nanoseconds = mtime } },
});
}
}
test "archive bytes do not depend on the stage path, the file order or the mtimes" {
const io = testing.io;
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var first_tmp = testing.tmpDir(.{ .iterate = true });
defer first_tmp.cleanup();
var second_tmp = testing.tmpDir(.{ .iterate = true });
defer second_tmp.cleanup();
const name = "nxdns-0.0.16-x86_64-linux-musl";
try stageFixture(io, first_tmp.dir, name, &.{ 0, 1, 2, 3, 4, 5 }, 0);
try stageFixture(io, second_tmp.dir, name, &.{ 5, 3, 1, 4, 2, 0 }, 1_700_000_000 * std.time.ns_per_s);
const first_root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{first_tmp.sub_path});
const second_root = try std.fmt.allocPrint(arena, ".zig-cache/tmp/{s}", .{second_tmp.sub_path});
const first = try buildArchive(arena, io, first_root, name);
const second = try buildArchive(arena, io, second_root, name);
try testing.expectEqualSlices(u8, first, second);
// The gzip header carries no name and no mtime: bytes 4..8 are the mtime
// field of RFC 1952, and bit 3 of the flag byte would announce a name.
try testing.expectEqual(@as(u8, 0x1f), first[0]);
try testing.expectEqual(@as(u8, 0x8b), first[1]);
try testing.expectEqual(@as(u8, 0), first[3] & 0b0000_1000);
try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, first[4..8]);
var gz_reader: Io.Reader = .fixed(first);
const window = try arena.alloc(u8, std.compress.flate.max_window_len);
var decompress: std.compress.flate.Decompress = .init(&gz_reader, .gzip, window);
var it: std.tar.Iterator = .init(&decompress.reader, .{
.file_name_buffer = try arena.alloc(u8, std.fs.max_path_bytes),
.link_name_buffer = try arena.alloc(u8, std.fs.max_path_bytes),
});
const root_entry = (try it.next()).?;
try testing.expectEqual(std.tar.FileKind.directory, root_entry.kind);
try testing.expectEqualStrings(name, std.mem.trimEnd(u8, root_entry.name, "/"));
try testing.expectEqual(@as(u32, payload_dir_mode), root_entry.mode & 0o7777);
for (fixture) |file| {
const entry = (try it.next()).?;
try testing.expectEqual(std.tar.FileKind.file, entry.kind);
const expected_name = try std.fmt.allocPrint(arena, "{s}/{s}", .{ name, file.name });
try testing.expectEqualStrings(expected_name, entry.name);
try testing.expectEqual(@as(u32, @intCast(file.mode)), entry.mode & 0o7777);
try testing.expectEqual(@as(u64, file.contents.len), entry.size);
var sink: Io.Writer.Allocating = .init(arena);
try it.streamRemaining(entry, &sink.writer);
try testing.expectEqualStrings(file.contents, sink.written());
}
try testing.expect((try it.next()) == null);
}
test "sriFromHex encodes the raw digest, not its hex text" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const zeros = "00" ** digest_length;
try testing.expectEqualStrings(
"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
try sriFromHex(arena, zeros),
);
}
test "hashFromSums picks the line for the named asset" {
const text =
"1111111111111111111111111111111111111111111111111111111111111111 nxdns-0.0.16-aarch64-linux-musl.tar.gz\n" ++
"2222222222222222222222222222222222222222222222222222222222222222 nxdns-0.0.16-x86_64-linux-musl.tar.gz\n";
try testing.expectEqualStrings(
"2222222222222222222222222222222222222222222222222222222222222222",
hashFromSums(text, "nxdns-0.0.16-x86_64-linux-musl.tar.gz").?,
);
try testing.expectEqual(@as(?[]const u8, null), hashFromSums(text, "nxdns-0.0.16-riscv64-linux-musl.tar.gz"));
}
test "rewriteBlock keeps the begin line's indentation and the rest of the file" {
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
const before =
"let\n" ++
" " ++ begin_marker ++ "\n" ++
" version = \"0.0.1\";\n" ++
" hashes = {\n" ++
" \"aarch64-linux\" = \"sha256-old\";\n" ++
" \"x86_64-linux\" = \"sha256-old\";\n" ++
" };\n" ++
" " ++ end_marker ++ "\n" ++
"in\n";
const rewritten = try rewriteBlock(arena, before, "flake.nix", "0.0.16", .{
"00" ** digest_length,
"ff" ** digest_length,
});
const expected =
"let\n" ++
" " ++ begin_marker ++ "\n" ++
" version = \"0.0.16\";\n" ++
" hashes = {\n" ++
" \"aarch64-linux\" = \"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\";\n" ++
" \"x86_64-linux\" = \"sha256-//////////////////////////////////////////8=\";\n" ++
" };\n" ++
" " ++ end_marker ++ "\n" ++
"in\n";
try testing.expectEqualStrings(expected, rewritten);
const block = try readBlock(arena, rewritten, "flake.nix");
try testing.expectEqualStrings("0.0.16", block.version);
try testing.expectEqualStrings("sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", block.hashes[0]);
}
test "locateBlock refuses a file that does not delimit exactly one block" {
const body =
" version = \"0.0.1\";\n" ++
" hashes = {\n" ++
" };\n";
const one = "let\n" ++ " " ++ begin_marker ++ "\n" ++ body ++ " " ++ end_marker ++ "\nin\n";
const span = try locateBlock(one);
try testing.expectEqualStrings(" ", span.indent);
try testing.expectError(error.NotOneEnd, locateBlock(
"let\n" ++ " " ++ begin_marker ++ "\n" ++ body ++ "in\n",
));
try testing.expectError(error.NotOneEnd, locateBlock(
"let\n" ++ " " ++ begin_marker ++ "\n" ++ body ++
" " ++ end_marker ++ "\n" ++ " " ++ end_marker ++ "\nin\n",
));
try testing.expectError(error.EndBeforeBegin, locateBlock(
"let\n" ++ " " ++ end_marker ++ "\n" ++ body ++ " " ++ begin_marker ++ "\nin\n",
));
try testing.expectError(error.NotOneBegin, locateBlock(
"let\n" ++ body ++ " " ++ end_marker ++ "\nin\n",
));
}
+2 -7
View File
@@ -7,7 +7,7 @@
//! Usage (the build system supplies all of it): //! Usage (the build system supplies all of it):
//! //!
//! verify_dist --dist-dir <dir> --work-dir <dir> --version <v> //! verify_dist --dist-dir <dir> --work-dir <dir> --version <v>
//! --git-commit <c> --zon <build.zig.zon> --max-bytes <n> //! --zon <build.zig.zon> --max-bytes <n>
//! --asset-free-max-bytes <n> --host-arch <arch> [--qemu] //! --asset-free-max-bytes <n> --host-arch <arch> [--qemu]
//! [--archive <triple> <basename>]... //! [--archive <triple> <basename>]...
//! [--asset-free <triple> <path>]... //! [--asset-free <triple> <path>]...
@@ -92,7 +92,6 @@ const Args = struct {
dist_dir: []const u8 = "", dist_dir: []const u8 = "",
work_dir: []const u8 = "", work_dir: []const u8 = "",
version: []const u8 = "", version: []const u8 = "",
git_commit: []const u8 = "",
zon: []const u8 = "", zon: []const u8 = "",
max_bytes: u64 = 0, max_bytes: u64 = 0,
asset_free_max_bytes: u64 = 0, asset_free_max_bytes: u64 = 0,
@@ -186,8 +185,6 @@ fn parseArgs(arena: Allocator, argv: []const []const u8) !Args {
args.work_dir = value; args.work_dir = value;
} else if (std.mem.eql(u8, flag, "--version")) { } else if (std.mem.eql(u8, flag, "--version")) {
args.version = value; args.version = value;
} else if (std.mem.eql(u8, flag, "--git-commit")) {
args.git_commit = value;
} else if (std.mem.eql(u8, flag, "--zon")) { } else if (std.mem.eql(u8, flag, "--zon")) {
args.zon = value; args.zon = value;
} else if (std.mem.eql(u8, flag, "--host-arch")) { } else if (std.mem.eql(u8, flag, "--host-arch")) {
@@ -621,9 +618,7 @@ fn checkVersionOutput(
}, },
} }
const expected = std.fmt.allocPrint(arena, "nxdns {s} ({s})", .{ const expected = std.fmt.allocPrint(arena, "nxdns {s}", .{args.version}) catch @panic("OOM");
args.version, args.git_commit,
}) catch @panic("OOM");
var lines = std.mem.splitScalar(u8, result.stdout, '\n'); var lines = std.mem.splitScalar(u8, result.stdout, '\n');
const first = lines.next() orelse ""; const first = lines.next() orelse "";
if (!std.mem.eql(u8, first, expected)) { if (!std.mem.eql(u8, first, expected)) {