diff --git a/.github/workflows/release-recovery.yml b/.github/workflows/release-recovery.yml deleted file mode 100644 index add909a..0000000 --- a/.github/workflows/release-recovery.yml +++ /dev/null @@ -1,246 +0,0 @@ -# One-cut recovery from the existing signed v0.1.2 tag. No signing keys in CI. -# Dispatch only from main after review; the signed tag and tagged tree are immutable. -name: Release draft recovery -on: - workflow_dispatch: - inputs: - tag: - description: Existing signed release tag - type: string - required: true - default: v0.1.2 - tag_object: - description: Approved annotated tag object SHA-1 - type: string - required: true - default: 326257266c6def26e49fd4336ad03c3f8fcac532 - commit: - description: Approved tagged commit SHA-1 - type: string - required: true - default: 1666a2ee3166f2b764514fef36794dfd514967d1 -permissions: - contents: read -concurrency: - group: release-refs/tags/${{ inputs.tag }} - cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - STORAGEPRIMS_RELEASE_TAG: ${{ inputs.tag }} - STORAGEPRIMS_EXPECTED_TAG_OBJECT: ${{ inputs.tag_object }} - STORAGEPRIMS_EXPECTED_COMMIT: ${{ inputs.commit }} -jobs: - validate: - name: Validate signed tag and package set - runs-on: ubuntu-latest - outputs: - commit: ${{ steps.release.outputs.commit }} - version: ${{ steps.release.outputs.version }} - defaults: - run: - shell: bash - steps: - - name: Checkout exact tagged commit - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - ref: ${{ inputs.commit }} - fetch-depth: 0 - persist-credentials: false - - name: Verify existing signed tag and tagged tree - id: release - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - git show "$GITHUB_SHA:scripts/release-recovery-guard.sh" >"$RUNNER_TEMP/recovery-guard.sh" - bash "$RUNNER_TEMP/recovery-guard.sh" verify - ./scripts/check-release-notes.sh "$STORAGEPRIMS_RELEASE_TAG" - printf 'commit=%s\n' "$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" - printf 'version=%s\n' "$(cat VERSION)" >>"$GITHUB_OUTPUT" - - name: Verify package archives without publishing - run: make release-check - verify-signature: - name: Recheck committed-pin and GitHub signature - needs: validate - runs-on: ubuntu-latest - defaults: - run: - shell: bash - steps: - - name: Checkout exact tagged commit - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - ref: ${{ inputs.commit }} - fetch-depth: 0 - persist-credentials: false - - name: Verify immutable tag again - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - git show "$GITHUB_SHA:scripts/release-recovery-guard.sh" >"$RUNNER_TEMP/recovery-guard.sh" - bash "$RUNNER_TEMP/recovery-guard.sh" verify - build-ffi: - name: FFI (${{ matrix.platform }}) - needs: validate - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-latest - target: x86_64-unknown-linux-gnu - platform: linux-amd64 - shared: libstorageprims_ffi.so - static: libstorageprims_ffi.a - - runner: ubuntu-latest-arm64-s - target: aarch64-unknown-linux-gnu - platform: linux-arm64 - shared: libstorageprims_ffi.so - static: libstorageprims_ffi.a - - runner: macos-15 - target: aarch64-apple-darwin - platform: darwin-arm64 - shared: libstorageprims_ffi.dylib - static: libstorageprims_ffi.a - defaults: - run: - shell: bash - steps: - - name: Checkout exact tagged commit - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - ref: ${{ inputs.commit }} - fetch-depth: 0 - persist-credentials: false - - name: Verify tagged source - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - git show "$GITHUB_SHA:scripts/release-recovery-guard.sh" >"$RUNNER_TEMP/recovery-guard.sh" - bash "$RUNNER_TEMP/recovery-guard.sh" verify - - name: Install Rust 1.94.1 - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1 - with: - toolchain: 1.94.1 - - name: Verify native runner - run: | - set -euo pipefail - test "$(rustc -vV | awk '/^host:/{print $2}')" = "${{ matrix.target }}" - - name: Build native FFI - run: cargo build --package storageprims-ffi --release --locked - - name: Package exact FFI archive - run: | - set -euo pipefail - package_dir="dist/package" - archive="storageprims-ffi-${{ needs.validate.outputs.version }}-${{ matrix.platform }}.tar.gz" - mkdir -p "$package_dir" - cp "target/release/${{ matrix.static }}" "$package_dir/" - cp "target/release/${{ matrix.shared }}" "$package_dir/" - cp ffi/storageprims-ffi/storageprims.h "$package_dir/storageprims.h" - cp LICENSE-MIT LICENSE-APACHE "$package_dir/" - tar -czf "$archive" -C "$package_dir" \ - LICENSE-APACHE LICENSE-MIT "${{ matrix.static }}" \ - "${{ matrix.shared }}" storageprims.h - source scripts/release-common.sh - # shellcheck disable=SC2031 - validate_ffi_archive "$archive" - - name: Upload FFI archive - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: ffi-${{ matrix.platform }} - path: storageprims-ffi-${{ needs.validate.outputs.version }}-${{ matrix.platform }}.tar.gz - if-no-files-found: error - retention-days: 7 - sbom: - name: CycloneDX SBOM - needs: validate - runs-on: ubuntu-latest - defaults: - run: - shell: bash - steps: - - name: Checkout exact tagged commit - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - ref: ${{ inputs.commit }} - fetch-depth: 0 - persist-credentials: false - - name: Verify tagged source - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - git show "$GITHUB_SHA:scripts/release-recovery-guard.sh" >"$RUNNER_TEMP/recovery-guard.sh" - bash "$RUNNER_TEMP/recovery-guard.sh" verify - - name: Generate SBOM with pinned Syft image - run: | - set -euo pipefail - docker run --rm \ - --volume "$PWD:/src" \ - --workdir /src \ - anchore/syft@sha256:f94e5d9fce1f2278491a8e3a63bd5f6ddb81fdfdbb8bf7a1637565c1d5344357 \ - dir:. \ - --output "cyclonedx-json=sbom-${{ needs.validate.outputs.version }}.cdx.json" - test -s "sbom-${{ needs.validate.outputs.version }}.cdx.json" - - name: Upload SBOM - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: sbom - path: sbom-${{ needs.validate.outputs.version }}.cdx.json - if-no-files-found: error - retention-days: 7 - draft: - name: Create exact unsigned draft only - needs: [validate, verify-signature, build-ffi, sbom] - runs-on: ubuntu-latest - permissions: - contents: write - defaults: - run: - shell: bash - steps: - - name: Checkout exact tagged commit - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 - with: - ref: ${{ inputs.commit }} - fetch-depth: 0 - persist-credentials: false - - name: Recheck signed tag before assembling draft - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - git show "$GITHUB_SHA:scripts/release-recovery-guard.sh" >"$RUNNER_TEMP/recovery-guard.sh" - bash "$RUNNER_TEMP/recovery-guard.sh" verify - - name: Download FFI archives - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - pattern: ffi-* - path: dist/release - merge-multiple: true - - name: Download SBOM - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: sbom - path: dist/release - - name: Assert exact unsigned inventory and absent release - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - cp LICENSE-MIT LICENSE-APACHE dist/release/ - ./scripts/validate-release-assets.sh dist/release base - bash "$RUNNER_TEMP/recovery-guard.sh" ref-only - - name: Create new unsigned draft and upload exact assets - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - git show "$GITHUB_SHA:scripts/release-recovery-create-draft.sh" >"$RUNNER_TEMP/create-draft.sh" - bash "$RUNNER_TEMP/create-draft.sh" - - name: Verify exact draft state and remote inventory - env: - GH_TOKEN: ${{ github.token }} - run: ./scripts/check-draft-release.sh after diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 426c066..3574fb9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,7 @@ jobs: outputs: commit: ${{ steps.release.outputs.commit }} version: ${{ steps.release.outputs.version }} + tag_object: ${{ steps.release.outputs.tag_object }} defaults: run: shell: bash @@ -39,9 +40,13 @@ jobs: STORAGEPRIMS_REQUIRE_TAG: '1' run: | set -euo pipefail + ./scripts/release-restore-tag-ref.sh ./scripts/release-guard-tag-version.sh - printf 'commit=%s\n' "$(git rev-parse HEAD)" >>"$GITHUB_OUTPUT" - printf 'version=%s\n' "$(cat VERSION)" >>"$GITHUB_OUTPUT" + { + printf 'tag_object=%s\n' "$(git rev-parse "refs/tags/$STORAGEPRIMS_RELEASE_TAG")" + printf 'commit=%s\n' "$(git rev-parse HEAD)" + printf 'version=%s\n' "$(cat VERSION)" + } >>"$GITHUB_OUTPUT" - name: Verify package archives without publishing run: make release-check verify-signature: @@ -61,8 +66,10 @@ jobs: env: GH_TOKEN: ${{ github.token }} STORAGEPRIMS_RELEASE_TAG: ${{ github.ref_name }} + STORAGEPRIMS_EXPECTED_TAG_OBJECT: ${{ needs.validate.outputs.tag_object }} run: | set -euo pipefail + ./scripts/release-restore-tag-ref.sh ./scripts/verify-pinned-tag.sh EXPECTED_OBJECT="$(git rev-parse "refs/tags/$STORAGEPRIMS_RELEASE_TAG")" tag_json="$(gh api "repos/3leaps/storageprims/git/tags/$EXPECTED_OBJECT")" @@ -196,6 +203,7 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} STORAGEPRIMS_RELEASE_TAG: ${{ github.ref_name }} + STORAGEPRIMS_EXPECTED_TAG_OBJECT: ${{ needs.validate.outputs.tag_object }} STORAGEPRIMS_REQUIRE_TAG: '1' run: | set -euo pipefail @@ -205,6 +213,7 @@ jobs: git config --local --unset-all http.https://github.com/.extraheader || true } trap cleanup_credentials EXIT + ./scripts/release-restore-tag-ref.sh ./scripts/release-guard-tag-version.sh cleanup_credentials trap - EXIT @@ -215,15 +224,15 @@ jobs: GH_TOKEN: ${{ github.token }} STORAGEPRIMS_RELEASE_TAG: ${{ github.ref_name }} run: ./scripts/check-draft-release.sh before - - name: Create draft GitHub release - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 - with: - draft: true - prerelease: false - target_commitish: ${{ needs.validate.outputs.commit }} - body_path: docs/releases/${{ github.ref_name }}.md - files: | - dist/release/* + - name: Create new unsigned draft with exact assets + env: + GH_TOKEN: ${{ github.token }} + STORAGEPRIMS_RELEASE_TAG: ${{ github.ref_name }} + STORAGEPRIMS_EXPECTED_COMMIT: ${{ needs.validate.outputs.commit }} + STORAGEPRIMS_EXPECTED_TAG_OBJECT: ${{ needs.validate.outputs.tag_object }} + run: | + set -euo pipefail + ./scripts/release-create-draft.sh - name: Verify exact draft state and remote inventory env: GH_TOKEN: ${{ github.token }} diff --git a/Makefile b/Makefile index e35d1ac..a20d29f 100644 --- a/Makefile +++ b/Makefile @@ -549,8 +549,8 @@ release-crates-verify: ## Wait for the final registry version and verify each pu @CRATE="$(CRATE)" ./scripts/release-crates-verify.sh release-tooling-test: ## Run release guard, asset, cleanup, and hygiene tests - @./scripts/release-recovery-guard.test.sh - @./scripts/release-recovery-create-draft.test.sh + @./scripts/release-restore-tag-ref.test.sh + @./scripts/release-create-draft.test.sh @./scripts/release-decernor.test.sh @./scripts/release-pin-precursors.test.sh @./scripts/release-tag-controls.test.sh diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index a771ed8..a328aaf 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -187,11 +187,9 @@ output, not a claim of protection or an exception to signature checks. Neither target loads a secret from the repository or creates a tag implicitly during the push step. Stop on any failing target; do not replace an existing tag. -- [ ] Confirm the tag workflow is green, or the separately authorized - `v0.1.2` draft-recovery workflow is green for the existing signed tag. - Recovery runs from reviewed `main` with the approved tag object and - tagged commit, refuses an existing release, and creates only an unsigned - draft. Never recreate, move or force-push the signed tag. +- [ ] Confirm the tag workflow is green and its exact unsigned draft exists. + Stop on failure; never move or replace a signed tag without a separate + explicit maintainer decision. - [ ] Confirm the tag has GitHub **Verified** status and the `verify-signature` gate passed before a draft is created - [ ] Confirm the GitHub release is still a draft diff --git a/docs/cicd.md b/docs/cicd.md index f6039be..43f34fa 100644 --- a/docs/cicd.md +++ b/docs/cicd.md @@ -60,7 +60,7 @@ recovery without recreating or moving a published signed tag. See the [release checklist](../RELEASE_CHECKLIST.md) for the distinct local-tag, remote-push, and later signing steps. -The separately authorized `v0.1.2` draft-recovery workflow is dispatched from -reviewed `main` with the existing signed tag object and tagged commit. It -verifies the committed public pin and GitHub signature, builds unsigned assets -from the tagged tree, refuses to overwrite a release, and creates a draft only. +After checkout, the tag-triggered workflow restores the runner-local annotated +ref from origin before validation, signature verification, and draft inventory +checks. Draft creation uses a create-only request; a release conflict or partial +asset upload stops for inspection without replacing an existing release. diff --git a/scripts/release-recovery-create-draft.sh b/scripts/release-create-draft.sh similarity index 80% rename from scripts/release-recovery-create-draft.sh rename to scripts/release-create-draft.sh index 837de11..f450a9d 100755 --- a/scripts/release-recovery-create-draft.sh +++ b/scripts/release-create-draft.sh @@ -3,17 +3,25 @@ set -euo pipefail main() { - local tag=v0.1.2 commit=1666a2ee3166f2b764514fef36794dfd514967d1 + local tag commit local directory=dist/release response id notes name asset - [[ "${STORAGEPRIMS_RELEASE_TAG:-}" == "$tag" && - "${STORAGEPRIMS_EXPECTED_COMMIT:-}" == "$commit" ]] || { - echo 'error: recovery cut does not match the approved tag and commit' >&2 + source scripts/release-common.sh + tag="$(release_tag)" + commit="${STORAGEPRIMS_EXPECTED_COMMIT:-}" + [[ "$commit" =~ ^[0-9a-f]{40}$ && "$(git rev-parse HEAD)" == "$commit" ]] || { + echo 'error: draft commit differs from validated tag target' >&2 exit 1 } [[ -z "${GH_REPO+x}" ]] || { echo 'error: GH_REPO must be unset' >&2 exit 1 } + [[ "${STORAGEPRIMS_EXPECTED_TAG_OBJECT:-}" =~ ^[0-9a-f]{40}$ ]] || { + echo 'error: validated annotated tag object is required' >&2 + exit 1 + } + # Repeat the remote/local tag-object binding immediately before creation. + ./scripts/release-restore-tag-ref.sh ./scripts/validate-release-assets.sh "$directory" base notes="docs/releases/$tag.md" [[ -s "$notes" && ! -L "$notes" ]] || { @@ -39,7 +47,6 @@ main() { # Upload only the exact, previously validated base inventory. Uploading by # release ID cannot target a different release. No --clobber or overwrite path. - source scripts/release-common.sh while IFS= read -r name; do [[ "$name" =~ ^[A-Za-z0-9._-]+$ && -f "$directory/$name" && ! -L "$directory/$name" ]] || { echo 'error: unsafe release asset; draft may be partial' >&2 @@ -58,7 +65,7 @@ main() { exit 1 } done < <(release_base_assets) - echo '[ok] create-only unsigned recovery draft assets uploaded' + echo '[ok] create-only unsigned draft assets uploaded' } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then diff --git a/scripts/release-recovery-create-draft.test.sh b/scripts/release-create-draft.test.sh similarity index 56% rename from scripts/release-recovery-create-draft.test.sh rename to scripts/release-create-draft.test.sh index ac7c048..4c2323d 100755 --- a/scripts/release-recovery-create-draft.test.sh +++ b/scripts/release-create-draft.test.sh @@ -5,13 +5,19 @@ root="$(cd "$(dirname "$0")/.." && pwd -P)" scratch="$(mktemp -d)" trap 'rm -rf "$scratch"' EXIT mkdir -p "$scratch/repo/scripts" "$scratch/repo/docs/releases" "$scratch/repo/dist/release" "$scratch/bin" -printf 'Release notes\n' >"$scratch/repo/docs/releases/v0.1.2.md" +printf 'Release notes\n' >"$scratch/repo/docs/releases/v1.2.3.md" printf 'fixture\n' >"$scratch/repo/dist/release/asset.txt" cat >"$scratch/repo/scripts/validate-release-assets.sh" <<'SH' #!/usr/bin/env bash [[ "$1" == dist/release && "$2" == base && -f "$1/asset.txt" ]] SH +cat >"$scratch/repo/scripts/release-restore-tag-ref.sh" <<'SH' +#!/usr/bin/env bash +[[ "$STORAGEPRIMS_EXPECTED_TAG_OBJECT" == 0000000000000000000000000000000000000000 ]] || exit 1 +printf 'recheck\n' >>"$MOCK_CALLS" +SH cat >"$scratch/repo/scripts/release-common.sh" <<'SH' +release_tag() { printf 'v1.2.3\n'; } release_base_assets() { printf 'asset.txt\n'; } SH cat >"$scratch/bin/gh" <<'SH' @@ -21,7 +27,7 @@ case "$*" in [[ "$*" == *'-F draft=true'* && "$*" == *'-F prerelease=false'* ]] || exit 1 printf 'create\n' >>"$MOCK_CALLS" case "${MOCK_CREATE:-ok}" in - ok) printf '{"id":123,"tag_name":"v0.1.2","target_commitish":"1666a2ee3166f2b764514fef36794dfd514967d1","draft":true,"prerelease":false}\n' ;; + ok) printf '{"id":123,"tag_name":"v1.2.3","target_commitish":"%s","draft":true,"prerelease":false}\n' "$STORAGEPRIMS_EXPECTED_COMMIT" ;; conflict) printf 'HTTP 422: already exists\n' >&2; exit 1 ;; esac ;; *'--method POST '*'uploads.github.com/repos/3leaps/storageprims/releases/123/assets?name=asset.txt'*) @@ -32,23 +38,27 @@ case "$*" in *) exit 1 ;; esac SH -chmod +x "$scratch/bin/gh" "$scratch/repo/scripts/validate-release-assets.sh" +chmod +x "$scratch/bin/gh" "$scratch/repo/scripts/validate-release-assets.sh" "$scratch/repo/scripts/release-restore-tag-ref.sh" export PATH="$scratch/bin:$PATH" MOCK_CALLS="$scratch/calls" -export STORAGEPRIMS_RELEASE_TAG=v0.1.2 -export STORAGEPRIMS_EXPECTED_COMMIT=1666a2ee3166f2b764514fef36794dfd514967d1 +git -C "$scratch/repo" init -q +git -C "$scratch/repo" -c user.name='Fixture Author' -c user.email='fixture@example.invalid' commit -q --allow-empty -m fixture +export STORAGEPRIMS_RELEASE_TAG=v1.2.3 +STORAGEPRIMS_EXPECTED_COMMIT="$(git -C "$scratch/repo" rev-parse HEAD)" +export STORAGEPRIMS_EXPECTED_COMMIT +export STORAGEPRIMS_EXPECTED_TAG_OBJECT=0000000000000000000000000000000000000000 cd "$scratch/repo" -if MOCK_CREATE=conflict bash "$root/scripts/release-recovery-create-draft.sh" >/dev/null 2>&1; then +if MOCK_CREATE=conflict bash "$root/scripts/release-create-draft.sh" >/dev/null 2>&1; then echo 'error: create conflict was accepted' >&2 exit 1 fi -[[ "$(cat "$MOCK_CALLS")" == create ]] +[[ "$(cat "$MOCK_CALLS")" == $'recheck\ncreate' ]] : >"$MOCK_CALLS" -if MOCK_UPLOAD=fail bash "$root/scripts/release-recovery-create-draft.sh" >/dev/null 2>&1; then +if MOCK_UPLOAD=fail bash "$root/scripts/release-create-draft.sh" >/dev/null 2>&1; then echo 'error: partial draft upload was accepted' >&2 exit 1 fi -[[ "$(cat "$MOCK_CALLS")" == $'create\nupload' ]] +[[ "$(cat "$MOCK_CALLS")" == $'recheck\ncreate\nupload' ]] : >"$MOCK_CALLS" -bash "$root/scripts/release-recovery-create-draft.sh" >/dev/null -[[ "$(cat "$MOCK_CALLS")" == $'create\nupload' ]] +bash "$root/scripts/release-create-draft.sh" >/dev/null +[[ "$(cat "$MOCK_CALLS")" == $'recheck\ncreate\nupload' ]] echo '[ok] create-only draft conflict and partial upload controls passed' diff --git a/scripts/release-recovery-guard.sh b/scripts/release-recovery-guard.sh deleted file mode 100755 index 2e3aa7f..0000000 --- a/scripts/release-recovery-guard.sh +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env bash -# One-cut, read-only verification for a draft recovery from an immutable tag. -set -euo pipefail - -recovery_die() { - echo "error: $*" >&2 - return 1 -} - -recover_annotated_ref() { - local tag="$1" expected_object="$2" expected_commit="$3" remote_object - remote_object="$(git ls-remote --exit-code origin "refs/tags/$tag" | awk '{print $1}')" || { - recovery_die 'remote signed tag is absent or unreadable' - return 1 - } - [[ "$remote_object" == "$expected_object" ]] || { - recovery_die 'remote signed tag object differs from approved object' - return 1 - } - # actions/checkout may fetch the peeled commit into refs/tags/ after - # fetching all tags. Repair only this runner-local ref from the checked remote. - git fetch --quiet --no-tags origin "+refs/tags/$tag:refs/tags/$tag" || return 1 - [[ "$(git rev-parse "refs/tags/$tag")" == "$expected_object" && - "$(git cat-file -t "refs/tags/$tag")" == tag && - "$(git rev-parse "refs/tags/$tag^{}")" == "$expected_commit" ]] || { - recovery_die 'runner-local tag is not the approved annotated object' - return 1 - } -} - -require_absent_release() { - local tag="$1" probe - probe="$(mktemp)" - if gh api -i "repos/3leaps/storageprims/releases/tags/$tag" >"$probe" 2>&1; then - rm -f "$probe" - recovery_die 'release already exists; refusing to overwrite it' - return 1 - fi - if ! grep -Eq '^HTTP/[0-9.]+ 404( |$)' "$probe"; then - rm -f "$probe" - recovery_die 'release absence could not be confirmed' - return 1 - fi - rm -f "$probe" -} - -main() { - local mode="${1:-}" tag=v0.1.2 - local object=326257266c6def26e49fd4336ad03c3f8fcac532 - local commit=1666a2ee3166f2b764514fef36794dfd514967d1 - [[ "$mode" == verify || "$mode" == ref-only ]] || recovery_die 'expected verify or ref-only mode' - [[ "${GITHUB_EVENT_NAME:-}" == workflow_dispatch && "${GITHUB_REF:-}" == refs/heads/main ]] || - recovery_die 'recovery requires a default-branch manual dispatch' - [[ "${STORAGEPRIMS_RELEASE_TAG:-}" == "$tag" && - "${STORAGEPRIMS_EXPECTED_TAG_OBJECT:-}" == "$object" && - "${STORAGEPRIMS_EXPECTED_COMMIT:-}" == "$commit" ]] || - recovery_die 'recovery inputs differ from the approved signed cut' - [[ "$(git rev-parse HEAD)" == "$commit" && "$(cat VERSION)" == 0.1.2 ]] || - recovery_die 'checkout differs from the approved tagged tree' - [[ -z "$(git symbolic-ref -q HEAD || true)" ]] || recovery_die 'checkout must be detached' - case "$(git config --get remote.origin.url)" in - https://github.com/3leaps/storageprims | https://github.com/3leaps/storageprims.git) ;; - *) recovery_die 'origin must identify github.com/3leaps/storageprims' ;; - esac - if [[ "$mode" == verify ]]; then - [[ -z "$(git status --porcelain --untracked-files=normal)" ]] || - recovery_die 'tagged checkout must be clean before asset generation' - fi - recover_annotated_ref "$tag" "$object" "$commit" - if [[ "$mode" == verify ]]; then - STORAGEPRIMS_RELEASE_TAG="$tag" ./scripts/verify-pinned-tag.sh - local tag_json - tag_json="$(gh api "repos/3leaps/storageprims/git/tags/$object")" || return 1 - jq -e --arg tag "$tag" --arg commit "$commit" \ - '.tag == $tag and .object.type == "commit" and .object.sha == $commit and - .verification.verified == true and .verification.reason == "valid"' \ - <<<"$tag_json" >/dev/null || recovery_die 'GitHub tag verification differs from the approved signed object' - else - require_absent_release "$tag" - fi - echo "[ok] recovery $mode verified immutable $tag" -} - -if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then - main "$@" -fi diff --git a/scripts/release-recovery-guard.test.sh b/scripts/release-recovery-guard.test.sh deleted file mode 100755 index eb95131..0000000 --- a/scripts/release-recovery-guard.test.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env bash -# Model actions/checkout replacing an annotated tag ref with its peeled commit. -set -euo pipefail -root="$(cd "$(dirname "$0")/.." && pwd -P)" -scratch="$(mktemp -d)" -trap 'rm -rf "$scratch"' EXIT -git init -q --bare "$scratch/remote.git" -git init -q -b main "$scratch/source" -git -C "$scratch/source" config user.name 'Fixture Author' -git -C "$scratch/source" config user.email 'fixture@example.invalid' -printf 'fixture\n' >"$scratch/source/file" -git -C "$scratch/source" add file -git -C "$scratch/source" commit -qm 'fixture commit' -git -C "$scratch/source" tag -am 'Fixture release' v1.2.3 -fixture_commit="$(git -C "$scratch/source" rev-parse HEAD)" -object="$(git -C "$scratch/source" rev-parse refs/tags/v1.2.3)" -git -C "$scratch/source" remote add origin "$scratch/remote.git" -git -C "$scratch/source" push -q origin main refs/tags/v1.2.3 -git clone -q "$scratch/remote.git" "$scratch/checkout" -git -C "$scratch/checkout" fetch -q --no-tags origin "+$fixture_commit:refs/tags/v1.2.3" -[[ "$(git -C "$scratch/checkout" cat-file -t refs/tags/v1.2.3)" == commit ]] - -( - cd "$scratch/checkout" - source "$root/scripts/release-recovery-guard.sh" - recover_annotated_ref v1.2.3 "$object" "$fixture_commit" - [[ "$(git rev-parse refs/tags/v1.2.3)" == "$object" ]] - [[ "$(git cat-file -t refs/tags/v1.2.3)" == tag ]] - if recover_annotated_ref v1.2.3 "$object" 0000000000000000000000000000000000000000 >/dev/null 2>&1; then - echo 'error: wrong approved commit was accepted' >&2 - exit 1 - fi -) - -# A remote replacement must be rejected before changing a known-good local ref. -git -C "$scratch/remote.git" update-ref refs/tags/v1.2.3 "$fixture_commit" -( - cd "$scratch/checkout" - source "$root/scripts/release-recovery-guard.sh" - if recover_annotated_ref v1.2.3 "$object" "$fixture_commit" >/dev/null 2>&1; then - echo 'error: changed remote tag was accepted' >&2 - exit 1 - fi - [[ "$(git rev-parse refs/tags/v1.2.3)" == "$object" ]] -) -mkdir -p "$scratch/bin" -cat >"$scratch/bin/gh" <<'SH' -#!/usr/bin/env bash -[[ "$*" == 'api -i repos/3leaps/storageprims/releases/tags/v1.2.3' ]] || exit 1 -printf 'HTTP/2.0 %s\n' "${RELEASE_STATUS:-404}" -[[ "${RELEASE_STATUS:-404}" == 200 ]] -SH -chmod +x "$scratch/bin/gh" -( - export PATH="$scratch/bin:$PATH" - source "$root/scripts/release-recovery-guard.sh" - require_absent_release v1.2.3 - for status in 200 403; do - if RELEASE_STATUS="$status" require_absent_release v1.2.3 >/dev/null 2>&1; then - echo 'error: existing or unreadable release was accepted' >&2 - exit 1 - fi - done -) -echo '[ok] runner-local peeled tag repair and remote-object refusal passed' diff --git a/scripts/release-restore-tag-ref.sh b/scripts/release-restore-tag-ref.sh new file mode 100755 index 0000000..a70b342 --- /dev/null +++ b/scripts/release-restore-tag-ref.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Restore the remote annotated tag ref after actions/checkout peels it locally. +set -euo pipefail + +tag="${STORAGEPRIMS_RELEASE_TAG:-}" +[[ "$tag" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || { + echo 'error: canonical release tag required' >&2 + exit 1 +} +case "$(git config --get remote.origin.url)" in +https://github.com/3leaps/storageprims | https://github.com/3leaps/storageprims.git) ;; +*) + echo 'error: unexpected release origin' >&2 + exit 1 + ;; +esac +remote_object="$(git ls-remote --exit-code origin "refs/tags/$tag" | awk '{print $1}')" || { + echo 'error: remote release tag is absent' >&2 + exit 1 +} +[[ "$remote_object" =~ ^[0-9a-f]{40}$ ]] || { + echo 'error: remote tag object is invalid' >&2 + exit 1 +} +if [[ -n "${STORAGEPRIMS_EXPECTED_TAG_OBJECT+x}" ]]; then + [[ "$STORAGEPRIMS_EXPECTED_TAG_OBJECT" =~ ^[0-9a-f]{40}$ && + "$remote_object" == "$STORAGEPRIMS_EXPECTED_TAG_OBJECT" ]] || { + echo 'error: remote tag object differs from validated object' >&2 + exit 1 + } +fi +# This fetch changes only the runner-local ref. The existing strict guard and +# pinned-signature job separately verify the object, target, and signer. +git fetch --quiet --no-tags origin "+refs/tags/$tag:refs/tags/$tag" +[[ "$(git rev-parse "refs/tags/$tag")" == "$remote_object" && +"$(git cat-file -t "refs/tags/$tag")" == tag && +"$(git rev-parse "refs/tags/$tag^{}")" == "$(git rev-parse HEAD)" ]] || { + echo 'error: runner-local tag is not the remote annotated tag at HEAD' >&2 + exit 1 +} +echo "[ok] runner-local annotated tag restored for $tag" diff --git a/scripts/release-restore-tag-ref.test.sh b/scripts/release-restore-tag-ref.test.sh new file mode 100755 index 0000000..b2ffb5d --- /dev/null +++ b/scripts/release-restore-tag-ref.test.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail +root="$(cd "$(dirname "$0")/.." && pwd -P)" +scratch="$(mktemp -d)" +trap 'rm -rf "$scratch"' EXIT +git init -q --bare "$scratch/remote.git" +git init -q -b main "$scratch/source" +git -C "$scratch/source" config user.name 'Fixture Author' +git -C "$scratch/source" config user.email 'fixture@example.invalid' +printf 'fixture\n' >"$scratch/source/file" +printf '1.2.3\n' >"$scratch/source/VERSION" +cp "$root/scripts/release-guard-tag-version.sh" "$scratch/source/guard.sh" +git -C "$scratch/source" add file VERSION guard.sh +git -C "$scratch/source" commit -qm 'fixture commit' +git -C "$scratch/source" tag -am 'Fixture release' v1.2.3 +commit="$(git -C "$scratch/source" rev-parse HEAD)" +object="$(git -C "$scratch/source" rev-parse refs/tags/v1.2.3)" +git -C "$scratch/source" remote add origin "$scratch/remote.git" +git -C "$scratch/source" push -q origin main refs/tags/v1.2.3 +git clone -q "$scratch/remote.git" "$scratch/checkout" +git -C "$scratch/checkout" checkout -q --detach "$commit" +git -C "$scratch/checkout" fetch -q --no-tags origin "+$commit:refs/tags/v1.2.3" +[[ "$(git -C "$scratch/checkout" cat-file -t refs/tags/v1.2.3)" == commit ]] +( + cd "$scratch/checkout" + git remote set-url origin https://github.com/3leaps/storageprims.git + # Redirect only the fixture's read/fetch transport, not the identity check. + git config url."$scratch/remote.git".insteadOf https://github.com/3leaps/storageprims.git + if STORAGEPRIMS_RELEASE_TAG=v1.2.3 STORAGEPRIMS_REQUIRE_TAG=1 bash ./guard.sh >/dev/null 2>&1; then + echo 'error: strict guard accepted checkout peeled-ref rewrite' >&2 + exit 1 + fi + STORAGEPRIMS_RELEASE_TAG=v1.2.3 bash "$root/scripts/release-restore-tag-ref.sh" >/dev/null + [[ "$(git rev-parse refs/tags/v1.2.3)" == "$object" ]] + [[ "$(git cat-file -t refs/tags/v1.2.3)" == tag ]] + STORAGEPRIMS_RELEASE_TAG=v1.2.3 STORAGEPRIMS_REQUIRE_TAG=1 bash ./guard.sh >/dev/null + if STORAGEPRIMS_RELEASE_TAG=v1.2.4 bash "$root/scripts/release-restore-tag-ref.sh" >/dev/null 2>&1; then + echo 'error: absent tag was accepted' >&2 + exit 1 + fi +) +git -C "$scratch/source" tag -fa -m 'Different signature fixture' v1.2.3 +git -C "$scratch/source" push -q --force origin refs/tags/v1.2.3 +( + cd "$scratch/checkout" + if STORAGEPRIMS_RELEASE_TAG=v1.2.3 STORAGEPRIMS_EXPECTED_TAG_OBJECT="$object" \ + bash "$root/scripts/release-restore-tag-ref.sh" >/dev/null 2>&1; then + echo 'error: different annotated object for same commit was accepted' >&2 + exit 1 + fi + [[ "$(git rev-parse refs/tags/v1.2.3)" == "$object" ]] +) +git -C "$scratch/remote.git" update-ref refs/tags/v1.2.3 "$commit" +( + cd "$scratch/checkout" + if STORAGEPRIMS_RELEASE_TAG=v1.2.3 bash "$root/scripts/release-restore-tag-ref.sh" >/dev/null 2>&1; then + echo 'error: non-annotated remote tag was accepted' >&2 + exit 1 + fi +) +printf 'changed\n' >>"$scratch/source/file" +git -C "$scratch/source" add file +git -C "$scratch/source" commit -qm 'another fixture commit' +git -C "$scratch/source" tag -fa -m 'Other fixture release' v1.2.3 +git -C "$scratch/source" push -q --force origin refs/tags/v1.2.3 +( + cd "$scratch/checkout" + if STORAGEPRIMS_RELEASE_TAG=v1.2.3 bash "$root/scripts/release-restore-tag-ref.sh" >/dev/null 2>&1; then + echo 'error: remote tag targeting another commit was accepted' >&2 + exit 1 + fi +) +echo '[ok] checkout peeled-ref repair and remote negative controls passed'