new: drive the forge hosts and tarball templates from data/forges.yml

This commit is contained in:
2026-09-18 13:30:42 +02:00
parent 7601524b7c
commit 12407c8eac
6 changed files with 182 additions and 78 deletions
+31
View File
@@ -0,0 +1,31 @@
## Forge hosts recognized by `pkh new` origin detection, with the
## release-tarball URL templates of each: `Forge::parse`
## (src/new/origin.rs) matches a git remote's host against the map keys,
## and the tarball download substitutes {owner}, {repo} and {tag} into the
## templates. Like host_keys.yml, this file exists so that static
## endpoints are data: adding a forge is a YAML entry, not a code change
## (self-hosted instances are deliberately absent — the download URL
## shapes differ per instance).
##
## tarball_templates are tried sequentially in file order, best candidate
## first (GitHub prefers the codeload direct link: no redirect).
## `kind` documents the forge family the URL shapes belong to; the
## templates fully describe the URLs, so nothing branches on it (yet) —
## but it must be one of the known kinds, enforced at load time.
##
## Where the values come from: each forge's release-archive download URL
## shapes, verified against the live forges —
## github: codeload.github.com/<owner>/<repo>/tar.gz/refs/tags/<tag>
## and github.com/<owner>/<repo>/archive/refs/tags/<tag>.tar.gz
## gitlab: gitlab.com/<owner>/<repo>/-/archive/<tag>/<repo>-<tag>.tar.gz
forges:
github.com:
kind: github
tarball_templates:
- https://codeload.github.com/{owner}/{repo}/tar.gz/refs/tags/{tag}
- https://github.com/{owner}/{repo}/archive/refs/tags/{tag}.tar.gz
gitlab.com:
kind: gitlab
tarball_templates:
- https://gitlab.com/{owner}/{repo}/-/archive/{tag}/{repo}-{tag}.tar.gz
+3 -3
View File
@@ -8,9 +8,9 @@
//! This module is deliberately not a central registry: each file is //! This module is deliberately not a central registry: each file is
//! embedded by the module that owns it (distro_info.rs owns //! embedded by the module that owns it (distro_info.rs owns
//! data/distro_info.yml, launchpad.rs owns data/launchpad.yml, //! data/distro_info.yml, launchpad.rs owns data/launchpad.yml,
//! apt/keyring.rs owns data/keyserver.yml, put/ssh.rs owns //! apt/keyring.rs owns data/keyserver.yml, new/origin.rs owns
//! data/host_keys.yml, quirks.rs owns data/quirks.yml) through the //! data/forges.yml, put/ssh.rs owns data/host_keys.yml, quirks.rs owns
//! [`embed_data!`] macro below, so data //! data/quirks.yml) through the [`embed_data!`] macro below, so data
//! and its accessors stay together and a diff touching one domain cannot //! and its accessors stay together and a diff touching one domain cannot
//! half-touch another. The macro embeds the file at compile time and //! half-touch another. The macro embeds the file at compile time and
//! parses it once into a `lazy_static` on first use; since the data ships //! parses it once into a `lazy_static` on first use; since the data ships
+1 -4
View File
@@ -1537,10 +1537,7 @@ mod tests {
resolve(cli).await.unwrap().orig, resolve(cli).await.unwrap().orig,
Some(OrigOrigin::Release { Some(OrigOrigin::Release {
tag: "v1.2.3".to_string(), tag: "v1.2.3".to_string(),
forge: crate::new::origin::Forge::GitHub { forge: crate::new::origin::Forge::parse("https://github.com/foo/bar.git").unwrap(),
owner: "foo".into(),
repo: "bar".into()
}
}) })
); );
+2 -4
View File
@@ -1535,10 +1535,8 @@ mod tests {
"0.1.0", "0.1.0",
&OrigOrigin::Release { &OrigOrigin::Release {
tag: "v0.1.0".to_string(), tag: "v0.1.0".to_string(),
forge: Forge::GitHub { forge: Forge::parse("https://github.com/pkh-nonexistent-org/pkh-nonexistent-repo")
owner: "pkh-nonexistent-org".into(), .unwrap(),
repo: "pkh-nonexistent-repo".into(),
},
}, },
false, false,
) )
+143 -59
View File
@@ -8,48 +8,85 @@
//! - is HEAD exactly on a tag, and which upstream version does it name, //! - is HEAD exactly on a tag, and which upstream version does it name,
//! - which is the last tag reachable from HEAD (for the //! - which is the last tag reachable from HEAD (for the
//! `<tag>+git<YYYYMMDD>.<hash>` version scheme), //! `<tag>+git<YYYYMMDD>.<hash>` version scheme),
//! - where does the `origin` remote point: only `github.com` and //! - where does the `origin` remote point: only the hosts of the bundled
//! `gitlab.com` are recognized as forges — self-hosted GitLab instances //! forge table (`data/forges.yml`) are recognized as forges — self-hosted
//! are deliberately not (the release-download URL shapes differ). //! GitLab instances are deliberately not part of it (the
//! release-download URL shapes differ), and the table also carries each
//! forge's tarball URL templates.
//! //!
//! Detection never touches the network: adding a remote stores its URL in //! Detection never touches the network: adding a remote stores its URL in
//! the local config only, which is all this module reads. //! the local config only, which is all this module reads.
use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use std::process::Command; use std::process::Command;
/// A forge hosting the project, parsed from the `origin` remote URL. use serde::Deserialize;
use crate::data::embed_data;
/// Family a forge belongs to. Documentation of which URL-shape family a
/// table entry belongs to — the tarball templates fully describe the
/// URLs, so nothing branches on the kind (yet). Parsed strictly: an
/// unknown kind fails the load rather than being silently ignored.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
enum ForgeKind {
/// `github.com` and its codeload archive service
GitHub,
/// `gitlab.com`
GitLab,
}
/// One entry of the bundled forge table: the release-tarball URL
/// templates of a forge host
#[derive(Debug, Deserialize)]
struct ForgeEntry {
/// Family of the forge (see [`ForgeKind`]). Kept even though nothing
/// branches on it (the templates fully describe the URLs): it
/// documents the entry's URL-shape family and the strict enum
/// validates it at load time.
#[allow(dead_code)]
kind: ForgeKind,
/// Release-tarball URL templates, best candidate first (tried
/// sequentially by the download)
tarball_templates: Vec<String>,
}
/// The bundled forge table (`data/forges.yml`): host name → entry
#[derive(Debug, Deserialize)]
struct ForgesData {
/// Recognized forge hosts
forges: HashMap<String, ForgeEntry>,
}
embed_data! {
static ref FORGES_DATA: ForgesData = "../../data/forges.yml"
}
/// A forge hosting the project, parsed from the `origin` remote URL: one
/// of the hosts of the bundled forge table, plus the repository it points
/// at.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Forge { pub struct Forge {
/// `github.com/<owner>/<repo>` /// Host name of the forge (the key of its `forges.yml` entry).
GitHub { host: &'static str,
/// Repository owner (user or organization). /// Repository owner (user or organization).
owner: String, owner: String,
/// Repository name, without the `.git` suffix. /// Repository name, without the `.git` suffix.
repo: String, repo: String,
},
/// `gitlab.com/<owner>/<repo>`
GitLab {
/// Repository owner (user or group).
owner: String,
/// Repository name, without the `.git` suffix.
repo: String,
},
} }
impl Forge { impl Forge {
/// Host name of the forge. /// Host name of the forge.
pub fn host(&self) -> &'static str { pub fn host(&self) -> &'static str {
match self { self.host
Forge::GitHub { .. } => "github.com",
Forge::GitLab { .. } => "gitlab.com",
}
} }
/// Parse a remote URL into a [`Forge`], accepting the `https://`, /// Parse a remote URL into a [`Forge`], accepting the `https://`,
/// `http://`, `git://` and `git@host:` spellings. Only `github.com` and /// `http://`, `git://` and `git@host:` spellings. Only the hosts
/// `gitlab.com` are recognized; anything else (self-hosted GitLab, /// listed in the bundled forge table are recognized; anything else
/// Bitbucket, plain URLs…) yields `None`. /// (self-hosted GitLab, Bitbucket, plain URLs…) yields `None`.
pub fn parse(url: &str) -> Option<Forge> { pub fn parse(url: &str) -> Option<Forge> {
let url = url.trim(); let url = url.trim();
// Normalize `git@host:path` to `host/path` and strip any scheme. // Normalize `git@host:path` to `host/path` and strip any scheme.
@@ -74,34 +111,34 @@ impl Forge {
if owner.is_empty() || repo.is_empty() { if owner.is_empty() || repo.is_empty() {
return None; return None;
} }
match host { // The table key doubles as the Forge's host, so the two can never
"github.com" => Some(Forge::GitHub { // disagree about how the forge is spelled.
owner: owner.to_string(), let (host, _entry) = FORGES_DATA.forges.get_key_value(host)?;
repo: repo.to_string(), Some(Forge {
}), host,
"gitlab.com" => Some(Forge::GitLab { owner: owner.to_string(),
owner: owner.to_string(), repo: repo.to_string(),
repo: repo.to_string(), })
}),
_ => None,
}
} }
/// Release-tarball URLs of `tag`, best candidate first. GitHub prefers /// Release-tarball URLs of `tag`, best candidate first: the templates
/// the codeload direct link (no redirect) and falls back to the /// of the forge's table entry, with `{owner}`, `{repo}` and `{tag}`
/// `github.com` archive URL; GitLab has a single archive URL. /// substituted, in file order (the download tries them sequentially).
pub fn release_tarball_urls(&self, tag: &str) -> Vec<String> { pub fn release_tarball_urls(&self, tag: &str) -> Vec<String> {
match self { let entry = FORGES_DATA
Forge::GitHub { owner, repo } => vec![ .forges
format!("https://codeload.github.com/{owner}/{repo}/tar.gz/refs/tags/{tag}"), .get(self.host)
format!("https://github.com/{owner}/{repo}/archive/refs/tags/{tag}.tar.gz"), .expect("the host of a parsed Forge is a key of the forge table");
], entry
Forge::GitLab { owner, repo } => { .tarball_templates
vec![format!( .iter()
"https://gitlab.com/{owner}/{repo}/-/archive/{tag}/{repo}-{tag}.tar.gz" .map(|template| {
)] template
} .replace("{owner}", &self.owner)
} .replace("{repo}", &self.repo)
.replace("{tag}", tag)
})
.collect()
} }
} }
@@ -299,35 +336,40 @@ mod tests {
fn forge_parses_remote_url_shapes() { fn forge_parses_remote_url_shapes() {
assert_eq!( assert_eq!(
Forge::parse("https://github.com/foo/bar.git"), Forge::parse("https://github.com/foo/bar.git"),
Some(Forge::GitHub { Some(Forge {
host: "github.com",
owner: "foo".into(), owner: "foo".into(),
repo: "bar".into() repo: "bar".into()
}) })
); );
assert_eq!( assert_eq!(
Forge::parse("git@github.com:foo/bar.git"), Forge::parse("git@github.com:foo/bar.git"),
Some(Forge::GitHub { Some(Forge {
host: "github.com",
owner: "foo".into(), owner: "foo".into(),
repo: "bar".into() repo: "bar".into()
}) })
); );
assert_eq!( assert_eq!(
Forge::parse("git://github.com/foo/bar"), Forge::parse("git://github.com/foo/bar"),
Some(Forge::GitHub { Some(Forge {
host: "github.com",
owner: "foo".into(), owner: "foo".into(),
repo: "bar".into() repo: "bar".into()
}) })
); );
assert_eq!( assert_eq!(
Forge::parse("https://gitlab.com/foo/bar/-/tree/main"), Forge::parse("https://gitlab.com/foo/bar/-/tree/main"),
Some(Forge::GitLab { Some(Forge {
host: "gitlab.com",
owner: "foo".into(), owner: "foo".into(),
repo: "bar".into() repo: "bar".into()
}) })
); );
assert_eq!( assert_eq!(
Forge::parse("ssh://git@gitlab.com/foo/bar.git"), Forge::parse("ssh://git@gitlab.com/foo/bar.git"),
Some(Forge::GitLab { Some(Forge {
host: "gitlab.com",
owner: "foo".into(), owner: "foo".into(),
repo: "bar".into() repo: "bar".into()
}) })
@@ -342,7 +384,8 @@ mod tests {
#[test] #[test]
fn forge_release_tarball_urls() { fn forge_release_tarball_urls() {
let gh = Forge::GitHub { let gh = Forge {
host: "github.com",
owner: "foo".into(), owner: "foo".into(),
repo: "bar".into(), repo: "bar".into(),
}; };
@@ -353,7 +396,8 @@ mod tests {
"https://github.com/foo/bar/archive/refs/tags/v1.2.3.tar.gz".to_string(), "https://github.com/foo/bar/archive/refs/tags/v1.2.3.tar.gz".to_string(),
] ]
); );
let gl = Forge::GitLab { let gl = Forge {
host: "gitlab.com",
owner: "foo".into(), owner: "foo".into(),
repo: "bar".into(), repo: "bar".into(),
}; };
@@ -363,6 +407,45 @@ mod tests {
); );
} }
/// Every table entry must be substitutable: a template missing one of
/// the placeholders would download from a literal `{name}` URL.
#[test]
fn forge_templates_carry_every_placeholder() {
for (host, entry) in &FORGES_DATA.forges {
assert!(
!entry.tarball_templates.is_empty(),
"forge '{host}' has no tarball template"
);
for template in &entry.tarball_templates {
for placeholder in ["{owner}", "{repo}", "{tag}"] {
assert!(
template.contains(placeholder),
"template '{template}' of forge '{host}' lacks {placeholder}"
);
}
}
}
}
/// The `kind` field is validated at load time: an unknown forge family
/// fails the parse instead of being silently accepted.
#[test]
fn forge_kind_is_validated() {
assert!(serde_yaml::from_str::<ForgeEntry>("kind: github").is_err());
assert!(
serde_yaml::from_str::<ForgeEntry>(
"kind: github\ntarball_templates: ['https://h/{owner}/{repo}/{tag}']"
)
.is_ok()
);
assert!(
serde_yaml::from_str::<ForgeEntry>(
"kind: codeberg\ntarball_templates: ['https://h/{owner}/{repo}/{tag}']"
)
.is_err()
);
}
#[test] #[test]
fn sanitized_tag_versions() { fn sanitized_tag_versions() {
assert_eq!(sanitized_tag_version("v1.2.3"), Some("1.2.3".to_string())); assert_eq!(sanitized_tag_version("v1.2.3"), Some("1.2.3".to_string()));
@@ -466,7 +549,8 @@ mod tests {
let origin = GitOrigin::detect(dir.path()).expect("detected"); let origin = GitOrigin::detect(dir.path()).expect("detected");
assert_eq!( assert_eq!(
origin.forge, origin.forge,
Some(Forge::GitHub { Some(Forge {
host: "github.com",
owner: "foo".into(), owner: "foo".into(),
repo: "bar".into() repo: "bar".into()
}) })
+2 -8
View File
@@ -1295,10 +1295,7 @@ mod tests {
fn orig_origin_choices_preorder_by_detection() { fn orig_origin_choices_preorder_by_detection() {
use crate::new::origin::Forge; use crate::new::origin::Forge;
let tagged_forge = GitOrigin { let tagged_forge = GitOrigin {
forge: Some(Forge::GitHub { forge: Some(Forge::parse("https://github.com/foo/bar").unwrap()),
owner: "foo".into(),
repo: "bar".into(),
}),
head_tag: Some("v1.4.0".into()), head_tag: Some("v1.4.0".into()),
..Default::default() ..Default::default()
}; };
@@ -1362,10 +1359,7 @@ mod tests {
let mut release = quilt.clone(); let mut release = quilt.clone();
release.orig = Some(options::OrigOrigin::Release { release.orig = Some(options::OrigOrigin::Release {
tag: "v0.14.0".to_string(), tag: "v0.14.0".to_string(),
forge: crate::new::origin::Forge::GitLab { forge: crate::new::origin::Forge::parse("https://gitlab.com/foo/bar").unwrap(),
owner: "foo".into(),
repo: "bar".into(),
},
}); });
let text = summary_text(&release, None); let text = summary_text(&release, None);
assert!( assert!(