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>
This commit is contained in:
parent
9955610e09
commit
eef98906b6
8 changed files with 514 additions and 124 deletions
57
docs/SPEC.md
57
docs/SPEC.md
|
|
@ -294,10 +294,26 @@ implements `repo`, `asset_pattern`, and `verification.method`
|
|||
`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 4–6 human review)
|
||||
are now implemented too — see Builder/Sanity checker/Publisher/Reviewer
|
||||
|
|
@ -357,15 +373,32 @@ Open questions on the schema:
|
|||
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/checker.rs`. 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.)*
|
||||
|
||||
Both hosts' HTTP is hand-rolled on the `reqwest` already in the tree,
|
||||
not an API-client crate: what pkgwatch needs is two `GET`s
|
||||
(latest-release, release-by-tag), GitHub's check deliberately uses the
|
||||
Atom feed that no API crate covers (to stay off the rate-limited REST
|
||||
API), and the release-by-tag call is shared verbatim between the two
|
||||
hosts, which two per-host crates would split in two. `octocrab` is
|
||||
async/tokio/hyper against this project's blocking `reqwest`, and its
|
||||
default tree alone (217 crates) is larger than all of pkgwatch's today
|
||||
(143); `forgejo-api` has a `sync` feature but is a generated binding of
|
||||
the whole Forgejo API for one endpoint. Revisit if pkgwatch ever needs
|
||||
authenticated or write API calls (e.g. publishing its own releases from
|
||||
code rather than CI).
|
||||
- **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;
|
||||
|
|
@ -517,7 +550,7 @@ Open questions on the schema:
|
|||
- [ ] Not yet implemented: `pkgwatch review <name> --reject` (a pending
|
||||
review can only be approved or left pending, not dismissed),
|
||||
per-package `check_interval` (the timer is a fixed hourly tick),
|
||||
non-GitHub sources, `minisign`/tier-1
|
||||
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.
|
||||
|
|
|
|||
116
src/checker.rs
116
src/checker.rs
|
|
@ -1,6 +1,51 @@
|
|||
use crate::github::GithubEndpoints;
|
||||
use anyhow::{Result, bail};
|
||||
use crate::source::Endpoints;
|
||||
use anyhow::{Context, Result, bail};
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Resolves the latest release tag for `repo` on whichever service
|
||||
/// `endpoints` points at.
|
||||
pub fn latest_release(
|
||||
client: &reqwest::blocking::Client,
|
||||
endpoints: &Endpoints,
|
||||
repo: &str,
|
||||
) -> Result<String> {
|
||||
match endpoints {
|
||||
Endpoints::Github(github) => latest_github_release(client, github, repo),
|
||||
Endpoints::Forgejo { api } => latest_forgejo_release(client, api, repo),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the latest release tag for `repo` on a Forgejo/Gitea instance.
|
||||
///
|
||||
/// One call, unlike GitHub's feed-then-confirm dance below: 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.
|
||||
pub fn latest_forgejo_release(
|
||||
client: &reqwest::blocking::Client,
|
||||
api: &str,
|
||||
repo: &str,
|
||||
) -> Result<String> {
|
||||
#[derive(Deserialize)]
|
||||
struct Latest {
|
||||
tag_name: String,
|
||||
}
|
||||
|
||||
let url = format!("{api}/repos/{repo}/releases/latest");
|
||||
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)
|
||||
}
|
||||
|
||||
/// Resolves the latest release tag for `repo` via its public Atom feed.
|
||||
///
|
||||
|
|
@ -145,4 +190,73 @@ mod tests {
|
|||
let err = latest_github_release(&client, &endpoints, "o/r").unwrap_err();
|
||||
assert!(err.to_string().contains("resolved to a real release"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_forgejo_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 tag = latest_forgejo_release(&client, &server.url(), "o/r").unwrap();
|
||||
assert_eq!(tag, "v0.1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_forgejo_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 err = latest_forgejo_release(&client, &server.url(), "o/r").unwrap_err();
|
||||
assert!(err.to_string().contains("no published release"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_forgejo_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 err = latest_forgejo_release(&client, &server.url(), "o/r").unwrap_err();
|
||||
assert!(err.to_string().contains("fetching latest release"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn latest_release_dispatches_on_endpoint_kind() {
|
||||
let mut server = mockito::Server::new();
|
||||
let _forgejo = server
|
||||
.mock("GET", "/repos/o/r/releases/latest")
|
||||
.with_status(200)
|
||||
.with_body(r#"{"tag_name": "v2.0.0"}"#)
|
||||
.create();
|
||||
let _feed = server
|
||||
.mock("GET", "/o/r/releases.atom")
|
||||
.with_body(atom_feed(&["v1.0.0"]))
|
||||
.create();
|
||||
let _release = server
|
||||
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
||||
.with_status(200)
|
||||
.with_body("{}")
|
||||
.create();
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let forgejo = Endpoints::Forgejo { api: server.url() };
|
||||
assert_eq!(latest_release(&client, &forgejo, "o/r").unwrap(), "v2.0.0");
|
||||
|
||||
let github = Endpoints::Github(GithubEndpoints {
|
||||
web: server.url(),
|
||||
api: server.url(),
|
||||
});
|
||||
assert_eq!(latest_release(&client, &github, "o/r").unwrap(), "v1.0.0");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
126
src/config.rs
126
src/config.rs
|
|
@ -2,7 +2,7 @@
|
|||
//! 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::{BTreeMap, HashMap};
|
||||
use std::path::Path;
|
||||
|
|
@ -12,9 +12,28 @@ 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,
|
||||
#[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 GitHub 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
|
||||
|
|
@ -60,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 {
|
||||
|
|
@ -107,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)
|
||||
|
|
@ -258,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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
//! `checker` 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 `source::Endpoints::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",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ mod paths;
|
|||
mod pipeline;
|
||||
mod publisher;
|
||||
mod sanity;
|
||||
mod source;
|
||||
mod state;
|
||||
#[cfg(test)]
|
||||
mod test_support;
|
||||
|
|
|
|||
181
src/pipeline.rs
181
src/pipeline.rs
|
|
@ -9,11 +9,11 @@ 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::sanity;
|
||||
use crate::source::Endpoints;
|
||||
use crate::state;
|
||||
use crate::verifier::{self, VerificationResult};
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
|
@ -57,7 +57,6 @@ fn load_packages(packages_dir: &Path) -> Result<Vec<(String, Package)>> {
|
|||
|
||||
pub fn run_check() -> Result<()> {
|
||||
let client = build_client()?;
|
||||
let endpoints = GithubEndpoints::default();
|
||||
let Paths {
|
||||
packages_dir,
|
||||
state_dir,
|
||||
|
|
@ -73,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 = Endpoints::for_package(pkg).and_then(|endpoints| {
|
||||
process_package(&client, &endpoints, &state_dir, &work_dir, name, pkg)
|
||||
});
|
||||
if let Err(err) = result {
|
||||
eprintln!(" error: {err:#}");
|
||||
any_failed = true;
|
||||
}
|
||||
|
|
@ -118,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,
|
||||
endpoints: &Endpoints,
|
||||
state_dir: &Path,
|
||||
work_dir: &Path,
|
||||
name: &str,
|
||||
pkg: &Package,
|
||||
) -> Result<()> {
|
||||
let latest = checker::latest_github_release(client, endpoints, &pkg.repo)?;
|
||||
let latest = checker::latest_release(client, endpoints, &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}");
|
||||
|
|
@ -205,7 +207,7 @@ struct FetchVerifyResult {
|
|||
/// trusting a possibly-stale flag from an earlier run).
|
||||
fn fetch_and_verify(
|
||||
client: &reqwest::blocking::Client,
|
||||
endpoints: &GithubEndpoints,
|
||||
endpoints: &Endpoints,
|
||||
work_dir: &Path,
|
||||
name: &str,
|
||||
pkg: &Package,
|
||||
|
|
@ -215,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 = endpoints.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,
|
||||
|
|
@ -317,7 +320,7 @@ 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 endpoints = Endpoints::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.
|
||||
|
|
@ -343,6 +346,7 @@ fn approve(state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, tag: &s
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::github::GithubEndpoints;
|
||||
|
||||
#[test]
|
||||
fn decide_tier_action_failed_verification_overrides_everything() {
|
||||
|
|
@ -374,6 +378,83 @@ 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 `Endpoints::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(),
|
||||
]
|
||||
}
|
||||
|
||||
/// `pkg_toml_extra` is spliced in before the `[verification]` table, so
|
||||
/// it can carry top-level keys like `source`.
|
||||
fn same_origin_package(pkg_toml_extra: &str) -> Package {
|
||||
toml::from_str(&format!(
|
||||
r#"
|
||||
repo = "o/r"
|
||||
asset_pattern = "thing.tar.gz"
|
||||
{pkg_toml_extra}
|
||||
[verification]
|
||||
method = "same-origin-sha256"
|
||||
checksum_asset_pattern = "SHA256SUMS"
|
||||
"#
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// 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(endpoints: &Endpoints, 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,
|
||||
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);
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
@ -384,11 +465,10 @@ mod tests {
|
|||
#[test]
|
||||
fn process_package_returns_err_on_verification_failure() {
|
||||
let mut server = mockito::Server::new();
|
||||
let endpoints = GithubEndpoints {
|
||||
let endpoints = Endpoints::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()
|
||||
|
|
@ -398,63 +478,28 @@ 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}"}}
|
||||
]}}"#
|
||||
assert_verification_failure_is_an_error(&endpoints, &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 endpoints = Endpoints::Forgejo { api: server.url() };
|
||||
let _latest = server
|
||||
.mock("GET", "/repos/o/r/releases/latest")
|
||||
.with_status(200)
|
||||
.with_body(r#"{"tag_name": "v1.0.0"}"#)
|
||||
.create();
|
||||
let _release_mocks = mock_release_with_bad_checksum(&mut server);
|
||||
|
||||
let pkg = same_origin_package(
|
||||
"source = \"forgejo-release\"\nbase_url = \"https://forge.example.com\"",
|
||||
);
|
||||
let _release = server
|
||||
.mock("GET", "/repos/o/r/releases/tags/v1.0.0")
|
||||
.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",
|
||||
)
|
||||
.create();
|
||||
|
||||
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);
|
||||
assert_verification_failure_is_an_error(&endpoints, &pkg);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
94
src/source.rs
Normal file
94
src/source.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//! Maps a package's configured `source` to the endpoints the pipeline
|
||||
//! stages talk to. The only module that knows how each hosting service
|
||||
//! lays out its URLs; `checker` and `fetcher` take what it hands them.
|
||||
|
||||
use crate::config::{Package, Source};
|
||||
use crate::github::GithubEndpoints;
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Endpoints {
|
||||
Github(GithubEndpoints),
|
||||
/// A Forgejo/Gitea instance's API root, i.e. `<base_url>/api/v1`.
|
||||
Forgejo {
|
||||
api: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl Endpoints {
|
||||
/// Errors only if a `forgejo-release` package has no `base_url`, which
|
||||
/// `config::load_packages_dir` already rejects — this is the same check
|
||||
/// again for a `Package` built some other way, not a second source of
|
||||
/// truth.
|
||||
pub fn for_package(pkg: &Package) -> Result<Self> {
|
||||
match pkg.source {
|
||||
Source::GithubRelease => Ok(Endpoints::Github(GithubEndpoints::default())),
|
||||
Source::ForgejoRelease => {
|
||||
let base_url = pkg
|
||||
.base_url
|
||||
.as_deref()
|
||||
.context("source = \"forgejo-release\" needs a base_url")?;
|
||||
Ok(Endpoints::Forgejo {
|
||||
api: format!("{}/api/v1", base_url.trim_end_matches('/')),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The releases API root. GitHub and Forgejo both serve
|
||||
/// `/repos/{owner}/{repo}/releases/tags/{tag}` under it with the same
|
||||
/// `assets[].{name, browser_download_url}` shape, which is why
|
||||
/// `fetcher` and `verifier` need only this and not the enum.
|
||||
pub fn api(&self) -> &str {
|
||||
match self {
|
||||
Endpoints::Github(github) => &github.api,
|
||||
Endpoints::Forgejo { api } => api,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn package(extra: &str) -> Package {
|
||||
toml::from_str(&format!(
|
||||
r#"
|
||||
repo = "o/r"
|
||||
asset_pattern = "x"
|
||||
{extra}
|
||||
[verification]
|
||||
method = "same-origin-sha256"
|
||||
checksum_asset_pattern = "SUMS"
|
||||
"#
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_source_uses_real_github() {
|
||||
let endpoints = Endpoints::for_package(&package("")).unwrap();
|
||||
assert_eq!(endpoints.api(), "https://api.github.com");
|
||||
assert!(matches!(endpoints, Endpoints::Github(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgejo_source_appends_api_v1() {
|
||||
let pkg = package("source = \"forgejo-release\"\nbase_url = \"https://code.example.com\"");
|
||||
let endpoints = Endpoints::for_package(&pkg).unwrap();
|
||||
assert_eq!(endpoints.api(), "https://code.example.com/api/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgejo_source_tolerates_trailing_slash() {
|
||||
let pkg = package("source = \"forgejo-release\"\nbase_url = \"https://code.example.com/\"");
|
||||
let endpoints = Endpoints::for_package(&pkg).unwrap();
|
||||
assert_eq!(endpoints.api(), "https://code.example.com/api/v1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgejo_source_without_base_url_errors() {
|
||||
let err = Endpoints::for_package(&package("source = \"forgejo-release\"")).unwrap_err();
|
||||
assert!(err.to_string().contains("needs a base_url"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue