diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb25072..41abb0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,33 +146,10 @@ jobs: - run: mix compile --warnings-as-errors - run: mix docs --warnings-as-errors - - name: Build and inspect unpacked Hex package - run: | - package_dir=$(mktemp -d) - trap 'rm -rf "$package_dir"' EXIT - mix hex.build --unpack --output "$package_dir" - - for required in lib/replicant/session_identity.ex docs/adr/0007-actual-replication-session-identity.md .formatter.exs mix.exs README.md LICENSE NOTICE CHANGELOG.md usage-rules.md; do - test -e "$package_dir/$required" || { - echo "::error::Missing package path: $required" - exit 1 - } - done - - test ! -e "$package_dir/AGENTS.md" || { - echo "::error::Package contains the private contributor-agent contract" - exit 1 - } - - if grep -nH 'AGENTS\.md' "$package_dir/README.md" "$package_dir/usage-rules.md" "$package_dir/CONTRIBUTING.md" "$package_dir/notebooks/getting_started.livemd"; then - echo "::error::Published documentation references the excluded AGENTS.md contract" - exit 1 - fi - - if find "$package_dir" -type d \( -name test -o -name .forge -o -name _build -o -name superpowers \) -print -quit | grep -q .; then - echo "::error::Package contains test, lifecycle, Forge, or build residue" - exit 1 - fi - - grep -q 'handle_session_identity' "$package_dir/lib/replicant/sink.ex" - grep -q 'IDENTIFY_SYSTEM' "$package_dir/lib/replicant/query_builder.ex" + # Build throwaway bytes from the exact commit and run the complete package workflow without + # retaining a candidate: exact manifest, Hex validation, artifact-derived audit/compile/docs, + # fresh-consumer provenance + semantic smoke, immutable witness, strict collision checks, and + # credential-free exact-byte uploader dry-run. The real candidate is minted once, from merged + # main, after this review set and all matrix rows settle. + - name: Prove the complete package workflow without minting a candidate + run: BUILD_DATE=ci bash scripts/release/build_candidate.sh --check diff --git a/.gitignore b/.gitignore index 092a6af..8a9d33b 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ priv/plts/ /.zcode/ /.agent/ /.aider/ +/.kimosabe/ # Superpowers lifecycle artifacts (specs, plans, handoffs, reviews) — local only, never tracked /docs/superpowers/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 751f93d..64b738c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.2.0] - 2026-08-19 + ### Added - **Proven support for PostgreSQL 15, 16, 17, and 18, with version-gated capabilities.** The CI @@ -50,6 +52,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`wal_status = 'lost'` is PG15's sole invalidation signal). Proven red-first at the unit level and verified against live PostgreSQL 15/16/17/18. +- **An unknown checkpoint with an absent replication slot now halts fail-closed instead of + silently creating a fresh slot (data-integrity, fail-closed).** In sink-owned mode a + checkpoint read fault (`sink.checkpoint/0` raising or erroring) reads as `checkpoint_lsn 0` + with `checkpoint_state: :fault`. The §14.15 streaming fail-open (resume-from-0, the + idempotent sink dedups the re-stream) is safe only when the slot is **present** — a resume + clamps to the slot's server-side `confirmed_flush_lsn`, so nothing is skipped. With the slot + **absent** there is nothing to resume: the connect path previously treated the fault-as-0 as + a genuine empty first run and created a fresh `CREATE_REPLICATION_SLOT`, which begins + streaming at its own creation LSN and silently skips every transaction between the (unknown) + real checkpoint and now — an unrecoverable data gap. That path now halts fail-closed in the + `:data_gap` family with a distinct, value-free telemetry reason + (`[:replicant, :connection, :slot_invalidated]`, `reason: :checkpoint_unknown`) and never + emits `CREATE_REPLICATION_SLOT`, including in incremental-snapshot mode when its separate + progress token is empty. A genuinely **empty** checkpoint (`checkpoint_state: :empty` — a real + first activation / go-forward) still creates the slot as before. Covered by red-first + connect-decision unit tests across plain and incremental modes and a live PostgreSQL fault probe + (raising-checkpoint sink, absent slot → structural halt, zero slots created on the server). + ### Security - **Telemetry metadata and measurements are now validated by a closed key set AND a per-key @@ -96,26 +116,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 field and is absent here) so the replay is a durable no-op, agreeing with `README.md` and ADR-0001; the `handle_message/2` docstring states the same. -### Fixed - -- **An unknown checkpoint with an absent replication slot now halts fail-closed instead of - silently creating a fresh slot (data-integrity, fail-closed).** In sink-owned mode a - checkpoint read fault (`sink.checkpoint/0` raising or erroring) reads as `checkpoint_lsn 0` - with `checkpoint_state: :fault`. The §14.15 streaming fail-open (resume-from-0, the - idempotent sink dedups the re-stream) is safe only when the slot is **present** — a resume - clamps to the slot's server-side `confirmed_flush_lsn`, so nothing is skipped. With the slot - **absent** there is nothing to resume: the connect path previously treated the fault-as-0 as - a genuine empty first run and created a fresh `CREATE_REPLICATION_SLOT`, which begins - streaming at its own creation LSN and silently skips every transaction between the (unknown) - real checkpoint and now — an unrecoverable data gap. That path now halts fail-closed in the - `:data_gap` family with a distinct, value-free telemetry reason - (`[:replicant, :connection, :slot_invalidated]`, `reason: :checkpoint_unknown`) and never - emits `CREATE_REPLICATION_SLOT`, including in incremental-snapshot mode when its separate - progress token is empty. A genuinely **empty** checkpoint (`checkpoint_state: :empty` — a real - first activation / go-forward) still creates the slot as before. Covered by red-first - connect-decision unit tests across plain and incremental modes and a live PostgreSQL fault probe - (raising-checkpoint sink, absent slot → structural halt, zero slots created on the server). - ## [1.1.0] - 2026-08-13 ### Fixed @@ -558,7 +558,8 @@ against a real-PG16 crash-injection suite (loss = 0, effect-dup = 0). **permanent** fail-closed halt (operator restart required), not auto-retry (spec §6 / §14.18). -[Unreleased]: https://github.com/baselabs/replicant/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/baselabs/replicant/compare/v1.2.0...HEAD +[1.2.0]: https://github.com/baselabs/replicant/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/baselabs/replicant/compare/v1.0.0...v1.1.0 [1.0.0]: https://github.com/baselabs/replicant/compare/v0.3.1...v1.0.0 [0.3.1]: https://github.com/baselabs/replicant/compare/v0.3.0...v0.3.1 diff --git a/README.md b/README.md index bfb6ea4..2cf2920 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,11 @@ consumer sibling to [`arcadic`](https://github.com/baselabs/arcadic). Multitenancy, classification, and Ash resources live one layer up, in the [`ash_replicant`](https://hex.pm/packages/ash_replicant) sink adapter. -> **Status:** 1.1.0 is published on Hex (tagged `v1.1.0`). Replicant owns +> **Status:** 1.1.0 is the latest release published on Hex (tagged `v1.1.0`). 1.2.0 +> is the prepared release candidate — it adds proven PostgreSQL 15–18 support, the typed +> `handle_slot_origin/2` callback, typed-shape telemetry validation, and a value-safety +> regression suite (see CHANGELOG `[1.2.0]`) — and is **not yet published or tagged**. +> Replicant owns > the replication slot via `Postgrex.ReplicationConnection`, acks only after the > sink durably commits (ack-after-checkpoint), halts fail-closed on slot > invalidation, and is proven by a real-PG16 crash-injection suite diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 218384e..b79fded 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # Replicant — Feature Tracker -**Updated:** 2026-08-13 · **Release:** `v1.1.0` (tagged) · **Branch:** `main` +**Updated:** 2026-08-19 · **Latest published:** `v1.1.0` (tagged) · **Candidate:** `1.2.0` (built, unpublished) · **Branch:** `main` > **⚠ Commit-SHA note.** Git history was rewritten after most of this file was > written, so the historical commit SHAs cited in the slice rows below (e.g. @@ -15,8 +15,14 @@ The initial sequencing plan below is complete, and both packages have continued through later releases: -- **`replicant` 1.1.0 is published and tagged** `v1.1.0` (a post-1.0 hardening patch — - see CHANGELOG `[1.1.0]`; 1.0.0 shipped at `v1.0.0`). +- **`replicant` 1.1.0 is the latest published release**, tagged `v1.1.0` (a post-1.0 + hardening patch — see CHANGELOG `[1.1.0]`; 1.0.0 shipped at `v1.0.0`). +- **`replicant` 1.2.0 is a built, verified release candidate — not yet published or + tagged.** It carries the R01–R05 fixes (fail-closed unknown-checkpoint halt, typed + telemetry shapes, logical-message value-safety, the `handle_slot_origin/2` callback, + and proven PostgreSQL 15–18 support); see CHANGELOG `[1.2.0]`. Publication and tagging + require separate explicit human authorization naming the exact version and artifact + digest. - **`ash_replicant` 0.4.0 is published and tagged** `v0.4.0` at `d4e9457`. Its current `main` at `197fca18594a4dbb20e57bc065ef78f30133ae5c` now consumes Replicant `>= 1.0.0 and < 2.0.0-0`, locks 1.1.0, and independently tests exact diff --git a/mix.exs b/mix.exs index 02b9e54..0aec44d 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Replicant.MixProject do use Mix.Project - @version "1.1.0" + @version "1.2.0" @source_url "https://github.com/baselabs/replicant" def project do diff --git a/scripts/release/build_candidate.sh b/scripts/release/build_candidate.sh new file mode 100755 index 0000000..68b925c --- /dev/null +++ b/scripts/release/build_candidate.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# Build and prove package bytes from an exact commit. `--check` uses only throwaway bytes; +# the default mint mode is allowed only on clean main equal to live origin/main. +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" + +mode="mint" +if [[ "${1:-}" == "--check" ]]; then + mode="check" + shift +fi +[[ $# -eq 0 ]] || { echo "usage: build_candidate.sh [--check]" >&2; exit 2; } + +log() { echo "build_candidate: $*" >&2; } +die() { echo "::error::build_candidate: $*" >&2; exit 1; } + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'; + else shasum -a 256 "$1" | awk '{print $1}'; fi +} + +[[ -z "$(git status --porcelain)" ]] || die "working tree is dirty" +commit="$(git rev-parse HEAD)" +version="$(grep -oE '@version "[^"]+"' mix.exs | head -1 | sed -E 's/@version "([^"]+)"/\1/')" +[[ -n "$version" ]] || die "could not read package version" + +if [[ "$mode" == "mint" ]]; then + [[ "$(git branch --show-current)" == "main" ]] || die "candidate mint is allowed only on main" + + if remote_main="$(git ls-remote --exit-code origin refs/heads/main 2>/dev/null)"; then + remote_main="$(printf '%s' "$remote_main" | awk '{print $1}')" + [[ "$remote_main" == "$commit" ]] || die "HEAD $commit does not equal live origin/main $remote_main" + else + die "could not prove live origin/main identity" + fi +fi + +elixir -r "$repo_root/scripts/release/package_identity.exs" \ + -e 'Replicant.PackageIdentity.verify_candidate!(hd(System.argv()))' -- "$version" + +build_tree="$(mktemp -d "${TMPDIR:-/tmp}/replicant-package.XXXXXX")" +witness_ref="" +witness_oid="" +primary="" +by_digest="" +backup="" +receipt="" +artifacts_retained=0 +receipt_retained=0 +witness_owned=0 +mint_complete=0 +cleanup() { + status=$? + cleanup_failed=0 + trap - EXIT + + if [[ $witness_owned -eq 1 && ( "$mode" == "check" || $mint_complete -eq 0 ) ]]; then + if ! git update-ref -d "$witness_ref" "$witness_oid" >/dev/null 2>&1; then + echo "::error::build_candidate: package witness cleanup failed" >&2 + cleanup_failed=1 + fi + fi + + if [[ "$mode" == "mint" && $mint_complete -eq 0 ]]; then + if [[ $receipt_retained -eq 1 ]]; then + chmod u+w "$receipt" >/dev/null 2>&1 || true + rm -f -- "$receipt" + fi + + if [[ $artifacts_retained -eq 1 ]]; then + for target in "$primary" "$by_digest" "$backup"; do + chmod u+w "$target" >/dev/null 2>&1 || true + rm -f -- "$target" + done + fi + fi + + rm -rf "$build_tree" + if [[ $status -eq 0 && $cleanup_failed -eq 1 ]]; then + status=1 + fi + exit "$status" +} +trap cleanup EXIT + +source_archive="$build_tree/source.tar" +git archive --format=tar --output="$source_archive" "$commit" +tar -xf "$source_archive" -C "$build_tree" +staged_tar="$build_tree/replicant-$version.tar" +( cd "$build_tree" && MIX_ENV=dev MIX_BUILD_PATH="$build_tree/_build" mix hex.build --output "$staged_tar" >/dev/null ) +[[ -f "$staged_tar" ]] || die "mix hex.build produced no tarball" + +digest="$(sha256_of "$staged_tar")" +size="$(wc -c < "$staged_tar" | tr -d ' ')" +lock_digest="$(sha256_of "$build_tree/mix.lock")" +verification="$build_tree/verification.txt" + +EXPECTED_VERSION="$version" bash "$repo_root/scripts/release/verify_package.sh" "$staged_tar" >&2 +bash "$repo_root/scripts/release/consume_candidate.sh" "$staged_tar" "$verification" >&2 + +if [[ "$mode" == "check" ]]; then + artifacts_dir="$build_tree/artifacts" + witness_ref="refs/attestations/checks/replicant/$version-$commit" +else + artifacts_dir="$repo_root/.kimosabe/artifacts" + witness_ref="refs/attestations/packages/replicant/$version" +fi + +primary="$artifacts_dir/replicant-$version.tar" +by_digest="$artifacts_dir/by-digest/$digest.tar" +backup="$artifacts_dir/backups/replicant-$version.tar" +receipt="$artifacts_dir/replicant-$version-receipt.txt" + +for target in "$primary" "$by_digest" "$backup" "$receipt"; do + [[ ! -e "$target" ]] || die "refusing to overwrite existing package evidence: $target" +done +if git show-ref --verify --quiet "$witness_ref"; then + die "package witness already exists: $witness_ref" +fi + +receipt_tmp="$build_tree/receipt.txt" +{ + echo "Replicant package candidate receipt" + echo "version: $version" + echo "source_commit: $commit" + echo "artifact: $primary" + echo "artifact_backup: $backup" + echo "artifact_by_digest: $by_digest" + echo "size_bytes: $size" + echo "sha256: $digest" + echo "source_mix_lock_sha256: $lock_digest" + echo "elixir: $(elixir --version 2>/dev/null | tail -1)" + echo "built_at: ${BUILD_DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}" + echo "published: NO" + echo "" + cat "$verification" +} > "$receipt_tmp" + +elixir -r "$repo_root/scripts/release/package_witness.exs" \ + -e 'Replicant.PackageWitness.retain_copies!(hd(System.argv()), tl(System.argv()))' -- \ + "$staged_tar" "$primary" "$by_digest" "$backup" +artifacts_retained=1 + +elixir -r "$repo_root/scripts/release/package_witness.exs" \ + -e 'Replicant.PackageWitness.retain_copies!(hd(System.argv()), tl(System.argv()))' -- \ + "$receipt_tmp" "$receipt" +receipt_retained=1 + +elixir -r "$repo_root/scripts/release/package_witness.exs" \ + -e 'Replicant.PackageWitness.verify_copies!(tl(System.argv()), hd(System.argv()))' -- \ + "$digest" "$primary" "$by_digest" "$backup" + +witness_oid="$(elixir -r "$repo_root/scripts/release/package_witness.exs" \ + -e 'IO.puts(Replicant.PackageWitness.create!(Enum.at(System.argv(), 0), Enum.at(System.argv(), 1), Enum.at(System.argv(), 2), Enum.at(System.argv(), 3)))' -- \ + "$repo_root" "$witness_ref" "$commit" "$receipt" +)" +witness_owned=1 + +mix run --no-start "$repo_root/scripts/release/upload_candidate.exs" \ + --artifact "$primary" --receipt "$receipt" --witness-ref "$witness_ref" + +mint_complete=1 + +if [[ "$mode" == "check" ]]; then + log "CHECK PASS — throwaway package built, audited, documented, consumed, and uploader dry-run verified" +else + log "MINT PASS — immutable candidate retained and witnessed at $witness_ref" +fi +echo "$digest $primary" diff --git a/scripts/release/consume_candidate.sh b/scripts/release/consume_candidate.sh new file mode 100755 index 0000000..c136236 --- /dev/null +++ b/scripts/release/consume_candidate.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Prove the retained Replicant candidate from a FRESH, external consumer that touches only the +# extracted package bytes — not the repository checkout, not the repo's _build. +# +# Steps: Hex-checksum-validate + unpack the exact retained tar -> build a brand-new Mix consumer +# whose only Replicant source is the extraction -> clear MIX_PATH/ERL_LIBS -> deps.get -> compile +# with warnings-as-errors -> run a semantic R01-R05 smoke that asserts the loaded Replicant BEAM +# resolves under the scratch tree. A missing/corrupt packaged file is invisible from the checkout; +# it is caught here. +# +# Usage: consume_candidate.sh [TARBALL] [VERIFICATION_RECEIPT] +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +log() { echo "consume_candidate: $*" >&2; } +die() { echo "::error::consume_candidate: $*" >&2; exit 1; } + +version="$(grep -oE '@version "[^"]+"' "$repo_root/mix.exs" | head -1 | sed -E 's/@version "([^"]+)"/\1/')" +tarball="${1:-$repo_root/.kimosabe/artifacts/replicant-$version.tar}" +verification_receipt="${2:-}" +[[ -f "$tarball" ]] || die "candidate tarball not found: $tarball (build it first with build_candidate.sh)" + +scratch="$(mktemp -d "${TMPDIR:-/tmp}/replicant-consume.XXXXXX")" +scratch="$(cd "$scratch" && pwd -P)" +cleanup() { rm -rf "$scratch"; } +trap cleanup EXIT +src="$scratch/replicant_src" +consumer="$scratch/consumer" + +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}'; + else shasum -a 256 "$1" | awk '{print $1}'; fi +} + +# --- 1. Hex-checksum-validated unpack of the EXACT retained tar (no rebuild). ---------------- +( cd "$repo_root" && mix run --no-start scripts/release/unpack_validated.exs "$tarball" "$src" ) >&2 +[[ -f "$src/mix.exs" ]] || die "extraction produced no mix.exs at $src" + +# --- 2. Audit, compile, and render docs from the exact extracted package source. --------------- +log "auditing and building the extracted package source" +( + cd "$src" + env -u MIX_PATH -u ERL_LIBS -u REPLICANT_TEST_URL MIX_ENV=dev mix deps.get >&2 + env -u MIX_PATH -u ERL_LIBS -u REPLICANT_TEST_URL MIX_ENV=dev mix audit >&2 + env -u MIX_PATH -u ERL_LIBS -u REPLICANT_TEST_URL MIX_ENV=dev mix compile --warnings-as-errors >&2 + env -u MIX_PATH -u ERL_LIBS -u REPLICANT_TEST_URL MIX_ENV=dev mix docs --warnings-as-errors >&2 + env -u MIX_PATH -u ERL_LIBS -u REPLICANT_TEST_URL MIX_ENV=dev mix deps.tree --format plain > "$scratch/package-deps.txt" +) + +package_lock_digest="$(sha256_of "$src/mix.lock")" +package_deps_digest="$(sha256_of "$scratch/package-deps.txt")" + +# --- 3. A brand-new consumer that depends ONLY on the extracted source. ---------------------- +mkdir -p "$consumer/lib" +cat > "$consumer/mix.exs" < 1.20", deps: deps()] + end + + def application, do: [extra_applications: [:logger]] + + defp deps do + [{:replicant, path: "$src"}] + end +end +EOF + +cat > "$consumer/lib/replicant_consumer_smoke.ex" <<'EOF' +defmodule ReplicantConsumerSmoke do + @moduledoc "Compile-only proof that the extracted Replicant package's public API is usable." + def slot_query(v), do: Replicant.QueryBuilder.slot_invalidation_status("s", v) +end +EOF + +# --- 4. Fresh resolve + compile from a clean environment (no repo _build, no MIX_PATH). ------- +log "building fresh consumer against extracted source $src" +( + cd "$consumer" + env -u MIX_PATH -u ERL_LIBS -u REPLICANT_TEST_URL MIX_ENV=dev \ + mix deps.get >&2 + env -u MIX_PATH -u ERL_LIBS -u REPLICANT_TEST_URL MIX_ENV=dev \ + mix compile --warnings-as-errors >&2 + env -u MIX_PATH -u ERL_LIBS -u REPLICANT_TEST_URL MIX_ENV=dev \ + mix deps.tree --format plain > "$scratch/consumer-deps.txt" + + # Provenance: the resolved dependency source must be the scratch extraction, never the repo. + dep_path="$(env -u MIX_PATH mix run --no-start -e 'IO.puts(Path.expand(Mix.Project.deps_paths()[:replicant]))')" + case "$dep_path" in + "$scratch"/*) : ;; + *) echo "::error::consume_candidate: replicant dep resolved to $dep_path, not under scratch $scratch" >&2; exit 1 ;; + esac + echo "consume_candidate: replicant dep resolves to $dep_path" >&2 + + # --- 5. Semantic R01-R05 smoke from the artifact-derived, freshly compiled modules. -------- + env -u MIX_PATH -u ERL_LIBS -u REPLICANT_TEST_URL MIX_ENV=dev SMOKE_SCRATCH="$scratch" \ + mix run --no-start "$repo_root/scripts/release/consumer_smoke.exs" >&2 +) + +consumer_lock_digest="$(sha256_of "$consumer/mix.lock")" +consumer_deps_digest="$(sha256_of "$scratch/consumer-deps.txt")" +artifact_digest="$(sha256_of "$tarball")" + +if [[ -n "$verification_receipt" ]]; then + { + echo "artifact_sha256: $artifact_digest" + echo "package_lock_sha256: $package_lock_digest" + echo "package_dependency_tree_sha256: $package_deps_digest" + echo "consumer_lock_sha256: $consumer_lock_digest" + echo "consumer_dependency_tree_sha256: $consumer_deps_digest" + echo "package_audit: PASS" + echo "package_compile: PASS" + echo "package_docs: PASS" + echo "consumer_compile: PASS" + echo "consumer_smoke: PASS" + echo "" + echo "package_dependency_tree:" + sed 's/^/ /' "$scratch/package-deps.txt" + echo "consumer_dependency_tree:" + sed 's/^/ /' "$scratch/consumer-deps.txt" + } > "$verification_receipt" +fi + +log "OK — candidate $version consumed from a fresh external project ($tarball)" diff --git a/scripts/release/consumer_smoke.exs b/scripts/release/consumer_smoke.exs new file mode 100644 index 0000000..3feabbb --- /dev/null +++ b/scripts/release/consumer_smoke.exs @@ -0,0 +1,57 @@ +# Exercise the R01-R05 public/runtime contracts from the ARTIFACT-DERIVED, freshly compiled +# Replicant — semantically, not by text grep. Run inside the scratch consumer project (which +# depends only on the extracted package source), so `:code.which/1` must resolve Replicant under +# the scratch tree: a leak of the repo checkout or its _build would fail the provenance guard. +# +# Usage (inside the consumer project): SMOKE_SCRATCH= mix run ../consumer_smoke.exs +scratch = System.get_env("SMOKE_SCRATCH") || raise "SMOKE_SCRATCH not set" + +path = Replicant |> :code.which() |> to_string() + +unless String.starts_with?(path, scratch <> "/") do + raise "provenance: Replicant loaded from #{path}, not under scratch #{scratch} — the consumer is not running the extracted artifact" +end + +# R04 + D2 — public Sink callbacks are present. +cbs = Replicant.Sink.behaviour_info(:callbacks) +unless {:handle_slot_origin, 2} in cbs, do: raise("R04: handle_slot_origin/2 missing from packaged Sink") +unless {:handle_session_identity, 2} in cbs, do: raise("D2: handle_session_identity/2 missing") + +# D2 — the identity query and struct ship. +unless Replicant.QueryBuilder.identify_system() == "IDENTIFY_SYSTEM", do: raise("D2: IDENTIFY_SYSTEM query missing") +_ = %Replicant.SessionIdentity{system_identifier: 1, timeline_id: 1, current_lsn: 0, database: "x"} + +# R02/R03 — the value-free telemetry boundary rejects a wrong-shape value without echoing it. +secret = "SECRET-ROW-VALUE-consumer" + +try do + Replicant.Telemetry.validate!(%{commit_lsn: secret}) + raise "R02: validate! accepted a string LSN" +rescue + e in ArgumentError -> + if String.contains?(Exception.message(e), secret), do: raise("R02/R03: telemetry leaked the value") +end + +# R05 — version-tiered slot-invalidation query. +{:ok, pg15} = Replicant.QueryBuilder.slot_invalidation_status("s", 150_000) +{:ok, pg17} = Replicant.QueryBuilder.slot_invalidation_status("s", 170_000) +if String.contains?(pg15, "conflicting"), do: raise("R05: PG15 query must not select `conflicting`") +unless String.contains?(pg17, "invalidation_reason"), do: raise("R05: PG17 query must select `invalidation_reason`") + +# R01 — unknown checkpoint + absent slot halts fail-closed, never creating a slot. +state = %Replicant.Connection{ + step: :invalidation_check, + slot_name: "audit_slot", + publication: ["audit_pub"], + snapshot: false, + checkpoint_lsn: 0, + checkpoint_state: :fault, + failover: false +} + +case Replicant.Connection.handle_result([%Postgrex.Result{rows: []}], state) do + {:disconnect, :data_gap} -> :ok + other -> raise "R01: fault+absent slot did not halt fail-closed; got #{inspect(other)}" +end + +IO.puts("consumer_smoke: OK — R01-R05 public surface exercised from #{path}") diff --git a/scripts/release/package_checksum.exs b/scripts/release/package_checksum.exs new file mode 100644 index 0000000..1adf0e8 --- /dev/null +++ b/scripts/release/package_checksum.exs @@ -0,0 +1,44 @@ +defmodule Replicant.PackageChecksum do + @moduledoc false + + def classify({:ok, {200, _headers, %{"checksum" => checksum}}}, checksum), do: :ok + + def classify({:ok, {200, _headers, %{"checksum" => actual}}}, expected) + when is_binary(actual) do + {:error, "published checksum #{actual} does not match uploaded artifact #{expected}"} + end + + def classify({:ok, {200, _headers, _body}}, _expected), + do: {:error, "published release response has no checksum"} + + def classify({:ok, {status, _headers, _body}}, _expected), + do: {:error, "published release checksum read returned HTTP #{status}"} + + def classify({:error, _reason}, _expected), + do: {:error, "published release checksum read failed"} + + def verify!(version, expected, key, attempts \\ 5) do + result = + 1..attempts + |> Enum.reduce_while(nil, fn attempt, _last -> + response = apply(Hex.API.Release, :get, ["hexpm", "replicant", version, [key: key]]) + + case classify(response, expected) do + :ok -> + {:halt, :ok} + + {:error, _} = error when attempt == attempts -> + {:halt, error} + + {:error, _} = error -> + Process.sleep(1_000) + {:cont, error} + end + end) + + case result do + :ok -> :ok + {:error, message} -> raise message + end + end +end diff --git a/scripts/release/package_files.manifest b/scripts/release/package_files.manifest new file mode 100644 index 0000000..501e425 --- /dev/null +++ b/scripts/release/package_files.manifest @@ -0,0 +1,51 @@ +.formatter.exs +CHANGELOG.md +CONTRIBUTING.md +LICENSE +NOTICE +README.md +docs/INVARIANTS.md +docs/ROADMAP.md +docs/adr/0001-logical-decoding-messages-delivery-guarantees.md +docs/adr/0002-multi-publication-per-pipeline.md +docs/adr/0003-value-free-error-boundary.md +docs/adr/0004-commit-lsn-transaction-watermark.md +docs/adr/0005-spill-is-ephemeral-scratch.md +docs/adr/0006-fail-closed-supervision.md +docs/adr/0007-actual-replication-session-identity.md +docs/adr/README.md +lib/replicant.ex +lib/replicant/application.ex +lib/replicant/assembler.ex +lib/replicant/assembler/batch.ex +lib/replicant/assembler/streaming.ex +lib/replicant/assembler_server.ex +lib/replicant/casting/array_parser.ex +lib/replicant/casting/types.ex +lib/replicant/change.ex +lib/replicant/checkpoint_store.ex +lib/replicant/config.ex +lib/replicant/connection.ex +lib/replicant/decoder/decoder.ex +lib/replicant/decoder/messages.ex +lib/replicant/decoder/oid_database.ex +lib/replicant/error.ex +lib/replicant/identifier.ex +lib/replicant/pipeline.ex +lib/replicant/query_builder.ex +lib/replicant/schema_change.ex +lib/replicant/session_identity.ex +lib/replicant/sink.ex +lib/replicant/snapshot_progress.ex +lib/replicant/snapshot_window.ex +lib/replicant/snapshotter.ex +lib/replicant/snapshotter/incremental.ex +lib/replicant/spill.ex +lib/replicant/spill/error.ex +lib/replicant/spill/reader.ex +lib/replicant/supervisor.ex +lib/replicant/telemetry.ex +lib/replicant/transaction.ex +mix.exs +notebooks/getting_started.livemd +usage-rules.md diff --git a/scripts/release/package_identity.exs b/scripts/release/package_identity.exs new file mode 100644 index 0000000..f19f3e4 --- /dev/null +++ b/scripts/release/package_identity.exs @@ -0,0 +1,171 @@ +defmodule Replicant.PackageIdentity do + @moduledoc false + + @repo_root Path.expand("../..", __DIR__) + @github_release_url "https://api.github.com/repos/baselabs/replicant/releases/tags/" + @hex_release_url "https://hex.pm/api/packages/replicant/releases/" + + def check_candidate(version, runner \\ &run/2) do + tag = "v#{version}" + + with :ok <- absent_local_tag(tag, runner), + :ok <- absent_remote_tag(tag, runner), + :ok <- absent_http(@github_release_url <> tag, "GitHub release", runner), + :ok <- absent_http(@hex_release_url <> version, "Hex release", runner) do + :ok + end + end + + def check_publish(version, source_commit, runner \\ &run/2) do + tag = "v#{version}" + + with :ok <- matching_local_tag(tag, source_commit, runner), + :ok <- matching_remote_tag(tag, source_commit, runner), + :ok <- absent_http(@github_release_url <> tag, "GitHub release", runner), + :ok <- absent_http(@hex_release_url <> version, "Hex release", runner) do + :ok + end + end + + def verify_candidate!(version), do: check_candidate(version) |> unwrap!() + + def verify_publish!(version, source_commit), + do: check_publish(version, source_commit) |> unwrap!() + + defp absent_local_tag(tag, runner) do + case runner.("git", ["rev-parse", "-q", "--verify", "refs/tags/#{tag}"]) do + {_, 1} -> + :ok + + {_, 0} -> + {:error, "local tag #{tag} already exists"} + + {output, status} -> + {:error, "local tag check failed (exit #{status}): #{structural(output)}"} + end + end + + defp absent_remote_tag(tag, runner) do + case runner.("git", ["ls-remote", "--exit-code", "--tags", "origin", "refs/tags/#{tag}"]) do + {_, 2} -> + :ok + + {_, 0} -> + {:error, "remote tag #{tag} already exists on origin"} + + {output, status} -> + {:error, "remote tag check failed (exit #{status}): #{structural(output)}"} + end + end + + defp matching_local_tag(tag, commit, runner) do + case runner.("git", ["rev-parse", "refs/tags/#{tag}^{}"]) do + {output, 0} -> + compare_tag("local", tag, String.trim(output), commit) + + {output, status} -> + {:error, "local tag #{tag} is unavailable (exit #{status}): #{structural(output)}"} + end + end + + defp matching_remote_tag(tag, commit, runner) do + args = [ + "ls-remote", + "--exit-code", + "--tags", + "origin", + "refs/tags/#{tag}", + "refs/tags/#{tag}^{}" + ] + + case runner.("git", args) do + {output, 0} -> + resolved = remote_tag_commit(output, tag) + + if is_nil(resolved) do + {:error, "remote tag #{tag} returned no parseable commit"} + else + compare_tag("remote", tag, resolved, commit) + end + + {output, status} -> + {:error, "remote tag #{tag} is unavailable (exit #{status}): #{structural(output)}"} + end + end + + defp remote_tag_commit(output, tag) do + refs = + output + |> String.split("\n", trim: true) + |> Enum.map(&String.split(&1, "\t", parts: 2)) + |> Map.new(fn [sha, ref] -> {ref, sha} end) + + refs["refs/tags/#{tag}^{}"] || refs["refs/tags/#{tag}"] + end + + defp compare_tag(_location, _tag, commit, commit), do: :ok + + defp compare_tag(location, tag, actual, expected) do + {:error, "#{location} tag #{tag} resolves to #{actual}, expected recorded source #{expected}"} + end + + defp absent_http(url, label, runner) do + args = [ + "--silent", + "--show-error", + "--connect-timeout", + "10", + "--max-time", + "30", + "--output", + "/dev/null", + "--write-out", + "%{http_code}", + url + ] + + case runner.("curl", args) do + {"404", 0} -> + :ok + + {"200", 0} -> + {:error, "#{label} already exists"} + + {status, 0} -> + {:error, "#{label} check returned HTTP #{String.trim(status)}"} + + {output, exit_status} -> + {:error, "#{label} check failed (exit #{exit_status}): #{structural(output)}"} + end + end + + @doc false + def run(command, args, timeout_ms \\ 30_000) do + case System.find_executable(command) do + nil -> + {"required command unavailable", 127} + + executable -> + task = + Task.async(fn -> + System.cmd(executable, args, cd: @repo_root, stderr_to_stdout: true) + end) + + case Task.yield(task, timeout_ms) do + {:ok, result} -> + result + + nil -> + Task.shutdown(task, :brutal_kill) + {"command timed out", 124} + end + end + end + + defp structural(output) do + if String.trim(output) == "", do: "no output", else: "command returned output" + end + + defp unwrap!(:ok), do: :ok + defp unwrap!({:error, message}), do: raise(message) +end diff --git a/scripts/release/package_publisher.exs b/scripts/release/package_publisher.exs new file mode 100644 index 0000000..9a50e08 --- /dev/null +++ b/scripts/release/package_publisher.exs @@ -0,0 +1,52 @@ +unless Code.ensure_loaded?(Replicant.PackageChecksum) do + Code.require_file("package_checksum.exs", __DIR__) +end + +defmodule Replicant.PackagePublisher do + @moduledoc false + + def publish(version, tar_bytes, opts \\ []) do + digest = sha256(tar_bytes) + expected = "#{version}:#{digest}" + env = Keyword.get(opts, :env, &System.get_env/1) + release_api = Keyword.get(opts, :release_api, Hex.API.Release) + checksum = Keyword.get(opts, :checksum, Replicant.PackageChecksum) + authorization = env.("REPLICANT_PUBLISH_AUTHORIZED") + key = env.("HEX_API_KEY") + + cond do + authorization != expected -> + {:error, + "--publish requires exact version:digest authorization for the witnessed artifact"} + + key in [nil, ""] -> + {:error, "HEX_API_KEY not set"} + + true -> + publish_exact(release_api, checksum, version, tar_bytes, digest, key) + end + end + + defp publish_exact(release_api, checksum, version, tar_bytes, digest, key) do + response = + apply(release_api, :publish, ["hexpm", tar_bytes, [key: key], fn _ -> nil end, false]) + + case response do + {:ok, {status, _, _}} when status in 200..299 -> + case apply(checksum, :verify!, [version, digest, key]) do + :ok -> {:ok, digest} + _ -> {:error, "published release checksum verification failed"} + end + + {:ok, {status, _, _}} -> + {:error, "publish failed with HTTP #{status}"} + + {:error, _reason} -> + {:error, "publish failed before a successful HTTP response"} + end + rescue + _error -> {:error, "published release checksum verification failed"} + end + + defp sha256(bytes), do: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower) +end diff --git a/scripts/release/package_witness.exs b/scripts/release/package_witness.exs new file mode 100644 index 0000000..c864e82 --- /dev/null +++ b/scripts/release/package_witness.exs @@ -0,0 +1,283 @@ +defmodule Replicant.PackageWitness do + @moduledoc false + + import Bitwise, only: [band: 2] + + @zero String.duplicate("0", 40) + + def create(repo, ref, source_commit, receipt_path) do + receipt = File.read!(receipt_path) + + with :ok <- receipt_commit_matches(receipt, source_commit), + :ok <- commit_exists(repo, source_commit), + {:ok, witness} <- create_commit(repo, source_commit, receipt_path, receipt) do + create_ref_and_verify(repo, ref, witness, receipt_path) + end + end + + def create!(repo, ref, source_commit, receipt_path) do + case create(repo, ref, source_commit, receipt_path) do + {:ok, witness} -> witness + {:error, message} -> raise message + end + end + + @doc false + def delete_ref(repo, ref, expected_witness) do + case System.cmd("git", ["update-ref", "-d", ref, expected_witness], + cd: repo, + stderr_to_stdout: true + ) do + {_, 0} -> :ok + {_, _} -> {:error, "package witness moved; refusing cleanup"} + end + end + + def verify(repo, ref, artifact_path, receipt_path) do + receipt = File.read!(receipt_path) + artifact = if artifact_path, do: File.read!(artifact_path), else: nil + + verify_content(repo, ref, artifact, receipt) + end + + def verify_content(repo, ref, artifact, receipt) do + with {:ok, witnessed} <- git_output(repo, ["show", "#{ref}:candidate-receipt.txt"]), + :ok <- exact_receipt(witnessed, receipt), + {:ok, parent} <- git_output(repo, ["rev-parse", "#{ref}^"]), + {:ok, commit} <- receipt_value(receipt, "source_commit", ~r/^[0-9a-f]{40}$/), + :ok <- exact_parent(String.trim(parent), commit), + :ok <- verify_artifact(artifact, receipt) do + :ok + end + end + + def verify!(repo, ref, artifact_path, receipt_path) do + case verify(repo, ref, artifact_path, receipt_path) do + :ok -> :ok + {:error, message} -> raise message + end + end + + def verify_copies(paths, expected_digest) do + Enum.reduce_while(paths, :ok, fn path, :ok -> + cond do + not File.regular?(path) -> + {:halt, {:error, "retained copy missing or non-regular: #{path}"}} + + sha256(File.read!(path)) != expected_digest -> + {:halt, {:error, "retained copy digest mismatch: #{path}"}} + + true -> + {:cont, :ok} + end + end) + end + + def verify_copies!(paths, expected_digest) do + case verify_copies(paths, expected_digest) do + :ok -> :ok + {:error, message} -> raise message + end + end + + def retain_copies!(source, destinations) do + bytes = File.read!(source) + + Enum.reduce(destinations, [], fn destination, created -> + try do + File.mkdir_p!(Path.dirname(destination)) + retain_copy!(destination, bytes) + [destination | created] + rescue + error -> + remove_created(created) + reraise error, __STACKTRACE__ + end + end) + + :ok + end + + def receipt_source_commit(receipt_path) do + receipt_path |> File.read!() |> receipt_value("source_commit", ~r/^[0-9a-f]{40}$/) + end + + def receipt_source_commit_content(receipt) do + receipt_value(receipt, "source_commit", ~r/^[0-9a-f]{40}$/) + end + + def read_immutable(path) do + case File.lstat(path) do + {:ok, %File.Stat{type: :regular, mode: mode}} -> + if band(mode, 0o222) == 0 do + File.read(path) + else + {:error, "uploader input must be read-only: #{path}"} + end + + {:ok, _stat} -> + {:error, "uploader input must be a regular file: #{path}"} + + {:error, _reason} -> + {:error, "uploader input is missing or unreadable: #{path}"} + end + end + + defp create_commit(repo, source_commit, receipt_path, _receipt) do + scratch = + Path.join( + System.tmp_dir!(), + "replicant-package-witness-#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(scratch) + + try do + tree_input = Path.join(scratch, "tree") + message = Path.join(scratch, "message") + File.write!(message, "Replicant package witness\n") + + with {:ok, blob} <- git_output(repo, ["hash-object", "-w", receipt_path]), + :ok <- + File.write(tree_input, "100644 blob #{String.trim(blob)}\tcandidate-receipt.txt\n"), + {:ok, tree} <- git_mktree(repo, tree_input), + {:ok, witness} <- + git_output(repo, [ + "commit-tree", + String.trim(tree), + "-p", + source_commit, + "-F", + message + ]) do + {:ok, String.trim(witness)} + end + after + File.rm_rf!(scratch) + end + end + + defp remove_created(paths) do + Enum.each(paths, fn path -> + File.chmod(path, 0o600) + File.rm(path) + end) + end + + defp retain_copy!(destination, bytes) do + case File.open(destination, [:write, :binary, :exclusive]) do + {:ok, io} -> + try do + try do + :ok = IO.binwrite(io, bytes) + after + File.close(io) + end + + File.chmod!(destination, 0o444) + rescue + error -> + remove_created([destination]) + reraise error, __STACKTRACE__ + end + + {:error, :eexist} -> + raise "refusing to overwrite retained package copy: #{destination}" + + {:error, reason} -> + raise "could not retain package copy #{destination}: #{inspect(reason)}" + end + end + + defp create_ref(repo, ref, witness) do + case System.cmd("git", ["update-ref", ref, witness, @zero], cd: repo, stderr_to_stdout: true) do + {_, 0} -> :ok + {_, _} -> {:error, "package witness ref already exists or could not be created: #{ref}"} + end + end + + defp create_ref_and_verify(repo, ref, witness, receipt_path) do + with :ok <- create_ref(repo, ref, witness) do + case verify(repo, ref, nil, receipt_path) do + :ok -> + {:ok, witness} + + {:error, _message} = error -> + case delete_ref(repo, ref, witness) do + :ok -> error + {:error, _cleanup} -> {:error, "package witness verification and cleanup failed"} + end + end + end + end + + defp commit_exists(repo, commit) do + case System.cmd("git", ["cat-file", "-e", "#{commit}^{commit}"], + cd: repo, + stderr_to_stdout: true + ) do + {_, 0} -> :ok + {_, _} -> {:error, "recorded source commit is not present"} + end + end + + defp receipt_commit_matches(receipt, commit) do + case receipt_value(receipt, "source_commit", ~r/^[0-9a-f]{40}$/) do + {:ok, ^commit} -> :ok + {:ok, _} -> {:error, "receipt source commit does not match witness parent"} + error -> error + end + end + + defp exact_receipt(receipt, receipt), do: :ok + defp exact_receipt(_, _), do: {:error, "receipt does not match immutable witness"} + + defp exact_parent(commit, commit), do: :ok + defp exact_parent(_, _), do: {:error, "witness parent does not match receipt source commit"} + + defp verify_artifact(nil, _receipt), do: :ok + + defp verify_artifact(bytes, receipt) do + with {:ok, recorded} <- receipt_value(receipt, "sha256", ~r/^[0-9a-f]{64}$/) do + if sha256(bytes) == recorded, + do: :ok, + else: {:error, "candidate artifact digest does not match witnessed receipt"} + end + end + + defp receipt_value(receipt, key, format) do + case Regex.run(~r/^#{Regex.escape(key)}:\s*(\S+)\s*$/m, receipt) do + [_, value] -> + if value =~ format, do: {:ok, value}, else: {:error, "receipt has malformed #{key}"} + + _ -> + {:error, "receipt has no #{key}"} + end + end + + defp git_output(repo, args) do + env = [ + {"GIT_AUTHOR_NAME", "Replicant Package Witness"}, + {"GIT_AUTHOR_EMAIL", "replicant@example.invalid"}, + {"GIT_COMMITTER_NAME", "Replicant Package Witness"}, + {"GIT_COMMITTER_EMAIL", "replicant@example.invalid"} + ] + + case System.cmd("git", args, cd: repo, env: env, stderr_to_stdout: true) do + {output, 0} -> {:ok, output} + {_, _} -> {:error, "package witness is missing or unreadable"} + end + end + + defp git_mktree(repo, input_path) do + case System.cmd("sh", ["-c", "git mktree < \"$1\"", "sh", input_path], + cd: repo, + stderr_to_stdout: true + ) do + {output, 0} -> {:ok, output} + {_, _} -> {:error, "could not create package witness tree"} + end + end + + defp sha256(bytes), do: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower) +end diff --git a/scripts/release/unpack_validated.exs b/scripts/release/unpack_validated.exs new file mode 100644 index 0000000..a3237fc --- /dev/null +++ b/scripts/release/unpack_validated.exs @@ -0,0 +1,34 @@ +# Unpack an EXISTING Hex tarball with Hex checksum validation — NOT a rebuild. +# +# `mix hex.build --unpack` rebuilds the package in memory and unpacks THAT, so it cannot inspect a +# retained artifact. `:mix_hex_tarball.unpack/2` reads the exact tar bytes, recomputes the inner +# checksum, and compares it to the tar's CHECKSUM member — a tampered payload fails closed here. +# +# Usage: mix run scripts/release/unpack_validated.exs +Mix.ensure_application!(:hex) + +[tar, dest] = System.argv() +bin = File.read!(tar) +File.rm_rf!(dest) +File.mkdir_p!(dest) + +case :mix_hex_tarball.unpack(bin, String.to_charlist(dest)) do + {:ok, %{outer_checksum: checksum}} when is_binary(checksum) and byte_size(checksum) > 0 -> + encoded = Base.encode16(checksum, case: :lower) + + IO.puts( + "unpack_validated: OK — Hex checksum validated, extracted to #{dest} (outer_checksum #{encoded})" + ) + + {:ok, _meta} -> + IO.puts(:stderr, "::error::unpack_validated: Hex validation returned no outer checksum") + System.halt(1) + + {:error, reason} -> + IO.puts( + :stderr, + "::error::unpack_validated: Hex checksum validation FAILED: #{inspect(reason)}" + ) + + System.halt(1) +end diff --git a/scripts/release/upload_candidate.exs b/scripts/release/upload_candidate.exs new file mode 100644 index 0000000..892d6cc --- /dev/null +++ b/scripts/release/upload_candidate.exs @@ -0,0 +1,120 @@ +# Guarded exact-byte uploader for a witnessed Replicant package candidate. +# Dry-run is the default and never reads a credential or sends package bytes. +Mix.ensure_application!(:hex) + +Code.require_file("package_identity.exs", __DIR__) +Code.require_file("package_witness.exs", __DIR__) +Code.require_file("package_publisher.exs", __DIR__) + +defmodule Replicant.UploadCandidate do + @moduledoc false + + @repo_root Path.expand("../..", __DIR__) + + def run(argv) do + {opts, rest, invalid} = + OptionParser.parse(argv, + strict: [publish: :boolean, artifact: :string, receipt: :string, witness_ref: :string] + ) + + if rest != [] or invalid != [], do: abort("invalid arguments") + + version = read_version() + artifacts = Path.join([@repo_root, ".kimosabe", "artifacts"]) + tar = opts[:artifact] || Path.join(artifacts, "replicant-#{version}.tar") + receipt = opts[:receipt] || Path.join(artifacts, "replicant-#{version}-receipt.txt") + witness_ref = opts[:witness_ref] || "refs/attestations/packages/replicant/#{version}" + publish? = opts[:publish] || false + + with {:ok, tar_bytes} <- Replicant.PackageWitness.read_immutable(tar), + {:ok, receipt_bytes} <- Replicant.PackageWitness.read_immutable(receipt), + :ok <- + Replicant.PackageWitness.verify_content( + @repo_root, + witness_ref, + tar_bytes, + receipt_bytes + ), + {:ok, source_commit} <- + Replicant.PackageWitness.receipt_source_commit_content(receipt_bytes), + :ok <- check_metadata(tar_bytes, version), + :ok <- check_identity(version, source_commit, publish?) do + if publish? do + publish(version, tar_bytes) + else + bytes = byte_size(tar_bytes) + + IO.puts(""" + upload_candidate: DRY-RUN — all guards passed, nothing uploaded. + would call: Hex.API.Release.publish("hexpm", <#{bytes} exact witnessed bytes>, [key: ], , false) + credential: NOT read + publication additionally requires --publish and exact version:digest authorization + """) + end + else + {:error, message} -> abort(message) + end + end + + defp read_version do + Path.join(@repo_root, "mix.exs") + |> File.read!() + |> then(&Regex.run(~r/@version "([^"]+)"/, &1)) + |> case do + [_, version] -> version + _ -> abort("could not read package version") + end + end + + defp check_metadata(tar_bytes, version) do + dest = + Path.join(System.tmp_dir!(), "replicant-upload-meta-#{System.unique_integer([:positive])}") + + File.mkdir_p!(dest) + + try do + case :mix_hex_tarball.unpack(tar_bytes, String.to_charlist(dest)) do + {:ok, meta} -> + metadata = Map.new(meta[:metadata] || %{}) + name = metadata["name"] || metadata[:name] + found_version = metadata["version"] || metadata[:version] + + cond do + name != "replicant" -> {:error, "package name is not replicant"} + found_version != version -> {:error, "package version does not match #{version}"} + true -> :ok + end + + {:error, _reason} -> + {:error, "Hex checksum validation failed"} + end + after + File.rm_rf!(dest) + end + end + + defp check_identity(version, source_commit, true), + do: Replicant.PackageIdentity.check_publish(version, source_commit) + + defp check_identity(version, _source_commit, false), + do: Replicant.PackageIdentity.check_candidate(version) + + defp publish(version, tar_bytes) do + case Replicant.PackagePublisher.publish(version, tar_bytes) do + {:ok, _digest} -> + IO.puts( + "upload_candidate: published replicant #{version}; Hex checksum matches exact witnessed bytes" + ) + + {:error, message} -> + abort(message) + end + end + + defp abort(message) do + IO.puts(:stderr, "::error::upload_candidate: #{message}") + System.halt(1) + end +end + +Replicant.UploadCandidate.run(System.argv()) diff --git a/scripts/release/verify_package.sh b/scripts/release/verify_package.sh new file mode 100755 index 0000000..0bd34d5 --- /dev/null +++ b/scripts/release/verify_package.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# Package-boundary gate for the Replicant Hex package. +# +# WHY: `mix.exs` `files:` is a glob allowlist (`lib`, `README*`, `docs/adr`, ...). Any +# ignored/untracked file that matches a glob silently enters the tarball — a synthetic +# `README.secret`, `lib/.env`, and `docs/adr/private-note.md` all packaged while the old CI +# grep gate still exited 0. This gate closes that hole: it compares the EXACT set of regular +# files in the built/retained tarball against a checked-in manifest and rejects every missing +# path, every extra path, and every symlink or special entry. It also asserts the package +# metadata name/version. +# +# Text greps are not an acceptance gate: this operates on the real unpacked tar bytes. +# +# Usage: +# verify_package.sh [TARBALL] +# TARBALL omitted -> build a fresh temporary tarball from the working tree (pre-mint check). +# TARBALL given -> verify that exact retained artifact (post-mint / R07 check). +# +# Env: +# EXPECTED_VERSION override the expected package version (default: mix.exs @version). +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +manifest="$repo_root/scripts/release/package_files.manifest" + +if [[ ! -f "$manifest" ]]; then + echo "::error::package manifest missing: $manifest" >&2 + exit 1 +fi + +expected_name="replicant" +expected_version="${EXPECTED_VERSION:-}" +if [[ -z "$expected_version" ]]; then + expected_version="$(grep -oE '@version "[^"]+"' "$repo_root/mix.exs" | head -1 | sed -E 's/@version "([^"]+)"/\1/')" +fi +if [[ -z "$expected_version" ]]; then + echo "::error::could not determine expected version" >&2 + exit 1 +fi + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +tarball="${1:-}" +if [[ -z "$tarball" ]]; then + # Build a fresh tarball from the working tree into an isolated build path so this can run + # standalone or from inside another mix invocation without contending on _build locks. + echo "verify_package: building a fresh tarball from the working tree" >&2 + ( cd "$repo_root" && MIX_ENV=dev MIX_BUILD_PATH="$work/_build" mix hex.build --output "$work/pkg.tar" >/dev/null ) + tarball="$work/pkg.tar" +fi + +if [[ ! -f "$tarball" ]]; then + echo "::error::tarball not found: $tarball" >&2 + exit 1 +fi + +# --- 1. Outer members are exactly the Hex envelope. ------------------------------------- +members="$(tar -tf "$tarball" | LC_ALL=C sort | tr '\n' ' ')" +expected_members="CHECKSUM VERSION contents.tar.gz metadata.config " +if [[ "$members" != "$expected_members" ]]; then + echo "::error::unexpected outer tar members: [$members] expected [$expected_members]" >&2 + exit 1 +fi + +# --- 2. Metadata name/version. ----------------------------------------------------------- +tar -xOf "$tarball" metadata.config > "$work/metadata.config" +got_name="$(grep -aoE '\{<<"name">>,<<"[^"]+">>\}' "$work/metadata.config" | head -1 | sed -E 's/.*<<"([^"]+)">>\}/\1/')" +got_version="$(grep -aoE '\{<<"version">>,<<"[^"]+">>\}' "$work/metadata.config" | head -1 | sed -E 's/.*<<"([^"]+)">>\}/\1/')" +if [[ "$got_name" != "$expected_name" ]]; then + echo "::error::package name is '$got_name', expected '$expected_name'" >&2 + exit 1 +fi +if [[ "$got_version" != "$expected_version" ]]; then + echo "::error::package version is '$got_version', expected '$expected_version'" >&2 + exit 1 +fi + +# --- 3. Extract contents and reject symlinks / special entries. -------------------------- +contents="$work/contents" +mkdir -p "$contents" +tar -xOf "$tarball" contents.tar.gz | tar -xzf - -C "$contents" + +if find "$contents" -type l | grep -q .; then + echo "::error::package contains a symlink:" >&2 + find "$contents" -type l | sed 's|^| |' >&2 + exit 1 +fi +if find "$contents" ! -type f ! -type d | grep -q .; then + echo "::error::package contains a special (non-regular, non-directory) entry:" >&2 + find "$contents" ! -type f ! -type d | sed 's|^| |' >&2 + exit 1 +fi + +# --- 4. Exact regular-file set == manifest. ---------------------------------------------- +actual="$work/actual.manifest" +( cd "$contents" && find . -type f | sed 's|^\./||' | LC_ALL=C sort ) > "$actual" + +expected_sorted="$work/expected.manifest" +LC_ALL=C sort "$manifest" > "$expected_sorted" + +extra="$(LC_ALL=C comm -13 "$expected_sorted" "$actual" || true)" +missing="$(LC_ALL=C comm -23 "$expected_sorted" "$actual" || true)" + +status=0 +if [[ -n "$extra" ]]; then + echo "::error::package contains files NOT in the manifest (boundary leak):" >&2 + printf ' + %s\n' "${extra//$'\n'/$'\n + '}" >&2 + status=1 +fi +if [[ -n "$missing" ]]; then + echo "::error::package is MISSING files listed in the manifest:" >&2 + printf ' - %s\n' "${missing//$'\n'/$'\n - '}" >&2 + status=1 +fi +if [[ $status -ne 0 ]]; then + exit 1 +fi + +count="$(wc -l < "$actual" | tr -d ' ')" +echo "verify_package: OK — $expected_name $got_version, $count regular files match the manifest, no symlink/special entries" diff --git a/test/release/package_checksum_test.exs b/test/release/package_checksum_test.exs new file mode 100644 index 0000000..94728b3 --- /dev/null +++ b/test/release/package_checksum_test.exs @@ -0,0 +1,28 @@ +Code.require_file("../../scripts/release/package_checksum.exs", __DIR__) + +defmodule Replicant.PackageChecksumTest do + use ExUnit.Case, async: true + + alias Replicant.PackageChecksum + + @digest String.duplicate("a", 64) + + test "accepts only the exact checksum returned for the published release" do + assert :ok == + PackageChecksum.classify({:ok, {200, [], %{"checksum" => @digest}}}, @digest) + + assert {:error, message} = + PackageChecksum.classify( + {:ok, {200, [], %{"checksum" => String.duplicate("b", 64)}}}, + @digest + ) + + assert message =~ "does not match" + end + + test "missing, malformed, or failed release reads fail closed" do + assert {:error, _} = PackageChecksum.classify({:ok, {200, [], %{}}}, @digest) + assert {:error, _} = PackageChecksum.classify({:ok, {503, [], %{}}}, @digest) + assert {:error, _} = PackageChecksum.classify({:error, :timeout}, @digest) + end +end diff --git a/test/release/package_identity_test.exs b/test/release/package_identity_test.exs new file mode 100644 index 0000000..af46b28 --- /dev/null +++ b/test/release/package_identity_test.exs @@ -0,0 +1,88 @@ +Code.require_file("../../scripts/release/package_identity.exs", __DIR__) + +defmodule Replicant.PackageIdentityTest do + use ExUnit.Case, async: true + + alias Replicant.PackageIdentity + + @version "1.2.0" + @commit String.duplicate("a", 40) + + test "candidate identity is free only when every authoritative check proves absence" do + runner = fn + "git", ["rev-parse", "-q", "--verify", "refs/tags/v1.2.0"] -> + {"", 1} + + "git", ["ls-remote", "--exit-code", "--tags", "origin", "refs/tags/v1.2.0"] -> + {"", 2} + + "curl", args when is_list(args) -> + assert Enum.chunk_every(args, 2, 1, :discard) |> Enum.member?(["--connect-timeout", "10"]) + assert Enum.chunk_every(args, 2, 1, :discard) |> Enum.member?(["--max-time", "30"]) + + assert List.last(args) in [ + "https://api.github.com/repos/baselabs/replicant/releases/tags/v1.2.0", + "https://hex.pm/api/packages/replicant/releases/1.2.0" + ] + + {"404", 0} + end + + assert :ok == PackageIdentity.check_candidate(@version, runner) + end + + test "remote tag lookup errors fail closed instead of becoming absence" do + runner = fn + "git", ["rev-parse", "-q", "--verify", "refs/tags/v1.2.0"] -> + {"", 1} + + "git", ["ls-remote", "--exit-code", "--tags", "origin", "refs/tags/v1.2.0"] -> + {"network unavailable", 128} + end + + assert {:error, message} = PackageIdentity.check_candidate(@version, runner) + assert message =~ "remote tag check failed" + end + + test "ambiguous GitHub and Hex responses fail closed" do + base = fn + "git", ["rev-parse", "-q", "--verify", "refs/tags/v1.2.0"] -> + {"", 1} + + "git", ["ls-remote", "--exit-code", "--tags", "origin", "refs/tags/v1.2.0"] -> + {"", 2} + + "curl", args -> + case List.last(args) do + "https://api.github.com/repos/baselabs/replicant/releases/tags/v1.2.0" -> {"503", 0} + "https://hex.pm/api/packages/replicant/releases/1.2.0" -> {"404", 0} + end + end + + assert {:error, message} = PackageIdentity.check_candidate(@version, base) + assert message =~ "GitHub release check returned HTTP 503" + end + + test "publish requires the local and remote tag to dereference to the recorded commit" do + runner = fn + "git", ["rev-parse", "refs/tags/v1.2.0^{}"] -> + {@commit <> "\n", 0} + + "git", + ["ls-remote", "--exit-code", "--tags", "origin", "refs/tags/v1.2.0", "refs/tags/v1.2.0^{}"] -> + {String.duplicate("b", 40) <> "\trefs/tags/v1.2.0\n", 0} + end + + assert {:error, message} = PackageIdentity.check_publish(@version, @commit, runner) + assert message =~ "remote tag v1.2.0 resolves to" + end + + test "command execution has a hard deadline" do + started = System.monotonic_time(:millisecond) + + assert {"command timed out", 124} = + PackageIdentity.run("sh", ["-c", "sleep 1"], 25) + + assert System.monotonic_time(:millisecond) - started < 500 + end +end diff --git a/test/release/package_publisher_test.exs b/test/release/package_publisher_test.exs new file mode 100644 index 0000000..8743211 --- /dev/null +++ b/test/release/package_publisher_test.exs @@ -0,0 +1,58 @@ +Code.require_file("../../scripts/release/package_publisher.exs", __DIR__) + +defmodule Replicant.PackagePublisherTest do + use ExUnit.Case, async: true + + alias Replicant.PackagePublisher + + defmodule ReleaseAPI do + def publish(repository, bytes, auth, progress, replace?) do + send(self(), {:publish, repository, bytes, auth, is_function(progress, 1), replace?}) + {:ok, {201, [], %{}}} + end + end + + defmodule Checksum do + def verify!(version, digest, key) do + send(self(), {:checksum, version, digest, key}) + :ok + end + end + + test "publishes the exact authorized bytes through the public Hex wrapper and verifies checksum" do + bytes = "exact witnessed package bytes" + digest = :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower) + + env = fn + "REPLICANT_PUBLISH_AUTHORIZED" -> "1.2.0:#{digest}" + "HEX_API_KEY" -> "test-key" + end + + assert {:ok, ^digest} = + PackagePublisher.publish("1.2.0", bytes, + env: env, + release_api: ReleaseAPI, + checksum: Checksum + ) + + assert_received {:publish, "hexpm", ^bytes, [key: "test-key"], true, false} + assert_received {:checksum, "1.2.0", ^digest, "test-key"} + end + + test "wrong authorization rejects before any publish call" do + env = fn + "REPLICANT_PUBLISH_AUTHORIZED" -> "1.2.0:wrong" + "HEX_API_KEY" -> "test-key" + end + + assert {:error, message} = + PackagePublisher.publish("1.2.0", "bytes", + env: env, + release_api: ReleaseAPI, + checksum: Checksum + ) + + assert message =~ "exact version:digest authorization" + refute_received {:publish, _, _, _, _, _} + end +end diff --git a/test/release/package_witness_test.exs b/test/release/package_witness_test.exs new file mode 100644 index 0000000..c68694f --- /dev/null +++ b/test/release/package_witness_test.exs @@ -0,0 +1,149 @@ +Code.require_file("../../scripts/release/package_witness.exs", __DIR__) + +defmodule Replicant.PackageWitnessTest do + use ExUnit.Case, async: false + + alias Replicant.PackageWitness + + setup do + root = + Path.join( + System.tmp_dir!(), + "replicant-package-witness-#{System.unique_integer([:positive])}" + ) + + File.mkdir_p!(root) + git!(root, ["init", "-q"]) + git!(root, ["config", "user.email", "test@example.invalid"]) + git!(root, ["config", "user.name", "Replicant Test"]) + + File.write!(Path.join(root, "source"), "one\n") + git!(root, ["add", "source"]) + git!(root, ["commit", "-q", "-m", "one"]) + first = git!(root, ["rev-parse", "HEAD"]) + + File.write!(Path.join(root, "source"), "two\n") + git!(root, ["commit", "-q", "-am", "two"]) + second = git!(root, ["rev-parse", "HEAD"]) + + artifact = Path.join(root, "artifact.tar") + File.write!(artifact, "synthetic artifact bytes") + digest = sha256(File.read!(artifact)) + receipt = Path.join(root, "receipt.txt") + File.write!(receipt, receipt_body(first, digest)) + + on_exit(fn -> File.rm_rf!(root) end) + + %{ + root: root, + first: first, + second: second, + artifact: artifact, + digest: digest, + receipt: receipt + } + end + + test "witness binds the exact receipt and source commit", ctx do + ref = "refs/attestations/packages/replicant/1.2.0" + assert {:ok, _witness} = PackageWitness.create(ctx.root, ref, ctx.first, ctx.receipt) + assert :ok == PackageWitness.verify(ctx.root, ref, ctx.artifact, ctx.receipt) + + File.write!(ctx.receipt, receipt_body(ctx.second, ctx.digest)) + + assert {:error, message} = PackageWitness.verify(ctx.root, ref, ctx.artifact, ctx.receipt) + assert message =~ "receipt does not match immutable witness" + end + + test "every retained copy must match the candidate digest", ctx do + backup = Path.join(ctx.root, "backup.tar") + by_digest = Path.join(ctx.root, "by-digest.tar") + File.cp!(ctx.artifact, backup) + File.cp!(ctx.artifact, by_digest) + File.write!(backup, "corrupt") + + assert {:error, message} = + PackageWitness.verify_copies([ctx.artifact, backup, by_digest], ctx.digest) + + assert message =~ "retained copy digest mismatch" + assert message =~ backup + end + + test "retained copies refuse to overwrite an existing destination", ctx do + fresh = Path.join(ctx.root, "fresh.tar") + destination = Path.join(ctx.root, "retained.tar") + File.write!(destination, "existing") + + assert_raise RuntimeError, ~r/refusing to overwrite retained package copy/, fn -> + PackageWitness.retain_copies!(ctx.artifact, [fresh, destination]) + end + + refute File.exists?(fresh) + assert File.read!(destination) == "existing" + end + + test "a later destination directory failure removes earlier copies", ctx do + fresh = Path.join(ctx.root, "fresh.tar") + blocked_parent = Path.join(ctx.root, "blocked-parent") + File.write!(blocked_parent, "not a directory") + + assert_raise File.Error, fn -> + PackageWitness.retain_copies!(ctx.artifact, [ + fresh, + Path.join(blocked_parent, "retained.tar") + ]) + end + + refute File.exists?(fresh) + assert File.read!(blocked_parent) == "not a directory" + end + + test "witness deletion requires the exact object still at the ref", ctx do + ref = "refs/attestations/packages/replicant/1.2.0" + assert {:ok, first_witness} = PackageWitness.create(ctx.root, ref, ctx.first, ctx.receipt) + + git!(ctx.root, ["update-ref", ref, ctx.second, first_witness]) + + assert {:error, "package witness moved; refusing cleanup"} = + PackageWitness.delete_ref(ctx.root, ref, first_witness) + + assert git!(ctx.root, ["rev-parse", ref]) == ctx.second + end + + test "witness creation rejects a receipt for a different source commit", ctx do + File.write!(ctx.receipt, receipt_body(ctx.second, ctx.digest)) + + assert {:error, "receipt source commit does not match witness parent"} = + PackageWitness.create( + ctx.root, + "refs/attestations/packages/replicant/1.2.0", + ctx.first, + ctx.receipt + ) + end + + test "uploader inputs must be read-only regular files, never symlinks", ctx do + link = Path.join(ctx.root, "artifact-link.tar") + File.ln_s!(ctx.artifact, link) + + assert {:error, message} = PackageWitness.read_immutable(link) + assert message =~ "regular file" + + assert {:error, message} = PackageWitness.read_immutable(ctx.artifact) + assert message =~ "read-only" + + File.chmod!(ctx.artifact, 0o444) + assert {:ok, "synthetic artifact bytes"} = PackageWitness.read_immutable(ctx.artifact) + end + + defp git!(root, args) do + {output, 0} = System.cmd("git", args, cd: root) + String.trim(output) + end + + defp receipt_body(commit, digest) do + "version: 1.2.0\nsource_commit: #{commit}\nsha256: #{digest}\n" + end + + defp sha256(bytes), do: :crypto.hash(:sha256, bytes) |> Base.encode16(case: :lower) +end diff --git a/test/release/public_surface_test.exs b/test/release/public_surface_test.exs new file mode 100644 index 0000000..c7f6109 --- /dev/null +++ b/test/release/public_surface_test.exs @@ -0,0 +1,97 @@ +defmodule Replicant.ReleasePublicSurfaceTest do + # The 1.2.0 candidate must actually carry the R01-R05 fixed surfaces, proven by + # SEMANTIC assertions rather than source text greps: a comment or dead string can satisfy + # `grep 'handle_session_identity'`, but cannot satisfy "the behaviour lists the callback" + # or "the query builder emits version-tiered SQL". Each assertion reds if its surface is + # removed, and none touches a live database. The artifact-derived counterpart runs the same + # exercises against the extracted package bytes in scripts/release/consume_candidate.sh. + use ExUnit.Case, async: true + + describe "R04 — typed slot-origin callback + D2 session identity (public Sink surface)" do + test "handle_slot_origin/2 and handle_session_identity/2 are optional Sink callbacks" do + callbacks = Replicant.Sink.behaviour_info(:callbacks) + optional = Replicant.Sink.behaviour_info(:optional_callbacks) + + assert {:handle_slot_origin, 2} in callbacks + assert {:handle_session_identity, 2} in callbacks + assert {:handle_slot_origin, 2} in optional + assert {:handle_session_identity, 2} in optional + end + + test "SessionIdentity carries the four typed identity fields" do + identity = %Replicant.SessionIdentity{ + system_identifier: 1, + timeline_id: 1, + current_lsn: 0, + database: "postgres" + } + + assert Map.keys(identity) |> Enum.sort() == + [:__struct__, :current_lsn, :database, :system_identifier, :timeline_id] + end + + test "the identity query is IDENTIFY_SYSTEM (the actual replication-session identity)" do + assert Replicant.QueryBuilder.identify_system() == "IDENTIFY_SYSTEM" + end + end + + describe "R05 — version-gated slot-invalidation query" do + test "slot_invalidation_status/2 selects a version-tiered column set" do + {:ok, pg15} = Replicant.QueryBuilder.slot_invalidation_status("s", 150_019) + {:ok, pg16} = Replicant.QueryBuilder.slot_invalidation_status("s", 160_014) + {:ok, pg17} = Replicant.QueryBuilder.slot_invalidation_status("s", 170_011) + + # PG15 has no `conflicting` column (added PG16); PG17 adds `invalidation_reason`. + refute pg15 =~ "conflicting" + assert pg16 =~ "conflicting" + refute pg16 =~ "invalidation_reason" + assert pg17 =~ "invalidation_reason" + end + end + + describe "R02/R03 — value-free telemetry boundary (no row/secret bytes escape)" do + test "a wrong-shape value on an allowlisted key raises with the value elided" do + secret = "SECRET-ROW-VALUE-9f3a" + + err = + assert_raise ArgumentError, fn -> + # commit_lsn's contract is :lsn (non-neg integer or nil); a string smuggling a row + # value must be rejected, and the rejection must not echo the bytes. + Replicant.Telemetry.validate!(%{commit_lsn: secret}) + end + + refute Exception.message(err) =~ secret + end + + test "an off-allowlist key is rejected without echoing the arbitrary key/value" do + err = + assert_raise ArgumentError, fn -> + Replicant.Telemetry.validate!(%{"row_password" => "hunter2"}) + end + + refute Exception.message(err) =~ "hunter2" + refute Exception.message(err) =~ "row_password" + end + end + + describe "R01 — unknown checkpoint with absent slot halts fail-closed" do + test "a fault checkpoint with no slot rows never emits CREATE_REPLICATION_SLOT" do + state = %Replicant.Connection{ + step: :invalidation_check, + slot_name: "audit_slot", + publication: ["audit_pub"], + snapshot: false, + checkpoint_lsn: 0, + checkpoint_state: :fault, + failover: false + } + + result = Replicant.Connection.handle_result([%Postgrex.Result{rows: []}], state) + + # The fix: a fail-closed data-gap halt, not a slot creation that would skip WAL. + assert result == {:disconnect, :data_gap} + + refute match?({:query, "CREATE_REPLICATION_SLOT" <> _, _}, result) + end + end +end diff --git a/test/release/release_contract_test.exs b/test/release/release_contract_test.exs new file mode 100644 index 0000000..7a226f1 --- /dev/null +++ b/test/release/release_contract_test.exs @@ -0,0 +1,70 @@ +defmodule Replicant.ReleaseContractTest do + # Release identity is a public contract: the package version, the CHANGELOG's cut + # release section, and the doc source-ref tag must agree, and the candidate must + # supersede the last published version. Drift here is quiet until publication or a + # downstream resolution, so it is gated in the cold suite (no live substrate needed). + use ExUnit.Case, async: true + + @changelog Path.expand("../../CHANGELOG.md", __DIR__) + + # The last version published to Hex that this candidate supersedes. Bumped as part of + # cutting each release; a candidate that fails to advance past it reds here rather than + # re-minting an already-published version. + @last_published "1.1.0" + + defp version, do: Mix.Project.config()[:version] + + test "candidate version advances past the last published release" do + assert Version.compare(version(), @last_published) == :gt, + "mix.exs version #{version()} must be strictly greater than the last published " <> + "#{@last_published}; a re-minted published version is a hard stop" + end + + test "CHANGELOG cuts a dated release section for the candidate as the newest release" do + body = File.read!(@changelog) + v = version() + + assert body =~ ~r/^## \[#{Regex.escape(v)}\] - \d{4}-\d{2}-\d{2}$/m, + "CHANGELOG.md has no dated `## [#{v}] - YYYY-MM-DD` release section" + + released = + Regex.scan(~r/^## \[(\d+\.\d+\.\d+)\]/m, body) + |> Enum.map(fn [_, ver] -> ver end) + + assert List.first(released) == v, + "newest released CHANGELOG section is #{inspect(List.first(released))}, expected #{v}" + + assert Version.compare(List.first(released), Enum.at(released, 1)) == :gt, + "the candidate section must be newer than the section beneath it" + end + + test "candidate changelog has one group for each change type" do + [_, after_candidate] = + File.read!(@changelog) |> String.split("## [#{version()}]", parts: 2) + + [candidate | _] = String.split(after_candidate, ~r/^## \[/m) + + headings = Regex.scan(~r/^### (.+)$/m, candidate) |> Enum.map(fn [_, heading] -> heading end) + + assert headings == Enum.uniq(headings), + "the candidate changelog repeats a change-type heading: #{inspect(headings)}" + end + + test "CHANGELOG comparison links bind the candidate to the last published tag" do + body = File.read!(@changelog) + v = version() + + assert body =~ + ~r{^\[#{Regex.escape(v)}\]: https://github.com/baselabs/replicant/compare/v#{Regex.escape(@last_published)}\.\.\.v#{Regex.escape(v)}$}m, + "missing/incorrect `[#{v}]:` comparison link to v#{@last_published}...v#{v}" + + assert body =~ + ~r{^\[Unreleased\]: https://github.com/baselabs/replicant/compare/v#{Regex.escape(v)}\.\.\.HEAD$}m, + "the [Unreleased] link must compare from the freshly cut v#{v} tag" + end + + test "docs source_ref pins the candidate version tag" do + assert Mix.Project.config()[:docs][:source_ref] == "v#{version()}", + "docs source_ref must be v#{version()} so HexDocs source links resolve to the release tag" + end +end