From eef98906b6a4543c3d241e71c0d42412c983bf02 Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Sun, 20 Sep 2026 10:36:13 +0200 Subject: [PATCH 1/5] 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/ 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 --- docs/SPEC.md | 57 +++++++++++---- src/checker.rs | 116 ++++++++++++++++++++++++++++++- src/config.rs | 126 ++++++++++++++++++++++++++++++++- src/fetcher.rs | 36 ++++------ src/main.rs | 1 + src/pipeline.rs | 181 ++++++++++++++++++++++++++++++------------------ src/source.rs | 94 +++++++++++++++++++++++++ src/verifier.rs | 27 ++------ 8 files changed, 514 insertions(+), 124 deletions(-) create mode 100644 src/source.rs diff --git a/docs/SPEC.md b/docs/SPEC.md index 3361cbe..842d338 100644 --- a/docs/SPEC.md +++ b/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/` 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/` 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 + `/api/v1/repos//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/` 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 --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. diff --git a/src/checker.rs b/src/checker.rs index 676120d..4f2057e 100644 --- a/src/checker.rs +++ b/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 { + 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 { + #[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"); + } } diff --git a/src/config.rs b/src/config.rs index 9c242b2..5123dd7 100644 --- a/src/config.rs +++ b/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, } +/// 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, /// 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> { .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 { + 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()); + } } diff --git a/src/fetcher.rs b/src/fetcher.rs index 2614258..4770ac8 100644 --- a/src/fetcher.rs +++ b/src/fetcher.rs @@ -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 { - 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", diff --git a/src/main.rs b/src/main.rs index e238779..9b9255b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,7 @@ mod paths; mod pipeline; mod publisher; mod sanity; +mod source; mod state; #[cfg(test)] mod test_support; diff --git a/src/pipeline.rs b/src/pipeline.rs index 753698e..c9f523e 100644 --- a/src/pipeline.rs +++ b/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> { 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 { + 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#""#, 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); } } diff --git a/src/source.rs b/src/source.rs new file mode 100644 index 0000000..03bd059 --- /dev/null +++ b/src/source.rs @@ -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. `/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 { + 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")); + } +} diff --git a/src/verifier.rs b/src/verifier.rs index 7ffa4d9..7b301d6 100644 --- a/src/verifier.rs +++ b/src/verifier.rs @@ -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", From e25ff8f6da38c9199a3413314de92cb025885ef8 Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Sun, 20 Sep 2026 10:39:35 +0200 Subject: [PATCH 2/5] Apply self-review feedback on the Forgejo source Fix stale ARCHITECTURE/config docs, soften source.rs's overclaim, trim the SPEC's crate paragraph (no brittle counts), and merge the duplicate same-origin Package test helper into test_support. Co-Authored-By: Claude Sonnet 5 --- docs/ARCHITECTURE.md | 4 ++-- docs/SPEC.md | 20 ++++++++------------ src/config.rs | 2 +- src/pipeline.rs | 24 ++++-------------------- src/source.rs | 25 +++++-------------------- src/test_support.rs | 30 +++++++++++++++++++++++++----- 6 files changed, 45 insertions(+), 60 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index df2058c..c42f9ad 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -61,8 +61,8 @@ practice rather than asserted from habit — see Further reading. Same testability goal as #3, applied to the specific ways this program reaches outside itself. A consistent shape beats ad hoc mocking invented per call site. - **Already in force**: `GithubEndpoints` (checker/fetcher/verifier), - `repo_add_bin` and the pacman.conf path (publisher), `PKGWATCH_REPO_DIR` + **Already in force**: `source::Endpoints` (checker), the plain API root + it hands to fetcher/verifier, `repo_add_bin` and the pacman.conf path (publisher), `PKGWATCH_REPO_DIR` (main, for manual dry runs against a scratch repo instead of the real one). A new external call follows the same shape: production code calls a thin wrapper with the real default; tests call the parameterized diff --git a/docs/SPEC.md b/docs/SPEC.md index 842d338..220d05c 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -382,18 +382,14 @@ Open questions on the schema: 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). + The HTTP is hand-rolled on the `reqwest` already in the tree, not an + API-client crate: pkgwatch needs two `GET`s, GitHub's check deliberately + uses an Atom feed no API crate covers (to stay off the rate-limited REST + API), and the release-by-tag call is shared verbatim by both hosts. + `octocrab` is async against our blocking `reqwest` with a default tree + larger than pkgwatch's whole current one; `forgejo-api` is a generated + binding of the entire API for one endpoint. Revisit if pkgwatch needs + authenticated or write API calls. - **Fetcher**: downloads the artifact (and any checksum/signature/ attestation companion) for a resolved version. *(Implemented — `src/fetcher.rs`, via the GitHub or Forgejo releases API — same diff --git a/src/config.rs b/src/config.rs index 5123dd7..3119eaa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -34,7 +34,7 @@ pub struct Package { /// (the API lives under `/api/v1`). Required for, and only meaningful /// with, `source = "forgejo-release"`. pub base_url: Option, - /// Exact GitHub release asset name (still not a glob — see + /// Exact 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 /// version (e.g. `scaleway-cli_{version}_linux_amd64`). Substituted via diff --git a/src/pipeline.rs b/src/pipeline.rs index c9f523e..88fa9e7 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -347,6 +347,7 @@ fn approve(state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, tag: &s mod tests { use super::*; use crate::github::GithubEndpoints; + use crate::test_support::same_origin_package; #[test] fn decide_tier_action_failed_verification_overrides_everything() { @@ -414,22 +415,6 @@ mod tests { ] } - /// `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. @@ -497,9 +482,8 @@ mod tests { .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\"", - ); - assert_verification_failure_is_an_error(&endpoints, &pkg); + // The package's own `source` is irrelevant here: `endpoints` is + // hand-built to point at the mock server, bypassing `for_package`. + assert_verification_failure_is_an_error(&endpoints, &same_origin_package("")); } } diff --git a/src/source.rs b/src/source.rs index 03bd059..c014dba 100644 --- a/src/source.rs +++ b/src/source.rs @@ -1,6 +1,6 @@ //! 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. +//! stages talk to. `checker` and `fetcher` take what it hands them and +//! build their own paths under it. use crate::config::{Package, Source}; use crate::github::GithubEndpoints; @@ -16,10 +16,8 @@ pub enum Endpoints { } 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. + /// Defensive: errors only if a `forgejo-release` package has no + /// `base_url`, which `config::load_packages_dir` already guarantees. pub fn for_package(pkg: &Package) -> Result { match pkg.source { Source::GithubRelease => Ok(Endpoints::Github(GithubEndpoints::default())), @@ -50,20 +48,7 @@ impl Endpoints { #[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() - } + use crate::test_support::same_origin_package as package; #[test] fn github_source_uses_real_github() { diff --git a/src/test_support.rs b/src/test_support.rs index d53c80b..09d72f4 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -1,10 +1,11 @@ //! Test-only fixture helpers shared across modules' `#[cfg(test)]` code -//! (`publisher`, `sanity`, `notifier`) — not production code, and not built outside -//! `cargo test`. See docs/ARCHITECTURE.md > "organize by pipeline stage, not -//! by layer": this exists to remove one specific piece of duplication -//! (two near-identical copies of "write an executable shell script"), not -//! as a general test-utils dump. +//! — not production code, and not built outside `cargo test`. See +//! docs/ARCHITECTURE.md > "organize by pipeline stage, not by layer": this +//! exists to remove specific pieces of duplication (near-identical copies +//! of "write an executable shell script" and of "build a same-origin +//! `Package` from TOML"), not as a general test-utils dump. +use crate::config::Package; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, Instant}; @@ -52,3 +53,22 @@ fn wait_until_executable(path: &Path) { } } } + +/// A `same-origin-sha256` `Package` for repo `o/r`, asset `thing.tar.gz`, +/// checksum asset `SHA256SUMS`. `extra` is spliced in as top-level keys +/// before the `[verification]` table (e.g. `source`/`base_url`), and is +/// parsed directly rather than through `config::load_packages_dir`, so it +/// skips load-time validation. +pub(crate) fn same_origin_package(extra: &str) -> Package { + toml::from_str(&format!( + r#" + repo = "o/r" + asset_pattern = "thing.tar.gz" + {extra} + [verification] + method = "same-origin-sha256" + checksum_asset_pattern = "SHA256SUMS" + "# + )) + .unwrap() +} From ba2c5c2d02f3b25307fbd6b91b71cd11d0373793 Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Sun, 20 Sep 2026 10:57:14 +0200 Subject: [PATCH 3/5] Make release sources polymorphic via a ReleaseSource trait Replaces the Endpoints enum and checker's per-host dispatch: checker.rs now holds only the ReleaseSource trait (latest release + API root), each host implements it in its own module (github.rs, forgejo.rs), and source.rs is a factory returning a Box per package. Adding a host no longer touches existing ones, and the pipeline only sees the trait. Co-Authored-By: Claude Sonnet 5 --- docs/ARCHITECTURE.md | 9 +- docs/SPEC.md | 19 ++-- src/checker.rs | 250 ++++--------------------------------------- src/fetcher.rs | 2 +- src/forgejo.rs | 112 +++++++++++++++++++ src/github.rs | 137 ++++++++++++++++++++++++ src/main.rs | 1 + src/pipeline.rs | 48 +++++---- src/source.rs | 74 ++++--------- 9 files changed, 336 insertions(+), 316 deletions(-) create mode 100644 src/forgejo.rs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c42f9ad..4440080 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -61,8 +61,9 @@ practice rather than asserted from habit — see Further reading. Same testability goal as #3, applied to the specific ways this program reaches outside itself. A consistent shape beats ad hoc mocking invented per call site. - **Already in force**: `source::Endpoints` (checker), the plain API root - it hands to fetcher/verifier, `repo_add_bin` and the pacman.conf path (publisher), `PKGWATCH_REPO_DIR` + **Already in force**: `GithubEndpoints`/`ForgejoEndpoints` (the + `ReleaseSource` implementations, whose API root is what fetcher/verifier + take), `repo_add_bin` and the pacman.conf path (publisher), `PKGWATCH_REPO_DIR` (main, for manual dry runs against a scratch repo instead of the real one). A new external call follows the same shape: production code calls a thin wrapper with the real default; tests call the parameterized @@ -106,8 +107,8 @@ practice rather than asserted from habit — see Further reading. directory, but the same information — why this way and not the obvious alternative — needs to live somewhere a future reader will actually see it: the doc comment on the thing itself. - **Example already here**: `checker.rs`'s doc comment on - `latest_github_release` explains why the newest Atom-feed entry isn't + **Example already here**: `github.rs`'s doc comment on + `GithubEndpoints::latest_release` explains why the newest Atom-feed entry isn't trusted outright (scaleway-cli's `-dbg1` tag has no real Release behind it) — the reasoning lives right next to the code it justifies, not in a commit message or a separate design doc no one will find later. diff --git a/docs/SPEC.md b/docs/SPEC.md index 220d05c..b3262bd 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -287,7 +287,7 @@ implements `repo`, `asset_pattern`, and `verification.method` matches the line by filename instead of assuming a single-hash file. - Separately, scaleway-cli's Atom feed lists a `vX.Y.Z-dbg1` tag newest, with no real Release object behind it (`releases/tags/` 404s) — - `checker::latest_github_release` now confirms each feed candidate + the GitHub source's `latest_release` now confirms each feed candidate against the releases API in feed order rather than trusting the first entry outright. @@ -373,10 +373,15 @@ 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 and Forgejo — `src/checker.rs`. GitHub - regex-matches the first `releases/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 + *(Implemented for GitHub and Forgejo. `src/checker.rs` defines the + `ReleaseSource` trait (latest release + API root); each host implements + it in its own module (`github.rs`, `forgejo.rs`), and `source.rs` picks + one per package, so adding a host doesn't touch existing ones. A trait + rather than an enum match because there are now two real hosts with + genuinely different logic. GitHub regex-matches the first + `releases/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 `/api/v1/repos//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 @@ -519,8 +524,8 @@ Open questions on the schema: confirmed correct — no build-provenance attestations upstream. Required adding `{version}`-placeholder support to `asset_pattern`/ `checksum_asset_pattern`, filename-matched parsing of combined - multi-asset checksum files, and having `latest_github_release` - confirm each Atom-feed candidate against the releases API (this + multi-asset checksum files, and having the GitHub source's + `latest_release` confirm each Atom-feed candidate against the releases API (this repo's newest feed entry, a `-dbg1` tag, has no real Release behind it). Still just flags for human review, same as any tier 4-6 pass — not auto-installed; see the unchecked build/publish item below. diff --git a/src/checker.rs b/src/checker.rs index 4f2057e..33aa612 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -1,89 +1,24 @@ -use crate::github::GithubEndpoints; -use crate::source::Endpoints; -use anyhow::{Context, Result, bail}; -use regex::Regex; -use serde::Deserialize; +//! The check stage's contract: a `ReleaseSource` says what a package's +//! latest release is and where its releases API lives, and +//! `version_from_tag` turns the resulting tag into a version string. Each +//! hosting service implements the trait in its own module (`github`, +//! `forgejo`) so per-host logic doesn't accumulate here; +//! `source::for_package` picks the implementation for a package. -/// 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 { - match endpoints { - Endpoints::Github(github) => latest_github_release(client, github, repo), - Endpoints::Forgejo { api } => latest_forgejo_release(client, api, repo), - } -} +use anyhow::Result; -/// 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 { - #[derive(Deserialize)] - struct Latest { - tag_name: String, - } +/// A hosting service a package's releases are published on. `Debug` so a +/// `Box` can sit in a `Result` that tests unwrap. +pub trait ReleaseSource: std::fmt::Debug { + /// The latest release tag for `repo`. + fn latest_release(&self, client: &reqwest::blocking::Client, repo: &str) -> Result; - 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. -/// -/// Deliberately not a full XML parse: the feed lists entries newest-first, -/// and each `"/>` is matched -/// in document order. Revisit with a real XML parser if GitHub's feed -/// shape ever changes. -/// -/// The feed can list a tag newer than any tag with a real Release object -/// behind it — observed on scaleway/scaleway-cli, which pushes a -/// `vX.Y.Z-dbg1` tag (no corresponding Release; `releases/tags/` -/// 404s) right after each real release, and that tag sorts newest in the -/// feed. So each candidate is confirmed against the releases API in feed -/// order, returning the first that actually resolves. -pub fn latest_github_release( - client: &reqwest::blocking::Client, - endpoints: &GithubEndpoints, - repo: &str, -) -> Result { - let url = format!("{}/{repo}/releases.atom", endpoints.web); - let body = client.get(&url).send()?.error_for_status()?.text()?; - - let re = Regex::new(r#"releases/tag/([^"]+)""#)?; - let mut candidates = re - .captures_iter(&body) - .map(|caps| caps[1].to_string()) - .peekable(); - if candidates.peek().is_none() { - bail!("no release tag found in {url}"); - } - - for tag in candidates { - let release_url = format!("{}/repos/{repo}/releases/tags/{tag}", endpoints.api); - if client.get(&release_url).send()?.status().is_success() { - return Ok(tag); - } - } - bail!("no release tag in {url} resolved to a real release via the API") + /// The releases API root, which `fetcher` and `verifier` build their + /// own paths under. GitHub and Forgejo both serve + /// `/repos/{owner}/{repo}/releases/tags/{tag}` there with the same + /// `assets[].{name, browser_download_url}` shape, which is why those + /// stages need only this and not the source itself. + fn api(&self) -> &str; } /// Strips a leading `v` from a release tag, e.g. `v2.62.0` -> `2.62.0`. @@ -110,153 +45,4 @@ mod tests { fn version_from_tag_leaves_bare_version_unchanged() { assert_eq!(version_from_tag("0.12.15"), "0.12.15"); } - - fn atom_feed(tags: &[&str]) -> String { - let entries: String = tags - .iter() - .map(|t| { - format!(r#""#) - }) - .collect(); - format!("{entries}") - } - - #[test] - fn latest_github_release_skips_tags_with_no_real_release() { - let mut server = mockito::Server::new(); - let endpoints = GithubEndpoints { - web: server.url(), - api: server.url(), - }; - - // Mirrors the real scaleway-cli case: newest feed entry (a -dbg1 - // tag) has no Release object behind it and 404s. - let _feed = server - .mock("GET", "/o/r/releases.atom") - .with_status(200) - .with_body(atom_feed(&["v2.62.0-dbg1", "v2.62.0"])) - .create(); - let _missing = server - .mock("GET", "/repos/o/r/releases/tags/v2.62.0-dbg1") - .with_status(404) - .create(); - let _real = server - .mock("GET", "/repos/o/r/releases/tags/v2.62.0") - .with_status(200) - .with_body("{}") - .create(); - - let client = reqwest::blocking::Client::new(); - let tag = latest_github_release(&client, &endpoints, "o/r").unwrap(); - assert_eq!(tag, "v2.62.0"); - } - - #[test] - fn latest_github_release_errors_when_feed_has_no_tags() { - let mut server = mockito::Server::new(); - let endpoints = GithubEndpoints { - web: server.url(), - api: server.url(), - }; - let _feed = server - .mock("GET", "/o/r/releases.atom") - .with_status(200) - .with_body("") - .create(); - - let client = reqwest::blocking::Client::new(); - let err = latest_github_release(&client, &endpoints, "o/r").unwrap_err(); - assert!(err.to_string().contains("no release tag found")); - } - - #[test] - fn latest_github_release_errors_when_no_candidate_resolves() { - let mut server = mockito::Server::new(); - let endpoints = GithubEndpoints { - web: server.url(), - api: server.url(), - }; - let _feed = server - .mock("GET", "/o/r/releases.atom") - .with_status(200) - .with_body(atom_feed(&["v1.0.0-dbg1"])) - .create(); - let _missing = server - .mock("GET", "/repos/o/r/releases/tags/v1.0.0-dbg1") - .with_status(404) - .create(); - - let client = reqwest::blocking::Client::new(); - 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"); - } } diff --git a/src/fetcher.rs b/src/fetcher.rs index 4770ac8..3fbafaa 100644 --- a/src/fetcher.rs +++ b/src/fetcher.rs @@ -29,7 +29,7 @@ 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. `api` is -/// the releases API root (see `source::Endpoints::api`); GitHub and Forgejo +/// the releases API root (see `ReleaseSource::api`); GitHub and Forgejo /// serve the same endpoint and JSON shape under it. pub fn download_asset( client: &reqwest::blocking::Client, diff --git a/src/forgejo.rs b/src/forgejo.rs new file mode 100644 index 0000000..af1ba00 --- /dev/null +++ b/src/forgejo.rs @@ -0,0 +1,112 @@ +//! A Forgejo (or Gitea) instance as a release source: its API root, and +//! how to find a repo's latest release there. + +use crate::checker::ReleaseSource; +use anyhow::{Context, Result, bail}; +use serde::Deserialize; + +#[derive(Debug, Clone)] +pub struct ForgejoEndpoints { + /// The instance's API root, i.e. `/api/v1`. + pub api: String, +} + +impl ForgejoEndpoints { + /// `base_url` is the instance's web root, e.g. + /// `https://code.austinschaefer.com`; a trailing slash is tolerated. + pub fn from_base_url(base_url: &str) -> Self { + Self { + api: format!("{}/api/v1", base_url.trim_end_matches('/')), + } + } +} + +impl ReleaseSource for ForgejoEndpoints { + /// One call, unlike GitHub's feed-then-confirm dance: 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. + fn latest_release(&self, client: &reqwest::blocking::Client, repo: &str) -> Result { + #[derive(Deserialize)] + struct Latest { + tag_name: String, + } + + let url = format!("{}/repos/{repo}/releases/latest", self.api); + 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) + } + + fn api(&self) -> &str { + &self.api + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_base_url_appends_api_v1() { + let endpoints = ForgejoEndpoints::from_base_url("https://code.example.com"); + assert_eq!(endpoints.api(), "https://code.example.com/api/v1"); + } + + #[test] + fn from_base_url_tolerates_trailing_slash() { + let endpoints = ForgejoEndpoints::from_base_url("https://code.example.com/"); + assert_eq!(endpoints.api(), "https://code.example.com/api/v1"); + } + + #[test] + fn latest_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 endpoints = ForgejoEndpoints { api: server.url() }; + assert_eq!(endpoints.latest_release(&client, "o/r").unwrap(), "v0.1.0"); + } + + #[test] + fn latest_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 endpoints = ForgejoEndpoints { api: server.url() }; + let err = endpoints.latest_release(&client, "o/r").unwrap_err(); + assert!(err.to_string().contains("no published release")); + } + + #[test] + fn latest_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 endpoints = ForgejoEndpoints { api: server.url() }; + let err = endpoints.latest_release(&client, "o/r").unwrap_err(); + assert!(err.to_string().contains("fetching latest release")); + } +} diff --git a/src/github.rs b/src/github.rs index a511f16..124349c 100644 --- a/src/github.rs +++ b/src/github.rs @@ -1,3 +1,10 @@ +//! GitHub as a release source: its endpoints, and how to find a repo's +//! latest release there. + +use crate::checker::ReleaseSource; +use anyhow::{Result, bail}; +use regex::Regex; + /// Base URLs for GitHub's public web host (Atom feeds, release pages) and /// its REST API, factored out so tests can point both at a local mock /// server instead of the real github.com/api.github.com. @@ -16,6 +23,47 @@ impl Default for GithubEndpoints { } } +impl ReleaseSource for GithubEndpoints { + /// Resolves the latest release tag for `repo` via its public Atom feed. + /// + /// Deliberately not a full XML parse: the feed lists entries + /// newest-first, and each `"/>` is matched in document order. Revisit + /// with a real XML parser if GitHub's feed shape ever changes. + /// + /// The feed can list a tag newer than any tag with a real Release + /// object behind it — observed on scaleway/scaleway-cli, which pushes a + /// `vX.Y.Z-dbg1` tag (no corresponding Release; `releases/tags/` + /// 404s) right after each real release, and that tag sorts newest in + /// the feed. So each candidate is confirmed against the releases API in + /// feed order, returning the first that actually resolves. + fn latest_release(&self, client: &reqwest::blocking::Client, repo: &str) -> Result { + let url = format!("{}/{repo}/releases.atom", self.web); + let body = client.get(&url).send()?.error_for_status()?.text()?; + + let re = Regex::new(r#"releases/tag/([^"]+)""#)?; + let mut candidates = re + .captures_iter(&body) + .map(|caps| caps[1].to_string()) + .peekable(); + if candidates.peek().is_none() { + bail!("no release tag found in {url}"); + } + + for tag in candidates { + let release_url = format!("{}/repos/{repo}/releases/tags/{tag}", self.api); + if client.get(&release_url).send()?.status().is_success() { + return Ok(tag); + } + } + bail!("no release tag in {url} resolved to a real release via the API") + } + + fn api(&self) -> &str { + &self.api + } +} + #[cfg(test)] mod tests { use super::*; @@ -26,4 +74,93 @@ mod tests { assert_eq!(endpoints.web, "https://github.com"); assert_eq!(endpoints.api, "https://api.github.com"); } + + fn atom_feed(tags: &[&str]) -> String { + let entries: String = tags + .iter() + .map(|t| { + format!(r#""#) + }) + .collect(); + format!("{entries}") + } + + #[test] + fn latest_release_skips_tags_with_no_real_release() { + let mut server = mockito::Server::new(); + let endpoints = GithubEndpoints { + web: server.url(), + api: server.url(), + }; + + // Mirrors the real scaleway-cli case: newest feed entry (a -dbg1 + // tag) has no Release object behind it and 404s. + let _feed = server + .mock("GET", "/o/r/releases.atom") + .with_status(200) + .with_body(atom_feed(&["v2.62.0-dbg1", "v2.62.0"])) + .create(); + let _missing = server + .mock("GET", "/repos/o/r/releases/tags/v2.62.0-dbg1") + .with_status(404) + .create(); + let _real = server + .mock("GET", "/repos/o/r/releases/tags/v2.62.0") + .with_status(200) + .with_body("{}") + .create(); + + let client = reqwest::blocking::Client::new(); + let tag = endpoints.latest_release(&client, "o/r").unwrap(); + assert_eq!(tag, "v2.62.0"); + } + + #[test] + fn latest_release_errors_when_feed_has_no_tags() { + let mut server = mockito::Server::new(); + let endpoints = GithubEndpoints { + web: server.url(), + api: server.url(), + }; + let _feed = server + .mock("GET", "/o/r/releases.atom") + .with_status(200) + .with_body("") + .create(); + + let client = reqwest::blocking::Client::new(); + let err = endpoints.latest_release(&client, "o/r").unwrap_err(); + assert!(err.to_string().contains("no release tag found")); + } + + #[test] + fn latest_release_errors_when_no_candidate_resolves() { + let mut server = mockito::Server::new(); + let endpoints = GithubEndpoints { + web: server.url(), + api: server.url(), + }; + let _feed = server + .mock("GET", "/o/r/releases.atom") + .with_status(200) + .with_body(atom_feed(&["v1.0.0-dbg1"])) + .create(); + let _missing = server + .mock("GET", "/repos/o/r/releases/tags/v1.0.0-dbg1") + .with_status(404) + .create(); + + let client = reqwest::blocking::Client::new(); + let err = endpoints.latest_release(&client, "o/r").unwrap_err(); + assert!(err.to_string().contains("resolved to a real release")); + } + + #[test] + fn api_is_the_configured_api_root() { + let endpoints = GithubEndpoints { + web: "http://w".into(), + api: "http://a".into(), + }; + assert_eq!(endpoints.api(), "http://a"); + } } diff --git a/src/main.rs b/src/main.rs index 9b9255b..3cc70d1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,6 +6,7 @@ mod builder; mod checker; mod config; mod fetcher; +mod forgejo; mod github; mod hash; mod notifier; diff --git a/src/pipeline.rs b/src/pipeline.rs index 88fa9e7..5bcb61f 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -6,14 +6,14 @@ //! `main.rs`. use crate::builder; -use crate::checker; +use crate::checker::{self, ReleaseSource}; use crate::config::{self, Package}; use crate::fetcher::{self, DownloadedAsset}; use crate::notifier::{self, Event}; use crate::paths::Paths; use crate::publisher; use crate::sanity; -use crate::source::Endpoints; +use crate::source; use crate::state; use crate::verifier::{self, VerificationResult}; use anyhow::{Context, Result, bail}; @@ -72,8 +72,15 @@ pub fn run_check() -> Result<()> { let mut any_failed = false; for (name, pkg) in &packages { println!("== {name} ({}) ==", pkg.repo); - let result = Endpoints::for_package(pkg).and_then(|endpoints| { - process_package(&client, &endpoints, &state_dir, &work_dir, name, pkg) + let result = source::for_package(pkg).and_then(|release_source| { + process_package( + &client, + release_source.as_ref(), + &state_dir, + &work_dir, + name, + pkg, + ) }); if let Err(err) = result { eprintln!(" error: {err:#}"); @@ -120,13 +127,13 @@ fn decide_tier_action(tier: u8, passed: bool, already_pending_this_version: bool fn process_package( client: &reqwest::blocking::Client, - endpoints: &Endpoints, + release_source: &dyn ReleaseSource, state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, ) -> Result<()> { - let latest = checker::latest_release(client, endpoints, &pkg.repo)?; + let latest = release_source.latest_release(client, &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}"); @@ -134,7 +141,7 @@ fn process_package( } println!(" new version detected: {latest} (previously: {last_seen:?})"); - let fetched = fetch_and_verify(client, endpoints, work_dir, name, pkg, &latest)?; + let fetched = fetch_and_verify(client, release_source, work_dir, name, pkg, &latest)?; println!(" fetched {}", fetched.asset.path.display()); println!( " verification (tier {}): {} — {}", @@ -207,7 +214,7 @@ struct FetchVerifyResult { /// trusting a possibly-stale flag from an earlier run). fn fetch_and_verify( client: &reqwest::blocking::Client, - endpoints: &Endpoints, + release_source: &dyn ReleaseSource, work_dir: &Path, name: &str, pkg: &Package, @@ -217,7 +224,7 @@ fn fetch_and_verify( let asset_name = pkg.asset_pattern.replace("{version}", &version); let dest_dir = work_dir.join(name).join(tag); - let api = endpoints.api(); + let api = release_source.api(); let asset = fetcher::download_asset(client, api, &pkg.repo, tag, &asset_name, &dest_dir)?; let verification = verifier::verify( client, @@ -320,11 +327,11 @@ 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 = Endpoints::for_package(pkg)?; + let release_source = source::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. - let fetched = fetch_and_verify(&client, &endpoints, work_dir, name, pkg, tag)?; + let fetched = fetch_and_verify(&client, release_source.as_ref(), work_dir, name, pkg, tag)?; if !fetched.verification.passed { bail!( "re-verification failed on approve: {}", @@ -346,6 +353,7 @@ fn approve(state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, tag: &s #[cfg(test)] mod tests { use super::*; + use crate::forgejo::ForgejoEndpoints; use crate::github::GithubEndpoints; use crate::test_support::same_origin_package; @@ -381,7 +389,7 @@ mod tests { /// 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 + /// These are identical on GitHub and Forgejo (see `ReleaseSource::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 { @@ -418,14 +426,14 @@ mod tests { /// 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) { + fn assert_verification_failure_is_an_error(release_source: &dyn ReleaseSource, 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, + release_source, state_dir.path(), work_dir.path(), "thing", @@ -450,10 +458,10 @@ mod tests { #[test] fn process_package_returns_err_on_verification_failure() { let mut server = mockito::Server::new(); - let endpoints = Endpoints::Github(GithubEndpoints { + let github = GithubEndpoints { web: server.url(), api: server.url(), - }); + }; let feed = format!( r#""#, server.url() @@ -465,7 +473,7 @@ mod tests { .create(); let _release_mocks = mock_release_with_bad_checksum(&mut server); - assert_verification_failure_is_an_error(&endpoints, &same_origin_package("")); + assert_verification_failure_is_an_error(&github, &same_origin_package("")); } /// Same as above but through a Forgejo source, proving the whole @@ -474,7 +482,7 @@ mod tests { #[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 forgejo = ForgejoEndpoints { api: server.url() }; let _latest = server .mock("GET", "/repos/o/r/releases/latest") .with_status(200) @@ -482,8 +490,8 @@ mod tests { .create(); let _release_mocks = mock_release_with_bad_checksum(&mut server); - // The package's own `source` is irrelevant here: `endpoints` is + // The package's own `source` is irrelevant here: `forgejo` is // hand-built to point at the mock server, bypassing `for_package`. - assert_verification_failure_is_an_error(&endpoints, &same_origin_package("")); + assert_verification_failure_is_an_error(&forgejo, &same_origin_package("")); } } diff --git a/src/source.rs b/src/source.rs index c014dba..2d42a6d 100644 --- a/src/source.rs +++ b/src/source.rs @@ -1,46 +1,24 @@ -//! Maps a package's configured `source` to the endpoints the pipeline -//! stages talk to. `checker` and `fetcher` take what it hands them and -//! build their own paths under it. +//! Picks the `ReleaseSource` implementation for a package from its +//! configured `source`. The one place that knows which hosts exist; the +//! pipeline and the stages it calls only ever see the trait. +use crate::checker::ReleaseSource; use crate::config::{Package, Source}; +use crate::forgejo::ForgejoEndpoints; 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. `/api/v1`. - Forgejo { - api: String, - }, -} - -impl Endpoints { - /// Defensive: errors only if a `forgejo-release` package has no - /// `base_url`, which `config::load_packages_dir` already guarantees. - pub fn for_package(pkg: &Package) -> Result { - 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, +/// Defensive: errors only if a `forgejo-release` package has no +/// `base_url`, which `config::load_packages_dir` already guarantees. +pub fn for_package(pkg: &Package) -> Result> { + match pkg.source { + Source::GithubRelease => Ok(Box::new(GithubEndpoints::default())), + Source::ForgejoRelease => { + let base_url = pkg + .base_url + .as_deref() + .context("source = \"forgejo-release\" needs a base_url")?; + Ok(Box::new(ForgejoEndpoints::from_base_url(base_url))) } } } @@ -52,28 +30,20 @@ mod tests { #[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(_))); + let source = for_package(&package("")).unwrap(); + assert_eq!(source.api(), "https://api.github.com"); } #[test] - fn forgejo_source_appends_api_v1() { + fn forgejo_source_uses_the_instances_api() { 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"); + let source = for_package(&pkg).unwrap(); + assert_eq!(source.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(); + let err = for_package(&package("source = \"forgejo-release\"")).unwrap_err(); assert!(err.to_string().contains("needs a base_url")); } } From 6459856aabedae167980e045121b9b33fabd1b4d Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Sun, 20 Sep 2026 11:08:25 +0200 Subject: [PATCH 4/5] Group release sources into a source module with re-exports Move the ReleaseSource trait, GithubEndpoints and ForgejoEndpoints under src/source/, each in its own file (release_source.rs, github.rs, forgejo.rs). The submodules are private; mod.rs re-exports their types and holds for_package, so the rest of the crate imports from crate::source and never names a host's file. checker.rs keeps only version_from_tag. Co-Authored-By: Claude Sonnet 5 --- docs/ARCHITECTURE.md | 8 +++++--- docs/SPEC.md | 8 +++++--- src/checker.rs | 25 +++---------------------- src/fetcher.rs | 2 +- src/main.rs | 2 -- src/pipeline.rs | 7 +++---- src/{ => source}/forgejo.rs | 2 +- src/{ => source}/github.rs | 2 +- src/{source.rs => source/mod.rs} | 19 +++++++++++++------ src/source/release_source.rs | 20 ++++++++++++++++++++ 10 files changed, 52 insertions(+), 43 deletions(-) rename src/{ => source}/forgejo.rs (99%) rename src/{ => source}/github.rs (99%) rename src/{source.rs => source/mod.rs} (73%) create mode 100644 src/source/release_source.rs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4440080..8e0a9b5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,8 +35,10 @@ practice rather than asserted from habit — see Further reading. `helpers/`) tends toward the opposite, and a single feature change ends up touching files scattered across every layer. **Rule**: modules are named after what they do in the pipeline - (`checker`, `fetcher`, `verifier`, `builder`, `sanity`, `publisher`, - `state`), not generic buckets. A new pipeline stage gets a new module + (`source`, `fetcher`, `verifier`, `builder`, `sanity`, `publisher`, + `state`), not generic buckets. (`source` is a directory module: the + `ReleaseSource` trait and one file per host, re-exported from its + `mod.rs` so the rest of the crate never names a host's file.) A new pipeline stage gets a new module named after the stage, not a method bolted onto an existing one. **Anti-example to keep watching for**: a `utils.rs` grab-bag. `hash.rs` could look like one but isn't — it exists for exactly one piece of @@ -107,7 +109,7 @@ practice rather than asserted from habit — see Further reading. directory, but the same information — why this way and not the obvious alternative — needs to live somewhere a future reader will actually see it: the doc comment on the thing itself. - **Example already here**: `github.rs`'s doc comment on + **Example already here**: `source/github.rs`'s doc comment on `GithubEndpoints::latest_release` explains why the newest Atom-feed entry isn't trusted outright (scaleway-cli's `-dbg1` tag has no real Release behind it) — the reasoning lives right next to the code it justifies, not in a diff --git a/docs/SPEC.md b/docs/SPEC.md index b3262bd..02edb5f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -373,10 +373,12 @@ 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 and Forgejo. `src/checker.rs` defines the + *(Implemented for GitHub and Forgejo. `src/source/` defines the `ReleaseSource` trait (latest release + API root); each host implements - it in its own module (`github.rs`, `forgejo.rs`), and `source.rs` picks - one per package, so adding a host doesn't touch existing ones. A trait + it in its own file (`github.rs`, `forgejo.rs`), and the module's + `for_package` picks one per package, so adding a host doesn't touch + existing ones. The rest of the crate imports the trait and hosts from + `crate::source`, which re-exports them. A trait rather than an enum match because there are now two real hosts with genuinely different logic. GitHub regex-matches the first `releases/tag/` link in the feed rather than doing a full XML diff --git a/src/checker.rs b/src/checker.rs index 33aa612..7d1b17c 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -1,25 +1,6 @@ -//! The check stage's contract: a `ReleaseSource` says what a package's -//! latest release is and where its releases API lives, and -//! `version_from_tag` turns the resulting tag into a version string. Each -//! hosting service implements the trait in its own module (`github`, -//! `forgejo`) so per-host logic doesn't accumulate here; -//! `source::for_package` picks the implementation for a package. - -use anyhow::Result; - -/// A hosting service a package's releases are published on. `Debug` so a -/// `Box` can sit in a `Result` that tests unwrap. -pub trait ReleaseSource: std::fmt::Debug { - /// The latest release tag for `repo`. - fn latest_release(&self, client: &reqwest::blocking::Client, repo: &str) -> Result; - - /// The releases API root, which `fetcher` and `verifier` build their - /// own paths under. GitHub and Forgejo both serve - /// `/repos/{owner}/{repo}/releases/tags/{tag}` there with the same - /// `assets[].{name, browser_download_url}` shape, which is why those - /// stages need only this and not the source itself. - fn api(&self) -> &str; -} +//! Turns a release tag into a version string. What the latest tag *is* +//! comes from a `ReleaseSource` (see `source`); this is the one piece of +//! the check stage that isn't host-specific. /// Strips a leading `v` from a release tag, e.g. `v2.62.0` -> `2.62.0`. /// diff --git a/src/fetcher.rs b/src/fetcher.rs index 3fbafaa..81f032d 100644 --- a/src/fetcher.rs +++ b/src/fetcher.rs @@ -1,6 +1,6 @@ //! 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. +//! `source` only resolves version tags, never downloads. use anyhow::{Context, Result}; use serde::Deserialize; diff --git a/src/main.rs b/src/main.rs index 3cc70d1..ac25251 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,8 +6,6 @@ mod builder; mod checker; mod config; mod fetcher; -mod forgejo; -mod github; mod hash; mod notifier; mod paths; diff --git a/src/pipeline.rs b/src/pipeline.rs index 5bcb61f..2723382 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -6,14 +6,14 @@ //! `main.rs`. use crate::builder; -use crate::checker::{self, ReleaseSource}; +use crate::checker; use crate::config::{self, Package}; use crate::fetcher::{self, DownloadedAsset}; use crate::notifier::{self, Event}; use crate::paths::Paths; use crate::publisher; use crate::sanity; -use crate::source; +use crate::source::{self, ReleaseSource}; use crate::state; use crate::verifier::{self, VerificationResult}; use anyhow::{Context, Result, bail}; @@ -353,8 +353,7 @@ fn approve(state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, tag: &s #[cfg(test)] mod tests { use super::*; - use crate::forgejo::ForgejoEndpoints; - use crate::github::GithubEndpoints; + use crate::source::{ForgejoEndpoints, GithubEndpoints}; use crate::test_support::same_origin_package; #[test] diff --git a/src/forgejo.rs b/src/source/forgejo.rs similarity index 99% rename from src/forgejo.rs rename to src/source/forgejo.rs index af1ba00..6ff070a 100644 --- a/src/forgejo.rs +++ b/src/source/forgejo.rs @@ -1,7 +1,7 @@ //! A Forgejo (or Gitea) instance as a release source: its API root, and //! how to find a repo's latest release there. -use crate::checker::ReleaseSource; +use super::ReleaseSource; use anyhow::{Context, Result, bail}; use serde::Deserialize; diff --git a/src/github.rs b/src/source/github.rs similarity index 99% rename from src/github.rs rename to src/source/github.rs index 124349c..eac9803 100644 --- a/src/github.rs +++ b/src/source/github.rs @@ -1,7 +1,7 @@ //! GitHub as a release source: its endpoints, and how to find a repo's //! latest release there. -use crate::checker::ReleaseSource; +use super::ReleaseSource; use anyhow::{Result, bail}; use regex::Regex; diff --git a/src/source.rs b/src/source/mod.rs similarity index 73% rename from src/source.rs rename to src/source/mod.rs index 2d42a6d..615a0bc 100644 --- a/src/source.rs +++ b/src/source/mod.rs @@ -1,11 +1,18 @@ -//! Picks the `ReleaseSource` implementation for a package from its -//! configured `source`. The one place that knows which hosts exist; the -//! pipeline and the stages it calls only ever see the trait. +//! Where a package's releases are published: the `ReleaseSource` trait, +//! one file per host implementing it, and `for_package`, which picks the +//! implementation for a package from its configured `source`. The +//! submodules are private and re-exported here, so the rest of the crate +//! imports everything from `crate::source` and never a host's file. + +mod forgejo; +mod github; +mod release_source; + +pub use forgejo::ForgejoEndpoints; +pub use github::GithubEndpoints; +pub use release_source::ReleaseSource; -use crate::checker::ReleaseSource; use crate::config::{Package, Source}; -use crate::forgejo::ForgejoEndpoints; -use crate::github::GithubEndpoints; use anyhow::{Context, Result}; /// Defensive: errors only if a `forgejo-release` package has no diff --git a/src/source/release_source.rs b/src/source/release_source.rs new file mode 100644 index 0000000..2cde371 --- /dev/null +++ b/src/source/release_source.rs @@ -0,0 +1,20 @@ +//! The `ReleaseSource` trait: what the pipeline needs from a hosting +//! service a package's releases are published on. Each host implements it +//! in its own file next to this one, so per-host logic never accumulates +//! here. + +use anyhow::Result; + +/// `Debug` so a `Box` can sit in a `Result` that tests +/// unwrap. +pub trait ReleaseSource: std::fmt::Debug { + /// The latest release tag for `repo`. + fn latest_release(&self, client: &reqwest::blocking::Client, repo: &str) -> Result; + + /// The releases API root, which `fetcher` and `verifier` build their + /// own paths under. GitHub and Forgejo both serve + /// `/repos/{owner}/{repo}/releases/tags/{tag}` there with the same + /// `assets[].{name, browser_download_url}` shape, which is why those + /// stages need only this and not the source itself. + fn api(&self) -> &str; +} From 984c11066f28e66a8ec9cc462df1fd6d7fdae08b Mon Sep 17 00:00:00 2001 From: Austin Schaefer Date: Sun, 20 Sep 2026 11:10:00 +0200 Subject: [PATCH 5/5] Rename the source module to release_source 'source' read like source code next to the config's source key. The trait file becomes contract.rs to avoid release_source::release_source, and the pipeline's local variables become 'host'. Co-Authored-By: Claude Sonnet 5 --- docs/ARCHITECTURE.md | 6 ++-- docs/SPEC.md | 4 +-- src/checker.rs | 2 +- src/fetcher.rs | 2 +- src/main.rs | 2 +- src/pipeline.rs | 33 ++++++++----------- .../contract.rs} | 0 src/{source => release_source}/forgejo.rs | 0 src/{source => release_source}/github.rs | 0 src/{source => release_source}/mod.rs | 7 ++-- 10 files changed, 25 insertions(+), 31 deletions(-) rename src/{source/release_source.rs => release_source/contract.rs} (100%) rename src/{source => release_source}/forgejo.rs (100%) rename src/{source => release_source}/github.rs (100%) rename src/{source => release_source}/mod.rs (93%) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8e0a9b5..74babdf 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -35,8 +35,8 @@ practice rather than asserted from habit — see Further reading. `helpers/`) tends toward the opposite, and a single feature change ends up touching files scattered across every layer. **Rule**: modules are named after what they do in the pipeline - (`source`, `fetcher`, `verifier`, `builder`, `sanity`, `publisher`, - `state`), not generic buckets. (`source` is a directory module: the + (`release_source`, `fetcher`, `verifier`, `builder`, `sanity`, + `publisher`, `state`), not generic buckets. (`release_source` is a directory module: the `ReleaseSource` trait and one file per host, re-exported from its `mod.rs` so the rest of the crate never names a host's file.) A new pipeline stage gets a new module named after the stage, not a method bolted onto an existing one. @@ -109,7 +109,7 @@ practice rather than asserted from habit — see Further reading. directory, but the same information — why this way and not the obvious alternative — needs to live somewhere a future reader will actually see it: the doc comment on the thing itself. - **Example already here**: `source/github.rs`'s doc comment on + **Example already here**: `release_source/github.rs`'s doc comment on `GithubEndpoints::latest_release` explains why the newest Atom-feed entry isn't trusted outright (scaleway-cli's `-dbg1` tag has no real Release behind it) — the reasoning lives right next to the code it justifies, not in a diff --git a/docs/SPEC.md b/docs/SPEC.md index 02edb5f..dcb3b01 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -373,12 +373,12 @@ 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 and Forgejo. `src/source/` defines the + *(Implemented for GitHub and Forgejo. `src/release_source/` defines the `ReleaseSource` trait (latest release + API root); each host implements it in its own file (`github.rs`, `forgejo.rs`), and the module's `for_package` picks one per package, so adding a host doesn't touch existing ones. The rest of the crate imports the trait and hosts from - `crate::source`, which re-exports them. A trait + `crate::release_source`, which re-exports them. A trait rather than an enum match because there are now two real hosts with genuinely different logic. GitHub regex-matches the first `releases/tag/` link in the feed rather than doing a full XML diff --git a/src/checker.rs b/src/checker.rs index 7d1b17c..a32d2fb 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -1,5 +1,5 @@ //! Turns a release tag into a version string. What the latest tag *is* -//! comes from a `ReleaseSource` (see `source`); this is the one piece of +//! comes from a `ReleaseSource` (see `release_source`); this is the one piece of //! the check stage that isn't host-specific. /// Strips a leading `v` from a release tag, e.g. `v2.62.0` -> `2.62.0`. diff --git a/src/fetcher.rs b/src/fetcher.rs index 81f032d..314d394 100644 --- a/src/fetcher.rs +++ b/src/fetcher.rs @@ -1,6 +1,6 @@ //! 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 — -//! `source` only resolves version tags, never downloads. +//! `release_source` only resolves version tags, never downloads. use anyhow::{Context, Result}; use serde::Deserialize; diff --git a/src/main.rs b/src/main.rs index ac25251..5001beb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,8 +11,8 @@ mod notifier; mod paths; mod pipeline; mod publisher; +mod release_source; mod sanity; -mod source; mod state; #[cfg(test)] mod test_support; diff --git a/src/pipeline.rs b/src/pipeline.rs index 2723382..97bb471 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -12,8 +12,8 @@ use crate::fetcher::{self, DownloadedAsset}; use crate::notifier::{self, Event}; use crate::paths::Paths; use crate::publisher; +use crate::release_source::{self, ReleaseSource}; use crate::sanity; -use crate::source::{self, ReleaseSource}; use crate::state; use crate::verifier::{self, VerificationResult}; use anyhow::{Context, Result, bail}; @@ -72,15 +72,8 @@ pub fn run_check() -> Result<()> { let mut any_failed = false; for (name, pkg) in &packages { println!("== {name} ({}) ==", pkg.repo); - let result = source::for_package(pkg).and_then(|release_source| { - process_package( - &client, - release_source.as_ref(), - &state_dir, - &work_dir, - name, - pkg, - ) + let result = release_source::for_package(pkg).and_then(|host| { + process_package(&client, host.as_ref(), &state_dir, &work_dir, name, pkg) }); if let Err(err) = result { eprintln!(" error: {err:#}"); @@ -127,13 +120,13 @@ fn decide_tier_action(tier: u8, passed: bool, already_pending_this_version: bool fn process_package( client: &reqwest::blocking::Client, - release_source: &dyn ReleaseSource, + host: &dyn ReleaseSource, state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, ) -> Result<()> { - let latest = release_source.latest_release(client, &pkg.repo)?; + let latest = host.latest_release(client, &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}"); @@ -141,7 +134,7 @@ fn process_package( } println!(" new version detected: {latest} (previously: {last_seen:?})"); - let fetched = fetch_and_verify(client, release_source, work_dir, name, pkg, &latest)?; + let fetched = fetch_and_verify(client, host, work_dir, name, pkg, &latest)?; println!(" fetched {}", fetched.asset.path.display()); println!( " verification (tier {}): {} — {}", @@ -214,7 +207,7 @@ struct FetchVerifyResult { /// trusting a possibly-stale flag from an earlier run). fn fetch_and_verify( client: &reqwest::blocking::Client, - release_source: &dyn ReleaseSource, + host: &dyn ReleaseSource, work_dir: &Path, name: &str, pkg: &Package, @@ -224,7 +217,7 @@ fn fetch_and_verify( let asset_name = pkg.asset_pattern.replace("{version}", &version); let dest_dir = work_dir.join(name).join(tag); - let api = release_source.api(); + let api = host.api(); let asset = fetcher::download_asset(client, api, &pkg.repo, tag, &asset_name, &dest_dir)?; let verification = verifier::verify( client, @@ -327,11 +320,11 @@ 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 release_source = source::for_package(pkg)?; + let host = release_source::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. - let fetched = fetch_and_verify(&client, release_source.as_ref(), work_dir, name, pkg, tag)?; + let fetched = fetch_and_verify(&client, host.as_ref(), work_dir, name, pkg, tag)?; if !fetched.verification.passed { bail!( "re-verification failed on approve: {}", @@ -353,7 +346,7 @@ fn approve(state_dir: &Path, work_dir: &Path, name: &str, pkg: &Package, tag: &s #[cfg(test)] mod tests { use super::*; - use crate::source::{ForgejoEndpoints, GithubEndpoints}; + use crate::release_source::{ForgejoEndpoints, GithubEndpoints}; use crate::test_support::same_origin_package; #[test] @@ -425,14 +418,14 @@ mod tests { /// 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(release_source: &dyn ReleaseSource, pkg: &Package) { + fn assert_verification_failure_is_an_error(host: &dyn ReleaseSource, 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, - release_source, + host, state_dir.path(), work_dir.path(), "thing", diff --git a/src/source/release_source.rs b/src/release_source/contract.rs similarity index 100% rename from src/source/release_source.rs rename to src/release_source/contract.rs diff --git a/src/source/forgejo.rs b/src/release_source/forgejo.rs similarity index 100% rename from src/source/forgejo.rs rename to src/release_source/forgejo.rs diff --git a/src/source/github.rs b/src/release_source/github.rs similarity index 100% rename from src/source/github.rs rename to src/release_source/github.rs diff --git a/src/source/mod.rs b/src/release_source/mod.rs similarity index 93% rename from src/source/mod.rs rename to src/release_source/mod.rs index 615a0bc..2f75e02 100644 --- a/src/source/mod.rs +++ b/src/release_source/mod.rs @@ -2,15 +2,16 @@ //! one file per host implementing it, and `for_package`, which picks the //! implementation for a package from its configured `source`. The //! submodules are private and re-exported here, so the rest of the crate -//! imports everything from `crate::source` and never a host's file. +//! imports everything from `crate::release_source` and never a host's +//! file. +mod contract; mod forgejo; mod github; -mod release_source; +pub use contract::ReleaseSource; pub use forgejo::ForgejoEndpoints; pub use github::GithubEndpoints; -pub use release_source::ReleaseSource; use crate::config::{Package, Source}; use anyhow::{Context, Result};