Compare commits
No commits in common. "6188f7f0b7be72cda5efab3e0eb6216f7296d821" and "09b198b96dc0ac54a63679ecb266a3e5ef5b4845" have entirely different histories.
6188f7f0b7
...
09b198b96d
8 changed files with 4 additions and 224 deletions
25
docs/SPEC.md
25
docs/SPEC.md
|
|
@ -425,27 +425,7 @@ 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. *(Implemented — user-level units under `systemd/`, run 10s
|
on this box. *(Not implemented — still a single one-shot `cargo run`.)*
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -509,8 +489,7 @@ 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),
|
||||||
per-package `check_interval` (the timer is a fixed hourly tick),
|
scheduling/`check_interval`, non-GitHub sources, `minisign`/tier-1
|
||||||
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.
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ 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
111
src/notifier.rs
|
|
@ -1,111 +0,0 @@
|
||||||
//! 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",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,7 +10,6 @@ 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;
|
||||||
|
|
@ -152,10 +151,6 @@ 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)");
|
||||||
|
|
@ -170,10 +165,6 @@ 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(())
|
||||||
|
|
|
||||||
|
|
@ -1,54 +1,21 @@
|
||||||
//! Test-only fixture helpers shared across modules' `#[cfg(test)]` code
|
//! Test-only fixture helpers shared across modules' `#[cfg(test)]` code
|
||||||
//! (`publisher`, `sanity`, `notifier`) — not production code, and not built outside
|
//! (`publisher`, `sanity`) — 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(
|
std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
|
||||||
&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()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
# 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"
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
# 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
|
|
||||||
Loading…
Reference in a new issue