You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/09/06 17:02:29 UTC
(opendal) 01/02: fix(dev): make source release archives reproducible
This is an automated email from the ASF dual-hosted git repository.
Xuanwo pushed a commit to branch xuanwo/reproducible-source-archives
in repository https://gitbox.apache.org/repos/asf/opendal.git
commit 840bf761292c1cc999dbe61631c5683a73574f8e
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Mon Sep 7 01:01:44 2026 +0800
fix(dev): make source release archives reproducible
---
.github/workflows/ci_odev.yml | 50 ++++++
dev/src/main.rs | 11 +-
dev/src/release/mod.rs | 191 +++++++++++++++++++++--
scripts/verify_source_reproducibility.py | 156 ++++++++++++++++++
website/community/release/release.md | 3 +
website/community/release/reproducible-source.md | 70 +++++++++
6 files changed, 462 insertions(+), 19 deletions(-)
diff --git a/.github/workflows/ci_odev.yml b/.github/workflows/ci_odev.yml
index b956ea01e0..d79f5d64c4 100644
--- a/.github/workflows/ci_odev.yml
+++ b/.github/workflows/ci_odev.yml
@@ -27,6 +27,9 @@ on:
paths:
- "**/*.rs"
- ".github/workflows/ci_odev.yml"
+ - "dev/Cargo.toml"
+ - "dev/Cargo.lock"
+ - "scripts/verify_source_reproducibility.py"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
@@ -92,3 +95,50 @@ jobs:
- name: Check diff
run: git diff --exit-code
+
+ reproduce:
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+ - uses: actions/setup-python@v6
+ with:
+ python-version: '3.12'
+ - uses: ./.github/actions/setup
+ - name: Rebuild source packages in independent checkouts
+ run: python3 scripts/verify_source_reproducibility.py --output "$RUNNER_TEMP/reproduction"
+ - uses: actions/upload-artifact@v7
+ if: always()
+ with:
+ name: source-reproducibility-${{ matrix.os }}
+ path: |
+ ${{ runner.temp }}/reproduction/report.json
+ ${{ runner.temp }}/reproduction/build-*.log
+ if-no-files-found: error
+
+ compare:
+ needs: reproduce
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/download-artifact@v8
+ with:
+ pattern: source-reproducibility-*
+ path: reproduction
+ - name: Compare Linux and macOS archive digests
+ run: |
+ python3 - <<'PYTHON'
+ import json
+ from pathlib import Path
+ reports = [json.loads(p.read_text()) for p in Path("reproduction").glob("*/report.json")]
+ assert len(reports) == 2, "missing platform report"
+ assert all(r["matched"] for r in reports), "checkout reproduction failed"
+ assert reports[0]["commit"] == reports[1]["commit"], "different source commits"
+ assert reports[0]["runs"][0] == reports[1]["runs"][0], "Linux and macOS artifacts differ"
+ print("All source archives match across Linux and macOS")
+ PYTHON
diff --git a/dev/src/main.rs b/dev/src/main.rs
index 7b65e4bb75..8459f19cf0 100644
--- a/dev/src/main.rs
+++ b/dev/src/main.rs
@@ -62,7 +62,13 @@ enum Commands {
/// Update the version of all packages.
UpdateVersion,
/// Create all the release artifacts.
- Release,
+ Release {
+ /// Build source archives without invoking GPG.
+ #[arg(long)]
+ unsigned: bool,
+ },
+ /// Print the source package inventory as JSON.
+ ReleasePackages,
}
fn main() -> anyhow::Result<()> {
@@ -71,6 +77,7 @@ fn main() -> anyhow::Result<()> {
match Cmd::parse().command {
Commands::Generate { language } => generate::run(&language),
Commands::UpdateVersion => release::update_version(),
- Commands::Release => release::archive_package(),
+ Commands::Release { unsigned } => release::archive_package(!unsigned),
+ Commands::ReleasePackages => release::print_packages(),
}
}
diff --git a/dev/src/release/mod.rs b/dev/src/release/mod.rs
index 04463ad240..c07e191c32 100644
--- a/dev/src/release/mod.rs
+++ b/dev/src/release/mod.rs
@@ -21,6 +21,7 @@ use flate2::write::GzEncoder;
use sha2::{Digest, Sha512};
use std::io::BufReader;
use std::io::Read;
+use std::path::Path;
mod bump;
mod package;
@@ -39,7 +40,23 @@ pub fn update_version() -> anyhow::Result<()> {
Ok(())
}
-pub fn archive_package() -> anyhow::Result<()> {
+pub fn print_packages() -> anyhow::Result<()> {
+ let packages = package::all_packages();
+ let inventory = packages
+ .iter()
+ .map(|p| {
+ serde_json::json!({
+ "path": p.name(), "version": p.version().to_string(),
+ "dependencies": p.dependencies().iter().map(|d| d.name()).collect::<Vec<_>>(),
+ "public_dependencies": p.public_compat_dependencies(),
+ })
+ })
+ .collect::<Vec<_>>();
+ println!("{}", serde_json::to_string_pretty(&inventory)?);
+ Ok(())
+}
+
+pub fn archive_package(sign: bool) -> anyhow::Result<()> {
std::fs::create_dir_all(workspace_dir().join("dist"))?;
let workspace_dir = workspace_dir();
@@ -48,16 +65,20 @@ pub fn archive_package() -> anyhow::Result<()> {
let packages = package::all_packages();
for package in packages {
let mut cmd = find_command("git", &workspace_dir);
- cmd.args(["ls-files", "LICENSE", "NOTICE"]);
+ cmd.args(["ls-files", "--stage", "-z", "--", "LICENSE", "NOTICE"]);
cmd.arg(package.name());
for dep in package.dependencies() {
cmd.arg(dep.name());
}
- let output = cmd.output().expect("failed to execute git ls-files");
- let output = String::from_utf8_lossy(&output.stdout);
- let files = output.lines().collect::<Vec<_>>();
+ let output = cmd.output()?;
+ anyhow::ensure!(output.status.success(), "git ls-files failed");
+ let files = archive_entries(&output.stdout)?;
archive_and_checksum(&package, &files)?;
+ if !sign {
+ continue;
+ }
+
let filename = format!("{}.tar.gz", package.make_prefix());
println!("Generate signature for package: {}", package.name());
@@ -70,7 +91,7 @@ pub fn archive_package() -> anyhow::Result<()> {
"--detach-sign",
filename.as_str(),
]);
- cmd.output().expect("failed to sign the package");
+ anyhow::ensure!(cmd.status()?.success(), "failed to sign {filename}");
println!("Check signature for package: {}", package.name());
let mut cmd = find_command("gpg", &dist_dir);
@@ -79,28 +100,20 @@ pub fn archive_package() -> anyhow::Result<()> {
format!("{filename}.asc").as_str(),
filename.as_str(),
]);
- cmd.output()
- .expect("failed to verify the package signature");
+ anyhow::ensure!(cmd.status()?.success(), "failed to verify {filename}");
}
Ok(())
}
-fn archive_and_checksum(package: &package::Package, files: &[&str]) -> anyhow::Result<()> {
+fn archive_and_checksum(package: &package::Package, files: &[ArchiveEntry]) -> anyhow::Result<()> {
println!("Archiving package: {}", package.name());
let prefix = package.make_prefix();
let filename = format!("{prefix}.tar.gz");
let tarball = workspace_dir().join("dist").join(&filename);
- {
- let tarball = std::fs::File::create(&tarball)?;
- let encoder = GzEncoder::new(tarball, Compression::default());
- let mut tar = tar::Builder::new(encoder);
- for file in files {
- tar.append_path_with_name(workspace_dir().join(file), format!("{prefix}/{file}"))?;
- }
- }
+ write_archive(&workspace_dir(), &tarball, &prefix, files)?;
{
let tarball = std::fs::File::open(&tarball)?;
@@ -126,6 +139,150 @@ fn archive_and_checksum(package: &package::Package, files: &[&str]) -> anyhow::R
Ok(())
}
+struct ArchiveEntry {
+ path: String,
+ mode: u32,
+}
+
+fn archive_entries(index: &[u8]) -> anyhow::Result<Vec<ArchiveEntry>> {
+ let mut entries = Vec::new();
+ for entry in index
+ .split(|byte| *byte == 0)
+ .filter(|entry| !entry.is_empty())
+ {
+ let entry = std::str::from_utf8(entry)?;
+ let (metadata, path) = entry
+ .split_once('\t')
+ .ok_or_else(|| anyhow::anyhow!("invalid Git index entry"))?;
+ let fields = metadata.split_whitespace().collect::<Vec<_>>();
+ anyhow::ensure!(
+ fields.len() == 3 && fields[2] == "0",
+ "unmerged Git index entry: {path}"
+ );
+ let mode = u32::from_str_radix(fields[0], 8)?;
+ anyhow::ensure!(
+ matches!(mode, 0o100644 | 0o100755 | 0o120000),
+ "unsupported Git mode for {path}"
+ );
+ entries.push(ArchiveEntry {
+ path: path.to_owned(),
+ mode,
+ });
+ }
+ entries.sort_by(|a, b| a.path.cmp(&b.path));
+ Ok(entries)
+}
+
+fn write_archive(
+ root: &Path,
+ output: &Path,
+ prefix: &str,
+ files: &[ArchiveEntry],
+) -> anyhow::Result<()> {
+ let encoder = GzEncoder::new(std::fs::File::create(output)?, Compression::default());
+ let mut tar = tar::Builder::new(encoder);
+ for entry in files {
+ // Git owns executable bits; checkout timestamps, owners and umask must not
+ // affect the release bytes. Gzip's default header has no timestamp/name.
+ let mut header = tar::Header::new_gnu();
+ header.set_uid(0);
+ header.set_gid(0);
+ header.set_mtime(0);
+ let path = root.join(&entry.path);
+ let archive_path = format!("{prefix}/{}", entry.path);
+ if entry.mode == 0o120000 {
+ header.set_mode(0o777);
+ header.set_entry_type(tar::EntryType::Symlink);
+ header.set_size(0);
+ tar.append_link(&mut header, archive_path, std::fs::read_link(path)?)?;
+ } else {
+ header.set_mode(entry.mode & 0o777);
+ header.set_entry_type(tar::EntryType::Regular);
+ let file = std::fs::File::open(path)?;
+ header.set_size(file.metadata()?.len());
+ tar.append_data(&mut header, archive_path, file)?;
+ }
+ }
+ tar.into_inner()?.finish()?;
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs::FileTimes;
+ use std::time::{Duration, UNIX_EPOCH};
+
+ #[test]
+ fn archive_ignores_checkout_metadata() -> anyhow::Result<()> {
+ let root = std::env::temp_dir().join(format!("opendal-archive-{}", std::process::id()));
+ std::fs::create_dir_all(&root)?;
+ let file = root.join("script");
+ std::fs::write(&file, b"#!/bin/sh\necho reproducible\n")?;
+ let entries = archive_entries(b"100755 abc 0\tscript\0")?;
+ for (name, seconds) in [("a.tar.gz", 1_700_000_000), ("b.tar.gz", 1_800_000_000)] {
+ std::fs::File::open(&file)?.set_times(
+ FileTimes::new().set_modified(UNIX_EPOCH + Duration::from_secs(seconds)),
+ )?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(
+ &file,
+ std::fs::Permissions::from_mode(if name.starts_with('a') {
+ 0o600
+ } else {
+ 0o755
+ }),
+ )?;
+ }
+ write_archive(&root, &root.join(name), "source", &entries)?;
+ }
+ assert_eq!(
+ std::fs::read(root.join("a.tar.gz"))?,
+ std::fs::read(root.join("b.tar.gz"))?
+ );
+ let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(std::fs::File::open(
+ root.join("a.tar.gz"),
+ )?));
+ let entry = archive.entries()?.next().unwrap()?;
+ assert_eq!(entry.header().mode()?, 0o755);
+ assert_eq!(entry.header().mtime()?, 0);
+ assert_eq!(entry.header().uid()?, 0);
+ assert_eq!(entry.header().gid()?, 0);
+ std::fs::remove_dir_all(root)?;
+ Ok(())
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn archive_preserves_symlinks_without_following_them() -> anyhow::Result<()> {
+ let root = std::env::temp_dir().join(format!("opendal-symlink-{}", std::process::id()));
+ std::fs::create_dir_all(&root)?;
+ std::os::unix::fs::symlink("missing-target", root.join("link"))?;
+ let entries = archive_entries(b"120000 abc 0\tlink\0")?;
+ write_archive(&root, &root.join("source.tar.gz"), "source", &entries)?;
+ let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(std::fs::File::open(
+ root.join("source.tar.gz"),
+ )?));
+ let entry = archive.entries()?.next().unwrap()?;
+ assert!(entry.header().entry_type().is_symlink());
+ assert_eq!(entry.link_name()?.unwrap(), Path::new("missing-target"));
+ assert_eq!(entry.size(), 0);
+ std::fs::remove_dir_all(root)?;
+ Ok(())
+ }
+
+ #[test]
+ fn index_paths_are_sorted_and_conflicts_rejected() -> anyhow::Result<()> {
+ let entries = archive_entries(b"100644 a 0\tz\x00100644 b 0\ta\tname\0")?;
+ assert_eq!(entries[0].path, "a\tname");
+ assert!(archive_entries(b"100644 a 1\tconflict\0").is_err());
+ assert!(archive_entries(b"160000 a 0\tsubmodule\0").is_err());
+ Ok(())
+ }
+}
+
fn format_digest_hex(digest: impl AsRef<[u8]>) -> String {
use std::fmt::Write;
diff --git a/scripts/verify_source_reproducibility.py b/scripts/verify_source_reproducibility.py
new file mode 100644
index 0000000000..e73cc94da3
--- /dev/null
+++ b/scripts/verify_source_reproducibility.py
@@ -0,0 +1,156 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Rebuild every source archive in two checkouts with different filesystem metadata."""
+
+import argparse
+import hashlib
+import json
+import os
+import platform
+import shutil
+import subprocess
+import tempfile
+from pathlib import Path
+
+
+def run(*args, **kwargs):
+ return subprocess.run([str(a) for a in args], check=True, **kwargs)
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--revision", default="HEAD")
+ parser.add_argument(
+ "--working-tree",
+ action="store_true",
+ help="Include tracked local changes in both checkouts",
+ )
+ parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument(
+ "--compare", type=Path, help="Also compare with downloaded CI tar.gz artifacts"
+ )
+ args = parser.parse_args()
+ repo = Path(__file__).resolve().parents[1]
+ sha = subprocess.check_output(
+ ["git", "rev-parse", args.revision], cwd=repo, text=True
+ ).strip()
+ patch = (
+ subprocess.check_output(["git", "diff", "--binary", sha], cwd=repo)
+ if args.working_tree
+ else b""
+ )
+ args.output.mkdir(parents=True, exist_ok=False)
+ (args.output / "input.patch").write_bytes(patch)
+ report = {
+ "platform": platform.platform(),
+ "rustc": subprocess.check_output(["rustc", "-Vv"], text=True),
+ "commit": sha,
+ "patch_sha512": hashlib.sha512(patch).hexdigest(),
+ "runs": [],
+ "matched": False,
+ }
+ try:
+ with tempfile.TemporaryDirectory(prefix="opendal-reproduce-") as tmp:
+ target = Path(tmp) / "target"
+ for index in range(2):
+ checkout = Path(tmp) / f"checkout-{index}"
+ run(
+ "git",
+ "clone",
+ "--shared",
+ "--no-checkout",
+ "-c",
+ "core.autocrlf=false",
+ repo,
+ checkout,
+ )
+ run("git", "checkout", "--detach", sha, cwd=checkout)
+ if patch:
+ run("git", "apply", "--binary", "-", cwd=checkout, input=patch)
+ tracked = subprocess.check_output(
+ ["git", "ls-files", "-z"], cwd=checkout
+ ).split(b"\0")
+ for raw in filter(None, tracked):
+ file = checkout / os.fsdecode(raw)
+ if file.is_symlink() or not file.is_file():
+ continue
+ os.utime(file, (1_700_000_000 + index * 100_000_000,) * 2)
+ if os.name == "posix":
+ os.chmod(file, 0o644 if index == 0 else 0o755)
+ env = {
+ **os.environ,
+ "CARGO_TARGET_DIR": str(target),
+ "TZ": "UTC" if index == 0 else "Asia/Shanghai",
+ }
+ log_path = args.output / f"build-{index}.log"
+ with log_path.open("wb") as log:
+ run(
+ "cargo",
+ "run",
+ "--locked",
+ "--manifest-path",
+ checkout / "dev/Cargo.toml",
+ "--",
+ "release",
+ "--unsigned",
+ cwd=checkout,
+ env=env,
+ stdout=log,
+ stderr=subprocess.STDOUT,
+ )
+ inventory = json.loads(
+ subprocess.check_output(
+ [str(target / "debug/odev"), "release-packages"], cwd=checkout
+ )
+ )
+ archives = sorted((checkout / "dist").glob("*.tar.gz"))
+ if len(archives) != len(inventory) or not archives:
+ raise ValueError("source archive inventory is incomplete")
+ hashes = {
+ file.name: hashlib.sha512(file.read_bytes()).hexdigest()
+ for file in archives
+ }
+ report["runs"].append(hashes)
+ if index == 0:
+ for file in archives:
+ shutil.copy2(file, args.output / file.name)
+ shutil.copy2(
+ Path(str(file) + ".sha512"),
+ args.output / (file.name + ".sha512"),
+ )
+ if index and hashes != report["runs"][0]:
+ raise ValueError("independent checkout artifacts differ")
+ if args.compare:
+ expected = {
+ file.name: hashlib.sha512(file.read_bytes()).hexdigest()
+ for file in args.compare.glob("*.tar.gz")
+ }
+ if expected != report["runs"][0]:
+ raise ValueError(
+ "downloaded CI artifacts differ or inventory does not match"
+ )
+ report["matched"] = True
+ finally:
+ (args.output / "report.json").write_text(json.dumps(report, indent=2) + "\n")
+ print(
+ f"Verified {len(report['runs'][0])} identical source archives. Evidence: {args.output}"
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/website/community/release/release.md b/website/community/release/release.md
index ca9720667f..0633a16fa0 100644
--- a/website/community/release/release.md
+++ b/website/community/release/release.md
@@ -6,6 +6,9 @@ sidebar_position: 3
This document mainly introduces how the release manager
releases a new version of Apache OpenDALâ„¢ in accordance with the Apache requirements.
+See [Reproducible source archives](./reproducible-source.md) for rebuilding and
+comparing the source packages before signing.
+
## Introduction
`Source Release` is the key point which Apache values, and is also necessary for an ASF release.
diff --git a/website/community/release/reproducible-source.md b/website/community/release/reproducible-source.md
new file mode 100644
index 0000000000..7944fd2ac0
--- /dev/null
+++ b/website/community/release/reproducible-source.md
@@ -0,0 +1,70 @@
+---
+title: Reproducible source archives
+sidebar_position: 5
+---
+
+# Reproducible source archives
+
+OpenDAL source release archives must reproduce byte for byte from the same Git
+revision and locked packaging dependencies. This contract covers the `.tar.gz`
+files generated by `odev release`, including every binding and integration source
+package. It does not claim reproducibility for Maven artifacts, wheels, native
+binaries, or OpenPGP signature files.
+
+## Archive inputs
+
+Build from a clean checkout of the exact candidate commit, with Git line-ending
+conversion disabled (`core.autocrlf=false`) and native symlink support. Run the
+packager with `cargo run --locked --manifest-path dev/Cargo.toml -- release --unsigned`.
+The packager reads tracked working-tree contents and the Git index's file modes;
+local source edits therefore change the release bytes. It does not regenerate
+source files as part of packaging.
+
+The source package inventory comes from `dev/src/release/package.rs`. Each archive
+includes the package, its declared source dependencies, and the root LICENSE and
+NOTICE files. Paths are sorted. Tar entries use timestamp zero, numeric owner and
+group zero, empty owner/group names, and Git's regular/executable file modes.
+Symlinks retain their link target and are not followed. Gzip includes neither a
+filename nor a timestamp. The checked-in `dev/Cargo.lock` fixes the compression
+implementation. Changing those dependencies can change archive bytes and requires
+repeating the reproduction checks.
+
+## Reproduce the complete package set
+
+From a trusted checkout containing the verification script:
+
+```shell
+python3 scripts/verify_source_reproducibility.py \
+ --revision <candidate-commit> \
+ --output /tmp/opendal-reproduction
+```
+
+The output directory must not already exist. The script creates two independent
+checkouts, changes their file timestamps, permissions and process timezones, and
+builds every source archive in both. It records the commit, packaging patch digest,
+host and compiler information, all archive hashes, and build logs. A mismatch or
+missing package fails the command. The first set of archives is retained alongside
+the report. Temporary checkouts are removed automatically.
+
+For local packaging-tool development, `--working-tree` applies tracked changes to
+both checkouts and retains the exact input patch in the output. Such a run is
+useful regression evidence, but release verification must use the committed
+candidate without this option.
+
+To validate a CI candidate on trusted hardware, download the complete voted-on
+`.tar.gz` set to a directory and add:
+
+```shell
+python3 scripts/verify_source_reproducibility.py \
+ --revision <candidate-commit> \
+ --compare /path/to/downloaded-candidate \
+ --output /tmp/opendal-candidate-reproduction
+```
+
+This compares the full filename set and every archive's SHA-512 against the local
+rebuilds. Verify the downloaded signatures separately and include both results in
+the release review. Reproducibility does not replace license review or testing.
+
+odev CI performs the same check on Linux and macOS and compares
+their manifests. A CI-to-CI comparison is regression coverage; it does not replace
+the required independent reproduction on trusted hardware before publication.