diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efc8b461c..52d459913 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,19 +14,18 @@ on: concurrency: group: release-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # A fresh first attempt supersedes stale work, while GitHub's explicit + # rerun attempt must not cancel itself inside the same concurrency group. + cancel-in-progress: ${{ github.run_attempt == 1 }} permissions: contents: read jobs: build: - # Minimal top-level (read); elevate only this job — it publishes the Release on tag. permissions: - contents: write + contents: read env: - # llama.cpp's ggml uses std::filesystem (needs macOS 10.15+). macos-latest is arm64 → 11.0. - # Ignored on Windows/Linux runners. MACOSX_DEPLOYMENT_TARGET: "11.0" strategy: fail-fast: false @@ -71,6 +70,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - name: Install Linux system deps (tauri GTK + llama.cpp native) @@ -79,31 +79,22 @@ jobs: sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev cmake clang libclang-dev - - name: Install macOS build deps (cmake for llama.cpp; clang from Xcode) + - name: Install macOS build deps if: matrix.os == 'macos-latest' run: brew install cmake - # windows-latest images ship cmake + LLVM + MSVC; no extra install needed. - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 20 - - run: npm ci - - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: src-tauri - # Builds the SvelteKit frontend (beforeBuildCommand) + the Rust app with the real - # llama.cpp engine (CPU in M6; GPU backends are a follow-up) and produces the OS bundles. - name: Tauri build (with embedded LLM) run: npm run tauri -- build --features llm-engine - # Operational audit/planning tools are intentionally shipped separately from the GUI so a - # cleanup run can reuse exact-head binaries without rebuilding a multi-GiB local target. - name: Build operational CLIs run: >- cargo build --manifest-path src-tauri/Cargo.toml --release --features cloud-cli @@ -118,13 +109,10 @@ jobs: local asset_path="$2" local usage_marker="$3" cp "$source_path" "$asset_path" - local help_output help_output="$("$asset_path" --help 2>&1 || true)" grep -F "$usage_marker" <<<"$help_output" - - local asset_dir - local asset_name + local asset_dir asset_name asset_dir="$(dirname "$asset_path")" asset_name="$(basename "$asset_path")" if command -v sha256sum >/dev/null 2>&1; then @@ -134,88 +122,172 @@ jobs: fi test -s "$asset_path.sha256" } + stage_cli "${{ matrix.cloud_cli_source }}" "${{ matrix.cloud_cli_asset }}" "usage: disksage-cloud-plan" + stage_cli "${{ matrix.duplicate_cli_source }}" "${{ matrix.duplicate_cli_asset }}" "usage: disksage-duplicate-audit" - stage_cli \ - "${{ matrix.cloud_cli_source }}" \ - "${{ matrix.cloud_cli_asset }}" \ - "usage: disksage-cloud-plan" - stage_cli \ - "${{ matrix.duplicate_cli_source }}" \ - "${{ matrix.duplicate_cli_asset }}" \ - "usage: disksage-duplicate-audit" - - # Validate the artifact users actually receive. An unsigned bundle can retain only the - # Mach-O linker's ad-hoc executable signature, which does not seal Info.plist/resources. - name: Verify macOS DMG app signature if: matrix.os == 'macos-latest' shell: bash run: | set -euo pipefail shopt -s nullglob - dmg_files=(src-tauri/target/release/bundle/dmg/*.dmg) if [[ ${#dmg_files[@]} -ne 1 ]]; then echo "Expected exactly one DMG, found ${#dmg_files[@]}" >&2 exit 1 fi - mount_dir="$RUNNER_TEMP/disksage-dmg" mkdir -p "$mount_dir" - cleanup() { - hdiutil detach "$mount_dir" >/dev/null 2>&1 || true - } + cleanup() { hdiutil detach "$mount_dir" >/dev/null 2>&1 || true; } trap cleanup EXIT - hdiutil attach "${dmg_files[0]}" -readonly -nobrowse -mountpoint "$mount_dir" app_bundles=("$mount_dir"/*.app) if [[ ${#app_bundles[@]} -ne 1 ]]; then echo "Expected exactly one app bundle, found ${#app_bundles[@]}" >&2 exit 1 fi - test -f "${app_bundles[0]}/Contents/_CodeSignature/CodeResources" codesign --verify --deep --strict --verbose=2 "${app_bundles[0]}" codesign --display --verbose=4 "${app_bundles[0]}" - # PR / manual: keep the bundles as inspectable artifacts, no release published. - - name: Upload build artifacts (no publish) - if: github.event_name != 'push' + - name: Upload release artifact set uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: disksage-${{ matrix.os }} + name: release-disksage-${{ matrix.os }} path: ${{ matrix.bundles }} if-no-files-found: error - # Tag push: publish/attach the bundles to a single GitHub Release (matrix-safe). - - name: Publish to GitHub Release - if: startsWith(github.ref, 'refs/tags/') + attest-release: + if: startsWith(github.ref, 'refs/tags/') + needs: build + runs-on: ubuntu-22.04 + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: Download exact release artifact set + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + pattern: release-disksage-* + path: release-artifacts + merge-multiple: false + + - name: Verify release artifact checksums + shell: bash + run: | + set -euo pipefail + require_exactly_one_path() { + local path_pattern="$1" label="$2" count=0 matched_path="" + while IFS= read -r -d '' matched_path; do count=$((count + 1)); done < <(find release-artifacts -type f -path "$path_pattern" -print0) + if [[ $count -ne 1 ]]; then + printf 'Expected exactly one %s, found %s.\n' "$label" "$count" >&2 + exit 1 + fi + } + require_exactly_one_file() { + local file_name="$1" count=0 matched_path="" + while IFS= read -r -d '' matched_path; do count=$((count + 1)); done < <(find release-artifacts -type f -name "$file_name" -print0) + if [[ $count -ne 1 ]]; then + printf 'Expected exactly one release artifact named %s, found %s.\n' "$file_name" "$count" >&2 + exit 1 + fi + } + require_exactly_one_path '*/bundle/deb/*.deb' 'Debian bundle' + require_exactly_one_path '*/bundle/appimage/*.AppImage' 'AppImage bundle' + require_exactly_one_path '*/bundle/msi/*.msi' 'Windows MSI bundle' + require_exactly_one_path '*/bundle/nsis/*.exe' 'Windows NSIS bundle' + require_exactly_one_path '*/bundle/dmg/*.dmg' 'macOS DMG bundle' + for required_name in \ + disksage-cloud-plan-linux-x86_64 \ + disksage-duplicate-audit-linux-x86_64 \ + disksage-cloud-plan-windows-x86_64.exe \ + disksage-duplicate-audit-windows-x86_64.exe \ + disksage-cloud-plan-macos-arm64 \ + disksage-duplicate-audit-macos-arm64; do + require_exactly_one_file "$required_name" + require_exactly_one_file "$required_name.sha256" + done + checksum_files=() + checksum_file="" + while IFS= read -r -d '' checksum_file; do checksum_files+=("$checksum_file"); done < <(find release-artifacts -type f -name '*.sha256' -print0) + if [[ ${#checksum_files[@]} -ne 6 ]]; then + printf 'Expected six operational CLI checksum files, found %s.\n' "${#checksum_files[@]}" >&2 + exit 1 + fi + for checksum_file in "${checksum_files[@]}"; do + checksum_dir="$(dirname "$checksum_file")" + checksum_name="$(basename "$checksum_file")" + expected_asset_name="${checksum_name%.sha256}" + checksum_line="" line="" line_count=0 + while IFS= read -r line || [[ -n "$line" ]]; do + line_count=$((line_count + 1)) + checksum_line="$line" + done <"$checksum_file" + if [[ $line_count -ne 1 ]]; then + printf 'Checksum file %s must contain exactly one record.\n' "$checksum_name" >&2 + exit 1 + fi + recorded_digest="" recorded_name="" extra_field="" + read -r recorded_digest recorded_name extra_field <<<"$checksum_line" + if [[ ! "$recorded_digest" =~ ^[0-9a-fA-F]{64}$ ]] || [[ "$recorded_name" != "$expected_asset_name" ]] || [[ -n "$extra_field" ]]; then + printf 'Checksum file %s must reference its adjacent operational CLI %s exactly once.\n' "$checksum_name" "$expected_asset_name" >&2 + exit 1 + fi + if command -v sha256sum >/dev/null 2>&1; then + (cd "$checksum_dir" && sha256sum --check "$checksum_name") + elif command -v shasum >/dev/null 2>&1; then + (cd "$checksum_dir" && shasum -a 256 --check "$checksum_name") + else + printf 'No SHA-256 checksum verifier is available.\n' >&2 + exit 1 + fi + done + unexpected_entry="$(find release-artifacts -mindepth 1 ! -type d ! -type f -print -quit)" + if [[ -n "$unexpected_entry" ]]; then + printf 'Unexpected release artifact entries: non-regular path %s is not publishable.\n' "$unexpected_entry" >&2 + exit 1 + fi + regular_file_count=0 + matched_path="" + while IFS= read -r -d '' matched_path; do regular_file_count=$((regular_file_count + 1)); done < <(find release-artifacts -type f -print0) + if [[ $regular_file_count -ne 17 ]]; then + printf 'Unexpected release artifact entries: expected exactly 17 regular files, found %s.\n' "$regular_file_count" >&2 + exit 1 + fi + + - name: Generate GitHub build provenance + uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4.1.0 + with: + subject-path: release-artifacts/**/* + + publish-release: + if: startsWith(github.ref, 'refs/tags/') + needs: attest-release + runs-on: ubuntu-22.04 + permissions: + contents: write + steps: + - name: Download attested release artifact set + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 + with: + pattern: release-disksage-* + path: release-artifacts + merge-multiple: false + - name: Publish attested artifacts to GitHub Release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: - files: ${{ matrix.bundles }} + files: release-artifacts/**/* generate_release_notes: true fail_on_unmatched_files: true - # GPU-enabled build (CUDA + Vulkan, dynamic backends) for RUNTIME verification on real GPU - # hardware. Uploads the raw app exe + ggml/cuda/vulkan shared libs (NOT an installer) so a - # tester can run it directly and confirm GPU offload. Installer bundling of the libs is a - # follow-up once the runtime path is verified. macOS Metal is already covered by the `build` - # job (auto-enabled on Apple Silicon), so this is Windows + Linux only. gpu-build: - # Pull requests already compile the embedded LLM in the three release-bundle jobs above. - # Reserve the expensive CUDA/Vulkan matrix for tags and explicit manual verification so - # unrelated Rust/UI PRs cannot saturate hosted runners or delay required safety checks. if: github.event_name != 'pull_request' permissions: contents: read strategy: fail-fast: false matrix: - # windows-2022 (VS 2022 / MSVC 14.4x): CUDA 12.6 rejects the newer MSVC on windows-latest - # (VS 2026) via host_config.h "unsupported Microsoft Visual Studio version". - # Windows drops Vulkan: llama.cpp's vulkan-shaders-gen nested build blows past the Windows - # 260-char MAX_PATH ("C1083: Cannot open compiler generated file"). The tester's GPU is - # NVIDIA (CUDA), so CUDA+CPU covers it; Windows Vulkan is a follow-up (needs a short build - # path). Linux keeps CUDA+Vulkan (no MAX_PATH limit). include: - os: ubuntu-22.04 features: "llm-engine,llama-cpp-2/cuda,llama-cpp-2/vulkan,llama-cpp-2/dynamic-backends" @@ -224,26 +296,22 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 60 env: - # Target the tester's GPU (RTX 3050 Ti = Ampere, compute 8.6) to keep the CUDA compile - # from building every arch. Best-effort — ignored if the build system doesn't read it. CMAKE_CUDA_ARCHITECTURES: "86" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - - name: Install Linux system deps (tauri GTK + llama.cpp native + Vulkan build) + - name: Install Linux system deps if: matrix.os == 'ubuntu-22.04' run: | sudo apt-get update sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev cmake clang libclang-dev glslang-tools - - name: Install CUDA toolkit uses: Jimver/cuda-toolkit@3d45d157f327c09c04b50ee6ccdea2d9d017ec76 # v0.2.35 with: cuda: "12.6.0" method: "network" - - name: Install Vulkan SDK if: matrix.os == 'ubuntu-22.04' uses: jakoch/install-vulkan-sdk-action@37effcfa045411f8bfbbda26df2fd1b3bf3436fa # v1.6.0 @@ -251,7 +319,6 @@ jobs: version: "1.3.290.0" install_runtime: true cache: true - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 20 @@ -261,63 +328,35 @@ jobs: with: workspaces: src-tauri key: gpu - - # Windows: the runner's Visual Studio (v18/2026) is newer than CUDA 12.6's VS integration, - # so cmake's VS generator fails with "No CUDA toolset found". Switch to Ninja + activate the - # MSVC env so nvcc uses cl.exe directly as the host compiler (no VS-integration files needed). - name: Setup MSVC dev environment (Windows) if: matrix.os == 'windows-2022' uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0 - - name: Force Ninja generator (Windows) if: matrix.os == 'windows-2022' shell: bash run: echo "CMAKE_GENERATOR=Ninja" >> "$GITHUB_ENV" - - # CUDA is dynamically linked (NVIDIA ships no static cudart); dynamic-backends builds each - # ggml backend as a separate shared lib loaded at runtime, so the app launches on machines - # WITHOUT CUDA (Vulkan/CPU fallback) — the distributable design from spec §6. - # --no-bundle: build the app binary + shared libs only, skip installer bundling (linuxdeploy - # fails on the dynamic backend .so's, and verification needs just the raw exe + libs anyway). - name: "Tauri build (GPU: CUDA [+ Vulkan on Linux] + dynamic backends)" run: npm run tauri -- build --no-bundle --features "${{ matrix.features }}" - - # Also build the lib test binary (contains engine.rs's #[ignore] real-model smoke test) so GPU - # offload can be verified headlessly via CLI — no GUI needed. Run it with DISKSAGE_MODEL set. - # No `shell: bash` — on Windows that puts git-bash's /usr/bin/link ahead of MSVC link.exe - # and breaks linking. Default shell (pwsh on Windows / bash on Linux) uses the right linker. - name: Build engine smoke-test binary run: cargo test --manifest-path src-tauri/Cargo.toml --release --no-run --features "${{ matrix.features }}" --lib - - # Diagnostic: learn WHERE the shared libs landed (first-attempt discovery). - name: List built shared libs (diagnostic) if: always() shell: bash run: | echo "== target/release top =="; ls -la src-tauri/target/release/ 2>/dev/null | head -40 || true - echo "== ggml/llama/cuda/vulkan libs under target (backends land in out/backends) =="; find src-tauri/target -maxdepth 8 -iregex '.*\(ggml\|llama\|cudart\|vulkan\).*\.\(dll\|so\|dylib\)' 2>/dev/null | head -60 || true + echo "== ggml/llama/cuda/vulkan libs under target =="; find src-tauri/target -maxdepth 8 -iregex '.*\(ggml\|llama\|cudart\|vulkan\).*\.\(dll\|so\|dylib\)' 2>/dev/null | head -60 || true echo "== out/backends dirs =="; find src-tauri/target -type d -name backends 2>/dev/null | head || true echo "== CUDA runtime dll/so =="; find "${CUDA_PATH:-/usr/local/cuda}" -iname 'cudart*' 2>/dev/null | head || true - - # Best-effort stage: app exe + every ggml/llama/cuda/vulkan shared lib next to it, so the - # tester can run the exe directly. Paths refined based on the diagnostic above. - name: Stage GPU run bundle if: always() shell: bash run: | - set +e +o pipefail # best-effort staging must never fail the job + set +e +o pipefail mkdir -p gpu-run - # app binary (Tauri output name follows productName/crate; grab both just in case) find src-tauri/target/release -maxdepth 1 -type f \( -name 'disksage' -o -name 'disksage.exe' -o -name 'DiskSage' -o -name 'DiskSage.exe' \) -exec cp {} gpu-run/ \; 2>/dev/null || true - # engine smoke-test binary (real inference via #[ignore] test) for headless GPU verification. - # Pick the freshest disksage_lib-* (ls -t) to avoid stale cached hashes; skip .d dep files. tb=$(ls -t src-tauri/target/release/deps/disksage_lib-* 2>/dev/null | grep -viE '\.(d|pdb)$' | head -1) [ -n "$tb" ] && cp "$tb" "gpu-run/engine_smoketest${{ matrix.os == 'windows-2022' && '.exe' || '' }}" 2>/dev/null || true - # all ggml/llama shared libs incl. dynamic backends (deep in build/.../out/backends) and - # Linux SONAME-versioned .so.N files; copy symlinks + targets so the set is self-contained. find src-tauri/target -maxdepth 8 -iregex '.*\(ggml\|llama\).*\(\.dll\|\.so\|\.so\..*\)' -exec cp {} gpu-run/ \; 2>/dev/null || true - # CUDA runtime libs ggml-cuda needs at load time (cudart + cuBLAS). nvcuda/vulkan-1 come - # from the GPU driver / Vulkan runtime already on the tester's machine. for pat in 'cudart64_*.dll' 'cublas64_*.dll' 'cublasLt64_*.dll'; do find "${CUDA_PATH:-/usr/local/cuda}" -iname "$pat" -exec cp {} gpu-run/ \; 2>/dev/null || true done @@ -325,7 +364,6 @@ jobs: find "${CUDA_PATH:-/usr/local/cuda}" -iname "$pat" -exec cp -L {} gpu-run/ \; 2>/dev/null || true done echo "== staged =="; ls -la gpu-run/ || true - - name: Upload GPU run bundle (for manual GPU verification) if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 033f9bae4..3ef1be8d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Changed +- Bind Tauri packaging to a fail-closed cross-manifest release-version verifier so `package.json`, `Cargo.toml`, `tauri.conf.json`, and any `v*` release tag must agree on one valid Semantic Version before a bundle is built. +- Add retry-safe release concurrency: fresh first attempts may supersede stale runs, while explicit GitHub rerun attempts do not self-cancel inside the same concurrency group. +- Replace generator-era Cargo package metadata with the DiskSage product description, MIT license expression, canonical source repository URL, and `publish = false` registry-publication boundary; deliberately omit Cargo's deprecated `authors` field, verify publication refusal through Cargo's versioned parsed metadata rather than substring matching, and regression-test commented/out-of-table decoys together with the retained acquisition metadata and doctoring evidence. - Require a fresh, exact, human-attributed approval and rationale for cloud copy-only and existing-copy adoption actions, with a 15-minute authorization lifetime bound to the candidate, destination, provider, account scope, and review fingerprint. - Return the candidate-specific cloud copy approval action, exact confirmation phrase, and maximum approval age from the Rust plan contract; the frontend only displays and submits that backend-authored phrase and fails closed when it is missing or does not match the candidate action. - Align the frontend toolchain on Vite 8.2 and `@sveltejs/vite-plugin-svelte` 7.2 so the declared peer dependency graph is installable and reproducible. - Declare the supported Node.js runtime floor as Node.js 20.19 or Node.js 22.12 and later, matching Vite 8 requirements. - Pin the primary test workflow to Node.js 20.19.0 so the minimum supported runtime is continuously verified. - Document the iCloud batch operation's local-only versus path-free shareable evidence boundary and map its fail-closed controls to NIST SP 800-53 Release 5.2.0, ISO/IEC 27040:2024, and primary secure-design literature with APA 7th references and deterministic documentation contract tests. +- Refresh the Tauri CSP standards evidence to the current July 29, 2026 W3C Content Security Policy Level 3 Working Draft and regression-test its exact publication URL so future doctoring cannot silently drift back to an older draft. ### Fixed @@ -22,6 +26,9 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and ### Security +- Add buyer-verifiable release artifact provenance with read-only platform build jobs, a tag-only least-privilege attestation job, exact 17-file admission, adjacent operational-CLI SHA-256 verification, preserved artifact namespaces, non-regular-entry rejection, and a separate publication job that cannot publish before attestation succeeds. +- Require explicit organization-tenant authority when either the destination account scope is organization-owned or the canonical organization-sensitive review reason is present; fail closed in both frontend projection and durable Rust transfer authorization even when the ordinary review flag is absent, and regression-test contradictory signal combinations. +- Enable an explicit fail-closed Tauri Content Security Policy to keep executable scripts and fonts local, grant production network authority only to the Tauri IPC transport, confine Vite WebSocket HMR to a separate development-only CSP, deny object/frame/base-URI authority, deny form submissions with explicit `form-action 'none'`, deny unused worker, media, and web-app-manifest fetch authority with explicit `'none'` directives, and regression-test against null, wildcard, remote-script/style, eval, and development-authority leakage. - Re-verify the installed GGUF immediately before llama.cpp initialization and retain the verified model handle through llama.cpp loading: reject missing, linked, non-regular, identity-raced, short, oversized, unreadable, or SHA-256-mismatched artifacts with stable path-free errors; use a stable descriptor path on Unix and a Windows read-sharing guard so the mutable source pathname cannot be substituted between verification and model parsing. - Bind the default on-device GGUF model to an immutable upstream revision, exact byte count, and SHA-256 digest; replace whole-model buffering and named sibling staging with bounded streaming into an unnamed same-directory temporary file; ignore and preserve unrelated legacy `.part` paths; refuse destination overwrite with create-new semantics; capture destination ownership from the returned open file handle; re-read and rehash the still-open staging source while copying; flush, sync, re-read, and rehash the destination before final acceptance; reject same-file source or destination mutation; preserve foreign destination replacements through identity-bound cleanup; and keep model installation inside the Rust coverage surface with privacy-safe stable errors and deterministic race regressions. - Persist copy-approval provenance in immutable receipt lineage, reject stale, generic, mismatched, or tampered approvals, and retain explicit backward readability for pre-approval receipt formats. diff --git a/docs/doctoring/release-artifact-provenance.md b/docs/doctoring/release-artifact-provenance.md new file mode 100644 index 000000000..b74e5cd11 --- /dev/null +++ b/docs/doctoring/release-artifact-provenance.md @@ -0,0 +1,102 @@ +# Release artifact provenance + +## Decision + +DiskSage treats provenance as a release gate rather than optional release metadata. A tagged release may be published only after all operating-system build jobs finish, the exact uploaded artifact set is downloaded into a clean tag-only job, every shipped operational CLI checksum is verified, and GitHub creates signed build-provenance attestations for the files that will be published. + +This design keeps three authorities separate: + +1. `build` compiles and tests release candidates with read-only repository access, then uploads ephemeral workflow artifacts. +2. `attest-release` receives only `contents: read`, `id-token: write`, and `attestations: write`. It verifies the expected platform and CLI set before generating provenance. +3. `publish-release` receives `contents: write` only after `attest-release` succeeds. It cannot publish an unattested build because it has a durable `needs: attest-release` dependency. + +Pull requests and manual non-tag builds still produce inspectable artifacts, but they cannot request an OpenID Connect identity, create durable attestations, or publish a GitHub Release through these jobs. + +## Evidence contract + +The authoritative implementation is `.github/workflows/release.yml`. + +The release contract requires all of the following: + +- checkout binds every platform build to `github.event.pull_request.head.sha` for pull requests and `github.sha` for tags or manual runs, rather than silently treating a generated pull-request merge ref as exact-head evidence; +- release concurrency uses `github.run_attempt == 1`, so a fresh first attempt supersedes stale work while explicit rerun attempts do not cancel themselves inside the same concurrency group; +- the three platform builds upload the exact bundle and operational CLI paths that later jobs consume; +- release workflow artifacts use the `release-disksage-*` namespace, which excludes concurrently uploaded `disksage-gpu-*` diagnostic bundles; +- attestation and publication downloads preserve each workflow artifact in its own directory instead of flattening archives, so duplicate basenames remain observable and last-writer-wins extraction cannot erase evidence before admission; +- release publication is absent from the matrix build job, preventing any matrix member from publishing before the complete set exists; +- the attestation and publication jobs run only for `refs/tags/`; +- the attestation job depends on the complete build matrix; +- Linux `.deb` and `.AppImage`, Windows `.msi` and NSIS `.exe`, and macOS `.dmg` bundles are present exactly once in their expected bundle paths; +- all six platform-specific operational CLIs and all six corresponding `.sha256` files are each present exactly once; +- the preserved release tree contains exactly 17 regular files and no symlink, device, socket, FIFO, or other non-regular entry, so unreviewed debug output, logs, dumps, or unrelated executables cannot become attested release subjects; +- every checksum file contains exactly one SHA-256 record naming its adjacent expected CLI basename, so alternate, absolute, traversing, or decoy filenames are rejected before digest verification; +- each checksum is verified before provenance generation; +- `actions/download-artifact` is immutably pinned to commit `37930b1c2abaa49bbe596cd826c3c89aef350131`, the upstream `v7.0.0` tag commit; +- `actions/attest` is immutably pinned to commit `59d89421af93a897026c735860bf21b6eb4f7b26`, the upstream `v4.1.0` tag commit; +- every published file is a subject of the generated attestation; and +- publication depends on successful attestation rather than merely running in parallel with it. + +GitHub's action emits an in-toto Statement v1 containing a SLSA Provenance v1 predicate. SLSA specification version 1.2 is the current approved framework version, while the stable build-provenance predicate URI remains `https://slsa.dev/provenance/v1`. + +## Buyer and operator verification + +Download one release artifact without renaming or modifying it, install a current GitHub CLI, authenticate if the repository visibility requires it, and run: + +```bash +gh attestation verify PATH/TO/ARTIFACT -R ContextualWisdomLab/disksage +``` + +The verifier must bind the artifact digest to `ContextualWisdomLab/disksage`. A successful result demonstrates that GitHub Actions produced an attestation for those exact bytes; it does not independently prove that the software is defect-free, that every dependency is trustworthy, or that the build platform satisfies a claimed SLSA level. Those are separate review and assurance questions. + +For offline evidence collection, download the attestation bundle while network access is available: + +```bash +gh attestation download PATH/TO/ARTIFACT -R ContextualWisdomLab/disksage +``` + +Retain the artifact, the downloaded bundle, the release tag, the source commit SHA, and the successful release workflow URL together. Do not substitute an attestation for a differently named or older artifact, even when the version string appears identical. + +## Failure and stale-evidence behavior + +The pipeline fails closed when an expected platform bundle, operational CLI, or checksum file is absent or duplicated. Artifact namespaces remain separate during download, so the same required filename contributed by two platform archives remains two filesystem entries and is rejected rather than silently overwritten. It validates checksum-record semantics and digests first so an invalid or redirected record receives the specific actionable diagnostic, then rejects any eighteenth regular file and every non-regular filesystem entry before attestation or publication. This exact-set rule prevents a build step from silently adding an unreviewed diagnostic archive, crash dump, log, secret-bearing output, or unrelated executable to the release. Path-scoped checks distinguish the Windows NSIS installer from the two separately shipped Windows operational CLI executables. The pipeline also fails when a checksum record names a file other than its adjacent operational CLI, contains additional fields or records, or presents a malformed digest. A checksum mismatch stops the attestation job. A failed, cancelled, skipped, neutral, missing, or stale-head attestation job cannot satisfy the publication dependency. + +Concurrency cancellation applies only to a first workflow attempt. A newer first attempt may cancel stale work for the same ref, but an explicit rerun has `github.run_attempt > 1` and therefore cannot cancel itself. A rerun remains non-authoritative until every required exact-head job in that attempt completes successfully. + +Attestations bind artifact digests, not mutable filenames. Rebuilding the same version produces different bytes and therefore requires new exact-build attestations. Evidence from an earlier workflow run or commit must never authorize publication of a later head. + +## Privacy and security boundaries + +The attestation describes build provenance and artifact digests. It must not include API keys, user data, local disk inventory, file paths from an operator workstation, model prompts, cleanup plans, or dynamic command output containing private host information. GitHub Secrets remain unavailable to pull-request-controlled release tests unless a separately reviewed workflow explicitly requires them. The exact 17-file allowlist is also a privacy boundary: unexpected diagnostics and transient build outputs are rejected rather than made durable through an attestation or GitHub Release. + +All third-party actions in the release path use immutable 40-character commit SHAs. The attestation job receives no `contents: write` permission, and the publication job receives neither `id-token: write` nor `attestations: write`. This separation limits the impact of a compromised publication or attestation step. + +## Rollback and migration + +Rollback is a workflow-source revert, not deletion or reuse of old attestations: + +1. revert the provenance workflow commit through an independently reviewed pull request; +2. rerun all exact-current-head test, security, packaging, and release-acceptance checks; +3. do not publish a replacement tag until the approved workflow state is on the protected branch; and +4. document why provenance was removed or changed in `CHANGELOG.md` and the release notes. + +Already published attestations remain historical evidence for their original artifact digests. They must not be presented as evidence for replacement binaries. If a release artifact is withdrawn, mark the GitHub Release accordingly and publish a new version with new provenance rather than silently replacing assets under the same tag. + +## MSA compatibility + +Provenance is attached at the DiskSage release boundary and does not require `naruon`, `contextual-orchestrator`, or organization-central services at runtime. CWL services may consume the same verification contract as a module integration gate: verify the artifact against `ContextualWisdomLab/disksage`, bind the verified digest in deployment metadata, and preserve that digest across promotion and rollback. + +## APA 7th references + +GitHub. (n.d.). *Using artifact attestations to establish provenance for builds*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations + +GitHub. (n.d.). *Verifying attestations offline*. GitHub Docs. Retrieved August 6, 2026, from https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/verify-attestations-offline + +in-toto Project. (n.d.). *in-toto attestation framework specification (Version 1.2)*. GitHub. Retrieved August 6, 2026, from https://github.com/in-toto/attestation/blob/v1.2.0/spec/README.md + +Supply-chain Levels for Software Artifacts. (n.d.). *SLSA specification (Version 1.2)*. The Linux Foundation. Retrieved August 6, 2026, from https://slsa.dev/spec/v1.2/ + +Supply-chain Levels for Software Artifacts. (n.d.). *Build: Verifying artifacts (Version 1.2)*. The Linux Foundation. Retrieved August 6, 2026, from https://slsa.dev/spec/v1.2/verifying-artifacts + +## Reference verification note + +The sources above were rechecked against their authoritative upstream locations on August 6, 2026. GitHub documentation was used for the supported action permissions and verification commands; upstream tag comparisons established the immutable action commits; the SLSA and in-toto specifications were used for provenance semantics and attestation structure. This document makes no unsupported claim that the workflow alone certifies a particular SLSA level. diff --git a/docs/doctoring/release-version-contract.md b/docs/doctoring/release-version-contract.md new file mode 100644 index 000000000..baf613060 --- /dev/null +++ b/docs/doctoring/release-version-contract.md @@ -0,0 +1,50 @@ +# Release version contract + +## Decision + +DiskSage fails closed before packaging when its buyer-visible release versions are not identical. `package.json`, `src-tauri/Cargo.toml`, and `src-tauri/tauri.conf.json` must each expose one identical Semantic Versioning value. A tag-triggered release must additionally use the exact tag `v`. + +The authoritative executable policy is `scripts/ci/release-version.mjs`. The package `build` command runs that policy before Vite compilation. Tauri executes `npm run build` through `beforeBuildCommand`, so the same check precedes Linux, Windows, and macOS bundle creation without relying on one operating system's shell syntax. Exact production coverage remains an independent CI authority owned by the coverage workflow contract; the release-version gate does not duplicate or weaken it. + +## Evidence contract + +The verifier: + +- reads each JSON manifest as UTF-8 and requires one non-empty string `version`; +- reads exactly one literal `version = "..."` from Cargo's `[package]` section and refuses absent, duplicated, or workspace-inherited ambiguity; +- requires all three values to be identical; +- requires the shared value to satisfy Semantic Versioning 2.0.0, including rejection of leading zeroes in numeric prerelease identifiers such as `1.0.0-01` and `1.0.0-alpha.01`; +- treats branch and pull-request builds as version-consistency checks without inventing a release tag; +- when `GITHUB_REF` is a tag reference, requires `GITHUB_REF_NAME` to equal `v` exactly; +- emits stable privacy-safe diagnostics containing only repository-controlled version values; and +- runs under ordinary read-only build authority before attestation or publication authority exists. + +`src/lib/releaseVersionContract.test.ts` verifies valid releases, prerelease/build metadata, Cargo section parsing, invalid JSON, missing and empty versions, duplicate Cargo versions, each manifest-disagreement path, malformed Semantic Versioning including numeric prerelease leading zeroes, tag drift, repository-root loading, and stable CLI success and failure behavior. + +## Failure and stale-evidence behavior + +A mismatch terminates `npm run build`; therefore Tauri cannot create a bundle and downstream provenance or publication jobs cannot receive release artifacts. A successful check from another commit, branch, tag, or workflow attempt is not reusable. Any manifest edit changes the exact current head and requires the complete Test, Release, security, review, packaging, provenance, and release-acceptance gates to run again. + +The contract does not bump versions automatically. Version changes remain explicit reviewed source changes across all three manifests and `CHANGELOG.md`. Release automation must never rewrite a tag or manifest to make a mismatch pass. + +## Rollback and migration + +Rollback requires a reviewed source revert. After a revert, run the exact-current-head coverage and packaging gates and confirm that all three manifests still agree. Do not reuse or replace assets under an existing tag; publish a new version with new provenance when replacement binaries are necessary. + +## MSA compatibility + +The verifier is standalone and requires no Naruon, contextual-orchestrator, model API, user data, or network access. CWL services that embed DiskSage may invoke the same package build contract or independently compare the three version sources and the deployment artifact digest before promotion. + +## APA 7th references + +npm, Inc. (n.d.). *Creating a package.json file*. npm Docs. Retrieved August 6, 2026, from https://docs.npmjs.com/creating-a-package-json-file/ + +Rust Project. (n.d.). *The manifest format*. The Cargo Book. Retrieved August 6, 2026, from https://doc.rust-lang.org/cargo/reference/manifest.html + +Semantic Versioning. (n.d.). *Semantic Versioning 2.0.0*. Retrieved August 6, 2026, from https://semver.org/spec/v2.0.0.html + +Tauri Programme within The Commons Conservancy. (n.d.). *Distribute*. Tauri. Retrieved August 6, 2026, from https://v2.tauri.app/distribute/ + +## Reference verification note + +The authoritative publisher sources above were rechecked on August 6, 2026. They support the manifest locations, package version semantics, including the prohibition on leading zeroes in numeric prerelease identifiers, and distribution boundary used by this contract; they do not imply external certification of DiskSage. diff --git a/package.json b/package.json index 92ff85c7d..5751fd168 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite dev", - "build": "vite build", + "verify:release-version": "node --input-type=module --eval \"import('./scripts/ci/release-version.mjs').then(({ main }) => main())\"", + "build": "npm run verify:release-version && vite build", "preview": "vite preview", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", diff --git a/scripts/ci/release-version.mjs b/scripts/ci/release-version.mjs new file mode 100644 index 000000000..bb5cedc5b --- /dev/null +++ b/scripts/ci/release-version.mjs @@ -0,0 +1,146 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +/** + * Read one JSON release manifest and return its non-empty version string. + * + * @param {string} manifestPath Repository-relative manifest path. + * @param {(path: string, encoding: BufferEncoding) => string} readText Text reader seam. + * @returns {string} The manifest version. + */ +export function readJsonVersion(manifestPath, readText = readFileSync) { + let parsed; + try { + parsed = JSON.parse(readText(manifestPath, 'utf8')); + } catch { + throw new Error(`Release manifest ${manifestPath} is missing or invalid JSON.`); + } + if (typeof parsed.version !== 'string' || parsed.version.length === 0) { + throw new Error( + `Release manifest ${manifestPath} must define one non-empty string version.`, + ); + } + return parsed.version; +} + +/** + * Read the Cargo package section and return its single literal version. + * + * Workspace-inherited or duplicated versions are refused because the packaged + * application must expose one buyer-verifiable version before publication. + * + * @param {string} manifestPath Repository-relative Cargo manifest path. + * @param {(path: string, encoding: BufferEncoding) => string} readText Text reader seam. + * @returns {string} The Cargo package version. + */ +export function readCargoPackageVersion(manifestPath, readText = readFileSync) { + const lines = readText(manifestPath, 'utf8').split(/\r?\n/); + let inPackage = false; + const versions = []; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed === '[package]') { + inPackage = true; + continue; + } + if (inPackage && /^\[.*\]$/.test(trimmed)) break; + if (!inPackage) continue; + const match = line.match(/^\s*version\s*=\s*"([^"]+)"\s*(?:#.*)?$/); + if (match) versions.push(match[1]); + } + if (versions.length !== 1) { + throw new Error( + `Release manifest ${manifestPath} must define exactly one package version.`, + ); + } + return versions[0]; +} + +/** + * Validate manifest agreement, Semantic Versioning, and an optional release tag. + * + * @param {{packageVersion: string, cargoVersion: string, tauriVersion: string, githubRef?: string, githubRefName?: string}} input Version evidence. + * @returns {string} Stable success message suitable for CI logs. + */ +export function validateReleaseVersion({ + packageVersion, + cargoVersion, + tauriVersion, + githubRef = '', + githubRefName = '', +}) { + if (packageVersion !== cargoVersion || packageVersion !== tauriVersion) { + throw new Error( + `Release manifest versions disagree: package.json=${packageVersion}, Cargo.toml=${cargoVersion}, tauri.conf.json=${tauriVersion}.`, + ); + } + const semver = + /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+(?:[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/; + if (!semver.test(packageVersion)) { + throw new Error( + `Release manifest version ${packageVersion} is not valid Semantic Versioning.`, + ); + } + if (githubRef.startsWith('refs/tags/')) { + const expectedTag = `v${packageVersion}`; + if (githubRefName !== expectedTag) { + throw new Error( + `Release tag ${githubRefName} does not match manifest version ${expectedTag}.`, + ); + } + } + return `Release version contract passed for ${packageVersion}.`; +} + +/** + * Read all release manifests from one repository root and validate their tag. + * + * @param {{repositoryRoot?: string, environment?: NodeJS.ProcessEnv, readText?: (path: string, encoding: BufferEncoding) => string}} options Runtime seams. + * @returns {string} Stable success message. + */ +export function verifyReleaseVersion({ + repositoryRoot = process.cwd(), + environment = process.env, + readText = readFileSync, +} = {}) { + return validateReleaseVersion({ + packageVersion: readJsonVersion( + resolve(repositoryRoot, 'package.json'), + readText, + ), + cargoVersion: readCargoPackageVersion( + resolve(repositoryRoot, 'src-tauri/Cargo.toml'), + readText, + ), + tauriVersion: readJsonVersion( + resolve(repositoryRoot, 'src-tauri/tauri.conf.json'), + readText, + ), + githubRef: environment.GITHUB_REF ?? '', + githubRefName: environment.GITHUB_REF_NAME ?? '', + }); +} + +/** + * Run the release-version gate with injectable output and exit-code boundaries. + * + * @param {{verify?: () => string, writeOutput?: (message: string) => void, writeError?: (message: string) => void, setExitCode?: (code: number) => void}} options Runtime seams. + * @returns {boolean} Whether validation passed. + */ +export function main({ + verify = verifyReleaseVersion, + writeOutput = console.log, + writeError = console.error, + setExitCode = (code) => { + process.exitCode = code; + }, +} = {}) { + try { + writeOutput(verify()); + return true; + } catch (error) { + writeError(error instanceof Error ? error.message : 'Unknown release version failure.'); + setExitCode(1); + return false; + } +} \ No newline at end of file diff --git a/src/lib/releaseArtifactAllowlistContract.test.ts b/src/lib/releaseArtifactAllowlistContract.test.ts new file mode 100644 index 000000000..2c574a3c8 --- /dev/null +++ b/src/lib/releaseArtifactAllowlistContract.test.ts @@ -0,0 +1,149 @@ +import { createHash } from 'node:crypto'; +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const operationalAssetNames = [ + 'disksage-cloud-plan-linux-x86_64', + 'disksage-duplicate-audit-linux-x86_64', + 'disksage-cloud-plan-windows-x86_64.exe', + 'disksage-duplicate-audit-windows-x86_64.exe', + 'disksage-cloud-plan-macos-arm64', + 'disksage-duplicate-audit-macos-arm64', +] as const; + +/** Read one UTF-8 file from the source-controlled repository root. */ +function readRepositoryFile(relativePath: string): string { + return readFileSync(resolve(repositoryRoot, relativePath), 'utf8'); +} + +/** Return one top-level GitHub Actions job block after normalizing line endings. */ +function extractWorkflowJob(workflow: string, jobName: string): string { + const normalizedWorkflow = workflow.replace(/\r\n?/g, '\n'); + const marker = `\n ${jobName}:\n`; + const start = normalizedWorkflow.indexOf(marker); + if (start < 0) throw new Error(`Missing workflow job: ${jobName}`); + const remaining = normalizedWorkflow.slice(start + marker.length); + const nextJobOffset = remaining.search(/\n [a-zA-Z0-9_-]+:\n/); + return nextJobOffset < 0 ? remaining : remaining.slice(0, nextJobOffset); +} + +/** Extract the literal Bash body from one named workflow step. */ +function extractWorkflowRunScript(job: string, stepName: string): string { + const normalizedJob = job.replace(/\r\n?/g, '\n'); + const stepMarker = ` - name: ${stepName}\n`; + const stepStart = normalizedJob.indexOf(stepMarker); + if (stepStart < 0) throw new Error(`Missing workflow step: ${stepName}`); + const runMarker = ' run: |\n'; + const runStart = normalizedJob.indexOf(runMarker, stepStart); + if (runStart < 0) throw new Error(`Missing literal run block: ${stepName}`); + const remaining = normalizedJob.slice(runStart + runMarker.length); + const nextStepOffset = remaining.search(/\n - (?:name:|uses:)/); + const script = nextStepOffset < 0 ? remaining : remaining.slice(0, nextStepOffset); + return script + .split('\n') + .map((line) => (line.startsWith(' ') ? line.slice(10) : line)) + .join('\n'); +} + +/** Create one complete, valid release artifact tree for verifier execution. */ +function createCompleteReleaseFixture(): string { + const fixtureRoot = mkdtempSync(join(tmpdir(), 'disksage-release-allowlist-')); + const artifactRoot = join(fixtureRoot, 'release-artifacts'); + const bundlePaths = [ + 'ubuntu/bundle/deb/disksage.deb', + 'ubuntu/bundle/appimage/disksage.AppImage', + 'windows/bundle/msi/disksage.msi', + 'windows/bundle/nsis/disksage-setup.exe', + 'macos/bundle/dmg/disksage.dmg', + ]; + for (const bundlePath of bundlePaths) { + const absolutePath = join(artifactRoot, bundlePath); + mkdirSync(dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, `bundle:${bundlePath}`); + } + for (const assetName of operationalAssetNames) { + const platformDirectory = assetName.includes('windows') + ? 'windows' + : assetName.includes('macos') + ? 'macos' + : 'ubuntu'; + const assetPath = join(artifactRoot, platformDirectory, assetName); + const bytes = Buffer.from(`operational-cli:${assetName}`); + mkdirSync(dirname(assetPath), { recursive: true }); + writeFileSync(assetPath, bytes); + writeFileSync( + `${assetPath}.sha256`, + `${createHash('sha256').update(bytes).digest('hex')} ${assetName}\n`, + ); + } + return fixtureRoot; +} + +/** Execute the source-controlled release admission script against one fixture. */ +function runReleaseArtifactVerifier(fixtureRoot: string) { + const workflow = readRepositoryFile('.github/workflows/release.yml'); + const attestJob = extractWorkflowJob(workflow, 'attest-release'); + const verifier = extractWorkflowRunScript( + attestJob, + 'Verify release artifact checksums', + ); + return spawnSync('bash', ['-c', verifier], { + cwd: fixtureRoot, + encoding: 'utf8', + }); +} + +describe('release artifact exact-set admission', () => { + it.runIf(process.platform !== 'win32')( + 'rejects an unexpected file that would otherwise be attested and published', + () => { + const fixtureRoot = createCompleteReleaseFixture(); + try { + writeFileSync( + join(fixtureRoot, 'release-artifacts', 'ubuntu', 'unexpected-debug-dump.txt'), + 'buyer-private-or-unreviewed-output', + ); + + const result = runReleaseArtifactVerifier(fixtureRoot); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('Unexpected release artifact entries'); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }, + ); + + it.runIf(process.platform !== 'win32')( + 'rejects a symlink before it can become a provenance subject', + () => { + const fixtureRoot = createCompleteReleaseFixture(); + try { + symlinkSync( + 'disksage-cloud-plan-linux-x86_64', + join(fixtureRoot, 'release-artifacts', 'ubuntu', 'unexpected-cli-alias'), + ); + + const result = runReleaseArtifactVerifier(fixtureRoot); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('non-regular path'); + expect(result.stderr).toContain('unexpected-cli-alias'); + } finally { + rmSync(fixtureRoot, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/src/lib/releaseVersionContract.test.ts b/src/lib/releaseVersionContract.test.ts new file mode 100644 index 000000000..4f60f5f27 --- /dev/null +++ b/src/lib/releaseVersionContract.test.ts @@ -0,0 +1,147 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; +import { + main, + readCargoPackageVersion, + readJsonVersion, + validateReleaseVersion, + verifyReleaseVersion, +} from '../../scripts/ci/release-version.mjs'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +/** Read one UTF-8 file from the source-controlled repository root. */ +function readRepositoryFile(relativePath: string): string { + return readFileSync(resolve(repositoryRoot, relativePath), 'utf8'); +} + +describe('release version contract', () => { + it('binds Tauri packaging to the cross-platform version gate', () => { + const packageManifest = JSON.parse(readRepositoryFile('package.json')) as { + scripts: Record; + }; + const tauriConfig = JSON.parse( + readRepositoryFile('src-tauri/tauri.conf.json'), + ) as { build: { beforeBuildCommand: string } }; + + expect(packageManifest.scripts['verify:release-version']).toContain( + "import('./scripts/ci/release-version.mjs')", + ); + expect(packageManifest.scripts.build).toBe( + 'npm run verify:release-version && vite build', + ); + expect(tauriConfig.build.beforeBuildCommand).toBe('npm run build'); + }); + + it('reads valid JSON and Cargo package versions', () => { + expect(readJsonVersion('package.json', () => '{"version":"1.2.3"}')).toBe('1.2.3'); + expect( + readCargoPackageVersion( + 'Cargo.toml', + () => '# preamble\n[workspace]\nmembers = []\n\n[package]\nname = "disksage"\nversion = "1.2.3" # buyer-visible\nedition = "2021"\n\n[dependencies]\n', + ), + ).toBe('1.2.3'); + }); + + it('refuses invalid, missing, empty, or ambiguous manifest versions', () => { + expect(() => readJsonVersion('broken.json', () => '{')).toThrow( + 'Release manifest broken.json is missing or invalid JSON.', + ); + expect(() => readJsonVersion('missing.json', () => '{}')).toThrow( + 'Release manifest missing.json must define one non-empty string version.', + ); + expect(() => readJsonVersion('empty.json', () => '{"version":""}')).toThrow( + 'Release manifest empty.json must define one non-empty string version.', + ); + expect(() => readCargoPackageVersion('missing.toml', () => '[workspace]\nmembers = []\n')).toThrow( + 'Release manifest missing.toml must define exactly one package version.', + ); + expect(() => readCargoPackageVersion('duplicate.toml', () => '[package]\nversion = "1.0.0"\nversion = "1.0.1"\n')).toThrow( + 'Release manifest duplicate.toml must define exactly one package version.', + ); + }); + + it('accepts matching manifests for branches and exact release tags', () => { + expect(validateReleaseVersion({ + packageVersion: '1.2.3-beta.1+build.7', + cargoVersion: '1.2.3-beta.1+build.7', + tauriVersion: '1.2.3-beta.1+build.7', + })).toBe('Release version contract passed for 1.2.3-beta.1+build.7.'); + expect(validateReleaseVersion({ + packageVersion: '0.1.0', + cargoVersion: '0.1.0', + tauriVersion: '0.1.0', + githubRef: 'refs/tags/v0.1.0', + githubRefName: 'v0.1.0', + })).toBe('Release version contract passed for 0.1.0.'); + }); + + it('refuses manifest disagreement, malformed SemVer, and tag drift', () => { + expect(() => validateReleaseVersion({ + packageVersion: '0.1.0', cargoVersion: '0.2.0', tauriVersion: '0.1.0', + })).toThrow('Release manifest versions disagree: package.json=0.1.0, Cargo.toml=0.2.0, tauri.conf.json=0.1.0.'); + expect(() => validateReleaseVersion({ + packageVersion: '0.1.0', cargoVersion: '0.1.0', tauriVersion: '0.2.0', + })).toThrow('Release manifest versions disagree: package.json=0.1.0, Cargo.toml=0.1.0, tauri.conf.json=0.2.0.'); + for (const invalidVersion of ['01.0.0', '1.0.0-01', '1.0.0-alpha.01']) { + expect(() => validateReleaseVersion({ + packageVersion: invalidVersion, + cargoVersion: invalidVersion, + tauriVersion: invalidVersion, + })).toThrow(`Release manifest version ${invalidVersion} is not valid Semantic Versioning.`); + } + expect(() => validateReleaseVersion({ + packageVersion: '0.1.0', cargoVersion: '0.1.0', tauriVersion: '0.1.0', + githubRef: 'refs/tags/v0.2.0', githubRefName: 'v0.2.0', + })).toThrow('Release tag v0.2.0 does not match manifest version v0.1.0.'); + }); + + it('loads repository manifests through injectable runtime boundaries', () => { + const manifests = new Map([ + ['/fixture/package.json', '{"version":"0.1.0"}'], + ['/fixture/src-tauri/Cargo.toml', '[package]\nname = "disksage"\nversion = "0.1.0"\n[dependencies]\n'], + ['/fixture/src-tauri/tauri.conf.json', '{"version":"0.1.0"}'], + ]); + const readText = vi.fn((path: string) => { + const value = manifests.get(path); + if (value === undefined) throw new Error(`unexpected path ${path}`); + return value; + }); + expect(verifyReleaseVersion({ + repositoryRoot: '/fixture', + environment: { GITHUB_REF: 'refs/tags/v0.1.0', GITHUB_REF_NAME: 'v0.1.0' }, + readText, + })).toBe('Release version contract passed for 0.1.0.'); + expect(readText).toHaveBeenCalledTimes(3); + expect(verifyReleaseVersion()).toBe('Release version contract passed for 0.1.0.'); + }); + + it('reports stable success and failure outcomes at the CLI boundary', () => { + const output: string[] = []; + const errors: string[] = []; + const exitCodes: number[] = []; + expect(main({ + verify: () => 'passed', + writeOutput: (message: string) => output.push(message), + writeError: (message: string) => errors.push(message), + setExitCode: (code: number) => exitCodes.push(code), + })).toBe(true); + expect(output).toEqual(['passed']); + expect(main({ + verify: () => { throw new Error('failed'); }, + writeOutput: (message: string) => output.push(message), + writeError: (message: string) => errors.push(message), + setExitCode: (code: number) => exitCodes.push(code), + })).toBe(false); + expect(main({ + verify: () => { throw 'non-error'; }, + writeOutput: (message: string) => output.push(message), + writeError: (message: string) => errors.push(message), + setExitCode: (code: number) => exitCodes.push(code), + })).toBe(false); + expect(errors).toEqual(['failed', 'Unknown release version failure.']); + expect(exitCodes).toEqual([1, 1]); + }); +}); diff --git a/src/lib/releaseWorkflowRetryContract.test.ts b/src/lib/releaseWorkflowRetryContract.test.ts new file mode 100644 index 000000000..860b8c94d --- /dev/null +++ b/src/lib/releaseWorkflowRetryContract.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +/** Read one UTF-8 repository file from the source-controlled project root. */ +function readRepositoryFile(relativePath: string): string { + return readFileSync(resolve(repositoryRoot, relativePath), 'utf8').replace(/\r\n?/g, '\n'); +} + +describe('release workflow retry contract', () => { + it('cancels stale first attempts without self-cancelling explicit reruns', () => { + const workflow = readRepositoryFile('.github/workflows/release.yml'); + expect(workflow).toContain("cancel-in-progress: ${{ github.run_attempt == 1 }}"); + expect(workflow).not.toContain('cancel-in-progress: true'); + }); + + it('documents retry-safe concurrency in authoritative evidence', () => { + const doctoring = readRepositoryFile('docs/doctoring/release-artifact-provenance.md'); + const changelog = readRepositoryFile('CHANGELOG.md'); + expect(doctoring).toContain('explicit rerun attempts do not cancel themselves'); + expect(doctoring).toContain('github.run_attempt == 1'); + expect(changelog).toContain('retry-safe release concurrency'); + }); +});