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>
160 lines
4.8 KiB
Rust
160 lines
4.8 KiB
Rust
use crate::github::GithubEndpoints;
|
|
use anyhow::{Context, Result};
|
|
use serde::Deserialize;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Release {
|
|
assets: Vec<Asset>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct Asset {
|
|
name: String,
|
|
browser_download_url: String,
|
|
}
|
|
|
|
/// A downloaded release asset: its local path plus the URL it came from,
|
|
/// the latter needed for the `source=` line of a generated PKGBUILD (see
|
|
/// `builder`) — `makepkg` uses it only as a fallback if the pre-seeded
|
|
/// local copy ever goes missing.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DownloadedAsset {
|
|
pub path: PathBuf,
|
|
pub download_url: String,
|
|
}
|
|
|
|
/// Downloads the release asset named exactly `asset_name` for `repo`@`tag`
|
|
/// into `dest_dir`, returning the local path and its origin URL.
|
|
pub fn download_asset(
|
|
client: &reqwest::blocking::Client,
|
|
endpoints: &GithubEndpoints,
|
|
repo: &str,
|
|
tag: &str,
|
|
asset_name: &str,
|
|
dest_dir: &Path,
|
|
) -> Result<DownloadedAsset> {
|
|
let api_url = format!("{}/repos/{repo}/releases/tags/{tag}", endpoints.api);
|
|
let release: Release = client
|
|
.get(&api_url)
|
|
.send()?
|
|
.error_for_status()
|
|
.with_context(|| format!("fetching release metadata from {api_url}"))?
|
|
.json()?;
|
|
|
|
let asset = release
|
|
.assets
|
|
.iter()
|
|
.find(|a| a.name == asset_name)
|
|
.with_context(|| format!("no asset named '{asset_name}' in {repo}@{tag}"))?;
|
|
|
|
std::fs::create_dir_all(dest_dir)?;
|
|
let dest_path = dest_dir.join(&asset.name);
|
|
let bytes = client
|
|
.get(&asset.browser_download_url)
|
|
.send()?
|
|
.error_for_status()?
|
|
.bytes()?;
|
|
std::fs::write(&dest_path, &bytes)?;
|
|
Ok(DownloadedAsset {
|
|
path: dest_path,
|
|
download_url: asset.browser_download_url.clone(),
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[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 asset_url = format!("{}/download/thing.tar.gz", server.url());
|
|
let release_body = format!(
|
|
r#"{{"assets": [{{"name": "thing.tar.gz", "browser_download_url": "{asset_url}"}}]}}"#
|
|
);
|
|
let _release = server
|
|
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
|
.with_status(200)
|
|
.with_body(release_body)
|
|
.create();
|
|
let _download = server
|
|
.mock("GET", "/download/thing.tar.gz")
|
|
.with_status(200)
|
|
.with_body(b"artifact-bytes".as_slice())
|
|
.create();
|
|
|
|
let client = reqwest::blocking::Client::new();
|
|
let dest_dir = tempfile::tempdir().unwrap();
|
|
let asset = download_asset(
|
|
&client,
|
|
&endpoints,
|
|
"o/r",
|
|
"v1.0.0",
|
|
"thing.tar.gz",
|
|
dest_dir.path(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(asset.path, dest_dir.path().join("thing.tar.gz"));
|
|
assert_eq!(asset.download_url, asset_url);
|
|
assert_eq!(std::fs::read(&asset.path).unwrap(), b"artifact-bytes");
|
|
}
|
|
|
|
#[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 _release = server
|
|
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
|
.with_status(200)
|
|
.with_body(r#"{"assets": [{"name": "other", "browser_download_url": "https://example.invalid/other"}]}"#)
|
|
.create();
|
|
|
|
let client = reqwest::blocking::Client::new();
|
|
let dest_dir = tempfile::tempdir().unwrap();
|
|
let err = download_asset(
|
|
&client,
|
|
&endpoints,
|
|
"o/r",
|
|
"v1.0.0",
|
|
"thing.tar.gz",
|
|
dest_dir.path(),
|
|
)
|
|
.unwrap_err();
|
|
assert!(err.to_string().contains("no asset named"));
|
|
}
|
|
|
|
#[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 _release = server
|
|
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
|
.with_status(404)
|
|
.create();
|
|
|
|
let client = reqwest::blocking::Client::new();
|
|
let dest_dir = tempfile::tempdir().unwrap();
|
|
let err = download_asset(
|
|
&client,
|
|
&endpoints,
|
|
"o/r",
|
|
"v1.0.0",
|
|
"thing.tar.gz",
|
|
dest_dir.path(),
|
|
)
|
|
.unwrap_err();
|
|
assert!(err.to_string().contains("fetching release metadata"));
|
|
}
|
|
}
|