diff --git a/.github/workflows/quality-schedule.yml b/.github/workflows/quality-schedule.yml index e427566d..dd67383a 100644 --- a/.github/workflows/quality-schedule.yml +++ b/.github/workflows/quality-schedule.yml @@ -63,3 +63,14 @@ jobs: with: name: mutation-report path: .ci-artifacts/mutants + + crate-drift: + runs-on: [self-hosted] + steps: + - uses: actions/checkout@v7 + - name: Diff the standalone nzb-* repos against the monorepo head + run: ci/tasks/crate-drift + env: + # Optional: a token that can read the standalone repos if any are + # private. Public repos clone anonymously and this stays empty. + GH_TOKEN: ${{ secrets.NZB_SYNC_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 6209acb7..821bd7d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1773,7 +1773,7 @@ dependencies = [ [[package]] name = "nzb-core" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "chrono", @@ -1794,7 +1794,7 @@ dependencies = [ [[package]] name = "nzb-decode" -version = "0.1.2" +version = "0.1.4" dependencies = [ "anyhow", "bytes", @@ -1810,7 +1810,7 @@ dependencies = [ [[package]] name = "nzb-dispatch" -version = "0.2.6" +version = "0.2.8" dependencies = [ "anyhow", "arc-swap", @@ -1836,7 +1836,7 @@ dependencies = [ [[package]] name = "nzb-news" -version = "0.1.12" +version = "0.1.14" dependencies = [ "nzb-nntp", "tokio", @@ -1846,7 +1846,7 @@ dependencies = [ [[package]] name = "nzb-nntp" -version = "0.2.22" +version = "0.2.24" dependencies = [ "anyhow", "arc-swap", @@ -1874,7 +1874,7 @@ dependencies = [ [[package]] name = "nzb-postproc" -version = "0.2.7" +version = "0.2.8" dependencies = [ "anyhow", "md-5 0.11.0", @@ -1892,7 +1892,7 @@ dependencies = [ [[package]] name = "nzb-web" -version = "0.4.21" +version = "0.4.22" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index e050052b..f9f4dfaf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,13 +86,13 @@ feed-rs = "2" unicode-normalization = "0.1" # Shared NZB crates -nzb-web = { version = "0.4.20", path = "crates/nzb-web", features = ["groups-db"] } -nzb-nntp = { version = "0.2.22", path = "crates/nzb-nntp" } -nzb-core = { version = "0.2.17", path = "crates/nzb-core", features = ["groups-db"] } -nzb-decode = { version = "0.1.2", path = "crates/nzb-decode" } -nzb-news = { version = "0.1.12", path = "crates/nzb-news" } -nzb-dispatch = { version = "0.2.6", path = "crates/nzb-dispatch" } -nzb-postproc = { version = "0.2.7", path = "crates/nzb-postproc" } +nzb-web = { version = "0.4.22", path = "crates/nzb-web", features = ["groups-db"] } +nzb-nntp = { version = "0.2.24", path = "crates/nzb-nntp" } +nzb-core = { version = "0.2.18", path = "crates/nzb-core", features = ["groups-db"] } +nzb-decode = { version = "0.1.4", path = "crates/nzb-decode" } +nzb-news = { version = "0.1.14", path = "crates/nzb-news" } +nzb-dispatch = { version = "0.2.8", path = "crates/nzb-dispatch" } +nzb-postproc = { version = "0.2.8", path = "crates/nzb-postproc" } mock-nntp-server = { path = "crates/mock-nntp-server" } rust-par2 = { version = "0.1.3" } yenc-simd = { version = "0.1.1" } diff --git a/ci/nzb-crates.txt b/ci/nzb-crates.txt new file mode 100644 index 00000000..7711ad98 --- /dev/null +++ b/ci/nzb-crates.txt @@ -0,0 +1,11 @@ +# Standalone nzb-* crates kept in sync from this monorepo (the head). +# One crate directory name per line; blank lines and #-comments are ignored. +# The monorepo owns version numbers; standalone repos are sync targets. +# Used by ci/tasks/sync-crate and ci/tasks/crate-drift. +nzb-core +nzb-nntp +nzb-decode +nzb-news +nzb-dispatch +nzb-postproc +nzb-web diff --git a/ci/tasks/crate-drift b/ci/tasks/crate-drift new file mode 100755 index 00000000..23365143 --- /dev/null +++ b/ci/tasks/crate-drift @@ -0,0 +1,89 @@ +#!/bin/sh +set -eu + +# crate-drift — verify the standalone nzb-* repos still match the monorepo. +# +# The monorepo is the head: every synced crate's `src/` tree and its package +# version must be identical in the standalone repo it publishes from. This +# task clones each standalone repo (from the crate's `repository` field), +# compares it against `crates//`, and exits non-zero when any crate has +# drifted, naming the sync command to fix it. It never writes anywhere. +# +# Public repos clone anonymously. For private mirrors, set GH_TOKEN and the +# clone authenticates the same way as ci/tasks/mirror-github. + +TASK_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH= cd -- "$TASK_DIR/../.." && pwd) +cd "$REPO_ROOT" + +crate_list=ci/nzb-crates.txt +[ -f "$crate_list" ] || { echo "crate-drift: missing $crate_list" >&2; exit 2; } + +workdir=$(mktemp -d) +askpass="" +cleanup() { rm -rf "$workdir"; [ -z "$askpass" ] || rm -f "$askpass"; } +trap cleanup EXIT + +if [ -n "${GH_TOKEN:-}" ]; then + askpass=$(mktemp) + cat >"$askpass" <<'ASK' +#!/bin/sh +case "$1" in + *Username*) printf '%s\n' x-access-token ;; + *Password*) printf '%s\n' "$GH_TOKEN" ;; + *) exit 1 ;; +esac +ASK + chmod 700 "$askpass" + export GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0 +fi + +manifest_field() { + # manifest_field — first ` = "value"` line's value. + grep -m1 "^$2[[:space:]]*=" "$1" | sed 's/[^"]*"\([^"]*\)".*/\1/' +} + +failures=0 +note_drift() { echo "drift: $1" >&2; failures=$((failures + 1)); } + +while IFS= read -r crate; do + case "$crate" in ''|\#*) continue ;; esac + + manifest="crates/$crate/Cargo.toml" + if [ ! -f "$manifest" ]; then + note_drift "$crate: no $manifest in the monorepo" + continue + fi + + mono_version=$(manifest_field "$manifest" version) + repo=$(manifest_field "$manifest" repository) + if [ -z "$repo" ]; then + note_drift "$crate: no repository field in $manifest" + continue + fi + + dest="$workdir/$crate" + if ! git clone --depth 1 --quiet "$repo" "$dest" 2>"$workdir/$crate.err"; then + note_drift "$crate: cannot clone $repo" + sed 's/^/ /' "$workdir/$crate.err" >&2 || true + continue + fi + + std_version=$(manifest_field "$dest/Cargo.toml" version) + if [ "$mono_version" != "$std_version" ]; then + note_drift "$crate: version monorepo=$mono_version standalone=$std_version" + fi + + if ! git -c core.fileMode=false diff --no-index --quiet \ + "$dest/src" "crates/$crate/src"; then + note_drift "$crate: src/ differs from the standalone repo — run: ci/tasks/sync-crate $crate" + git -c core.fileMode=false diff --no-index --stat \ + "$dest/src" "crates/$crate/src" 2>/dev/null | sed 's/^/ /' >&2 || true + fi +done <"$crate_list" + +if [ "$failures" -ne 0 ]; then + echo "crate-drift: $failures crate(s) drifted from the monorepo head" >&2 + exit 1 +fi +echo "crate-drift: all synced crates match the monorepo head" diff --git a/ci/tasks/sync-crate b/ci/tasks/sync-crate new file mode 100755 index 00000000..b41cb6ac --- /dev/null +++ b/ci/tasks/sync-crate @@ -0,0 +1,83 @@ +#!/bin/sh +set -eu + +# sync-crate — carry one nzb-* crate from the monorepo head to its +# standalone repo. +# +# Exports crates/ at HEAD (tracked files only), replaces the standalone +# repo's tree with it, and opens a pull request against that repo's main +# branch. The monorepo owns version numbers, so the exported Cargo.toml +# carries the version across untouched. Publishing to crates.io happens from a +# tag on the standalone repo after this PR merges (see docs/RELEASING.md); this +# task never publishes and never pushes to the monorepo. +# +# Requires GH_TOKEN (a token that can push to and open PRs on the standalone +# repo) and the gh CLI. + +crate=${1:?usage: sync-crate } + +TASK_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +REPO_ROOT=$(CDPATH= cd -- "$TASK_DIR/../.." && pwd) +cd "$REPO_ROOT" + +grep -qx "$crate" ci/nzb-crates.txt \ + || { echo "$crate is not a synced nzb-* crate (see ci/nzb-crates.txt)" >&2; exit 2; } + +manifest="crates/$crate/Cargo.toml" +[ -f "$manifest" ] || { echo "unknown crate: no $manifest" >&2; exit 2; } + +: "${GH_TOKEN:?GH_TOKEN is required}" +command -v gh >/dev/null 2>&1 || { echo "the gh CLI is required" >&2; exit 127; } + +manifest_field() { grep -m1 "^$2[[:space:]]*=" "$1" | sed 's/[^"]*"\([^"]*\)".*/\1/'; } + +repo=$(manifest_field "$manifest" repository) +version=$(manifest_field "$manifest" version) +[ -n "$repo" ] || { echo "$crate has no repository field" >&2; exit 2; } +slug=$(printf '%s' "$repo" | sed 's#https://github.com/##; s#\.git$##') +head_sha=$(git rev-parse --short HEAD) +branch="sync/monorepo-$head_sha" + +workdir=$(mktemp -d) +askpass=$(mktemp) +cleanup() { rm -rf "$workdir"; rm -f "$askpass"; } +trap cleanup EXIT + +cat >"$askpass" <<'ASK' +#!/bin/sh +case "$1" in + *Username*) printf '%s\n' x-access-token ;; + *Password*) printf '%s\n' "$GH_TOKEN" ;; + *) exit 1 ;; +esac +ASK +chmod 700 "$askpass" +export GIT_ASKPASS="$askpass" GIT_TERMINAL_PROMPT=0 GH_TOKEN + +clone="$workdir/repo" +git clone --quiet "$repo" "$clone" +git -C "$clone" checkout -q -B "$branch" + +# Replace the standalone tree with the monorepo crate at HEAD. Clear the old +# tracked files first so deletions in the monorepo propagate, then extract the +# crate subtree (git archive emits tracked files only, no target/). +git -C "$clone" ls-files -z | (cd "$clone" && xargs -0 rm -f --) +git archive "HEAD:crates/$crate" | tar -x -C "$clone" + +git -C "$clone" add -A +if git -C "$clone" diff --cached --quiet; then + echo "sync-crate: $crate already matches the standalone repo at v$version; nothing to do" + exit 0 +fi + +git -C "$clone" commit -q \ + -m "sync: $crate from rustnzb monorepo $head_sha (v$version)" +git -C "$clone" push -q --force-with-lease -u origin "$branch" + +gh pr create --repo "$slug" --head "$branch" --base main \ + --title "sync: $crate from rustnzb monorepo $head_sha" \ + --body "Automated sync of \`crates/$crate\` at rustnzb monorepo commit $head_sha (version $version). + +The monorepo owns nzb-* version numbers; this PR carries its current tree across verbatim. After merge, publish v$version from a \`v$version\` tag on this repo — see rustnzb docs/RELEASING.md." + +echo "sync-crate: opened a PR on $slug from branch $branch (v$version)" diff --git a/crates/nzb-core/Cargo.toml b/crates/nzb-core/Cargo.toml index 565d5cfa..c127be5b 100644 --- a/crates/nzb-core/Cargo.toml +++ b/crates/nzb-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nzb-core" -version = "0.2.17" +version = "0.2.18" edition = "2024" description = "Shared models, config, NZB parser, and SQLite database for NZB clients" license = "MIT" @@ -12,7 +12,7 @@ default = [] groups-db = [] [dependencies] -nzb-nntp = { version = "0.2.22", path = "../nzb-nntp" } +nzb-nntp = { version = "0.2.24", path = "../nzb-nntp" } serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "1.1" diff --git a/crates/nzb-decode/Cargo.toml b/crates/nzb-decode/Cargo.toml index 534ab287..08b740c3 100644 --- a/crates/nzb-decode/Cargo.toml +++ b/crates/nzb-decode/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nzb-decode" -version = "0.1.2" +version = "0.1.4" edition = "2024" description = "yEnc decoding, file assembly, and article cache for NZB download clients" license = "MIT" diff --git a/crates/nzb-dispatch/Cargo.toml b/crates/nzb-dispatch/Cargo.toml index f1447b2f..7e14f38e 100644 --- a/crates/nzb-dispatch/Cargo.toml +++ b/crates/nzb-dispatch/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nzb-dispatch" -version = "0.2.6" +version = "0.2.8" edition = "2024" description = "Article-level dispatcher: per-server worker pool, priority gating, retry + hopeless tracking. Part of the nzb-* layered usenet engine." license = "MIT" @@ -8,9 +8,9 @@ repository = "https://github.com/TheDancingDeveloper-org/nzb-dispatch" readme = "README.md" [dependencies] -nzb-nntp = { version = "0.2.22", path = "../nzb-nntp" } -nzb-decode = { version = "0.1.2", path = "../nzb-decode" } -nzb-core = { version = "0.2.17", path = "../nzb-core" } +nzb-nntp = { version = "0.2.24", path = "../nzb-nntp" } +nzb-decode = { version = "0.1.4", path = "../nzb-decode" } +nzb-core = { version = "0.2.18", path = "../nzb-core" } tokio = { version = "1", features = ["full"] } async-trait = "0.1" @@ -25,7 +25,7 @@ thiserror = "2" unicode-normalization = "0.1" [dev-dependencies] -nzb-nntp = { version = "0.2.22", path = "../nzb-nntp", features = ["test-support"] } +nzb-nntp = { version = "0.2.24", path = "../nzb-nntp", features = ["test-support"] } tempfile = "3" tokio = { version = "1", features = ["full", "test-util"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/nzb-news/Cargo.toml b/crates/nzb-news/Cargo.toml index 0e16d2f7..da4adf9e 100644 --- a/crates/nzb-news/Cargo.toml +++ b/crates/nzb-news/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nzb-news" -version = "0.1.12" +version = "0.1.14" edition = "2024" description = "Layered NNTP download engine: persistent per-connection workers, priority-aware dispatch with cascade-retry, pipelined batch fetching. Pure fetch layer — no decode, no assembly." license = "MIT" @@ -8,13 +8,13 @@ repository = "https://github.com/TheDancingDeveloper-org/nzb-news" readme = "README.md" [dependencies] -nzb-nntp = { version = "0.2.22", path = "../nzb-nntp" } +nzb-nntp = { version = "0.2.24", path = "../nzb-nntp" } tokio = { version = "1", features = ["sync", "rt", "time", "macros"] } tracing = "0.1" [dev-dependencies] -nzb-nntp = { version = "0.2.22", path = "../nzb-nntp", features = ["test-support"] } +nzb-nntp = { version = "0.2.24", path = "../nzb-nntp", features = ["test-support"] } tokio = { version = "1", features = ["full", "test-util"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/crates/nzb-nntp/Cargo.toml b/crates/nzb-nntp/Cargo.toml index eda5a5d7..73432684 100644 --- a/crates/nzb-nntp/Cargo.toml +++ b/crates/nzb-nntp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nzb-nntp" -version = "0.2.22" +version = "0.2.24" edition = "2024" description = "Async NNTP client with TLS, pipelining, connection pooling, and multi-server support" license = "MIT" diff --git a/crates/nzb-postproc/Cargo.toml b/crates/nzb-postproc/Cargo.toml index dcc368cd..20f790ab 100644 --- a/crates/nzb-postproc/Cargo.toml +++ b/crates/nzb-postproc/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nzb-postproc" -version = "0.2.7" +version = "0.2.8" edition = "2024" description = "Post-processing pipeline: PAR2 verify/repair, archive extraction" license = "MIT" @@ -12,7 +12,7 @@ default = [] groups-db = ["nzb-core/groups-db"] [dependencies] -nzb-core = { version = "0.2.17", path = "../nzb-core" } +nzb-core = { version = "0.2.18", path = "../nzb-core" } tokio = { version = "1", features = ["rt", "sync", "process"] } tracing = "0.1" opentelemetry.workspace = true diff --git a/crates/nzb-web/Cargo.toml b/crates/nzb-web/Cargo.toml index a3f2d4de..b3ef4fd5 100644 --- a/crates/nzb-web/Cargo.toml +++ b/crates/nzb-web/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nzb-web" -version = "0.4.21" +version = "0.4.22" edition = "2024" description = "Usenet download engine: queue management, download orchestration, and background services" license = "MIT" @@ -11,10 +11,10 @@ default = [] groups-db = ["nzb-postproc/groups-db"] [dependencies] -nzb-nntp = { version = "0.2.22", path = "../nzb-nntp" } -nzb-decode = { version = "0.1.2", path = "../nzb-decode" } -nzb-postproc = { version = "0.2.7", path = "../nzb-postproc" } -nzb-dispatch = { version = "0.2.6", path = "../nzb-dispatch" } +nzb-nntp = { version = "0.2.24", path = "../nzb-nntp" } +nzb-decode = { version = "0.1.4", path = "../nzb-decode" } +nzb-postproc = { version = "0.2.8", path = "../nzb-postproc" } +nzb-dispatch = { version = "0.2.8", path = "../nzb-dispatch" } axum = { version = "0.8", features = ["multipart"] } tower = "0.5" tower-http = { version = "0.7", features = ["cors", "trace"] } @@ -55,5 +55,5 @@ unused = "warn" [dev-dependencies] tempfile = "3.27.0" -nzb-nntp = { version = "0.2.22", path = "../nzb-nntp", features = ["test-support"] } +nzb-nntp = { version = "0.2.24", path = "../nzb-nntp", features = ["test-support"] } yenc-simd = { version = "0.1" } diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 61c29aea..b177552b 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -3015,7 +3015,7 @@ dependencies = [ [[package]] name = "nzb-core" -version = "0.2.17" +version = "0.2.18" dependencies = [ "anyhow", "chrono", @@ -3034,7 +3034,7 @@ dependencies = [ [[package]] name = "nzb-decode" -version = "0.1.2" +version = "0.1.4" dependencies = [ "anyhow", "bytes", @@ -3048,7 +3048,7 @@ dependencies = [ [[package]] name = "nzb-dispatch" -version = "0.2.6" +version = "0.2.8" dependencies = [ "anyhow", "arc-swap", @@ -3068,7 +3068,7 @@ dependencies = [ [[package]] name = "nzb-nntp" -version = "0.2.22" +version = "0.2.24" dependencies = [ "anyhow", "arc-swap", @@ -3092,7 +3092,7 @@ dependencies = [ [[package]] name = "nzb-postproc" -version = "0.2.7" +version = "0.2.8" dependencies = [ "anyhow", "nzb-core", @@ -3108,7 +3108,7 @@ dependencies = [ [[package]] name = "nzb-web" -version = "0.4.21" +version = "0.4.22" dependencies = [ "anyhow", "arc-swap", diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 907c66d0..ed50e9d7 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -22,6 +22,27 @@ format and must point to a commit reachable from `main`. GitHub generates release notes from the tagged history. Review them before publishing; do not add version-specific release-note files to the repository. +## Standalone nzb-* crates + +The seven `nzb-*` crates under `crates/` are published to crates.io from +standalone repositories (one per crate, named in `ci/nzb-crates.txt`, URL in +each crate's `repository` field). This monorepo is the head: + +- **The monorepo owns version numbers.** Change a crate's `version` only in + `crates//Cargo.toml`; the standalone repos never originate a bump. + Keep each new version strictly above the crates.io maximum for that crate. +- **Standalone repos are sync targets.** `ci/tasks/sync-crate ` exports + `crates/` at `HEAD` and opens a pull request on that crate's standalone + repo. It never publishes and never pushes to the monorepo. +- **Publish only from a synced tag.** After a sync PR merges, publish that + crate from a `vX.Y.Z` tag on the standalone repo — never from an out-of-sync + tree. +- **Drift is gated.** The scheduled `crate-drift` job + (`.github/workflows/quality-schedule.yml`, via `ci/tasks/crate-drift`) fails + when any standalone repo's `src/` or package version differs from the + monorepo. A failure means a crate changed here without a sync; run + `ci/tasks/sync-crate `, merge, and publish from the tag. + ## Rollback Do not move or replace a published tag. Revert the faulty change on `main`,