From a6ac89d02672c127f1fe004167db0002c126865b Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:27:26 +0000 Subject: [PATCH 1/2] feat(build): embed dependency data in release binaries with cargo-auditable Build every standalone release binary and the container image through cargo-auditable, so the resolved crate list is embedded in the shipped executable rather than inferred by syft from the build tree. - ci/tasks/auditable pins v0.7.7 and its tarball SHA-256, installs it once per task, and provides the wrapper environment the cross builders (cargo zigbuild / cargo xwin) need. - the Linux, arm64 and Windows build tasks build through it and fail if the resulting binary has no .dep-v0 section. - the Dockerfile installs the same pinned tool and asserts the section. - release.yml and promote-release.yml re-check both shipped Linux binaries before SBOM generation, so a regression cannot quietly produce a non-dependency-accurate SBOM. --- .github/workflows/promote-release.yml | 14 ++++ .github/workflows/release.yml | 33 +++++++++ Dockerfile | 20 ++++++ ci/tasks/auditable | 100 ++++++++++++++++++++++++++ ci/tasks/build-linux | 7 +- ci/tasks/build-linux-arm64 | 6 +- ci/tasks/build-windows | 6 +- 7 files changed, 182 insertions(+), 4 deletions(-) create mode 100755 ci/tasks/auditable diff --git a/.github/workflows/promote-release.yml b/.github/workflows/promote-release.yml index 27af3db0..6585293e 100644 --- a/.github/workflows/promote-release.yml +++ b/.github/workflows/promote-release.yml @@ -142,6 +142,20 @@ jobs: mkdir -p .ci-output/packages docker cp "$container:/workspace/.ci-output/packages/." .ci-output/packages/ (cd .ci-output/packages && sha256sum -c "SHA256SUMS-${STABLE}.txt") + # Stable binaries are rebuilt by the same tasks as a tag release, so they + # must carry cargo-auditable's `.dep-v0` section too. Without it the SBOMs + # generated from these files fall back to detection heuristics. + - name: Verify the audit section reached the shipped binaries + run: | + set -eu + work=$(mktemp -d) + trap 'rm -rf "$work"' EXIT + for arch in x86_64 aarch64; do + tar -xzf ".ci-output/packages/rustnzb-${STABLE}-linux-${arch}.tar.gz" \ + -C "$work" "rustnzb-${STABLE}/rustnzb" + objdump -h "$work/rustnzb-${STABLE}/rustnzb" | grep -q '\.dep-v0' + printf 'audit: linux-%s carries .dep-v0\n' "$arch" + done - uses: actions/upload-artifact@v4 with: name: rustnzb-${{ needs.resolve.outputs.stable_tag }}-release-assets diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8cf6a825..ac819c90 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -218,6 +218,19 @@ jobs: mkdir -p .ci-output/packages docker cp "$container:/workspace/.ci-output/packages/." .ci-output/packages/ (cd .ci-output/packages && sha256sum -c "SHA256SUMS-${GITHUB_REF_NAME}.txt") + # The shipped binaries must carry cargo-auditable's `.dep-v0` section, or + # syft falls back to guessing and the SBOM is not dependency-accurate. + - name: Verify the audit section reached the shipped binaries + run: | + set -eu + work=$(mktemp -d) + trap 'rm -rf "$work"' EXIT + for arch in x86_64 aarch64; do + tar -xzf ".ci-output/packages/rustnzb-${GITHUB_REF_NAME}-linux-${arch}.tar.gz" \ + -C "$work" "rustnzb-${GITHUB_REF_NAME}/rustnzb" + objdump -h "$work/rustnzb-${GITHUB_REF_NAME}/rustnzb" | grep -q '\.dep-v0' + printf 'audit: linux-%s carries .dep-v0\n' "$arch" + done - uses: sigstore/cosign-installer@v3.7.0 with: cosign-release: v2.4.1 @@ -240,6 +253,26 @@ jobs: case "$file" in *.spdx.json) continue ;; esac syft "$file" -o "spdx-json=${file}.spdx.json" done + # Package-manager manifests, rendered from the release's own checksum file + # that was produced above. This is a dry run: the files are uploaded as a + # workflow artefact for the operator to review and submit. Nothing is + # published to Homebrew, Scoop or winget from CI. + - name: Render Scoop and winget manifests (dry run, upload only) + run: | + set -eu + mkdir -p .ci-output/manifests + packaging/distribution/render-windows-manifests.sh \ + "${GITHUB_REF_NAME}" \ + --sums ".ci-output/packages/SHA256SUMS-${GITHUB_REF_NAME}.txt" \ + --assets-dir .ci-output/packages \ + --release-date "$(date -u +%Y-%m-%d)" \ + --out .ci-output/manifests + - uses: actions/upload-artifact@v4 + with: + name: rustnzb-${{ github.ref_name }}-distribution-manifests + path: .ci-output/manifests/packaging/** + if-no-files-found: error + retention-days: 30 # Build provenance (who built what, from which commit and workflow) for # every binary package, plus the signed checksum file. - name: Attest build provenance for the release artefacts diff --git a/Dockerfile b/Dockerfile index ea5c7934..adbdc26e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,8 @@ ARG CROSS_IMAGE=repo.indexarr.net/indexarr/rustnzb-ci-cross@sha256:d747454fca94ebfd9dba1f566d9f549c4c9fbedf785c6aba36a0c383d9b9a955 ARG UNRAR_VERSION=7.2.3 ARG UNRAR_SHA256=3995af0aa32b1505a566da053725551a1f0698dc42b2fdf7ba7d65db0d004e33 +ARG CARGO_AUDITABLE_VERSION=v0.7.7 +ARG CARGO_AUDITABLE_SHA256=4c8d3b08c3c34e9f2333367224dbbfb4aab68067698b43de59fe19aded3416dd FROM alpine:3.23 AS unrar-builder @@ -22,6 +24,8 @@ FROM --platform=$BUILDPLATFORM ${CROSS_IMAGE} AS builder ARG TARGETPLATFORM ARG RUSTNZB_BUILD_REF=local ARG RELEASE_OPTIMIZED=false +ARG CARGO_AUDITABLE_VERSION +ARG CARGO_AUDITABLE_SHA256 WORKDIR /build @@ -41,6 +45,19 @@ COPY Cargo.toml Cargo.lock ./ COPY apps apps COPY crates crates +# cargo-auditable embeds the exact crate versions in a `.dep-v0` section, so +# `syft` reads the dependencies from the shipped binary instead of guessing +# from the build tree. Pinned by version and tarball checksum. +RUN set -eu; \ + tarball=/tmp/cargo-auditable.tar.xz; \ + curl -fsSL -o "$tarball" \ + "https://github.com/rust-secure-code/cargo-auditable/releases/download/${CARGO_AUDITABLE_VERSION}/cargo-auditable-x86_64-unknown-linux-gnu.tar.xz"; \ + printf '%s %s\n' "${CARGO_AUDITABLE_SHA256}" "$tarball" | sha256sum -c -; \ + mkdir -p /usr/local/lib/cargo-auditable; \ + tar -xJf "$tarball" -C /usr/local/lib/cargo-auditable --strip-components=1; \ + rm -f "$tarball"; \ + test -x /usr/local/lib/cargo-auditable/cargo-auditable + # The build uses only public crates.io dependencies for the rustnzb binary. RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ --mount=type=cache,target=/usr/local/cargo/git,sharing=locked \ @@ -57,9 +74,12 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \ CARGO_PROFILE_RELEASE_STRIP=symbols; \ fi; \ CARGO_TARGET_DIR=/build/target \ + RUSTC_WORKSPACE_WRAPPER=/usr/local/lib/cargo-auditable/cargo-auditable \ + CARGO_AUDITABLE_ORIG_ARGS='{"offline":false,"locked":true,"frozen":false,"config":[]}' \ RUSTNZB_BUILD_REF="$RUSTNZB_BUILD_REF" \ cargo zigbuild --release --locked -p rustnzb \ --features webdav,vendored-openssl --target "$rust_target"; \ + objdump -h "/build/target/$rust_target/release/rustnzb" | grep -q '\.dep-v0'; \ mkdir -p /out; \ cp "/build/target/$rust_target/release/rustnzb" /out/rustnzb diff --git a/ci/tasks/auditable b/ci/tasks/auditable new file mode 100755 index 00000000..757b2603 --- /dev/null +++ b/ci/tasks/auditable @@ -0,0 +1,100 @@ +#!/bin/sh +# cargo-auditable install/verification, shared by the release build tasks. +# +# Every standalone release binary (Linux amd64/arm64, Windows) is built with +# rust-secure-code/cargo-auditable so its dependency list is embedded in a +# `.dep-v0` section (ELF) / `.dep-v0`-equivalent PE section. That is what lets +# `Syft`/`cargo audit bin`/`grype` read the exact crate versions from the shipped +# file instead of guessing from the build tree. +# +# The version and the Linux tarball checksum are pinned here, mirroring the +# SHA-256 style used for Zig and sccache in Dockerfile.ci. Bump both together. +# +# Usage: +# . ci/tasks/auditable +# install_cargo_auditable # puts cargo-auditable on PATH +# auditable_cargo build --release # `cargo` with audit data injected +# verify_auditable_binary PATH TRIPLE +set -eu + +CARGO_AUDITABLE_VERSION=v0.7.7 +CARGO_AUDITABLE_TARBALL=cargo-auditable-x86_64-unknown-linux-gnu.tar.xz +CARGO_AUDITABLE_SHA256=4c8d3b08c3c34e9f2333367224dbbfb4aab68067698b43de59fe19aded3416dd +CARGO_AUDITABLE_URL="https://github.com/rust-secure-code/cargo-auditable/releases/download/${CARGO_AUDITABLE_VERSION}/${CARGO_AUDITABLE_TARBALL}" + +auditable_dir() { + printf '%s\n' "${CARGO_AUDITABLE_DIR:-$REPO_ROOT/.ci-output/cargo-auditable-${CARGO_AUDITABLE_VERSION}}" +} + +install_cargo_auditable() { + dir=$(auditable_dir) + if [ ! -x "$dir/cargo-auditable" ]; then + mkdir -p "$dir" + tarball="$dir/$CARGO_AUDITABLE_TARBALL" + curl -fsSL -o "$tarball" "$CARGO_AUDITABLE_URL" + printf '%s %s\n' "$CARGO_AUDITABLE_SHA256" "$tarball" | sha256sum -c - >/dev/null + tar -xJf "$tarball" -C "$dir" --strip-components=1 + rm -f "$tarball" + fi + test -x "$dir/cargo-auditable" + PATH="$dir:$PATH" + export PATH +} + +# `cargo auditable ` for the build tasks that invoke cargo themselves and +# `RUSTC_WORKSPACE_WRAPPER=... cargo ` for the cross builders +# (cargo zigbuild / cargo xwin) that construct their own cargo invocation. +# +# cargo-auditable embeds the dependency list by running a rustc wrapper on +# workspace members only (RUSTC_WORKSPACE_WRAPPER), and only when +# CARGO_AUDITABLE_ORIG_ARGS is present. Exported as a function so the build +# scripts read the same way whichever cargo front end they use. +auditable_cargo() { + dir=$(auditable_dir) + test -x "$dir/cargo-auditable" || { + printf 'auditable_cargo: run install_cargo_auditable first\n' >&2 + return 127 + } + PATH="$dir:$PATH" "$dir/cargo-auditable" auditable "$@" +} + +# The cross builders spawn cargo with a modified environment. Pass the wrapper +# explicitly and provide the flags `cargo auditable` would have recorded, so the +# wrapper can call `cargo metadata` with the same --locked/--offline options. +# +# The JSON is deliberately a plain string: cargo-auditable parses it as JSON, +# so the quotes and braces must survive the export unchanged. +auditable_env() { + dir=$(auditable_dir) + test -x "$dir/cargo-auditable" || { + printf 'auditable_env: run install_cargo_auditable first\n' >&2 + return 127 + } + # cargo-auditable parses this value as JSON, so its quotes and braces must + # stay literal. shellcheck's SC2089/SC2090 warnings describe exactly that + # (deliberate) quoting, hence the directive rather than a rewrite. + # shellcheck disable=SC2089,SC2090 + CARGO_AUDITABLE_ORIG_ARGS='{"offline":false,"locked":true,"frozen":false,"config":[]}' + RUSTC_WORKSPACE_WRAPPER="$dir/cargo-auditable" + # shellcheck disable=SC2090 + export CARGO_AUDITABLE_ORIG_ARGS + export RUSTC_WORKSPACE_WRAPPER +} + +# Fail the release build when the audit data did not make it into the binary. +# ELF and PE both expose it through the section table, so objdump is enough and +# no extra dependency is required in the cross image. +verify_auditable_binary() { + binary=$1 + triple=${2:-} + test -f "$binary" || { + printf 'verify_auditable_binary: %s is missing\n' "$binary" >&2 + return 1 + } + if objdump -h "$binary" 2>/dev/null | grep -q '\.dep-v0'; then + printf 'audit: %s carries .dep-v0 (%s)\n' "$binary" "${triple:-unknown target}" + return 0 + fi + printf 'verify_auditable_binary: %s has no .dep-v0 audit section\n' "$binary" >&2 + return 1 +} diff --git a/ci/tasks/build-linux b/ci/tasks/build-linux index 1625c6a1..f8b8537a 100755 --- a/ci/tasks/build-linux +++ b/ci/tasks/build-linux @@ -1,14 +1,17 @@ #!/bin/sh set -eu . "$(dirname "$0")/lib.sh" +. "$(dirname "$0")/auditable" trap cleanup_task_frontend EXIT -task_start build-linux cargo npm +task_start build-linux cargo npm curl xz task_target_dir build-linux ensure_frontend +install_cargo_auditable if [ -n "${CI_COMMIT_TAG:-}" ]; then export CARGO_PROFILE_RELEASE_LTO=fat CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 CARGO_PROFILE_RELEASE_STRIP=symbols fi -cargo build --release -p rustnzb --features webdav --locked +auditable_cargo build --release -p rustnzb --features webdav --locked test -x "$CARGO_TARGET_DIR/release/rustnzb" +verify_auditable_binary "$CARGO_TARGET_DIR/release/rustnzb" x86_64-unknown-linux-gnu ls -lh "$CARGO_TARGET_DIR/release/rustnzb" show_sccache_stats diff --git a/ci/tasks/build-linux-arm64 b/ci/tasks/build-linux-arm64 index 2af37784..039ca371 100755 --- a/ci/tasks/build-linux-arm64 +++ b/ci/tasks/build-linux-arm64 @@ -1,10 +1,13 @@ #!/bin/sh set -eu . "$(dirname "$0")/lib.sh" +. "$(dirname "$0")/auditable" trap cleanup_task_frontend EXIT -task_start build-linux-arm64 cargo zig +task_start build-linux-arm64 cargo zig curl xz task_target_dir build-linux-arm64 ensure_frontend +install_cargo_auditable +auditable_env if [ -n "${CI_COMMIT_TAG:-}" ]; then export CARGO_PROFILE_RELEASE_LTO=fat CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 CARGO_PROFILE_RELEASE_STRIP=symbols fi @@ -13,6 +16,7 @@ cargo zigbuild --release -p rustnzb \ --features webdav,vendored-openssl --locked binary=$CARGO_TARGET_DIR/aarch64-unknown-linux-gnu/release/rustnzb test -x "$binary" +verify_auditable_binary "$binary" aarch64-unknown-linux-gnu file "$binary" ls -lh "$binary" show_sccache_stats diff --git a/ci/tasks/build-windows b/ci/tasks/build-windows index 36a4255d..436efdc2 100755 --- a/ci/tasks/build-windows +++ b/ci/tasks/build-windows @@ -1,16 +1,20 @@ #!/bin/sh set -eu . "$(dirname "$0")/lib.sh" +. "$(dirname "$0")/auditable" trap cleanup_task_frontend EXIT -task_start build-windows cargo clang lld +task_start build-windows cargo clang lld curl xz task_target_dir build-windows ensure_frontend +install_cargo_auditable +auditable_env if [ -n "${CI_COMMIT_TAG:-}" ]; then export CARGO_PROFILE_RELEASE_LTO=fat CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 CARGO_PROFILE_RELEASE_STRIP=symbols fi XWIN_ACCEPT_LICENSE=1 cargo xwin build --release --target x86_64-pc-windows-msvc --features webdav --locked binary=$CARGO_TARGET_DIR/x86_64-pc-windows-msvc/release/rustnzb.exe test -f "$binary" +verify_auditable_binary "$binary" x86_64-pc-windows-msvc file "$binary" ls -lh "$binary" show_sccache_stats From 97cc9900d50e9fa9ecb4b8032a0d72ca5fb092b0 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:28:45 +0000 Subject: [PATCH 2/2] feat(packaging): add Homebrew, Scoop and winget manifests Add the remaining distribution channels beyond Unraid and AUR, with every value derived from the release workflow's own assets and checksums. Nothing is published: CI renders the Windows manifests and uploads them as a workflow artefact for the operator to review and submit. - packaging/homebrew/Formula/rustnzb.rb installs the prebuilt Linux tarballs (x86_64 default, aarch64 under on_arm); update-formula.sh downloads both assets and SHA256SUMS, verifies them, and rewrites version/URL/hashes. - packaging/scoop/bucket/rustnzb.json extracts the NSIS installer with Expand-7zipArchive instead of running it, so no registry entries, service or uninstaller are created. - packaging/winget/.../1.5.0 declares InstallerType nullsoft, machine scope, elevatesSelf, the rustnzb command and the .nzb file extension. - packaging/distribution/render-windows-manifests.sh renders both Windows channels from one SHA256SUMS file and writes nothing on a bad or missing hash; release.yml runs it as a dry run against the assets it just built. - cargo-auditable builds are documented in RELEASING.md, with the cosign mirror question recorded as an open operator decision. The Windows installer is not Authenticode-signed, so SmartScreen warns on first run; winget accepts that, and the docs say so. A macOS formula needs a macOS artefact, which the release does not produce yet. --- CHANGELOG.md | 2 + docs/RELEASING.md | 40 ++++ packaging/README.md | 115 ++++++++- .../distribution/render-windows-manifests.sh | 223 ++++++++++++++++++ packaging/homebrew/Formula/rustnzb.rb | 60 +++++ packaging/homebrew/update-formula.sh | 58 +++++ packaging/scoop/bucket/rustnzb.json | 42 ++++ ...TheDancingDeveloper.rustnzb.installer.yaml | 22 ++ ...DancingDeveloper.rustnzb.locale.en-US.yaml | 25 ++ .../1.5.0/TheDancingDeveloper.rustnzb.yaml | 7 + 10 files changed, 582 insertions(+), 12 deletions(-) create mode 100755 packaging/distribution/render-windows-manifests.sh create mode 100644 packaging/homebrew/Formula/rustnzb.rb create mode 100755 packaging/homebrew/update-formula.sh create mode 100644 packaging/scoop/bucket/rustnzb.json create mode 100644 packaging/winget/manifests/t/TheDancingDeveloper/rustnzb/1.5.0/TheDancingDeveloper.rustnzb.installer.yaml create mode 100644 packaging/winget/manifests/t/TheDancingDeveloper/rustnzb/1.5.0/TheDancingDeveloper.rustnzb.locale.en-US.yaml create mode 100644 packaging/winget/manifests/t/TheDancingDeveloper/rustnzb/1.5.0/TheDancingDeveloper.rustnzb.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d75dbcd..165ec13b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ also carries generated release notes with the merged pull requests. - **config:** Back up and restore every setting as one TOML file, with a masked export by default and a validated import +- **packaging:** Homebrew formula and Scoop/winget manifest templates, rendered from release checksums +- Embed dependency data in release binaries with cargo-auditable - **ledger:** Prune damage-ledger rows for removed jobs and expired holes - **queue:** Leave held PAR2 volumes out of queue size and record volumes fetched in history - **dispatch:** Hold PAR2 recovery volumes until the slice map shows damage diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 4c8b6200..4b7cd35a 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -89,9 +89,49 @@ takes the rebuild path below. ghcr.io/thedancingdeveloper-org/rustnzbd:latest ``` +8. Refresh the package-manager manifests for the new release, then submit + them yourself. CI renders the Windows manifests but publishes nothing. + + ```bash + packaging/homebrew/update-formula.sh vX.Y.Z + packaging/distribution/render-windows-manifests.sh vX.Y.Z --release-date YYYY-MM-DD + ``` + + Both scripts read the release's own `SHA256SUMS-.txt` and abort on a + mismatch. Commit the refreshed files and follow + [packaging/README.md](../packaging/README.md) for submission. Pre-release + tags are never offered to package managers. + GitHub generates release notes from the tagged history. Review them before publishing; do not add version-specific release-note files to the repository. +## SBOMs and dependency accuracy + +The standalone binaries are built with `cargo-auditable`, which embeds the +resolved crate list in a `.dep-v0` section of the executable. The release and +promotion workflows assert that section is present in both Linux tarballs +before generating SBOMs, so syft reads exact crate versions from the shipped +file rather than guessing from the build tree. The container image is built +the same way for the same reason. + +`ci/tasks/auditable` pins the cargo-auditable version and its tarball SHA-256. +Bump both together. A binary built without the wrapper still works, but its +SBOM will list far fewer crates — the release workflow fails rather than +shipping one. + +Every release asset gets a `.spdx.json` alongside it, generated by syft from +the asset itself (the Windows installer included). The container image carries +its SBOM as an image attestation instead. + +### Cosign signatures on the mirrors + +`SHA256SUMS-.txt` is signed keyless with the release workflow's OIDC +identity, and its `.sig`/`.pem` live on the GitHub release. The Forgejo +rollback registry and the old GHCR namespace mirror the image digest but not +the blob signature. Whether to copy the signature and certificate to those +mirrors is an open operator decision; until it is made, verify checksums +against the GitHub release. + ## Standalone nzb-* crates The seven `nzb-*` crates under `crates/` are published to crates.io from diff --git a/packaging/README.md b/packaging/README.md index 19688724..dd078013 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -1,26 +1,117 @@ # Distribution packaging Drafts for channels beyond the GitHub release assets. Nothing here is -published by CI; each channel is submitted by the operator. +published by CI; each channel is submitted by the operator. The files are +generated from, or pinned to, the assets and `SHA256SUMS-.txt` that the +release workflow already produces. | Path | Channel | Status | |------|---------|--------| | `unraid/rustnzb.xml` | Unraid Community Apps template | Ready to submit. Points at the public GHCR image `ghcr.io/thedancingdeveloper-org/rustnzbd:latest`. | | `aur/PKGBUILD` | AUR `rustnzb-bin` | Ready to submit. Pinned to the `v1.6.0-beta.3` tarball with its published SHA-256. | | `aur/update-pkgbuild.sh` | Refresh the PKGBUILD for a release | Verifies the tarball against the release `SHA256SUMS` before writing. | +| `homebrew/Formula/rustnzb.rb` | Homebrew formula (Linux tap or `homebrew-core`) | Template, pinned to the `v1.5.0` Linux tarballs. Not published. | +| `homebrew/update-formula.sh` | Refresh the formula for a release | Verifies both Linux tarballs against the release `SHA256SUMS` before writing. | +| `scoop/bucket/rustnzb.json` | Scoop manifest | Template, pinned to the `v1.5.0` NSIS installer. Not published. | +| `winget/manifests/t/…` | winget manifest set | Template, pinned to the `v1.5.0` NSIS installer. Not published. | +| `distribution/render-windows-manifests.sh` | Regenerate Scoop + winget from one checksum file | Writes both channels in one pass; verifies the installer when given `--assets-dir`. | -## Submitting +Everything is pinned to the last **stable** release (`v1.5.0`). Pre-release +tags are not offered to package managers. -- **Unraid:** submit `unraid/rustnzb.xml` to the Community Apps repository - through its submission process. Re-check the `Repository` tag and the WebUI - port against the release image on each release. -- **AUR:** on an Arch host, run `makepkg --printsrcinfo > .SRCINFO` next to the - PKGBUILD, then push the two files to the `rustnzb-bin` AUR git repository. - Run `update-pkgbuild.sh ` first when moving to a new release. The - PKGBUILD has not been built here: `makepkg` is not installed in this - workspace, so the packaging step was checked by running `package()` against - the real release archive. +## Windows: one asset, two manifests + +The Windows release asset is an NSIS installer +(`rustnzb-v-windows-x86_64-setup.exe`). Both Windows channels describe +that one file, so they are rendered together: + +```bash +# from a checkout, using the published checksum file +packaging/distribution/render-windows-manifests.sh v1.5.0 --release-date 2026-09-18 + +# or from downloaded release assets, verifying the installer's hash too +packaging/distribution/render-windows-manifests.sh v1.5.0 \ + --sums SHA256SUMS-v1.5.0.txt \ + --assets-dir .ci-output/packages \ + --release-date 2026-09-18 +``` + +The release workflow runs the same script as a dry run against the assets it +just built, and uploads the result as the +`rustnzb--distribution-manifests` artefact. It never submits anything. + +- **Scoop** extracts the installer with `Expand-7zipArchive` rather than + running it, so no registry entries, file associations or uninstaller are + created. `7zip` must be installed in the same Scoop root; the manifest says + so in its notes. `checkver`/`autoupdate` track stable GitHub releases. +- **winget** uses `InstallerType: nullsoft`, `Scope: machine` and + `ElevationRequirement: elevatesSelf`, matching the NSIS script's + `RequestExecutionLevel admin`. It declares the `rustnzb` command and the + `.nzb` file extension that the installer registers. + +### Submitting the Windows manifests + +winget (after reviewing the generated files under +`packaging/winget/manifests/t/TheDancingDeveloper/rustnzb//`): + +```powershell +winget validate --manifest packaging/winget/manifests/t/TheDancingDeveloper/rustnzb/1.5.0 +winget install --manifest packaging/winget/manifests/t/TheDancingDeveloper/rustnzb/1.5.0 +# then, from a winget-pkgs checkout: +wingetcreate submit packaging/winget/manifests/t/TheDancingDeveloper/rustnzb/1.5.0 +``` + +Scoop: copy `packaging/scoop/bucket/rustnzb.json` into your bucket, then +`checkver rustnzb` and `scoop install rustnzb`. + +The installer is **not Authenticode-signed**. winget accepts unsigned +installers, but SmartScreen warns on first run; that is a known precondition, +not a defect in the manifest. + +## Homebrew + +The formula installs the prebuilt Linux binaries — `x86_64` by default and +`aarch64` on Apple Silicon Linux hosts (`on_arm`). It depends on `sevenzip` +for extraction; Homebrew has no `unrar` formula (only a cask), so direct +unpack stays off unless the user installs one separately. + +There is no macOS artefact in the release, so the formula is Linux-only +(`depends_on :linux`). A macOS formula needs a macOS binary or a source build +first; that is out of scope here. + +```bash +# refresh for a new tag: verifies both tarballs against SHA256SUMS-.txt +packaging/homebrew/update-formula.sh v1.5.0 + +# local check from the repository root +brew install --build-from-source ./packaging/homebrew/Formula/rustnzb.rb +brew audit --strict --new-formula ./packaging/homebrew/Formula/rustnzb.rb +brew test ./packaging/homebrew/Formula/rustnzb.rb +``` + +`brew` is not available in this workspace, so the formula has not been +executed here. Its URLs and hashes were verified against the published +release, and `update-formula.sh` was run end to end to produce them. + +To ship it, copy `Formula/rustnzb.rb` into your tap (or submit it to +`homebrew-core` as a new formula) yourself. Do not add a bottle block by hand; +Homebrew builds and publishes bottles. + +## cargo-auditable binaries + +The standalone release binaries and the container image are built with +[`cargo-auditable`](https://github.com/rust-secure-code/cargo-auditable), which +embeds the resolved crate list in a `.dep-v0` section. `ci/tasks/auditable` +pins the tool version and tarball checksum; the release workflows assert the +section is present in both shipped Linux binaries before generating SBOMs. That +is what makes the per-artefact SPDX files dependency-accurate instead of +heuristic. See [docs/RELEASING.md](../docs/RELEASING.md#sboms-and-dependency-accuracy). ## Not yet covered -Homebrew, winget/Scoop, and dmg/AppImage builds are follow-ups. +- **macOS**: no dmg and no macOS binary, so no Homebrew-on-macOS formula. +- **AppImage**: not built. +- **Copying cosign signatures to the mirrors**: undecided; the signature and + certificate stay on the GitHub release next to `SHA256SUMS-.txt`. +- **Publishing from CI**: deliberately absent. Every channel here is an + operator submission. diff --git a/packaging/distribution/render-windows-manifests.sh b/packaging/distribution/render-windows-manifests.sh new file mode 100755 index 00000000..d15eeb7a --- /dev/null +++ b/packaging/distribution/render-windows-manifests.sh @@ -0,0 +1,223 @@ +#!/bin/sh +# Render the Scoop and winget manifests for a rustnzb Windows release. +# +# Both manifests describe the same NSIS installer asset, so they are rendered +# from one checksum file in one pass. Nothing is published: the files land under +# packaging/scoop and packaging/winget for the operator to submit. +# +# usage: render-windows-manifests.sh TAG [--sums FILE] [--assets-dir DIR] +# [--release-date YYYY-MM-DD] [--out DIR] +# +# TAG release tag, e.g. v1.5.0 +# --sums FILE use a local SHA256SUMS-.txt instead of downloading it +# --assets-dir directory holding the installer asset, to verify it +# --release-date optional release date recorded in the winget installer +# --out DIR repository root to write under (default: this checkout) +# +# Every rendered value comes from the release's own SHA256SUMS file. A release +# that does not list the Windows installer, or an asset that does not match the +# listed hash, aborts before anything is written. +set -eu + +repo=TheDancingDeveloper-org/rustnzb +homepage=https://rustnzb.dev +publisher=TheDancingDeveloper +package_id=$publisher.rustnzb + +tag=${1:?usage: render-windows-manifests.sh TAG [--sums FILE] [--assets-dir DIR] [--out DIR]} +shift +case "$tag" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "render-windows-manifests: tag must look like vX.Y.Z or vX.Y.Z-suffix" >&2; exit 2 ;; +esac +version=${tag#v} + +sums= +assets_dir= +release_date= +out= +while [ $# -gt 0 ]; do + case "$1" in + --sums) sums=${2:?--sums needs a file}; shift 2 ;; + --assets-dir) assets_dir=${2:?--assets-dir needs a directory}; shift 2 ;; + --release-date) release_date=${2:?--release-date needs YYYY-MM-DD}; shift 2 ;; + --out) out=${2:?--out needs a directory}; shift 2 ;; + *) echo "render-windows-manifests: unknown argument: $1" >&2; exit 2 ;; + esac +done + +case "$release_date" in + '') ;; + [0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]) ;; + *) echo "render-windows-manifests: --release-date must be YYYY-MM-DD" >&2; exit 2 ;; +esac + +script_dir=$(CDPATH="" cd -- "$(dirname -- "$0")" && pwd) +root=${out:-$(CDPATH="" cd -- "$script_dir/../.." && pwd)} +asset=rustnzb-$tag-windows-x86_64-setup.exe +url=https://github.com/$repo/releases/download/$tag/$asset + +work= +tmp= +cleanup() { [ -z "$work" ] || rm -rf "$work"; [ -z "$tmp" ] || rm -f "$tmp"; } +trap cleanup EXIT + +if [ -z "$sums" ]; then + work=$(mktemp -d) + curl -fsSL -o "$work/SHA256SUMS-$tag.txt" \ + "https://github.com/$repo/releases/download/$tag/SHA256SUMS-$tag.txt" + sums=$work/SHA256SUMS-$tag.txt +fi +test -f "$sums" || { echo "render-windows-manifests: no such file: $sums" >&2; exit 1; } + +expected=$(awk -v f="$asset" '$2 == f || $2 == "*" f { print tolower($1) }' "$sums") +test -n "$expected" || { + echo "render-windows-manifests: $asset is not listed in $sums" >&2 + exit 1 +} +case "$expected" in + *[!0-9a-f]*) echo "render-windows-manifests: bad hash for $asset in $sums" >&2; exit 1 ;; +esac +[ ${#expected} -eq 64 ] || { + echo "render-windows-manifests: hash for $asset is not 64 hex characters" >&2 + exit 1 +} + +if [ -n "$assets_dir" ]; then + test -f "$assets_dir/$asset" || { + echo "render-windows-manifests: $assets_dir/$asset is missing" >&2 + exit 1 + } + actual=$(sha256sum "$assets_dir/$asset" | awk '{ print $1 }') + [ "$actual" = "$expected" ] || { + echo "render-windows-manifests: checksum mismatch for $asset" >&2 + echo " expected $expected" >&2 + echo " actual $actual" >&2 + exit 1 + } + echo "render-windows-manifests: verified $asset ($expected)" +fi + +scoop_dir=$root/packaging/scoop/bucket +winget_root=$root/packaging/winget/manifests/t/$publisher/rustnzb +winget_dir=$winget_root/$version +mkdir -p "$scoop_dir" "$winget_dir" + +upper_hash=$(printf '%s' "$expected" | tr 'a-f' 'A-F') +date_line= +[ -z "$release_date" ] || date_line="ReleaseDate: \"$release_date\"" + +tmp=$scoop_dir/rustnzb.json.tmp + +cat >"$tmp" < to pin the config file; without it rustnzb creates one in its default location on first start.", + "The web UI listens on http://localhost:9090.", + "Archive extraction needs 7-Zip on PATH: scoop install 7zip." + ], + "checkver": { + "github": "https://github.com/$repo", + "regex": "v([\\\\d.]+)\$" + }, + "autoupdate": { + "architecture": { + "64bit": { + "url": "https://github.com/$repo/releases/download/v\$version/rustnzb-v\$version-windows-x86_64-setup.exe" + } + } + } +} +EOF +mv "$tmp" "$scoop_dir/rustnzb.json" +tmp= + +cat >"$winget_dir/$package_id.yaml" <"$winget_dir/$package_id.locale.en-US.yaml" <"$winget_dir/$package_id.installer.yaml" <`. +# +# Linux only: the release workflow ships Linux tarballs and a Windows +# installer, so there is no macOS artefact to point a `url` at. +class Rustnzb < Formula + desc "Usenet NZB downloader with a SABnzbd-compatible API and web UI" + homepage "https://rustnzb.dev" + url "https://github.com/TheDancingDeveloper-org/rustnzb/releases/download/v1.5.0/rustnzb-v1.5.0-linux-x86_64.tar.gz" + sha256 "7fecddc1fb61a33bf3d34ab23ec69e9cbb6398992afa826708b4dc7223018f7d" + license "MIT" + version "1.5.0" + + depends_on :linux + + # Extraction. `sevenzip` provides `7zz`, which rustnzb prefers and which can + # read RAR archives. Homebrew has no `unrar` formula (it is a cask), so + # direct unpack stays off unless the user installs one themselves. + depends_on "sevenzip" + + on_arm do + url "https://github.com/TheDancingDeveloper-org/rustnzb/releases/download/v1.5.0/rustnzb-v1.5.0-linux-aarch64.tar.gz" + sha256 "bb9cf8313c3a18f42ff4e49dcf137784106717bfdc2d8e2a3753757a3b495b53" + end + + def install + bin.install "rustnzb-v#{version}/rustnzb" + pkgshare.install "rustnzb-v#{version}/config.example.toml" + doc.install "rustnzb-v#{version}/README.md" + end + + service do + run [opt_bin/"rustnzb"] + keep_alive true + working_dir var + log_path var/"log/rustnzb.log" + error_log_path var/"log/rustnzb.log" + end + + def caveats + <<~EOS + rustnzb reads $XDG_CONFIG_HOME/rustnzb/config.toml (default + ~/.config/rustnzb/config.toml) and creates it on first start. A fully + commented example is installed at: + #{opt_pkgshare}/config.example.toml + The default config expects /downloads and /data; either create those + directories or edit the config before starting the service. + The web UI listens on http://localhost:9090. + EOS + end + + test do + assert_match "rustnzb #{version}", shell_output("#{bin}/rustnzb --version") + system bin/"rustnzb", "--check-config", "--config", "#{opt_pkgshare}/config.example.toml" + end +end diff --git a/packaging/homebrew/update-formula.sh b/packaging/homebrew/update-formula.sh new file mode 100755 index 00000000..a2cf1cb7 --- /dev/null +++ b/packaging/homebrew/update-formula.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# Refresh packaging/homebrew/Formula/rustnzb.rb for a published release tag. +# +# usage: packaging/homebrew/update-formula.sh v1.5.0 +# +# Downloads both Linux release tarballs and the release's own SHA256SUMS into a +# temporary directory, checks each tarball against that file, and only then +# writes the version, URLs and two sha256 lines into the formula. The tarballs +# are never extracted here; their checksums are all that is read. +set -eu + +tag=${1:?usage: packaging/homebrew/update-formula.sh TAG (e.g. v1.5.0)} +case "$tag" in + v[0-9]*.[0-9]*.[0-9]*) ;; + *) echo "update-formula: tag must look like vX.Y.Z or vX.Y.Z-suffix" >&2; exit 2 ;; +esac + +ver=${tag#v} +repo=https://github.com/TheDancingDeveloper-org/rustnzb/releases/download/$tag +amd64=rustnzb-$tag-linux-x86_64.tar.gz +arm64=rustnzb-$tag-linux-aarch64.tar.gz + +script_dir=$(CDPATH="" cd -- "$(dirname -- "$0")" && pwd) +formula=$script_dir/Formula/rustnzb.rb +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +curl -fsSL -o "$work/$amd64" "$repo/$amd64" +curl -fsSL -o "$work/$arm64" "$repo/$arm64" +curl -fsSL -o "$work/SHA256SUMS.txt" "$repo/SHA256SUMS-$tag.txt" + +verify() { + file=$1 + expected=$(awk -v f="$file" '$2 == f { print $1 }' "$work/SHA256SUMS.txt") + test -n "$expected" || { + echo "update-formula: $file is not listed in the release SHA256SUMS" >&2 + exit 1 + } + actual=$(sha256sum "$work/$file" | awk '{ print $1 }') + if [ "$expected" != "$actual" ]; then + echo "update-formula: checksum mismatch for $file" >&2 + exit 1 + fi + printf '%s\n' "$actual" +} + +x86_hash=$(verify "$amd64") +arm_hash=$(verify "$arm64") + +sed -i \ + -e "s|^ version \".*\"$| version \"$ver\"|" \ + -e "s|^ url \"https://github.com/TheDancingDeveloper-org/rustnzb/releases/download/.*/rustnzb-.*-linux-x86_64.tar.gz\"$| url \"$repo/$amd64\"|" \ + -e "s|releases/download/.*/rustnzb-.*-linux-aarch64.tar.gz|releases/download/$tag/$arm64|" \ + -e "0,/^ sha256 \"[0-9a-f]*\"$/s|| sha256 \"$x86_hash\"|" \ + -e "0,/^ sha256 \"[0-9a-f]*\"$/s|| sha256 \"$arm_hash\"|" \ + "$formula" + +printf 'update-formula: version=%s x86_64=%s aarch64=%s\n' "$ver" "$x86_hash" "$arm_hash" diff --git a/packaging/scoop/bucket/rustnzb.json b/packaging/scoop/bucket/rustnzb.json new file mode 100644 index 00000000..280e2fc4 --- /dev/null +++ b/packaging/scoop/bucket/rustnzb.json @@ -0,0 +1,42 @@ +{ + "##": "Template kept in the rustnzb source repository; CI never publishes it. Regenerated by packaging/distribution/render-windows-manifests.sh.", + "$schema": "https://raw.githubusercontent.com/ScoopInstaller/Scoop/master/schema.json", + "version": "1.5.0", + "description": "Usenet NZB downloader with a SABnzbd-compatible API and web UI.", + "homepage": "https://rustnzb.dev", + "license": "MIT", + "architecture": { + "64bit": { + "url": "https://github.com/TheDancingDeveloper-org/rustnzb/releases/download/v1.5.0/rustnzb-v1.5.0-windows-x86_64-setup.exe", + "hash": "2e81d4cbb8f9661c85ddec1001d0f4f6f577a3278472fc1ef73d9395e6a0f66b" + } + }, + "pre_install": [ + "Expand-7zipArchive \"$dir\\$fname\" \"$dir\"", + "Remove-Item \"$dir\\$fname\"" + ], + "bin": "rustnzb.exe", + "shortcuts": [ + [ + "rustnzb.exe", + "rustnzb" + ] + ], + "notes": [ + "The release asset is an NSIS installer; this manifest extracts it instead of running it, so no registry entries or service are created.", + "Pass --config to pin the config file; without it rustnzb creates one in its default location on first start.", + "The web UI listens on http://localhost:9090.", + "Archive extraction needs 7-Zip on PATH: scoop install 7zip." + ], + "checkver": { + "github": "https://github.com/TheDancingDeveloper-org/rustnzb", + "regex": "v([\\d.]+)$" + }, + "autoupdate": { + "architecture": { + "64bit": { + "url": "https://github.com/TheDancingDeveloper-org/rustnzb/releases/download/v$version/rustnzb-v$version-windows-x86_64-setup.exe" + } + } + } +} diff --git a/packaging/winget/manifests/t/TheDancingDeveloper/rustnzb/1.5.0/TheDancingDeveloper.rustnzb.installer.yaml b/packaging/winget/manifests/t/TheDancingDeveloper/rustnzb/1.5.0/TheDancingDeveloper.rustnzb.installer.yaml new file mode 100644 index 00000000..f9603d36 --- /dev/null +++ b/packaging/winget/manifests/t/TheDancingDeveloper/rustnzb/1.5.0/TheDancingDeveloper.rustnzb.installer.yaml @@ -0,0 +1,22 @@ +# Template kept in the rustnzb source repository; CI never submits it. +PackageIdentifier: TheDancingDeveloper.rustnzb +PackageVersion: 1.5.0 +InstallerType: nullsoft +Scope: machine +UpgradeBehavior: install +Commands: +- rustnzb +FileExtensions: +- nzb +ElevationRequirement: elevatesSelf +ReleaseDate: "2026-09-18" +Installers: +- Architecture: x64 + InstallerUrl: https://github.com/TheDancingDeveloper-org/rustnzb/releases/download/v1.5.0/rustnzb-v1.5.0-windows-x86_64-setup.exe + InstallerSha256: 2E81D4CBB8F9661C85DDEC1001D0F4F6F577A3278472FC1EF73D9395E6A0F66B + AppsAndFeaturesEntries: + - DisplayName: rustnzb 1.5.0 + Publisher: TheDancingDeveloper + DisplayVersion: 1.5.0 +ManifestType: installer +ManifestVersion: 1.10.0 diff --git a/packaging/winget/manifests/t/TheDancingDeveloper/rustnzb/1.5.0/TheDancingDeveloper.rustnzb.locale.en-US.yaml b/packaging/winget/manifests/t/TheDancingDeveloper/rustnzb/1.5.0/TheDancingDeveloper.rustnzb.locale.en-US.yaml new file mode 100644 index 00000000..72b0bc0e --- /dev/null +++ b/packaging/winget/manifests/t/TheDancingDeveloper/rustnzb/1.5.0/TheDancingDeveloper.rustnzb.locale.en-US.yaml @@ -0,0 +1,25 @@ +# Template kept in the rustnzb source repository; CI never submits it. +PackageIdentifier: TheDancingDeveloper.rustnzb +PackageVersion: 1.5.0 +PackageLocale: en-US +Publisher: TheDancingDeveloper +PublisherUrl: https://github.com/TheDancingDeveloper-org +PublisherSupportUrl: https://github.com/TheDancingDeveloper-org/rustnzb/issues +PackageName: rustnzb +PackageUrl: https://rustnzb.dev +License: MIT +LicenseUrl: https://github.com/TheDancingDeveloper-org/rustnzb/blob/main/LICENSE +ShortDescription: Usenet NZB downloader with a SABnzbd-compatible API and web UI +Moniker: rustnzb +Tags: +- usenet +- nzb +- sabnzbd +- downloader +Description: |- + rustnzb downloads NZBs over pipelined TLS NNTP connections, decodes yEnc, + verifies and repairs PAR2 sets, extracts archives, and exposes both a native + REST API and a SABnzbd-compatible API for *arr applications and SAB clients. +ReleaseNotesUrl: https://github.com/TheDancingDeveloper-org/rustnzb/releases/tag/v1.5.0 +ManifestType: locale +ManifestVersion: 1.10.0 diff --git a/packaging/winget/manifests/t/TheDancingDeveloper/rustnzb/1.5.0/TheDancingDeveloper.rustnzb.yaml b/packaging/winget/manifests/t/TheDancingDeveloper/rustnzb/1.5.0/TheDancingDeveloper.rustnzb.yaml new file mode 100644 index 00000000..fa1b4c61 --- /dev/null +++ b/packaging/winget/manifests/t/TheDancingDeveloper/rustnzb/1.5.0/TheDancingDeveloper.rustnzb.yaml @@ -0,0 +1,7 @@ +# Template kept in the rustnzb source repository; CI never submits it. +# Regenerated by packaging/distribution/render-windows-manifests.sh. +PackageIdentifier: TheDancingDeveloper.rustnzb +PackageVersion: 1.5.0 +DefaultLocale: en-US +ManifestType: version +ManifestVersion: 1.10.0