Compare commits

...

28 commits

Author SHA1 Message Date
34ef7be4e7 Merge pull request 'Add a Forgejo release source' (#5) from worktree-forgejo-source into master
All checks were successful
CI / build (push) Successful in 38s
CI / test (push) Successful in 2m32s
CI / audit (push) Successful in 10s
CI / coverage (push) Successful in 5m11s
Reviewed-on: #5
Reviewed-by: Austin Schaefer <austin.schaefer@mailo.eu>
2026-09-20 09:19:59 +00:00
Austin Schaefer
984c11066f Rename the source module to release_source
All checks were successful
CI / build (pull_request) Successful in 37s
CI / test (pull_request) Successful in 2m41s
CI / audit (pull_request) Successful in 11s
CI / coverage (pull_request) Successful in 4m38s
'source' read like source code next to the config's source key. The trait
file becomes contract.rs to avoid release_source::release_source, and the
pipeline's local variables become 'host'.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 11:10:00 +02:00
Austin Schaefer
6459856aab Group release sources into a source module with re-exports
All checks were successful
CI / build (pull_request) Successful in 35s
CI / test (pull_request) Successful in 2m26s
CI / audit (pull_request) Successful in 13s
CI / coverage (pull_request) Successful in 6m11s
Move the ReleaseSource trait, GithubEndpoints and ForgejoEndpoints under
src/source/, each in its own file (release_source.rs, github.rs,
forgejo.rs). The submodules are private; mod.rs re-exports their types and
holds for_package, so the rest of the crate imports from crate::source and
never names a host's file. checker.rs keeps only version_from_tag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 11:08:25 +02:00
Austin Schaefer
ba2c5c2d02 Make release sources polymorphic via a ReleaseSource trait
All checks were successful
CI / build (pull_request) Successful in 35s
CI / test (pull_request) Successful in 2m27s
CI / audit (pull_request) Successful in 10s
CI / coverage (pull_request) Successful in 5m8s
Replaces the Endpoints enum and checker's per-host dispatch: checker.rs now
holds only the ReleaseSource trait (latest release + API root), each host
implements it in its own module (github.rs, forgejo.rs), and source.rs is a
factory returning a Box<dyn ReleaseSource> per package. Adding a host no
longer touches existing ones, and the pipeline only sees the trait.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:58:26 +02:00
Austin Schaefer
e25ff8f6da Apply self-review feedback on the Forgejo source
All checks were successful
CI / build (pull_request) Successful in 36s
CI / test (pull_request) Successful in 2m35s
CI / audit (pull_request) Successful in 10s
CI / coverage (pull_request) Successful in 5m8s
Fix stale ARCHITECTURE/config docs, soften source.rs's overclaim, trim the
SPEC's crate paragraph (no brittle counts), and merge the duplicate
same-origin Package test helper into test_support.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:39:35 +02:00
Austin Schaefer
eef98906b6 Add a Forgejo release source
Packages can now declare source = "forgejo-release" plus a base_url and be
checked, fetched and verified against a Forgejo instance's releases API,
alongside the existing GitHub source. This is what lets pkgwatch track its
own releases from the self-hosted Forgejo.

- config: Source enum (github-release default, forgejo-release) + base_url,
  validated once at load (base_url pairing, http(s) scheme, and no
  github-attestation on a Forgejo source).
- source: new module mapping a package to its Endpoints.
- checker: latest_forgejo_release, one call to releases/latest; latest_release
  dispatches per source.
- fetcher/verifier: take the releases API root instead of GithubEndpoints,
  since GitHub and Forgejo serve the same releases/tags/<tag> shape.
- pipeline: endpoints are resolved per package.
- docs: SPEC documents the source key and why the HTTP is hand-rolled rather
  than an API-client crate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:36:13 +02:00
9955610e09 Merge pull request 'Resolve config, state and work dirs via XDG paths' (#4) from worktree-xdg-paths into master
All checks were successful
CI / build (push) Successful in 38s
CI / test (push) Successful in 2m31s
CI / audit (push) Successful in 11s
CI / coverage (push) Successful in 5m3s
Reviewed-on: #4
2026-09-20 08:26:53 +00:00
Austin Schaefer
d0ab2525e4 Apply review feedback on XDG paths
All checks were successful
CI / build (pull_request) Successful in 38s
CI / test (pull_request) Successful in 2m37s
CI / audit (pull_request) Successful in 12s
CI / coverage (pull_request) Successful in 5m13s
Ignore relative XDG_* values and reject a relative HOME, per the XDG
spec; add a hint to the missing-config error; tighten docs and comments.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:19:12 +02:00
Austin Schaefer
6188f7f0b7 Drop the now-unneeded WorkingDirectory from the service
All checks were successful
CI / build (pull_request) Successful in 50s
CI / test (pull_request) Successful in 2m21s
CI / audit (pull_request) Successful in 13s
CI / coverage (pull_request) Successful in 4m52s
Paths no longer resolve relative to the cwd, so the unit's comment and
the SPEC's explanation of it were stale.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:03:24 +02:00
Austin Schaefer
00ce665038 Merge origin/master (systemd timer and notifications) into xdg-paths
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:03:11 +02:00
b78c5ad9d9 Merge pull request 'Add systemd timer and desktop notifications' (#3) from worktree-add-systemd-timer into master
All checks were successful
CI / build (push) Successful in 34s
CI / test (push) Successful in 2m29s
CI / audit (push) Successful in 11s
CI / coverage (push) Successful in 5m36s
Reviewed-on: #3
2026-09-20 08:02:07 +00:00
Austin Schaefer
09b198b96d Resolve config, state and work dirs via XDG, not the cwd
An installed pkgwatch has no checkout to run from, so packages.d/,
state/ and work/ can no longer be relative to the working directory.
New paths module resolves them per the XDG base-directory spec, with
PKGWATCH_{CONFIG,STATE,WORK}_DIR overrides for dry runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 10:01:54 +02:00
Austin Schaefer
dd42bcbe75 Fix ETXTBSY flake in stub-script tests
All checks were successful
CI / build (pull_request) Successful in 37s
CI / test (pull_request) Successful in 2m34s
CI / audit (pull_request) Successful in 11s
CI / coverage (pull_request) Successful in 5m6s
write_executable_script now probe-execs the script and retries on Text
file busy, so it returns only once the script is actually runnable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 09:54:14 +02:00
Austin Schaefer
38a32a0c60 Apply review feedback: docs, doc comment, lifetimes
Some checks failed
CI / build (pull_request) Successful in 39s
CI / audit (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 09:51:10 +02:00
Austin Schaefer
1f84460a65 Drop RandomizedDelaySec so the login check really is immediate
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 09:49:14 +02:00
Austin Schaefer
7a00f412bb Check 10s after login instead of 2min
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 09:49:04 +02:00
Austin Schaefer
071cf27f72 Run a check shortly after login, not just on the hourly tick
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 09:48:28 +02:00
Austin Schaefer
b373881c86 Add systemd timer and desktop notifications
Hourly user-level pkgwatch.timer/.service, an OnFailure= notifier, and a
notifier module that sends notify-send alerts when a tier 4-6 release is
queued for review or a tier 1-3 release is published.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 09:45:26 +02:00
bf85160861 Merge pull request 'Add wrapper-script env var support for claude-code's self-update guard' (#2) from worktree-add-wrapper-env-support into master
All checks were successful
CI / build (push) Successful in 48s
CI / test (push) Successful in 2m35s
CI / audit (push) Successful in 12s
CI / coverage (push) Successful in 5m8s
Reviewed-on: #2
2026-09-20 07:32:10 +00:00
Austin Schaefer
994cee65f5 Add wrapper-script env var support for claude-code's self-update guard
All checks were successful
CI / build (pull_request) Successful in 47s
CI / test (pull_request) Successful in 3m31s
CI / audit (pull_request) Successful in 14s
CI / coverage (pull_request) Successful in 6m59s
The previously-installed claude-code package (2.1.273-1, an AUR build)
wraps its real binary in a /usr/bin/claude script that sets
DISABLE_UPDATES=1 and DISABLE_INSTALLATION_CHECKS=1 before exec-ing
/opt/claude-code/bin/claude — almost certainly to stop Claude Code's own
self-updater from fighting with a package manager already managing it,
which applies just as much to a pkgwatch-managed install. The generated
PKGBUILD had no way to replicate that: it only ever wrote one file.

Add Package::env (a sorted BTreeMap for deterministic output). When set
and non-empty, builder.rs now installs the real binary under
/usr/lib/<pkgname>/ and generates a /usr/bin/<binary_name> wrapper that
exports the declared vars before exec-ing it, written inline via a
quoted heredoc (no bash expansion at PKGBUILD-build time). The wrapper
finds its sibling binary via $(dirname "$0") rather than a hardcoded
absolute path, since /bin/sh is bash on this box and sets $0 to the
full resolved path when found via PATH (confirmed empirically) — so the
same wrapper resolves correctly both under sanity.rs's staging-directory
pkgdir check and after a real pacman install.

Wired claude-code.toml to declare both vars. Verified end to end against
a scratch repo: build succeeds, the sanity check (which now runs through
the wrapper, not the raw binary) passes, and the built package's wrapper
genuinely exports both vars at runtime before exec-ing the real binary
(confirmed by hand, substituting the exec line for an env dump).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 13:07:03 +02:00
d96b7a32f6 Merge pull request 'Close the loop: build, sanity-check, and publish' (#1) from worktree-build-publish-pipeline into master
All checks were successful
CI / build (push) Successful in 43s
CI / test (push) Successful in 3m38s
CI / audit (push) Successful in 13s
CI / coverage (push) Successful in 7m22s
Reviewed-on: #1
2026-09-18 10:56:35 +00:00
Austin Schaefer
0044eda532 Add a third builder shape for archives with no wrapping directory
All checks were successful
CI / build (pull_request) Successful in 1m13s
CI / test (pull_request) Successful in 3m46s
CI / audit (pull_request) Successful in 15s
CI / coverage (pull_request) Successful in 7m27s
Merging master's claude-code.toml onto this branch surfaced a real gap:
builder.rs only knew "bare binary download" and "tarball extracting into
a same-named directory" (uv). claude-code's tarball extracts a bare
`claude` file with no wrapping directory, and that inner filename
doesn't match the package name either — makepkg's package() failed with
"cannot stat .../claude-linux-x64/claude-code" (confirmed by actually
running the build).

Add Package::archive_binary_path, an explicit override for the
in-archive path builder.rs installs from, used verbatim when present
instead of the stem/binary_name convention. Set binary_name = "claude"
too, matching the box's actual command name (/opt/claude-code/bin/claude)
rather than the claude-code package name. Also added a sanity_check
block (claude --version), matching uv's pattern, confirmed against the
real built binary's output ("2.1.276 (Claude Code)").

Verified end to end against the real repo with PKGWATCH_REPO_DIR
pointed at a scratch dir: check -> fetch -> verify -> review --approve
-> build -> sanity-check -> publish all pass for claude-code v2.1.276.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 12:46:28 +02:00
14e1cee36f Merge branch 'master' into worktree-build-publish-pipeline
All checks were successful
CI / build (pull_request) Successful in 1m6s
CI / test (pull_request) Successful in 3m58s
CI / audit (pull_request) Successful in 15s
CI / coverage (pull_request) Successful in 8m6s
2026-09-18 10:38:05 +00:00
Austin Schaefer
3e87282b21 Move high-level docs into docs/, matching doubleo7's convention
All checks were successful
CI / build (pull_request) Successful in 48s
CI / test (pull_request) Successful in 2m1s
CI / audit (pull_request) Successful in 9s
CI / coverage (pull_request) Successful in 3m53s
SPEC.md and ARCHITECTURE.md were sitting at the repo root alongside
Cargo.toml/Makefile.toml/packages.d — moved both into docs/ (doubleo7
already does this for its own supplementary docs, so this matches an
existing convention in the fleet rather than inventing a new one).

Updated every doc-comment cross-reference across src/*.rs and
Makefile.toml (23 references) to the new docs/SPEC.md / docs/
ARCHITECTURE.md paths. The two files' own cross-references to each other
didn't need changing — they're still same-directory relative references.

Also updated the project reference memory pointing at ARCHITECTURE.md's
location, so it doesn't go stale pointing at a path that no longer exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 13:22:40 +02:00
Austin Schaefer
f55188e36f Fix issues from code review: shell-escaping gap, exit code, and more
All checks were successful
CI / build (pull_request) Successful in 51s
CI / test (pull_request) Successful in 3m35s
CI / audit (pull_request) Successful in 13s
CI / coverage (pull_request) Successful in 6m57s
A single-agent code review of this branch's diff (builder/pipeline/
publisher/sanity/hash + main/config/fetcher/state/verifier changes)
found six real issues, all fixed here:

- builder.rs: validate_shell_safe only rejected a literal single quote
  and newline, written for the single-quoted PKGBUILD fields. But
  asset_name (via install_source) and binary_name land in the install()
  line, which is necessarily double-quoted so ${srcdir}/${pkgdir} can
  expand — where $, backtick, and backslash are still live. Not
  currently exploitable (the one variable component, version, is already
  independently constrained by validate_pkgver's strict charset), but a
  latent gap relying on that coincidence rather than the validator
  actually covering its real use context. Widened the reject-list to
  cover both quoting styles, added regression tests including one at the
  generate_pkgbuild level. Corrected SPEC.md's "single-quoted" claim to
  match.
- pipeline.rs: a verification failure returned Ok(()) from
  process_package, so run_check never counted it as a failure and the
  process exited 0 even on a failed cryptographic/attestation check —
  exactly the event a monitoring setup (systemd OnFailure=, cron
  mail-on-error) most needs a non-zero exit to catch. Now bails, which
  run_check already treats as a package failure. Added an integration
  test against a mocked GitHub server exercising this exact path.
- builder.rs: find_built_package hardcoded the .pkg.tar.zst suffix, so a
  box with a different PKGEXT in makepkg.conf would report a false
  "makepkg failed" for a build that actually succeeded. Widened to match
  any .pkg.tar.* compression. Added direct unit tests (it had none).
- pipeline.rs: a newer tier 4-6 version silently overwrote a still-
  unreviewed older pending version with no indication anything was
  superseded. Now says so explicitly.
- hash.rs: builder/verifier each read a whole downloaded artifact into
  memory via std::fs::read just to hash it, doubling peak memory for no
  reason since the file's already on disk. Added sha256_hex_file,
  streamed in fixed-size chunks; both callers switched to it.
- Deduplicated two near-identical test-only "write an executable shell
  script" helpers (publisher.rs, sanity.rs) into a shared
  src/test_support.rs.

75 tests (was 63), cargo make ci clean. Re-verified end to end against
the real astral-sh/uv release after all six fixes — build, sanity check,
and publish into a scratch repo all still succeed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 12:06:40 +02:00
Austin Schaefer
3f30e0acc6 Add ARCHITECTURE.md and apply it to this PR's code
All checks were successful
CI / build (pull_request) Successful in 54s
CI / test (pull_request) Successful in 4m6s
CI / audit (pull_request) Successful in 17s
CI / coverage (pull_request) Successful in 6m19s
Researched current industry practice on code organization/maintainability
(Ousterhout's deep modules and information hiding, package-by-feature vs.
package-by-layer, functional-core/imperative-shell testability, tech-debt
prevention via ADR-equivalent inline rationale) and wrote it into
ARCHITECTURE.md as a set of concrete, project-specific rules rather than
a generic essay — each principle cites a real example already in this
codebase or fixed by this commit. Cross-linked from SPEC.md, which stays
about product design, not code organization.

Applied it to this PR's own code:

- Pulled process_package/fetch_and_verify/build_and_publish/run_review/
  approve out of main.rs into a new pipeline.rs. main.rs's own main() had
  grown to 278 lines and zero tests by treating "it's just the entry
  point" as an excuse to skip separating logic from wiring; now main.rs
  is argv dispatch only.
- Extracted decide_tier_action as a pure function (verification outcome +
  pending-state -> what to do), replacing dispatch logic that was
  previously inlined into a function that also made the real network/
  build calls. Four unit tests, no I/O, covering all four outcomes.
- Added a `//!` module doc comment to every file touched in this branch,
  each stating that module's one job in a sentence, per the "deep
  modules" principle the spec argues for.

Coverage's reported total drops (94% -> 78%) because pipeline.rs is
deliberately NOT excluded from it the way main.rs is, even though it's
mostly the same kind of untestable I/O orchestration — excluding it would
hide decide_tier_action's real unit-test coverage along with the untested
parts. Noted inline in Makefile.toml/ci.yml so the number doesn't look
like a quality regression at a glance.

Also added a project reference memory pointing at ARCHITECTURE.md rather
than duplicating its content there, per this session's own memory-hygiene
rules (architecture/conventions are derivable from the repo and shouldn't
be duplicated somewhere that can go stale).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 11:42:18 +02:00
Austin Schaefer
6305743e4d Check the custom repo is registered in pacman.conf before building
All checks were successful
CI / build (pull_request) Successful in 1m22s
CI / test (pull_request) Successful in 3m50s
CI / audit (pull_request) Successful in 14s
CI / coverage (pull_request) Successful in 9m2s
PR feedback: the local repo isn't guaranteed to exist on every box this
runs on, so it shouldn't just be assumed. publisher::ensure_registered
checks /etc/pacman.conf for an active [<repo_name>] section before a
build even starts, failing fast with the exact snippet to add if it's
missing — instead of spending several seconds on a makepkg build that
would succeed and then publish into a repo pacman never syncs from.

The repo directory and its database file were already self-healing
(publish creates the dir if missing, repo-add creates the db on first
run) — the actual gap was the pacman.conf registration, which can't be
made self-healing without root, so this fails loud with instructions
instead of trying to write to /etc/pacman.conf itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 11:30:43 +02:00
Austin Schaefer
68fa648010 Close the loop: build, sanity-check, and publish for the first time
All checks were successful
CI / build (pull_request) Successful in 13m14s
CI / test (pull_request) Successful in 3m35s
CI / coverage (pull_request) Successful in 9m23s
CI / audit (pull_request) Successful in 13s
Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD
generation + makepkg (builder.rs), a post-build version sanity check
(sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs
for both the tier 1-3 auto-publish path and a new tier 4-6 review queue
(`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via
state::{load,save,clear}_pending_version, tracked separately from
last-published-version since approving one release isn't a standing
auto-publish grant for future ones).

Publishing targets an existing, already-registered local pacman repo
(~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather
than one pkgwatch invents — found already in real use for a hand-packaged
AppImage, which resolves SPEC's open question on where the repo lives
without pkgwatch ever touching pacman.conf. Publishing stops at
`repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is
left to the operator, not run automatically.

Getting a real second package (scaleway-cli, tier 4) through the new
pipeline immediately surfaced a real gap: its pacman package is named
`scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql`
against the currently-installed extra package) — without a way to
declare that, the build would install alongside extra's package under
the wrong name instead of shadowing it. Added `Package::binary_name`
(config.rs) to cover it.

Every upstream-controlled string (version, asset name, download URL)
is validated before it touches generated shell content in the PKGBUILD
template — rejects anything containing a single quote or newline, since
values are embedded in single-quoted bash strings.

Verified for real, end to end: uv (tier 2) auto-built and published
against the real astral-sh/uv release with no human step; scaleway-cli
(tier 4) queued for review, then approved via `pkgwatch review
scaleway-cli --approve`, which re-verified, built, and published it —
confirmed the built package contains exactly usr/bin/scw. Both landed in
the real custom repo's database. Left scaleway-cli's real-repo review
pending rather than approving it myself: the tier 4-6 gate exists for a
human judgment call, not the agent's.

69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 11:13:13 +02:00
29 changed files with 3149 additions and 342 deletions

View file

@ -110,9 +110,10 @@ jobs:
command -v cargo-llvm-cov >/dev/null 2>&1 || cargo install cargo-llvm-cov --locked command -v cargo-llvm-cov >/dev/null 2>&1 || cargo install cargo-llvm-cov --locked
# Reports coverage only — no --fail-under-lines yet. main.rs is # Reports coverage only — no --fail-under-lines yet. main.rs is
# excluded: thin orchestration glue exercised by the real end-to-end # excluded: thin argv dispatch exercised by the real end-to-end
# `cargo run` against live GitHub, not unit tests, so it's not a # `cargo run`, not unit tests, so it's not a meaningful signal here.
# meaningful signal here. See Makefile.toml > coverage-report. # See Makefile.toml > coverage-report for why pipeline.rs, despite
# being mostly untestable I/O orchestration too, stays included.
- name: Coverage - name: Coverage
run: cargo llvm-cov --ignore-filename-regex 'main\.rs' --summary-only run: cargo llvm-cov --ignore-filename-regex 'main\.rs' --summary-only

View file

@ -30,9 +30,15 @@ args = ["test"]
# same-named custom one. # same-named custom one.
# #
# Reports coverage only; not gated on a threshold yet — main.rs is thin # Reports coverage only; not gated on a threshold yet — main.rs is thin
# orchestration glue exercised by the real end-to-end `cargo run`, not unit # argv dispatch exercised by the real end-to-end `cargo run`, not unit
# tests, so it's excluded here rather than dragging the number down for # tests, so it's excluded here rather than dragging the number down for
# reasons unrelated to test quality. # reasons unrelated to test quality. pipeline.rs is deliberately NOT
# excluded even though it's mostly network/subprocess/filesystem
# orchestration too (hence its own low number) — its one pure decision
# function (decide_tier_action) is unit tested and should stay visible in
# this report; excluding the whole file would hide that signal along with
# the untested parts. See docs/ARCHITECTURE.md > "separate pure decision logic
# from I/O."
[tasks.coverage-report] [tasks.coverage-report]
command = "cargo" command = "cargo"
args = ["llvm-cov", "--ignore-filename-regex", "main\\.rs", "--summary-only"] args = ["llvm-cov", "--ignore-filename-regex", "main\\.rs", "--summary-only"]

134
docs/ARCHITECTURE.md Normal file
View file

@ -0,0 +1,134 @@
# pkgwatch — code organization
Status: written 2026-09-17, once the build/publish pipeline PR gave this
project enough real code to have actual conventions worth writing down,
instead of guessing at them in advance.
This is distinct from `SPEC.md`, which is the product design (what
pkgwatch does and why). This file is about how the *code* implementing
that design is organized, so it stays readable as it grows past PoC size
instead of quietly accumulating debt. Researched against current industry
practice rather than asserted from habit — see Further reading.
## Principles
1. **One module, one job — and say what it is, up front.**
Ousterhout's "deep modules": the best modules expose a lot of
functionality through a simple interface, hiding the complexity behind
it. The two failure modes he names — *change amplification* (one
conceptual change forces edits in many places) and *obscurity* (a
reader can't tell where responsibility lives) — are both symptoms of
modules that don't have one clear job.
**Rule**: every `src/*.rs` file opens with a `//!` doc comment stating
its one responsibility in a sentence. If it can't be one sentence, the
module is doing too much.
**Example already here**: `builder.rs`'s job is "turn an
already-downloaded, already-verified artifact into a built package." It
hides PKGBUILD templating, upstream-string validation, and the
`makepkg` invocation behind one `build()` call — none of that leaks to
callers.
2. **Organize by pipeline stage (feature), not by technical layer.**
The package-by-feature vs. package-by-layer research is consistent:
feature-based grouping gives high cohesion within a module and low
coupling between modules; layer-based grouping (`models/`, `utils/`,
`helpers/`) tends toward the opposite, and a single feature change ends
up touching files scattered across every layer.
**Rule**: modules are named after what they do in the pipeline
(`release_source`, `fetcher`, `verifier`, `builder`, `sanity`,
`publisher`, `state`), not generic buckets. (`release_source` is a directory module: the
`ReleaseSource` trait and one file per host, re-exported from its
`mod.rs` so the rest of the crate never names a host's file.) A new pipeline stage gets a new module
named after the stage, not a method bolted onto an existing one.
**Anti-example to keep watching for**: a `utils.rs` grab-bag. `hash.rs`
could look like one but isn't — it exists for exactly one piece of
shared logic (`sha256_hex`) that two real stages (`verifier`,
`builder`) both need, not as a place to dump unrelated helpers.
3. **Separate pure decision logic from I/O ("functional core, imperative
shell").**
A function that decides *and* does in the same body can't be tested
without standing up everything the "does" half touches — often a
network call, a subprocess, or the filesystem. Pulling the decision out
into its own pure function makes it trivially unit-testable and makes
the I/O half thin enough that it obviously matches the decision.
**Applied this PR**: `pipeline::process_package`'s tier dispatch
(publish now / still pending / newly pending / verification failed) was
originally inline in a function that also made the real network and
build calls. Pulled out into `decide_tier_action`, a pure function with
its own unit tests covering all four outcomes, no I/O involved.
4. **Every network, subprocess, filesystem-root, or environment boundary
is injectable.**
Same testability goal as #3, applied to the specific ways this program
reaches outside itself. A consistent shape beats ad hoc mocking invented
per call site.
**Already in force**: `GithubEndpoints`/`ForgejoEndpoints` (the
`ReleaseSource` implementations, whose API root is what fetcher/verifier
take), `repo_add_bin` and the pacman.conf path (publisher), `PKGWATCH_REPO_DIR`
(main, for manual dry runs against a scratch repo instead of the real
one). A new external call follows the same shape: production code calls
a thin wrapper with the real default; tests call the parameterized
version with a fake.
5. **`main.rs` is a dispatcher, not the program.**
Found by looking at this project's own `main.rs`: it grew to 278 lines
and zero tests over the course of one PR, because "it's just the entry
point" is an easy excuse to skip separating logic from wiring — even
though Rust doesn't actually stop you from unit-testing a binary
crate's `main.rs`. The Rust community convention of splitting
entry-point parsing from application logic exists precisely so the
logic ends up somewhere it's normal to test.
**Rule**: `main.rs` may parse `argv`, build shared clients, and print
output. It must not contain a pipeline decision, a network/subprocess
call, or anything with a test worth writing — that belongs in
`pipeline.rs`.
**Applied this PR**: moved `process_package`, `fetch_and_verify`,
`build_and_publish`, `run_review`, and `approve` out of `main.rs` into a
new `pipeline.rs`, leaving `main.rs` as argument dispatch only.
6. **Validate at the boundary, once — don't scatter checks.**
Already stated project-wide (see the user's global instructions: don't
validate scenarios that can't happen, validate at system boundaries).
**Example already here**: `builder.rs`'s `validate_pkgname`/
`validate_pkgver`/`validate_shell_safe` run once, at PKGBUILD-generation
time, against every upstream-controlled string — not sprinkled through
whatever code happens to produce those strings.
7. **Don't build generality the currently-tracked packages don't need.**
Already the load-bearing design principle in `SPEC.md` ("a small fixed
set of PKGBUILD shapes," "extend when a third real shape shows up").
Restated here because it's also a tech-debt principle in its own right:
speculative abstraction is debt too — every future reader has to
understand it whether or not it's ever exercised.
8. **Every non-obvious structural decision gets one sentence of "why,"
inline.**
Standard tech-debt-prevention advice is to keep Architecture Decision
Records; a single-crate personal tool doesn't need a `docs/adr/`
directory, but the same information — why this way and not the obvious
alternative — needs to live somewhere a future reader will actually see
it: the doc comment on the thing itself.
**Example already here**: `release_source/github.rs`'s doc comment on
`GithubEndpoints::latest_release` explains why the newest Atom-feed entry isn't
trusted outright (scaleway-cli's `-dbg1` tag has no real Release behind
it) — the reasoning lives right next to the code it justifies, not in a
commit message or a separate design doc no one will find later.
## What's machine-enforced vs. what isn't
`cargo make ci` (format, clippy, cognitive-complexity threshold, coverage,
audit) mechanically enforces what's checkable: style, a handful of lint
categories, a complexity ceiling, and that coverage doesn't quietly
regress. It does **not** enforce module cohesion, naming, or "is this
logic in the right module" — those stay code-review questions. Worth
being honest about that boundary rather than implying CI catches
everything above.
## Further reading
- [A Philosophy of Software Design — deep modules & information hiding, summary](https://medium.com/swlh/a-philosophy-of-software-design-by-john-ousterhout-4a00d0ff9f1c)
- [Package by feature vs. package by layer](https://medium.com/@felixnjunge78/package-by-feature-vs-package-by-layer-which-one-wins-11ee03921fed)
- [Coupling and cohesion as the foundations of a maintainable codebase](https://medium.com/@iamprovidence/coupling-and-cohesion-foundations-that-affect-your-entire-codebase-77d06d44af0d)
- [Rust module and crate organization best practices](https://softwarepatternslexicon.com/rust/idiomatic-rust-patterns/module-and-crate-organization-best-practices/)
- [Reducing technical debt in 2026 — IBM](https://www.ibm.com/think/insights/reduce-technical-debt)

View file

@ -2,6 +2,10 @@
Status: design draft, pre-PoC. Captures the design discussion as of 2026-09-11. Status: design draft, pre-PoC. Captures the design discussion as of 2026-09-11.
This is the *product* design — what pkgwatch does and why. For how the
code implementing it is organized (module boundaries, testability
conventions, what CI does and doesn't enforce), see `ARCHITECTURE.md`.
## Problem ## Problem
Software not packaged by the distro (Arch/Manjaro here) usually gets installed Software not packaged by the distro (Arch/Manjaro here) usually gets installed
@ -242,6 +246,12 @@ asset_pattern = "otherpkg-x86_64-unknown-linux-gnu.tar.gz"
method = "same-origin-sha256" method = "same-origin-sha256"
checksum_asset_pattern = "otherpkg-x86_64-unknown-linux-gnu.tar.gz.sha256" checksum_asset_pattern = "otherpkg-x86_64-unknown-linux-gnu.tar.gz.sha256"
# binary_name: only needed when the installed binary's name differs from
# the pacman package name — e.g. real-world case, scaleway-cli's package
# is named scaleway-cli but its actual binary is `scw` (see
# packages.d/scaleway-cli.toml). Defaults to the package name.
binary_name = "otherbin"
# Tier 1 example — not yet implemented in the PoC (only # Tier 1 example — not yet implemented in the PoC (only
# same-origin-sha256 and github-attestation exist so far): # same-origin-sha256 and github-attestation exist so far):
[package.somepkg] [package.somepkg]
@ -277,17 +287,40 @@ implements `repo`, `asset_pattern`, and `verification.method`
matches the line by filename instead of assuming a single-hash file. matches the line by filename instead of assuming a single-hash file.
- Separately, scaleway-cli's Atom feed lists a `vX.Y.Z-dbg1` tag newest, - Separately, scaleway-cli's Atom feed lists a `vX.Y.Z-dbg1` tag newest,
with no real Release object behind it (`releases/tags/<tag>` 404s) — with no real Release object behind it (`releases/tags/<tag>` 404s) —
`checker::latest_github_release` now confirms each feed candidate the GitHub source's `latest_release` now confirms each feed candidate
against the releases API in feed order rather than trusting the first against the releases API in feed order rather than trusting the first
entry outright. entry outright.
Tier 4-6 packages (scaleway-cli included) are not auto-published — see `sanity_check` and `binary_name` are now real, implemented fields (see
Build/publish/review-queue below. `source`, `check_method`, Builder/Sanity checker above) — added `packages.d/uv.toml`'s and
`check_interval`, and `sanity_check` are still schema sketch, not yet read `packages.d/scaleway-cli.toml`'s own `sanity_check` blocks, and
by the code — the PoC only knows how to check GitHub-release sources. scaleway-cli's `binary_name = "scw"`. `source` is implemented too, with
two values: `github-release` (the default when omitted, so existing
configs are unchanged) and `forgejo-release`, which also requires a
`base_url` (see Checker below). `check_method` and `check_interval` are
still schema sketch, not yet read by the code — checks are a fixed hourly
tick, not per-package.
```toml
# A package released from a Forgejo instance instead of GitHub. Only
# `same-origin-sha256` is valid here: `github-attestation` needs GitHub.
[package.mytool]
source = "forgejo-release"
base_url = "https://code.austinschaefer.com"
repo = "schaefera/mytool"
asset_pattern = "mytool-linux-x86_64.tar.gz"
[package.mytool.verification]
method = "same-origin-sha256"
checksum_asset_pattern = "SHA256SUMS"
```
Build/publish/review-queue (`makepkg`, `repo-add`, tier 46 human review) Build/publish/review-queue (`makepkg`, `repo-add`, tier 46 human review)
are not implemented yet; a tier 46 pass currently just logs "flagging for are now implemented too — see Builder/Sanity checker/Publisher/Reviewer
review" and stops. queue above and the Status entry below for the first full end-to-end run.
Tier 4-6 packages still don't auto-publish (by design, see Verification
trust tiers > Automation posture per tier); they queue for
`pkgwatch review <name> --approve`.
Open questions on the schema: Open questions on the schema:
@ -313,19 +346,62 @@ Open questions on the schema:
- **Config loader**: parses `packages.d/*.toml` into an in-memory package - **Config loader**: parses `packages.d/*.toml` into an in-memory package
list. *(Implemented — `src/config.rs`.)* list. *(Implemented — `src/config.rs`.)*
- **Paths**: where config, state and work files live, resolved by
`src/paths.rs` per the XDG base-directory spec rather than the current
working directory, so an installed binary behaves the same wherever it's
launched from. *(Implemented.)*
| What | Default | XDG variable | Override |
|---|---|---|---|
| Package declarations (`packages.d/*.toml`) | `~/.config/pkgwatch/packages.d` | `XDG_CONFIG_HOME` | `PKGWATCH_CONFIG_DIR` (the dir *containing* `packages.d`) |
| Last-published / pending versions | `~/.local/state/pkgwatch` | `XDG_STATE_HOME` | `PKGWATCH_STATE_DIR` |
| Downloads and build trees (safe to delete) | `~/.cache/pkgwatch` | `XDG_CACHE_HOME` | `PKGWATCH_WORK_DIR` |
Precedence per directory: override, then the XDG variable, then the
default under `$HOME`; an empty variable counts as unset. The overrides
are used verbatim (no `pkgwatch/` suffix) and exist for dry runs against
scratch directories, like `PKGWATCH_REPO_DIR` does for the pacman repo.
The XDG variables and `$HOME` must be absolute paths: a relative XDG
value is ignored, as the XDG spec requires, and a relative `$HOME` is an
error.
The checkout's `packages.d/` is no longer read on its own; it's just the
source to link from. Migrating from the old cwd-relative layout: move
`state/` to the state dir and copy or symlink `packages.d/` into the
config dir; `work/` is cache and can simply be dropped.
- **Checker**: per source type, resolves "what's the latest version" — - **Checker**: per source type, resolves "what's the latest version" —
likely reuses `nvchecker`'s logic/sources conceptually for non-GitHub likely reuses `nvchecker`'s logic/sources conceptually for non-GitHub
sources eventually. For GitHub sources, prefers the `github-atom` feed sources eventually. For GitHub sources, prefers the `github-atom` feed
(see Scaling > Check method) over unconditional REST polling. (see Scaling > Check method) over unconditional REST polling.
*(Implemented for GitHub only — `src/checker.rs` regex-matches the first *(Implemented for GitHub and Forgejo. `src/release_source/` defines the
`releases/tag/<tag>` link in the feed rather than doing a full XML parse; `ReleaseSource` trait (latest release + API root); each host implements
fine while the feed's newest-entry-first shape holds, revisit if that it in its own file (`github.rs`, `forgejo.rs`), and the module's
ever changes. `check_interval`/per-package cadence not wired up yet — `for_package` picks one per package, so adding a host doesn't touch
the PoC is a single one-shot run, not a scheduled loop.)* existing ones. The rest of the crate imports the trait and hosts from
`crate::release_source`, which re-exports them. A trait
rather than an enum match because there are now two real hosts with
genuinely different logic. GitHub regex-matches the first
`releases/tag/<tag>` link in the feed rather than doing a full XML
parse; fine while the feed's newest-entry-first shape holds, revisit if
that ever changes. Forgejo is one call to
`<base_url>/api/v1/repos/<repo>/releases/latest`, which already returns
only the newest non-draft, non-prerelease release, so it needs none of
GitHub's confirm-each-tag step. `check_interval`/per-package cadence not
wired up yet — checks are a fixed hourly tick.)*
The HTTP is hand-rolled on the `reqwest` already in the tree, not an
API-client crate: pkgwatch needs two `GET`s, GitHub's check deliberately
uses an Atom feed no API crate covers (to stay off the rate-limited REST
API), and the release-by-tag call is shared verbatim by both hosts.
`octocrab` is async against our blocking `reqwest` with a default tree
larger than pkgwatch's whole current one; `forgejo-api` is a generated
binding of the entire API for one endpoint. Revisit if pkgwatch needs
authenticated or write API calls.
- **Fetcher**: downloads the artifact (and any checksum/signature/ - **Fetcher**: downloads the artifact (and any checksum/signature/
attestation companion) for a resolved version. *(Implemented — attestation companion) for a resolved version. *(Implemented —
`src/fetcher.rs`, via the GitHub releases API; exact asset-name match, `src/fetcher.rs`, via the GitHub or Forgejo releases API — same
not a glob.)* `releases/tags/<tag>` endpoint and JSON shape on both; exact asset-name
match, not a glob.)*
- **Verifier**: tier-specific verification implementations, dispatched via - **Verifier**: tier-specific verification implementations, dispatched via
a `Verification` enum matched on `method` (an internally-tagged serde a `Verification` enum matched on `method` (an internally-tagged serde
enum) rather than a trait — simpler while there are only two methods; enum) rather than a trait — simpler while there are only two methods;
@ -334,25 +410,85 @@ Open questions on the schema:
separately. *(Implemented for `same-origin-sha256` and separately. *(Implemented for `same-origin-sha256` and
`github-attestation``src/verifier.rs`. The latter shells out to `gh `github-attestation``src/verifier.rs`. The latter shells out to `gh
attestation verify` rather than reimplementing sigstore verification.)* attestation verify` rather than reimplementing sigstore verification.)*
- **Builder**: for tiers 13 on pass, generates/updates the PKGBUILD - **Builder**: for tiers 13 on pass, generates a PKGBUILD (strict
(strict validation on any upstream-controlled string — version, filename validation on every upstream-controlled string — version, asset name,
— before it touches generated shell content; never unescaped download URL — before it touches generated shell content; most fields
interpolation) and runs `makepkg`. are single-quoted, but the `install()` line necessarily uses double
quotes so `${srcdir}`/`${pkgdir}` expand, so the validation rejects `'`,
newline, `$`, backtick, *and* backslash — safe for either quoting style
rather than assuming a value only ever lands in one of them — never
unescaped interpolation) and runs `makepkg`. *(Implemented —
`src/builder.rs`. One fixed "prebuilt binary" PKGBUILD shape covers both
tracked packages so far: a bare-binary download (scaleway-cli) and a
tarball extracting to a same-named directory (uv) — see Scaling >
Template reuse. `Package.binary_name` (config.rs) covers the case where
the installed binary's name differs from the pacman package name, which
turned out to matter immediately: scaleway-cli's real binary is `scw`,
not `scaleway-cli` — confirmed by inspecting the currently-installed
`extra` package with `pacman -Ql`, not guessable from the repo name.
Without it the build would install alongside `extra`'s package instead
of shadowing it.)*
- **Sanity checker**: after a successful build, runs the package's - **Sanity checker**: after a successful build, runs the package's
declared `sanity_check.command` against the built artifact and confirms declared `sanity_check.command` — with the freshly built package's
the reported version matches what pkgwatch believes it just built. `usr/bin` prepended to `PATH`, so it exercises what was just built
Mismatch = fail loud, do not publish. This is a correctness check, not a rather than whatever's already installed system-wide — and confirms the
security control — it catches checker bugs and mangled/wrong-artifact reported version matches what pkgwatch believes it just built. Mismatch
downloads, not malicious releases. = fail loud, do not publish. This is a correctness check, not a security
- **Publisher**: runs `repo-add` against the local repo, only after the control — it catches checker bugs and mangled/wrong-artifact downloads,
sanity check passes. not malicious releases. *(Implemented — `src/sanity.rs`.)*
- **Publisher**: copies the built package into the local repo directory
and runs `repo-add`, only after the sanity check passes. *(Implemented —
`src/publisher.rs`. Targets an existing, already-registered local pacman
repo rather than one pkgwatch creates — this box already has one at
`~/.local/share/pacman/custom`, registered as `[custom]` in
`/etc/pacman.conf` (`SigLevel = Optional TrustAll`) and already in use
for a hand-packaged AppImage. But that repo directory/registration isn't
guaranteed to exist on every box this ever runs on, so it isn't just
assumed: `publisher::ensure_registered` checks `/etc/pacman.conf` for an
active `[<repo_name>]` section before a build even starts, failing fast
with the exact snippet to add if it's missing, rather than wasting a
`makepkg` build on a repo pacman will never sync from. The repo
*directory* and its database file, by contrast, are fully self-healing —
`publish` creates the directory if missing and `repo-add` creates the
database on its first run. What's deliberately not automatic, and can't
safely be: writing the `[section]` into `/etc/pacman.conf` itself — that
needs root, which this process doesn't have and shouldn't grab for
itself. Similarly, publish deliberately stops at `repo-add`: getting the
new version onto the running system is a separate, deliberate
`pacman -Syu`/`pacman -S <pkg>` step left to the operator, not run
automatically.)*
- **Reviewer queue**: for tiers 46, records the detected change instead of - **Reviewer queue**: for tiers 46, records the detected change instead of
auto-building; a separate `pkgwatch review` command lets a human auto-building; a separate `pkgwatch review` command lets a human
approve/reject, which then triggers the build → sanity-check → publish approve/reject, which then triggers the build → sanity-check → publish
steps above. steps above. *(Implemented — `state::{load,save,clear}_pending_version`
plus the `review`/`review <name> --approve` subcommands in `src/main.rs`.
Tracked separately from the last-published-version state: approving one
release doesn't mean future ones auto-publish. `--approve` re-verifies
before building rather than trusting a possibly-stale flag from an
earlier run. No reject/dismiss command yet — see Status below.)*
- **Scheduling**: systemd `.service` (oneshot) + `.timer` running it - **Scheduling**: systemd `.service` (oneshot) + `.timer` running it
periodically, matching the pattern already used for other periodic tasks periodically, matching the pattern already used for other periodic tasks
on this box. on this box. *(Implemented — user-level units under `systemd/`, run 10s
after login and then hourly, non-persistent. Install from the main
checkout:*
```sh
cargo build --release && mkdir -p ~/.config/systemd/user &&
cp systemd/* ~/.config/systemd/user/ && systemctl --user daemon-reload &&
systemctl --user enable --now pkgwatch.timer
```
*`pkgwatch.service` runs the release binary from the checkout and sets
no `WorkingDirectory`: config, state and work dirs come from the XDG
paths above, so the service needs `~/.config/pkgwatch/packages.d` set
up first — see the migration note under Paths.)*
- **Notifications**: `notifier.rs` sends a desktop notification
(`notify-send`) when a tier 4-6 release is newly queued for review or a
tier 1-3 release is published; both are best-effort and never fail a
run. A non-zero exit (verification/build/network failure) triggers
`pkgwatch-failure.service` via `OnFailure=`. Approving via
`pkgwatch review --approve` doesn't notify — the operator is already at
the terminal.
## Prior art / reference points ## Prior art / reference points
@ -390,23 +526,39 @@ Open questions on the schema:
confirmed correct — no build-provenance attestations upstream. confirmed correct — no build-provenance attestations upstream.
Required adding `{version}`-placeholder support to `asset_pattern`/ Required adding `{version}`-placeholder support to `asset_pattern`/
`checksum_asset_pattern`, filename-matched parsing of combined `checksum_asset_pattern`, filename-matched parsing of combined
multi-asset checksum files, and having `latest_github_release` multi-asset checksum files, and having the GitHub source's
confirm each Atom-feed candidate against the releases API (this `latest_release` confirm each Atom-feed candidate against the releases API (this
repo's newest feed entry, a `-dbg1` tag, has no real Release behind repo's newest feed entry, a `-dbg1` tag, has no real Release behind
it). Still just flags for human review, same as any tier 4-6 pass — it). Still just flags for human review, same as any tier 4-6 pass —
not auto-installed; see the unchecked build/publish item below. not auto-installed; see the unchecked build/publish item below.
- [ ] Not yet implemented: build (PKGBUILD generation + `makepkg`), - [x] Full pipeline closed end to end for the first time: check → fetch →
publish (`repo-add`), reviewer queue for tier 46, scheduling/ verify → build → sanity-check → publish, against two real packages.
`check_interval`, non-GitHub sources, `minisign`/tier-1 method. `uv` (tier 2) auto-built and published on the first run with no
- [ ] Refine config schema further (see open questions above), including human step. `scaleway-cli` (tier 4) queued for review, then
the `sanity_check` block per package. `pkgwatch review scaleway-cli --approve` re-verified, built, and
published it — confirmed the built package installs as
`/usr/bin/scw`, actually shadowing `extra`'s package rather than
installing alongside it under the wrong name. Both landed in the
real `~/.local/share/pacman/custom` repo's database
(`custom.db.tar.gz`), ready for `sudo pacman -Syu`/`sudo pacman -S`
— not run automatically. See Builder/Sanity checker/Publisher/
Reviewer queue above for what each piece does.
One cosmetic wrinkle, not a correctness issue: `makepkg` printed
`libfakeroot internal error: payload not recognized!` while
packaging scaleway-cli's large Go binary, but still produced a
correct package (verified: exactly `usr/bin/scw` plus standard
metadata) — looks like an environment quirk in this sandbox's
fakeroot, not something pkgwatch caused; revisit if a real build
ever actually fails on it.
- [ ] Not yet implemented: `pkgwatch review <name> --reject` (a pending
review can only be approved or left pending, not dismissed),
per-package `check_interval` (the timer is a fixed hourly tick),
sources other than GitHub and Forgejo releases, `minisign`/tier-1
method, retention/pruning of old versions in the local repo (see
Scaling > Local repo retention), staggering/auth for GitHub API
rate limits at higher package counts.
- [ ] Refine config schema further (see open questions above).
- [ ] Decide version-check strategy for non-GitHub sources: shell out to - [ ] Decide version-check strategy for non-GitHub sources: shell out to
`nvchecker` vs. own implementation. `nvchecker` vs. own implementation.
- [ ] Implement PKGBUILD generation with strict upstream-string validation
from day one (see Builder, above) — cheap to do right up front,
expensive to retrofit.
- [ ] Next PoC iteration: carry the verified `uv` artifact through
build → sanity-check → `repo-add` publish, closing the loop to an
actual local pacman repo `pacman -Syu` can pick up.
- [ ] Decide on project home: local-only for now, or push to - [ ] Decide on project home: local-only for now, or push to
code.austinschaefer.com (Forgejo) once the spec settles. code.austinschaefer.com (Forgejo) once the spec settles.

View file

@ -16,11 +16,35 @@
# no `{version}` placeholder is needed, same as uv's config. Tracking the # no `{version}` placeholder is needed, same as uv's config. Tracking the
# glibc x86_64 Linux build (`claude-linux-x64.tar.gz`), not the musl # glibc x86_64 Linux build (`claude-linux-x64.tar.gz`), not the musl
# variant, to match this machine. # variant, to match this machine.
#
# Archive shape doesn't match uv's or scaleway-cli's: the tarball extracts a
# bare `claude` file with no wrapping directory (confirmed via `tar tzvf`
# against the real v2.1.276 asset) — hence archive_binary_path below (see
# builder.rs's third shape). binary_name is also set explicitly to `claude`
# (the real upstream command name, not the `claude-code` package name) so
# the installed binary matches what this box already invokes as `claude`
# (see /opt/claude-code/bin/claude).
[package.claude-code] [package.claude-code]
repo = "anthropics/claude-code" repo = "anthropics/claude-code"
asset_pattern = "claude-linux-x64.tar.gz" asset_pattern = "claude-linux-x64.tar.gz"
binary_name = "claude"
archive_binary_path = "claude"
[package.claude-code.verification] [package.claude-code.verification]
method = "same-origin-sha256" method = "same-origin-sha256"
checksum_asset_pattern = "SHASUMS256.txt" checksum_asset_pattern = "SHASUMS256.txt"
[package.claude-code.sanity_check]
command = "claude --version"
version_regex = '(\d+\.\d+\.\d+) \(Claude Code\)'
# The previously-installed AUR package (claude-code 2.1.273-1) shipped these
# via a /usr/bin/claude wrapper around the real /opt/claude-code/bin/claude
# binary — almost certainly to stop Claude Code's own self-updater from
# fighting with a package manager already managing it, which applies just
# as much here. builder.rs replicates that wrapper when `env` is set: real
# binary under /usr/lib/claude-code/, generated /usr/bin/claude wrapper.
[package.claude-code.env]
DISABLE_UPDATES = "1"
DISABLE_INSTALLATION_CHECKS = "1"

View file

@ -7,11 +7,22 @@
# Releases ship one combined `SHA256SUMS` file (one line per platform # Releases ship one combined `SHA256SUMS` file (one line per platform
# asset) rather than a per-asset checksum file like uv's — verifier # asset) rather than a per-asset checksum file like uv's — verifier
# matches the line by filename. # matches the line by filename.
#
# binary_name = "scw": confirmed by checking the currently-installed
# `extra` package (`pacman -Ql scaleway-cli`) — the pacman package is
# named scaleway-cli but the actual binary it installs is `scw`. Without
# this, pkgwatch's build would install as /usr/bin/scaleway-cli, which
# would NOT shadow extra's /usr/bin/scw at all.
[package.scaleway-cli] [package.scaleway-cli]
repo = "scaleway/scaleway-cli" repo = "scaleway/scaleway-cli"
asset_pattern = "scaleway-cli_{version}_linux_amd64" asset_pattern = "scaleway-cli_{version}_linux_amd64"
binary_name = "scw"
[package.scaleway-cli.verification] [package.scaleway-cli.verification]
method = "same-origin-sha256" method = "same-origin-sha256"
checksum_asset_pattern = "SHA256SUMS" checksum_asset_pattern = "SHA256SUMS"
[package.scaleway-cli.sanity_check]
command = "scw version"
version_regex = 'Version\s+(\d+\.\d+\.\d+)'

View file

@ -10,3 +10,7 @@ asset_pattern = "uv-x86_64-unknown-linux-gnu.tar.gz"
[package.uv.verification] [package.uv.verification]
method = "github-attestation" method = "github-attestation"
[package.uv.sanity_check]
command = "uv --version"
version_regex = 'uv (\d+\.\d+\.\d+)'

739
src/builder.rs Normal file
View file

@ -0,0 +1,739 @@
//! Turns an already-downloaded, already-verified artifact into a built
//! pacman package: generates a PKGBUILD, then runs `makepkg`. Hides all
//! PKGBUILD templating and upstream-string validation behind `build()`.
use crate::config::Package;
use crate::hash;
use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
use std::process::Command;
/// Archive extensions `makepkg` auto-extracts before `package()` runs.
/// Longest-first so `.tar.gz` isn't shadowed by a hypothetical `.gz` entry.
const ARCHIVE_EXTENSIONS: &[&str] = &[".tar.gz", ".tar.xz", ".tar.zst", ".tar.bz2", ".tgz", ".zip"];
/// Everything needed to generate and build a PKGBUILD for one release.
pub struct BuildRequest<'a> {
pub pkg_name: &'a str,
pub pkg: &'a Package,
pub version: &'a str,
pub repo: &'a str,
pub asset_name: &'a str,
pub download_url: &'a str,
pub artifact_path: &'a Path,
}
pub struct BuildResult {
/// The built `.pkg.tar.zst`, ready for `publisher::publish`.
pub package_path: PathBuf,
/// `makepkg`'s package staging directory (`$pkgdir`), still present
/// after a successful build — lets `sanity` exercise the freshly built
/// binary without installing it system-wide first.
pub pkgdir: PathBuf,
}
/// Generates a PKGBUILD around an already-downloaded, already-verified
/// artifact, then runs `makepkg` in `build_dir`.
///
/// Deliberately one fixed "prebuilt binary" shape, not a templating engine
/// — see docs/SPEC.md > Scaling > Template reuse. Covers the shapes
/// currently-tracked packages actually need: a bare-binary download
/// (scaleway-cli), a tarball containing a same-named directory (uv), and a
/// tarball with no wrapping directory at all whose inner filename doesn't
/// match the package name (claude-code — see `Package::archive_binary_path`).
/// Extend when a fourth real shape shows up rather than guessing at
/// generality now.
pub fn build(req: &BuildRequest, build_dir: &Path) -> Result<BuildResult> {
let pkgbuild = generate_pkgbuild(req)?;
std::fs::create_dir_all(build_dir)
.with_context(|| format!("creating build dir {}", build_dir.display()))?;
std::fs::write(build_dir.join("PKGBUILD"), pkgbuild)?;
// makepkg looks for the source file by its declared name next to
// PKGBUILD; pre-seed it with the copy pkgwatch already downloaded and
// verified so makepkg's own sha256 check passes without re-fetching
// from the network (and without trusting the network a second time).
std::fs::copy(req.artifact_path, build_dir.join(req.asset_name))?;
let status = Command::new("makepkg")
.args(["--noconfirm", "--force"])
.current_dir(build_dir)
.status()
.context("running makepkg (is base-devel installed?)")?;
if !status.success() {
bail!("makepkg failed for {} {}", req.pkg_name, req.version);
}
let package_path = find_built_package(build_dir, req.pkg_name, req.version)?;
let pkgdir = build_dir.join("pkg").join(req.pkg_name);
Ok(BuildResult {
package_path,
pkgdir,
})
}
/// Builds the PKGBUILD text for `req`, validating every upstream-controlled
/// string first (see docs/SPEC.md > Architecture > Builder: "strict validation
/// on any upstream-controlled string ... never unescaped interpolation").
/// Pure and side-effect-free so it's testable without invoking `makepkg`.
fn generate_pkgbuild(req: &BuildRequest) -> Result<String> {
validate_pkgname(req.pkg_name)?;
validate_pkgver(req.version)?;
validate_shell_safe("asset name", req.asset_name)?;
validate_shell_safe("download url", req.download_url)?;
validate_shell_safe("repo", req.repo)?;
let binary_name = req.pkg.binary_name(req.pkg_name);
validate_pkgname(binary_name)?;
let sha256 = hash::sha256_hex_file(req.artifact_path)?;
let install_source = if let Some(path) = &req.pkg.archive_binary_path {
validate_shell_safe("archive binary path", path)?;
path.clone()
} else {
match archive_stem(req.asset_name) {
Some(stem) => format!("{stem}/{binary_name}"),
None => req.asset_name.to_string(),
}
};
let package_body = package_body(
req.pkg_name,
binary_name,
&install_source,
req.pkg.env.as_ref(),
)?;
Ok(format!(
"# Maintainer: pkgwatch (auto-generated — do not edit by hand,\n\
# edits are overwritten on the next update)\n\
pkgname='{name}'\n\
pkgver='{version}'\n\
pkgrel=1\n\
pkgdesc='{repo} release {version}, packaged by pkgwatch'\n\
arch=('x86_64')\n\
url='https://github.com/{repo}'\n\
license=('unknown')\n\
options=('!strip')\n\
source=('{asset}::{url}')\n\
sha256sums=('{sha256}')\n\
\n\
package() {{\n\
{package_body}\
}}\n",
name = req.pkg_name,
version = req.version,
repo = req.repo,
asset = req.asset_name,
url = req.download_url,
))
}
/// Builds the `package()` function body: a plain single-file install, or —
/// when `env` declares variables to export — the real binary installed
/// under `/usr/lib/<pkgname>/` plus a generated `/usr/bin/<binary_name>`
/// wrapper that exports them before `exec`-ing it (see
/// `Package::env`'s doc comment for why this exists).
///
/// The wrapper locates its sibling binary via `$(dirname "$0")` rather
/// than a hardcoded absolute path: on this box `/bin/sh` is `bash`, which
/// sets `$0` to the full resolved path when a script is found via `PATH`
/// (confirmed empirically) — so the same relative lookup resolves
/// correctly both under `sanity.rs`'s staging-directory `pkgdir` check and
/// after a real `pacman` install, with no need to special-case either.
fn package_body(
pkg_name: &str,
binary_name: &str,
install_source: &str,
env: Option<&std::collections::BTreeMap<String, String>>,
) -> Result<String> {
match env.filter(|e| !e.is_empty()) {
None => Ok(format!(
"\x20 install -Dm755 \"${{srcdir}}/{install_source}\" \"${{pkgdir}}/usr/bin/{binary_name}\"\n"
)),
Some(env) => {
let mut exports = String::new();
for (key, value) in env {
validate_env_key(key)?;
validate_single_quoted_safe("env value", value)?;
exports.push_str(&format!("export {key}='{value}'\n"));
}
Ok(format!(
"\x20 install -Dm755 \"${{srcdir}}/{install_source}\" \"${{pkgdir}}/usr/lib/{pkg_name}/{binary_name}\"\n\
\x20 install -Dm755 /dev/stdin \"${{pkgdir}}/usr/bin/{binary_name}\" <<'PKGWATCH_WRAPPER'\n\
#!/bin/sh\n\
{exports}\
exec \"$(dirname \"$0\")/../lib/{pkg_name}/{binary_name}\" \"$@\"\n\
PKGWATCH_WRAPPER\n"
))
}
}
}
/// Matches `<prefix><anything>.pkg.tar.<compression>` — not hardcoded to
/// `.zst` specifically, since `PKGEXT` in makepkg.conf can be set to any
/// of pacman's supported compressions (`.xz`, `.gz`, `.bz2`, ...). This
/// box's default happens to be `.zst`, but guessing wrong would otherwise
/// report a false "makepkg failed" for a build that actually succeeded.
fn find_built_package(build_dir: &Path, pkg_name: &str, version: &str) -> Result<PathBuf> {
let prefix = format!("{pkg_name}-{version}-");
for entry in std::fs::read_dir(build_dir)? {
let path = entry?.path();
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if file_name.starts_with(&prefix) && file_name.contains(".pkg.tar.") {
return Ok(path);
}
}
bail!(
"makepkg reported success but no {prefix}*.pkg.tar.* found in {}",
build_dir.display()
)
}
/// Strips a recognized archive extension, returning the resulting stem —
/// the directory name `makepkg` extracts a same-named tarball into, by
/// the convention every currently-tracked tarball-shaped package follows.
/// `None` means the asset is a bare binary download (no extraction).
fn archive_stem(asset_name: &str) -> Option<&str> {
ARCHIVE_EXTENSIONS
.iter()
.find_map(|ext| asset_name.strip_suffix(ext))
}
/// Rejects characters that are dangerous in *either* quoting style the
/// PKGBUILD template uses: a single quote breaks out of the single-quoted
/// fields (`pkgname`, `sha256sums`, ...); `$`, a backtick, or a backslash
/// are still live inside the double-quoted `install()` line, where
/// `asset_name` (via `install_source`) and `binary_name` end up embedded
/// so `${srcdir}`/`${pkgdir}` can expand. A single check covering both
/// contexts is safer than trying to remember which fields land in which
/// quoting style. See docs/SPEC.md > Architecture > Builder ("never unescaped
/// interpolation").
fn validate_shell_safe(field: &str, value: &str) -> Result<()> {
if value.contains(['\'', '\n', '$', '`', '\\']) {
bail!("{field} '{value}' contains an unsafe character for a generated PKGBUILD");
}
Ok(())
}
/// A wrapper-script env var name must be a valid POSIX shell identifier —
/// this alone also rules out every shell metacharacter, so `export
/// {key}=...` in the generated wrapper can never be anything but a plain
/// assignment.
fn validate_env_key(key: &str) -> Result<()> {
let valid = !key.is_empty()
&& key.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
&& key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if !valid {
bail!("'{key}' is not a valid environment variable name");
}
Ok(())
}
/// Rejects only what can break out of a *single*-quoted shell string: a
/// literal `'` (ends the quoting early) or a newline (injects an extra
/// statement into the wrapper). Unlike `validate_shell_safe`, `$`/backtick/
/// backslash are fine here — single quotes make them inert, and the
/// generated wrapper only ever embeds `value` inside `export key='value'`.
fn validate_single_quoted_safe(field: &str, value: &str) -> Result<()> {
if value.contains(['\'', '\n']) {
bail!("{field} '{value}' contains an unsafe character for a generated PKGBUILD");
}
Ok(())
}
/// A pacman `pkgver` may only contain alphanumerics, `.`, `_`, `+` — no
/// hyphens (pacman reserves `-` as the pkgver/pkgrel separator in the
/// final package filename) and no shell metacharacters.
fn validate_pkgver(version: &str) -> Result<()> {
let valid = !version.is_empty()
&& version
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+'));
if !valid {
bail!("'{version}' is not a valid pacman pkgver (only [A-Za-z0-9._+] allowed)");
}
Ok(())
}
/// A pacman package/binary name may only contain lowercase alphanumerics
/// plus `@ . _ + -`.
fn validate_pkgname(name: &str) -> Result<()> {
let valid = !name.is_empty()
&& name.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '@' | '.' | '_' | '+' | '-')
});
if !valid {
bail!("'{name}' is not a valid pacman package name");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn archive_stem_strips_known_extensions() {
assert_eq!(
archive_stem("uv-x86_64-unknown-linux-gnu.tar.gz"),
Some("uv-x86_64-unknown-linux-gnu")
);
assert_eq!(archive_stem("thing.zip"), Some("thing"));
}
#[test]
fn archive_stem_none_for_bare_binary() {
assert_eq!(archive_stem("scaleway-cli_2.62.0_linux_amd64"), None);
}
#[test]
fn find_built_package_matches_default_zst_extension() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("uv-0.12.15-1-x86_64.pkg.tar.zst"), b"").unwrap();
let found = find_built_package(dir.path(), "uv", "0.12.15").unwrap();
assert_eq!(found, dir.path().join("uv-0.12.15-1-x86_64.pkg.tar.zst"));
}
#[test]
fn find_built_package_matches_non_default_pkgext() {
// A box with PKGEXT='.pkg.tar.xz' in makepkg.conf shouldn't report
// a false failure just because this crate's default assumption
// (.zst) doesn't match.
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("uv-0.12.15-1-x86_64.pkg.tar.xz"), b"").unwrap();
let found = find_built_package(dir.path(), "uv", "0.12.15").unwrap();
assert_eq!(found, dir.path().join("uv-0.12.15-1-x86_64.pkg.tar.xz"));
}
#[test]
fn find_built_package_ignores_non_matching_prefix() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("other-0.12.15-1-x86_64.pkg.tar.zst"), b"").unwrap();
assert!(find_built_package(dir.path(), "uv", "0.12.15").is_err());
}
#[test]
fn find_built_package_errors_with_clear_message_when_nothing_matches() {
let dir = tempfile::tempdir().unwrap();
let err = find_built_package(dir.path(), "uv", "0.12.15").unwrap_err();
assert!(err.to_string().contains("uv-0.12.15-"));
assert!(err.to_string().contains(".pkg.tar.*"));
}
#[test]
fn validate_pkgver_accepts_dotted_version() {
assert!(validate_pkgver("2.62.0").is_ok());
}
#[test]
fn validate_pkgver_rejects_hyphen() {
assert!(validate_pkgver("2.62.0-dbg1").is_err());
}
#[test]
fn validate_pkgver_rejects_shell_metacharacters() {
assert!(validate_pkgver("2.62.0; rm -rf /").is_err());
}
#[test]
fn validate_pkgver_rejects_empty() {
assert!(validate_pkgver("").is_err());
}
#[test]
fn validate_pkgname_accepts_hyphenated_name() {
assert!(validate_pkgname("scaleway-cli").is_ok());
}
#[test]
fn validate_pkgname_rejects_uppercase() {
assert!(validate_pkgname("Scaleway-CLI").is_err());
}
#[test]
fn validate_shell_safe_rejects_single_quote() {
assert!(validate_shell_safe("asset name", "thing'; touch pwned #.tar.gz").is_err());
}
#[test]
fn validate_shell_safe_rejects_newline() {
assert!(validate_shell_safe("download url", "https://example.com/a\nb").is_err());
}
#[test]
fn validate_shell_safe_rejects_dollar_sign() {
// asset_name lands inside a double-quoted string via
// install_source — $() command substitution is still live there
// even though single-quote breakout isn't.
assert!(validate_shell_safe("asset name", "thing$(touch pwned).tar.gz").is_err());
}
#[test]
fn validate_shell_safe_rejects_backtick() {
assert!(validate_shell_safe("asset name", "thing`touch pwned`.tar.gz").is_err());
}
#[test]
fn validate_shell_safe_rejects_backslash() {
assert!(validate_shell_safe("asset name", "thing\\$(touch pwned).tar.gz").is_err());
}
#[test]
fn validate_shell_safe_accepts_normal_url() {
assert!(validate_shell_safe("download url", "https://example.com/a/b.tar.gz").is_ok());
}
fn make_package(binary_name: Option<&str>) -> Package {
let toml_text = match binary_name {
Some(bin) => format!(
r#"
repo = "o/r"
asset_pattern = "x"
binary_name = "{bin}"
[verification]
method = "github-attestation"
"#
),
None => r#"
repo = "o/r"
asset_pattern = "x"
[verification]
method = "github-attestation"
"#
.to_string(),
};
toml::from_str(&toml_text).unwrap()
}
fn make_package_with_archive_binary_path(
binary_name: &str,
archive_binary_path: &str,
) -> Package {
let toml_text = format!(
r#"
repo = "o/r"
asset_pattern = "x"
binary_name = "{binary_name}"
archive_binary_path = "{archive_binary_path}"
[verification]
method = "github-attestation"
"#
);
toml::from_str(&toml_text).unwrap()
}
fn make_package_with_env(entries: &[(&str, &str)]) -> Package {
let env_lines: String = entries
.iter()
.map(|(k, v)| format!("{k} = \"{v}\"\n"))
.collect();
let toml_text = format!(
r#"
repo = "o/r"
asset_pattern = "x"
[verification]
method = "github-attestation"
[env]
{env_lines}
"#
);
toml::from_str(&toml_text).unwrap()
}
#[test]
fn build_rejects_unsafe_version() {
let pkg = make_package(None);
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("thing.tar.gz");
std::fs::write(&artifact_path, b"data").unwrap();
let req = BuildRequest {
pkg_name: "thing",
pkg: &pkg,
version: "1.0.0-dbg1",
repo: "o/r",
asset_name: "thing.tar.gz",
download_url: "https://example.com/thing.tar.gz",
artifact_path: &artifact_path,
};
let build_dir = dir.path().join("build");
assert!(build(&req, &build_dir).is_err());
}
#[test]
fn generate_pkgbuild_bare_binary_installs_under_binary_name_override() {
let pkg = make_package(Some("scw"));
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("scaleway-cli_2.62.0_linux_amd64");
std::fs::write(&artifact_path, b"binary-bytes").unwrap();
let expected_sha = hash::sha256_hex(b"binary-bytes");
let req = BuildRequest {
pkg_name: "scaleway-cli",
pkg: &pkg,
version: "2.62.0",
repo: "scaleway/scaleway-cli",
asset_name: "scaleway-cli_2.62.0_linux_amd64",
download_url: "https://github.com/scaleway/scaleway-cli/releases/download/v2.62.0/scaleway-cli_2.62.0_linux_amd64",
artifact_path: &artifact_path,
};
let pkgbuild = generate_pkgbuild(&req).unwrap();
assert!(pkgbuild.contains("pkgname='scaleway-cli'"));
assert!(pkgbuild.contains("pkgver='2.62.0'"));
assert!(pkgbuild.contains(&format!("sha256sums=('{expected_sha}')")));
// Bare binary (no archive extension) — installed straight from
// srcdir under the overridden binary name, not the pkgname.
assert!(pkgbuild.contains(
"install -Dm755 \"${srcdir}/scaleway-cli_2.62.0_linux_amd64\" \"${pkgdir}/usr/bin/scw\""
));
}
#[test]
fn generate_pkgbuild_tarball_installs_from_extracted_stem_dir() {
let pkg = make_package(None);
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("uv-x86_64-unknown-linux-gnu.tar.gz");
std::fs::write(&artifact_path, b"tarball-bytes").unwrap();
let req = BuildRequest {
pkg_name: "uv",
pkg: &pkg,
version: "0.12.15",
repo: "astral-sh/uv",
asset_name: "uv-x86_64-unknown-linux-gnu.tar.gz",
download_url: "https://github.com/astral-sh/uv/releases/download/0.12.15/uv-x86_64-unknown-linux-gnu.tar.gz",
artifact_path: &artifact_path,
};
let pkgbuild = generate_pkgbuild(&req).unwrap();
// No binary_name override — pkgname doubles as the binary name,
// and makepkg extracts the tarball into a same-named directory.
assert!(pkgbuild.contains(
"install -Dm755 \"${srcdir}/uv-x86_64-unknown-linux-gnu/uv\" \"${pkgdir}/usr/bin/uv\""
));
}
#[test]
fn generate_pkgbuild_flat_archive_installs_from_archive_binary_path_override() {
// claude-code's shape: a tarball with no wrapping directory, whose
// inner filename ("claude") doesn't match the package name
// ("claude-code") — neither existing shape (stem/binary_name, or
// bare-binary-no-archive) fits, hence the explicit override.
let pkg = make_package_with_archive_binary_path("claude-code", "claude");
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("claude-linux-x64.tar.gz");
std::fs::write(&artifact_path, b"tarball-bytes").unwrap();
let req = BuildRequest {
pkg_name: "claude-code",
pkg: &pkg,
version: "2.1.276",
repo: "anthropics/claude-code",
asset_name: "claude-linux-x64.tar.gz",
download_url: "https://github.com/anthropics/claude-code/releases/download/v2.1.276/claude-linux-x64.tar.gz",
artifact_path: &artifact_path,
};
let pkgbuild = generate_pkgbuild(&req).unwrap();
assert!(
pkgbuild
.contains("install -Dm755 \"${srcdir}/claude\" \"${pkgdir}/usr/bin/claude-code\"")
);
}
#[test]
fn generate_pkgbuild_rejects_archive_binary_path_with_command_substitution() {
let pkg = make_package_with_archive_binary_path("claude-code", "claude$(touch pwned)");
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("claude-linux-x64.tar.gz");
std::fs::write(&artifact_path, b"data").unwrap();
let req = BuildRequest {
pkg_name: "claude-code",
pkg: &pkg,
version: "2.1.276",
repo: "anthropics/claude-code",
asset_name: "claude-linux-x64.tar.gz",
download_url: "https://github.com/anthropics/claude-code/releases/download/v2.1.276/claude-linux-x64.tar.gz",
artifact_path: &artifact_path,
};
assert!(generate_pkgbuild(&req).is_err());
}
#[test]
fn generate_pkgbuild_with_env_installs_via_lib_and_wrapper() {
let pkg = make_package_with_env(&[
("DISABLE_UPDATES", "1"),
("DISABLE_INSTALLATION_CHECKS", "1"),
]);
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("thing.tar.gz");
std::fs::write(&artifact_path, b"data").unwrap();
let req = BuildRequest {
pkg_name: "thing",
pkg: &pkg,
version: "1.0.0",
repo: "o/r",
asset_name: "thing.tar.gz",
download_url: "https://example.com/thing.tar.gz",
artifact_path: &artifact_path,
};
let pkgbuild = generate_pkgbuild(&req).unwrap();
// Real binary goes under /usr/lib/<pkgname>/, not /usr/bin directly.
assert!(pkgbuild.contains(
"install -Dm755 \"${srcdir}/thing/thing\" \"${pkgdir}/usr/lib/thing/thing\""
));
// Wrapper written inline via a quoted heredoc (no bash expansion at
// PKGBUILD-build time) to /usr/bin/<binary_name>.
assert!(pkgbuild.contains(
"install -Dm755 /dev/stdin \"${pkgdir}/usr/bin/thing\" <<'PKGWATCH_WRAPPER'"
));
assert!(pkgbuild.contains("export DISABLE_INSTALLATION_CHECKS='1'"));
assert!(pkgbuild.contains("export DISABLE_UPDATES='1'"));
// Sorted (BTreeMap) — deterministic regardless of TOML source order.
let checks_pos = pkgbuild.find("DISABLE_INSTALLATION_CHECKS").unwrap();
let updates_pos = pkgbuild.find("DISABLE_UPDATES").unwrap();
assert!(checks_pos < updates_pos);
// Relative $0-based lookup, not a hardcoded absolute path — see
// package_body's doc comment for why.
assert!(pkgbuild.contains(r#"exec "$(dirname "$0")/../lib/thing/thing" "$@""#));
}
#[test]
fn generate_pkgbuild_without_env_keeps_single_file_install() {
// Regression guard: packages with no `env` table must keep the
// original one-line install, not gain a wrapper/lib split.
let pkg = make_package(None);
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("thing.tar.gz");
std::fs::write(&artifact_path, b"data").unwrap();
let req = BuildRequest {
pkg_name: "thing",
pkg: &pkg,
version: "1.0.0",
repo: "o/r",
asset_name: "thing.tar.gz",
download_url: "https://example.com/thing.tar.gz",
artifact_path: &artifact_path,
};
let pkgbuild = generate_pkgbuild(&req).unwrap();
assert!(!pkgbuild.contains("PKGWATCH_WRAPPER"));
assert!(!pkgbuild.contains("/usr/lib/"));
}
#[test]
fn generate_pkgbuild_rejects_env_value_with_single_quote() {
let pkg = make_package_with_env(&[("FOO", "bar'; touch pwned #")]);
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("thing.tar.gz");
std::fs::write(&artifact_path, b"data").unwrap();
let req = BuildRequest {
pkg_name: "thing",
pkg: &pkg,
version: "1.0.0",
repo: "o/r",
asset_name: "thing.tar.gz",
download_url: "https://example.com/thing.tar.gz",
artifact_path: &artifact_path,
};
assert!(generate_pkgbuild(&req).is_err());
}
#[test]
fn generate_pkgbuild_rejects_invalid_env_key() {
let pkg = make_package_with_env(&[("1BAD-KEY", "value")]);
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("thing.tar.gz");
std::fs::write(&artifact_path, b"data").unwrap();
let req = BuildRequest {
pkg_name: "thing",
pkg: &pkg,
version: "1.0.0",
repo: "o/r",
asset_name: "thing.tar.gz",
download_url: "https://example.com/thing.tar.gz",
artifact_path: &artifact_path,
};
assert!(generate_pkgbuild(&req).is_err());
}
#[test]
fn validate_env_key_accepts_underscore_and_digits_after_first_char() {
assert!(validate_env_key("DISABLE_UPDATES_2").is_ok());
assert!(validate_env_key("_private").is_ok());
}
#[test]
fn validate_env_key_rejects_leading_digit() {
assert!(validate_env_key("1KEY").is_err());
}
#[test]
fn validate_env_key_rejects_hyphen() {
assert!(validate_env_key("MY-KEY").is_err());
}
#[test]
fn validate_single_quoted_safe_accepts_dollar_and_backtick() {
// Inert inside single quotes, unlike validate_shell_safe's context.
assert!(validate_single_quoted_safe("env value", "$(touch pwned)").is_ok());
assert!(validate_single_quoted_safe("env value", "`touch pwned`").is_ok());
}
#[test]
fn validate_single_quoted_safe_rejects_single_quote() {
assert!(validate_single_quoted_safe("env value", "it's").is_err());
}
#[test]
fn generate_pkgbuild_rejects_download_url_with_single_quote() {
let pkg = make_package(None);
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("thing.tar.gz");
std::fs::write(&artifact_path, b"data").unwrap();
let req = BuildRequest {
pkg_name: "thing",
pkg: &pkg,
version: "1.0.0",
repo: "o/r",
asset_name: "thing.tar.gz",
download_url: "https://example.com/x'; touch pwned #.tar.gz",
artifact_path: &artifact_path,
};
assert!(generate_pkgbuild(&req).is_err());
}
#[test]
fn generate_pkgbuild_rejects_asset_name_with_command_substitution() {
// Regression test: asset_name feeds install_source, which is
// embedded in the double-quoted install() line, not a
// single-quoted field — a single-quote-only check would miss this.
let pkg = make_package(None);
let dir = tempfile::tempdir().unwrap();
let artifact_path = dir.path().join("thing.tar.gz");
std::fs::write(&artifact_path, b"data").unwrap();
let req = BuildRequest {
pkg_name: "thing",
pkg: &pkg,
version: "1.0.0",
repo: "o/r",
asset_name: "thing$(touch pwned).tar.gz",
download_url: "https://example.com/thing.tar.gz",
artifact_path: &artifact_path,
};
assert!(generate_pkgbuild(&req).is_err());
}
}

View file

@ -1,45 +1,6 @@
use crate::github::GithubEndpoints; //! Turns a release tag into a version string. What the latest tag *is*
use anyhow::{Result, bail}; //! comes from a `ReleaseSource` (see `release_source`); this is the one piece of
use regex::Regex; //! the check stage that isn't host-specific.
/// Resolves the latest release tag for `repo` via its public Atom feed.
///
/// Deliberately not a full XML parse: the feed lists entries newest-first,
/// and each `<link rel="alternate" .../releases/tag/<tag>"/>` is matched
/// in document order. Revisit with a real XML parser if GitHub's feed
/// shape ever changes.
///
/// The feed can list a tag newer than any tag with a real Release object
/// behind it — observed on scaleway/scaleway-cli, which pushes a
/// `vX.Y.Z-dbg1` tag (no corresponding Release; `releases/tags/<tag>`
/// 404s) right after each real release, and that tag sorts newest in the
/// feed. So each candidate is confirmed against the releases API in feed
/// order, returning the first that actually resolves.
pub fn latest_github_release(
client: &reqwest::blocking::Client,
endpoints: &GithubEndpoints,
repo: &str,
) -> Result<String> {
let url = format!("{}/{repo}/releases.atom", endpoints.web);
let body = client.get(&url).send()?.error_for_status()?.text()?;
let re = Regex::new(r#"releases/tag/([^"]+)""#)?;
let mut candidates = re
.captures_iter(&body)
.map(|caps| caps[1].to_string())
.peekable();
if candidates.peek().is_none() {
bail!("no release tag found in {url}");
}
for tag in candidates {
let release_url = format!("{}/repos/{repo}/releases/tags/{tag}", endpoints.api);
if client.get(&release_url).send()?.status().is_success() {
return Ok(tag);
}
}
bail!("no release tag in {url} resolved to a real release via the API")
}
/// Strips a leading `v` from a release tag, e.g. `v2.62.0` -> `2.62.0`. /// Strips a leading `v` from a release tag, e.g. `v2.62.0` -> `2.62.0`.
/// ///
@ -65,84 +26,4 @@ mod tests {
fn version_from_tag_leaves_bare_version_unchanged() { fn version_from_tag_leaves_bare_version_unchanged() {
assert_eq!(version_from_tag("0.12.15"), "0.12.15"); assert_eq!(version_from_tag("0.12.15"), "0.12.15");
} }
fn atom_feed(tags: &[&str]) -> String {
let entries: String = tags
.iter()
.map(|t| {
format!(r#"<link rel="alternate" href="https://github.com/o/r/releases/tag/{t}"/>"#)
})
.collect();
format!("<feed>{entries}</feed>")
}
#[test]
fn latest_github_release_skips_tags_with_no_real_release() {
let mut server = mockito::Server::new();
let endpoints = GithubEndpoints {
web: server.url(),
api: server.url(),
};
// Mirrors the real scaleway-cli case: newest feed entry (a -dbg1
// tag) has no Release object behind it and 404s.
let _feed = server
.mock("GET", "/o/r/releases.atom")
.with_status(200)
.with_body(atom_feed(&["v2.62.0-dbg1", "v2.62.0"]))
.create();
let _missing = server
.mock("GET", "/repos/o/r/releases/tags/v2.62.0-dbg1")
.with_status(404)
.create();
let _real = server
.mock("GET", "/repos/o/r/releases/tags/v2.62.0")
.with_status(200)
.with_body("{}")
.create();
let client = reqwest::blocking::Client::new();
let tag = latest_github_release(&client, &endpoints, "o/r").unwrap();
assert_eq!(tag, "v2.62.0");
}
#[test]
fn latest_github_release_errors_when_feed_has_no_tags() {
let mut server = mockito::Server::new();
let endpoints = GithubEndpoints {
web: server.url(),
api: server.url(),
};
let _feed = server
.mock("GET", "/o/r/releases.atom")
.with_status(200)
.with_body("<feed></feed>")
.create();
let client = reqwest::blocking::Client::new();
let err = latest_github_release(&client, &endpoints, "o/r").unwrap_err();
assert!(err.to_string().contains("no release tag found"));
}
#[test]
fn latest_github_release_errors_when_no_candidate_resolves() {
let mut server = mockito::Server::new();
let endpoints = GithubEndpoints {
web: server.url(),
api: server.url(),
};
let _feed = server
.mock("GET", "/o/r/releases.atom")
.with_status(200)
.with_body(atom_feed(&["v1.0.0-dbg1"]))
.create();
let _missing = server
.mock("GET", "/repos/o/r/releases/tags/v1.0.0-dbg1")
.with_status(404)
.create();
let client = reqwest::blocking::Client::new();
let err = latest_github_release(&client, &endpoints, "o/r").unwrap_err();
assert!(err.to_string().contains("resolved to a real release"));
}
} }

View file

@ -1,6 +1,10 @@
use anyhow::{Context, Result}; //! Parses `packages.d/*.toml` into typed, in-memory `Package` records.
//! The only module that knows the TOML shape — everything downstream
//! works with `Package`/`Verification`/`SanityCheck`, never raw TOML.
use anyhow::{Context, Result, bail};
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::{BTreeMap, HashMap};
use std::path::Path; use std::path::Path;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@ -8,23 +12,117 @@ struct PackageFile {
package: HashMap<String, Package>, package: HashMap<String, Package>,
} }
/// Where a package's releases are published: the `source` key from
/// docs/SPEC.md > Config schema. Omitted means GitHub, so every existing
/// `packages.d/*.toml` keeps working.
#[derive(Debug, Deserialize, Clone, Copy, Default, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum Source {
#[default]
GithubRelease,
/// A Forgejo (or Gitea) instance; needs `base_url` too.
ForgejoRelease,
}
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct Package { pub struct Package {
/// `owner/name` on whichever `source` hosts it.
pub repo: String, pub repo: String,
/// Exact GitHub release asset name (still not a glob — see #[serde(default)]
/// SPEC.md > Architecture > Fetcher), optionally containing a pub source: Source,
/// Web root of the Forgejo instance, e.g. `https://code.austinschaefer.com`
/// (the API lives under `/api/v1`). Required for, and only meaningful
/// with, `source = "forgejo-release"`.
pub base_url: Option<String>,
/// Exact release asset name (still not a glob — see
/// docs/SPEC.md > Architecture > Fetcher), optionally containing a
/// `{version}` placeholder for projects whose asset names embed the /// `{version}` placeholder for projects whose asset names embed the
/// version (e.g. `scaleway-cli_{version}_linux_amd64`). Substituted via /// version (e.g. `scaleway-cli_{version}_linux_amd64`). Substituted via
/// `checker::version_from_tag` before matching. /// `checker::version_from_tag` before matching.
pub asset_pattern: String, pub asset_pattern: String,
pub verification: Verification, pub verification: Verification,
/// Name of the executable inside the built package, if it differs from
/// the package name itself — e.g. scaleway-cli's pacman package is
/// named `scaleway-cli` but its real binary is `scw` (discovered by
/// checking the currently-installed extra package, not guessable from
/// the repo name). Defaults to the package name when omitted.
pub binary_name: Option<String>,
/// Explicit path to the binary inside the extracted archive, relative
/// to `srcdir`, for archive layouts that don't match the "extracts into
/// a directory named after the archive stem" convention `builder.rs`
/// otherwise assumes (e.g. claude-code's tarball extracts a bare
/// `claude` file with no wrapping directory, and that inner filename
/// doesn't match the package name either). Only meaningful when
/// `asset_pattern` names an archive; ignored for bare-binary downloads,
/// where the downloaded file *is* the source path already. Defaults to
/// the stem/`binary_name` convention when omitted.
pub archive_binary_path: Option<String>,
/// Environment variables to export before the real binary runs, when
/// the vendor's own install ships them via a wrapper script that
/// `builder.rs` would otherwise not replicate — e.g. claude-code's
/// prior AUR package sets `DISABLE_UPDATES=1`/
/// `DISABLE_INSTALLATION_CHECKS=1` specifically so its self-updater
/// doesn't fight with a package manager already managing it, which
/// applies just as much to pkgwatch-managed installs. `BTreeMap` for
/// deterministic (sorted) ordering in the generated PKGBUILD. When
/// present and non-empty, the real binary installs to
/// `/usr/lib/<pkgname>/<binary_name>` instead of `/usr/bin` directly,
/// and a generated `/usr/bin/<binary_name>` wrapper sets these vars
/// before `exec`-ing it. Omitted or empty: no wrapper, same single-file
/// install as before.
pub env: Option<BTreeMap<String, String>>,
/// Post-build correctness check (not a security control — see
/// docs/SPEC.md > Verification trust tiers). Runs `command` against the
/// freshly built binary and confirms `version_regex`'s capture group
/// matches the version pkgwatch believes it just built.
pub sanity_check: Option<SanityCheck>,
}
impl Package {
/// Rejects combinations that can't work, once at load time rather than
/// as a confusing failure deep in a run (see docs/ARCHITECTURE.md >
/// "validate at the boundary, once").
fn validate(&self, name: &str) -> Result<()> {
match (self.source, &self.base_url) {
(Source::GithubRelease, None) => {}
(Source::GithubRelease, Some(_)) => {
// Silently ignoring it would hide a mistyped `source`.
bail!("{name}: base_url only applies to source = \"forgejo-release\"");
}
(Source::ForgejoRelease, None) => {
bail!("{name}: source = \"forgejo-release\" needs a base_url");
}
(Source::ForgejoRelease, Some(url)) => {
if !url.starts_with("https://") && !url.starts_with("http://") {
bail!("{name}: base_url '{url}' must start with http:// or https://");
}
// `gh attestation verify` only speaks GitHub's attestation API.
if matches!(self.verification, Verification::GithubAttestation) {
bail!("{name}: github-attestation verification needs a GitHub source");
}
}
}
Ok(())
}
/// The name of the executable inside the built package: `binary_name`
/// if the package declares one, else `pkg_name` itself.
pub fn binary_name<'a>(&'a self, pkg_name: &'a str) -> &'a str {
self.binary_name.as_deref().unwrap_or(pkg_name)
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct SanityCheck {
pub command: String,
pub version_regex: String,
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
#[serde(tag = "method", rename_all = "kebab-case")] #[serde(tag = "method", rename_all = "kebab-case")]
pub enum Verification { pub enum Verification {
/// Tier 4: proves transport integrity only, not authorship. See /// Tier 4: proves transport integrity only, not authorship. See
/// SPEC.md > Verification trust tiers. `checksum_asset_pattern` may /// docs/SPEC.md > Verification trust tiers. `checksum_asset_pattern` may
/// also contain a `{version}` placeholder, same as `asset_pattern`. /// also contain a `{version}` placeholder, same as `asset_pattern`.
SameOriginSha256 { checksum_asset_pattern: String }, SameOriginSha256 { checksum_asset_pattern: String },
/// Tier 2: GitHub build-provenance attestation, verified via `gh /// Tier 2: GitHub build-provenance attestation, verified via `gh
@ -42,7 +140,7 @@ impl Verification {
} }
/// Loads every `*.toml` file in `dir` (the `packages.d/` layout from /// Loads every `*.toml` file in `dir` (the `packages.d/` layout from
/// SPEC.md > Scaling to many packages), keyed by package name. /// docs/SPEC.md > Scaling to many packages), keyed by package name.
pub fn load_packages_dir(dir: &Path) -> Result<Vec<(String, Package)>> { pub fn load_packages_dir(dir: &Path) -> Result<Vec<(String, Package)>> {
let mut out = Vec::new(); let mut out = Vec::new();
for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? { for entry in std::fs::read_dir(dir).with_context(|| format!("reading {}", dir.display()))? {
@ -54,6 +152,10 @@ pub fn load_packages_dir(dir: &Path) -> Result<Vec<(String, Package)>> {
.with_context(|| format!("reading {}", path.display()))?; .with_context(|| format!("reading {}", path.display()))?;
let file: PackageFile = let file: PackageFile =
toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?; toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
for (name, pkg) in &file.package {
pkg.validate(name)
.with_context(|| format!("in {}", path.display()))?;
}
out.extend(file.package); out.extend(file.package);
} }
Ok(out) Ok(out)
@ -125,6 +227,38 @@ mod tests {
assert_eq!(packages[0].1.verification.tier(), 2); assert_eq!(packages[0].1.verification.tier(), 2);
} }
#[test]
fn loads_env_table_as_sorted_map() {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"pkg.toml",
r#"
[package.pkg]
repo = "o/r"
asset_pattern = "pkg.tar.gz"
[package.pkg.verification]
method = "github-attestation"
[package.pkg.env]
DISABLE_UPDATES = "1"
DISABLE_INSTALLATION_CHECKS = "1"
"#,
);
let packages = load_packages_dir(dir.path()).unwrap();
let env = packages[0].1.env.as_ref().unwrap();
let entries: Vec<(&String, &String)> = env.iter().collect();
assert_eq!(
entries,
vec![
(&"DISABLE_INSTALLATION_CHECKS".to_string(), &"1".to_string()),
(&"DISABLE_UPDATES".to_string(), &"1".to_string()),
]
);
}
#[test] #[test]
fn loads_multiple_files_and_ignores_non_toml() { fn loads_multiple_files_and_ignores_non_toml() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@ -173,4 +307,79 @@ mod tests {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
assert!(load_packages_dir(dir.path()).unwrap().is_empty()); assert!(load_packages_dir(dir.path()).unwrap().is_empty());
} }
/// One `[package.p]` with the given extra top-level lines and
/// verification table, loaded through the real loader so validation
/// runs too.
fn load_one(extra: &str, verification: &str) -> Result<Package> {
let dir = tempfile::tempdir().unwrap();
write(
dir.path(),
"p.toml",
&format!(
"[package.p]\nrepo = \"o/r\"\nasset_pattern = \"x\"\n{extra}\n\
[package.p.verification]\n{verification}\n"
),
);
let mut loaded = load_packages_dir(dir.path())?;
Ok(loaded.remove(0).1)
}
const SAME_ORIGIN: &str = "method = \"same-origin-sha256\"\nchecksum_asset_pattern = \"SUMS\"";
const ATTESTATION: &str = "method = \"github-attestation\"";
const FORGEJO: &str = "source = \"forgejo-release\"\nbase_url = \"https://forge.example.com\"";
#[test]
fn source_defaults_to_github_release() {
let pkg = load_one("", ATTESTATION).unwrap();
assert_eq!(pkg.source, Source::GithubRelease);
assert_eq!(pkg.base_url, None);
}
#[test]
fn loads_explicit_github_release_source() {
let pkg = load_one("source = \"github-release\"", ATTESTATION).unwrap();
assert_eq!(pkg.source, Source::GithubRelease);
}
#[test]
fn loads_forgejo_release_source() {
let pkg = load_one(FORGEJO, SAME_ORIGIN).unwrap();
assert_eq!(pkg.source, Source::ForgejoRelease);
assert_eq!(pkg.base_url.as_deref(), Some("https://forge.example.com"));
}
#[test]
fn rejects_forgejo_release_without_base_url() {
let err = load_one("source = \"forgejo-release\"", SAME_ORIGIN).unwrap_err();
assert!(format!("{err:#}").contains("needs a base_url"));
}
#[test]
fn rejects_base_url_on_a_github_source() {
let err = load_one("base_url = \"https://forge.example.com\"", SAME_ORIGIN).unwrap_err();
assert!(format!("{err:#}").contains("only applies to source"));
}
#[test]
fn rejects_forgejo_base_url_without_scheme() {
let err = load_one(
"source = \"forgejo-release\"\nbase_url = \"forge.example.com\"",
SAME_ORIGIN,
)
.unwrap_err();
assert!(format!("{err:#}").contains("must start with http"));
}
#[test]
fn rejects_github_attestation_on_a_forgejo_source() {
let err = load_one(FORGEJO, ATTESTATION).unwrap_err();
assert!(format!("{err:#}").contains("needs a GitHub source"));
}
#[test]
fn rejects_unknown_source() {
assert!(load_one("source = \"gitlab-release\"", SAME_ORIGIN).is_err());
}
} }

View file

@ -1,4 +1,7 @@
use crate::github::GithubEndpoints; //! Downloads a named release asset (from GitHub or Forgejo) to a local
//! path. The only module that talks to the releases API for asset bytes —
//! `release_source` only resolves version tags, never downloads.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::Deserialize; use serde::Deserialize;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -14,17 +17,29 @@ struct Asset {
browser_download_url: String, browser_download_url: String,
} }
/// A downloaded release asset: its local path plus the URL it came from,
/// the latter needed for the `source=` line of a generated PKGBUILD (see
/// `builder`) — `makepkg` uses it only as a fallback if the pre-seeded
/// local copy ever goes missing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DownloadedAsset {
pub path: PathBuf,
pub download_url: String,
}
/// Downloads the release asset named exactly `asset_name` for `repo`@`tag` /// Downloads the release asset named exactly `asset_name` for `repo`@`tag`
/// into `dest_dir`, returning the local path. /// into `dest_dir`, returning the local path and its origin URL. `api` is
/// the releases API root (see `ReleaseSource::api`); GitHub and Forgejo
/// serve the same endpoint and JSON shape under it.
pub fn download_asset( pub fn download_asset(
client: &reqwest::blocking::Client, client: &reqwest::blocking::Client,
endpoints: &GithubEndpoints, api: &str,
repo: &str, repo: &str,
tag: &str, tag: &str,
asset_name: &str, asset_name: &str,
dest_dir: &Path, dest_dir: &Path,
) -> Result<PathBuf> { ) -> Result<DownloadedAsset> {
let api_url = format!("{}/repos/{repo}/releases/tags/{tag}", endpoints.api); let api_url = format!("{api}/repos/{repo}/releases/tags/{tag}");
let release: Release = client let release: Release = client
.get(&api_url) .get(&api_url)
.send()? .send()?
@ -46,7 +61,10 @@ pub fn download_asset(
.error_for_status()? .error_for_status()?
.bytes()?; .bytes()?;
std::fs::write(&dest_path, &bytes)?; std::fs::write(&dest_path, &bytes)?;
Ok(dest_path) Ok(DownloadedAsset {
path: dest_path,
download_url: asset.browser_download_url.clone(),
})
} }
#[cfg(test)] #[cfg(test)]
@ -56,10 +74,7 @@ mod tests {
#[test] #[test]
fn download_asset_writes_matching_asset_to_dest_dir() { fn download_asset_writes_matching_asset_to_dest_dir() {
let mut server = mockito::Server::new(); let mut server = mockito::Server::new();
let endpoints = GithubEndpoints { let api = server.url();
web: server.url(),
api: server.url(),
};
let asset_url = format!("{}/download/thing.tar.gz", server.url()); let asset_url = format!("{}/download/thing.tar.gz", server.url());
let release_body = format!( let release_body = format!(
r#"{{"assets": [{{"name": "thing.tar.gz", "browser_download_url": "{asset_url}"}}]}}"# r#"{{"assets": [{{"name": "thing.tar.gz", "browser_download_url": "{asset_url}"}}]}}"#
@ -77,9 +92,9 @@ mod tests {
let client = reqwest::blocking::Client::new(); let client = reqwest::blocking::Client::new();
let dest_dir = tempfile::tempdir().unwrap(); let dest_dir = tempfile::tempdir().unwrap();
let path = download_asset( let asset = download_asset(
&client, &client,
&endpoints, &api,
"o/r", "o/r",
"v1.0.0", "v1.0.0",
"thing.tar.gz", "thing.tar.gz",
@ -87,17 +102,15 @@ mod tests {
) )
.unwrap(); .unwrap();
assert_eq!(path, dest_dir.path().join("thing.tar.gz")); assert_eq!(asset.path, dest_dir.path().join("thing.tar.gz"));
assert_eq!(std::fs::read(&path).unwrap(), b"artifact-bytes"); assert_eq!(asset.download_url, asset_url);
assert_eq!(std::fs::read(&asset.path).unwrap(), b"artifact-bytes");
} }
#[test] #[test]
fn download_asset_errors_when_no_asset_matches() { fn download_asset_errors_when_no_asset_matches() {
let mut server = mockito::Server::new(); let mut server = mockito::Server::new();
let endpoints = GithubEndpoints { let api = server.url();
web: server.url(),
api: server.url(),
};
let _release = server let _release = server
.mock("GET", "/repos/o/r/releases/tags/v1.0.0") .mock("GET", "/repos/o/r/releases/tags/v1.0.0")
.with_status(200) .with_status(200)
@ -108,7 +121,7 @@ mod tests {
let dest_dir = tempfile::tempdir().unwrap(); let dest_dir = tempfile::tempdir().unwrap();
let err = download_asset( let err = download_asset(
&client, &client,
&endpoints, &api,
"o/r", "o/r",
"v1.0.0", "v1.0.0",
"thing.tar.gz", "thing.tar.gz",
@ -121,10 +134,7 @@ mod tests {
#[test] #[test]
fn download_asset_errors_when_release_not_found() { fn download_asset_errors_when_release_not_found() {
let mut server = mockito::Server::new(); let mut server = mockito::Server::new();
let endpoints = GithubEndpoints { let api = server.url();
web: server.url(),
api: server.url(),
};
let _release = server let _release = server
.mock("GET", "/repos/o/r/releases/tags/v1.0.0") .mock("GET", "/repos/o/r/releases/tags/v1.0.0")
.with_status(404) .with_status(404)
@ -134,7 +144,7 @@ mod tests {
let dest_dir = tempfile::tempdir().unwrap(); let dest_dir = tempfile::tempdir().unwrap();
let err = download_asset( let err = download_asset(
&client, &client,
&endpoints, &api,
"o/r", "o/r",
"v1.0.0", "v1.0.0",
"thing.tar.gz", "thing.tar.gz",

View file

@ -1,29 +0,0 @@
/// Base URLs for GitHub's public web host (Atom feeds, release pages) and
/// its REST API, factored out so tests can point both at a local mock
/// server instead of the real github.com/api.github.com.
#[derive(Debug, Clone)]
pub struct GithubEndpoints {
pub web: String,
pub api: String,
}
impl Default for GithubEndpoints {
fn default() -> Self {
Self {
web: "https://github.com".to_string(),
api: "https://api.github.com".to_string(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_points_at_real_github() {
let endpoints = GithubEndpoints::default();
assert_eq!(endpoints.web, "https://github.com");
assert_eq!(endpoints.api, "https://api.github.com");
}
}

81
src/hash.rs Normal file
View file

@ -0,0 +1,81 @@
//! Two functions, shared by two real callers (`verifier`, `builder`) —
//! not a general-purpose utils dump. See docs/ARCHITECTURE.md > "organize by
//! pipeline stage, not by layer" for why that distinction matters.
use anyhow::{Context, Result};
use sha2::{Digest, Sha256};
use std::io::Read;
use std::path::Path;
/// In-memory digest — test-only now that both real callers (`verifier`,
/// `builder`) hash a file already on disk via `sha256_hex_file` instead.
/// Kept for building expected hashes from in-memory test fixtures.
#[cfg(test)]
pub(crate) fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
/// Same digest as `sha256_hex(&std::fs::read(path)?)`, but streamed in
/// fixed-size chunks instead of reading the whole file into memory first —
/// downloaded release assets are tens of MB, and the file is already on
/// disk, so there's no reason to hold a second full copy in memory just to
/// hash it.
pub fn sha256_hex_file(path: &Path) -> Result<String> {
let mut file =
std::fs::File::open(path).with_context(|| format!("opening {}", path.display()))?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = file
.read(&mut buf)
.with_context(|| format!("reading {}", path.display()))?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_known_sha256() {
// printf 'hello world' | sha256sum
assert_eq!(
sha256_hex(b"hello world"),
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
#[test]
fn sha256_hex_file_matches_in_memory_digest() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("data.bin");
// Bigger than one read chunk, to actually exercise the loop.
let data = vec![0x5au8; 200 * 1024];
std::fs::write(&path, &data).unwrap();
assert_eq!(sha256_hex_file(&path).unwrap(), sha256_hex(&data));
}
#[test]
fn sha256_hex_file_matches_for_empty_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.bin");
std::fs::write(&path, b"").unwrap();
assert_eq!(sha256_hex_file(&path).unwrap(), sha256_hex(b""));
}
#[test]
fn sha256_hex_file_errors_on_missing_file() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("does-not-exist.bin");
assert!(sha256_hex_file(&missing).is_err());
}
}

View file

@ -1,92 +1,35 @@
//! Entry point: parses `argv` and dispatches to `pipeline`. Nothing here
//! makes a network/subprocess call or contains a decision worth a test —
//! see docs/ARCHITECTURE.md > "main is a dispatcher, not the program."
mod builder;
mod checker; mod checker;
mod config; mod config;
mod fetcher; mod fetcher;
mod github; mod hash;
mod notifier;
mod paths;
mod pipeline;
mod publisher;
mod release_source;
mod sanity;
mod state; mod state;
#[cfg(test)]
mod test_support;
mod verifier; mod verifier;
use anyhow::Result; use anyhow::{Result, bail};
use github::GithubEndpoints;
use std::path::Path;
/// First iteration: check -> fetch -> verify -> report, for whatever is /// check -> fetch -> verify -> build -> sanity-check -> publish, for
/// in packages.d/. No build/publish step yet (see SPEC.md > Status). /// whatever is in packages.d/. Tier 1-3 passes auto-publish; tier 4-6
/// passes queue for `pkgwatch review`. See docs/SPEC.md > Architecture for what
/// each stage does, and docs/ARCHITECTURE.md for how the code implementing it
/// is organized.
fn main() -> Result<()> { fn main() -> Result<()> {
let client = reqwest::blocking::Client::builder() let args: Vec<String> = std::env::args().skip(1).collect();
.user_agent("pkgwatch/0.1 (PoC; https://code.austinschaefer.com)") match args.first().map(String::as_str) {
.build()?; None => pipeline::run_check(),
let endpoints = GithubEndpoints::default(); Some("review") => pipeline::run_review(&args[1..]),
Some(other) => bail!("unknown subcommand '{other}' (expected: review)"),
let packages_dir = Path::new("packages.d");
let state_dir = Path::new("state");
let work_dir = Path::new("work");
let packages = config::load_packages_dir(packages_dir)?;
if packages.is_empty() {
println!("no packages configured under {}/", packages_dir.display());
return Ok(());
}
for (name, pkg) in packages {
println!("== {name} ({}) ==", pkg.repo);
let latest = checker::latest_github_release(&client, &endpoints, &pkg.repo)?;
let last_seen = state::load_last_version(state_dir, &name);
if last_seen.as_deref() == Some(latest.as_str()) {
println!(" up to date at {latest}");
continue;
}
println!(" new version detected: {latest} (previously: {last_seen:?})");
let dest_dir = work_dir.join(&name).join(&latest);
let asset_name = pkg
.asset_pattern
.replace("{version}", checker::version_from_tag(&latest));
let artifact_path = fetcher::download_asset(
&client,
&endpoints,
&pkg.repo,
&latest,
&asset_name,
&dest_dir,
)?;
println!(" fetched {}", artifact_path.display());
let result = verifier::verify(
&client,
&endpoints,
&pkg.verification,
&pkg.repo,
&latest,
&artifact_path,
&dest_dir,
)?;
println!(
" verification (tier {}): {} — {}",
result.tier,
if result.passed { "PASS" } else { "FAIL" },
result.justification
);
match (result.tier, result.passed) {
(1..=3, true) => {
println!(" tier 1-3 pass: would auto-build + publish (not yet implemented)");
state::save_last_version(state_dir, &name, &latest)?;
}
(_, true) => {
println!(" tier 4-6 pass: flagging for human review, not auto-publishing");
println!(
" (review-queue persistence not yet implemented — this is where it plugs in)"
);
}
(_, false) => {
println!(" verification failed — not publishing, not updating state");
} }
} }
}
Ok(())
}

111
src/notifier.rs Normal file
View file

@ -0,0 +1,111 @@
//! Tells the operator, via a desktop notification, that a package needs
//! attention (a tier 4-6 release awaiting review) or just landed in the
//! local repo. Best-effort: a missing `notify-send` or session bus must
//! never fail a run, since the pipeline's real outcome is already in
//! state and stdout.
use std::path::Path;
use std::process::Command;
/// What happened to a package that the operator should hear about.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Event<'a> {
/// Tier 4-6: verified, waiting on `pkgwatch review <name> --approve`.
NeedsReview { name: &'a str, version: &'a str },
/// Tier 1-3: built and added to the local repo, waiting on `pacman -Syu`.
Published { name: &'a str, version: &'a str },
}
/// (summary, body) for `event` — pure, so the wording is unit-testable
/// without a notification daemon.
fn message(event: Event<'_>) -> (String, String) {
match event {
Event::NeedsReview { name, version } => (
format!("{name} {version} needs review"),
format!("Run `pkgwatch review {name} --approve`, then `sudo pacman -Syu`."),
),
Event::Published { name, version } => (
format!("{name} {version} published"),
"Run `sudo pacman -Syu` to install it.".to_string(),
),
}
}
/// Best-effort desktop notification via the real `notify-send`; never
/// fails the caller.
pub fn notify(event: Event<'_>) {
notify_with(Path::new("notify-send"), event);
}
/// `notify_send_bin` is injectable for the same reason as
/// `publisher::publish_with`'s `repo_add_bin`.
fn notify_with(notify_send_bin: &Path, event: Event<'_>) {
let (summary, body) = message(event);
let result = Command::new(notify_send_bin)
.args(["--app-name=pkgwatch", "--", &summary, &body])
.status();
match result {
Ok(status) if status.success() => {}
Ok(status) => eprintln!(" warning: notify-send exited with {status}"),
Err(err) => eprintln!(" warning: could not run notify-send: {err}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::write_executable_script;
#[test]
fn message_for_review_names_the_approve_command() {
let (summary, body) = message(Event::NeedsReview {
name: "claude-code",
version: "v2.1.278",
});
assert_eq!(summary, "claude-code v2.1.278 needs review");
assert!(body.contains("pkgwatch review claude-code --approve"));
}
#[test]
fn message_for_publish_points_at_pacman() {
let (summary, body) = message(Event::Published {
name: "uv",
version: "0.12.17",
});
assert_eq!(summary, "uv 0.12.17 published");
assert!(body.contains("pacman -Syu"));
}
#[test]
fn notify_invokes_binary_with_summary_and_body() {
let stub_dir = tempfile::tempdir().unwrap();
let log_path = stub_dir.path().join("invoked_with.txt");
let stub = write_executable_script(
stub_dir.path(),
"fake-notify-send",
&format!("printf '%s\\n' \"$@\" > {}", log_path.display()),
);
notify_with(
&stub,
Event::Published {
name: "uv",
version: "0.12.17",
},
);
let invoked_with = std::fs::read_to_string(&log_path).unwrap();
assert!(invoked_with.contains("uv 0.12.17 published"));
}
#[test]
fn notify_survives_missing_binary() {
notify_with(
Path::new("/nonexistent/notify-send"),
Event::NeedsReview {
name: "x",
version: "1",
},
);
}
}

184
src/paths.rs Normal file
View file

@ -0,0 +1,184 @@
//! Decides where pkgwatch's config, state, and work directories live on
//! disk — the only module that reads the environment to answer that; every
//! other module takes the directories it needs as parameters. (The pacman
//! repo dir, `pipeline::custom_repo_dir`, is resolved separately.)
//!
//! These follow the XDG base-directory spec instead of the current working
//! directory, so an installed `/usr/bin/pkgwatch` behaves the same
//! wherever it's launched from (a systemd unit, a shell, another checkout)
//! instead of only working from inside the repo.
use anyhow::{Context, Result};
use std::path::{Path, PathBuf};
const APP_DIR: &str = "pkgwatch";
#[derive(Debug, PartialEq, Eq)]
pub struct Paths {
/// `*.toml` package declarations. Config: hand-edited, worth backing up.
pub packages_dir: PathBuf,
/// Last-published/pending versions. State: small, but losing it makes
/// every package look new, so it isn't cache.
pub state_dir: PathBuf,
/// Downloaded artifacts and build trees. Cache: safe to delete.
pub work_dir: PathBuf,
}
impl Paths {
pub fn from_env() -> Result<Self> {
Self::resolve(|key| std::env::var(key).ok())
}
/// `getenv` is injectable so tests don't mutate the process-global
/// environment, which would race with `cargo test`'s parallel threads.
///
/// Each directory has a pkgwatch-specific override, then the matching
/// XDG variable, then the XDG default under `$HOME`; empty counts as
/// unset. The overrides exist for dry runs against scratch
/// directories, like `PKGWATCH_REPO_DIR` does for the pacman repo, so
/// they're used verbatim. The XDG variables and `$HOME` must be
/// absolute: the spec says to ignore a relative XDG value, and honoring
/// one would bring back the cwd dependence this module exists to remove.
fn resolve(getenv: impl Fn(&str) -> Option<String>) -> Result<Self> {
let base = |override_var: &str, xdg_var: &str, home_subpath: &str| -> Result<PathBuf> {
if let Some(dir) = non_empty(&getenv, override_var) {
return Ok(PathBuf::from(dir));
}
if let Some(dir) = absolute(&getenv, xdg_var) {
return Ok(Path::new(&dir).join(APP_DIR));
}
let home = absolute(&getenv, "HOME").context("HOME is not set to an absolute path")?;
Ok(Path::new(&home).join(home_subpath).join(APP_DIR))
};
Ok(Self {
packages_dir: base("PKGWATCH_CONFIG_DIR", "XDG_CONFIG_HOME", ".config")?
.join("packages.d"),
state_dir: base("PKGWATCH_STATE_DIR", "XDG_STATE_HOME", ".local/state")?,
work_dir: base("PKGWATCH_WORK_DIR", "XDG_CACHE_HOME", ".cache")?,
})
}
}
/// The XDG spec says an empty variable must be treated as unset.
fn non_empty(getenv: &impl Fn(&str) -> Option<String>, key: &str) -> Option<String> {
getenv(key).filter(|v| !v.is_empty())
}
/// Like `non_empty`, but also drops relative values (an empty string isn't
/// absolute either, so this subsumes the empty check).
fn absolute(getenv: &impl Fn(&str) -> Option<String>, key: &str) -> Option<String> {
getenv(key).filter(|v| Path::new(v).is_absolute())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |key| map.get(key).cloned()
}
#[test]
fn defaults_live_under_home() {
let paths = Paths::resolve(env(&[("HOME", "/home/u")])).unwrap();
assert_eq!(
paths,
Paths {
packages_dir: "/home/u/.config/pkgwatch/packages.d".into(),
state_dir: "/home/u/.local/state/pkgwatch".into(),
work_dir: "/home/u/.cache/pkgwatch".into(),
}
);
}
#[test]
fn xdg_variables_override_home_defaults() {
let paths = Paths::resolve(env(&[
("HOME", "/home/u"),
("XDG_CONFIG_HOME", "/xdg/config"),
("XDG_STATE_HOME", "/xdg/state"),
("XDG_CACHE_HOME", "/xdg/cache"),
]))
.unwrap();
assert_eq!(
paths.packages_dir,
Path::new("/xdg/config/pkgwatch/packages.d")
);
assert_eq!(paths.state_dir, Path::new("/xdg/state/pkgwatch"));
assert_eq!(paths.work_dir, Path::new("/xdg/cache/pkgwatch"));
}
#[test]
fn pkgwatch_overrides_win_and_are_used_verbatim() {
let paths = Paths::resolve(env(&[
("HOME", "/home/u"),
("XDG_STATE_HOME", "/xdg/state"),
("PKGWATCH_CONFIG_DIR", "/scratch/cfg"),
("PKGWATCH_STATE_DIR", "/scratch/state"),
("PKGWATCH_WORK_DIR", "/scratch/work"),
]))
.unwrap();
// No `pkgwatch/` suffix appended to an explicit override.
assert_eq!(paths.packages_dir, Path::new("/scratch/cfg/packages.d"));
assert_eq!(paths.state_dir, Path::new("/scratch/state"));
assert_eq!(paths.work_dir, Path::new("/scratch/work"));
}
#[test]
fn empty_variables_are_treated_as_unset() {
let paths = Paths::resolve(env(&[
("HOME", "/home/u"),
("XDG_STATE_HOME", ""),
("PKGWATCH_WORK_DIR", ""),
]))
.unwrap();
assert_eq!(paths.state_dir, Path::new("/home/u/.local/state/pkgwatch"));
assert_eq!(paths.work_dir, Path::new("/home/u/.cache/pkgwatch"));
}
#[test]
fn relative_xdg_variables_are_ignored() {
let paths = Paths::resolve(env(&[
("HOME", "/home/u"),
("XDG_CONFIG_HOME", "rel/config"),
("XDG_STATE_HOME", "./state"),
("XDG_CACHE_HOME", "cache"),
]))
.unwrap();
assert_eq!(
paths.packages_dir,
Path::new("/home/u/.config/pkgwatch/packages.d")
);
assert_eq!(paths.state_dir, Path::new("/home/u/.local/state/pkgwatch"));
assert_eq!(paths.work_dir, Path::new("/home/u/.cache/pkgwatch"));
}
#[test]
fn relative_home_is_an_error() {
let err = Paths::resolve(env(&[("HOME", "relative/home")])).unwrap_err();
assert!(err.to_string().contains("HOME"));
}
#[test]
fn errors_when_nothing_locates_home() {
let err = Paths::resolve(env(&[])).unwrap_err();
assert!(err.to_string().contains("HOME"));
}
#[test]
fn no_home_needed_when_every_dir_is_overridden() {
let paths = Paths::resolve(env(&[
("PKGWATCH_CONFIG_DIR", "/c"),
("PKGWATCH_STATE_DIR", "/s"),
("PKGWATCH_WORK_DIR", "/w"),
]))
.unwrap();
assert_eq!(paths.state_dir, Path::new("/s"));
}
}

489
src/pipeline.rs Normal file
View file

@ -0,0 +1,489 @@
//! Orchestrates one run of check -> fetch -> verify -> build ->
//! sanity-check -> publish across every configured package, plus the
//! `review` subcommand for tier 4-6 approvals. The only module that calls
//! more than one other pipeline-stage module — see docs/ARCHITECTURE.md > "main
//! is a dispatcher, not the program" for why this lives here and not in
//! `main.rs`.
use crate::builder;
use crate::checker;
use crate::config::{self, Package};
use crate::fetcher::{self, DownloadedAsset};
use crate::notifier::{self, Event};
use crate::paths::Paths;
use crate::publisher;
use crate::release_source::{self, ReleaseSource};
use crate::sanity;
use crate::state;
use crate::verifier::{self, VerificationResult};
use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
/// Not a repo pkgwatch invents: this is the existing, already-registered
/// local pacman repo on this box (see `[custom]` in /etc/pacman.conf and
/// its `Server = file://...` line). pkgwatch adds packages to it; it does
/// not create the repo or touch pacman.conf.
const CUSTOM_REPO_NAME: &str = "custom";
const CUSTOM_REPO_SUBPATH: &str = ".local/share/pacman/custom";
fn build_client() -> Result<reqwest::blocking::Client> {
Ok(reqwest::blocking::Client::builder()
.user_agent("pkgwatch/0.1 (PoC; https://code.austinschaefer.com)")
.build()?)
}
fn custom_repo_dir() -> Result<PathBuf> {
// Override for testing against a scratch repo instead of the real one
// at $HOME/.local/share/pacman/custom.
if let Ok(dir) = std::env::var("PKGWATCH_REPO_DIR") {
return Ok(PathBuf::from(dir));
}
let home = std::env::var("HOME").context("HOME is not set")?;
Ok(Path::new(&home).join(CUSTOM_REPO_SUBPATH))
}
/// Adds a hint to the bare "No such file" a missing config dir would give:
/// the dir is no longer relative to the cwd, so a checkout's `packages.d/`
/// isn't picked up on its own (see docs/SPEC.md > Paths).
fn load_packages(packages_dir: &Path) -> Result<Vec<(String, Package)>> {
config::load_packages_dir(packages_dir).with_context(|| {
format!(
"no package config at {} (set PKGWATCH_CONFIG_DIR to the directory containing \
packages.d, or see docs/SPEC.md > Paths for moving a checkout's packages.d/ there)",
packages_dir.display()
)
})
}
pub fn run_check() -> Result<()> {
let client = build_client()?;
let Paths {
packages_dir,
state_dir,
work_dir,
} = Paths::from_env()?;
let packages = load_packages(&packages_dir)?;
if packages.is_empty() {
println!("no packages configured under {}/", packages_dir.display());
return Ok(());
}
let mut any_failed = false;
for (name, pkg) in &packages {
println!("== {name} ({}) ==", pkg.repo);
let result = release_source::for_package(pkg).and_then(|host| {
process_package(&client, host.as_ref(), &state_dir, &work_dir, name, pkg)
});
if let Err(err) = result {
eprintln!(" error: {err:#}");
any_failed = true;
}
}
if any_failed {
bail!("one or more packages failed — see errors above");
}
Ok(())
}
/// What to do about a package after verification, derived purely from the
/// verification outcome and whether this exact version is already queued
/// for review — no I/O. See docs/ARCHITECTURE.md > "separate pure decision
/// logic from I/O."
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TierAction {
/// Verification failed outright — don't build, don't touch state.
VerificationFailed,
/// Tier 1-3: safe to auto-build and publish immediately.
Publish,
/// Tier 4-6, and this exact version was already flagged on an earlier
/// run — nothing new to report.
StillPending,
/// Tier 4-6, and this version hasn't been flagged yet.
NewlyPending,
}
fn decide_tier_action(tier: u8, passed: bool, already_pending_this_version: bool) -> TierAction {
if !passed {
return TierAction::VerificationFailed;
}
if tier <= 3 {
return TierAction::Publish;
}
if already_pending_this_version {
TierAction::StillPending
} else {
TierAction::NewlyPending
}
}
fn process_package(
client: &reqwest::blocking::Client,
host: &dyn ReleaseSource,
state_dir: &Path,
work_dir: &Path,
name: &str,
pkg: &Package,
) -> Result<()> {
let latest = host.latest_release(client, &pkg.repo)?;
let last_seen = state::load_last_version(state_dir, name);
if last_seen.as_deref() == Some(latest.as_str()) {
println!(" up to date at {latest}");
return Ok(());
}
println!(" new version detected: {latest} (previously: {last_seen:?})");
let fetched = fetch_and_verify(client, host, work_dir, name, pkg, &latest)?;
println!(" fetched {}", fetched.asset.path.display());
println!(
" verification (tier {}): {} — {}",
fetched.verification.tier,
if fetched.verification.passed {
"PASS"
} else {
"FAIL"
},
fetched.verification.justification
);
let previously_pending = state::load_pending_version(state_dir, name);
let already_pending = previously_pending.as_deref() == Some(latest.as_str());
match decide_tier_action(
fetched.verification.tier,
fetched.verification.passed,
already_pending,
) {
// Bail rather than just print-and-return: a verification failure
// is exactly the kind of event a monitoring setup (systemd
// OnFailure=, cron mail-on-error) needs a non-zero exit to catch —
// see run_check, which treats an Err here as a failed package.
TierAction::VerificationFailed => {
bail!("verification failed — not publishing, not updating state");
}
TierAction::Publish => {
println!(" tier 1-3 pass: building + publishing");
build_and_publish(name, pkg, &latest, &fetched)?;
state::save_last_version(state_dir, name, &latest)?;
state::clear_pending_version(state_dir, name)?;
println!(" published {name} {latest}");
notifier::notify(Event::Published {
name,
version: &latest,
});
}
TierAction::StillPending => {
println!(" tier 4-6 pass: still pending review (`pkgwatch review` to see it)");
}
TierAction::NewlyPending => {
state::save_pending_version(state_dir, name, &latest)?;
match previously_pending {
Some(superseded) => println!(
" tier 4-6 pass: flagged for human review, superseding still-unreviewed {superseded} (`pkgwatch review` to approve {latest})"
),
None => println!(
" tier 4-6 pass: flagged for human review (`pkgwatch review` to approve)"
),
}
notifier::notify(Event::NeedsReview {
name,
version: &latest,
});
}
}
Ok(())
}
struct FetchVerifyResult {
version: String,
asset_name: String,
dest_dir: PathBuf,
asset: DownloadedAsset,
verification: VerificationResult,
}
/// Shared by the normal check loop (tier 1-3 auto-path) and `pkgwatch
/// review --approve` (which re-verifies before publishing rather than
/// trusting a possibly-stale flag from an earlier run).
fn fetch_and_verify(
client: &reqwest::blocking::Client,
host: &dyn ReleaseSource,
work_dir: &Path,
name: &str,
pkg: &Package,
tag: &str,
) -> Result<FetchVerifyResult> {
let version = checker::version_from_tag(tag).to_string();
let asset_name = pkg.asset_pattern.replace("{version}", &version);
let dest_dir = work_dir.join(name).join(tag);
let api = host.api();
let asset = fetcher::download_asset(client, api, &pkg.repo, tag, &asset_name, &dest_dir)?;
let verification = verifier::verify(
client,
api,
&pkg.verification,
&pkg.repo,
tag,
&asset.path,
&dest_dir,
)?;
Ok(FetchVerifyResult {
version,
asset_name,
dest_dir,
asset,
verification,
})
}
fn build_and_publish(
name: &str,
pkg: &Package,
tag: &str,
fetched: &FetchVerifyResult,
) -> Result<()> {
// Fail fast if the repo isn't registered in pacman.conf, before
// spending several seconds on a makepkg build that would otherwise
// succeed and then publish somewhere pacman never syncs from.
let repo_dir = custom_repo_dir()?;
publisher::ensure_registered(CUSTOM_REPO_NAME, &repo_dir)?;
let build_dir = fetched.dest_dir.join("build");
let req = builder::BuildRequest {
pkg_name: name,
pkg,
version: &fetched.version,
repo: &pkg.repo,
asset_name: &fetched.asset_name,
download_url: &fetched.asset.download_url,
artifact_path: &fetched.asset.path,
};
let built = builder::build(&req, &build_dir)?;
println!(" built {}", built.package_path.display());
if let Some(check) = &pkg.sanity_check {
let bin_dir = built.pkgdir.join("usr/bin");
sanity::run(check, &bin_dir, &fetched.version)
.with_context(|| format!("sanity check for {name} {tag}"))?;
println!(" sanity check passed");
}
let published = publisher::publish(&built.package_path, &repo_dir, CUSTOM_REPO_NAME)?;
println!(
" added to {} repo: {}",
CUSTOM_REPO_NAME,
published.display()
);
println!(
" not installed automatically — run `sudo pacman -Syu` (or `sudo pacman -S {name}`) to pick it up"
);
Ok(())
}
pub fn run_review(args: &[String]) -> Result<()> {
let Paths {
packages_dir,
state_dir,
work_dir,
} = Paths::from_env()?;
let packages = load_packages(&packages_dir)?;
match args {
[] => {
let mut any = false;
for (name, _) in &packages {
if let Some(pending) = state::load_pending_version(&state_dir, name) {
println!(
"{name}: {pending} pending review (run `pkgwatch review {name} --approve`)"
);
any = true;
}
}
if !any {
println!("no packages pending review");
}
Ok(())
}
[name, flag] if flag == "--approve" => {
let (_, pkg) = packages.iter().find(|(n, _)| n == name).with_context(|| {
format!("no package named '{name}' in {}/", packages_dir.display())
})?;
let tag = state::load_pending_version(&state_dir, name)
.with_context(|| format!("'{name}' has no pending review"))?;
approve(&state_dir, &work_dir, name, pkg, &tag)
}
_ => bail!("usage: pkgwatch review [<name> --approve]"),
}
}
fn approve(state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, tag: &str) -> Result<()> {
let client = build_client()?;
let host = release_source::for_package(pkg)?;
// Re-verify rather than trusting the earlier flag: the artifact at
// this tag could in principle have changed since it was queued.
let fetched = fetch_and_verify(&client, host.as_ref(), work_dir, name, pkg, tag)?;
if !fetched.verification.passed {
bail!(
"re-verification failed on approve: {}",
fetched.verification.justification
);
}
println!(
"re-verified (tier {}): {}",
fetched.verification.tier, fetched.verification.justification
);
build_and_publish(name, pkg, tag, &fetched)?;
state::save_last_version(state_dir, name, tag)?;
state::clear_pending_version(state_dir, name)?;
println!("approved and published {name} {tag}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::release_source::{ForgejoEndpoints, GithubEndpoints};
use crate::test_support::same_origin_package;
#[test]
fn decide_tier_action_failed_verification_overrides_everything() {
assert_eq!(
decide_tier_action(2, false, false),
TierAction::VerificationFailed
);
assert_eq!(
decide_tier_action(4, false, true),
TierAction::VerificationFailed
);
}
#[test]
fn decide_tier_action_tier_1_to_3_publishes() {
assert_eq!(decide_tier_action(1, true, false), TierAction::Publish);
assert_eq!(decide_tier_action(2, true, false), TierAction::Publish);
assert_eq!(decide_tier_action(3, true, true), TierAction::Publish);
}
#[test]
fn decide_tier_action_tier_4_to_6_newly_pending_when_not_seen_before() {
assert_eq!(decide_tier_action(4, true, false), TierAction::NewlyPending);
assert_eq!(decide_tier_action(6, true, false), TierAction::NewlyPending);
}
#[test]
fn decide_tier_action_tier_4_to_6_still_pending_when_already_flagged() {
assert_eq!(decide_tier_action(4, true, true), TierAction::StillPending);
}
/// Mocks the release-tag, asset, and checksum endpoints for `o/r@v1.0.0`
/// with a checksum that doesn't match the asset, so verification fails.
/// These are identical on GitHub and Forgejo (see `ReleaseSource::api`); only
/// how the latest tag is found differs per test. Returned mocks must
/// stay alive for the test's duration.
fn mock_release_with_bad_checksum(server: &mut mockito::ServerGuard) -> Vec<mockito::Mock> {
let asset_url = format!("{}/download/thing.tar.gz", server.url());
let sums_url = format!("{}/download/SHA256SUMS", server.url());
let release_body = format!(
r#"{{"assets": [
{{"name": "thing.tar.gz", "browser_download_url": "{asset_url}"}},
{{"name": "SHA256SUMS", "browser_download_url": "{sums_url}"}}
]}}"#
);
vec![
server
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
.with_status(200)
.with_body(release_body)
.create(),
server
.mock("GET", "/download/thing.tar.gz")
.with_status(200)
.with_body(b"artifact-bytes".as_slice())
.create(),
// Wrong hash for "artifact-bytes" — forces a verification failure.
server
.mock("GET", "/download/SHA256SUMS")
.with_status(200)
.with_body(
"0000000000000000000000000000000000000000000000000000000000000000 thing.tar.gz\n",
)
.create(),
]
}
/// Runs `process_package` for a package whose checksum is wrong and
/// asserts it errors with "verification failed" while leaving no trace
/// in state.
fn assert_verification_failure_is_an_error(host: &dyn ReleaseSource, pkg: &Package) {
let client = reqwest::blocking::Client::new();
let state_dir = tempfile::tempdir().unwrap();
let work_dir = tempfile::tempdir().unwrap();
let err = process_package(
&client,
host,
state_dir.path(),
work_dir.path(),
"thing",
pkg,
)
.unwrap_err();
assert!(err.to_string().contains("verification failed"));
// Neither published nor queued for review — a failed verification
// shouldn't leave any trace in state.
assert_eq!(state::load_last_version(state_dir.path(), "thing"), None);
assert_eq!(state::load_pending_version(state_dir.path(), "thing"), None);
}
/// Regression test for the exit-code gap this PR fixes: a verification
/// failure previously returned `Ok(())` from `process_package`, so
/// `run_check` never counted it as a failure and the process exited 0
/// even though the single most security-relevant check had failed.
/// Exercises the full check -> fetch -> verify path against a mocked
/// GitHub (no real network), stopping before any build/publish step
/// since verification failure returns before reaching those.
#[test]
fn process_package_returns_err_on_verification_failure() {
let mut server = mockito::Server::new();
let github = GithubEndpoints {
web: server.url(),
api: server.url(),
};
let feed = format!(
r#"<feed><link rel="alternate" href="{}/o/r/releases/tag/v1.0.0"/></feed>"#,
server.url()
);
let _atom = server
.mock("GET", "/o/r/releases.atom")
.with_status(200)
.with_body(feed)
.create();
let _release_mocks = mock_release_with_bad_checksum(&mut server);
assert_verification_failure_is_an_error(&github, &same_origin_package(""));
}
/// Same as above but through a Forgejo source, proving the whole
/// check -> fetch -> verify path works there too: the error is the
/// verification failure, not a fetch or check failure on the way to it.
#[test]
fn process_package_returns_err_on_verification_failure_via_forgejo() {
let mut server = mockito::Server::new();
let forgejo = ForgejoEndpoints { api: server.url() };
let _latest = server
.mock("GET", "/repos/o/r/releases/latest")
.with_status(200)
.with_body(r#"{"tag_name": "v1.0.0"}"#)
.create();
let _release_mocks = mock_release_with_bad_checksum(&mut server);
// The package's own `source` is irrelevant here: `forgejo` is
// hand-built to point at the mock server, bypassing `for_package`.
assert_verification_failure_is_an_error(&forgejo, &same_origin_package(""));
}
}

200
src/publisher.rs Normal file
View file

@ -0,0 +1,200 @@
//! Gets a built package into the local pacman repo: copies it in, runs
//! `repo-add`, and checks the repo is actually registered in
//! `/etc/pacman.conf` first. The only module that touches the repo
//! directory or pacman.conf.
use anyhow::{Context, Result, bail};
use std::path::{Path, PathBuf};
use std::process::Command;
/// Pacman's system-wide config — hardcoded like the rest of this tool's
/// Arch/Manjaro-specific assumptions (see docs/SPEC.md > Scope).
const PACMAN_CONF: &str = "/etc/pacman.conf";
/// Copies the built package into `repo_dir` and runs `repo-add` against
/// `<repo_name>.db.tar.gz` there. Creates `repo_dir` if it doesn't exist
/// yet — `repo-add` itself creates the database file on its first run, so
/// everything filesystem-side is self-healing.
///
/// What is *not* self-healing, and can't safely be: registering
/// `repo_name` in `/etc/pacman.conf` (see `ensure_registered`) — that
/// needs root, which this process doesn't have and shouldn't grab for
/// itself. Getting a published version onto the running system is a
/// separate, deliberate `pacman -Syu`/`pacman -S` step too, also left to
/// the operator.
pub fn publish(package_path: &Path, repo_dir: &Path, repo_name: &str) -> Result<PathBuf> {
publish_with(Path::new("repo-add"), package_path, repo_dir, repo_name)
}
/// `repo_add_bin` is injectable so tests can point it at a stub script
/// instead of the real `repo-add` (or a mutated global `PATH`, which would
/// race with `cargo test`'s parallel test threads).
fn publish_with(
repo_add_bin: &Path,
package_path: &Path,
repo_dir: &Path,
repo_name: &str,
) -> Result<PathBuf> {
std::fs::create_dir_all(repo_dir)
.with_context(|| format!("creating repo dir {}", repo_dir.display()))?;
let file_name = package_path
.file_name()
.context("built package path has no filename")?;
let dest = repo_dir.join(file_name);
std::fs::copy(package_path, &dest)
.with_context(|| format!("copying {} to {}", package_path.display(), dest.display()))?;
let db_path = repo_dir.join(format!("{repo_name}.db.tar.gz"));
let status = Command::new(repo_add_bin)
.arg(&db_path)
.arg(&dest)
.status()
.with_context(|| {
format!(
"running {} (is pacman-contrib installed?)",
repo_add_bin.display()
)
})?;
if !status.success() {
bail!("repo-add failed for {}", dest.display());
}
Ok(dest)
}
/// Verifies `repo_name` is registered as an active `[section]` in
/// `/etc/pacman.conf`, so a build isn't wasted on a repo pacman will never
/// actually sync from. Call this before `publish` — ideally before even
/// starting the build, so a missing repo fails fast instead of after
/// several seconds of `makepkg` work.
///
/// Doesn't check that the section's `Server =`/`Include =` line points at
/// `repo_dir` specifically — just that a repo by this name exists at all.
/// A same-named repo pointed somewhere else is a rare, easily-diagnosed
/// misconfiguration, not worth the parsing complexity to catch here.
pub fn ensure_registered(repo_name: &str, repo_dir: &Path) -> Result<()> {
ensure_registered_at(Path::new(PACMAN_CONF), repo_name, repo_dir)
}
fn ensure_registered_at(pacman_conf: &Path, repo_name: &str, repo_dir: &Path) -> Result<()> {
let conf = std::fs::read_to_string(pacman_conf)
.with_context(|| format!("reading {}", pacman_conf.display()))?;
let header = format!("[{repo_name}]");
let registered = conf.lines().map(str::trim).any(|line| line == header);
if !registered {
bail!(
"'{repo_name}' is not registered in {} — add this once, as root, then re-run:\n\n\
[{repo_name}]\n\
SigLevel = Optional TrustAll\n\
Server = file://{}\n",
pacman_conf.display(),
repo_dir.display()
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::write_executable_script;
#[test]
fn publish_copies_package_and_invokes_repo_add() {
let stub_dir = tempfile::tempdir().unwrap();
let log_path = stub_dir.path().join("invoked_with.txt");
let repo_add = write_executable_script(
stub_dir.path(),
"fake-repo-add",
&format!("echo \"$@\" > {}", log_path.display()),
);
let src_dir = tempfile::tempdir().unwrap();
let package_path = src_dir.path().join("thing-1.0.0-1-x86_64.pkg.tar.zst");
std::fs::write(&package_path, b"pkg-bytes").unwrap();
let repo_dir = tempfile::tempdir().unwrap();
let dest = publish_with(&repo_add, &package_path, repo_dir.path(), "custom").unwrap();
assert_eq!(
dest,
repo_dir.path().join("thing-1.0.0-1-x86_64.pkg.tar.zst")
);
assert_eq!(std::fs::read(&dest).unwrap(), b"pkg-bytes");
let invoked_with = std::fs::read_to_string(&log_path).unwrap();
assert!(invoked_with.contains("custom.db.tar.gz"));
assert!(invoked_with.contains("thing-1.0.0-1-x86_64.pkg.tar.zst"));
}
#[test]
fn publish_errors_when_repo_add_fails() {
let stub_dir = tempfile::tempdir().unwrap();
let repo_add = write_executable_script(stub_dir.path(), "fake-repo-add-fail", "exit 1");
let src_dir = tempfile::tempdir().unwrap();
let package_path = src_dir.path().join("thing-1.0.0-1-x86_64.pkg.tar.zst");
std::fs::write(&package_path, b"pkg-bytes").unwrap();
let repo_dir = tempfile::tempdir().unwrap();
let err = publish_with(&repo_add, &package_path, repo_dir.path(), "custom").unwrap_err();
assert!(err.to_string().contains("repo-add failed"));
}
#[test]
fn publish_creates_repo_dir_if_missing() {
let stub_dir = tempfile::tempdir().unwrap();
let repo_add = write_executable_script(stub_dir.path(), "fake-repo-add-ok", "exit 0");
let src_dir = tempfile::tempdir().unwrap();
let package_path = src_dir.path().join("thing-1.0.0-1-x86_64.pkg.tar.zst");
std::fs::write(&package_path, b"pkg-bytes").unwrap();
let parent = tempfile::tempdir().unwrap();
let repo_dir = parent.path().join("nested/repo");
publish_with(&repo_add, &package_path, &repo_dir, "custom").unwrap();
assert!(repo_dir.join("thing-1.0.0-1-x86_64.pkg.tar.zst").exists());
}
fn write_pacman_conf(dir: &Path, contents: &str) -> PathBuf {
let path = dir.join("pacman.conf");
std::fs::write(&path, contents).unwrap();
path
}
#[test]
fn ensure_registered_passes_when_section_present() {
let dir = tempfile::tempdir().unwrap();
let conf = write_pacman_conf(
dir.path(),
"[options]\nArchitecture = auto\n\n[extra]\nInclude = /etc/pacman.d/mirrorlist\n\n[custom]\nSigLevel = Optional TrustAll\nServer = file:///home/austin/.local/share/pacman/custom\n",
);
assert!(ensure_registered_at(&conf, "custom", Path::new("/repo")).is_ok());
}
#[test]
fn ensure_registered_fails_when_section_missing() {
let dir = tempfile::tempdir().unwrap();
let conf = write_pacman_conf(dir.path(), "[options]\nArchitecture = auto\n\n[extra]\n");
let err = ensure_registered_at(&conf, "custom", Path::new("/repo")).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not registered"));
assert!(msg.contains("[custom]"));
assert!(msg.contains("/repo"));
}
#[test]
fn ensure_registered_does_not_match_substring_of_another_section() {
// "custom" must match the whole section header, not just appear
// as a substring of e.g. "[custom-extra]".
let dir = tempfile::tempdir().unwrap();
let conf = write_pacman_conf(dir.path(), "[custom-extra]\nServer = file:///elsewhere\n");
assert!(ensure_registered_at(&conf, "custom", Path::new("/repo")).is_err());
}
#[test]
fn ensure_registered_errors_when_pacman_conf_missing() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("does-not-exist.conf");
assert!(ensure_registered_at(&missing, "custom", Path::new("/repo")).is_err());
}
}

View file

@ -0,0 +1,20 @@
//! The `ReleaseSource` trait: what the pipeline needs from a hosting
//! service a package's releases are published on. Each host implements it
//! in its own file next to this one, so per-host logic never accumulates
//! here.
use anyhow::Result;
/// `Debug` so a `Box<dyn ReleaseSource>` can sit in a `Result` that tests
/// unwrap.
pub trait ReleaseSource: std::fmt::Debug {
/// The latest release tag for `repo`.
fn latest_release(&self, client: &reqwest::blocking::Client, repo: &str) -> Result<String>;
/// The releases API root, which `fetcher` and `verifier` build their
/// own paths under. GitHub and Forgejo both serve
/// `/repos/{owner}/{repo}/releases/tags/{tag}` there with the same
/// `assets[].{name, browser_download_url}` shape, which is why those
/// stages need only this and not the source itself.
fn api(&self) -> &str;
}

View file

@ -0,0 +1,112 @@
//! A Forgejo (or Gitea) instance as a release source: its API root, and
//! how to find a repo's latest release there.
use super::ReleaseSource;
use anyhow::{Context, Result, bail};
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct ForgejoEndpoints {
/// The instance's API root, i.e. `<base_url>/api/v1`.
pub api: String,
}
impl ForgejoEndpoints {
/// `base_url` is the instance's web root, e.g.
/// `https://code.austinschaefer.com`; a trailing slash is tolerated.
pub fn from_base_url(base_url: &str) -> Self {
Self {
api: format!("{}/api/v1", base_url.trim_end_matches('/')),
}
}
}
impl ReleaseSource for ForgejoEndpoints {
/// One call, unlike GitHub's feed-then-confirm dance: Forgejo's
/// `releases/latest` already returns only the newest non-draft,
/// non-prerelease *release object*, so a stray tag with no release
/// behind it (GitHub's scaleway-cli `-dbg1` problem) can't be returned.
fn latest_release(&self, client: &reqwest::blocking::Client, repo: &str) -> Result<String> {
#[derive(Deserialize)]
struct Latest {
tag_name: String,
}
let url = format!("{}/repos/{repo}/releases/latest", self.api);
let response = client.get(&url).send()?;
// Forgejo answers 404 both for an unknown repo and for one with no
// releases yet — the common state for a project's very first
// release.
if response.status() == reqwest::StatusCode::NOT_FOUND {
bail!("no published release found at {url} (repo missing, or nothing released yet)");
}
let latest: Latest = response
.error_for_status()
.with_context(|| format!("fetching latest release from {url}"))?
.json()?;
Ok(latest.tag_name)
}
fn api(&self) -> &str {
&self.api
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn from_base_url_appends_api_v1() {
let endpoints = ForgejoEndpoints::from_base_url("https://code.example.com");
assert_eq!(endpoints.api(), "https://code.example.com/api/v1");
}
#[test]
fn from_base_url_tolerates_trailing_slash() {
let endpoints = ForgejoEndpoints::from_base_url("https://code.example.com/");
assert_eq!(endpoints.api(), "https://code.example.com/api/v1");
}
#[test]
fn latest_release_returns_tag_name() {
let mut server = mockito::Server::new();
let _latest = server
.mock("GET", "/repos/o/r/releases/latest")
.with_status(200)
.with_body(r#"{"tag_name": "v0.1.0", "assets": []}"#)
.create();
let client = reqwest::blocking::Client::new();
let endpoints = ForgejoEndpoints { api: server.url() };
assert_eq!(endpoints.latest_release(&client, "o/r").unwrap(), "v0.1.0");
}
#[test]
fn latest_release_names_the_no_releases_case() {
let mut server = mockito::Server::new();
let _latest = server
.mock("GET", "/repos/o/r/releases/latest")
.with_status(404)
.create();
let client = reqwest::blocking::Client::new();
let endpoints = ForgejoEndpoints { api: server.url() };
let err = endpoints.latest_release(&client, "o/r").unwrap_err();
assert!(err.to_string().contains("no published release"));
}
#[test]
fn latest_release_surfaces_server_errors() {
let mut server = mockito::Server::new();
let _latest = server
.mock("GET", "/repos/o/r/releases/latest")
.with_status(500)
.create();
let client = reqwest::blocking::Client::new();
let endpoints = ForgejoEndpoints { api: server.url() };
let err = endpoints.latest_release(&client, "o/r").unwrap_err();
assert!(err.to_string().contains("fetching latest release"));
}
}

View file

@ -0,0 +1,166 @@
//! GitHub as a release source: its endpoints, and how to find a repo's
//! latest release there.
use super::ReleaseSource;
use anyhow::{Result, bail};
use regex::Regex;
/// Base URLs for GitHub's public web host (Atom feeds, release pages) and
/// its REST API, factored out so tests can point both at a local mock
/// server instead of the real github.com/api.github.com.
#[derive(Debug, Clone)]
pub struct GithubEndpoints {
pub web: String,
pub api: String,
}
impl Default for GithubEndpoints {
fn default() -> Self {
Self {
web: "https://github.com".to_string(),
api: "https://api.github.com".to_string(),
}
}
}
impl ReleaseSource for GithubEndpoints {
/// Resolves the latest release tag for `repo` via its public Atom feed.
///
/// Deliberately not a full XML parse: the feed lists entries
/// newest-first, and each `<link rel="alternate"
/// .../releases/tag/<tag>"/>` is matched in document order. Revisit
/// with a real XML parser if GitHub's feed shape ever changes.
///
/// The feed can list a tag newer than any tag with a real Release
/// object behind it — observed on scaleway/scaleway-cli, which pushes a
/// `vX.Y.Z-dbg1` tag (no corresponding Release; `releases/tags/<tag>`
/// 404s) right after each real release, and that tag sorts newest in
/// the feed. So each candidate is confirmed against the releases API in
/// feed order, returning the first that actually resolves.
fn latest_release(&self, client: &reqwest::blocking::Client, repo: &str) -> Result<String> {
let url = format!("{}/{repo}/releases.atom", self.web);
let body = client.get(&url).send()?.error_for_status()?.text()?;
let re = Regex::new(r#"releases/tag/([^"]+)""#)?;
let mut candidates = re
.captures_iter(&body)
.map(|caps| caps[1].to_string())
.peekable();
if candidates.peek().is_none() {
bail!("no release tag found in {url}");
}
for tag in candidates {
let release_url = format!("{}/repos/{repo}/releases/tags/{tag}", self.api);
if client.get(&release_url).send()?.status().is_success() {
return Ok(tag);
}
}
bail!("no release tag in {url} resolved to a real release via the API")
}
fn api(&self) -> &str {
&self.api
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_points_at_real_github() {
let endpoints = GithubEndpoints::default();
assert_eq!(endpoints.web, "https://github.com");
assert_eq!(endpoints.api, "https://api.github.com");
}
fn atom_feed(tags: &[&str]) -> String {
let entries: String = tags
.iter()
.map(|t| {
format!(r#"<link rel="alternate" href="https://github.com/o/r/releases/tag/{t}"/>"#)
})
.collect();
format!("<feed>{entries}</feed>")
}
#[test]
fn latest_release_skips_tags_with_no_real_release() {
let mut server = mockito::Server::new();
let endpoints = GithubEndpoints {
web: server.url(),
api: server.url(),
};
// Mirrors the real scaleway-cli case: newest feed entry (a -dbg1
// tag) has no Release object behind it and 404s.
let _feed = server
.mock("GET", "/o/r/releases.atom")
.with_status(200)
.with_body(atom_feed(&["v2.62.0-dbg1", "v2.62.0"]))
.create();
let _missing = server
.mock("GET", "/repos/o/r/releases/tags/v2.62.0-dbg1")
.with_status(404)
.create();
let _real = server
.mock("GET", "/repos/o/r/releases/tags/v2.62.0")
.with_status(200)
.with_body("{}")
.create();
let client = reqwest::blocking::Client::new();
let tag = endpoints.latest_release(&client, "o/r").unwrap();
assert_eq!(tag, "v2.62.0");
}
#[test]
fn latest_release_errors_when_feed_has_no_tags() {
let mut server = mockito::Server::new();
let endpoints = GithubEndpoints {
web: server.url(),
api: server.url(),
};
let _feed = server
.mock("GET", "/o/r/releases.atom")
.with_status(200)
.with_body("<feed></feed>")
.create();
let client = reqwest::blocking::Client::new();
let err = endpoints.latest_release(&client, "o/r").unwrap_err();
assert!(err.to_string().contains("no release tag found"));
}
#[test]
fn latest_release_errors_when_no_candidate_resolves() {
let mut server = mockito::Server::new();
let endpoints = GithubEndpoints {
web: server.url(),
api: server.url(),
};
let _feed = server
.mock("GET", "/o/r/releases.atom")
.with_status(200)
.with_body(atom_feed(&["v1.0.0-dbg1"]))
.create();
let _missing = server
.mock("GET", "/repos/o/r/releases/tags/v1.0.0-dbg1")
.with_status(404)
.create();
let client = reqwest::blocking::Client::new();
let err = endpoints.latest_release(&client, "o/r").unwrap_err();
assert!(err.to_string().contains("resolved to a real release"));
}
#[test]
fn api_is_the_configured_api_root() {
let endpoints = GithubEndpoints {
web: "http://w".into(),
api: "http://a".into(),
};
assert_eq!(endpoints.api(), "http://a");
}
}

57
src/release_source/mod.rs Normal file
View file

@ -0,0 +1,57 @@
//! Where a package's releases are published: the `ReleaseSource` trait,
//! one file per host implementing it, and `for_package`, which picks the
//! implementation for a package from its configured `source`. The
//! submodules are private and re-exported here, so the rest of the crate
//! imports everything from `crate::release_source` and never a host's
//! file.
mod contract;
mod forgejo;
mod github;
pub use contract::ReleaseSource;
pub use forgejo::ForgejoEndpoints;
pub use github::GithubEndpoints;
use crate::config::{Package, Source};
use anyhow::{Context, Result};
/// Defensive: errors only if a `forgejo-release` package has no
/// `base_url`, which `config::load_packages_dir` already guarantees.
pub fn for_package(pkg: &Package) -> Result<Box<dyn ReleaseSource>> {
match pkg.source {
Source::GithubRelease => Ok(Box::new(GithubEndpoints::default())),
Source::ForgejoRelease => {
let base_url = pkg
.base_url
.as_deref()
.context("source = \"forgejo-release\" needs a base_url")?;
Ok(Box::new(ForgejoEndpoints::from_base_url(base_url)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::same_origin_package as package;
#[test]
fn github_source_uses_real_github() {
let source = for_package(&package("")).unwrap();
assert_eq!(source.api(), "https://api.github.com");
}
#[test]
fn forgejo_source_uses_the_instances_api() {
let pkg = package("source = \"forgejo-release\"\nbase_url = \"https://code.example.com\"");
let source = for_package(&pkg).unwrap();
assert_eq!(source.api(), "https://code.example.com/api/v1");
}
#[test]
fn forgejo_source_without_base_url_errors() {
let err = for_package(&package("source = \"forgejo-release\"")).unwrap_err();
assert!(err.to_string().contains("needs a base_url"));
}
}

122
src/sanity.rs Normal file
View file

@ -0,0 +1,122 @@
//! Post-build correctness check: runs the freshly built binary and
//! confirms it reports the version pkgwatch believes it just built. Not a
//! security control — see docs/SPEC.md > Verification trust tiers.
use crate::config::SanityCheck;
use anyhow::{Context, Result, bail};
use regex::Regex;
use std::path::Path;
use std::process::Command;
/// Runs `check.command` with `pkg_bin_dir` prepended to `PATH`, so it
/// exercises the binary pkgwatch just built (still sitting in makepkg's
/// package staging directory, not installed system-wide) rather than
/// whatever's already on the system. Confirms `check.version_regex`'s
/// capture group matches `expected_version`.
///
/// Correctness check only, not a security control — see docs/SPEC.md >
/// Verification trust tiers. Catches checker bugs and mangled/wrong-asset
/// downloads, not malicious releases.
pub fn run(check: &SanityCheck, pkg_bin_dir: &Path, expected_version: &str) -> Result<()> {
let path_env = format!(
"{}:{}",
pkg_bin_dir.display(),
std::env::var("PATH").unwrap_or_default()
);
let output = Command::new("sh")
.arg("-c")
.arg(&check.command)
.env("PATH", path_env)
.output()
.with_context(|| format!("running sanity check command '{}'", check.command))?;
if !output.status.success() {
bail!(
"sanity check command '{}' exited with {}: {}",
check.command,
output.status,
String::from_utf8_lossy(&output.stderr).trim()
);
}
let combined = format!(
"{}{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
let re = Regex::new(&check.version_regex)
.with_context(|| format!("invalid version_regex '{}'", check.version_regex))?;
let found = re
.captures(&combined)
.and_then(|caps| caps.get(1))
.with_context(|| {
format!(
"version_regex '{}' did not match sanity check output: {combined:?}",
check.version_regex
)
})?
.as_str();
if found != expected_version {
bail!(
"sanity check reported version '{found}', pkgwatch built '{expected_version}' — mismatch"
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::write_executable_script as write_fake_binary;
#[test]
fn run_passes_when_reported_version_matches() {
let dir = tempfile::tempdir().unwrap();
write_fake_binary(dir.path(), "uv", "echo 'uv 0.12.15 (abc 2026-09-01)'");
let check = SanityCheck {
command: "uv --version".to_string(),
version_regex: r"uv (\d+\.\d+\.\d+)".to_string(),
};
assert!(run(&check, dir.path(), "0.12.15").is_ok());
}
#[test]
fn run_fails_when_reported_version_differs() {
let dir = tempfile::tempdir().unwrap();
write_fake_binary(dir.path(), "uv", "echo 'uv 0.12.14 (abc 2026-08-01)'");
let check = SanityCheck {
command: "uv --version".to_string(),
version_regex: r"uv (\d+\.\d+\.\d+)".to_string(),
};
let err = run(&check, dir.path(), "0.12.15").unwrap_err();
assert!(err.to_string().contains("mismatch"));
}
#[test]
fn run_fails_when_command_exits_nonzero() {
let dir = tempfile::tempdir().unwrap();
write_fake_binary(dir.path(), "uv", "exit 1");
let check = SanityCheck {
command: "uv --version".to_string(),
version_regex: r"uv (\d+\.\d+\.\d+)".to_string(),
};
let err = run(&check, dir.path(), "0.12.15").unwrap_err();
assert!(err.to_string().contains("exited with"));
}
#[test]
fn run_fails_when_output_does_not_match_regex() {
let dir = tempfile::tempdir().unwrap();
write_fake_binary(dir.path(), "uv", "echo 'not a version'");
let check = SanityCheck {
command: "uv --version".to_string(),
version_regex: r"uv (\d+\.\d+\.\d+)".to_string(),
};
let err = run(&check, dir.path(), "0.12.15").unwrap_err();
assert!(err.to_string().contains("did not match"));
}
}

View file

@ -1,10 +1,14 @@
//! Persists two independent per-package facts as plain files: the last
//! published version, and any version currently pending human review.
//! The only module that touches the state directory (see `paths.rs`) on disk.
use anyhow::Result; use anyhow::Result;
use std::path::Path; use std::path::Path;
/// Last-known-published version per package, so re-runs don't re-flag a /// Last-known-published version per package, so re-runs don't re-flag a
/// version already handled. Deliberately just one file per package for /// version already handled. Deliberately just one file per package for
/// now — this is where a real review-queue persistence layer plugs in /// now — this is where a real review-queue persistence layer plugs in
/// later (see SPEC.md > Architecture > Reviewer queue). /// later (see docs/SPEC.md > Architecture > Reviewer queue).
pub fn load_last_version(state_dir: &Path, name: &str) -> Option<String> { pub fn load_last_version(state_dir: &Path, name: &str) -> Option<String> {
std::fs::read_to_string(state_dir.join(format!("{name}.version"))) std::fs::read_to_string(state_dir.join(format!("{name}.version")))
.ok() .ok()
@ -17,6 +21,33 @@ pub fn save_last_version(state_dir: &Path, name: &str, version: &str) -> Result<
Ok(()) Ok(())
} }
/// Tag currently awaiting human review for a tier 4-6 package (see
/// docs/SPEC.md > Architecture > Reviewer queue), if any. Separate from
/// `load_last_version`/`save_last_version`: approving a review doesn't
/// mean future versions auto-publish, so the two must be tracked
/// independently.
pub fn load_pending_version(state_dir: &Path, name: &str) -> Option<String> {
std::fs::read_to_string(state_dir.join(format!("{name}.pending")))
.ok()
.map(|s| s.trim().to_string())
}
pub fn save_pending_version(state_dir: &Path, name: &str, version: &str) -> Result<()> {
std::fs::create_dir_all(state_dir)?;
std::fs::write(state_dir.join(format!("{name}.pending")), version)?;
Ok(())
}
/// Clears a pending review, e.g. once it's been approved and published.
/// Not an error if there was nothing pending.
pub fn clear_pending_version(state_dir: &Path, name: &str) -> Result<()> {
match std::fs::remove_file(state_dir.join(format!("{name}.pending"))) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(e.into()),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -57,4 +88,49 @@ mod tests {
Some("0.12.15".to_string()) Some("0.12.15".to_string())
); );
} }
#[test]
fn load_pending_version_missing_file_returns_none() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(load_pending_version(dir.path(), "scaleway-cli"), None);
}
#[test]
fn save_then_load_pending_roundtrips() {
let dir = tempfile::tempdir().unwrap();
save_pending_version(dir.path(), "scaleway-cli", "v2.62.0").unwrap();
assert_eq!(
load_pending_version(dir.path(), "scaleway-cli"),
Some("v2.62.0".to_string())
);
}
#[test]
fn clear_pending_version_removes_it() {
let dir = tempfile::tempdir().unwrap();
save_pending_version(dir.path(), "scaleway-cli", "v2.62.0").unwrap();
clear_pending_version(dir.path(), "scaleway-cli").unwrap();
assert_eq!(load_pending_version(dir.path(), "scaleway-cli"), None);
}
#[test]
fn clear_pending_version_is_a_noop_when_nothing_pending() {
let dir = tempfile::tempdir().unwrap();
assert!(clear_pending_version(dir.path(), "scaleway-cli").is_ok());
}
#[test]
fn pending_and_last_version_are_tracked_independently() {
let dir = tempfile::tempdir().unwrap();
save_last_version(dir.path(), "scaleway-cli", "v2.61.0").unwrap();
save_pending_version(dir.path(), "scaleway-cli", "v2.62.0").unwrap();
assert_eq!(
load_last_version(dir.path(), "scaleway-cli"),
Some("v2.61.0".to_string())
);
assert_eq!(
load_pending_version(dir.path(), "scaleway-cli"),
Some("v2.62.0".to_string())
);
}
} }

74
src/test_support.rs Normal file
View file

@ -0,0 +1,74 @@
//! Test-only fixture helpers shared across modules' `#[cfg(test)]` code
//! — not production code, and not built outside `cargo test`. See
//! docs/ARCHITECTURE.md > "organize by pipeline stage, not by layer": this
//! exists to remove specific pieces of duplication (near-identical copies
//! of "write an executable shell script" and of "build a same-origin
//! `Package` from TOML"), not as a general test-utils dump.
use crate::config::Package;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{Duration, Instant};
/// Set by `wait_until_executable`'s probe so the script exits before
/// running its real body.
const PROBE_ENV: &str = "PKGWATCH_TEST_SCRIPT_PROBE";
/// `ETXTBSY`: exec of a file some process still has open for writing.
const TEXT_FILE_BUSY: i32 = 26;
/// Writes an executable `#!/bin/sh` script named `name` into `dir`,
/// running `body` as its contents. Used to stand in for a real binary
/// (`repo-add`, a package's own `--version` command) in tests, without
/// needing the real tool installed or a mutated global `PATH`.
///
/// Doesn't return until the script can actually be exec'd. `cargo test`
/// runs tests on parallel threads, and a `fork` on another thread between
/// this function's write-open and close leaves the child holding a copy of
/// the write fd until it execs, so an immediate exec of the new script can
/// fail with `Text file busy`. Once no holder is left none can appear (our
/// fd is closed), so a probe exec that succeeds proves later ones will.
pub(crate) fn write_executable_script(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(
&path,
format!("#!/bin/sh\n[ -z \"${PROBE_ENV}\" ] || exit 0\n{body}\n"),
)
.unwrap();
let mut perms = std::fs::metadata(&path).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
std::fs::set_permissions(&path, perms).unwrap();
wait_until_executable(&path);
path
}
fn wait_until_executable(path: &Path) {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
match Command::new(path).env(PROBE_ENV, "1").status() {
Ok(_) => return,
Err(err) if err.raw_os_error() == Some(TEXT_FILE_BUSY) && Instant::now() < deadline => {
std::thread::sleep(Duration::from_millis(5));
}
Err(err) => panic!("probe-exec of {} failed: {err}", path.display()),
}
}
}
/// A `same-origin-sha256` `Package` for repo `o/r`, asset `thing.tar.gz`,
/// checksum asset `SHA256SUMS`. `extra` is spliced in as top-level keys
/// before the `[verification]` table (e.g. `source`/`base_url`), and is
/// parsed directly rather than through `config::load_packages_dir`, so it
/// skips load-time validation.
pub(crate) fn same_origin_package(extra: &str) -> Package {
toml::from_str(&format!(
r#"
repo = "o/r"
asset_pattern = "thing.tar.gz"
{extra}
[verification]
method = "same-origin-sha256"
checksum_asset_pattern = "SHA256SUMS"
"#
))
.unwrap()
}

View file

@ -1,9 +1,13 @@
//! Runs the trust-tier-specific check declared for a package against a
//! downloaded artifact, and reports a pass/fail plus the tier it implies.
//! The only module that knows what each `Verification::method` actually
//! proves — see docs/SPEC.md > Verification trust tiers.
use crate::checker::version_from_tag; use crate::checker::version_from_tag;
use crate::config::Verification; use crate::config::Verification;
use crate::fetcher; use crate::fetcher;
use crate::github::GithubEndpoints; use crate::hash;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use sha2::{Digest, Sha256};
use std::path::Path; use std::path::Path;
use std::process::Command; use std::process::Command;
@ -14,11 +18,11 @@ pub struct VerificationResult {
} }
/// Runs the verification method declared for a package against a /// Runs the verification method declared for a package against a
/// downloaded artifact. See SPEC.md > Verification trust tiers for what /// downloaded artifact. See docs/SPEC.md > Verification trust tiers for what
/// each tier does and does not prove. /// each tier does and does not prove.
pub fn verify( pub fn verify(
client: &reqwest::blocking::Client, client: &reqwest::blocking::Client,
endpoints: &GithubEndpoints, api: &str,
verification: &Verification, verification: &Verification,
repo: &str, repo: &str,
tag: &str, tag: &str,
@ -31,23 +35,16 @@ pub fn verify(
} => { } => {
let checksum_asset_name = let checksum_asset_name =
checksum_asset_pattern.replace("{version}", version_from_tag(tag)); checksum_asset_pattern.replace("{version}", version_from_tag(tag));
let checksum_path = fetcher::download_asset( let checksum_asset =
client, fetcher::download_asset(client, api, repo, tag, &checksum_asset_name, dest_dir)?;
endpoints, let checksum_text = std::fs::read_to_string(&checksum_asset.path)?;
repo,
tag,
&checksum_asset_name,
dest_dir,
)?;
let checksum_text = std::fs::read_to_string(&checksum_path)?;
let artifact_name = artifact_path let artifact_name = artifact_path
.file_name() .file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.context("artifact path has no filename")?; .context("artifact path has no filename")?;
let expected = expected_checksum(&checksum_text, artifact_name)?; let expected = expected_checksum(&checksum_text, artifact_name)?;
let data = std::fs::read(artifact_path)?; let actual = hash::sha256_hex_file(artifact_path)?;
let actual = sha256_hex(&data);
let passed = actual == expected; let passed = actual == expected;
Ok(VerificationResult { Ok(VerificationResult {
@ -55,7 +52,7 @@ pub fn verify(
passed, passed,
justification: if passed { justification: if passed {
"same-origin sha256 matched — proves transport integrity only, \ "same-origin sha256 matched — proves transport integrity only, \
not authorship (see tier 4 in SPEC.md)" not authorship (see tier 4 in docs/SPEC.md)"
.into() .into()
} else { } else {
format!("sha256 mismatch: expected {expected}, got {actual}") format!("sha256 mismatch: expected {expected}, got {actual}")
@ -94,12 +91,6 @@ pub fn verify(
} }
} }
fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
/// Finds the expected hash for `artifact_name` in a checksum file. /// Finds the expected hash for `artifact_name` in a checksum file.
/// ///
/// Handles both a bare-hash file covering a single asset (e.g. uv's /// Handles both a bare-hash file covering a single asset (e.g. uv's
@ -186,14 +177,11 @@ mod tests {
#[test] #[test]
fn verify_same_origin_sha256_passes_on_matching_checksum() { fn verify_same_origin_sha256_passes_on_matching_checksum() {
let mut server = mockito::Server::new(); let mut server = mockito::Server::new();
let endpoints = GithubEndpoints { let api = server.url();
web: server.url(),
api: server.url(),
};
let dest_dir = tempfile::tempdir().unwrap(); let dest_dir = tempfile::tempdir().unwrap();
let artifact_path = dest_dir.path().join("thing.tar.gz"); let artifact_path = dest_dir.path().join("thing.tar.gz");
std::fs::write(&artifact_path, b"hello world").unwrap(); std::fs::write(&artifact_path, b"hello world").unwrap();
let expected_hash = sha256_hex(b"hello world"); let expected_hash = hash::sha256_hex(b"hello world");
let release_url = format!("{}/download/SHA256SUMS", server.url()); let release_url = format!("{}/download/SHA256SUMS", server.url());
let release_body = format!( let release_body = format!(
@ -216,7 +204,7 @@ mod tests {
let client = reqwest::blocking::Client::new(); let client = reqwest::blocking::Client::new();
let result = verify( let result = verify(
&client, &client,
&endpoints, &api,
&verification, &verification,
"o/r", "o/r",
"v1.0.0", "v1.0.0",
@ -232,10 +220,7 @@ mod tests {
#[test] #[test]
fn verify_same_origin_sha256_fails_on_mismatched_checksum() { fn verify_same_origin_sha256_fails_on_mismatched_checksum() {
let mut server = mockito::Server::new(); let mut server = mockito::Server::new();
let endpoints = GithubEndpoints { let api = server.url();
web: server.url(),
api: server.url(),
};
let dest_dir = tempfile::tempdir().unwrap(); let dest_dir = tempfile::tempdir().unwrap();
let artifact_path = dest_dir.path().join("thing.tar.gz"); let artifact_path = dest_dir.path().join("thing.tar.gz");
std::fs::write(&artifact_path, b"hello world").unwrap(); std::fs::write(&artifact_path, b"hello world").unwrap();
@ -263,7 +248,7 @@ mod tests {
let client = reqwest::blocking::Client::new(); let client = reqwest::blocking::Client::new();
let result = verify( let result = verify(
&client, &client,
&endpoints, &api,
&verification, &verification,
"o/r", "o/r",
"v1.0.0", "v1.0.0",

View file

@ -0,0 +1,11 @@
# Triggered by pkgwatch.service's OnFailure=. Covers what the in-process
# notifier can't: a verification failure, build failure, or network error
# exits non-zero, and that would otherwise only show up in the journal.
[Unit]
Description=Notify that a pkgwatch run failed
[Service]
Type=oneshot
# Absolute path: systemd requires one, unlike the in-process notifier,
# which resolves notify-send from PATH.
ExecStart=/usr/bin/notify-send --app-name=pkgwatch --urgency=critical "pkgwatch run failed" "See: journalctl --user -u pkgwatch.service"

16
systemd/pkgwatch.service Normal file
View file

@ -0,0 +1,16 @@
# User-level oneshot: one check -> fetch -> verify -> build -> publish pass.
# Install: see docs/SPEC.md > Scheduling.
#
# No WorkingDirectory: config, state and work dirs come from the XDG paths
# in src/paths.rs (see docs/SPEC.md > Paths), not the cwd.
# The binary is the release build in the main checkout (`cargo build
# --release`), so a rebuild is what picks up code changes.
[Unit]
Description=pkgwatch: check tracked packages for new upstream releases
OnFailure=pkgwatch-failure.service
[Service]
Type=oneshot
ExecStart=%h/dev/pkgwatch/target/release/pkgwatch
# Builds (makepkg, large Go/Rust binaries) can legitimately take a while.
TimeoutStartSec=30min

18
systemd/pkgwatch.timer Normal file
View file

@ -0,0 +1,18 @@
# Periodic, not persistent (see docs/SPEC.md > Vision): no catch-up burst
# for missed ticks. The laptop is only on while logged in, so the user
# manager starting (= login) is what matters: OnStartupSec runs a check
# right after login (10s, so the session bus and network are up) instead
# of waiting up to an hour for the next tick.
#
# No RandomizedDelaySec: it applies to every trigger, including the
# startup one, and a single machine has no herd to spread out anyway.
[Unit]
Description=Run pkgwatch at login and hourly
[Timer]
OnStartupSec=10s
OnCalendar=hourly
Persistent=false
[Install]
WantedBy=timers.target