Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b017dcf43 | ||
|
|
4ab41e691a | ||
|
|
c18f1fe9c2 |
+13
-22
@@ -14,9 +14,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::context::Context;
|
||||
use crate::debian::{
|
||||
ChecksumEntry, ControlInfo, FileChecksums, FilesList, parse_changelog_entry_from_str,
|
||||
};
|
||||
use crate::debian::{ChecksumEntry, ControlInfo, FileChecksums, FilesList};
|
||||
|
||||
use super::parse_checksum_field;
|
||||
|
||||
@@ -70,7 +68,10 @@ pub fn generate_binary_metadata(
|
||||
// Metadata sources inside the context
|
||||
// ------------------------------------------------------------------
|
||||
let changelog_content = ctx.read_file(&package_dir.join("debian/changelog"))?;
|
||||
let entry = parse_changelog_entry_from_str(&changelog_content)?;
|
||||
let mut entries =
|
||||
crate::debian::changelog::parse_changelog_entries_from_str(&changelog_content, Some(2))?;
|
||||
let entry = entries.remove(0);
|
||||
let previous_entry = entries.into_iter().next();
|
||||
|
||||
let control_content = ctx.read_file(&package_dir.join("debian/control"))?;
|
||||
let control = ControlInfo::parse_content(&control_content)?;
|
||||
@@ -147,22 +148,13 @@ pub fn generate_binary_metadata(
|
||||
// a changelog that cannot yield it is a hard error, like in the
|
||||
// source-build path. Reuse the changelog read above instead of
|
||||
// reading the file a second time.
|
||||
let changelog_path = package_dir.join("debian/changelog");
|
||||
let prev = crate::debian::changelog::parse_previous_version_from_str(&changelog_content)
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"cannot parse the previous version from '{}': {e}",
|
||||
changelog_path.display()
|
||||
)
|
||||
})?;
|
||||
if let Some(prev) = prev {
|
||||
source_display = format!("{} ({})", entry.source, prev);
|
||||
if let Some(prev) = &previous_entry {
|
||||
source_display = format!("{} ({})", entry.source, prev.version.full());
|
||||
binary_only_changes = Some(format!(
|
||||
"{}\n\n -- {} <{}> {}",
|
||||
entry.changes_field, entry.maintainer_name, entry.maintainer_email, entry.date_raw
|
||||
));
|
||||
let prev_version = crate::debian::DebianVersion::parse(&prev)?;
|
||||
let dsc_name = format!("{}_{}.dsc", entry.source, prev_version.no_epoch());
|
||||
let dsc_name = format!("{}_{}.dsc", entry.source, prev.version.no_epoch());
|
||||
let dsc_path = upload_dir.join(&dsc_name);
|
||||
if ctx.exists(&dsc_path)? {
|
||||
include_dsc_artifacts(ctx, upload_dir, &dsc_name, &mut checksums)?;
|
||||
@@ -628,10 +620,10 @@ Files:
|
||||
}
|
||||
|
||||
/// A binary-only (binNMU) build whose changelog cannot yield the
|
||||
/// previous version (malformed second header, unbalanced parenthesis)
|
||||
/// must fail the metadata generation with a diagnostic naming the
|
||||
/// changelog, instead of silently emitting a plain `Source:` `.changes`
|
||||
/// with no `Binary-Only-Changes` and no redistributed previous `.dsc`.
|
||||
/// previous entry (malformed second header, unbalanced parenthesis) must
|
||||
/// fail the metadata generation with a diagnostic naming the problem,
|
||||
/// instead of silently emitting a plain `Source:` `.changes` with no
|
||||
/// `Binary-Only-Changes` and no redistributed previous `.dsc`.
|
||||
#[test]
|
||||
fn binary_only_prev_version_parse_failure_errors_instead_of_wrong_metadata() {
|
||||
let changelog = "\
|
||||
@@ -683,9 +675,8 @@ Description: test package
|
||||
let err = generate_binary_metadata(&ctx, &tree, base.path(), &opts)
|
||||
.expect_err("binary-only build with an unparseable changelog must fail");
|
||||
let err = err.to_string();
|
||||
assert!(err.contains("debian/changelog"), "{err}");
|
||||
assert!(err.contains("previous version"), "{err}");
|
||||
assert!(err.contains("unbalanced parenthesis"), "{err}");
|
||||
assert!(err.contains("1.0-1 unstable"), "{err}");
|
||||
}
|
||||
|
||||
/// An unreadable `debian/files` (e.g. permissions) must fail the
|
||||
|
||||
@@ -4,10 +4,58 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::OrigSourceMode;
|
||||
use crate::debian::changelog::ChangelogEntry;
|
||||
use crate::debian::checksums::FileChecksums;
|
||||
use crate::debian::control::{Paragraph, write_paragraph};
|
||||
use crate::debian::files::FilesList;
|
||||
|
||||
/// Compression suffixes dpkg recognizes on source tarballs.
|
||||
const TARBALL_COMPRESSIONS: &[&str] = &[".gz", ".bz2", ".xz", ".lzma", ".zst"];
|
||||
|
||||
/// Whether this `.dsc`-listed file is an upstream orig tarball
|
||||
/// (`*.orig.tar.<ext>` or a component tarball `*.orig-<c>.tar.<ext>`),
|
||||
/// mirroring dpkg-genchanges' strip pattern `\.orig(-.+)?\.tar\.$ext`.
|
||||
pub fn is_orig_tarball(name: &str) -> bool {
|
||||
TARBALL_COMPRESSIONS.iter().any(|ext| {
|
||||
name.strip_suffix(ext)
|
||||
.and_then(|s| s.strip_suffix(".tar"))
|
||||
.is_some_and(|stem| stem.ends_with(".orig") || stem.contains(".orig-"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this `.dsc`-listed file is the Debian part of the source package
|
||||
/// (`*.debian.tar.<ext>` for the 3.0 formats, `*.diff.<ext>` for 1.0).
|
||||
pub fn is_debian_tarball_or_diff(name: &str) -> bool {
|
||||
TARBALL_COMPRESSIONS.iter().any(|ext| {
|
||||
name.ends_with(&format!(".debian.tar{ext}")) || name.ends_with(&format!(".diff{ext}"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the upload redistributes the upstream tarballs, mirroring the
|
||||
/// dpkg-genchanges source styles: `Always`/`Never` are the forced
|
||||
/// `-sa`/`-sd`, while `Auto` is the default `-si` — include them only when
|
||||
/// there is no previous changelog entry (first upload) or the source name or
|
||||
/// upstream version changed since it. Like dpkg, the comparison uses the
|
||||
/// epoch-less upstream version: a plain revision bump reuses the tarball
|
||||
/// already in the archive.
|
||||
pub fn include_orig_tarball(
|
||||
mode: OrigSourceMode,
|
||||
current: &ChangelogEntry,
|
||||
previous: Option<&ChangelogEntry>,
|
||||
) -> bool {
|
||||
match mode {
|
||||
OrigSourceMode::Always => true,
|
||||
OrigSourceMode::Never => false,
|
||||
OrigSourceMode::Auto => match previous {
|
||||
None => true,
|
||||
Some(prev) => {
|
||||
prev.source != current.source || prev.version.upstream != current.version.upstream
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything needed to render a `.changes` file.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChangesInput {
|
||||
@@ -159,6 +207,91 @@ pub fn save_changes(path: &Path, paragraph: &Paragraph) -> Result<(), Box<dyn st
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn orig_tarball_detection() {
|
||||
assert!(is_orig_tarball("pkg_1.0.orig.tar.gz"));
|
||||
assert!(is_orig_tarball("pkg_1.0.orig.tar.xz"));
|
||||
assert!(is_orig_tarball("pkg_1.0.orig.tar.zst"));
|
||||
assert!(is_orig_tarball("pkg_1.0~rc1.orig.tar.bz2"));
|
||||
// Component tarballs.
|
||||
assert!(is_orig_tarball("pkg_1.0.orig-docs.tar.xz"));
|
||||
assert!(is_orig_tarball("pkg_1.0.orig-vendor.tar.gz"));
|
||||
// Not orig tarballs.
|
||||
assert!(!is_orig_tarball("pkg_1.0.debian.tar.xz"));
|
||||
assert!(!is_debian_tarball_or_diff("pkg_1.0.orig.tar.xz"));
|
||||
assert!(!is_orig_tarball("pkg_1.0.tar.xz")); // native tarball
|
||||
assert!(!is_orig_tarball("pkg_1.0.dsc"));
|
||||
assert!(!is_orig_tarball("pkg_1.0.orig.tar")); // no compression suffix
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debian_tarball_detection() {
|
||||
assert!(is_debian_tarball_or_diff("pkg_1.0.debian.tar.xz"));
|
||||
assert!(is_debian_tarball_or_diff("pkg_1.0.diff.gz"));
|
||||
assert!(!is_debian_tarball_or_diff("pkg_1.0.orig.tar.xz"));
|
||||
assert!(!is_debian_tarball_or_diff("pkg_1.0.tar.xz"));
|
||||
}
|
||||
|
||||
/// Build a minimal changelog entry for one source/version pair.
|
||||
fn entry(src: &str, ver: &str) -> ChangelogEntry {
|
||||
crate::debian::changelog::parse_changelog_entries_from_str(
|
||||
&format!(
|
||||
"{src} ({ver}) unstable; urgency=medium\n\n * x\n\n \
|
||||
-- A B <a@b.c> Thu, 01 Jan 2026 00:00:00 +0000\n"
|
||||
),
|
||||
Some(1),
|
||||
)
|
||||
.unwrap()
|
||||
.remove(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orig_inclusion_matrix() {
|
||||
let cur = entry("pkg", "1.4-2");
|
||||
let prev_same_upstream = entry("pkg", "1.4-1");
|
||||
let prev_new_upstream = entry("pkg", "2.0-1");
|
||||
let prev_renamed = entry("renamed", "1.4-1");
|
||||
|
||||
// -sa / -sd force the outcome.
|
||||
assert!(include_orig_tarball(
|
||||
OrigSourceMode::Always,
|
||||
&cur,
|
||||
Some(&prev_same_upstream)
|
||||
));
|
||||
assert!(!include_orig_tarball(
|
||||
OrigSourceMode::Never,
|
||||
&cur,
|
||||
Some(&prev_new_upstream)
|
||||
));
|
||||
|
||||
// -si: first upload includes; a revision bump excludes; a new
|
||||
// upstream version or a renamed source includes.
|
||||
assert!(include_orig_tarball(OrigSourceMode::Auto, &cur, None));
|
||||
assert!(!include_orig_tarball(
|
||||
OrigSourceMode::Auto,
|
||||
&cur,
|
||||
Some(&prev_same_upstream)
|
||||
));
|
||||
assert!(include_orig_tarball(
|
||||
OrigSourceMode::Auto,
|
||||
&cur,
|
||||
Some(&prev_new_upstream)
|
||||
));
|
||||
assert!(include_orig_tarball(
|
||||
OrigSourceMode::Auto,
|
||||
&cur,
|
||||
Some(&prev_renamed)
|
||||
));
|
||||
|
||||
// The epoch is not part of the comparison, like dpkg's version().
|
||||
let cur_epoch = entry("pkg", "2:1.4-2");
|
||||
assert!(!include_orig_tarball(
|
||||
OrigSourceMode::Auto,
|
||||
&cur_epoch,
|
||||
Some(&prev_same_upstream)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn description_formatting() {
|
||||
assert_eq!(
|
||||
|
||||
+159
-35
@@ -27,6 +27,20 @@ use crate::debian::{
|
||||
use crate::ui::deb::DebUi;
|
||||
use crate::ui::logfmt::{DpkgSourceClassifier, GenericClassifier};
|
||||
|
||||
/// Whether the upload distributes the upstream orig tarballs (`--orig`),
|
||||
/// mirroring the `dpkg-genchanges` source styles.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum OrigSourceMode {
|
||||
/// Include them only when the upstream version changed since the
|
||||
/// previous changelog entry (`-si`, dpkg's default).
|
||||
#[default]
|
||||
Auto,
|
||||
/// Always include them (`-sa`).
|
||||
Always,
|
||||
/// Never include them (`-sd`).
|
||||
Never,
|
||||
}
|
||||
|
||||
/// Options for a native source-package build.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SourceBuildOptions {
|
||||
@@ -39,6 +53,10 @@ pub struct SourceBuildOptions {
|
||||
/// build (`-D`). `dpkg-buildpackage` skips `dpkg-checkbuilddeps`
|
||||
/// entirely for source-only builds unless forced.
|
||||
pub force_dep_check: bool,
|
||||
/// Whether the upload distributes the upstream orig tarballs
|
||||
/// (`--orig`). Defaults to [`OrigSourceMode::Auto`], like
|
||||
/// `dpkg-genchanges`' `-si`.
|
||||
pub orig_source: OrigSourceMode,
|
||||
}
|
||||
|
||||
/// Artifacts produced by a successful source build.
|
||||
@@ -70,6 +88,7 @@ pub struct SourceBuildOutput {
|
||||
/// exactly once.
|
||||
pub fn build_source_package(
|
||||
cwd: Option<&Path>,
|
||||
opts: SourceBuildOptions,
|
||||
ui: Option<Arc<DebUi>>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
// Default to the process's current working directory, resolved to an
|
||||
@@ -81,10 +100,10 @@ pub fn build_source_package(
|
||||
None => std::env::current_dir()
|
||||
.map_err(|e| format!("cannot determine the current working directory: {e}"))?,
|
||||
};
|
||||
let output = match run_source_build(&cwd, &SourceBuildOptions::default(), ui.clone()) {
|
||||
let output = match run_source_build(&cwd, &opts, ui.clone()) {
|
||||
Ok(output) => output,
|
||||
Err(e) if e.downcast_ref::<VendorDriftError>().is_some() => {
|
||||
return retry_after_revendor(&cwd, ui, e);
|
||||
return retry_after_revendor(&cwd, ui, opts, e);
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(u) = &ui {
|
||||
@@ -133,6 +152,7 @@ pub fn build_source_package(
|
||||
fn retry_after_revendor(
|
||||
cwd: &Path,
|
||||
ui: Option<Arc<DebUi>>,
|
||||
opts: SourceBuildOptions,
|
||||
original: Box<dyn Error>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
|
||||
@@ -197,8 +217,8 @@ fn retry_after_revendor(
|
||||
}
|
||||
crate::new::orig::create_vendor_component(cwd, &entry.source, uversion)?;
|
||||
|
||||
// 3. One retry.
|
||||
run_source_build(cwd, &SourceBuildOptions::default(), ui).map(|_| ())
|
||||
// 3. One retry, with the options of the original attempt.
|
||||
run_source_build(cwd, &opts, ui).map(|_| ())
|
||||
}
|
||||
|
||||
/// Run the full native source-build pipeline in `cwd`.
|
||||
@@ -255,7 +275,12 @@ pub fn run_source_build(
|
||||
// ------------------------------------------------------------------
|
||||
// 2. Metadata resolution
|
||||
// ------------------------------------------------------------------
|
||||
let entry = crate::debian::parse_changelog_entry(&changelog_path)?;
|
||||
// The current entry plus the one below it: the previous entry drives
|
||||
// both the binNMU metadata references and the orig-tarball inclusion
|
||||
// decision.
|
||||
let mut entries = crate::debian::changelog::parse_changelog_entries(&changelog_path, Some(2))?;
|
||||
let entry = entries.remove(0);
|
||||
let previous_entry = entries.into_iter().next();
|
||||
let ctrl = ControlInfo::parse(&control_path)?;
|
||||
|
||||
if let Some(u) = &ui {
|
||||
@@ -265,7 +290,7 @@ pub fn run_source_build(
|
||||
// binNMU builds reference the *previous* (source) version in their
|
||||
// artifact metadata, like dpkg-genchanges/genbuildinfo do.
|
||||
let previous_version = if entry.binary_only {
|
||||
crate::debian::changelog::parse_previous_version(&changelog_path)?
|
||||
previous_entry.as_ref().map(|e| e.version.full())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -493,6 +518,13 @@ pub fn run_source_build(
|
||||
.next()
|
||||
.ok_or_else(|| format!("'{}' is empty", ref_dsc_path.display()))?;
|
||||
|
||||
// Whether the upload redistributes the upstream tarballs (dpkg
|
||||
// -sa/-si/-sd source styles). Stripping only applies to a split source
|
||||
// package — a native one has no orig tarball, and an explicit `never`
|
||||
// for one is only a warning, like dpkg-genchanges with -sd.
|
||||
let include_orig =
|
||||
changes::include_orig_tarball(opts.orig_source, &entry, previous_entry.as_ref());
|
||||
|
||||
let mut tarball_paths = Vec::new();
|
||||
let mut dsc_file_names: Vec<String> = Vec::new();
|
||||
let mut dsc_files: HashMap<String, PartialChecksum> = HashMap::new();
|
||||
@@ -518,10 +550,38 @@ pub fn run_source_build(
|
||||
slot.size = Some(cl.size);
|
||||
}
|
||||
}
|
||||
|
||||
let has_debian_part = dsc_file_names
|
||||
.iter()
|
||||
.any(|n| changes::is_debian_tarball_or_diff(n));
|
||||
let strip_origs = !include_orig && has_debian_part;
|
||||
if opts.orig_source == OrigSourceMode::Never && !has_debian_part {
|
||||
log::warn!("ignoring --orig never for a native Debian package");
|
||||
}
|
||||
if let Some(u) = &ui {
|
||||
u.progress_message(if strip_origs {
|
||||
"Not including original source code in upload"
|
||||
} else {
|
||||
"Including full source code in upload"
|
||||
});
|
||||
}
|
||||
// Stripped orig tarballs (and their detached .asc signatures) are not
|
||||
// distributed at all: not hashed, not required on disk, like
|
||||
// dpkg-genchanges.
|
||||
let is_stripped = |name: &str| {
|
||||
strip_origs
|
||||
&& (changes::is_orig_tarball(name)
|
||||
|| (name.ends_with(".asc")
|
||||
&& changes::is_orig_tarball(name.strip_suffix(".asc").unwrap_or(name))))
|
||||
};
|
||||
|
||||
for name in &dsc_file_names {
|
||||
if name == &ref_dsc_name {
|
||||
continue; // already computed directly above
|
||||
}
|
||||
if is_stripped(name) {
|
||||
continue;
|
||||
}
|
||||
let path = parent.join(name);
|
||||
if !path.exists() {
|
||||
return Err(format!(
|
||||
@@ -567,7 +627,7 @@ pub fn run_source_build(
|
||||
ctrl.priority(),
|
||||
));
|
||||
for name in &dsc_file_names {
|
||||
if name != &ref_dsc_name {
|
||||
if name != &ref_dsc_name && !is_stripped(name) {
|
||||
changes_files.add(FilesEntry::new(name, ctrl.section(), ctrl.priority()));
|
||||
}
|
||||
}
|
||||
@@ -1204,8 +1264,8 @@ mod differential_tests {
|
||||
body: &'static [&'static str],
|
||||
patches: &'static [&'static str],
|
||||
extra_source_fields: &'static [(&'static str, &'static str)],
|
||||
/// Two-entry changelog (previous entry) for binNMU cases.
|
||||
with_previous_entry: bool,
|
||||
/// Version of the previous changelog entry, when the fixture has one.
|
||||
previous_version: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl FixtureSpec {
|
||||
@@ -1222,7 +1282,7 @@ mod differential_tests {
|
||||
body: &["* Something changed."],
|
||||
patches: &[],
|
||||
extra_source_fields: &[],
|
||||
with_previous_entry: false,
|
||||
previous_version: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1245,13 +1305,7 @@ mod differential_tests {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&format!("\n -- {MAINTAINER} {DATE}\n"));
|
||||
if self.with_previous_entry {
|
||||
let prev = match self.version.split_once(':') {
|
||||
Some((_, r)) => r.to_string(),
|
||||
None => self.version.to_string(),
|
||||
};
|
||||
// Turn "1.0-1+b1" into "1.0-1" for the previous entry.
|
||||
let prev = prev.rsplit_once('+').map(|(p, _)| p).unwrap_or(&prev);
|
||||
if let Some(prev) = self.previous_version {
|
||||
out.push_str(&format!(
|
||||
"\n{p} ({pv}) {d}; urgency={u}\n\n * Initial release.\n\n -- {MAINTAINER} {DATE}\n",
|
||||
p = self.name,
|
||||
@@ -1377,16 +1431,12 @@ mod differential_tests {
|
||||
);
|
||||
}
|
||||
|
||||
fn run_dpkg(tree: &Path) {
|
||||
fn run_dpkg(tree: &Path, source_style: &[&str]) {
|
||||
let status = crate::test_support::run_logged(
|
||||
Command::new("dpkg-buildpackage").current_dir(tree).args([
|
||||
"-S",
|
||||
"-I",
|
||||
"-i",
|
||||
"-nc",
|
||||
"-d",
|
||||
"--no-sign",
|
||||
]),
|
||||
Command::new("dpkg-buildpackage")
|
||||
.current_dir(tree)
|
||||
.args(["-S", "-I", "-i", "-nc", "-d", "--no-sign"])
|
||||
.args(source_style),
|
||||
)
|
||||
.expect("failed to run dpkg-buildpackage (is dpkg-dev installed?)");
|
||||
assert!(status.success(), "dpkg-buildpackage failed");
|
||||
@@ -1475,8 +1525,10 @@ mod differential_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build `src_tree` with both implementations and compare all artifacts.
|
||||
fn differential_on_tree(src_tree: &Path) {
|
||||
/// Build `src_tree` with both implementations and compare all artifacts,
|
||||
/// the native side running with `opts` (dpkg receives the matching
|
||||
/// source style so both sides make the same orig-tarball decision).
|
||||
fn differential_on_tree(src_tree: &Path, opts: &SourceBuildOptions) {
|
||||
let src_parent = src_tree.parent().expect("tree has a parent directory");
|
||||
let tree_name = src_tree.file_name().expect("tree has a name").to_owned();
|
||||
|
||||
@@ -1503,9 +1555,13 @@ mod differential_tests {
|
||||
let golden_tree = golden_root.join(&tree_name);
|
||||
let ours_tree = ours_root.join(&tree_name);
|
||||
|
||||
run_dpkg(&golden_tree);
|
||||
run_source_build(&ours_tree, &SourceBuildOptions::default(), None)
|
||||
.expect("native source pipeline failed");
|
||||
let source_style: &[&str] = match opts.orig_source {
|
||||
OrigSourceMode::Auto => &[],
|
||||
OrigSourceMode::Always => &["-sa"],
|
||||
OrigSourceMode::Never => &["-sd"],
|
||||
};
|
||||
run_dpkg(&golden_tree, source_style);
|
||||
run_source_build(&ours_tree, opts, None).expect("native source pipeline failed");
|
||||
|
||||
let entry =
|
||||
crate::debian::parse_changelog_entry(&ours_tree.join("debian/changelog")).unwrap();
|
||||
@@ -1535,7 +1591,7 @@ mod differential_tests {
|
||||
fn differential_case(spec: &FixtureSpec) {
|
||||
let base = tempfile::tempdir().expect("tempdir");
|
||||
let tree = write_fixture(base.path(), spec);
|
||||
differential_on_tree(&tree);
|
||||
differential_on_tree(&tree, &SourceBuildOptions::default());
|
||||
}
|
||||
|
||||
/// Differential check of [`crate::debian::arch::arch_env`] against real
|
||||
@@ -1981,7 +2037,7 @@ Provides: virtual-thing (= 2.0), plain-virtual
|
||||
fn diff_binmu_binary_only() {
|
||||
let mut spec = FixtureSpec::new("pkh-diff-i", "1.0-1+b1", "unstable");
|
||||
spec.body = &["* Binary-only rebuild."];
|
||||
spec.with_previous_entry = true;
|
||||
spec.previous_version = Some("1.0-1");
|
||||
|
||||
// Binary-only metadata references the previous version's .dsc, which
|
||||
// must already exist next to the package tree.
|
||||
@@ -1996,7 +2052,7 @@ Provides: virtual-thing (= 2.0), plain-virtual
|
||||
prev_dsc,
|
||||
)
|
||||
.expect("write previous dsc");
|
||||
differential_on_tree(&tree);
|
||||
differential_on_tree(&tree, &SourceBuildOptions::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2024,6 +2080,74 @@ Provides: virtual-thing (= 2.0), plain-virtual
|
||||
differential_case(&spec);
|
||||
}
|
||||
|
||||
/// The default dpkg-genchanges source style (-si): a revision bump
|
||||
/// within the same upstream version must NOT redistribute the orig
|
||||
/// tarball in the `.changes`.
|
||||
#[test]
|
||||
fn diff_quilt_revision_bump_excludes_orig() {
|
||||
let mut spec = FixtureSpec::new("pkh-diff-n", "1.4-2", "unstable");
|
||||
spec.format = "3.0 (quilt)";
|
||||
spec.patches = &[
|
||||
"--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched by debian\n",
|
||||
];
|
||||
spec.previous_version = Some("1.4-1");
|
||||
differential_case(&spec);
|
||||
}
|
||||
|
||||
/// Conversely, a new upstream version must redistribute the orig
|
||||
/// tarball, even though a previous entry exists.
|
||||
#[test]
|
||||
fn diff_quilt_new_upstream_includes_orig() {
|
||||
let mut spec = FixtureSpec::new("pkh-diff-o", "2.0-1", "unstable");
|
||||
spec.format = "3.0 (quilt)";
|
||||
spec.patches = &[
|
||||
"--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched by debian\n",
|
||||
];
|
||||
spec.previous_version = Some("1.4-2");
|
||||
differential_case(&spec);
|
||||
}
|
||||
|
||||
/// `--orig always` (-sa) forces the tarball into a same-upstream
|
||||
/// revision bump upload.
|
||||
#[test]
|
||||
fn diff_orig_always_forces_inclusion() {
|
||||
let mut spec = FixtureSpec::new("pkh-diff-p", "1.4-2", "unstable");
|
||||
spec.format = "3.0 (quilt)";
|
||||
spec.patches = &[
|
||||
"--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched by debian\n",
|
||||
];
|
||||
spec.previous_version = Some("1.4-1");
|
||||
let base = tempfile::tempdir().expect("tempdir");
|
||||
let tree = write_fixture(base.path(), &spec);
|
||||
differential_on_tree(
|
||||
&tree,
|
||||
&SourceBuildOptions {
|
||||
orig_source: OrigSourceMode::Always,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// `--orig never` (-sd) forces the tarball out of a new-upstream upload.
|
||||
#[test]
|
||||
fn diff_orig_never_forces_exclusion() {
|
||||
let mut spec = FixtureSpec::new("pkh-diff-q", "2.0-1", "unstable");
|
||||
spec.format = "3.0 (quilt)";
|
||||
spec.patches = &[
|
||||
"--- a/hello.txt\n+++ b/hello.txt\n@@ -1 +1 @@\n-upstream content v1\n+patched by debian\n",
|
||||
];
|
||||
spec.previous_version = Some("1.4-2");
|
||||
let base = tempfile::tempdir().expect("tempdir");
|
||||
let tree = write_fixture(base.path(), &spec);
|
||||
differential_on_tree(
|
||||
&tree,
|
||||
&SourceBuildOptions {
|
||||
orig_source: OrigSourceMode::Never,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Differential check of a single real archive package: pull it with
|
||||
/// pkh's own [`crate::pull`] (archive download mode) from `dist`
|
||||
/// (optionally `series`), then compare artifacts produced by real
|
||||
@@ -2067,7 +2191,7 @@ Provides: virtual-thing (= 2.0), plain-virtual
|
||||
dist,
|
||||
series.unwrap_or("latest")
|
||||
);
|
||||
differential_on_tree(&tree);
|
||||
differential_on_tree(&tree, &SourceBuildOptions::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+170
-41
@@ -36,8 +36,12 @@ pub struct ChangelogEntry {
|
||||
pub closes: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse the most recent entry of a Debian changelog file.
|
||||
pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
|
||||
/// Parse up to `limit` entries of a Debian changelog file, newest first
|
||||
/// (`None` parses the whole file).
|
||||
pub fn parse_changelog_entries(
|
||||
path: &Path,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<ChangelogEntry>, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path).map_err(|e| {
|
||||
format!(
|
||||
"failed to read changelog '{}': {}. Make sure you are running \
|
||||
@@ -46,17 +50,60 @@ pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std:
|
||||
e
|
||||
)
|
||||
})?;
|
||||
parse_changelog_entry_from_str(&content)
|
||||
parse_changelog_entries_from_str(&content, limit)
|
||||
}
|
||||
|
||||
/// Parse the most recent changelog entry from its textual content. `origin`
|
||||
/// is used in error messages only.
|
||||
/// Parse the most recent entry of a Debian changelog file.
|
||||
pub fn parse_changelog_entry(path: &Path) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
|
||||
parse_changelog_entries(path, Some(1)).map(|mut entries| entries.remove(0))
|
||||
}
|
||||
|
||||
/// Parse the most recent changelog entry from its textual content.
|
||||
pub fn parse_changelog_entry_from_str(
|
||||
content: &str,
|
||||
) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
|
||||
parse_changelog_entries_from_str(content, Some(1)).map(|mut entries| entries.remove(0))
|
||||
}
|
||||
|
||||
/// Parse changelog entries from their textual content, newest first.
|
||||
///
|
||||
/// `limit` bounds the number of parsed entries (`None` parses the whole
|
||||
/// file). Content below the last entry that is not another entry header
|
||||
/// (e.g. an older changelog kept in a non-Debian format) is ignored.
|
||||
pub fn parse_changelog_entries_from_str(
|
||||
content: &str,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<ChangelogEntry>, Box<dyn std::error::Error>> {
|
||||
let origin = "changelog";
|
||||
let mut lines = content.lines().peekable();
|
||||
let mut entries = Vec::new();
|
||||
loop {
|
||||
if limit.is_some_and(|n| entries.len() >= n) {
|
||||
break;
|
||||
}
|
||||
// Blank separators between entries.
|
||||
while lines.peek().is_some_and(|l| l.trim().is_empty()) {
|
||||
lines.next();
|
||||
}
|
||||
let Some(next) = lines.peek() else {
|
||||
break;
|
||||
};
|
||||
if !entries.is_empty() && !looks_like_header(next.trim_end()) {
|
||||
break;
|
||||
}
|
||||
entries.push(parse_one_entry(&mut lines, origin)?);
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Parse one entry: header line, body, maintainer trailer. Parsing stops
|
||||
/// without consuming the first line that is a trailer terminator, an emacs
|
||||
/// local-variables block, or the next entry's header — the stream can then
|
||||
/// be resumed for the following entry.
|
||||
fn parse_one_entry(
|
||||
lines: &mut std::iter::Peekable<std::str::Lines<'_>>,
|
||||
origin: &str,
|
||||
) -> Result<ChangelogEntry, Box<dyn std::error::Error>> {
|
||||
// --- Header line: `package (version) distributions; urgency=medium[, key=value]`
|
||||
let header = loop {
|
||||
match lines.next() {
|
||||
@@ -104,19 +151,24 @@ pub fn parse_changelog_entry_from_str(
|
||||
// --- Body until trailer line ` -- Name <email> Date`
|
||||
let mut body_lines: Vec<String> = Vec::new();
|
||||
let mut trailer: Option<String> = None;
|
||||
for line in lines {
|
||||
loop {
|
||||
let Some(line) = lines.peek().copied() else {
|
||||
break;
|
||||
};
|
||||
let line = line.trim_end();
|
||||
if line.starts_with(" -- ") {
|
||||
trailer = Some(line.to_string());
|
||||
trailer = lines.next().map(|l| l.trim_end().to_string());
|
||||
break;
|
||||
}
|
||||
// Stop at an emacs local-variables block or a new entry header.
|
||||
// Stop at an emacs local-variables block or a new entry header
|
||||
// (both peeked, not consumed).
|
||||
if line.starts_with("Local variables:") {
|
||||
break;
|
||||
}
|
||||
if !line.trim().is_empty() && looks_like_header(line) && !body_lines.is_empty() {
|
||||
break;
|
||||
}
|
||||
lines.next();
|
||||
// Blank lines become "." like dpkg does for the Changes field.
|
||||
if line.trim().is_empty() {
|
||||
body_lines.push(".".to_string());
|
||||
@@ -211,39 +263,6 @@ fn find_closes(body_lines: &[String]) -> Option<String> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the version of the *previous* changelog entry (the second header
|
||||
/// in the file), or `None` when only one entry exists.
|
||||
pub fn parse_previous_version(path: &Path) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("failed to read changelog '{}': {}", path.display(), e))?;
|
||||
parse_previous_version_from_str(&content)
|
||||
}
|
||||
|
||||
/// Return the version of the *previous* changelog entry from the textual
|
||||
/// content of a changelog file.
|
||||
pub fn parse_previous_version_from_str(
|
||||
content: &str,
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
let mut seen_first = false;
|
||||
for line in content.lines() {
|
||||
let line = line.trim_end();
|
||||
if looks_like_header(line) {
|
||||
if !seen_first {
|
||||
seen_first = true;
|
||||
continue;
|
||||
}
|
||||
let open = line
|
||||
.find('(')
|
||||
.ok_or_else(|| format!("invalid changelog header: {line}"))?;
|
||||
let close = line[open..]
|
||||
.find(')')
|
||||
.ok_or_else(|| format!("unbalanced parenthesis in changelog header '{line}'"))?;
|
||||
return Ok(Some(line[open + 1..open + close].to_string()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Heuristic check for a changelog entry header line
|
||||
/// (`name (version) dist; urgency=...`).
|
||||
fn looks_like_header(line: &str) -> bool {
|
||||
@@ -309,4 +328,114 @@ pkg (1.0-1+b1) unstable; urgency=medium, binary-only=yes
|
||||
assert!(entry.binary_only);
|
||||
assert_eq!(entry.version.full(), "1.0-1+b1");
|
||||
}
|
||||
|
||||
const THREE_ENTRIES: &str = "\
|
||||
pkg (2.0-1) unstable; urgency=low
|
||||
|
||||
* New upstream release.
|
||||
|
||||
-- Pkh Tester <pkh@example.com> Thu, 01 Jan 2026 00:00:00 +0000
|
||||
|
||||
pkg (1.4-2) unstable; urgency=medium
|
||||
|
||||
* Revision bump.
|
||||
|
||||
-- Pkh Tester <pkh@example.com> Wed, 01 Jan 2025 00:00:00 +0000
|
||||
|
||||
pkg (1.4-1) unstable; urgency=medium
|
||||
|
||||
* Initial release.
|
||||
|
||||
-- Pkh Tester <pkh@example.com> Sat, 01 Mar 2025 00:00:00 +0000
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn entries_parse_newest_first_with_limits() {
|
||||
// Whole file.
|
||||
let all = parse_changelog_entries_from_str(THREE_ENTRIES, None).unwrap();
|
||||
assert_eq!(all.len(), 3);
|
||||
assert_eq!(all[0].version.full(), "2.0-1");
|
||||
assert_eq!(all[1].version.full(), "1.4-2");
|
||||
assert_eq!(all[2].version.full(), "1.4-1");
|
||||
|
||||
// Bounded limits.
|
||||
assert_eq!(
|
||||
parse_changelog_entries_from_str(THREE_ENTRIES, Some(1))
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let two = parse_changelog_entries_from_str(THREE_ENTRIES, Some(2)).unwrap();
|
||||
assert_eq!(two.len(), 2);
|
||||
assert_eq!(two[0].version.full(), "2.0-1");
|
||||
assert_eq!(two[1].version.full(), "1.4-2");
|
||||
|
||||
// A limit beyond the entry count yields everything.
|
||||
assert_eq!(
|
||||
parse_changelog_entries_from_str(THREE_ENTRIES, Some(10))
|
||||
.unwrap()
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
|
||||
// The single-entry helpers agree with a limit of 1.
|
||||
let one = parse_changelog_entries_from_str(THREE_ENTRIES, Some(1)).unwrap();
|
||||
let via_helper = parse_changelog_entry_from_str(THREE_ENTRIES).unwrap();
|
||||
assert_eq!(one[0].version.full(), via_helper.version.full());
|
||||
assert_eq!(one[0].source, via_helper.source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_ignore_trailing_foreign_content() {
|
||||
let content = "\
|
||||
pkg (1.0) unstable; urgency=medium
|
||||
|
||||
* Something.
|
||||
|
||||
-- Pkh Tester <pkh@example.com> Thu, 01 Jan 2026 00:00:00 +0000
|
||||
|
||||
older changelog kept in an ad-hoc format:
|
||||
version 0.9 - some text, not a Debian entry
|
||||
version 0.8 - more text
|
||||
";
|
||||
let entries = parse_changelog_entries_from_str(content, None).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].version.full(), "1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_parse_body_of_later_entries() {
|
||||
let entries = parse_changelog_entries_from_str(THREE_ENTRIES, Some(2)).unwrap();
|
||||
// The second entry's body and trailer are fully parsed, not merely
|
||||
// its header line.
|
||||
assert_eq!(
|
||||
entries[1].changes_field,
|
||||
"\npkg (1.4-2) unstable; urgency=medium\n.\n * Revision bump."
|
||||
);
|
||||
assert_eq!(entries[1].maintainer_email, "pkh@example.com");
|
||||
assert_eq!(entries[1].urgency, "medium");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_reject_malformed_later_entry() {
|
||||
let content = "\
|
||||
pkg (1.0) unstable; urgency=medium
|
||||
|
||||
* Something.
|
||||
|
||||
-- Pkh Tester <pkh@example.com> Thu, 01 Jan 2026 00:00:00 +0000
|
||||
|
||||
pkg (0.9) unstable; urgency=medium
|
||||
|
||||
* No trailer below.
|
||||
";
|
||||
assert!(parse_changelog_entries_from_str(content, None).is_err());
|
||||
// Not parsed when not requested.
|
||||
assert_eq!(
|
||||
parse_changelog_entries_from_str(content, Some(1))
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -20,8 +20,8 @@ pub mod files;
|
||||
pub mod version;
|
||||
|
||||
pub use changelog::{
|
||||
ChangelogEntry, parse_changelog_entry, parse_changelog_entry_from_str,
|
||||
parse_previous_version_from_str,
|
||||
ChangelogEntry, parse_changelog_entries, parse_changelog_entries_from_str,
|
||||
parse_changelog_entry, parse_changelog_entry_from_str,
|
||||
};
|
||||
pub use checksums::{ChecksumKind, Entry as ChecksumEntry, FileChecksums};
|
||||
pub use control::{
|
||||
|
||||
+18
-2
@@ -184,7 +184,10 @@ fn main() {
|
||||
.subcommand(
|
||||
Command::new("build")
|
||||
.about("Build the source package (into a .dsc)")
|
||||
.arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false)),
|
||||
.arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false))
|
||||
.arg(arg!(--orig <when> "Original source tarball in the upload [auto, always, never] (default: auto)").required(false)
|
||||
.long_help("Whether the upload distributes the original source tarball(s), like dpkg-genchanges' -sa/-si/-sd source styles.\nauto: include them only when the upstream version changed since the previous changelog entry (the default);\nalways: force inclusion, even for a revision bump of the same upstream version;\nnever: never include them, even for a new upstream version.\nAn explicit value is ignored with a warning for native packages (they have no separate orig tarball).")
|
||||
.value_parser(["auto", "always", "never"])),
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("put")
|
||||
@@ -495,7 +498,20 @@ fn main() {
|
||||
Some(std::sync::Arc::new(pkh::ui::deb::DebUi::new(&multi)))
|
||||
};
|
||||
|
||||
if let Err(e) = pkh::build::build_source_package(Some(&cwd), ui) {
|
||||
let orig_source = match sub_matches.get_one::<String>("orig").map(String::as_str) {
|
||||
Some("always") => pkh::build::OrigSourceMode::Always,
|
||||
Some("never") => pkh::build::OrigSourceMode::Never,
|
||||
_ => pkh::build::OrigSourceMode::Auto,
|
||||
};
|
||||
|
||||
if let Err(e) = pkh::build::build_source_package(
|
||||
Some(&cwd),
|
||||
pkh::build::SourceBuildOptions {
|
||||
orig_source,
|
||||
..Default::default()
|
||||
},
|
||||
ui,
|
||||
) {
|
||||
error!("{}", e);
|
||||
// Unmet build dependencies/conflicts exit with status 3,
|
||||
// like dpkg-buildpackage does.
|
||||
|
||||
@@ -598,7 +598,11 @@ pub async fn offer_verification(
|
||||
}
|
||||
|
||||
let ui = Some(std::sync::Arc::new(crate::ui::deb::DebUi::new(multi)));
|
||||
if let Err(e) = crate::build::build_source_package(Some(&tree), ui) {
|
||||
if let Err(e) = crate::build::build_source_package(
|
||||
Some(&tree),
|
||||
crate::build::SourceBuildOptions::default(),
|
||||
ui,
|
||||
) {
|
||||
log::error!("Verification source build failed: {e}");
|
||||
log::info!(
|
||||
"The scaffolded tree is intact. Inspect it, then retry with \
|
||||
|
||||
@@ -101,6 +101,9 @@ impl Template for Rust {
|
||||
/// sources (e.g. `-sys` crates shipping `config.sub`/`config.guess`)
|
||||
/// carry per-file cargo checksums, and debhelper refreshing those files
|
||||
/// with the system's newer copies would break `cargo build --offline`.
|
||||
/// `dh_clean` gets `-X Cargo.toml.orig` for the same reason: it treats
|
||||
/// every vendored `Cargo.toml.orig` as a patch backup and deletes it,
|
||||
/// which breaks the checksums on any build without a warm cache.
|
||||
fn rules_extra(&self, opts: &NewOptions) -> String {
|
||||
let locked = if lockfile_present(opts) {
|
||||
" --locked"
|
||||
@@ -125,6 +128,12 @@ impl Template for Rust {
|
||||
\n\
|
||||
override_dh_update_autotools_config:\n\
|
||||
\n\
|
||||
override_dh_clean:\n\
|
||||
\t# dh_clean unlinks `*.orig` patch backups, but vendored crates\n\
|
||||
\t# ship files like `Cargo.toml.orig` that cargo's per-file\n\
|
||||
\t# checksums require on cold builds (chroots, Launchpad).\n\
|
||||
\tdh_clean -X .orig\n\
|
||||
\n\
|
||||
override_dh_auto_clean:\n\
|
||||
\tcargo clean\n",
|
||||
locked = locked,
|
||||
@@ -552,6 +561,8 @@ mod tests {
|
||||
);
|
||||
assert!(extra.contains("override_dh_auto_test:\n\tcargo test --release --offline\n"));
|
||||
assert!(extra.contains("override_dh_auto_clean:\n\tcargo clean"));
|
||||
assert!(extra.contains("override_dh_clean:"));
|
||||
assert!(extra.contains("\tdh_clean -X .orig"));
|
||||
assert!(!extra.contains("--locked"));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user