docs: plan Nix flake packaging (spec, ADR 0018, tasks 0036-0042)
All checks were successful
CI / test (push) Successful in 55s

Add the design record for distributing gitea-axi as a Nix flake: a
package, a development shell, and a checks output, plus the continuous
integration changes that come with it.

ADR 0018 records the wrapper's deference to the operator's own `git` and
`tea` — the reverse of the hermetic instinct, chosen because `tea`
refreshes OAuth tokens in place and so must not have two versions
mutating one credential store.

Also records the `tea`-is-still-a-runtime-dependency gotcha, which ADR
0002's title obscures.
This commit is contained in:
2026-07-19 22:22:02 -04:00
parent 408956cf32
commit 0cbfe43ae0
10 changed files with 459 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
---
spec: nix-flake-packaging
---
## What to build
The packaging tier today holds two kinds of assertion joined only by an expensive shared setup step: assertions about the shape of the packed tarball and its manifest, and assertions that drive the resulting installed binary.
Split them, and teach the second group to accept the path of the binary to drive from the environment.
When the environment names an already-installed binary, the tier drives that binary directly and skips the pack-and-install setup entirely.
When it does not, the tier packs and installs exactly as it does today and drives the result.
This makes one seam that both the npm distribution path and — later — the Nix installation path call, so the two cannot drift apart in what they guarantee about an installed gitea-axi.
The tarball-shape assertions stay npm-only, since a Nix installation produces no tarball and no packed manifest.
The assertions themselves do not change in character: they drive a real subprocess, answer its HTTP calls with the in-process fixture server used throughout the suite, and point the `setup` command at a temporary home directory to observe the Agent Skill being written.
This tier deliberately inherits the parent environment, unlike the in-process CLI-seam harness — the spawned binary genuinely needs it.
Nothing may assert on store paths, wrapper internals, or the arrangement of files within the installed tree; those are implementation detail of the installation method.
## Acceptance criteria
- [ ] The installed-binary assertions live separately from the tarball-shape assertions, and both still run under the packaging tier's own runner configuration.
- [ ] An environment variable naming an existing binary makes the installed-binary group drive that binary and skip pack-and-install.
- [ ] With that variable unset, the group packs, installs, and drives the result as before — the default developer experience is unchanged.
- [ ] The full packaging tier passes in both modes.
- [ ] No assertion in the installed-binary group depends on how the binary was installed.

View File

@@ -0,0 +1,43 @@
---
spec: nix-flake-packaging
---
## What to build
A Nix flake at the repository root exposing gitea-axi as a package, so the maintainer's NixOS configuration can add it to the system package set the way it adds anything else.
Building the package and running the resulting binary is the demoable outcome of this slice.
The derivation lives in its own expression, separate from the flake, in the conventional callable form nixpkgs uses.
The flake's own file stays limited to interface concerns — what it consumes and what it exports — leaving the derivation buildable outside a flake context and usable in an overlay unchanged.
The flake exposes only the package for now; the development shell and checks output arrive in a later slice, and no NixOS module or overlay is exposed at all.
Its nixpkgs input tracks the unstable channel, matching the maintainer's system; consumers deduplicate by pointing that input at their own, so it governs only standalone builds.
Systems coverage is the four common Linux and Darwin targets, enumerated with a small helper built from the nixpkgs standard library rather than a third-party systems-enumeration flake input.
Dependencies are fetched by deriving each package's fetch from the integrity fields already in the lockfile, not from a single fixed-output hash committed to the expression — the latter breaks on every lockfile change and is repaired by copying a hash out of an error message, which is a permanent recurring tax.
The package version is read from the package manifest at evaluation time, so a released version and its store path can never disagree.
The runtime Node is the nixpkgs default; Node 20 is not an option, as nixpkgs marks it end-of-life with known vulnerabilities.
The derivation's source is an explicit allowlist of the paths the build and its tests actually read — the TypeScript sources, the test tier, the bundled Agent Skill, the package manifest and lockfile, the two TypeScript configurations, and the default test-runner configuration.
Taking the whole repository, or a gitignore-derived filter, would let the highest-churn and entirely build-irrelevant directories invalidate the derivation and force a full rebuild with tests.
The installed binary is wrapped so the closure's `git` and `tea` are **appended** to the operator's existing search path, never prepended or substituted — ADR 0018 records why, and this slice lands that ADR.
The operator's own binaries win where present; the closure supplies a fallback so a fresh machine works with no manual setup.
The build runs the fast test tier, which needs a real `git` and a `which` available to it because some of its files invoke `git` directly and one resolves it by lookup.
The live end-to-end and benchmark smoke tiers are excluded — they require a live Gitea host.
The allowlist's failure mode is loud but disconnected from its cause, so this slice also records a Gotcha in the agent instructions: a new build-relevant top-level file must be added to the source allowlist or the Nix build fails on a missing file.
## Acceptance criteria
- [ ] Building the flake's package from a clean checkout produces a runnable `gitea-axi` that prints help and reports its version.
- [ ] The store path's version matches the package manifest's version, with the version appearing in no Nix expression.
- [ ] Changing a dependency in the lockfile requires no edit to any Nix expression.
- [ ] The derivation is a separate callable expression that the flake file consumes; it evaluates outside a flake context.
- [ ] The package builds for the four supported Linux and Darwin systems, with no third-party flake input beyond nixpkgs.
- [ ] Touching a file outside the source allowlist — an ADR, a spec, a benchmark file, prose documentation — does not change the derivation's output path.
- [ ] The wrapped binary finds `git` and `tea` on a machine where neither is otherwise installed.
- [ ] With the operator's own `git` and `tea` on the search path, those are the ones the binary invokes.
- [ ] The fast test tier runs and passes inside the build; a deliberately failing test fails the build.
- [ ] ADR 0018 is committed as part of this slice.
- [ ] The agent instructions carry a Gotcha about extending the source allowlist for new build-relevant files.

