From 5556985b8e6872e6b1eeaaa3481c63e796127eb7 Mon Sep 17 00:00:00 2001 From: Sidney Sissaoui Date: Fri, 24 Jul 2026 21:06:36 +0200 Subject: [PATCH] fix(release): publish APT indexes by hash --- .../release-linux-repository-build.yml | 16 ++- scripts/generate-apt-repository.sh | 7 + .../release/downstream_workflow_contract.py | 4 + tests/release_linux_repository_spec.rs | 124 ++++++++++++++++++ 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 tests/release_linux_repository_spec.rs diff --git a/.github/workflows/release-linux-repository-build.yml b/.github/workflows/release-linux-repository-build.yml index 34cbb30e7..1bf5b6efb 100644 --- a/.github/workflows/release-linux-repository-build.yml +++ b/.github/workflows/release-linux-repository-build.yml @@ -100,6 +100,16 @@ jobs: --expected-event workflow_dispatch --expected-head-branch "$RMUX_RELEASE_REF" \ "${mode[@]}" --max-attempts 1 + - name: Preserve protected recovery metadata generator + shell: bash + run: | + set -euo pipefail + generator="$RUNNER_TEMP/rmux-recovery-generate-apt-repository.sh" + rm -f "$generator" + if test "$GITHUB_RUN_ID" != "$RMUX_RECEIPT_RUN_ID"; then + install -m 0755 scripts/generate-apt-repository.sh "$generator" + fi + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: ref: ${{ inputs.expected_source_sha }} @@ -157,6 +167,10 @@ jobs: set -euo pipefail root="$RUNNER_TEMP/rmux-linux-repository" version="${RMUX_RELEASE_REF#v}" + apt_generator="scripts/generate-apt-repository.sh" + if test -x "$RUNNER_TEMP/rmux-recovery-generate-apt-repository.sh"; then + apt_generator="$RUNNER_TEMP/rmux-recovery-generate-apt-repository.sh" + fi git clone --depth 1 --branch main \ https://github.com/Helvesec/rmux-packages.git "$root/history" base="$(git -C "$root/history" rev-parse HEAD)" @@ -171,7 +185,7 @@ jobs: --rpm-signing-key "$RMUX_RPM_GPG_KEY" --current-version "$version" \ --apt-architecture amd64 --apt-architecture arm64 \ --rpm-architecture x86_64 --rpm-architecture aarch64 - scripts/generate-apt-repository.sh \ + "$apt_generator" \ --input-dir "$root/packages" --output-dir "$root/output/debian" \ --suite stable --component main --architecture amd64 --architecture arm64 \ --signing-key "$RMUX_APT_GPG_KEY" diff --git a/scripts/generate-apt-repository.sh b/scripts/generate-apt-repository.sh index 4ff34db8c..5e08cf27d 100755 --- a/scripts/generate-apt-repository.sh +++ b/scripts/generate-apt-repository.sh @@ -168,6 +168,12 @@ for architecture in "${architectures[@]}"; do } >> "$packages" done gzip -n -c "$packages" > "$packages.gz" + by_hash_dir="$binary_dir/by-hash/SHA256" + mkdir -p "$by_hash_dir" + for index in "$packages" "$packages.gz"; do + index_hash="$(hash_file sha256 "$index")" + cp "$index" "$by_hash_dir/$index_hash" + done release_files+=("$packages" "$packages.gz") done @@ -182,6 +188,7 @@ Date: $date_utc Architectures: ${architectures[*]} Components: $component Description: RMUX APT repository +Acquire-By-Hash: yes MD5Sum: $(release_hash_block md5 "$output_dir/dists/$suite" "${release_files[@]}") SHA256: diff --git a/scripts/release/downstream_workflow_contract.py b/scripts/release/downstream_workflow_contract.py index 34601a787..45a70bff0 100644 --- a/scripts/release/downstream_workflow_contract.py +++ b/scripts/release/downstream_workflow_contract.py @@ -93,6 +93,10 @@ def _validate_reusable_workflow(path: Path, *, require_repository_guard: bool) - raise ValueError(f"Linux repository recovery lost run mode {mode}") if text.count('test "$GITHUB_REF" = "refs/heads/main"') != 1: raise ValueError("Linux repository recovery is not bound to protected main") + if text.count("rmux-recovery-generate-apt-repository.sh") != 3: + raise ValueError( + "Linux repository recovery lost its protected APT generator" + ) elif "\n workflow_dispatch:" in text: raise ValueError(f"{path.name} gained a mutation-capable dispatch trigger") if "runs-on: self-hosted" in text or "\n - self-hosted" in text: diff --git a/tests/release_linux_repository_spec.rs b/tests/release_linux_repository_spec.rs new file mode 100644 index 000000000..76ce2fc75 --- /dev/null +++ b/tests/release_linux_repository_spec.rs @@ -0,0 +1,124 @@ +#![cfg(unix)] + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn repo_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn temp_dir(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("rmux-{label}-{}-{nonce}", std::process::id())) +} + +#[cfg(unix)] +fn make_executable(path: &Path) { + use std::os::unix::fs::PermissionsExt; + + let mut permissions = fs::metadata(path) + .expect("read tool metadata") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions).expect("make tool executable"); +} + +fn sha256(path: &Path) -> String { + let output = Command::new("sha256sum") + .arg(path) + .output() + .expect("run sha256sum"); + assert!(output.status.success()); + String::from_utf8(output.stdout) + .expect("sha256sum output is UTF-8") + .split_whitespace() + .next() + .expect("sha256sum emitted a digest") + .to_owned() +} + +#[test] +#[cfg(unix)] +fn apt_repository_publishes_sha256_by_hash_indexes() { + let root = temp_dir("apt-by-hash"); + let input = root.join("input"); + let output = root.join("output"); + let tools = root.join("tools"); + fs::create_dir_all(&input).expect("create input"); + fs::create_dir_all(&tools).expect("create tools"); + fs::write(input.join("rmux_0.9.1_amd64.deb"), b"amd64 package").expect("write amd64 package"); + fs::write(input.join("rmux_0.9.1_arm64.deb"), b"arm64 package").expect("write arm64 package"); + + let dpkg_deb = tools.join("dpkg-deb"); + fs::write( + &dpkg_deb, + r#"#!/bin/sh +set -eu +test "$1" = -f +case "$2" in + *_amd64.deb) architecture=amd64 ;; + *_arm64.deb) architecture=arm64 ;; + *) exit 64 ;; +esac +printf 'Package: rmux\nVersion: 0.9.1\nArchitecture: %s\n' "$architecture" +"#, + ) + .expect("write dpkg-deb fixture"); + make_executable(&dpkg_deb); + + let path = std::env::join_paths(std::iter::once(tools.clone()).chain(std::env::split_paths( + &std::env::var_os("PATH").expect("PATH is defined"), + ))) + .expect("compose PATH"); + let result = Command::new(repo_root().join("scripts/generate-apt-repository.sh")) + .args(["--input-dir"]) + .arg(&input) + .args(["--output-dir"]) + .arg(&output) + .args([ + "--suite", + "stable", + "--component", + "main", + "--architecture", + "amd64", + "--architecture", + "arm64", + ]) + .env("PATH", path) + .current_dir(repo_root()) + .output() + .expect("generate APT repository"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + + let suite = output.join("dists/stable"); + let release = fs::read_to_string(suite.join("Release")).expect("read Release"); + assert!(release.contains("\nAcquire-By-Hash: yes\n")); + for architecture in ["amd64", "arm64"] { + let binary = suite.join(format!("main/binary-{architecture}")); + for name in ["Packages", "Packages.gz"] { + let index = binary.join(name); + let digest = sha256(&index); + let by_hash = binary.join("by-hash/SHA256").join(&digest); + assert_eq!( + fs::read(&by_hash).expect("read by-hash index"), + fs::read(&index).expect("read canonical index") + ); + assert!( + release.contains(&format!(" main/binary-{architecture}/{name}\n")), + "Release does not bind {architecture}/{name}" + ); + } + } + + fs::remove_dir_all(root).expect("remove fixture"); +}