Compare commits

...

21 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
22 changed files with 1428 additions and 308 deletions

View file

@ -35,8 +35,10 @@ practice rather than asserted from habit — see Further reading.
`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
(`checker`, `fetcher`, `verifier`, `builder`, `sanity`, `publisher`,
`state`), not generic buckets. A new pipeline stage gets a new module
(`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
@ -61,8 +63,9 @@ practice rather than asserted from habit — see Further reading.
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` (checker/fetcher/verifier),
`repo_add_bin` and the pacman.conf path (publisher), `PKGWATCH_REPO_DIR`
**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
@ -106,8 +109,8 @@ practice rather than asserted from habit — see Further reading.
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**: `checker.rs`'s doc comment on
`latest_github_release` explains why the newest Atom-feed entry isn't
**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.

View file

@ -287,17 +287,33 @@ implements `repo`, `asset_pattern`, and `verification.method`
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,
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
entry outright.
`sanity_check` and `binary_name` are now real, implemented fields (see
Builder/Sanity checker above) — added `packages.d/uv.toml`'s and
`packages.d/scaleway-cli.toml`'s own `sanity_check` blocks, and
scaleway-cli's `binary_name = "scw"`. `source`, `check_method`, and
`check_interval` are still schema sketch, not yet read by the code — the
PoC only knows how to check GitHub-release sources, on a single one-shot
run rather than a scheduled loop.
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)
are now implemented too — see Builder/Sanity checker/Publisher/Reviewer
@ -330,19 +346,62 @@ Open questions on the schema:
- **Config loader**: parses `packages.d/*.toml` into an in-memory package
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" —
likely reuses `nvchecker`'s logic/sources conceptually for non-GitHub
sources eventually. For GitHub sources, prefers the `github-atom` feed
(see Scaling > Check method) over unconditional REST polling.
*(Implemented for GitHub only — `src/checker.rs` 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. `check_interval`/per-package cadence not wired up yet —
the PoC is a single one-shot run, not a scheduled loop.)*
*(Implemented for GitHub and Forgejo. `src/release_source/` defines the
`ReleaseSource` trait (latest release + API root); each host implements
it in its own file (`github.rs`, `forgejo.rs`), and the module's
`for_package` picks one per package, so adding a host doesn't touch
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/
attestation companion) for a resolved version. *(Implemented —
`src/fetcher.rs`, via the GitHub releases API; exact asset-name match,
not a glob.)*
`src/fetcher.rs`, via the GitHub or Forgejo releases API — same
`releases/tags/<tag>` endpoint and JSON shape on both; exact asset-name
match, not a glob.)*
- **Verifier**: tier-specific verification implementations, dispatched via
a `Verification` enum matched on `method` (an internally-tagged serde
enum) rather than a trait — simpler while there are only two methods;
@ -409,7 +468,27 @@ Open questions on the schema:
earlier run. No reject/dismiss command yet — see Status below.)*
- **Scheduling**: systemd `.service` (oneshot) + `.timer` running it
periodically, matching the pattern already used for other periodic tasks
on this box. *(Not implemented — still a single one-shot `cargo run`.)*
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
@ -447,8 +526,8 @@ Open questions on the schema:
confirmed correct — no build-provenance attestations upstream.
Required adding `{version}`-placeholder support to `asset_pattern`/
`checksum_asset_pattern`, filename-matched parsing of combined
multi-asset checksum files, and having `latest_github_release`
confirm each Atom-feed candidate against the releases API (this
multi-asset checksum files, and having the GitHub source's
`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
it). Still just flags for human review, same as any tier 4-6 pass —
not auto-installed; see the unchecked build/publish item below.
@ -473,7 +552,8 @@ Open questions on the schema:
ever actually fails on it.
- [ ] Not yet implemented: `pkgwatch review <name> --reject` (a pending
review can only be approved or left pending, not dismissed),
scheduling/`check_interval`, non-GitHub sources, `minisign`/tier-1
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.

View file

@ -38,3 +38,13 @@ 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

@ -98,6 +98,13 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result<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\
@ -113,7 +120,7 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result<String> {
sha256sums=('{sha256}')\n\
\n\
package() {{\n\
\x20 install -Dm755 \"${{srcdir}}/{install_source}\" \"${{pkgdir}}/usr/bin/{binary_name}\"\n\
{package_body}\
}}\n",
name = req.pkg_name,
version = req.version,
@ -123,6 +130,47 @@ fn generate_pkgbuild(req: &BuildRequest) -> Result<String> {
))
}
/// 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
@ -171,6 +219,32 @@ fn validate_shell_safe(field: &str, value: &str) -> Result<()> {
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.
@ -352,6 +426,24 @@ mod tests {
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);
@ -473,6 +565,137 @@ mod tests {
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);

View file

@ -1,45 +1,6 @@
use crate::github::GithubEndpoints;
use anyhow::{Result, bail};
use regex::Regex;
/// 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")
}
//! Turns a release tag into a version string. What the latest tag *is*
//! comes from a `ReleaseSource` (see `release_source`); this is the one piece of
//! the check stage that isn't host-specific.
/// 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() {
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

@ -2,9 +2,9 @@
//! The only module that knows the TOML shape — everything downstream
//! works with `Package`/`Verification`/`SanityCheck`, never raw TOML.
use anyhow::{Context, Result};
use anyhow::{Context, Result, bail};
use serde::Deserialize;
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
#[derive(Debug, Deserialize)]
@ -12,10 +12,29 @@ struct PackageFile {
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)]
pub struct Package {
/// `owner/name` on whichever `source` hosts it.
pub repo: String,
/// Exact GitHub release asset name (still not a glob — see
#[serde(default)]
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 (e.g. `scaleway-cli_{version}_linux_amd64`). Substituted via
@ -38,6 +57,20 @@ pub struct Package {
/// 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
@ -46,6 +79,32 @@ pub struct Package {
}
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 {
@ -93,6 +152,10 @@ pub fn load_packages_dir(dir: &Path) -> Result<Vec<(String, Package)>> {
.with_context(|| format!("reading {}", path.display()))?;
let file: PackageFile =
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);
}
Ok(out)
@ -164,6 +227,38 @@ mod tests {
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]
fn loads_multiple_files_and_ignores_non_toml() {
let dir = tempfile::tempdir().unwrap();
@ -212,4 +307,79 @@ mod tests {
let dir = tempfile::tempdir().unwrap();
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,8 +1,7 @@
//! Downloads a named GitHub release asset to a local path. The only
//! module that talks to the releases API for asset bytes — `checker` only
//! resolves version tags, never downloads.
//! 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 crate::github::GithubEndpoints;
use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::{Path, PathBuf};
@ -29,16 +28,18 @@ pub struct DownloadedAsset {
}
/// Downloads the release asset named exactly `asset_name` for `repo`@`tag`
/// into `dest_dir`, returning the local path and its origin URL.
/// 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(
client: &reqwest::blocking::Client,
endpoints: &GithubEndpoints,
api: &str,
repo: &str,
tag: &str,
asset_name: &str,
dest_dir: &Path,
) -> 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
.get(&api_url)
.send()?
@ -73,10 +74,7 @@ mod tests {
#[test]
fn download_asset_writes_matching_asset_to_dest_dir() {
let mut server = mockito::Server::new();
let endpoints = GithubEndpoints {
web: server.url(),
api: server.url(),
};
let api = server.url();
let asset_url = format!("{}/download/thing.tar.gz", server.url());
let release_body = format!(
r#"{{"assets": [{{"name": "thing.tar.gz", "browser_download_url": "{asset_url}"}}]}}"#
@ -96,7 +94,7 @@ mod tests {
let dest_dir = tempfile::tempdir().unwrap();
let asset = download_asset(
&client,
&endpoints,
&api,
"o/r",
"v1.0.0",
"thing.tar.gz",
@ -112,10 +110,7 @@ mod tests {
#[test]
fn download_asset_errors_when_no_asset_matches() {
let mut server = mockito::Server::new();
let endpoints = GithubEndpoints {
web: server.url(),
api: server.url(),
};
let api = server.url();
let _release = server
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
.with_status(200)
@ -126,7 +121,7 @@ mod tests {
let dest_dir = tempfile::tempdir().unwrap();
let err = download_asset(
&client,
&endpoints,
&api,
"o/r",
"v1.0.0",
"thing.tar.gz",
@ -139,10 +134,7 @@ mod tests {
#[test]
fn download_asset_errors_when_release_not_found() {
let mut server = mockito::Server::new();
let endpoints = GithubEndpoints {
web: server.url(),
api: server.url(),
};
let api = server.url();
let _release = server
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
.with_status(404)
@ -152,7 +144,7 @@ mod tests {
let dest_dir = tempfile::tempdir().unwrap();
let err = download_asset(
&client,
&endpoints,
&api,
"o/r",
"v1.0.0",
"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");
}
}

View file

@ -6,10 +6,12 @@ mod builder;
mod checker;
mod config;
mod fetcher;
mod github;
mod hash;
mod notifier;
mod paths;
mod pipeline;
mod publisher;
mod release_source;
mod sanity;
mod state;
#[cfg(test)]

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"));
}
}

View file

@ -9,17 +9,16 @@ use crate::builder;
use crate::checker;
use crate::config::{self, Package};
use crate::fetcher::{self, DownloadedAsset};
use crate::github::GithubEndpoints;
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};
const PACKAGES_DIR: &str = "packages.d";
const STATE_DIR: &str = "state";
const WORK_DIR: &str = "work";
/// 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
@ -43,14 +42,28 @@ fn custom_repo_dir() -> Result<PathBuf> {
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 endpoints = GithubEndpoints::default();
let packages_dir = Path::new(PACKAGES_DIR);
let state_dir = Path::new(STATE_DIR);
let work_dir = Path::new(WORK_DIR);
let Paths {
packages_dir,
state_dir,
work_dir,
} = Paths::from_env()?;
let packages = config::load_packages_dir(packages_dir)?;
let packages = load_packages(&packages_dir)?;
if packages.is_empty() {
println!("no packages configured under {}/", packages_dir.display());
return Ok(());
@ -59,7 +72,10 @@ pub fn run_check() -> Result<()> {
let mut any_failed = false;
for (name, pkg) in &packages {
println!("== {name} ({}) ==", pkg.repo);
if let Err(err) = process_package(&client, &endpoints, state_dir, work_dir, name, pkg) {
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;
}
@ -104,13 +120,13 @@ fn decide_tier_action(tier: u8, passed: bool, already_pending_this_version: bool
fn process_package(
client: &reqwest::blocking::Client,
endpoints: &GithubEndpoints,
host: &dyn ReleaseSource,
state_dir: &Path,
work_dir: &Path,
name: &str,
pkg: &Package,
) -> Result<()> {
let latest = checker::latest_github_release(client, endpoints, &pkg.repo)?;
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}");
@ -118,7 +134,7 @@ fn process_package(
}
println!(" new version detected: {latest} (previously: {last_seen:?})");
let fetched = fetch_and_verify(client, endpoints, work_dir, name, pkg, &latest)?;
let fetched = fetch_and_verify(client, host, work_dir, name, pkg, &latest)?;
println!(" fetched {}", fetched.asset.path.display());
println!(
" verification (tier {}): {} — {}",
@ -151,6 +167,10 @@ fn process_package(
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)");
@ -165,6 +185,10 @@ fn process_package(
" tier 4-6 pass: flagged for human review (`pkgwatch review` to approve)"
),
}
notifier::notify(Event::NeedsReview {
name,
version: &latest,
});
}
}
Ok(())
@ -183,7 +207,7 @@ struct FetchVerifyResult {
/// trusting a possibly-stale flag from an earlier run).
fn fetch_and_verify(
client: &reqwest::blocking::Client,
endpoints: &GithubEndpoints,
host: &dyn ReleaseSource,
work_dir: &Path,
name: &str,
pkg: &Package,
@ -193,10 +217,11 @@ fn fetch_and_verify(
let asset_name = pkg.asset_pattern.replace("{version}", &version);
let dest_dir = work_dir.join(name).join(tag);
let asset = fetcher::download_asset(client, endpoints, &pkg.repo, tag, &asset_name, &dest_dir)?;
let api = host.api();
let asset = fetcher::download_asset(client, api, &pkg.repo, tag, &asset_name, &dest_dir)?;
let verification = verifier::verify(
client,
endpoints,
api,
&pkg.verification,
&pkg.repo,
tag,
@ -258,16 +283,18 @@ fn build_and_publish(
}
pub fn run_review(args: &[String]) -> Result<()> {
let packages_dir = Path::new(PACKAGES_DIR);
let state_dir = Path::new(STATE_DIR);
let work_dir = Path::new(WORK_DIR);
let packages = config::load_packages_dir(packages_dir)?;
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) {
if let Some(pending) = state::load_pending_version(&state_dir, name) {
println!(
"{name}: {pending} pending review (run `pkgwatch review {name} --approve`)"
);
@ -283,9 +310,9 @@ pub fn run_review(args: &[String]) -> Result<()> {
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)
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)
approve(&state_dir, &work_dir, name, pkg, &tag)
}
_ => bail!("usage: pkgwatch review [<name> --approve]"),
}
@ -293,11 +320,11 @@ pub fn run_review(args: &[String]) -> Result<()> {
fn approve(state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, tag: &str) -> Result<()> {
let client = build_client()?;
let endpoints = GithubEndpoints::default();
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, &endpoints, work_dir, name, pkg, tag)?;
let fetched = fetch_and_verify(&client, host.as_ref(), work_dir, name, pkg, tag)?;
if !fetched.verification.passed {
bail!(
"re-verification failed on approve: {}",
@ -319,6 +346,8 @@ fn approve(state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, tag: &s
#[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() {
@ -350,6 +379,67 @@ mod tests {
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
@ -360,11 +450,10 @@ mod tests {
#[test]
fn process_package_returns_err_on_verification_failure() {
let mut server = mockito::Server::new();
let endpoints = GithubEndpoints {
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()
@ -374,63 +463,27 @@ mod tests {
.with_status(200)
.with_body(feed)
.create();
let _release_mocks = mock_release_with_bad_checksum(&mut server);
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}"}}
]}}"#
);
let _release = server
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
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(release_body)
.create();
let _asset = 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.
let _sums = server
.mock("GET", "/download/SHA256SUMS")
.with_status(200)
.with_body(
"0000000000000000000000000000000000000000000000000000000000000000 thing.tar.gz\n",
)
.with_body(r#"{"tag_name": "v1.0.0"}"#)
.create();
let _release_mocks = mock_release_with_bad_checksum(&mut server);
let pkg: Package = toml::from_str(
r#"
repo = "o/r"
asset_pattern = "thing.tar.gz"
[verification]
method = "same-origin-sha256"
checksum_asset_pattern = "SHA256SUMS"
"#,
)
.unwrap();
let client = reqwest::blocking::Client::new();
let state_dir = tempfile::tempdir().unwrap();
let work_dir = tempfile::tempdir().unwrap();
let err = process_package(
&client,
&endpoints,
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);
// 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(""));
}
}

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"));
}
}

View file

@ -1,6 +1,6 @@
//! 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 `state/` on disk.
//! The only module that touches the state directory (see `paths.rs`) on disk.
use anyhow::Result;
use std::path::Path;

View file

@ -1,21 +1,74 @@
//! Test-only fixture helpers shared across modules' `#[cfg(test)]` code
//! (`publisher`, `sanity`) — not production code, and not built outside
//! `cargo test`. See docs/ARCHITECTURE.md > "organize by pipeline stage, not
//! by layer": this exists to remove one specific piece of duplication
//! (two near-identical copies of "write an executable shell script"), not
//! as a general test-utils dump.
//! — 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{body}\n")).unwrap();
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

@ -6,7 +6,6 @@
use crate::checker::version_from_tag;
use crate::config::Verification;
use crate::fetcher;
use crate::github::GithubEndpoints;
use crate::hash;
use anyhow::{Context, Result, bail};
use std::path::Path;
@ -23,7 +22,7 @@ pub struct VerificationResult {
/// each tier does and does not prove.
pub fn verify(
client: &reqwest::blocking::Client,
endpoints: &GithubEndpoints,
api: &str,
verification: &Verification,
repo: &str,
tag: &str,
@ -36,14 +35,8 @@ pub fn verify(
} => {
let checksum_asset_name =
checksum_asset_pattern.replace("{version}", version_from_tag(tag));
let checksum_asset = fetcher::download_asset(
client,
endpoints,
repo,
tag,
&checksum_asset_name,
dest_dir,
)?;
let checksum_asset =
fetcher::download_asset(client, api, repo, tag, &checksum_asset_name, dest_dir)?;
let checksum_text = std::fs::read_to_string(&checksum_asset.path)?;
let artifact_name = artifact_path
.file_name()
@ -184,10 +177,7 @@ mod tests {
#[test]
fn verify_same_origin_sha256_passes_on_matching_checksum() {
let mut server = mockito::Server::new();
let endpoints = GithubEndpoints {
web: server.url(),
api: server.url(),
};
let api = server.url();
let dest_dir = tempfile::tempdir().unwrap();
let artifact_path = dest_dir.path().join("thing.tar.gz");
std::fs::write(&artifact_path, b"hello world").unwrap();
@ -214,7 +204,7 @@ mod tests {
let client = reqwest::blocking::Client::new();
let result = verify(
&client,
&endpoints,
&api,
&verification,
"o/r",
"v1.0.0",
@ -230,10 +220,7 @@ mod tests {
#[test]
fn verify_same_origin_sha256_fails_on_mismatched_checksum() {
let mut server = mockito::Server::new();
let endpoints = GithubEndpoints {
web: server.url(),
api: server.url(),
};
let api = server.url();
let dest_dir = tempfile::tempdir().unwrap();
let artifact_path = dest_dir.path().join("thing.tar.gz");
std::fs::write(&artifact_path, b"hello world").unwrap();
@ -261,7 +248,7 @@ mod tests {
let client = reqwest::blocking::Client::new();
let result = verify(
&client,
&endpoints,
&api,
&verification,
"o/r",
"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