From 0cbfe43ae0912185158826214cc200a1f0a080d0 Mon Sep 17 00:00:00 2001 From: alexion Date: Sun, 19 Jul 2026 22:22:02 -0400 Subject: [PATCH] docs: plan Nix flake packaging (spec, ADR 0018, tasks 0036-0042) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...nix-wrapper-defers-to-operator-binaries.md | 36 +++ .claude/spec/nix-flake-packaging.md | 224 ++++++++++++++++++ ...036-parameterized-installed-binary-tier.md | 27 +++ .../tasks/0037-flake-package-and-wrapper.md | 43 ++++ .../0038-nix-build-drives-installed-binary.md | 22 ++ .claude/tasks/0039-dev-shell-and-checks.md | 23 ++ .../0040-ci-node-matrix-and-new-tiers.md | 34 +++ .claude/tasks/0041-ci-flake-build-job.md | 20 ++ .../tasks/0042-verify-hook-path-resolution.md | 25 ++ CLAUDE.md | 5 + 10 files changed, 459 insertions(+) create mode 100644 .claude/adr/0018-nix-wrapper-defers-to-operator-binaries.md create mode 100644 .claude/spec/nix-flake-packaging.md create mode 100644 .claude/tasks/0036-parameterized-installed-binary-tier.md create mode 100644 .claude/tasks/0037-flake-package-and-wrapper.md create mode 100644 .claude/tasks/0038-nix-build-drives-installed-binary.md create mode 100644 .claude/tasks/0039-dev-shell-and-checks.md create mode 100644 .claude/tasks/0040-ci-node-matrix-and-new-tiers.md create mode 100644 .claude/tasks/0041-ci-flake-build-job.md create mode 100644 .claude/tasks/0042-verify-hook-path-resolution.md diff --git a/.claude/adr/0018-nix-wrapper-defers-to-operator-binaries.md b/.claude/adr/0018-nix-wrapper-defers-to-operator-binaries.md new file mode 100644 index 0000000..40846e1 --- /dev/null +++ b/.claude/adr/0018-nix-wrapper-defers-to-operator-binaries.md @@ -0,0 +1,36 @@ +# The Nix wrapper appends `git` and `tea` to PATH rather than prepending them + +The Nix-packaged `gitea-axi` binary is wrapped with `makeWrapper --suffix PATH`, not `--prefix`. +The operator's own `git` and `tea` win whenever they are present; the ones from the Nix closure are a fallback that makes the tool work on a machine where neither is installed. + +This is the reverse of the usual Nix instinct, which is to pin runtime dependencies so the packaged tool behaves identically everywhere. + +## Considered Options + +**`--prefix PATH` for both** (rejected) — The hermetic choice: the closure's `git` and `tea` always win, `TEA_NOT_INSTALLED` becomes unreachable, and a half-upgraded system `tea` cannot break gitea-axi. +It fails on `tea` specifically. +Per ADR 0001 as amended, the token comes from `tea login helper get`, which **refreshes near-expiry OAuth tokens in place** — so the invoked `tea` does not merely read `~/.config/tea/config.yml`, it *writes* to it. +Prefixing would put two `tea` versions on one mutable store: the operator's, used interactively for `tea login add`, and the closure's, used for token refresh. +nixpkgs currently carries 0.14.0 while ADR 0001 was verified against 0.14.2, so this is a live version skew, not a hypothetical one. +Divergence in that file surfaces later as an auth failure with no visible connection to its cause. + +**`--set PATH`** (rejected) — Fully sealing the environment is defensible in principle, because `src/subprocess.ts` is the single spawn point and invokes only these two binaries, so the surface is small enough to seal. +It inherits every problem above in stronger form, and additionally breaks whatever `git` itself shells out to that is not in the closure: credential helpers, LFS filters, diff and merge drivers, and `ssh` for SSH remotes — which would take `pr checkout` with it. + +**`--prefix` for `git`, `--suffix` for `tea`** (rejected) — Puts the hermetic guarantee where state is not shared and defers where it is. +Examined and dropped because the reproducibility it buys on `git` is largely illusory: a pinned `git` still reads the operator's `~/.gitconfig`, so behaviour is not pinned, only the binary is. +Worse, a closure `git` missing an extension the operator relies on can *introduce* the divergence prefixing was meant to prevent. +That leaves a two-rule wrapper paying real explanatory cost for close to nothing. + +**`--suffix PATH` for both** (chosen) — One rule, one sentence to explain. +A single `tea` — the one that created the credential store — owns reading and writing it. +The fresh-machine fallback is preserved, so nothing regresses for an operator who has neither binary. + +## Consequences + +- gitea-axi's behaviour depends on ambient `PATH`, so it is not reproducible across machines in the way a Nix package normally is. + This is accepted deliberately: the tool's job is to drive *the operator's* repositories using *the operator's* credentials, both of which are ambient state already. +- An operator whose `tea` predates the `login helper` interface hits an obscure failure while a working `tea` sits unused in the closure. + Judged acceptable — that interface exists in 0.14.0, the oldest version in nixpkgs. +- The closure carries `git` and `tea` that are usually unused. This is the price of the fallback. +- If the `tea` dependency is ever removed (see ADR 0002's retained credential-discovery role), the argument here collapses to the `git`-only case, and prefixing could be reconsidered — though the `~/.gitconfig` objection would still stand. diff --git a/.claude/spec/nix-flake-packaging.md b/.claude/spec/nix-flake-packaging.md new file mode 100644 index 0000000..9832eb7 --- /dev/null +++ b/.claude/spec/nix-flake-packaging.md @@ -0,0 +1,224 @@ +## Problem Statement + +The maintainer runs NixOS and wants gitea-axi installed declaratively through the system configuration, alongside every other tool on the machine. + +Today there is no declarative path. +gitea-axi is distributed only as an npm package, and nothing has been published yet — there are no release tags and no tarball on the registry. +Installing it means an imperative global npm install, which sits outside the system configuration, is invisible to rollbacks, and drifts from the rest of the machine's declarative state. + +The repository also has no declarative development environment. +It carries no Nix expression, no direnv configuration, and no Node version file, while documenting a build, a live end-to-end tier, and a benchmark harness that all assume a Node toolchain the repository never specifies. +The continuous integration workflow pins Node 20, which reached end-of-life in April 2026, so the one place a Node version *is* named now names an unsupported one. + +## Solution + +A Nix flake at the repository root that exposes gitea-axi as a package, so the maintainer's NixOS configuration can add it to the system package set the same way it adds anything else. + +The flake also exposes a development shell carrying the toolchain the repository actually needs — Node, `git`, and `tea` — giving `nix develop` a declarative answer to "what do I need to work on this". +A checks output makes `nix flake check` build the package and run its tests, so the flake itself is verifiable rather than silently rotting. + +Because gitea-axi discovers credentials by shelling out to `tea`, and drives repositories by shelling out to `git`, the installed binary is wrapped so both are reachable from the Nix closure. +The wrapper defers to the operator's own binaries when present, and supplies the closure's only as a fallback. + +Alongside the flake, the continuous integration workflow 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. + +## User Stories + +1. As the maintainer, I want gitea-axi available as a flake package, so that I can install it from my NixOS configuration instead of through an imperative global npm install. +2. As the maintainer, I want the installed binary to find `git` and `tea` without my having to install them separately, so that the tool works on a fresh machine with no manual setup. +3. As the maintainer, I want my own `tea` to be the one gitea-axi invokes when I have one, so that a single `tea` version owns the credential store it reads and writes. +4. As the maintainer, I want the flake to build without my maintaining a dependency hash, so that bumping an npm dependency does not also require an edit to the Nix expression. +5. As the maintainer, I want the package version taken from the package manifest, so that a released version and its store path can never disagree. +6. As the maintainer, I want the Nix build to run the fast test tier, so that a package that builds is also a package whose behavior was checked. +7. As the maintainer, I want the Nix build to drive the binary it just installed, so that a broken executable bit or a misplaced bundled Agent Skill fails the build instead of failing on first use. +8. As the maintainer, I want the build to depend only on files that can change its output, so that writing an ADR or landing a benchmark result does not trigger a rebuild and a full test run. +9. As the maintainer, I want a development shell with Node, `git`, and `tea`, so that `nix develop` gives me the toolchain for the build, the live end-to-end tier, and the benchmark harness. +10. As the maintainer, I want the development shell and the packaged binary to share one Node reference, so that development and the shipped artifact cannot drift onto different major versions. +11. As the maintainer, I want `nix flake check` to actually build and test, so that the conventional health-check command is not a silent no-op. +12. As the maintainer, I want continuous integration to build the flake, so that I learn a build-relevant file was omitted from the source filter at the commit that caused it rather than weeks later at my next system rebuild. +13. As the maintainer, I want continuous integration to run on supported Node versions only, so that I am not gating merges on a runtime that receives no security fixes. +14. As the maintainer, I want the declared engine range to match the versions actually tested, so that the compatibility promise in the manifest is verified rather than asserted. +15. As the maintainer, I want the benchmark harness tier to run in continuous integration, so that harness logic is guarded rather than relying on my remembering its non-default runner configuration. +16. As the maintainer, I want the packaging tier to run in continuous integration, so that a broken distribution artifact is caught before a manual publish rather than by whoever installs it. +17. As the maintainer, I want the assertions about an installed binary to be written once and driven by both the npm install path and the Nix install path, so that the two distribution methods cannot drift apart in what they guarantee. + +## Implementation Decisions + +### Flake surface + +The flake exposes a package, a development shell, and a checks output. +It deliberately exposes no NixOS module and no overlay. + +A NixOS module was rejected because gitea-axi is a stateless CLI with no daemon and no system-level configuration; a module would wrap the system package set and nothing else. +An overlay was rejected as an interface with no consumer — the maintainer is the sole consumer and already knows the package goes into the system package set. +Both remain purely additive to add later. + +A home-manager module is explicitly deferred rather than rejected. +The one piece of per-user state is the Agent Skill that the `setup` command installs, and a module managing it declaratively would reintroduce exactly the automatism that ADR 0009 rejected when it chose an explicit `setup` command over a postinstall script. +That trade-off deserves its own decision, made with usage evidence. + +Consumers deduplicate nixpkgs by pointing the flake's nixpkgs input at their own. +Consequently the flake's own nixpkgs input governs only standalone builds, the development shell, and flake checks — never the deployed artifact. +That input tracks the unstable channel, matching the maintainer's system, so the development shell reflects the same package set the installed binary is built against. + +System coverage is the four common Linux and Darwin targets, enumerated with a small helper built from the nixpkgs standard library rather than by taking a dependency on a third-party systems-enumeration flake. +Cross-platform support is close to free because the package contains no compiled code; the only per-system variation is which Node, `git`, and `tea` are pulled in. + +### Package expression + +The derivation lives in its own expression, separate from the flake, written in the conventional callable form that nixpkgs uses. +This keeps the flake's own file to interface concerns — what it consumes and what it exports — and leaves the derivation buildable outside a flake context, usable in an overlay unchanged, and upstreamable to nixpkgs later. + +Dependencies are fetched by deriving each package's fetch from the integrity fields already present in the lockfile, rather than by a single fixed-output derivation keyed on a hash committed to the Nix expression. +The hash-based approach was rejected on maintenance grounds: it breaks on every lockfile change and is repaired by copying a hash out of an error message into the expression, which is a permanent recurring tax and a standing source of stale-hash commits. +The lockfile-derived approach is viable here specifically because every runtime dependency resolves to the public npm registry with an integrity field, there are no git or filesystem dependencies, and the lockfile is version 3. + +The package version is read from the package manifest at evaluation time. +The manifest is already the canonical version — the documented release flow bumps it — and hardcoding it in the Nix expression would create a second place to update, whose omission yields a store path labelled with one version containing another's code. + +The runtime Node is the nixpkgs default, currently a supported long-term-support release. +Node 20 is not an option: nixpkgs marks it with known vulnerabilities as end-of-life, so using it would require the consuming configuration to permit an insecure package. +The development shell references the same Node attribute as the package, so the two cannot drift. + +### Source filtering + +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 was rejected because this repository's highest-churn directories are all build-irrelevant — the ADR, spec, and task directories, the benchmark harness, and the prose documentation. +Under a whole-repository source, writing an ADR invalidates the derivation and forces a full rebuild including the test suite. +A gitignore-derived filter was rejected as insufficient for the same reason: it still admits the benchmark harness, the agent-context directory, and the several additional test-runner configurations. + +The cost is that adding a build-relevant top-level file requires updating the allowlist. +That failure is loud and immediate — the build fails on a missing file — but it is disconnected enough from its cause to warrant both a Gotcha entry in the agent instructions and the continuous-integration flake job described below. + +### Runtime dependency wrapping + +The installed binary is wrapped so that the closure's `git` and `tea` are appended to the operator's existing search path, not prepended. +The operator's own binaries win where present; the closure supplies a fallback so a fresh machine works and the not-installed error path becomes unreachable in practice. + +This is the reverse of the hermetic instinct, and the reason is specific to `tea`. +Per ADR 0001 as amended, the token is fetched through `tea`'s git-credential-protocol interface, which refreshes near-expiry OAuth tokens *in place*. +The invoked `tea` therefore writes to the operator's credential store, so prepending would mean two `tea` versions mutating one state file — the maintainer's for interactive login management, the closure's for token refresh. + +`git` takes the same treatment for uniformity, after the reproducibility argument for prepending it was examined and rejected as illusory: a pinned `git` still reads the operator's global configuration, so behavior is not actually pinned, while a closure `git` lacking an extension the operator relies on — a filter, a credential helper — would *introduce* the divergence that prepending is meant to prevent. +Replacing the search path outright was rejected for the same reasons in stronger form, plus it would break repository operations over SSH remotes. + +`tea` remains a runtime dependency for credential discovery only. +ADR 0002 moved command dispatch to the Gitea REST API but explicitly retained `tea` for auth, and nothing in this work changes that. + +### Verification inside the build + +The build runs the fast test tier, which requires a real `git` and a `which` in the check inputs because two of its files invoke `git` directly and one resolves `git` by lookup; `tea` is already stubbed within that tier. +The live end-to-end tier and the benchmark smoke tier are excluded because they require a live Gitea host. + +After installation, the build drives the wrapped binary through the shared installed-binary tier described under Testing Decisions. +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. + +The checks output aliases the package, so the conventional flake health-check command builds it and thereby runs both phases. +Granular per-stage checks were rejected: 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 above. +The full typecheck stays in continuous integration, where it already runs. + +### Continuous integration + +The workflow matrixes over the two supported Node versions and the declared engine range narrows to match. +The current declaration 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 the range is free right now because nothing has been published and no tags exist; that window closes at first publish. + +The live end-to-end tier runs on the highest matrix leg only, because 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 of continuous integration. +It targets a live host discovered through the maintainer's own credentials and skips cleanly when they are absent, so in continuous integration it would pass by skipping — a green check that verified nothing. + +A separate job builds the flake, on both push and pull request. +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 value is detecting flake rot — most concretely, a build-relevant file omitted from the source allowlist — at the commit that causes it rather than at the maintainer's next system rebuild. +Its cost is honest: 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. + +Release automation stays out of scope. +Automating a publish path that has never once been exercised manually would encode assumptions about a process with no track record, and the artifact-integrity concern that would motivate it is already covered by adding the packaging tier. + +## Testing Decisions + +A good test here asserts externally observable behavior of an *installed* gitea-axi — that the binary runs, renders, and installs its Agent Skill — and not the mechanics of how it came to be installed. +Nothing should assert on store paths, wrapper script internals, derivation attribute values, or the arrangement of files within the installed tree, because all of those are implementation detail of the packaging method and would have to change in lockstep with it. + +### The seam + +There is one new seam, and it is a parameterization of an existing tier rather than a new suite. + +The packaging tier today contains two kinds of assertion coupled only by a shared setup step: assertions about the shape of the packed tarball and its manifest, and assertions that drive the resulting installed binary. +The second group is parameterized by exactly one value — the path of the binary to drive — and is precisely what the Nix build needs to assert about its own installed output. + +That group is therefore split out and taught to accept its binary path from the environment. +When the environment names an already-installed binary, the tier drives it directly and skips the pack-and-install setup; when it does not, the tier packs and installs as it does today and drives the result. +The npm distribution path and the Nix installation path become two callers of one seam. + +The tarball-shape assertions remain npm-only, since a Nix installation produces no tarball and no packed manifest. +This split also improves the existing tier on its own terms, by separating two concerns that were only ever joined by an expensive shared setup. + +Two alternatives were rejected. +A bespoke shell smoke test in the Nix build would need no TypeScript change and no test runner inside the derivation, but it is a second and weaker seam asserting the same intent, and it would not have caught the bundled-Skill arrangement bug that motivates the check at all. +Running only the fast tier after installation was rejected because it does not exercise the installed layout, which is the entire class of failure the post-install check exists to catch. + +### Consequential change to existing assertions + +The packaging tier currently asserts that the declared engine range mentions Node 20. +Narrowing the engine range changes that assertion. +It is part of this work rather than a later surprise. + +### Prior art + +The installed-binary assertions already exist and are the model: 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. +Unlike the in-process CLI-seam harness, which keeps the environment fully explicit, this tier deliberately inherits the parent environment because the spawned binary genuinely needs it — that remains true, and is more true under a wrapper. + +### Not covered by automated tests + +The flake's consumption from a NixOS configuration is verified by the maintainer performing a system rebuild, not by an automated test. +Building the package proves the derivation is correct; whether the maintainer's configuration wires it in correctly is outside this repository. + +## Out of Scope + +Removing the `tea` runtime dependency. +It was raised and examined during design: ADR 0002 moved command dispatch off `tea` but retained it for credential discovery, and eliminating it would mean either owning a credential store or reading `tea`'s internal configuration format, which ADR 0001 explicitly rejected because it forfeits OAuth token refresh. +Wrapping the binary makes the dependency invisible in practice, which removes most of the practical motivation. + +A home-manager module, and with it any declarative management of the Agent Skill or the session-start hook. + +An overlay output and a NixOS module output. + +A direnv configuration. +The maintainer does not currently run direnv, so committing one would be configuration for a tool that is not installed. + +Release and publish automation, and the first publish itself. + +Migrating continuous integration to build via Nix. +The workflow keeps its container-and-npm shape, deliberately preserving the GitHub Actions compatibility the workflow documents as a goal; the Nix job is additive. + +## Further Notes + +### Open verification item + +The `setup` command's hook installation passes the 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 recording it would break silently, since 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 this could not be confirmed during design because the dependency was not installed. + +The decision is to verify before acting. +If the SDK does record 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 would then 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. + +### Verified during design + +The `tea` in nixpkgs carries the credential-helper interface that ADR 0001's amendment depends on, under the singular alias the code uses. +All three runtime dependencies resolve to the public npm registry. +The lockfile is version 3 and the lockfile-derived fetching helper is available. +There are no native modules and no install scripts in the runtime closure. +The reserved self-update command is already shadowed and never writes, so it poses no read-only-store hazard. + +### Candidate ADR + +The wrapper's deference to the operator's own binaries warrants an ADR. +It is surprising without context, since the hermetic instinct points the other way; it is the product of a real trade-off between reproducibility and single-owner mutable state; and it is hard to reverse in the sense that flipping it can corrupt an operator's credential store rather than merely changing behavior. diff --git a/.claude/tasks/0036-parameterized-installed-binary-tier.md b/.claude/tasks/0036-parameterized-installed-binary-tier.md new file mode 100644 index 0000000..9081d14 --- /dev/null +++ b/.claude/tasks/0036-parameterized-installed-binary-tier.md @@ -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. diff --git a/.claude/tasks/0037-flake-package-and-wrapper.md b/.claude/tasks/0037-flake-package-and-wrapper.md new file mode 100644 index 0000000..f81ab48 --- /dev/null +++ b/.claude/tasks/0037-flake-package-and-wrapper.md @@ -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. diff --git a/.claude/tasks/0038-nix-build-drives-installed-binary.md b/.claude/tasks/0038-nix-build-drives-installed-binary.md new file mode 100644 index 0000000..0f1fe87 --- /dev/null +++ b/.claude/tasks/0038-nix-build-drives-installed-binary.md @@ -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. diff --git a/.claude/tasks/0039-dev-shell-and-checks.md b/.claude/tasks/0039-dev-shell-and-checks.md new file mode 100644 index 0000000..1191421 --- /dev/null +++ b/.claude/tasks/0039-dev-shell-and-checks.md @@ -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. diff --git a/.claude/tasks/0040-ci-node-matrix-and-new-tiers.md b/.claude/tasks/0040-ci-node-matrix-and-new-tiers.md new file mode 100644 index 0000000..55686ff --- /dev/null +++ b/.claude/tasks/0040-ci-node-matrix-and-new-tiers.md @@ -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. diff --git a/.claude/tasks/0041-ci-flake-build-job.md b/.claude/tasks/0041-ci-flake-build-job.md new file mode 100644 index 0000000..1a2c404 --- /dev/null +++ b/.claude/tasks/0041-ci-flake-build-job.md @@ -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. diff --git a/.claude/tasks/0042-verify-hook-path-resolution.md b/.claude/tasks/0042-verify-hook-path-resolution.md new file mode 100644 index 0000000..59246ca --- /dev/null +++ b/.claude/tasks/0042-verify-hook-path-resolution.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index e9a3b8f..884f5c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,3 +29,8 @@ Always `git fetch origin` and cut a task branch from `origin/main`, not from wha Gitea's issue/PR search endpoint (`GET /repos/issues/search`, behind `search issues`/`search prs`) is backed by an **asynchronous, eventually-consistent issue indexer** (bleve by default). Content created moments earlier may not be searchable yet, so end-to-end assertions that create an issue/PR and then search for it must poll (e.g. `expect.poll`) until it is indexed rather than searching once. The fixture tier is unaffected — it stubs the endpoint — so this bites only the live `test/e2e` tier. + +`tea` is **still a runtime dependency**, despite [ADR 0002](.claude/adr/0002-direct-gitea-api-over-tea-subprocess.md) being titled "use direct Gitea API instead of wrapping the `tea` subprocess". +That ADR moved *command dispatch* to `gitea-js`; it explicitly kept `tea` for **credential discovery**, and its own Consequences section says so. +Per [ADR 0001](.claude/adr/0001-diff-auth-via-tea-login-list.md) as amended, `src/context.ts` resolves auth by shelling out to `tea login list --output json` (discovery) and `tea login helper get --login ` (token, with in-place OAuth refresh). +The only bypass is the test hook requiring `GITEA_AXI_API_URL` + `GITEA_AXI_TOKEN` + `GITEA_AXI_REPO` together; there is no user-facing path that avoids `tea`, and `TEA_NOT_INSTALLED` exists for its absence.