View File

@@ -0,0 +1,22 @@
---
spec: nix-flake-packaging
blocked-by: [0036-parameterized-installed-binary-tier, 0037-flake-package-and-wrapper]
---
## What to build
After it installs, the Nix build drives the wrapped binary it just produced through the shared installed-binary tier, pointing that tier at the installed path rather than letting it pack and install.
This guards a class of failure the fast tier structurally cannot reach.
The compiler does not set the executable bit that npm would otherwise set from the manifest's `bin` entry at install time.
And the `setup` command resolves the bundled Agent Skill relative to its own module location, which makes the relative arrangement of the built output and the bundled Skill load-bearing — an arrangement that only exists once installed.
The check reuses the seam from the parameterized tier; it does not introduce a second, weaker set of assertions in shell script, and it does not re-run the fast tier, which would not exercise the installed layout at all.
## Acceptance criteria
- [ ] The Nix build drives the installed binary through the shared installed-binary tier after installation.
- [ ] A binary installed without its executable bit fails the build.
- [ ] A bundled Agent Skill installed at the wrong location relative to the built output fails the build.
- [ ] The post-install phase adds no assertions of its own beyond pointing the shared tier at the installed binary.
- [ ] `nix build` still succeeds end to end on a clean checkout.

View File

@@ -0,0 +1,23 @@
---
spec: nix-flake-packaging
blocked-by: 0037-flake-package-and-wrapper
---
## What to build
Two further flake outputs, so `nix develop` gives a declarative answer to "what do I need to work on this" and `nix flake check` is not a silent no-op.
The development shell carries the toolchain the repository actually needs: Node, `git`, and `tea` — enough for the build, the live end-to-end tier, and the benchmark harness, all of which the repository documents while specifying no toolchain anywhere.
It references the same Node attribute as the package, so development and the shipped artifact cannot drift onto different major versions.
The checks output aliases the package, so the conventional health-check command builds it and thereby runs both its verification phases.
Granular per-stage checks are deliberately not added: the one stage that would add real coverage is the full typecheck, which spans the test and benchmark directories and would therefore drag the benchmark harness into the derivation's inputs, undoing the source filtering.
The full typecheck stays in continuous integration, where it already runs.
## Acceptance criteria
- [ ] `nix develop` yields a shell with Node, `git`, and `tea` available.
- [ ] The build, the fast tier, and the benchmark harness's runner all work from inside that shell.
- [ ] The shell's Node and the package's Node come from one reference — changing it moves both, and they cannot be set independently.
- [ ] `nix flake check` builds the package and runs its tests, and fails when the package fails.
- [ ] No per-stage check derivations are added.

View File

