new: add upstream-aware orig tarball origins and the orig-vendor component

This commit is contained in:
2026-09-17 01:31:35 +02:00
parent 8e06b2074d
commit 77420e723a
19 changed files with 3270 additions and 184 deletions
+571 -5
View File
@@ -15,6 +15,8 @@ use crate::debian::DebianVersion;
use crate::debian::deps::{Deps, ParseOpts};
use crate::distro_info;
use crate::new::detect::{self, Detection};
use crate::new::origin::{Forge, GitOrigin};
use crate::new::templates;
/// Build systems / project kinds `pkh new` knows about.
///
@@ -133,6 +135,82 @@ pub enum SourceDir {
Path(PathBuf),
}
impl SourceDir {
/// The directory being packaged, when it already exists on disk (`None`
/// for the skeleton mode, whose sources are rendered in memory).
pub fn existing_dir(&self, cwd: &Path) -> Option<PathBuf> {
match self {
SourceDir::Skeleton => None,
SourceDir::Here => Some(cwd.to_path_buf()),
SourceDir::Path(path) => Some(path.clone()),
}
}
}
/// Debian source format of the scaffolded package: an explicit, derived
/// choice rather than a boolean afterthought.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SourceFormat {
/// `3.0 (native)`: no orig tarball at all. The default for a fresh,
/// self-authored skeleton.
Native,
/// `3.0 (quilt)`: upstream sources in the orig tarball, packaging in
/// `debian/`. The default when packaging an existing project.
Quilt,
}
impl SourceFormat {
/// The value written to `debian/source/format`.
pub fn deb_string(&self) -> &'static str {
match self {
SourceFormat::Native => super::debian::SOURCE_FORMAT_NATIVE,
SourceFormat::Quilt => super::debian::SOURCE_FORMAT_QUILT,
}
}
}
/// How the orig tarball of a quilt package is produced. The right answer
/// depends on where the upstream sources come from, so the wizard offers
/// them pre-ordered by what git origin detection found (release download
/// and `git archive` only exist when HEAD sits exactly on a tag).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OrigOrigin {
/// Download the upstream release tarball of `tag` from the forge of the
/// `origin` remote and repack it (explicit network consent).
Release {
/// Release tag the tarball is downloaded for.
tag: String,
/// Forge the tarball is downloaded from.
forge: Forge,
},
/// `git archive` the tag HEAD sits on: offline and deterministic.
GitArchive {
/// Release tag archived into the tarball.
tag: String,
},
/// Repack a tarball the user provides (a local path or an http(s) URL).
Provided {
/// Path or URL of the user-provided tarball.
source: String,
},
/// Snapshot the working tree into the orig tarball (the only option for
/// a skeleton, and the fallback when the upstream history is unknown).
Snapshot,
}
impl OrigOrigin {
/// Human-readable description used in the wizard summary and the
/// post-scaffold report.
pub fn label(&self) -> String {
match self {
OrigOrigin::Release { tag, .. } => format!("release download ({tag})"),
OrigOrigin::GitArchive { tag } => format!("git archive ({tag})"),
OrigOrigin::Provided { source } => format!("user tarball ({source})"),
OrigOrigin::Snapshot => "working tree snapshot".to_string(),
}
}
}
/// Upstream license of the package: a curated SPDX list plus a free-text
/// fallback for anything else (including "unknown" until the user picks one).
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -242,8 +320,13 @@ pub struct NewOptions {
/// Runtime Depends clauses of the metapackage flavor (canonically
/// rendered; empty for every other flavor).
pub depends: Vec<String>,
/// Use the `3.0 (native)` source format (no orig tarball).
pub native: bool,
/// Debian source format: `3.0 (native)` for a fresh skeleton by
/// default, `3.0 (quilt)` when packaging an existing project; either
/// can be forced with `--native` / `--quilt`.
pub source_format: SourceFormat,
/// How the orig tarball is produced (quilt only; `None` with the native
/// format, which has no orig tarball).
pub orig: Option<OrigOrigin>,
/// Initialize a git repository (gitignores are written regardless).
pub git: bool,
/// Write the autopkgtest smoke test (`debian/tests/control` +
@@ -320,6 +403,12 @@ pub struct NewCli {
pub release: bool,
/// `--native`.
pub native: bool,
/// `--quilt` (mutually exclusive with `--native`).
pub quilt: bool,
/// `--orig-from <release|git|path|snapshot>`.
pub orig_from: Option<String>,
/// `--orig-path <file|url>` (required by `--orig-from path`).
pub orig_path: Option<String>,
/// True unless `--no-git`.
pub git: bool,
/// `--defaults`.
@@ -604,11 +693,65 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
// Everything below has a default and is validated as it is resolved.
validate_source_name(&name)?;
let upstream_version = cli.upstream_version.unwrap_or_else(|| "0.1.0".to_string());
// Source format: explicit flags win (--native / --quilt, mutually
// exclusive), otherwise the mode decides — a fresh, self-authored
// skeleton is native by default, an existing project is quilt.
let source_format = match (cli.native, cli.quilt) {
(true, true) => {
return Err("--native and --quilt are mutually exclusive".to_string());
}
(true, false) => SourceFormat::Native,
(false, true) => SourceFormat::Quilt,
(false, false) => match source_dir {
SourceDir::Skeleton => SourceFormat::Native,
SourceDir::Here | SourceDir::Path(_) => SourceFormat::Quilt,
},
};
// Git origin state of the packaged directory: needed for the version
// default (tag / +git scheme) and for the orig-tarball plan. Probing is
// purely local (git config and refs, no network).
let existing_dir = source_dir.existing_dir(&std::env::current_dir().unwrap_or_default());
let needs_origin = cli.upstream_version.is_none()
|| (source_format == SourceFormat::Quilt
&& (cli.orig_from.is_some() || existing_dir.is_some()));
let origin = if needs_origin {
existing_dir.as_deref().and_then(GitOrigin::detect)
} else {
None
};
// Upstream version default: probed project version, else the tag HEAD
// sits on, else `<lasttag>+git<YYYYMMDD>.<hash>`, else 0.1.0.
let upstream_version = match cli.upstream_version {
Some(ref version) => version.clone(),
None => {
let probed = template
.and_then(templates::get)
.and_then(|t| t.probe(existing_dir.as_deref()?))
.and_then(|probe| probe.version)
.filter(|version| {
validate_upstream_version(version, cli.revision.unwrap_or(1)).is_ok()
});
let derived = probed
.or_else(|| origin.as_ref().and_then(GitOrigin::head_tag_version))
.or_else(|| origin.as_ref().and_then(GitOrigin::git_version))
.unwrap_or_else(|| "0.1.0".to_string());
if derived != "0.1.0" {
log::info!("No --upstream-version given, defaulting to '{derived}'");
}
derived
}
};
let revision = cli.revision.unwrap_or(1);
validate_upstream_version(&upstream_version, revision)
.map_err(|e| format!("Invalid upstream version: {e}"))?;
// Orig-tarball plan (quilt only). Without an explicit --orig-from the
// non-interactive default never touches the network: `git archive` of
// the tag when HEAD sits on one, a working-tree snapshot otherwise.
let orig = resolve_orig_origin(&cli, &source_dir, source_format, origin.as_ref())?;
let homepage = match &cli.homepage {
Some(h) => {
validate_homepage(h)?;
@@ -673,7 +816,8 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
series,
release: cli.release,
depends,
native: cli.native,
source_format,
orig,
git: cli.git,
autopkgtest: false,
pkg_config: false,
@@ -681,6 +825,111 @@ pub async fn resolve(cli: NewCli) -> Result<NewOptions, String> {
})
}
/// Resolve the orig-tarball plan of a quilt scaffold from the CLI answers
/// and the detected git origin. Fails early with a pointed message when an
/// explicit `--orig-from` mode cannot apply to this tree; the implicit,
/// non-interactive default never touches the network (`git archive` of the
/// tag when HEAD sits exactly on one, a working-tree snapshot otherwise).
fn resolve_orig_origin(
cli: &NewCli,
source_dir: &SourceDir,
source_format: SourceFormat,
origin: Option<&GitOrigin>,
) -> Result<Option<OrigOrigin>, String> {
if source_format == SourceFormat::Native {
if cli.orig_from.is_some() || cli.orig_path.is_some() {
return Err(
"--orig-from/--orig-path need a quilt package (drop --native \
or pass --quilt to build with an orig tarball)"
.to_string(),
);
}
return Ok(None);
}
let skeleton = matches!(source_dir, SourceDir::Skeleton);
let head_tag = || origin.and_then(|o| o.head_tag.clone());
let plan = match cli.orig_from.as_deref() {
Some("snapshot") => OrigOrigin::Snapshot,
Some("path") => {
let source = cli.orig_path.clone().ok_or_else(|| {
"--orig-from path requires --orig-path <tarball file or URL>".to_string()
})?;
validate_orig_path(&source)?;
OrigOrigin::Provided { source }
}
Some("git") => {
if skeleton {
return Err("a fresh skeleton has no upstream git history; use \
--orig-from snapshot (or drop --quilt)"
.to_string());
}
match head_tag() {
Some(tag) => OrigOrigin::GitArchive { tag },
None => {
return Err("--orig-from git requires HEAD to be exactly on a \
release tag"
.to_string());
}
}
}
Some("release") => {
if skeleton {
return Err("a fresh skeleton has no upstream release to download; \
use --orig-from snapshot (or drop --quilt)"
.to_string());
}
let Some(tag) = head_tag() else {
return Err("--orig-from release requires HEAD to be exactly on a \
release tag"
.to_string());
};
let forge = origin.and_then(|o| o.forge.clone()).ok_or_else(|| {
"--orig-from release needs a github.com or gitlab.com origin \
remote (self-hosted forges are not recognized)"
.to_string()
})?;
OrigOrigin::Release { tag, forge }
}
Some(other) => {
return Err(format!(
"Unknown --orig-from value '{other}'. Supported: release, \
git, path, snapshot."
));
}
None => {
// Skeletons (and anything without an upstream history) can only
// be snapshotted; on a tag, git archive is the offline default.
match (skeleton, head_tag()) {
(false, Some(tag)) => OrigOrigin::GitArchive { tag },
_ => OrigOrigin::Snapshot,
}
}
};
if cli.orig_path.is_some() && !matches!(plan, OrigOrigin::Provided { .. }) {
return Err("--orig-path is only valid together with --orig-from path".to_string());
}
Ok(Some(plan))
}
/// Validate a `--orig-path` value (and the wizard's tarball answer): either
/// an http(s) URL (downloaded at scaffold time) or an existing local
/// tarball.
pub fn validate_orig_path(source: &str) -> Result<(), String> {
if source.starts_with("http://") || source.starts_with("https://") {
return Ok(());
}
if Path::new(source).is_file() {
return Ok(());
}
Err(format!(
"'{source}' is not an http(s) URL and not an existing file: \
--orig-path must point at a tarball to repack"
))
}
/// Set of names no template may produce twice (collision check).
pub(crate) fn check_file_collisions(paths: &[String]) -> Result<(), String> {
let mut seen: HashSet<&String> = HashSet::new();
@@ -788,7 +1037,8 @@ mod tests {
series: "sid".into(),
release: false,
depends: Vec::new(),
native: false,
source_format: SourceFormat::Quilt,
orig: None,
git: false,
autopkgtest: false,
pkg_config: false,
@@ -937,6 +1187,10 @@ mod tests {
assert_eq!(opts.depends, vec!["hello (>= 1.0)", "hello-data"]);
assert!(opts.git);
assert!(!opts.release);
// An existing project (Path mode) is quilt by default; an empty,
// non-git directory has no better orig plan than a tree snapshot.
assert_eq!(opts.source_format, SourceFormat::Quilt);
assert_eq!(opts.orig, Some(OrigOrigin::Snapshot));
// Series: the development series of the current vendor (lowercased,
// matching the distro-info keys).
let dist = crate::build::env::current_vendor().to_lowercase();
@@ -979,4 +1233,316 @@ mod tests {
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("--release requires --series"), "{err}");
}
/// Minimal CLI answers for a scripted source directory.
fn cli_for(dir: &std::path::Path) -> NewCli {
NewCli {
name: Some("mytool".into()),
source: Some(dir.to_path_buf()),
lang: Some("empty".into()),
description: Some("A tool".into()),
maintainer: Some("Jane <jane@example.com>".into()),
git: true,
..Default::default()
}
}
/// A git repo fixture: init, commit, optional tag, optional extra
/// commit (needs the git CLI; returns (repo dir, HEAD short hash)).
fn git_repo(tag: Option<&str>, second_commit: bool) -> (tempfile::TempDir, String) {
let run = |dir: &std::path::Path, args: &[&str]| {
let status = std::process::Command::new("git")
.args([
"-c",
"user.name=T",
"-c",
"user.email=t@example.invalid",
"-c",
"commit.gpgsign=false",
])
.args(args)
.current_dir(dir)
.status()
.expect("git should be runnable");
assert!(status.success(), "git {args:?} failed");
};
let dir = tempfile::tempdir().unwrap();
run(dir.path(), &["init", "-q"]);
std::fs::write(dir.path().join("f"), "one\n").unwrap();
run(dir.path(), &["add", "f"]);
run(dir.path(), &["commit", "-q", "-m", "first"]);
if let Some(tag) = tag {
run(dir.path(), &["tag", tag]);
}
if second_commit {
std::fs::write(dir.path().join("f"), "two\n").unwrap();
run(dir.path(), &["add", "f"]);
run(dir.path(), &["commit", "-q", "-m", "second"]);
}
let hash = std::process::Command::new("git")
.args(["rev-parse", "--short", "HEAD"])
.current_dir(dir.path())
.output()
.unwrap();
let hash = String::from_utf8_lossy(&hash.stdout).trim().to_string();
(dir, hash)
}
#[tokio::test]
async fn resolve_skeleton_is_native_without_orig_tarball() {
// A self-authored skeleton defaults to 3.0 (native): no orig
// tarball, no orig plan.
let cli = NewCli {
name: Some("mytool".into()),
lang: Some("shell".into()),
description: Some("A tool".into()),
maintainer: Some("Jane <jane@example.com>".into()),
git: true,
..Default::default()
};
let opts = resolve(cli).await.unwrap();
assert_eq!(opts.source_format, SourceFormat::Native);
assert_eq!(opts.orig, None);
}
#[tokio::test]
async fn resolve_skeleton_forced_quilt_snapshots_the_tree() {
let cli = NewCli {
name: Some("mytool".into()),
lang: Some("shell".into()),
description: Some("A tool".into()),
maintainer: Some("Jane <jane@example.com>".into()),
quilt: true,
git: true,
..Default::default()
};
let opts = resolve(cli).await.unwrap();
assert_eq!(opts.source_format, SourceFormat::Quilt);
assert_eq!(opts.orig, Some(OrigOrigin::Snapshot));
}
#[tokio::test]
async fn resolve_skeleton_rejects_history_dependent_orig_modes() {
// A skeleton has no upstream git history: only the snapshot mode
// makes sense.
for mode in ["git", "release"] {
let cli = NewCli {
name: Some("mytool".into()),
lang: Some("shell".into()),
description: Some("A tool".into()),
maintainer: Some("Jane <jane@example.com>".into()),
quilt: true,
orig_from: Some(mode.to_string()),
git: true,
..Default::default()
};
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("no upstream"), "{mode}: {err}");
}
}
#[tokio::test]
async fn resolve_existing_project_forced_native() {
let dir = tempfile::tempdir().unwrap();
let mut cli = cli_for(dir.path());
cli.native = true;
let opts = resolve(cli).await.unwrap();
assert_eq!(opts.source_format, SourceFormat::Native);
assert_eq!(opts.orig, None);
}
#[tokio::test]
async fn resolve_native_and_quilt_conflict() {
let cli = NewCli {
name: Some("mytool".into()),
lang: Some("shell".into()),
description: Some("A tool".into()),
maintainer: Some("Jane <jane@example.com>".into()),
native: true,
quilt: true,
..Default::default()
};
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("mutually exclusive"), "{err}");
}
#[tokio::test]
async fn resolve_head_on_tag_defaults_to_git_archive_and_tag_version() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
return;
}
let (dir, _hash) = git_repo(Some("v1.2.3"), false);
let opts = resolve(cli_for(dir.path())).await.unwrap();
assert_eq!(opts.source_format, SourceFormat::Quilt);
// Version default: the tag version, not 0.1.0.
assert_eq!(opts.upstream_version, "1.2.3");
// Non-interactive orig default on a tag: git archive (no network).
assert_eq!(
opts.orig,
Some(OrigOrigin::GitArchive {
tag: "v1.2.3".to_string()
})
);
}
#[tokio::test]
async fn resolve_between_releases_snapshots_with_git_version() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
return;
}
let (dir, _hash) = git_repo(Some("v1.2.3"), true);
let opts = resolve(cli_for(dir.path())).await.unwrap();
let expected = crate::new::origin::GitOrigin::detect(dir.path())
.unwrap()
.git_version()
.unwrap();
assert_eq!(opts.upstream_version, expected);
assert!(opts.upstream_version.starts_with("1.2.3+git"));
assert_eq!(opts.orig, Some(OrigOrigin::Snapshot));
}
#[tokio::test]
async fn resolve_probed_project_version_beats_the_tag() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
return;
}
let (dir, _) = git_repo(Some("v9.9.9"), false);
// A Cargo.toml without src/ makes `cargo metadata` fail, so the
// template probe falls back to its deterministic line parse.
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"x\"\nversion = \"3.3.3\"\n",
)
.unwrap();
let mut cli = cli_for(dir.path());
cli.lang = Some("rust".into());
let opts = resolve(cli).await.unwrap();
assert_eq!(opts.upstream_version, "3.3.3");
// The orig plan still comes from the tag.
assert_eq!(
opts.orig,
Some(OrigOrigin::GitArchive {
tag: "v9.9.9".to_string()
})
);
}
#[tokio::test]
async fn resolve_orig_from_modes() {
if std::process::Command::new("git")
.arg("--version")
.output()
.is_err()
{
return;
}
let (dir, _) = git_repo(Some("v1.2.3"), false);
// snapshot is always allowed.
let mut cli = cli_for(dir.path());
cli.orig_from = Some("snapshot".into());
assert_eq!(resolve(cli).await.unwrap().orig, Some(OrigOrigin::Snapshot));
// release requires a recognized forge remote.
let mut cli = cli_for(dir.path());
cli.orig_from = Some("release".into());
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("github.com or gitlab.com"), "{err}");
// After adding a recognized remote the release plan resolves.
std::process::Command::new("git")
.args(["remote", "add", "origin", "https://github.com/foo/bar.git"])
.current_dir(dir.path())
.status()
.unwrap()
.success()
.then_some(())
.unwrap();
let mut cli = cli_for(dir.path());
cli.orig_from = Some("release".into());
assert_eq!(
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()
}
})
);
// path requires --orig-path, and an existing file or a URL.
let mut cli = cli_for(dir.path());
cli.orig_from = Some("path".into());
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("--orig-path"), "{err}");
let tarball = dir.path().join("upstream.tar.gz");
std::fs::write(&tarball, b"not really a tarball").unwrap();
let mut cli = cli_for(dir.path());
cli.orig_from = Some("path".into());
cli.orig_path = Some(tarball.to_string_lossy().into_owned());
assert_eq!(
resolve(cli).await.unwrap().orig,
Some(OrigOrigin::Provided {
source: tarball.to_string_lossy().into_owned()
})
);
let mut cli = cli_for(dir.path());
cli.orig_from = Some("path".into());
cli.orig_path = Some("https://example.com/upstream.tar.gz".into());
assert_eq!(
resolve(cli).await.unwrap().orig,
Some(OrigOrigin::Provided {
source: "https://example.com/upstream.tar.gz".to_string()
})
);
// A missing local path is rejected early.
let mut cli = cli_for(dir.path());
cli.orig_from = Some("path".into());
cli.orig_path = Some(
dir.path()
.join("missing.tar.gz")
.to_string_lossy()
.into_owned(),
);
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("not an http(s) URL"), "{err}");
// --orig-path without --orig-from path is a misuse.
let mut cli = cli_for(dir.path());
cli.orig_path = Some(tarball.to_string_lossy().into_owned());
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("only valid together"), "{err}");
// git requires HEAD exactly on a tag.
let (off, _) = git_repo(Some("v1.0.0"), true);
let mut cli = cli_for(off.path());
cli.orig_from = Some("git".into());
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("exactly on a release tag"), "{err}");
// ... and the orig flags need quilt.
let mut cli = cli_for(dir.path());
cli.native = true;
cli.orig_from = Some("git".into());
let err = resolve(cli).await.unwrap_err();
assert!(err.contains("need a quilt package"), "{err}");
}
}