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
//! embedded by the module that owns it (distro_info.rs owns
//! data/distro_info.yml, launchpad.rs owns data/launchpad.yml,
//! apt/keyring.rs owns data/keyserver.yml, put/ssh.rs owns
//! data/host_keys.yml, quirks.rs owns data/quirks.yml) through the
//! [`embed_data!`] macro below, so data
//! apt/keyring.rs owns data/keyserver.yml, new/origin.rs owns
//! data/forges.yml, put/ssh.rs owns data/host_keys.yml, quirks.rs owns
//! data/quirks.yml) through the [`embed_data!`] macro below, so data
//! and its accessors stay together and a diff touching one domain cannot
//! 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
+1 -4
View File
@@ -1537,10 +1537,7 @@ mod tests {
resolve(cli).await.unwrap().orig,
Some(OrigOrigin::Release {
tag: "v1.2.3".to_string(),
forge: crate::new::origin::Forge::GitHub {
owner: "foo".into(),
repo: "bar".into()
}
forge: crate::new::origin::Forge::parse("https://github.com/foo/bar.git").unwrap(),
})
);
+2 -4
View File
@@ -1535,10 +1535,8 @@ mod tests {
"0.1.0",
&OrigOrigin::Release {
tag: "v0.1.0".to_string(),
forge: Forge::GitHub {
owner: "pkh-nonexistent-org".into(),
repo: "pkh-nonexistent-repo".into(),
},
forge: Forge::parse("https://github.com/pkh-nonexistent-org/pkh-nonexistent-repo")
.unwrap(),
},
false,
)
+137 -53
View File
@@ -8,48 +8,85 @@
//! - is HEAD exactly on a tag, and which upstream version does it name,
//! - which is the last tag reachable from HEAD (for the
//! `<tag>+git<YYYYMMDD>.<hash>` version scheme),
//! - where does the `origin` remote point: only `github.com` and
//! `gitlab.com` are recognized as forges — self-hosted GitLab instances
//! are deliberately not (the release-download URL shapes differ).
//! - where does the `origin` remote point: only the hosts of the bundled
//! forge table (`data/forges.yml`) are recognized as forges — self-hosted
//! 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
//! the local config only, which is all this module reads.
use std::collections::HashMap;
use std::path::Path;
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)]
pub enum Forge {
/// `github.com/<owner>/<repo>`
GitHub {
pub struct Forge {
/// Host name of the forge (the key of its `forges.yml` entry).
host: &'static str,
/// Repository owner (user or organization).
owner: String,
/// Repository name, without the `.git` suffix.
repo: String,
},
/// `gitlab.com/<owner>/<repo>`
GitLab {
/// Repository owner (user or group).
owner: String,
/// Repository name, without the `.git` suffix.
repo: String,
},
}
impl Forge {
/// Host name of the forge.
pub fn host(&self) -> &'static str {
match self {
Forge::GitHub { .. } => "github.com",
Forge::GitLab { .. } => "gitlab.com",
}
self.host
}
/// Parse a remote URL into a [`Forge`], accepting the `https://`,
/// `http://`, `git://` and `git@host:` spellings. Only `github.com` and
/// `gitlab.com` are recognized; anything else (self-hosted GitLab,
/// Bitbucket, plain URLs…) yields `None`.
/// `http://`, `git://` and `git@host:` spellings. Only the hosts
/// listed in the bundled forge table are recognized; anything else
/// (self-hosted GitLab, Bitbucket, plain URLs…) yields `None`.
pub fn parse(url: &str) -> Option<Forge> {
let url = url.trim();
// Normalize `git@host:path` to `host/path` and strip any scheme.
@@ -74,34 +111,34 @@ impl Forge {
if owner.is_empty() || repo.is_empty() {
return None;
}
match host {
"github.com" => Some(Forge::GitHub {
// The table key doubles as the Forge's host, so the two can never
// disagree about how the forge is spelled.
let (host, _entry) = FORGES_DATA.forges.get_key_value(host)?;
Some(Forge {
host,
owner: owner.to_string(),
repo: repo.to_string(),
}),
"gitlab.com" => Some(Forge::GitLab {
owner: owner.to_string(),
repo: repo.to_string(),
}),
_ => None,
}
})
}
/// Release-tarball URLs of `tag`, best candidate first. GitHub prefers
/// the codeload direct link (no redirect) and falls back to the
/// `github.com` archive URL; GitLab has a single archive URL.
/// Release-tarball URLs of `tag`, best candidate first: the templates
/// of the forge's table entry, with `{owner}`, `{repo}` and `{tag}`
/// substituted, in file order (the download tries them sequentially).
pub fn release_tarball_urls(&self, tag: &str) -> Vec<String> {
match self {
Forge::GitHub { owner, repo } => vec![
format!("https://codeload.github.com/{owner}/{repo}/tar.gz/refs/tags/{tag}"),
format!("https://github.com/{owner}/{repo}/archive/refs/tags/{tag}.tar.gz"),
],
Forge::GitLab { owner, repo } => {
vec![format!(
"https://gitlab.com/{owner}/{repo}/-/archive/{tag}/{repo}-{tag}.tar.gz"
)]
}
}
let entry = FORGES_DATA
.forges
.get(self.host)
.expect("the host of a parsed Forge is a key of the forge table");
entry
.tarball_templates
.iter()
.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() {
assert_eq!(
Forge::parse("https://github.com/foo/bar.git"),
Some(Forge::GitHub {
Some(Forge {
host: "github.com",
owner: "foo".into(),
repo: "bar".into()
})
);
assert_eq!(
Forge::parse("git@github.com:foo/bar.git"),
Some(Forge::GitHub {
Some(Forge {
host: "github.com",
owner: "foo".into(),
repo: "bar".into()
})
);
assert_eq!(
Forge::parse("git://github.com/foo/bar"),
Some(Forge::GitHub {
Some(Forge {
host: "github.com",
owner: "foo".into(),
repo: "bar".into()
})
);
assert_eq!(
Forge::parse("https://gitlab.com/foo/bar/-/tree/main"),
Some(Forge::GitLab {
Some(Forge {
host: "gitlab.com",
owner: "foo".into(),
repo: "bar".into()
})
);
assert_eq!(
Forge::parse("ssh://git@gitlab.com/foo/bar.git"),
Some(Forge::GitLab {
Some(Forge {
host: "gitlab.com",
owner: "foo".into(),
repo: "bar".into()
})
@@ -342,7 +384,8 @@ mod tests {
#[test]
fn forge_release_tarball_urls() {
let gh = Forge::GitHub {
let gh = Forge {
host: "github.com",
owner: "foo".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(),
]
);
let gl = Forge::GitLab {
let gl = Forge {
host: "gitlab.com",
owner: "foo".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]
fn sanitized_tag_versions() {
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");
assert_eq!(
origin.forge,
Some(Forge::GitHub {
Some(Forge {
host: "github.com",
owner: "foo".into(),
repo: "bar".into()
})
+2 -8
View File
@@ -1295,10 +1295,7 @@ mod tests {
fn orig_origin_choices_preorder_by_detection() {
use crate::new::origin::Forge;
let tagged_forge = GitOrigin {
forge: Some(Forge::GitHub {
owner: "foo".into(),
repo: "bar".into(),
}),
forge: Some(Forge::parse("https://github.com/foo/bar").unwrap()),
head_tag: Some("v1.4.0".into()),
..Default::default()
};
@@ -1362,10 +1359,7 @@ mod tests {
let mut release = quilt.clone();
release.orig = Some(options::OrigOrigin::Release {
tag: "v0.14.0".to_string(),
forge: crate::new::origin::Forge::GitLab {
owner: "foo".into(),
repo: "bar".into(),
},
forge: crate::new::origin::Forge::parse("https://gitlab.com/foo/bar").unwrap(),
});
let text = summary_text(&release, None);
assert!(