@@ -0,0 +1,34 @@
---
spec: nix-flake-packaging
blocked-by: 0036-parameterized-installed-binary-tier
---
## What to build
Continuous integration moves off end-of-life Node, tests the full range of Node versions the package claims to support, and gains the two test tiers it currently never runs.
The workflow matrixes over the two supported Node versions, and the declared engine range in the package manifest narrows to match.
Today the manifest promises support down to Node 20 while testing only Node 20, so the entire claimed range below the tested version is unverified and its floor is end-of-life.
Narrowing is free right now because nothing has been published and no tags exist; that window closes at first publish.
The packaging tier currently asserts that the declared range mentions Node 20, so that assertion changes with it — part of this work rather than a later surprise.
The live end-to-end tier moves to the highest matrix leg only: it exercises the Gitea API contract rather than Node-version behavior, and each leg provisions a full Gitea service.
Two tiers join the workflow.
The benchmark harness tier runs on every leg — it is deterministic, needs no network or agent SDK, and is currently unguarded despite its non-default runner configuration being an easy thing to believe is running when it is not.
The packaging tier runs on the highest leg only, being slow and largely version-independent; it is the only automated guard on the distribution artifact, given that publishing is a manual command.
The benchmark smoke tier stays out: it targets a live host discovered through the maintainer's own credentials and skips cleanly when they are absent, so here it would pass by skipping — a green check that verified nothing.
The workflow keeps its container-and-npm shape and its GitHub Actions compatibility; nothing migrates to building via Nix.
## Acceptance criteria
- [ ] The workflow runs a matrix over the two supported Node versions, and no leg runs an end-of-life Node.
- [ ] The manifest's declared engine range names exactly the versions the matrix tests.
- [ ] The packaging tier's engine assertion matches the narrowed range and passes.
- [ ] The live end-to-end tier runs on the highest leg only.
- [ ] The benchmark harness tier runs on every leg, under its own runner configuration.
- [ ] The packaging tier runs on the highest leg only.
- [ ] The benchmark smoke tier does not run.
- [ ] The workflow syntax stays GitHub-Actions-compatible.

View File

@@ -0,0 +1,20 @@
---
spec: nix-flake-packaging
blocked-by: 0037-flake-package-and-wrapper
---
## What to build
A separate continuous-integration job that builds the flake, on both push and pull request.
Its value is detecting flake rot — most concretely, a build-relevant file omitted from the source allowlist — at the commit that causes it rather than weeks later at the maintainer's next system rebuild.
It is deliberately non-gating for the other jobs, so an infrastructure problem with Nix availability on the runner does not block an otherwise legitimate change.
Its cost is honest and accepted: because the checks output aliases the package, this job re-runs the fast tier inside the derivation and, without a warm store, rebuilds the whole dependency closure.
## Acceptance criteria
- [ ] A distinct job builds the flake on push and on pull request.
- [ ] Its failure does not block or fail the other jobs.
- [ ] Removing a build-relevant file from the source allowlist makes this job fail.
- [ ] The job's cost and its non-gating intent are stated in the workflow so neither reads as an oversight.

View File

@@ -0,0 +1,25 @@
---
spec: nix-flake-packaging
---
## What to build
Resolve the spec's one open verification item, then act on what is found.
The `setup` command's hook installation passes the agent SDK both an absolute path to the running entrypoint and the bare binary name.
Under Nix the absolute path is content-addressed: it changes on every rebuild and is eventually garbage-collected, so a hook that records it would break silently — a session-start hook that cannot execute simply does not run.
The bare binary name strongly suggests the SDK prefers search-path resolution and treats the absolute path as a fallback, which would make this a non-issue, but that could not be confirmed during design because the dependency was not installed.
The decision is to verify before acting.
Determine, against the installed SDK, which of the two the hook installation actually records.
If it prefers the bare name, record the finding and close the item — no code changes.
If it records the absolute path, the immediate mitigation is documenting that the hook setup should be re-run after an upgrade.
Changing the `setup` command to prefer the bare name is explicitly **not** part of this task: it would become a separate task with its own ADR, justified on the grounds that a stable search-path name is more robust for *every* installation method, and explicitly not as a special case that detects Nix store paths in application code.
## Acceptance criteria
- [ ] The SDK's actual hook-path behavior is determined by observation against the installed dependency, not inference from its interface.
- [ ] The finding is recorded where a future reader will meet it, so the question is not re-opened from scratch.
- [ ] If the absolute path is recorded, the documentation states that hook setup must be re-run after an upgrade.
- [ ] No change is made to how the `setup` command constructs the hook in this task.