diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c8f9721..1ca5135c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,12 +23,13 @@ permissions: contents: read jobs: - # Prove the package surface before building release assets + # Prove the package surface and resolve one immutable tagged source commit. release-preflight: name: Release Preflight runs-on: ${{ github.repository == 'EffortlessMetrics/shiplog-swarm' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && 'self-hosted' || 'ubuntu-latest' }} outputs: release_tag: ${{ steps.release_tag.outputs.release_tag }} + release_sha: ${{ steps.release_commit.outputs.release_sha }} steps: - name: Checkout code @@ -63,15 +64,46 @@ jobs: echo "release_tag=$tag" >> "$GITHUB_OUTPUT" - - name: Release hold guard - run: bash scripts/check-release-hold.sh "${{ steps.release_tag.outputs.release_tag }}" + - name: Checkout exact release tag + shell: bash + env: + RELEASE_TAG: ${{ steps.release_tag.outputs.release_tag }} + run: | + set -euo pipefail + tag="$RELEASE_TAG" + git fetch --force --depth=1 origin "refs/tags/$tag:refs/tags/$tag" + git checkout --detach "$tag" - - name: Checkout manual dispatch tag - if: github.event_name == 'workflow_dispatch' + - name: Resolve immutable release commit + id: release_commit shell: bash + env: + RELEASE_TAG: ${{ steps.release_tag.outputs.release_tag }} + EVENT_NAME: ${{ github.event_name }} + WEBHOOK_SHA: ${{ github.sha }} run: | - git fetch --depth=1 origin "refs/tags/${{ steps.release_tag.outputs.release_tag }}:refs/tags/${{ steps.release_tag.outputs.release_tag }}" - git checkout --detach "${{ steps.release_tag.outputs.release_tag }}" + set -euo pipefail + tag="$RELEASE_TAG" + release_sha="$(git rev-parse HEAD)" + tag_commit="$(git rev-list -n 1 "$tag")" + if [[ "$release_sha" != "$tag_commit" ]]; then + echo "checked out $release_sha, but $tag resolves to $tag_commit" >&2 + exit 1 + fi + if [[ "$EVENT_NAME" == "push" && "$release_sha" != "$WEBHOOK_SHA" ]]; then + echo "tag $tag resolved to $release_sha, but the push webhook identified $WEBHOOK_SHA" >&2 + exit 1 + fi + if [[ ! "$release_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "release tag did not resolve to a full commit SHA" >&2 + exit 1 + fi + echo "release_sha=$release_sha" >> "$GITHUB_OUTPUT" + + - name: Release hold guard + env: + RELEASE_TAG: ${{ steps.release_tag.outputs.release_tag }} + run: bash scripts/check-release-hold.sh "$RELEASE_TAG" - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master @@ -94,7 +126,7 @@ jobs: - name: Publish dry run (allowlist) run: scripts/publish-dry-run.sh - # Build binaries for all platforms + # Build the exact tagged binary on every supported target. build-binary: name: Build ${{ matrix.target }} runs-on: ${{ matrix.os }} @@ -116,19 +148,27 @@ jobs: ext: .exe steps: - - name: Checkout code + - name: Checkout exact release tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ needs.release-preflight.outputs.release_tag }} + - name: Confirm exact release commit + shell: bash + env: + RELEASE_SHA: ${{ needs.release-preflight.outputs.release_sha }} + run: test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: toolchain: "1.95.0" - name: Install Rust target - run: rustup target add ${{ matrix.target }} + env: + TARGET: ${{ matrix.target }} + run: rustup target add "$TARGET" - name: Cache cargo registry uses: actions/cache@v6 @@ -149,14 +189,20 @@ jobs: key: ${{ runner.os }}-${{ matrix.target }}-cargo-release-${{ hashFiles('**/Cargo.lock') }} - name: Build release - run: cargo build --release --target ${{ matrix.target }} -p shiplog --locked + env: + TARGET: ${{ matrix.target }} + run: cargo build --release --target "$TARGET" -p shiplog --locked - name: Stage binary shell: bash + env: + TARGET: ${{ matrix.target }} + EXT: ${{ matrix.ext }} run: | + set -euo pipefail mkdir -p dist - src="target/${{ matrix.target }}/release/shiplog${{ matrix.ext }}" - dest="dist/shiplog-${{ matrix.target }}${{ matrix.ext }}" + src="target/$TARGET/release/shiplog$EXT" + dest="dist/shiplog-$TARGET$EXT" if [[ ! -f "$src" ]]; then echo "missing expected binary: $src" >&2 find target -maxdepth 4 -type f -name 'shiplog*' -print >&2 || true @@ -171,7 +217,7 @@ jobs: path: dist/shiplog-${{ matrix.target }}${{ matrix.ext }} retention-days: 5 - # Create GitHub release + # Swarm records the source handoff; canonical source creates the draft release. create-release: name: Create Release runs-on: ${{ github.repository == 'EffortlessMetrics/shiplog-swarm' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && 'self-hosted' || 'ubuntu-latest' }} @@ -180,43 +226,119 @@ jobs: version: ${{ steps.version.outputs.version }} steps: - - name: Checkout code + - name: Checkout exact release tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ needs.release-preflight.outputs.release_tag }} + - name: Confirm exact release commit + shell: bash + env: + RELEASE_SHA: ${{ needs.release-preflight.outputs.release_sha }} + run: test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + - name: Extract version id: version + shell: bash + env: + RELEASE_TAG: ${{ needs.release-preflight.outputs.release_tag }} run: | - VERSION=${{ needs.release-preflight.outputs.release_tag }} + VERSION="$RELEASE_TAG" VERSION=${VERSION#v} - echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "version=$VERSION" >> "$GITHUB_OUTPUT" - name: Record source release handoff + shell: bash run: | - echo "Swarm verifies release inputs but cannot create a GitHub release." - echo "Promote this exact proven tree to EffortlessMetrics/shiplog for authorized release execution." >> "$GITHUB_STEP_SUMMARY" + { + echo "Swarm verifies release inputs but cannot create a GitHub release." + echo "Promote this exact proven tree to EffortlessMetrics/shiplog for authorized release execution." + } >> "$GITHUB_STEP_SUMMARY" - # Upload binaries to release + # Assemble one immutable workflow-staged candidate set before acceptance. upload-assets: - name: Upload Assets + name: Stage Release Candidates runs-on: ${{ github.repository == 'EffortlessMetrics/shiplog-swarm' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && 'self-hosted' || 'ubuntu-latest' }} needs: [release-preflight, build-binary, create-release] + outputs: + candidate_artifact_id: ${{ steps.candidates.outputs.artifact-id }} + candidate_artifact_digest: ${{ steps.candidates.outputs.artifact-digest }} + candidate_manifest_sha256: ${{ steps.candidate_manifest.outputs.candidate_manifest_sha256 }} + checksum_manifest_sha256: ${{ steps.candidate_manifest.outputs.checksum_manifest_sha256 }} steps: - - name: Download all artifacts - uses: actions/download-artifact@v8 + - name: Checkout exact release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ needs.release-preflight.outputs.release_tag }} + + - name: Confirm exact release commit + shell: bash + env: + RELEASE_SHA: ${{ needs.release-preflight.outputs.release_sha }} + run: test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + + - name: Download all binary artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: artifacts - - name: Generate checksums + - name: Generate candidate manifest and checksums + id: candidate_manifest + shell: bash + env: + RELEASE_TAG: ${{ needs.release-preflight.outputs.release_tag }} + RELEASE_SHA: ${{ needs.release-preflight.outputs.release_sha }} + REPOSITORY: ${{ github.repository }} + WORKFLOW_RUN_ID: ${{ github.run_id }} + WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }} run: | + set -euo pipefail cd artifacts - find . -maxdepth 2 -type f ! -name SHA256SUMS.txt -print0 | sort -z | xargs -0 sha256sum > SHA256SUMS.txt + + expected=( + "shiplog-x86_64-unknown-linux-gnu/shiplog-x86_64-unknown-linux-gnu" + "shiplog-x86_64-apple-darwin/shiplog-x86_64-apple-darwin" + "shiplog-aarch64-apple-darwin/shiplog-aarch64-apple-darwin" + "shiplog-x86_64-pc-windows-msvc/shiplog-x86_64-pc-windows-msvc.exe" + ) + for path in "${expected[@]}"; do + if [[ ! -f "$path" ]]; then + echo "missing expected staged binary: $path" >&2 + find . -maxdepth 3 -type f -print >&2 + exit 1 + fi + done + + : > SHA256SUMS.txt + for path in "${expected[@]}"; do + sha256sum "$path" >> SHA256SUMS.txt + done + checksum_manifest_sha256="$(sha256sum SHA256SUMS.txt | awk '{print $1}')" + + asset_count="${#expected[@]}" + cat > RELEASE_CANDIDATE.txt <> "$GITHUB_OUTPUT" + echo "candidate_manifest_sha256=$candidate_manifest_sha256" >> "$GITHUB_OUTPUT" + cat RELEASE_CANDIDATE.txt cat SHA256SUMS.txt - - name: Retain verified release candidates + - name: Retain exact staged release candidates + id: candidates uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: verified-release-candidates @@ -226,22 +348,92 @@ jobs: artifacts/shiplog-aarch64-apple-darwin/shiplog-aarch64-apple-darwin artifacts/shiplog-x86_64-pc-windows-msvc/shiplog-x86_64-pc-windows-msvc.exe artifacts/SHA256SUMS.txt - retention-days: 5 + artifacts/RELEASE_CANDIDATE.txt + retention-days: 30 - # Validate the release + - name: Record staged candidate identity + shell: bash + env: + RELEASE_TAG: ${{ needs.release-preflight.outputs.release_tag }} + RELEASE_SHA: ${{ needs.release-preflight.outputs.release_sha }} + ARTIFACT_ID: ${{ steps.candidates.outputs.artifact-id }} + ARTIFACT_DIGEST: ${{ steps.candidates.outputs.artifact-digest }} + CANDIDATE_MANIFEST_SHA256: ${{ steps.candidate_manifest.outputs.candidate_manifest_sha256 }} + CHECKSUM_MANIFEST_SHA256: ${{ steps.candidate_manifest.outputs.checksum_manifest_sha256 }} + run: | + { + echo "## Workflow-staged candidate" + echo "- Tag: \`$RELEASE_TAG\`" + echo "- Source commit: \`$RELEASE_SHA\`" + echo "- Artifact id: \`$ARTIFACT_ID\`" + echo "- Artifact digest: \`$ARTIFACT_DIGEST\`" + echo "- Candidate manifest SHA-256: \`$CANDIDATE_MANIFEST_SHA256\`" + echo "- Checksum manifest SHA-256: \`$CHECKSUM_MANIFEST_SHA256\`" + } >> "$GITHUB_STEP_SUMMARY" + + # Validate the staged candidate while retaining independent release-mode checks. release-validation: name: Release Validation runs-on: ${{ github.repository == 'EffortlessMetrics/shiplog-swarm' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && 'self-hosted' || 'ubuntu-latest' }} - needs: [release-preflight, build-binary, create-release] + needs: [release-preflight, upload-assets] steps: - - name: Checkout code + - name: Checkout exact release tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ needs.release-preflight.outputs.release_tag }} - - name: Install Rust toolchain + - name: Confirm exact release commit + shell: bash + env: + RELEASE_SHA: ${{ needs.release-preflight.outputs.release_sha }} + run: test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + + - name: Download staged candidate set + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: verified-release-candidates + path: candidate + + - name: Verify staged candidate identity + shell: bash + env: + RELEASE_TAG: ${{ needs.release-preflight.outputs.release_tag }} + RELEASE_SHA: ${{ needs.release-preflight.outputs.release_sha }} + CANDIDATE_MANIFEST_SHA256: ${{ needs.upload-assets.outputs.candidate_manifest_sha256 }} + run: | + set -euo pipefail + expected_assets=( + shiplog-x86_64-unknown-linux-gnu/shiplog-x86_64-unknown-linux-gnu + shiplog-x86_64-apple-darwin/shiplog-x86_64-apple-darwin + shiplog-aarch64-apple-darwin/shiplog-aarch64-apple-darwin + shiplog-x86_64-pc-windows-msvc/shiplog-x86_64-pc-windows-msvc.exe + ) + grep -Fxq "schema_version=1" candidate/RELEASE_CANDIDATE.txt + grep -Fxq "release_tag=$RELEASE_TAG" candidate/RELEASE_CANDIDATE.txt + grep -Fxq "source_sha=$RELEASE_SHA" candidate/RELEASE_CANDIDATE.txt + grep -Fxq "asset_count=${#expected_assets[@]}" candidate/RELEASE_CANDIDATE.txt + expected="$(sed -n 's/^checksum_manifest_sha256=//p' candidate/RELEASE_CANDIDATE.txt)" + actual="$(sha256sum candidate/SHA256SUMS.txt | awk '{print $1}')" + test "$actual" = "$expected" + test "$(sha256sum candidate/RELEASE_CANDIDATE.txt | awk '{print $1}')" = "$CANDIDATE_MANIFEST_SHA256" + + - name: Smoke staged Linux candidate + shell: bash + run: | + set -euo pipefail + binary="candidate/shiplog-x86_64-unknown-linux-gnu/shiplog-x86_64-unknown-linux-gnu" + expected="$(awk '$2 == "shiplog-x86_64-unknown-linux-gnu/shiplog-x86_64-unknown-linux-gnu" {print $1}' candidate/SHA256SUMS.txt)" + actual="$(sha256sum "$binary" | awk '{print $1}')" + test "$actual" = "$expected" + chmod +x "$binary" + "$binary" --help + "$binary" --version + "$binary" collect --help + "$binary" render --help + + - name: Install Rust toolchain for independent release-mode checks uses: dtolnay/rust-toolchain@master with: toolchain: "1.95.0" @@ -252,35 +444,16 @@ jobs: path: target key: ${{ runner.os }}-cargo-release-${{ hashFiles('**/Cargo.lock') }} - - name: Download Linux x86_64 artifact - uses: actions/download-artifact@v8 - with: - name: shiplog-x86_64-unknown-linux-gnu - path: artifacts/shiplog-x86_64-unknown-linux-gnu - - - name: Smoke downloaded Linux artifact - run: | - chmod +x artifacts/shiplog-x86_64-unknown-linux-gnu/shiplog-x86_64-unknown-linux-gnu - artifacts/shiplog-x86_64-unknown-linux-gnu/shiplog-x86_64-unknown-linux-gnu --help - artifacts/shiplog-x86_64-unknown-linux-gnu/shiplog-x86_64-unknown-linux-gnu --version - artifacts/shiplog-x86_64-unknown-linux-gnu/shiplog-x86_64-unknown-linux-gnu collect --help - artifacts/shiplog-x86_64-unknown-linux-gnu/shiplog-x86_64-unknown-linux-gnu render --help - - - name: Build release + - name: Build independent release-mode test binary run: cargo build --release -p shiplog --locked - - name: Run smoke tests + - name: Run independent command smoke tests + shell: bash run: | - echo "Testing --help..." + set -euo pipefail ./target/release/shiplog --help - - echo "Testing --version..." ./target/release/shiplog --version - - echo "Testing help subcommand..." ./target/release/shiplog help - - echo "Testing product command help..." ./target/release/shiplog init --help ./target/release/shiplog doctor --help ./target/release/shiplog config --help @@ -308,26 +481,26 @@ jobs: fi - name: Generate validation summary + shell: bash + env: + CANDIDATE_ARTIFACT_DIGEST: ${{ needs.upload-assets.outputs.candidate_artifact_digest }} + CANDIDATE_MANIFEST_SHA256: ${{ needs.upload-assets.outputs.candidate_manifest_sha256 }} run: | - echo "# Release Validation Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "## Build Status" >> $GITHUB_STEP_SUMMARY - echo "- Release build: **SUCCESS**" >> $GITHUB_STEP_SUMMARY - echo "- Multi-platform builds: **SUCCESS** (Linux x86_64, macOS x86_64, macOS ARM64, Windows x86_64)" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "## Smoke Tests" >> $GITHUB_STEP_SUMMARY - echo "- Downloaded Linux artifact help/version: **PASSED**" >> $GITHUB_STEP_SUMMARY - echo "- Downloaded Linux artifact collect/render help: **PASSED**" >> $GITHUB_STEP_SUMMARY - echo "- Help command: **PASSED**" >> $GITHUB_STEP_SUMMARY - echo "- Version command: **PASSED**" >> $GITHUB_STEP_SUMMARY - echo "- Help subcommand: **PASSED**" >> $GITHUB_STEP_SUMMARY - echo "- Product command help: **PASSED**" >> $GITHUB_STEP_SUMMARY - - # Prove the published binaries from the outside on every supported target. + { + echo "# Release Validation Results" + echo + echo "- Staged candidate artifact digest: \`$CANDIDATE_ARTIFACT_DIGEST\`" + echo "- Candidate manifest SHA-256: \`$CANDIDATE_MANIFEST_SHA256\`" + echo "- Staged Linux candidate help/version/collect/render: **PASSED**" + echo "- Independent release-mode command checks: **PASSED**" + echo "- Multi-platform candidate assembly: **PASSED**" + } >> "$GITHUB_STEP_SUMMARY" + + # Prove the same workflow-staged bytes from empty directories on all targets. release-binary-acceptance: name: First-use ${{ matrix.name }} runs-on: ${{ matrix.os }} - needs: [release-preflight, create-release] + needs: [release-preflight, upload-assets] strategy: fail-fast: false matrix: @@ -335,15 +508,19 @@ jobs: - name: Linux x86_64 os: ubuntu-latest shell: bash + target: x86_64-unknown-linux-gnu - name: macOS Intel os: macos-15-intel shell: bash + target: x86_64-apple-darwin - name: macOS Apple Silicon os: macos-15 shell: bash + target: aarch64-apple-darwin - name: Windows x86_64 os: windows-latest shell: pwsh + target: x86_64-pc-windows-msvc steps: - name: Checkout release scripts @@ -352,35 +529,168 @@ jobs: persist-credentials: false ref: ${{ needs.release-preflight.outputs.release_tag }} - - name: Run published-binary first-use acceptance (Unix) + - name: Confirm exact release commit + shell: bash + env: + RELEASE_SHA: ${{ needs.release-preflight.outputs.release_sha }} + run: test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + + - name: Download exact staged candidate set + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: verified-release-candidates + path: ${{ runner.temp }}/release-candidate + + - name: Run staged-binary first-use acceptance (Unix) if: matrix.shell == 'bash' shell: bash env: - SHIPLOG_RELEASE_REPO: ${{ github.repository }} - run: | - bash scripts/release-install-smoke.sh "${{ needs.release-preflight.outputs.release_tag }}" + SHIPLOG_RELEASE_CANDIDATE_DIR: ${{ runner.temp }}/release-candidate + SHIPLOG_RELEASE_SOURCE_SHA: ${{ needs.release-preflight.outputs.release_sha }} + RELEASE_TAG: ${{ needs.release-preflight.outputs.release_tag }} + run: bash scripts/release-install-smoke.sh "$RELEASE_TAG" - - name: Run published-binary first-use acceptance (Windows) + - name: Run staged-binary first-use acceptance (Windows) if: matrix.shell == 'pwsh' shell: pwsh env: - SHIPLOG_RELEASE_REPO: ${{ github.repository }} + SHIPLOG_RELEASE_CANDIDATE_DIR: ${{ runner.temp }}/release-candidate + SHIPLOG_RELEASE_SOURCE_SHA: ${{ needs.release-preflight.outputs.release_sha }} + RELEASE_TAG: ${{ needs.release-preflight.outputs.release_tag }} + run: pwsh -NoProfile -File scripts/release-install-smoke.ps1 "$env:RELEASE_TAG" + + - name: Upload first-use diagnostics and receipts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-first-use-${{ matrix.target }} + path: target/release-install-smoke/${{ needs.release-preflight.outputs.release_tag }} + if-no-files-found: warn + retention-days: 14 + + # Prove checksum and executable failures cannot satisfy the candidate gate. + release-candidate-negative-controls: + name: Release Candidate Negative Controls + runs-on: ubuntu-latest + needs: [release-preflight, upload-assets] + + steps: + - name: Checkout release scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ needs.release-preflight.outputs.release_tag }} + + - name: Confirm exact negative-control checkout + shell: bash + env: + RELEASE_SHA: ${{ needs.release-preflight.outputs.release_sha }} + run: test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + + - name: Download exact staged candidate set + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: verified-release-candidates + path: ${{ runner.temp }}/release-candidate + + - name: Prove corrupted checksum is rejected + shell: bash + env: + RELEASE_TAG: ${{ needs.release-preflight.outputs.release_tag }} + RELEASE_SHA: ${{ needs.release-preflight.outputs.release_sha }} run: | - pwsh -NoProfile -File scripts/release-install-smoke.ps1 "${{ needs.release-preflight.outputs.release_tag }}" + set -euo pipefail + root="$RUNNER_TEMP/checksum-broken" + rm -rf "$root" + mkdir -p "$root" + cp -a "$RUNNER_TEMP/release-candidate/." "$root/" + printf '\ncorrupt\n' >> "$root/shiplog-x86_64-unknown-linux-gnu/shiplog-x86_64-unknown-linux-gnu" + + if SHIPLOG_RELEASE_CANDIDATE_DIR="$root" \ + SHIPLOG_RELEASE_SOURCE_SHA="$RELEASE_SHA" \ + SHIPLOG_RELEASE_SMOKE_DIR="$RUNNER_TEMP/checksum-control-work" \ + bash scripts/release-install-smoke.sh "$RELEASE_TAG" \ + >"$RUNNER_TEMP/checksum-control.log" 2>&1; then + echo "corrupted candidate unexpectedly passed" >&2 + exit 1 + fi + grep -q "checksum mismatch" "$RUNNER_TEMP/checksum-control.log" + + - name: Prove broken executable command is rejected + shell: bash + env: + RELEASE_TAG: ${{ needs.release-preflight.outputs.release_tag }} + RELEASE_SHA: ${{ needs.release-preflight.outputs.release_sha }} + run: | + set -euo pipefail + root="$RUNNER_TEMP/command-broken" + asset_rel="shiplog-x86_64-unknown-linux-gnu/shiplog-x86_64-unknown-linux-gnu" + rm -rf "$root" + mkdir -p "$root" + cp -a "$RUNNER_TEMP/release-candidate/." "$root/" + + cat > "$root/$asset_rel" <<'BROKEN' + #!/usr/bin/env bash + exit 42 + BROKEN + sed -i 's/^ //' "$root/$asset_rel" + chmod +x "$root/$asset_rel" + + replacement_sha="$(sha256sum "$root/$asset_rel" | awk '{print $1}')" + awk -v asset="$asset_rel" -v replacement="$replacement_sha" ' + $2 == asset { print replacement " " asset; found=1; next } + { print } + END { if (!found) exit 1 } + ' "$root/SHA256SUMS.txt" > "$root/SHA256SUMS.txt.new" + mv "$root/SHA256SUMS.txt.new" "$root/SHA256SUMS.txt" + + sums_sha="$(sha256sum "$root/SHA256SUMS.txt" | awk '{print $1}')" + awk -F= -v OFS== -v digest="$sums_sha" ' + $1 == "checksum_manifest_sha256" { $2=digest } + { print } + ' "$root/RELEASE_CANDIDATE.txt" > "$root/RELEASE_CANDIDATE.txt.new" + mv "$root/RELEASE_CANDIDATE.txt.new" "$root/RELEASE_CANDIDATE.txt" + + if SHIPLOG_RELEASE_CANDIDATE_DIR="$root" \ + SHIPLOG_RELEASE_SOURCE_SHA="$RELEASE_SHA" \ + SHIPLOG_RELEASE_SMOKE_DIR="$RUNNER_TEMP/command-control-work" \ + bash scripts/release-install-smoke.sh "$RELEASE_TAG" \ + >"$RUNNER_TEMP/command-control.log" 2>&1; then + echo "broken candidate executable unexpectedly passed" >&2 + exit 1 + fi + grep -q "candidate binary failed --version" "$RUNNER_TEMP/command-control.log" - # Run integration tests + - name: Upload negative-control evidence + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-candidate-negative-controls + path: | + ${{ runner.temp }}/checksum-control.log + ${{ runner.temp }}/command-control.log + if-no-files-found: warn + retention-days: 14 + + # Run independent integration tests in release mode. release-test: name: Release Integration Tests runs-on: ${{ github.repository == 'EffortlessMetrics/shiplog-swarm' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && 'self-hosted' || 'ubuntu-latest' }} needs: [release-preflight, build-binary, create-release] steps: - - name: Checkout code + - name: Checkout exact release tag uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false ref: ${{ needs.release-preflight.outputs.release_tag }} + - name: Confirm exact release commit + shell: bash + env: + RELEASE_SHA: ${{ needs.release-preflight.outputs.release_sha }} + run: test "$(git rev-parse HEAD)" = "$RELEASE_SHA" + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@master with: @@ -397,7 +707,96 @@ jobs: - name: Generate test summary if: always() + shell: bash + run: | + echo "# Release Integration Test Results" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "All integration tests passed in release mode!" >> "$GITHUB_STEP_SUMMARY" + + # One terminal authority for human source-side publication. + release-candidate-ready: + name: Release Candidate Ready + runs-on: ubuntu-latest + if: always() + needs: + - release-preflight + - upload-assets + - release-validation + - release-binary-acceptance + - release-candidate-negative-controls + - release-test + + steps: + - name: Require every candidate gate + shell: bash + env: + PREFLIGHT_RESULT: ${{ needs.release-preflight.result }} + STAGE_RESULT: ${{ needs.upload-assets.result }} + VALIDATION_RESULT: ${{ needs.release-validation.result }} + FIRST_USE_RESULT: ${{ needs.release-binary-acceptance.result }} + NEGATIVE_CONTROL_RESULT: ${{ needs.release-candidate-negative-controls.result }} + RELEASE_TEST_RESULT: ${{ needs.release-test.result }} + run: | + set -euo pipefail + for result in \ + "$PREFLIGHT_RESULT" \ + "$STAGE_RESULT" \ + "$VALIDATION_RESULT" \ + "$FIRST_USE_RESULT" \ + "$NEGATIVE_CONTROL_RESULT" \ + "$RELEASE_TEST_RESULT"; do + if [[ "$result" != "success" ]]; then + echo "release candidate gate is not green: $result" >&2 + exit 1 + fi + done + + - name: Record release candidate authority + shell: bash + env: + RELEASE_TAG: ${{ needs.release-preflight.outputs.release_tag }} + RELEASE_SHA: ${{ needs.release-preflight.outputs.release_sha }} + CANDIDATE_ARTIFACT_ID: ${{ needs.upload-assets.outputs.candidate_artifact_id }} + CANDIDATE_ARTIFACT_DIGEST: ${{ needs.upload-assets.outputs.candidate_artifact_digest }} + CANDIDATE_MANIFEST_SHA256: ${{ needs.upload-assets.outputs.candidate_manifest_sha256 }} + CHECKSUM_MANIFEST_SHA256: ${{ needs.upload-assets.outputs.checksum_manifest_sha256 }} run: | - echo "# Release Integration Test Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "All integration tests passed in release mode!" >> $GITHUB_STEP_SUMMARY + set -euo pipefail + cat > release-candidate-ready.txt <> "$GITHUB_STEP_SUMMARY" + + - name: Retain release candidate ready receipt + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: release-candidate-ready + path: release-candidate-ready.txt + retention-days: 30 diff --git a/apps/shiplog/tests/release_candidate_smoke.rs b/apps/shiplog/tests/release_candidate_smoke.rs new file mode 100644 index 00000000..ed0e94c2 --- /dev/null +++ b/apps/shiplog/tests/release_candidate_smoke.rs @@ -0,0 +1,480 @@ +//! Ordinary-PR proof for the staged release-candidate selection contract. +//! +//! These tests synthesize one complete four-platform candidate bundle and drive +//! the real platform smoke script. They prove selection, manifest, checksum, +//! command, and receipt behavior without creating a tag or public release. They +//! do not replace the explicit-tag four-runner proof required before #245 closes. + +use anyhow::{Context, Result, bail, ensure}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use tempfile::TempDir; + +const SOURCE_SHA: &str = "1111111111111111111111111111111111111111"; +const RELEASE_ASSETS: [&str; 4] = [ + "shiplog-x86_64-unknown-linux-gnu/shiplog-x86_64-unknown-linux-gnu", + "shiplog-x86_64-apple-darwin/shiplog-x86_64-apple-darwin", + "shiplog-aarch64-apple-darwin/shiplog-aarch64-apple-darwin", + "shiplog-x86_64-pc-windows-msvc/shiplog-x86_64-pc-windows-msvc.exe", +]; + +struct CandidateFixture { + _root: TempDir, + candidate_dir: PathBuf, + smoke_dir: PathBuf, + asset_path: PathBuf, +} + +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn current_release_asset() -> Result<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("linux", "x86_64") => Ok(RELEASE_ASSETS[0]), + ("macos", "x86_64") => Ok(RELEASE_ASSETS[1]), + ("macos", "aarch64") => Ok(RELEASE_ASSETS[2]), + ("windows", "x86_64") => Ok(RELEASE_ASSETS[3]), + (os, arch) => bail!("unsupported release candidate test platform: {os}/{arch}"), + } +} + +fn sha256(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("read {}", path.display()))?; + Ok(hex::encode(Sha256::digest(bytes))) +} + +fn write_candidate_metadata(fixture: &CandidateFixture) -> Result<()> { + let sums_path = fixture.candidate_dir.join("SHA256SUMS.txt"); + let mut sums = String::new(); + for relative in RELEASE_ASSETS { + let path = fixture.candidate_dir.join(relative); + sums.push_str(&format!("{} {relative}\n", sha256(&path)?)); + } + fs::write(&sums_path, sums).with_context(|| format!("write {}", sums_path.display()))?; + + let manifest_path = fixture.candidate_dir.join("RELEASE_CANDIDATE.txt"); + fs::write( + &manifest_path, + format!( + concat!( + "schema_version=1\n", + "release_tag=v{}\n", + "source_sha={}\n", + "repository=EffortlessMetrics/shiplog-swarm\n", + "workflow_run_id=1\n", + "workflow_run_attempt=1\n", + "asset_count=4\n", + "checksum_manifest_sha256={}\n" + ), + env!("CARGO_PKG_VERSION"), + SOURCE_SHA, + sha256(&sums_path)? + ), + ) + .with_context(|| format!("write {}", manifest_path.display()))?; + Ok(()) +} + +fn candidate_fixture() -> Result { + let root = TempDir::new().context("create release candidate fixture")?; + let candidate_dir = root.path().join("candidate"); + let smoke_dir = root.path().join("smoke"); + let asset_relative = current_release_asset()?; + let asset_path = candidate_dir.join(asset_relative); + + for relative in RELEASE_ASSETS { + let path = candidate_dir.join(relative); + let parent = path + .parent() + .context("candidate asset should have a parent")?; + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + if relative == asset_relative { + fs::copy(env!("CARGO_BIN_EXE_shiplog"), &path).with_context(|| { + format!( + "copy test binary {} to {}", + env!("CARGO_BIN_EXE_shiplog"), + path.display() + ) + })?; + } else { + fs::write(&path, format!("fixture placeholder for {relative}\n")) + .with_context(|| format!("write {}", path.display()))?; + } + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(&asset_path) + .with_context(|| format!("read metadata for {}", asset_path.display()))? + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&asset_path, permissions) + .with_context(|| format!("set executable permissions for {}", asset_path.display()))?; + } + + let fixture = CandidateFixture { + _root: root, + candidate_dir, + smoke_dir, + asset_path, + }; + write_candidate_metadata(&fixture)?; + Ok(fixture) +} + +fn run_candidate_smoke(fixture: &CandidateFixture) -> Result { + let root = repo_root(); + let version = env!("CARGO_PKG_VERSION"); + + #[cfg(windows)] + let mut command = { + let script_path = root.join("scripts/release-install-smoke.ps1"); + let script = script_path + .to_str() + .context("PowerShell smoke path is not UTF-8")?; + let mut command = Command::new("pwsh"); + command.args(["-NoProfile", "-File", script, version]); + command + }; + + #[cfg(not(windows))] + let mut command = { + let script_path = root.join("scripts/release-install-smoke.sh"); + let script = script_path + .to_str() + .context("Bash smoke path is not UTF-8")?; + let mut command = Command::new("bash"); + command.args([script, version]); + command + }; + + command + .current_dir(&root) + .env("SHIPLOG_RELEASE_CANDIDATE_DIR", &fixture.candidate_dir) + .env("SHIPLOG_RELEASE_SOURCE_SHA", SOURCE_SHA) + .env("SHIPLOG_RELEASE_SMOKE_DIR", &fixture.smoke_dir) + .env_remove("GITHUB_TOKEN") + .env_remove("GH_TOKEN") + .env_remove("GITLAB_TOKEN") + .env_remove("JIRA_TOKEN") + .env_remove("LINEAR_API_KEY") + .env_remove("SHIPLOG_REDACT_KEY") + .output() + .context("run staged release candidate smoke") +} + +fn combined_output(output: &Output) -> String { + format!( + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +fn manifest_path(fixture: &CandidateFixture) -> PathBuf { + fixture.candidate_dir.join("RELEASE_CANDIDATE.txt") +} + +fn rewrite_host_checksum_entry_as_flat_path(fixture: &CandidateFixture) -> Result<()> { + let asset = current_release_asset()?; + let asset_name = Path::new(asset) + .file_name() + .and_then(|name| name.to_str()) + .context("host candidate asset name is not UTF-8")?; + let sums_path = fixture.candidate_dir.join("SHA256SUMS.txt"); + let sums = fs::read_to_string(&sums_path) + .with_context(|| format!("read checksum manifest {}", sums_path.display()))?; + let host_digest = sha256(&fixture.asset_path)?; + let rewritten = sums + .lines() + .map(|line| { + if line.ends_with(asset) { + format!("{host_digest} {asset_name}") + } else { + line.to_owned() + } + }) + .collect::>() + .join("\n") + + "\n"; + fs::write(&sums_path, rewritten) + .with_context(|| format!("write flat checksum manifest {}", sums_path.display()))?; + let sums_digest = sha256(&sums_path)?; + let manifest = manifest_path(fixture); + let text = fs::read_to_string(&manifest) + .with_context(|| format!("read candidate manifest {}", manifest.display()))?; + let updated = text + .lines() + .map(|line| { + if line.starts_with("checksum_manifest_sha256=") { + format!("checksum_manifest_sha256={sums_digest}") + } else { + line.to_owned() + } + }) + .collect::>() + .join("\n") + + "\n"; + fs::write(&manifest, updated).with_context(|| { + format!( + "write flat-checksum candidate manifest {}", + manifest.display() + ) + })?; + Ok(()) +} + +#[test] +fn staged_candidate_smoke_uses_local_bundle_and_emits_receipts() -> Result<()> { + let fixture = candidate_fixture()?; + let output = run_candidate_smoke(&fixture)?; + ensure!(output.status.success(), "{}", combined_output(&output)); + + let text = combined_output(&output); + ensure!( + text.contains("release install smoke passed"), + "all successful modes should emit the general release smoke line: {text}" + ); + ensure!( + text.contains("staged release candidate smoke passed"), + "candidate mode should report its exact proof boundary: {text}" + ); + ensure!( + fixture + .smoke_dir + .join("cold-start/status.latest.json") + .is_file(), + "candidate smoke should retain the status receipt" + ); + ensure!( + fixture + .smoke_dir + .join("demo-review-rescue.stdout") + .is_file(), + "candidate smoke should retain the no-network rescue transcript" + ); + Ok(()) +} + +#[test] +fn staged_candidate_smoke_rejects_incomplete_four_platform_bundle() -> Result<()> { + let fixture = candidate_fixture()?; + let current = current_release_asset()?; + let missing_relative = RELEASE_ASSETS + .iter() + .copied() + .find(|relative| *relative != current) + .context("fixture should have a non-host candidate asset")?; + let missing_name = Path::new(missing_relative) + .file_name() + .and_then(|name| name.to_str()) + .context("missing candidate asset name is not UTF-8")?; + let missing_path = fixture.candidate_dir.join(missing_relative); + fs::remove_file(&missing_path) + .with_context(|| format!("remove missing fixture asset {}", missing_path.display()))?; + + let output = run_candidate_smoke(&fixture)?; + ensure!( + !output.status.success(), + "incomplete candidate set unexpectedly passed" + ); + ensure!( + combined_output(&output).contains(&format!( + "candidate bundle must contain exactly one {missing_name}; found 0" + )), + "candidate mode must prove all four platform binaries exist: {}", + combined_output(&output) + ); + Ok(()) +} + +#[test] +fn staged_candidate_smoke_rejects_duplicate_manifest_fields() -> Result<()> { + let fixture = candidate_fixture()?; + let path = manifest_path(&fixture); + let mut manifest = fs::read_to_string(&path) + .with_context(|| format!("read candidate manifest {}", path.display()))?; + manifest.push_str("release_tag=v0.0.0\n"); + fs::write(&path, manifest) + .with_context(|| format!("write duplicate-field manifest {}", path.display()))?; + + let output = run_candidate_smoke(&fixture)?; + ensure!( + !output.status.success(), + "duplicate manifest unexpectedly passed" + ); + ensure!( + combined_output(&output).contains("duplicate candidate manifest field: release_tag"), + "duplicate keys must fail before candidate execution: {}", + combined_output(&output) + ); + Ok(()) +} + +#[test] +fn staged_candidate_smoke_rejects_unknown_manifest_fields() -> Result<()> { + let fixture = candidate_fixture()?; + let path = manifest_path(&fixture); + let mut manifest = fs::read_to_string(&path) + .with_context(|| format!("read candidate manifest {}", path.display()))?; + manifest.push_str("unexpected=value\n"); + fs::write(&path, manifest) + .with_context(|| format!("write unknown-field manifest {}", path.display()))?; + + let output = run_candidate_smoke(&fixture)?; + ensure!( + !output.status.success(), + "unknown manifest field unexpectedly passed" + ); + ensure!( + combined_output(&output).contains("unknown candidate manifest field: unexpected"), + "unknown keys must fail before candidate execution: {}", + combined_output(&output) + ); + Ok(()) +} + +#[test] +fn staged_candidate_smoke_rejects_invalid_workflow_identity() -> Result<()> { + let fixture = candidate_fixture()?; + let path = manifest_path(&fixture); + let manifest = fs::read_to_string(&path) + .with_context(|| format!("read candidate manifest {}", path.display()))? + .replace("workflow_run_id=1", "workflow_run_id=not-a-run"); + fs::write(&path, manifest) + .with_context(|| format!("write invalid-identity manifest {}", path.display()))?; + + let output = run_candidate_smoke(&fixture)?; + ensure!( + !output.status.success(), + "unsafe workflow identity unexpectedly passed" + ); + ensure!( + combined_output(&output).contains("invalid workflow run identity"), + "manifest identities must stay closed and machine-shaped: {}", + combined_output(&output) + ); + Ok(()) +} + +#[test] +fn staged_candidate_smoke_rejects_checksum_mismatch_before_execution() -> Result<()> { + let fixture = candidate_fixture()?; + let mut bytes = fs::read(&fixture.asset_path) + .with_context(|| format!("read candidate asset {}", fixture.asset_path.display()))?; + bytes.extend_from_slice(b"corrupt"); + fs::write(&fixture.asset_path, bytes).with_context(|| { + format!( + "write corrupted candidate asset {}", + fixture.asset_path.display() + ) + })?; + + let output = run_candidate_smoke(&fixture)?; + ensure!( + !output.status.success(), + "corrupted candidate unexpectedly passed" + ); + ensure!( + combined_output(&output).contains("checksum mismatch"), + "corruption should fail at the checksum boundary: {}", + combined_output(&output) + ); + Ok(()) +} + +#[test] +fn staged_candidate_smoke_accepts_flat_checksum_entry() -> Result<()> { + let fixture = candidate_fixture()?; + rewrite_host_checksum_entry_as_flat_path(&fixture)?; + let output = run_candidate_smoke(&fixture)?; + ensure!(output.status.success(), "{}", combined_output(&output)); + Ok(()) +} + +#[test] +fn staged_candidate_smoke_rejects_broken_command_with_matching_checksum() -> Result<()> { + let fixture = candidate_fixture()?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::write(&fixture.asset_path, "#!/usr/bin/env bash\nexit 42\n").with_context(|| { + format!( + "write broken Unix candidate {}", + fixture.asset_path.display() + ) + })?; + let mut permissions = fs::metadata(&fixture.asset_path) + .with_context(|| format!("read metadata for {}", fixture.asset_path.display()))? + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&fixture.asset_path, permissions).with_context(|| { + format!( + "set executable permissions for {}", + fixture.asset_path.display() + ) + })?; + } + #[cfg(windows)] + fs::write(&fixture.asset_path, b"not a Windows executable").with_context(|| { + format!( + "write broken Windows candidate {}", + fixture.asset_path.display() + ) + })?; + write_candidate_metadata(&fixture)?; + + let output = run_candidate_smoke(&fixture)?; + ensure!( + !output.status.success(), + "broken candidate unexpectedly passed" + ); + ensure!( + combined_output(&output).contains("candidate binary failed --version"), + "matching checksums must not rescue a broken executable: {}", + combined_output(&output) + ); + Ok(()) +} + +#[test] +fn release_workflow_binds_tag_push_identity_and_staged_contract() -> Result<()> { + let path = repo_root().join(".github/workflows/release.yml"); + let workflow = fs::read_to_string(&path) + .with_context(|| format!("read release workflow {}", path.display()))?; + ensure!( + workflow.contains("WEBHOOK_SHA: ${{ github.sha }}"), + "preflight must receive the push webhook SHA through the step environment" + ); + ensure!( + workflow.contains("EVENT_NAME\" == \"push\" && \"$release_sha\" != \"$WEBHOOK_SHA\""), + "tag-push preflight must reject a tag that moved after the webhook" + ); + ensure!( + workflow.contains( + "Swarm verifies release inputs but cannot create a GitHub release." + ) && workflow.contains( + "Promote this exact proven tree to EffortlessMetrics/shiplog for authorized release execution." + ), + "the source handoff must preserve both durable summary statements" + ); + ensure!( + workflow.contains("Confirm exact negative-control checkout") + && workflow.contains("run: test \"$(git rev-parse HEAD)\" = \"$RELEASE_SHA\""), + "negative controls must verify the checked-out release SHA" + ); + ensure!( + !workflow.contains("actions/download-artifact@v8"), + "all download-artifact actions must be immutable" + ); + ensure!( + workflow.contains("asset_count=$asset_count") + && workflow.contains("asset_count=\"${#expected[@]}\""), + "candidate manifest asset_count must derive from its canonical expected set" + ); + Ok(()) +} diff --git a/policy/workflow-allowlist.toml b/policy/workflow-allowlist.toml index f2f094ba..11d7a0c7 100644 --- a/policy/workflow-allowlist.toml +++ b/policy/workflow-allowlist.toml @@ -151,7 +151,7 @@ external_actions = [ "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", "dtolnay/rust-toolchain@master", "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", - "actions/download-artifact@v8", + "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", "actions/cache@v6", "Swatinem/rust-cache@v2", "taiki-e/install-action@v2", diff --git a/scripts/release-install-smoke.ps1 b/scripts/release-install-smoke.ps1 index 4aaf761d..273dcdc5 100644 --- a/scripts/release-install-smoke.ps1 +++ b/scripts/release-install-smoke.ps1 @@ -54,15 +54,140 @@ function Get-Sha256Hex { } } +function Find-UniqueCandidateFile { + param( + [Parameter(Mandatory = $true)] + [string]$Root, + + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $candidateMatches = @( + Get-ChildItem -LiteralPath $Root -Recurse -File -Force | + Where-Object { + $_.Name -eq $Name -and + -not ($_.Attributes -band [System.IO.FileAttributes]::ReparsePoint) + } + ) + if ($candidateMatches.Count -ne 1) { + throw "candidate bundle must contain exactly one $Name; found $($candidateMatches.Count) under $Root" + } + return $candidateMatches[0].FullName +} + +function Assert-CandidateManifest { + param( + [Parameter(Mandatory = $true)] + [string]$CandidateRoot, + + [Parameter(Mandatory = $true)] + [string]$ManifestPath, + + [Parameter(Mandatory = $true)] + [string]$SumsPath, + + [Parameter(Mandatory = $true)] + [string]$ExpectedTag, + + [Parameter(Mandatory = $true)] + [string]$ExpectedSourceSha + ) + + if ($ExpectedSourceSha -notmatch '^[0-9a-fA-F]{40}$') { + throw "SHIPLOG_RELEASE_SOURCE_SHA must be a full 40-character commit SHA" + } + + $allowedKeys = @( + 'schema_version', + 'release_tag', + 'source_sha', + 'repository', + 'workflow_run_id', + 'workflow_run_attempt', + 'asset_count', + 'checksum_manifest_sha256' + ) + $entries = @{} + foreach ($line in Get-Content -LiteralPath $ManifestPath) { + if ([string]::IsNullOrWhiteSpace($line)) { + continue + } + if ($line -match '[\x00-\x1F\x7F]') { + throw "candidate manifest contains a control character" + } + $parts = $line -split '=', 2 + if ($parts.Count -ne 2 -or + [string]::IsNullOrEmpty($parts[0]) -or + [string]::IsNullOrEmpty($parts[1])) { + throw "malformed candidate manifest field" + } + $key = $parts[0] + $value = $parts[1] + if ($key -notin $allowedKeys) { + throw "unknown candidate manifest field: $key" + } + if ($entries.ContainsKey($key)) { + throw "duplicate candidate manifest field: $key" + } + $entries[$key] = $value + } + + if ($entries.Count -ne $allowedKeys.Count) { + throw "candidate manifest is missing required fields" + } + if ($entries['schema_version'] -ne '1') { + throw "candidate manifest has unsupported schema" + } + if ($entries['release_tag'] -ne $ExpectedTag) { + throw "candidate manifest is not bound to $ExpectedTag" + } + if ($entries['source_sha'] -ne $ExpectedSourceSha) { + throw "candidate manifest is not bound to source commit $ExpectedSourceSha" + } + if ($entries['repository'] -notmatch '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$') { + throw "candidate manifest has an invalid repository identity" + } + if ($entries['workflow_run_id'] -notmatch '^[0-9]+$' -or + $entries['workflow_run_attempt'] -notmatch '^[0-9]+$') { + throw "candidate manifest has an invalid workflow run identity" + } + if ($entries['asset_count'] -ne '4') { + throw "candidate manifest does not record the four supported binaries" + } + + foreach ($requiredAsset in @( + 'shiplog-x86_64-unknown-linux-gnu', + 'shiplog-x86_64-apple-darwin', + 'shiplog-aarch64-apple-darwin', + 'shiplog-x86_64-pc-windows-msvc.exe' + )) { + $null = Find-UniqueCandidateFile -Root $CandidateRoot -Name $requiredAsset + } + + if ($entries['checksum_manifest_sha256'] -notmatch '^[0-9a-fA-F]{64}$') { + throw "candidate manifest has no valid checksum manifest digest" + } + + $actualSumsSha = Get-Sha256Hex $SumsPath + if ($actualSumsSha -ne $entries['checksum_manifest_sha256'].ToLowerInvariant()) { + throw "candidate SHA256SUMS.txt digest mismatch`nexpected: $($entries['checksum_manifest_sha256'])`nactual: $actualSumsSha" + } +} + if ($Version -eq "-h" -or $Version -eq "--help") { @" usage: scripts/release-install-smoke.ps1 -Downloads the Windows GitHub release binary, verifies SHA256SUMS.txt, proves the -no-token first-use path and runs the no-network review rescue smoke path. This -script is intended to work without Rust or Cargo installed. +Verifies the Windows Shiplog release candidate, proves the no-token first-use +path, and runs the no-network review rescue smoke path. By default the script +downloads public GitHub Release assets. During staged release proof, point it at +the exact workflow candidate bundle instead. -Set SHIPLOG_RELEASE_REPO=owner/repo to verify a fork. +Set SHIPLOG_RELEASE_CANDIDATE_DIR=path to use a local staged candidate bundle. +Set SHIPLOG_RELEASE_SOURCE_SHA= with candidate mode to bind the bundle +to the exact tagged source commit. +Set SHIPLOG_RELEASE_REPO=owner/repo to verify a public release or fork. Set SHIPLOG_RELEASE_SMOKE_DIR=path to override the scratch directory. "@ | Write-Error exit 2 @@ -71,6 +196,8 @@ Set SHIPLOG_RELEASE_SMOKE_DIR=path to override the scratch directory. $versionNumber = $Version.TrimStart("v") $tag = "v$versionNumber" $repo = if ($env:SHIPLOG_RELEASE_REPO) { $env:SHIPLOG_RELEASE_REPO } else { "EffortlessMetrics/shiplog" } +$candidateDir = $env:SHIPLOG_RELEASE_CANDIDATE_DIR +$expectedSourceSha = $env:SHIPLOG_RELEASE_SOURCE_SHA $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $repoRoot = Split-Path -Parent $scriptDir @@ -84,18 +211,51 @@ $downloadDir = Join-Path $workDir "download" $demoOut = Join-Path $workDir "demo-out" $asset = "shiplog-x86_64-pc-windows-msvc.exe" -$baseUrl = "https://github.com/$repo/releases/download/$tag" $binaryPath = Join-Path $downloadDir "shiplog.exe" $sumsPath = Join-Path $downloadDir "SHA256SUMS.txt" +$manifestPath = Join-Path $downloadDir "RELEASE_CANDIDATE.txt" -Invoke-Step "downloading $repo@$tag release asset for Windows" Remove-Item -Recurse -Force $workDir -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force $downloadDir | Out-Null -Invoke-WebRequest -UseBasicParsing -Uri "$baseUrl/$asset" -OutFile $binaryPath -Invoke-WebRequest -UseBasicParsing -Uri "$baseUrl/SHA256SUMS.txt" -OutFile $sumsPath +$workDir = (Resolve-Path -LiteralPath $workDir).ProviderPath +$downloadDir = (Resolve-Path -LiteralPath $downloadDir).ProviderPath +$demoOut = Join-Path $workDir "demo-out" +$binaryPath = Join-Path $downloadDir "shiplog.exe" +$sumsPath = Join-Path $downloadDir "SHA256SUMS.txt" +$manifestPath = Join-Path $downloadDir "RELEASE_CANDIDATE.txt" + +if ($candidateDir) { + if (-not $expectedSourceSha) { + throw "SHIPLOG_RELEASE_SOURCE_SHA is required with SHIPLOG_RELEASE_CANDIDATE_DIR" + } + $candidateRoot = (Resolve-Path -LiteralPath $candidateDir).ProviderPath + $candidateAsset = Find-UniqueCandidateFile -Root $candidateRoot -Name $asset + $candidateSums = Find-UniqueCandidateFile -Root $candidateRoot -Name "SHA256SUMS.txt" + $candidateManifest = Find-UniqueCandidateFile -Root $candidateRoot -Name "RELEASE_CANDIDATE.txt" + + Invoke-Step "loading staged candidate $tag for Windows" + Copy-Item -LiteralPath $candidateAsset -Destination $binaryPath + Copy-Item -LiteralPath $candidateSums -Destination $sumsPath + Copy-Item -LiteralPath $candidateManifest -Destination $manifestPath + Assert-CandidateManifest ` + -CandidateRoot $candidateRoot ` + -ManifestPath $manifestPath ` + -SumsPath $sumsPath ` + -ExpectedTag $tag ` + -ExpectedSourceSha $expectedSourceSha +} +else { + $baseUrl = "https://github.com/$repo/releases/download/$tag" + Invoke-Step "downloading $repo@$tag release asset for Windows" + Invoke-WebRequest -UseBasicParsing -Uri "$baseUrl/$asset" -OutFile $binaryPath + Invoke-WebRequest -UseBasicParsing -Uri "$baseUrl/SHA256SUMS.txt" -OutFile $sumsPath +} Invoke-Step "verifying SHA256SUMS.txt entry for $asset" -$sumLine = Get-Content $sumsPath | Where-Object { $_ -match "/$([Regex]::Escape($asset))$" } | Select-Object -First 1 +$sumLine = Get-Content $sumsPath | Where-Object { + $parts = $_ -split "\s+" + $parts.Count -ge 2 -and [System.IO.Path]::GetFileName($parts[-1]) -eq $asset +} | Select-Object -First 1 if (-not $sumLine) { throw "no SHA256SUMS.txt entry found for $asset" } @@ -105,10 +265,19 @@ if ($actualSha -ne $expectedSha) { throw "checksum mismatch for $asset`nexpected: $expectedSha`nactual: $actualSha" } -Invoke-Step "smoking downloaded binary" -$versionOutput = & $binaryPath --version -if ($LASTEXITCODE -ne 0 -or $versionOutput.Trim() -ne "shiplog $versionNumber") { - throw "unexpected version output: $versionOutput" +Invoke-Step "smoking candidate binary" +try { + $versionOutput = @(& $binaryPath --version) + if ($LASTEXITCODE -ne 0) { + throw "candidate binary failed --version" + } +} +catch { + throw "candidate binary failed --version: $($_.Exception.Message)" +} +$versionText = ($versionOutput -join [Environment]::NewLine).Trim() +if ($versionText -ne "shiplog $versionNumber") { + throw "unexpected version output: $versionText" } Invoke-Shiplog $binaryPath @("--help") | Out-Null @@ -175,12 +344,18 @@ foreach ($artifact in @( } } -# Structurally validate the receipts, not merely their existence: the -# published binary must parse its own intake.report.json/packet.md/ledger/ -# coverage/bundle receipts back into their canonical shapes. +# The intake report records its path relative to the original cold-start +# working directory. Validate from that same directory so the self-reference is +# resolved once rather than joined onto the run directory a second time. Invoke-Step "structurally validating cold-start receipts" -$reportJson = Join-Path $latestRun.FullName "intake.report.json" -Invoke-Shiplog $binaryPath @("report", "validate", "--path", $reportJson, "--receipts") | Out-Null +Push-Location -LiteralPath $coldStartDir +try { + $relativeReport = Join-Path "." (Join-Path "out" (Join-Path $latestRun.Name "intake.report.json")) + Invoke-Shiplog $binaryPath @("report", "validate", "--path", $relativeReport, "--receipts") | Out-Null +} +finally { + Pop-Location +} Invoke-Step "running no-network review rescue fixture" Remove-Item -Recurse -Force $demoOut -ErrorAction SilentlyContinue @@ -195,3 +370,6 @@ if (-not (Get-ChildItem -Path $demoOut -Recurse -Filter "packet.md" | Select-Obj } Write-Host "release install smoke passed for $repo@$tag" +if ($candidateDir) { + Write-Host "staged release candidate smoke passed for $tag at $expectedSourceSha" +} diff --git a/scripts/release-install-smoke.sh b/scripts/release-install-smoke.sh index cb85c988..0982f8f8 100755 --- a/scripts/release-install-smoke.sh +++ b/scripts/release-install-smoke.sh @@ -5,11 +5,15 @@ usage() { cat >&2 <<'USAGE' usage: scripts/release-install-smoke.sh -Downloads the current-platform GitHub release binary, verifies SHA256SUMS.txt, -proves the no-token first-use path and runs the no-network review rescue smoke -path. This script is intended to work without Rust or Cargo installed. +Verifies the current-platform Shiplog release candidate, proves the no-token +first-use path, and runs the no-network review rescue smoke path. By default the +script downloads public GitHub Release assets. During staged release proof, +point it at the exact workflow candidate bundle instead. -Set SHIPLOG_RELEASE_REPO=owner/repo to verify a fork. +Set SHIPLOG_RELEASE_CANDIDATE_DIR=path to use a local staged candidate bundle. +Set SHIPLOG_RELEASE_SOURCE_SHA= with candidate mode to bind the bundle +to the exact tagged source commit. +Set SHIPLOG_RELEASE_REPO=owner/repo to verify a public release or fork. Set SHIPLOG_RELEASE_SMOKE_DIR=path to override the scratch directory. USAGE } @@ -26,6 +30,8 @@ fi version="${1#v}" tag="v$version" repo="${SHIPLOG_RELEASE_REPO:-EffortlessMetrics/shiplog}" +candidate_dir="${SHIPLOG_RELEASE_CANDIDATE_DIR:-}" +expected_source_sha="${SHIPLOG_RELEASE_SOURCE_SHA:-}" script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" repo_root="$(cd -- "$script_dir/.." && pwd)" @@ -77,26 +83,195 @@ sha256_file() { fi } +lower_hex() { + printf '%s' "$1" | tr '[:upper:]' '[:lower:]' +} + +find_unique_candidate_file() { + local root="$1" + local name="$2" + local match="" + local candidate + local count=0 + + # Do not follow symlinked files/directories or cross filesystem boundaries. + # NUL-delimited parsing keeps candidate identity unambiguous even when a + # local scratch path contains whitespace or a newline. + while IFS= read -r -d '' candidate; do + count=$((count + 1)) + match="$candidate" + done < <(find "$root" -xdev -type f -name "$name" -print0) + + if [[ "$count" != "1" ]]; then + echo "candidate bundle must contain exactly one $name; found $count under $root" >&2 + exit 1 + fi + printf '%s\n' "$match" +} + +validate_candidate_manifest() { + local root="$1" + local manifest="$2" + local sums="$3" + local line key value required_asset + local schema_version="" release_tag="" source_sha="" repository="" + local workflow_run_id="" workflow_run_attempt="" asset_count="" + local expected_sums_sha="" actual_sums_sha + local seen_schema=0 seen_tag=0 seen_source=0 seen_repository=0 + local seen_run=0 seen_attempt=0 seen_count=0 seen_sums=0 + + if [[ "$expected_source_sha" == "" ]]; then + echo "SHIPLOG_RELEASE_SOURCE_SHA is required with SHIPLOG_RELEASE_CANDIDATE_DIR" >&2 + exit 2 + fi + if [[ ! "$expected_source_sha" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "SHIPLOG_RELEASE_SOURCE_SHA must be a full 40-character commit SHA" >&2 + exit 2 + fi + + # Parse one exact, closed schema. Duplicate, unknown, empty, or control-byte + # fields are rejected before any manifest value reaches logs or commands. + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -n "$line" ]] || continue + if [[ "$line" != *=* ]]; then + echo "malformed candidate manifest field" >&2 + exit 1 + fi + key="${line%%=*}" + value="${line#*=}" + if [[ -z "$key" || -z "$value" || "$key" =~ [[:cntrl:]] || "$value" =~ [[:cntrl:]] ]]; then + echo "candidate manifest contains an empty or unsafe field" >&2 + exit 1 + fi + + case "$key" in + schema_version) + (( seen_schema == 0 )) || { echo "duplicate candidate manifest field: schema_version" >&2; exit 1; } + seen_schema=1; schema_version="$value" ;; + release_tag) + (( seen_tag == 0 )) || { echo "duplicate candidate manifest field: release_tag" >&2; exit 1; } + seen_tag=1; release_tag="$value" ;; + source_sha) + (( seen_source == 0 )) || { echo "duplicate candidate manifest field: source_sha" >&2; exit 1; } + seen_source=1; source_sha="$value" ;; + repository) + (( seen_repository == 0 )) || { echo "duplicate candidate manifest field: repository" >&2; exit 1; } + seen_repository=1; repository="$value" ;; + workflow_run_id) + (( seen_run == 0 )) || { echo "duplicate candidate manifest field: workflow_run_id" >&2; exit 1; } + seen_run=1; workflow_run_id="$value" ;; + workflow_run_attempt) + (( seen_attempt == 0 )) || { echo "duplicate candidate manifest field: workflow_run_attempt" >&2; exit 1; } + seen_attempt=1; workflow_run_attempt="$value" ;; + asset_count) + (( seen_count == 0 )) || { echo "duplicate candidate manifest field: asset_count" >&2; exit 1; } + seen_count=1; asset_count="$value" ;; + checksum_manifest_sha256) + (( seen_sums == 0 )) || { echo "duplicate candidate manifest field: checksum_manifest_sha256" >&2; exit 1; } + seen_sums=1; expected_sums_sha="$value" ;; + *) + echo "unknown candidate manifest field: $key" >&2 + exit 1 + ;; + esac + done < "$manifest" + + if (( seen_schema + seen_tag + seen_source + seen_repository + seen_run + seen_attempt + seen_count + seen_sums != 8 )); then + echo "candidate manifest is missing required fields" >&2 + exit 1 + fi + [[ "$schema_version" == "1" ]] || { echo "candidate manifest has unsupported schema" >&2; exit 1; } + [[ "$release_tag" == "$tag" ]] || { echo "candidate manifest is not bound to $tag" >&2; exit 1; } + [[ "$source_sha" == "$expected_source_sha" ]] || { + echo "candidate manifest is not bound to source commit $expected_source_sha" >&2 + exit 1 + } + [[ "$repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || { + echo "candidate manifest has an invalid repository identity" >&2 + exit 1 + } + [[ "$workflow_run_id" =~ ^[0-9]+$ && "$workflow_run_attempt" =~ ^[0-9]+$ ]] || { + echo "candidate manifest has an invalid workflow run identity" >&2 + exit 1 + } + [[ "$asset_count" == "4" ]] || { + echo "candidate manifest does not record the four supported binaries" >&2 + exit 1 + } + [[ "$expected_sums_sha" =~ ^[0-9a-fA-F]{64}$ ]] || { + echo "candidate manifest has no valid checksum manifest digest" >&2 + exit 1 + } + + for required_asset in \ + shiplog-x86_64-unknown-linux-gnu \ + shiplog-x86_64-apple-darwin \ + shiplog-aarch64-apple-darwin \ + shiplog-x86_64-pc-windows-msvc.exe; do + find_unique_candidate_file "$root" "$required_asset" >/dev/null + done + + actual_sums_sha="$(sha256_file "$sums")" + if [[ "$(lower_hex "$actual_sums_sha")" != "$(lower_hex "$expected_sums_sha")" ]]; then + echo "candidate SHA256SUMS.txt digest mismatch" >&2 + echo "expected: $expected_sums_sha" >&2 + echo "actual: $actual_sums_sha" >&2 + exit 1 + fi +} + asset="$(host_asset)" -base_url="https://github.com/$repo/releases/download/$tag" binary_path="$download_dir/shiplog" if [[ "$asset" == *.exe ]]; then binary_path="$download_dir/shiplog.exe" fi -echo "==> downloading $repo@$tag release asset for this platform" rm -rf "$work_dir" mkdir -p "$download_dir" -download "$base_url/$asset" "$binary_path" -download "$base_url/SHA256SUMS.txt" "$download_dir/SHA256SUMS.txt" +work_dir="$(cd -P -- "$work_dir" && pwd)" +download_dir="$(cd -P -- "$download_dir" && pwd)" +demo_out="$work_dir/demo-out" +binary_path="$download_dir/$(basename -- "$binary_path")" + +if [[ "$candidate_dir" != "" ]]; then + candidate_dir="$(cd -P -- "$candidate_dir" && pwd)" + candidate_asset="$(find_unique_candidate_file "$candidate_dir" "$asset")" + candidate_sums="$(find_unique_candidate_file "$candidate_dir" SHA256SUMS.txt)" + candidate_manifest="$(find_unique_candidate_file "$candidate_dir" RELEASE_CANDIDATE.txt)" + + echo "==> loading staged candidate $tag for this platform" + cp "$candidate_asset" "$binary_path" + cp "$candidate_sums" "$download_dir/SHA256SUMS.txt" + cp "$candidate_manifest" "$download_dir/RELEASE_CANDIDATE.txt" + validate_candidate_manifest \ + "$candidate_dir" \ + "$download_dir/RELEASE_CANDIDATE.txt" \ + "$download_dir/SHA256SUMS.txt" +else + base_url="https://github.com/$repo/releases/download/$tag" + echo "==> downloading $repo@$tag release asset for this platform" + download "$base_url/$asset" "$binary_path" + download "$base_url/SHA256SUMS.txt" "$download_dir/SHA256SUMS.txt" +fi echo "==> verifying SHA256SUMS.txt entry for $asset" expected_sha="$( - awk -v asset="$asset" '$2 ~ "/" asset "$" { print $1; found=1 } END { if (!found) exit 1 }' \ + awk -v asset="$asset" ' + { + path = $2 + sub(/^.*\//, "", path) + if (path == asset) { + print $1 + found = 1 + exit + } + } + END { if (!found) exit 1 } + ' \ "$download_dir/SHA256SUMS.txt" )" actual_sha="$(sha256_file "$binary_path")" -if [[ "$actual_sha" != "$expected_sha" ]]; then +if [[ "$(lower_hex "$actual_sha")" != "$(lower_hex "$expected_sha")" ]]; then echo "checksum mismatch for $asset" >&2 echo "expected: $expected_sha" >&2 echo "actual: $actual_sha" >&2 @@ -105,8 +280,15 @@ fi chmod +x "$binary_path" 2>/dev/null || true -echo "==> smoking downloaded binary" -"$binary_path" --version | grep -Fxq "shiplog $version" +echo "==> smoking candidate binary" +if ! version_output="$("$binary_path" --version)"; then + echo "candidate binary failed --version" >&2 + exit 1 +fi +if [[ "$version_output" != "shiplog $version" ]]; then + echo "unexpected version output: $version_output" >&2 + exit 1 +fi "$binary_path" --help >/dev/null echo "==> proving the no-token first-use path" @@ -149,11 +331,17 @@ for artifact in \ fi done -# Structurally validate the receipts, not merely their existence: the -# published binary must parse its own intake.report.json/packet.md/ledger/ -# coverage/bundle receipts back into their canonical shapes. +# The intake report records its path relative to the original cold-start +# working directory. Validate from that same directory so the self-reference is +# resolved once rather than joined onto the run directory a second time. echo "==> structurally validating cold-start receipts" -"$binary_path" report validate --path "$latest_run/intake.report.json" --receipts +latest_run_name="$(basename "$latest_run")" +( + cd "$cold_start_dir" + "$binary_path" report validate \ + --path "./out/$latest_run_name/intake.report.json" \ + --receipts +) echo "==> running no-network review rescue fixture" rm -rf "$demo_out" @@ -172,3 +360,6 @@ if ! find "$demo_out" -name packet.md -type f -print -quit | grep -q .; then fi echo "release install smoke passed for $repo@$tag" +if [[ "$candidate_dir" != "" ]]; then + echo "staged release candidate smoke passed for $tag at $expected_source_sha" +fi