Add ARCHITECTURE.md and apply it to this PR's code
Researched current industry practice on code organization/maintainability
(Ousterhout's deep modules and information hiding, package-by-feature vs.
package-by-layer, functional-core/imperative-shell testability, tech-debt
prevention via ADR-equivalent inline rationale) and wrote it into
ARCHITECTURE.md as a set of concrete, project-specific rules rather than
a generic essay — each principle cites a real example already in this
codebase or fixed by this commit. Cross-linked from SPEC.md, which stays
about product design, not code organization.
Applied it to this PR's own code:
- Pulled process_package/fetch_and_verify/build_and_publish/run_review/
approve out of main.rs into a new pipeline.rs. main.rs's own main() had
grown to 278 lines and zero tests by treating "it's just the entry
point" as an excuse to skip separating logic from wiring; now main.rs
is argv dispatch only.
- Extracted decide_tier_action as a pure function (verification outcome +
pending-state -> what to do), replacing dispatch logic that was
previously inlined into a function that also made the real network/
build calls. Four unit tests, no I/O, covering all four outcomes.
- Added a `//!` module doc comment to every file touched in this branch,
each stating that module's one job in a sentence, per the "deep
modules" principle the spec argues for.
Coverage's reported total drops (94% -> 78%) because pipeline.rs is
deliberately NOT excluded from it the way main.rs is, even though it's
mostly the same kind of untestable I/O orchestration — excluding it would
hide decide_tier_action's real unit-test coverage along with the untested
parts. Noted inline in Makefile.toml/ci.yml so the number doesn't look
like a quality regression at a glance.
Also added a project reference memory pointing at ARCHITECTURE.md rather
than duplicating its content there, per this session's own memory-hygiene
rules (architecture/conventions are derivable from the repo and shouldn't
be duplicated somewhere that can go stale).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:42:18 +00:00
|
|
|
//! Post-build correctness check: runs the freshly built binary and
|
|
|
|
|
//! confirms it reports the version pkgwatch believes it just built. Not a
|
|
|
|
|
//! security control — see SPEC.md > Verification trust tiers.
|
|
|
|
|
|
Close the loop: build, sanity-check, and publish for the first time
Implements the last unimplemented pipeline stage from SPEC.md: PKGBUILD
generation + makepkg (builder.rs), a post-build version sanity check
(sanity.rs), and repo-add publishing (publisher.rs), wired into main.rs
for both the tier 1-3 auto-publish path and a new tier 4-6 review queue
(`pkgwatch review` / `pkgwatch review <name> --approve`, persisted via
state::{load,save,clear}_pending_version, tracked separately from
last-published-version since approving one release isn't a standing
auto-publish grant for future ones).
Publishing targets an existing, already-registered local pacman repo
(~/.local/share/pacman/custom, `[custom]` in /etc/pacman.conf) rather
than one pkgwatch invents — found already in real use for a hand-packaged
AppImage, which resolves SPEC's open question on where the repo lives
without pkgwatch ever touching pacman.conf. Publishing stops at
`repo-add`; actually installing/upgrading (`pacman -Syu`/`pacman -S`) is
left to the operator, not run automatically.
Getting a real second package (scaleway-cli, tier 4) through the new
pipeline immediately surfaced a real gap: its pacman package is named
`scaleway-cli` but the actual binary is `scw` (confirmed via `pacman -Ql`
against the currently-installed extra package) — without a way to
declare that, the build would install alongside extra's package under
the wrong name instead of shadowing it. Added `Package::binary_name`
(config.rs) to cover it.
Every upstream-controlled string (version, asset name, download URL)
is validated before it touches generated shell content in the PKGBUILD
template — rejects anything containing a single quote or newline, since
values are embedded in single-quoted bash strings.
Verified for real, end to end: uv (tier 2) auto-built and published
against the real astral-sh/uv release with no human step; scaleway-cli
(tier 4) queued for review, then approved via `pkgwatch review
scaleway-cli --approve`, which re-verified, built, and published it —
confirmed the built package contains exactly usr/bin/scw. Both landed in
the real custom repo's database. Left scaleway-cli's real-repo review
pending rather than approving it myself: the tier 4-6 gate exists for a
human judgment call, not the agent's.
69 tests, cargo make ci clean (fmt, clippy, complexity, coverage, audit).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 09:13:13 +00:00
|
|
|
use crate::config::SanityCheck;
|
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
|
|
|
use regex::Regex;
|
|
|
|
|
use std::path::Path;
|
|
|
|
|
use std::process::Command;
|
|
|
|
|
|
|
|
|
|
/// Runs `check.command` with `pkg_bin_dir` prepended to `PATH`, so it
|
|
|
|
|
/// exercises the binary pkgwatch just built (still sitting in makepkg's
|
|
|
|
|
/// package staging directory, not installed system-wide) rather than
|
|
|
|
|
/// whatever's already on the system. Confirms `check.version_regex`'s
|
|
|
|
|
/// capture group matches `expected_version`.
|
|
|
|
|
///
|
|
|
|
|
/// Correctness check only, not a security control — see SPEC.md >
|
|
|
|
|
/// Verification trust tiers. Catches checker bugs and mangled/wrong-asset
|
|
|
|
|
/// downloads, not malicious releases.
|
|
|
|
|
pub fn run(check: &SanityCheck, pkg_bin_dir: &Path, expected_version: &str) -> Result<()> {
|
|
|
|
|
let path_env = format!(
|
|
|
|
|
"{}:{}",
|
|
|
|
|
pkg_bin_dir.display(),
|
|
|
|
|
std::env::var("PATH").unwrap_or_default()
|
|
|
|
|
);
|
|
|
|
|
let output = Command::new("sh")
|
|
|
|
|
.arg("-c")
|
|
|
|
|
.arg(&check.command)
|
|
|
|
|
.env("PATH", path_env)
|
|
|
|
|
.output()
|
|
|
|
|
.with_context(|| format!("running sanity check command '{}'", check.command))?;
|
|
|
|
|
if !output.status.success() {
|
|
|
|
|
bail!(
|
|
|
|
|
"sanity check command '{}' exited with {}: {}",
|
|
|
|
|
check.command,
|
|
|
|
|
output.status,
|
|
|
|
|
String::from_utf8_lossy(&output.stderr).trim()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let combined = format!(
|
|
|
|
|
"{}{}",
|
|
|
|
|
String::from_utf8_lossy(&output.stdout),
|
|
|
|
|
String::from_utf8_lossy(&output.stderr)
|
|
|
|
|
);
|
|
|
|
|
let re = Regex::new(&check.version_regex)
|
|
|
|
|
.with_context(|| format!("invalid version_regex '{}'", check.version_regex))?;
|
|
|
|
|
let found = re
|
|
|
|
|
.captures(&combined)
|
|
|
|
|
.and_then(|caps| caps.get(1))
|
|
|
|
|
.with_context(|| {
|
|
|
|
|
format!(
|
|
|
|
|
"version_regex '{}' did not match sanity check output: {combined:?}",
|
|
|
|
|
check.version_regex
|
|
|
|
|
)
|
|
|
|
|
})?
|
|
|
|
|
.as_str();
|
|
|
|
|
|
|
|
|
|
if found != expected_version {
|
|
|
|
|
bail!(
|
|
|
|
|
"sanity check reported version '{found}', pkgwatch built '{expected_version}' — mismatch"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn write_fake_binary(dir: &Path, name: &str, script: &str) {
|
|
|
|
|
let path = dir.join(name);
|
|
|
|
|
std::fs::write(&path, format!("#!/bin/sh\n{script}\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();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn run_passes_when_reported_version_matches() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
write_fake_binary(dir.path(), "uv", "echo 'uv 0.12.15 (abc 2026-09-01)'");
|
|
|
|
|
|
|
|
|
|
let check = SanityCheck {
|
|
|
|
|
command: "uv --version".to_string(),
|
|
|
|
|
version_regex: r"uv (\d+\.\d+\.\d+)".to_string(),
|
|
|
|
|
};
|
|
|
|
|
assert!(run(&check, dir.path(), "0.12.15").is_ok());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn run_fails_when_reported_version_differs() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
write_fake_binary(dir.path(), "uv", "echo 'uv 0.12.14 (abc 2026-08-01)'");
|
|
|
|
|
|
|
|
|
|
let check = SanityCheck {
|
|
|
|
|
command: "uv --version".to_string(),
|
|
|
|
|
version_regex: r"uv (\d+\.\d+\.\d+)".to_string(),
|
|
|
|
|
};
|
|
|
|
|
let err = run(&check, dir.path(), "0.12.15").unwrap_err();
|
|
|
|
|
assert!(err.to_string().contains("mismatch"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn run_fails_when_command_exits_nonzero() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
write_fake_binary(dir.path(), "uv", "exit 1");
|
|
|
|
|
|
|
|
|
|
let check = SanityCheck {
|
|
|
|
|
command: "uv --version".to_string(),
|
|
|
|
|
version_regex: r"uv (\d+\.\d+\.\d+)".to_string(),
|
|
|
|
|
};
|
|
|
|
|
let err = run(&check, dir.path(), "0.12.15").unwrap_err();
|
|
|
|
|
assert!(err.to_string().contains("exited with"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn run_fails_when_output_does_not_match_regex() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
write_fake_binary(dir.path(), "uv", "echo 'not a version'");
|
|
|
|
|
|
|
|
|
|
let check = SanityCheck {
|
|
|
|
|
command: "uv --version".to_string(),
|
|
|
|
|
version_regex: r"uv (\d+\.\d+\.\d+)".to_string(),
|
|
|
|
|
};
|
|
|
|
|
let err = run(&check, dir.path(), "0.12.15").unwrap_err();
|
|
|
|
|
assert!(err.to_string().contains("did not match"));
|
|
|
|
|
}
|
|
|
|
|
}
|