feat(nix): package gitea-axi as a flake with a PATH-suffixing wrapper (task 0037) #46
@@ -30,14 +30,75 @@ The allowlist's failure mode is loud but disconnected from its cause, so this sl
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] Building the flake's package from a clean checkout produces a runnable `gitea-axi` that prints help and reports its version.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [-] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] The agent instructions carry a Gotcha about extending the source allowlist for new build-relevant files.
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Three systems, not four — `x86_64-darwin` is gone
|
||||||
|
|
||||||
|
The one dropped criterion. nixpkgs 26.11, which `nixos-unstable` now points at, has removed `x86_64-darwin` support outright.
|
||||||
|
`legacyPackages.x86_64-darwin` *throws* on evaluation rather than merely failing to build, so enumerating it would break `nix flake show` and `nix flake check` for **every** system at once, not just that one.
|
||||||
|
The flake therefore covers `x86_64-linux`, `aarch64-linux`, and `aarch64-darwin`, with the omission commented at the `systems` list.
|
||||||
|
Intel macOS would need the 26.05 branch; nobody is asking for it.
|
||||||
|
The rest of the criterion holds: nixpkgs is the only flake input.
|
||||||
|
|
||||||
|
### `doCheck = true` was silently doing nothing
|
||||||
|
|
||||||
|
`buildNpmPackage` wires config, build, and install hooks but supplies **no check hook**, so `doCheck` alone is inert.
|
||||||
|
The first green build logged `no Makefile or custom checkPhase, doing nothing` and produced a package whose tests had never run — a passing build that verified nothing.
|
||||||
|
The fix is an explicit `checkPhase`, plus `git` and `which` in `nativeCheckInputs` and a writable `HOME`.
|
||||||
|
Recorded as a Gotcha, since the failure mode is a *green* build.
|
||||||
|
|
||||||
|
The second half of that criterion was demonstrated unintentionally but genuinely: with the check phase live, a failing test failed the build with exit code 1 before anything was installed.
|
||||||
|
|
||||||
|
### The build tree is named, and why that is a workaround
|
||||||
|
|
||||||
|
`postUnpack` renames the build tree from the builder's generic `source` to `gitea-axi`.
|
||||||
|
|
||||||
|
This is not cosmetic. `test/setup.test.ts` asserts that `setup hooks` updates its managed entry in place rather than appending a duplicate.
|
||||||
|
The SDK recognises its own hook by testing whether the recorded command *string contains* `"gitea-axi"`, and the recorded command is the entrypoint's absolute path whenever PATH resolution does not match it — which it never does under vitest, since the entrypoint resolves to `src/main.js`, a file that does not exist.
|
||||||
|
So the assertion holds only when the checkout's path happens to contain `gitea-axi`.
|
||||||
|
|
||||||
|
That was verified rather than assumed: copying the repository to `/tmp/clean-probe-9d3/proj` and running the tier reproduces the failure outside Nix entirely.
|
||||||
|
A first probe under the session scratchpad passed and was misleading — that path contains `-home-alexion-wrk-gitea-axi-…`, so it satisfied the substring by accident.
|
||||||
|
|
||||||
|
The rename makes the build environment representative of a real installation (`node_modules/gitea-axi/…` under npm, `…-gitea-axi-<version>/…` under Nix) rather than an arrangement no operator ever has.
|
||||||
|
It is a workaround for a defect, not a property worth keeping, and is commented as such.
|
||||||
|
|
||||||
|
### Follow-up for task 0042 — the open verification item is answered, unfavourably
|
||||||
|
|
||||||
|
The spec's open verification item asked whether the SDK prefers the bare binary name over the absolute entrypoint path.
|
||||||
|
It does not.
|
||||||
|
`resolvePortableHookCommand` returns the bare name only when a `PATH` entry realpath-matches the entrypoint, and the absolute path otherwise.
|
||||||
|
|
||||||
|
Driving the Nix-built binary shows what actually lands in `~/.claude/settings.json`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/nix/store/nmkzjny0hpzjvyxzdz189whk605di8b6-gitea-axi-0.1.0/lib/node_modules/gitea-axi/dist/main.js
|
||||||
|
```
|
||||||
|
|
||||||
|
That is content-addressed: it changes on every rebuild and is eventually garbage-collected, and a `SessionStart` hook that cannot execute simply does not run.
|
||||||
|
So the dashboard stops appearing after an upgrade, silently and with nothing pointing at the cause — the exact failure the item hoped to rule out, now confirmed under the install method this slice adds.
|
||||||
|
|
||||||
|
Two findings for 0042, both tracing to the same line:
|
||||||
|
|
||||||
|
1. The stale store path above — user-facing breakage, and the more serious of the two.
|
||||||
|
2. `setup hooks` appends a duplicate entry instead of updating in place whenever the entrypoint path lacks the marker.
|
||||||
|
|
||||||
|
Both were left alone deliberately, at the maintainer's direction, to keep this slice about packaging; fixing hook resolution here would have pre-empted 0042's design work with a decision made in passing.
|
||||||
|
When 0042 lands, the `postUnpack` rename should be removed with it.
|
||||||
|
|
||||||
|
### ADR 0018
|
||||||
|
|
||||||
|
Already committed in `0cbfe43` during the planning pass, ahead of this branch, so the criterion is satisfied by an earlier commit rather than by this one.
|
||||||
|
No change was needed; `package.nix` implements it via `--suffix PATH`.
|
||||||
|
|||||||
@@ -23,3 +23,27 @@ Changing the `setup` command to prefer the bare name is explicitly **not** part
|
|||||||
- [ ] The finding is recorded where a future reader will meet it, so the question is not re-opened from scratch.
|
- [ ] 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.
|
- [ ] 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.
|
- [ ] No change is made to how the `setup` command constructs the hook in this task.
|
||||||
|
|
||||||
|
## Evidence gathered during task 0037
|
||||||
|
|
||||||
|
Task 0037 built the flake, which made the SDK's behaviour directly observable.
|
||||||
|
The answer is the unfavourable one: **the absolute path is recorded**, so the mitigation branch of this task applies, not the close-the-item branch.
|
||||||
|
|
||||||
|
`resolvePortableHookCommand` in `axi-sdk-js` returns the bare binary name only when a `PATH` entry realpath-matches the entrypoint, and the absolute path in every other case.
|
||||||
|
Driving the Nix-built binary writes this into `~/.claude/settings.json`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/nix/store/nmkzjny0hpzjvyxzdz189whk605di8b6-gitea-axi-0.1.0/lib/node_modules/gitea-axi/dist/main.js
|
||||||
|
```
|
||||||
|
|
||||||
|
That path is content-addressed, so it changes on every rebuild and is eventually garbage-collected, and the session-start hook then silently stops running.
|
||||||
|
|
||||||
|
A second defect surfaced from the same line.
|
||||||
|
`isManagedHook` recognises its own hook by testing whether the recorded command *string contains* the marker `"gitea-axi"`, so when the entrypoint path lacks that substring, `setup hooks` appends a duplicate entry instead of updating in place — contradicting the idempotency its help text promises.
|
||||||
|
This is reproducible outside Nix: copy the checkout to a path containing no `gitea-axi` segment and `test/setup.test.ts` fails.
|
||||||
|
|
||||||
|
Consequences for this task:
|
||||||
|
|
||||||
|
- Both defects trace to the same resolution line, so they should be weighed together.
|
||||||
|
- The stale store path is user-facing breakage on the install method task 0037 added, which argues for not letting this drift far behind it.
|
||||||
|
- `package.nix` carries a `postUnpack` rename of the build tree purely to work around the substring coupling. It is commented as a workaround and should be **deleted as part of this task**, once the hook no longer depends on the entrypoint path.
|
||||||
|
|||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -40,3 +40,15 @@ This is a NixOS host with no global Node install, and the repository has no dev
|
|||||||
Until then, prefix commands with `nix shell nixpkgs#nodejs -c ...`, adding `nixpkgs#tea` for anything that resolves credentials — including `gitea-axi pr create`.
|
Until then, prefix commands with `nix shell nixpkgs#nodejs -c ...`, adding `nixpkgs#tea` for anything that resolves credentials — including `gitea-axi pr create`.
|
||||||
`node_modules/` may be absent too, so `npm ci` first.
|
`node_modules/` may be absent too, so `npm ci` first.
|
||||||
Commits need an explicit identity: `git -c user.name=alexion -c user.email=contact@alexion.dev commit ...`, matching the existing history.
|
Commits need an explicit identity: `git -c user.name=alexion -c user.email=contact@alexion.dev commit ...`, matching the existing history.
|
||||||
|
|
||||||
|
The Nix derivation's source is an **explicit allowlist** in [`package.nix`](package.nix), not the whole repository and not a gitignore filter.
|
||||||
|
A new build-relevant top-level file — a TypeScript configuration, a runner configuration the fast tier loads, a directory the build reads — must be added to that `lib.fileset.unions` list or `nix build` fails on a missing file.
|
||||||
|
The failure is loud but disconnected from its cause: the error names the missing file, not the allowlist that omitted it.
|
||||||
|
The flip side is the point of the design — touching an ADR, a spec, a task, a `bench/` file, or prose documentation must *not* change the derivation's output path.
|
||||||
|
|
||||||
|
`buildNpmPackage` provides **no check hook**, so `doCheck = true` on its own is silently inert — the build logs `no Makefile or custom checkPhase, doing nothing` and ships a package whose tests never ran.
|
||||||
|
Running the fast tier inside the derivation requires an explicit `checkPhase`; it also needs `git` and `which` in `nativeCheckInputs` and a writable `HOME`, since some of those tests shell out to `git`.
|
||||||
|
|
||||||
|
nixpkgs 26.11 (the `nixos-unstable` the flake tracks) has **dropped `x86_64-darwin`**.
|
||||||
|
`legacyPackages.x86_64-darwin` now *throws* rather than merely failing to build, so listing that system in the flake's `systems` breaks `nix flake show` and `nix flake check` for every system at once, not just that one.
|
||||||
|
Intel macOS would need the 26.05 branch.
|
||||||
|
|||||||
27
flake.lock
generated
Normal file
27
flake.lock
generated
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784356753,
|
||||||
|
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
31
flake.nix
Normal file
31
flake.nix
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
description = "Agent-ergonomic CLI for Gitea issues and pull requests";
|
||||||
|
|
||||||
|
# Tracks unstable to match the maintainer's system. Consumers deduplicate by
|
||||||
|
# pointing this input at their own nixpkgs, so it governs standalone builds
|
||||||
|
# only — never the deployed artifact.
|
||||||
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
|
outputs =
|
||||||
|
{ self, nixpkgs }:
|
||||||
|
let
|
||||||
|
# x86_64-darwin is deliberately absent: nixpkgs 26.11 dropped it, and
|
||||||
|
# `legacyPackages.x86_64-darwin` now throws rather than merely failing to
|
||||||
|
# build — so listing it would break `nix flake show` and `nix flake check`
|
||||||
|
# for every system, not just that one. Intel macOS needs the 26.05 branch.
|
||||||
|
systems = [
|
||||||
|
"x86_64-linux"
|
||||||
|
"aarch64-linux"
|
||||||
|
"aarch64-darwin"
|
||||||
|
];
|
||||||
|
|
||||||
|
forAllSystems =
|
||||||
|
f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages = forAllSystems (pkgs: rec {
|
||||||
|
gitea-axi = pkgs.callPackage ./package.nix { };
|
||||||
|
default = gitea-axi;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
119
package.nix
Normal file
119
package.nix
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
{
|
||||||
|
lib,
|
||||||
|
buildNpmPackage,
|
||||||
|
importNpmLock,
|
||||||
|
makeWrapper,
|
||||||
|
nodejs,
|
||||||
|
git,
|
||||||
|
tea,
|
||||||
|
which,
|
||||||
|
}:
|
||||||
|
|
||||||
|
let
|
||||||
|
# The manifest is the canonical version: the release flow bumps it, and
|
||||||
|
# reading it here means a store path and a released version cannot disagree.
|
||||||
|
manifest = lib.importJSON ./package.json;
|
||||||
|
|
||||||
|
# An explicit allowlist of what the build and its tests actually read. The
|
||||||
|
# repository's highest-churn directories — .claude, bench, prose docs — are
|
||||||
|
# all build-irrelevant, so a whole-repository source would let writing an ADR
|
||||||
|
# invalidate the derivation and force a rebuild with a full test run.
|
||||||
|
#
|
||||||
|
# Adding a build-relevant top-level file means adding it here too; the build
|
||||||
|
# otherwise fails on a missing file.
|
||||||
|
src = lib.fileset.toSource {
|
||||||
|
root = ./.;
|
||||||
|
fileset = lib.fileset.unions [
|
||||||
|
./src
|
||||||
|
# The fast tier only. `test/e2e` needs a live Gitea host and is excluded
|
||||||
|
# from the runner config, so admitting it would let end-to-end churn
|
||||||
|
# invalidate the derivation — the very cost this allowlist exists to
|
||||||
|
# avoid. `test/packaging` stays: task 0038 drives it against the
|
||||||
|
# installed binary.
|
||||||
|
(lib.fileset.difference ./test ./test/e2e)
|
||||||
|
./skills
|
||||||
|
./package.json
|
||||||
|
./package-lock.json
|
||||||
|
./tsconfig.json
|
||||||
|
./tsconfig.build.json
|
||||||
|
./vitest.config.ts
|
||||||
|
];
|
||||||
|
};
|
||||||
|
in
|
||||||
|
buildNpmPackage {
|
||||||
|
pname = "gitea-axi";
|
||||||
|
inherit (manifest) version;
|
||||||
|
inherit src nodejs;
|
||||||
|
|
||||||
|
# Each dependency's fetch is derived from the integrity fields already in the
|
||||||
|
# lockfile, so a lockfile change needs no edit here. A single fixed-output
|
||||||
|
# hash would break on every dependency bump and be repaired by copying a hash
|
||||||
|
# out of an error message — a permanent recurring tax.
|
||||||
|
npmDeps = importNpmLock { npmRoot = src; };
|
||||||
|
inherit (importNpmLock) npmConfigHook;
|
||||||
|
|
||||||
|
nativeBuildInputs = [ makeWrapper ];
|
||||||
|
|
||||||
|
# The builder would otherwise unpack to a generic `source` directory, which no
|
||||||
|
# real installation resembles: under npm the tree lives at
|
||||||
|
# `node_modules/gitea-axi`, under Nix at `…-gitea-axi-<version>/…`. The fast
|
||||||
|
# tier's `setup hooks` test is sensitive to the difference, because the SDK
|
||||||
|
# records the entrypoint's absolute path and recognises its own managed hook by
|
||||||
|
# finding "gitea-axi" within it. Naming the tree makes the build representative
|
||||||
|
# rather than an environment no operator ever has.
|
||||||
|
#
|
||||||
|
# This coupling is a defect, not a property worth preserving — see task 0042,
|
||||||
|
# which removes the hook's dependence on the entrypoint path entirely. Once it
|
||||||
|
# lands this rename should go with it.
|
||||||
|
postUnpack = ''
|
||||||
|
mv "$sourceRoot" gitea-axi
|
||||||
|
export sourceRoot=gitea-axi
|
||||||
|
'';
|
||||||
|
|
||||||
|
# The fast tier only. The live end-to-end and benchmark smoke tiers need a
|
||||||
|
# live Gitea host. Two of these test files invoke `git` directly and one
|
||||||
|
# resolves it with `which`; `tea` is already stubbed within this tier.
|
||||||
|
doCheck = true;
|
||||||
|
nativeCheckInputs = [
|
||||||
|
git
|
||||||
|
which
|
||||||
|
];
|
||||||
|
|
||||||
|
# `buildNpmPackage` wires config, build and install hooks but no check hook, so
|
||||||
|
# `doCheck` alone is inert and the phase has to be spelled out. `git init` and
|
||||||
|
# `git commit` in the fast tier also need a writable HOME, which the sandbox
|
||||||
|
# otherwise points at a non-existent directory.
|
||||||
|
checkPhase = ''
|
||||||
|
runHook preCheck
|
||||||
|
|
||||||
|
export HOME=$(mktemp -d)
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
runHook postCheck
|
||||||
|
'';
|
||||||
|
|
||||||
|
# ADR 0018: append, never prepend. The operator's own `tea` owns the
|
||||||
|
# credential store it refreshes in place, so the closure's copy is a
|
||||||
|
# fresh-machine fallback rather than an override.
|
||||||
|
postInstall = ''
|
||||||
|
wrapProgram $out/bin/gitea-axi \
|
||||||
|
--suffix PATH : ${lib.makeBinPath [ git tea ]}
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
inherit (manifest) description homepage;
|
||||||
|
|
||||||
|
# Looked up by SPDX identifier rather than hardcoded, for the same reason
|
||||||
|
# the version is read from the manifest: one canonical source, no second
|
||||||
|
# place to update on a relicence.
|
||||||
|
license = lib.licensesSpdx.${manifest.license};
|
||||||
|
|
||||||
|
mainProgram = "gitea-axi";
|
||||||
|
|
||||||
|
# Broader than the flake's `systems` list, deliberately. This describes what
|
||||||
|
# the package supports — everything, since it contains no compiled code —
|
||||||
|
# whereas that list encodes which systems the pinned nixpkgs can still
|
||||||
|
# evaluate. Consumed against 26.05, x86_64-darwin builds fine from here.
|
||||||
|
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user