Compare commits

...

9 commits

Author SHA1 Message Date
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
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
8 changed files with 224 additions and 4 deletions

View file

@ -425,7 +425,27 @@ Open questions on the schema:
earlier run. No reject/dismiss command yet — see Status below.)* 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. *(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 ## Prior art / reference points
@ -489,7 +509,8 @@ Open questions on the schema:
ever actually fails on it. ever actually fails on it.
- [ ] Not yet implemented: `pkgwatch review <name> --reject` (a pending - [ ] Not yet implemented: `pkgwatch review <name> --reject` (a pending
review can only be approved or left pending, not dismissed), 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),
non-GitHub sources, `minisign`/tier-1
method, retention/pruning of old versions in the local repo (see method, retention/pruning of old versions in the local repo (see
Scaling > Local repo retention), staggering/auth for GitHub API Scaling > Local repo retention), staggering/auth for GitHub API
rate limits at higher package counts. rate limits at higher package counts.

View file

@ -8,6 +8,7 @@ mod config;
mod fetcher; mod fetcher;
mod github; mod github;
mod hash; mod hash;
mod notifier;
mod paths; mod paths;
mod pipeline; mod pipeline;
mod publisher; mod publisher;

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

View file

@ -10,6 +10,7 @@ use crate::checker;
use crate::config::{self, Package}; use crate::config::{self, Package};
use crate::fetcher::{self, DownloadedAsset}; use crate::fetcher::{self, DownloadedAsset};
use crate::github::GithubEndpoints; use crate::github::GithubEndpoints;
use crate::notifier::{self, Event};
use crate::paths::Paths; use crate::paths::Paths;
use crate::publisher; use crate::publisher;
use crate::sanity; use crate::sanity;
@ -151,6 +152,10 @@ fn process_package(
state::save_last_version(state_dir, name, &latest)?; state::save_last_version(state_dir, name, &latest)?;
state::clear_pending_version(state_dir, name)?; state::clear_pending_version(state_dir, name)?;
println!(" published {name} {latest}"); println!(" published {name} {latest}");
notifier::notify(Event::Published {
name,
version: &latest,
});
} }
TierAction::StillPending => { TierAction::StillPending => {
println!(" tier 4-6 pass: still pending review (`pkgwatch review` to see it)"); println!(" tier 4-6 pass: still pending review (`pkgwatch review` to see it)");
@ -165,6 +170,10 @@ fn process_package(
" tier 4-6 pass: flagged for human review (`pkgwatch review` to approve)" " tier 4-6 pass: flagged for human review (`pkgwatch review` to approve)"
), ),
} }
notifier::notify(Event::NeedsReview {
name,
version: &latest,
});
} }
} }
Ok(()) Ok(())

View file

@ -1,21 +1,54 @@
//! Test-only fixture helpers shared across modules' `#[cfg(test)]` code //! Test-only fixture helpers shared across modules' `#[cfg(test)]` code
//! (`publisher`, `sanity`) — not production code, and not built outside //! (`publisher`, `sanity`, `notifier`) — not production code, and not built outside
//! `cargo test`. See docs/ARCHITECTURE.md > "organize by pipeline stage, not //! `cargo test`. See docs/ARCHITECTURE.md > "organize by pipeline stage, not
//! by layer": this exists to remove one specific piece of duplication //! by layer": this exists to remove one specific piece of duplication
//! (two near-identical copies of "write an executable shell script"), not //! (two near-identical copies of "write an executable shell script"), not
//! as a general test-utils dump. //! as a general test-utils dump.
use std::path::{Path, PathBuf}; 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`, /// Writes an executable `#!/bin/sh` script named `name` into `dir`,
/// running `body` as its contents. Used to stand in for a real binary /// running `body` as its contents. Used to stand in for a real binary
/// (`repo-add`, a package's own `--version` command) in tests, without /// (`repo-add`, a package's own `--version` command) in tests, without
/// needing the real tool installed or a mutated global `PATH`. /// 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 { pub(crate) fn write_executable_script(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name); 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(); let mut perms = std::fs::metadata(&path).unwrap().permissions();
std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755); std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
std::fs::set_permissions(&path, perms).unwrap(); std::fs::set_permissions(&path, perms).unwrap();
wait_until_executable(&path);
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()),
}
}
}

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