From 9f90aade299c457597e1fcadad47720beb346258 Mon Sep 17 00:00:00 2001 From: nicodes Date: Sun, 27 Sep 2026 04:30:52 +0200 Subject: [PATCH] Retry an existing release without building a new ZIP A failed registry publish must be able to reuse the immutable GitHub release. Retry checks protected main, tag ancestry, plugin version, release target, and ZIP digest, then refuses to create another release. --- .github/actions/test/action.yml | 4 + .github/workflows/release.yml | 115 ++++--- README.md | 7 +- scripts/release_recovery.sh | 276 +++++++++++++++++ tests/release_recovery_test.sh | 529 ++++++++++++++++++++++++++++++++ 5 files changed, 887 insertions(+), 44 deletions(-) create mode 100755 scripts/release_recovery.sh create mode 100755 tests/release_recovery_test.sh diff --git a/.github/actions/test/action.yml b/.github/actions/test/action.yml index 641d985..6f87642 100644 --- a/.github/actions/test/action.yml +++ b/.github/actions/test/action.yml @@ -43,6 +43,10 @@ runs: shell: bash run: ./tests/runner_self_test.sh + - name: Release recovery negative controls + shell: bash + run: ./tests/release_recovery_test.sh + # No `if: hashFiles(...)` guard. This step used to skip itself when # tests/test.sh was absent, which is indistinguishable from the script being # renamed or deleted -- a skipped test is a green tick. This addon has a diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f37c6a..9093ac9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,13 +4,21 @@ on: workflow_dispatch: inputs: bump: - description: Version bump + description: Version bump. Ignored when retry-tag and retry-sha256 are both set. required: true type: choice options: - patch - minor - major + retry-tag: + description: Existing immutable vX.Y.Z tag to republish. Empty for a normal bump. + required: false + type: string + retry-sha256: + description: Required SHA-256 of the existing release ZIP when retry-tag is set. + required: false + type: string permissions: contents: read @@ -23,6 +31,9 @@ jobs: outputs: version: ${{ steps.release.outputs.version }} tag: ${{ steps.release.outputs.tag }} + retry: ${{ steps.release.outputs.retry }} + target: ${{ steps.release.outputs.target }} + sha256: ${{ steps.release.outputs.sha256 }} # Bounded, so a step that hangs fails here rather than sitting until the # runner's own timeout hours later. timeout-minutes: 20 @@ -35,49 +46,30 @@ jobs: id: release env: BUMP: ${{ inputs.bump }} + RETRY_TAG: ${{ inputs['retry-tag'] }} + RETRY_SHA256: ${{ inputs['retry-sha256'] }} run: | set -euo pipefail - if [ "$GITHUB_REF" != "refs/heads/main" ]; then - echo 'Run releases from the main branch.' >&2 - exit 1 - fi git fetch --tags --force - latest="$(git tag --list 'v[0-9]*' | sed -E 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)" - if [ -z "$latest" ]; then - version="0.0.1" - else - IFS=. read -r major minor patch <<< "$latest" - case "$BUMP" in - major) major=$((major + 1)); minor=0; patch=0 ;; - minor) minor=$((minor + 1)); patch=0 ;; - patch) patch=$((patch + 1)) ;; - *) echo "Unsupported bump: $BUMP" >&2; exit 1 ;; - esac - version="${major}.${minor}.${patch}" - fi - tag="v${version}" - if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then - echo "Tag already exists: $tag" >&2 - exit 1 - fi - plugin_version="$(sed -n -E 's/^version="([^"]+)"/\1/p' addon/plugin.cfg | head -n 1)" - if [ "$plugin_version" != "$version" ]; then - echo "addon/plugin.cfg version is $plugin_version, but the next $BUMP release is $version." >&2 - echo "Update addon/plugin.cfg to version=\"$version\", commit it, then rerun this workflow." >&2 - exit 1 - fi - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=$tag" >> "$GITHUB_OUTPUT" - - # The same checks CI runs, from the same definition. This workflow used - # to package and publish without running any of them -- the only thing - # between a broken commit and the GDAM registry was whether somebody had - # looked at CI. Running them here against this exact commit is the point: - # CI passing on this SHA earlier is a claim about that run. - - name: Test + ./scripts/release_recovery.sh plan + + # The same checks CI runs, from the same definition. Skipped on retry: + # retry republishes the existing ZIP and must not package a new one. + - name: Test exact release commit and package + if: steps.release.outputs.retry != 'true' uses: ./.github/actions/test + - name: Recover existing immutable release package + if: steps.release.outputs.retry == 'true' + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ steps.release.outputs.tag }} + EXPECTED_SHA256: ${{ steps.release.outputs.sha256 }} + TARGET: ${{ steps.release.outputs.target }} + run: ./scripts/release_recovery.sh recover + - name: Upload exact tested package + if: steps.release.outputs.retry != 'true' uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-package-${{ github.sha }} @@ -88,6 +80,16 @@ jobs: dist/web-acceptance.png if-no-files-found: error + - name: Upload recovered release package + if: steps.release.outputs.retry == 'true' + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-package-${{ github.sha }} + path: | + dist/@aviorstudio_gd-network.zip + dist/@aviorstudio_gd-network.zip.sha256 + if-no-files-found: error + publish: needs: test runs-on: ubuntu-latest @@ -108,17 +110,46 @@ jobs: run: bash ./scripts/verify_package_checksum.sh - name: Create GitHub Release + if: needs.test.outputs.retry != 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ needs.test.outputs.tag }} - run: gh release create "$TAG" dist/*.zip --target "$GITHUB_SHA" --title "$TAG" --notes "Release $TAG" + RETRY: ${{ needs.test.outputs.retry }} + RETRY_TAG: ${{ inputs['retry-tag'] }} + RETRY_SHA256: ${{ inputs['retry-sha256'] }} + run: | + set -euo pipefail + ./scripts/release_recovery.sh assert-bump + gh release create "$TAG" dist/*.zip --target "$GITHUB_SHA" --title "$TAG" --notes "Release $TAG" + + - name: Verify existing GitHub Release + if: needs.test.outputs.retry == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.test.outputs.tag }} + TARGET: ${{ needs.test.outputs.target }} + EXPECTED_SHA256: ${{ needs.test.outputs.sha256 }} + run: ./scripts/release_recovery.sh recheck - - name: Install GDAM + - name: Install checksum-verified GDAM v0.0.8 + id: install-gdam uses: aviorstudio/gdam-actions/install@d735444eb470194585def44521d5d91df2260e63 # v0.0.2 + with: + version: 'v0.0.8' + + - name: Verify GDAM version + env: + INSTALLED_VERSION: ${{ steps.install-gdam.outputs.version }} + run: | + case "$INSTALLED_VERSION" in + *0.0.8*) printf '%s\n' "$INSTALLED_VERSION" ;; + *) echo "Unexpected GDAM version: $INSTALLED_VERSION" >&2; exit 1 ;; + esac - - name: Publish to GDAM + - name: Publish exact GitHub asset to GDAM uses: aviorstudio/gdam-actions/publish@d735444eb470194585def44521d5d91df2260e63 # v0.0.2 with: - version: ${{ needs.test.outputs.version }} tag: ${{ needs.test.outputs.tag }} + addon: '@aviorstudio/gd-network' + asset: '@aviorstudio_gd-network.zip' secret-key: ${{ secrets.GDAM_SECRET_KEY }} diff --git a/README.md b/README.md index cf0535d..1d50626 100644 --- a/README.md +++ b/README.md @@ -104,11 +104,13 @@ packets, and rejects outbound UTF-8 text larger than 1 MiB. - `addon/src/`: reusable GDScript modules. - `tests/`: Godot test project/scripts for addon behavior. - `.github/workflows/ci.yml`: validates package shape and runs tests. -- `.github/workflows/release.yml`: creates GitHub release ZIPs and publishes to GDAM. +- `.github/workflows/release.yml`: creates GitHub release ZIPs and publishes to GDAM. A verified retry can republish an existing immutable release without creating another tag or ZIP. ## Versioning And Releases -The version in `addon/plugin.cfg` is the addon package version. `0.0.4` adds `post_zero_body`. Releases are created from `main` with the manual release workflow and plain semver tags like `v0.0.4`; the workflow verifies `plugin.cfg`, builds `@aviorstudio_gd-network.zip`, and publishes `@aviorstudio/gd-network` to GDAM. +The version in `addon/plugin.cfg` is the addon package version. `0.0.4` adds `post_zero_body`. Releases are created from protected `main` with the manual release workflow and plain semver tags like `v0.0.4`; the workflow verifies `plugin.cfg`, builds `@aviorstudio_gd-network.zip`, and publishes `@aviorstudio/gd-network` to GDAM. + +To republish an existing immutable tag after a failed registry publish, dispatch that same workflow from protected `main` with `retry-tag` and `retry-sha256` set together. Retry requires the tag commit to be the current main commit or an ancestor of it, `plugin.cfg` at that tag and on main to equal the tag version, the GitHub release target to match the tag, and the existing `@aviorstudio_gd-network.zip` digest to match `retry-sha256`. Retry downloads and checksums that ZIP. It does not build a package, create a tag, or create a GitHub release. Leave both retry inputs empty for a normal bump. ## Testing @@ -116,6 +118,7 @@ Run locally with: ```sh ./tests/test.sh +./tests/release_recovery_test.sh ``` **Correction ([fieldsofrevik#145](https://github.com/aviorstudio/fieldsofrevik/issues/145)):** the prior text said CI ran the test script diff --git a/scripts/release_recovery.sh b/scripts/release_recovery.sh new file mode 100755 index 0000000..2797ee5 --- /dev/null +++ b/scripts/release_recovery.sh @@ -0,0 +1,276 @@ +#!/usr/bin/env bash +# Plan a protected-main release, or bind a retry to an existing immutable tag. +# Retry never creates or moves a tag or GitHub release. assert-bump is the last +# gate in front of `gh release create` on the normal bump path. +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd) +ASSET_NAME='@aviorstudio_gd-network.zip' + +usage() { + echo "usage: release_recovery.sh plan|recover|recheck|assert-bump" >&2 + exit 2 +} + +repo_root() { + printf '%s\n' "${RELEASE_REPO_ROOT:-$ROOT_DIR}" +} + +plugin_version_text() { + local text="$1" + printf '%s\n' "$text" | sed -n -E 's/^version="([^"]+)"/\1/p' | head -n 1 +} + +require_sha() { + local label="$1" + local value="$2" + if ! [[ "$value" =~ ^[0-9a-f]{40}$ ]]; then + echo "$label must be a 40-character lowercase commit SHA." >&2 + exit 1 + fi +} + +require_tag() { + local tag="$1" + if ! [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Tag must be an immutable vX.Y.Z tag: $tag" >&2 + exit 1 + fi +} + +require_sha256() { + local value="$1" + if ! [[ "$value" =~ ^[0-9a-f]{64}$ ]]; then + echo "SHA-256 must be 64 lowercase hex characters." >&2 + exit 1 + fi +} + +require_protected_main() { + local repo="$1" + local head="" + if [ "${GITHUB_REF:-}" != "refs/heads/main" ]; then + echo "Run releases from protected main (refs/heads/main)." >&2 + exit 1 + fi + if [ -z "${GITHUB_SHA:-}" ]; then + echo "GITHUB_SHA is required as the permitted main target." >&2 + exit 1 + fi + require_sha "Permitted main target" "$GITHUB_SHA" + head="$(git -C "$repo" rev-parse HEAD)" + if [ "$head" != "$GITHUB_SHA" ]; then + echo "Checkout $head is not the permitted main target $GITHUB_SHA." >&2 + exit 1 + fi +} + +write_output() { + local key="$1" + local value="$2" + if [ -n "${GITHUB_OUTPUT:-}" ]; then + printf '%s=%s\n' "$key" "$value" >>"$GITHUB_OUTPUT" + fi + printf '%s=%s\n' "$key" "$value" +} + +emit_plan() { + write_output version "$1" + write_output tag "$2" + write_output retry "$3" + write_output target "$4" + write_output sha256 "$5" +} + +cmd_plan() { + local repo="" + local retry_tag="" + local retry_sha="" + local bump="" + local target="" + local version="" + local tag_plugin="" + local head_plugin="" + local shown="" + local latest="" + local major="" + local minor="" + local patch="" + local tag="" + repo="$(repo_root)" + require_protected_main "$repo" + retry_tag="${RETRY_TAG:-}" + retry_sha="${RETRY_SHA256:-}" + bump="${BUMP:-}" + + # Either retry input selects the immutable path. A partial pair must not + # fall through into a bump and create a new release. + if [ -n "$retry_tag" ] || [ -n "$retry_sha" ]; then + if [ -z "$retry_tag" ] || [ -z "$retry_sha" ]; then + echo "retry-tag and retry-sha256 must be set together." >&2 + exit 1 + fi + require_tag "$retry_tag" + require_sha256 "$retry_sha" + if ! target="$(git -C "$repo" rev-list -n 1 "$retry_tag" 2>/dev/null)"; then + echo "retry-tag does not resolve to a commit: $retry_tag" >&2 + exit 1 + fi + require_sha "Tag target" "$target" + if ! git -C "$repo" merge-base --is-ancestor "$target" "$GITHUB_SHA"; then + echo "Tag $retry_tag target $target is not an ancestor of permitted main $GITHUB_SHA." >&2 + exit 1 + fi + version="${retry_tag#v}" + if ! shown="$(git -C "$repo" show "${target}:addon/plugin.cfg" 2>/dev/null)"; then + echo "addon/plugin.cfg is missing at tag target $target." >&2 + exit 1 + fi + tag_plugin="$(plugin_version_text "$shown")" + if [ -z "$tag_plugin" ]; then + echo "addon/plugin.cfg at $target has no version." >&2 + exit 1 + fi + if [ ! -f "$repo/addon/plugin.cfg" ]; then + echo "addon/plugin.cfg is missing on permitted main." >&2 + exit 1 + fi + head_plugin="$(plugin_version_text "$(cat "$repo/addon/plugin.cfg")")" + if [ "$tag_plugin" != "$version" ] || [ "$head_plugin" != "$version" ]; then + echo "plugin.cfg must equal $version at the immutable tag and on permitted main (tag=$tag_plugin main=$head_plugin)." >&2 + exit 1 + fi + emit_plan "$version" "$retry_tag" true "$target" "$retry_sha" + return 0 + fi + + latest="$(git -C "$repo" tag --list 'v[0-9]*' | sed -E 's/^v//' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 1 || true)" + if [ -z "$latest" ]; then + version="0.0.1" + else + IFS=. read -r major minor patch <<<"$latest" + case "$bump" in + major) major=$((major + 1)); minor=0; patch=0 ;; + minor) minor=$((minor + 1)); patch=0 ;; + patch) patch=$((patch + 1)) ;; + *) echo "Unsupported bump: $bump" >&2; exit 1 ;; + esac + version="${major}.${minor}.${patch}" + fi + tag="v${version}" + if git -C "$repo" rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + echo "Tag already exists: $tag" >&2 + exit 1 + fi + if [ ! -f "$repo/addon/plugin.cfg" ]; then + echo "addon/plugin.cfg is missing." >&2 + exit 1 + fi + head_plugin="$(plugin_version_text "$(cat "$repo/addon/plugin.cfg")")" + if [ "$head_plugin" != "$version" ]; then + echo "addon/plugin.cfg version is $head_plugin, but the next $bump release is $version." >&2 + echo "Update addon/plugin.cfg to version=\"$version\", commit it, then rerun this workflow." >&2 + exit 1 + fi + emit_plan "$version" "$tag" false "$GITHUB_SHA" "" +} + +release_target() { + local tag="$1" + gh release view "$tag" --json targetCommitish --jq .targetCommitish +} + +release_digest() { + local tag="$1" + gh release view "$tag" --json assets --jq ".assets[] | select(.name==\"${ASSET_NAME}\") | .digest" +} + +require_release_identity() { + local tag="$1" + local expected="$2" + local target="$3" + local actual="" + require_tag "$tag" + require_sha256 "$expected" + require_sha "Tag target" "$target" + actual="$(release_target "$tag")" + if [ "$actual" != "$target" ]; then + echo "GitHub release target does not match the immutable tag target." >&2 + exit 1 + fi +} + +cmd_recover() { + local repo="" + local dist="" + local tag="" + local expected="" + local target="" + local actual_digest="" + local stage="" + local -a files=() + repo="$(repo_root)" + dist="${RELEASE_DIST_DIR:-$repo/dist}" + tag="${TAG:-}" + expected="${EXPECTED_SHA256:-}" + target="${TARGET:-}" + require_release_identity "$tag" "$expected" "$target" + stage="$(mktemp -d)" + # RETURN runs before this function's locals disappear, including on set -e. + trap 'rm -rf "$stage"' RETURN + gh release download "$tag" --pattern "$ASSET_NAME" --dir "$stage" + mapfile -t files < <(find "$stage" -type f | sort) + if [ "${#files[@]}" -ne 1 ] || [ "$(basename "${files[0]}")" != "$ASSET_NAME" ]; then + echo "Existing release download did not yield exactly $ASSET_NAME." >&2 + exit 1 + fi + printf '%s %s\n' "$expected" "$ASSET_NAME" >"$stage/$ASSET_NAME.sha256" + bash "$SCRIPT_DIR/verify_package_checksum.sh" "$stage" + actual_digest="$(release_digest "$tag")" + if [ "$actual_digest" != "sha256:$expected" ]; then + echo "Release asset digest does not match the pinned SHA-256." >&2 + exit 1 + fi + mkdir -p "$dist" + cp "$stage/$ASSET_NAME" "$dist/$ASSET_NAME" + cp "$stage/$ASSET_NAME.sha256" "$dist/$ASSET_NAME.sha256" + echo "REACHED gd-network release_recovery assertions=4" +} + +cmd_recheck() { + local tag="" + local expected="" + local target="" + local actual_digest="" + tag="${TAG:-}" + expected="${EXPECTED_SHA256:-}" + target="${TARGET:-}" + require_release_identity "$tag" "$expected" "$target" + actual_digest="$(release_digest "$tag")" + if [ "$actual_digest" != "sha256:$expected" ]; then + echo "Release asset digest does not match the pinned SHA-256." >&2 + exit 1 + fi + echo "REACHED gd-network existing_release assertions=2" +} + +cmd_assert_bump() { + if [ "${RETRY:-}" != "false" ]; then + echo "Refusing to create a release unless retry=false." >&2 + exit 1 + fi + if [ -n "${RETRY_TAG:-}" ] || [ -n "${RETRY_SHA256:-}" ]; then + echo "Refusing to create a release while retry inputs are set." >&2 + exit 1 + fi + echo "REACHED gd-network assert_bump" +} + +case "${1:-}" in + plan) cmd_plan ;; + recover) cmd_recover ;; + recheck) cmd_recheck ;; + assert-bump) cmd_assert_bump ;; + *) usage ;; +esac diff --git a/tests/release_recovery_test.sh b/tests/release_recovery_test.sh new file mode 100755 index 0000000..fb43c84 --- /dev/null +++ b/tests/release_recovery_test.sh @@ -0,0 +1,529 @@ +#!/usr/bin/env bash +# Negative controls for immutable release retry. These never call GitHub. +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +ROOT_DIR=$(cd "$SCRIPT_DIR/.." && pwd) +SCRIPT="$ROOT_DIR/scripts/release_recovery.sh" +WORKFLOW="$ROOT_DIR/.github/workflows/release.yml" +ASSET_NAME='@aviorstudio_gd-network.zip' +# Operator pin for the held v0.0.4 ZIP. Unit tests prove the planner accepts +# this exact digest and that a disagreed release identity is refused. +V004_SHA256=85e7dc926971ee210af309e577c816300f83cfdef64ca131c4be5e785450c047 + +controls=0 +reached=0 +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +expect_failure() { + local label="$1" + shift + local status=0 + local out="$work/fail-output" + local err="$work/fail-err" + : >"$out" + : >"$err" + set +e + GITHUB_OUTPUT="$out" "$@" >"$work/fail-stdout" 2>"$err" + status=$? + set -e + if [ "$status" -eq 0 ]; then + echo "Negative control unexpectedly passed: $label" >&2 + exit 1 + fi + if [ "$status" -eq 99 ]; then + echo "Negative control invoked a forbidden gh mutation or unplanned gh call: $label" >&2 + exit 1 + fi + if [ -s "$out" ]; then + echo "Negative control wrote release outputs: $label" >&2 + cat "$out" >&2 + exit 1 + fi + if [ -n "${EXPECT_MSG:-}" ] && ! grep -F -- "$EXPECT_MSG" "$err" >/dev/null; then + echo "Negative control missed expected message: $label" >&2 + echo "expected: $EXPECT_MSG" >&2 + cat "$err" >&2 + exit 1 + fi + controls=$((controls + 1)) + echo "CONTROL_FAIL_OK $label" +} + +install_tripwire_gh() { + local bin="$1" + mkdir -p "$bin" + cat >"$bin/gh" <<'EOF' +#!/bin/bash +echo "gh must not be called: $*" >&2 +exit 99 +EOF + chmod +x "$bin/gh" +} + +init_repo() { + local repo="$1" + mkdir -p "$repo" + git -C "$repo" init -b main >/dev/null + git -C "$repo" config user.email test@example.com + git -C "$repo" config user.name test +} + +commit_plugin() { + local repo="$1" + local version="$2" + local message="$3" + mkdir -p "$repo/addon" + printf '[plugin]\nversion="%s"\n' "$version" >"$repo/addon/plugin.cfg" + git -C "$repo" add addon/plugin.cfg + git -C "$repo" commit --allow-empty -m "$message" >/dev/null + git -C "$repo" rev-parse HEAD +} + +output_value() { + local file="$1" + local key="$2" + sed -n "s/^${key}=//p" "$file" | head -n 1 +} + +assert_eq() { + local label="$1" + local got="$2" + local want="$3" + if [ "$got" != "$want" ]; then + echo "$label: expected [$want] got [$got]" >&2 + exit 1 + fi +} + +run_plan() { + local repo="$1" + local out="$2" + shift 2 + : >"$out" + env \ + GITHUB_OUTPUT="$out" \ + GITHUB_REF=refs/heads/main \ + GITHUB_SHA="$(git -C "$repo" rev-parse HEAD)" \ + RELEASE_REPO_ROOT="$repo" \ + PATH="$tripwire:$PATH" \ + "$@" \ + "$SCRIPT" plan +} + +tripwire="$work/tripwire-bin" +install_tripwire_gh "$tripwire" + +base="$work/base" +init_repo "$base" +base_parent="$(commit_plugin "$base" 0.0.4 base)" +base_tag="$(commit_plugin "$base" 0.0.4 tagged)" +git -C "$base" tag v0.0.4 "$base_tag" +base_main="$(commit_plugin "$base" 0.0.4 main)" + +expect_failure ref-not-main \ + env GITHUB_REF=refs/heads/feature GITHUB_SHA="$base_main" RELEASE_REPO_ROOT="$base" PATH="$tripwire:$PATH" \ + "$SCRIPT" plan +expect_failure missing-permitted-sha \ + env GITHUB_REF=refs/heads/main RELEASE_REPO_ROOT="$base" PATH="$tripwire:$PATH" \ + "$SCRIPT" plan +EXPECT_MSG='is not the permitted main target' expect_failure checkout-not-permitted-target \ + env GITHUB_REF=refs/heads/main GITHUB_SHA="$base_parent" RELEASE_REPO_ROOT="$base" PATH="$tripwire:$PATH" \ + "$SCRIPT" plan +expect_failure bad-tag \ + env GITHUB_REF=refs/heads/main GITHUB_SHA="$base_main" RELEASE_REPO_ROOT="$base" PATH="$tripwire:$PATH" \ + RETRY_TAG=v0.0.4-rc1 RETRY_SHA256="$V004_SHA256" \ + "$SCRIPT" plan +expect_failure bad-digest \ + env GITHUB_REF=refs/heads/main GITHUB_SHA="$base_main" RELEASE_REPO_ROOT="$base" PATH="$tripwire:$PATH" \ + RETRY_TAG=v0.0.4 RETRY_SHA256=85E7DC926971EE210AF309E577C816300F83CFDEF64CA131C4BE5E785450C047 \ + "$SCRIPT" plan +partial="$work/partial" +init_repo "$partial" +partial_old="$(commit_plugin "$partial" 0.0.4 old)" +git -C "$partial" tag v0.0.4 "$partial_old" +partial_main="$(commit_plugin "$partial" 0.0.5 next)" +expect_failure tag-without-digest \ + env GITHUB_REF=refs/heads/main GITHUB_SHA="$partial_main" RELEASE_REPO_ROOT="$partial" PATH="$tripwire:$PATH" \ + RETRY_TAG=v0.0.4 \ + "$SCRIPT" plan +expect_failure digest-without-tag \ + env GITHUB_REF=refs/heads/main GITHUB_SHA="$partial_main" RELEASE_REPO_ROOT="$partial" PATH="$tripwire:$PATH" \ + BUMP=patch RETRY_SHA256="$V004_SHA256" \ + "$SCRIPT" plan +expect_failure missing-tag \ + env GITHUB_REF=refs/heads/main GITHUB_SHA="$base_main" RELEASE_REPO_ROOT="$base" PATH="$tripwire:$PATH" \ + RETRY_TAG=v9.9.9 RETRY_SHA256="$V004_SHA256" \ + "$SCRIPT" plan + +side="$work/side" +init_repo "$side" +side_main="$(commit_plugin "$side" 0.0.4 main)" +git -C "$side" checkout -b side "$side_main" >/dev/null +side_tip="$(commit_plugin "$side" 0.0.8 side)" +git -C "$side" tag v0.0.8 "$side_tip" +git -C "$side" checkout main >/dev/null +expect_failure tag-not-ancestor \ + env GITHUB_REF=refs/heads/main GITHUB_SHA="$side_main" RELEASE_REPO_ROOT="$side" PATH="$tripwire:$PATH" \ + RETRY_TAG=v0.0.8 RETRY_SHA256="$V004_SHA256" \ + "$SCRIPT" plan + +mismatch="$work/mismatch" +init_repo "$mismatch" +mismatch_tag="$(commit_plugin "$mismatch" 0.0.3 old)" +git -C "$mismatch" tag v0.0.4 "$mismatch_tag" +mismatch_main="$(commit_plugin "$mismatch" 0.0.4 main)" +expect_failure tag-plugin-mismatch \ + env GITHUB_REF=refs/heads/main GITHUB_SHA="$mismatch_main" RELEASE_REPO_ROOT="$mismatch" PATH="$tripwire:$PATH" \ + RETRY_TAG=v0.0.4 RETRY_SHA256="$V004_SHA256" \ + "$SCRIPT" plan + +moved="$work/moved" +init_repo "$moved" +moved_tag="$(commit_plugin "$moved" 0.0.4 tagged)" +git -C "$moved" tag v0.0.4 "$moved_tag" +moved_main="$(commit_plugin "$moved" 0.0.5 bumped)" +expect_failure main-plugin-mismatch \ + env GITHUB_REF=refs/heads/main GITHUB_SHA="$moved_main" RELEASE_REPO_ROOT="$moved" PATH="$tripwire:$PATH" \ + RETRY_TAG=v0.0.4 RETRY_SHA256="$V004_SHA256" \ + "$SCRIPT" plan + +duplicate="$work/duplicate" +init_repo "$duplicate" +duplicate_old="$(commit_plugin "$duplicate" 0.0.4 old)" +git -C "$duplicate" tag v0.0.4 "$duplicate_old" +commit_plugin "$duplicate" 0.0.5 next >/dev/null +dup_bin="$work/dup-bin" +mkdir -p "$dup_bin" +cat >"$dup_bin/git" <<'EOF' +#!/bin/bash +set -euo pipefail +if [ "${1:-}" = "-C" ] && [ "${3:-}" = "tag" ] && [ "${4:-}" = "--list" ]; then + printf '%s\n' v0.0.4 + exit 0 +fi +if [ "${1:-}" = "-C" ] && [ "${3:-}" = "rev-parse" ] && [[ "$*" == *refs/tags/v0.0.5* ]]; then + printf '%s\n' 0123456789abcdef0123456789abcdef01234567 + exit 0 +fi +exec /usr/bin/git "$@" +EOF +chmod +x "$dup_bin/git" +EXPECT_MSG='Tag already exists: v0.0.5' expect_failure duplicate-bump-tag \ + env GITHUB_REF=refs/heads/main GITHUB_SHA="$(git -C "$duplicate" rev-parse HEAD)" RELEASE_REPO_ROOT="$duplicate" PATH="$dup_bin:$tripwire:$PATH" \ + BUMP=patch \ + "$SCRIPT" plan +expect_failure bad-bump \ + env GITHUB_REF=refs/heads/main GITHUB_SHA="$base_main" RELEASE_REPO_ROOT="$base" PATH="$tripwire:$PATH" \ + BUMP=weekly \ + "$SCRIPT" plan +expect_failure bump-plugin-mismatch \ + env GITHUB_REF=refs/heads/main GITHUB_SHA="$base_main" RELEASE_REPO_ROOT="$base" PATH="$tripwire:$PATH" \ + BUMP=patch \ + "$SCRIPT" plan + +plan_out="$work/plan-ok" +run_plan "$base" "$plan_out" RETRY_TAG=v0.0.4 RETRY_SHA256="$V004_SHA256" BUMP=nope +assert_eq retry-ancestor-version "$(output_value "$plan_out" version)" 0.0.4 +assert_eq retry-ancestor-tag "$(output_value "$plan_out" tag)" v0.0.4 +assert_eq retry-ancestor-retry "$(output_value "$plan_out" retry)" true +assert_eq retry-ancestor-target "$(output_value "$plan_out" target)" "$base_tag" +if [ "$base_tag" = "$base_main" ]; then + echo "retry ancestor fixture did not create a descendant main commit" >&2 + exit 1 +fi +assert_eq retry-ancestor-sha "$(output_value "$plan_out" sha256)" "$V004_SHA256" +reached=$((reached + 1)) + +equal="$work/equal" +init_repo "$equal" +equal_tag="$(commit_plugin "$equal" 0.0.4 only)" +git -C "$equal" tag v0.0.4 "$equal_tag" +run_plan "$equal" "$plan_out" RETRY_TAG=v0.0.4 RETRY_SHA256="$V004_SHA256" +assert_eq retry-equal-target "$(output_value "$plan_out" target)" "$equal_tag" +assert_eq retry-equal-retry "$(output_value "$plan_out" retry)" true +reached=$((reached + 1)) + +next="$work/next" +init_repo "$next" +next_old="$(commit_plugin "$next" 0.0.4 old)" +git -C "$next" tag v0.0.4 "$next_old" +commit_plugin "$next" 0.0.5 next >/dev/null +run_plan "$next" "$plan_out" BUMP=patch +assert_eq bump-patch-version "$(output_value "$plan_out" version)" 0.0.5 +assert_eq bump-patch-tag "$(output_value "$plan_out" tag)" v0.0.5 +assert_eq bump-patch-retry "$(output_value "$plan_out" retry)" false +assert_eq bump-patch-target "$(output_value "$plan_out" target)" "$(git -C "$next" rev-parse HEAD)" +assert_eq bump-patch-sha "$(output_value "$plan_out" sha256)" "" +reached=$((reached + 1)) +commit_plugin "$next" 0.1.0 minor >/dev/null +run_plan "$next" "$plan_out" BUMP=minor +assert_eq bump-minor-tag "$(output_value "$plan_out" tag)" v0.1.0 +reached=$((reached + 1)) +commit_plugin "$next" 1.0.0 major >/dev/null +run_plan "$next" "$plan_out" BUMP=major +assert_eq bump-major-tag "$(output_value "$plan_out" tag)" v1.0.0 +reached=$((reached + 1)) + +first="$work/first" +init_repo "$first" +commit_plugin "$first" 0.0.1 first >/dev/null +run_plan "$first" "$plan_out" BUMP=patch +assert_eq bump-first-tag "$(output_value "$plan_out" tag)" v0.0.1 +assert_eq bump-first-retry "$(output_value "$plan_out" retry)" false +reached=$((reached + 1)) + +install_fake_gh() { + local bin="$1" + mkdir -p "$bin" + cat >"$bin/gh" <<'EOF' +#!/bin/bash +set -euo pipefail +printf '%s\n' "$*" >>"${GH_LOG:?}" +joined="$*" +case "$joined" in + *'release create'*|*'release delete'*|*'release edit'*|*'release upload'*) + echo "mutation refused: $joined" >&2 + exit 99 + ;; +esac +if [ "${1:-}" != "release" ]; then + echo "unexpected gh: $joined" >&2 + exit 97 +fi +cmd="$2" +shift 2 +tag="${1:-}" +shift || true +if [ "$tag" != "${FAKE_TAG:?}" ]; then + echo "unexpected tag: $tag" >&2 + exit 97 +fi +case "$cmd" in + view) + if [[ "$*" == *targetCommitish* ]]; then + printf '%s\n' "${FAKE_TARGET:?}" + exit 0 + fi + if [[ "$*" == *assets* ]]; then + printf '%s\n' "${FAKE_DIGEST:-}" + exit 0 + fi + echo "unexpected view: $*" >&2 + exit 97 + ;; + download) + dir="" + pattern="" + while [ "$#" -gt 0 ]; do + case "$1" in + --dir) dir="$2"; shift 2 ;; + --pattern) pattern="$2"; shift 2 ;; + *) echo "unexpected download arg: $1" >&2; exit 97 ;; + esac + done + if [ "$pattern" != "${FAKE_ASSET:?}" ]; then + echo "unexpected asset pattern: $pattern" >&2 + exit 97 + fi + mkdir -p "$dir" + case "${FAKE_DOWNLOAD_MODE:-file}" in + missing) exit 0 ;; + extra) printf extra >"$dir/extra.txt" ;; + esac + cp "${FAKE_ZIP:?}" "$dir/$pattern" + exit 0 + ;; + *) + echo "unexpected release cmd: $cmd" >&2 + exit 97 + ;; +esac +EOF + chmod +x "$bin/gh" +} + +fake_bin="$work/fake-bin" +install_fake_gh "$fake_bin" +good_zip="$work/good.zip" +bad_zip="$work/bad.zip" +printf 'exact-existing-zip' >"$good_zip" +printf 'tampered-zip' >"$bad_zip" +good_sha="$(sha256sum "$good_zip" | awk '{print $1}')" +other_target=0123456789abcdef0123456789abcdef01234567 +gh_log="$work/gh.log" + +run_recover() { + local mode="$1" + local zip="$2" + local expected="$3" + local fake_target="$4" + local fake_digest="$5" + : >"$gh_log" + PATH="$fake_bin:$PATH" \ + GH_LOG="$gh_log" \ + FAKE_TAG=v0.0.4 \ + FAKE_TARGET="$fake_target" \ + FAKE_DIGEST="$fake_digest" \ + FAKE_ASSET="$ASSET_NAME" \ + FAKE_ZIP="$zip" \ + FAKE_DOWNLOAD_MODE="$mode" \ + TAG=v0.0.4 \ + TARGET="$base_tag" \ + EXPECTED_SHA256="$expected" \ + RELEASE_DIST_DIR="$work/recovered" \ + "$SCRIPT" recover +} + +: >"$gh_log" +expect_failure release-target-mismatch \ + env PATH="$fake_bin:$PATH" GH_LOG="$gh_log" FAKE_TAG=v0.0.4 FAKE_TARGET="$other_target" \ + FAKE_DIGEST="sha256:$good_sha" FAKE_ASSET="$ASSET_NAME" FAKE_ZIP="$good_zip" \ + FAKE_DOWNLOAD_MODE=file TAG=v0.0.4 TARGET="$base_tag" EXPECTED_SHA256="$good_sha" \ + RELEASE_DIST_DIR="$work/recovered" \ + "$SCRIPT" recover +if grep -q 'release download' "$gh_log"; then + echo "target mismatch downloaded a release" >&2 + exit 1 +fi +reached=$((reached + 1)) + +expect_failure recover-bad-digest \ + env PATH="$fake_bin:$PATH" GH_LOG="$gh_log" FAKE_TAG=v0.0.4 FAKE_TARGET="$base_tag" \ + FAKE_DIGEST="sha256:$good_sha" FAKE_ASSET="$ASSET_NAME" FAKE_ZIP="$good_zip" \ + TAG=v0.0.4 TARGET="$base_tag" EXPECTED_SHA256=deadbeef \ + RELEASE_DIST_DIR="$work/recovered" \ + "$SCRIPT" recover +expect_failure file-digest-mismatch \ + env PATH="$fake_bin:$PATH" GH_LOG="$gh_log" FAKE_TAG=v0.0.4 FAKE_TARGET="$base_tag" \ + FAKE_DIGEST="sha256:$good_sha" FAKE_ASSET="$ASSET_NAME" FAKE_ZIP="$bad_zip" \ + FAKE_DOWNLOAD_MODE=file TAG=v0.0.4 TARGET="$base_tag" EXPECTED_SHA256="$good_sha" \ + RELEASE_DIST_DIR="$work/recovered" \ + "$SCRIPT" recover +expect_failure api-digest-mismatch \ + env PATH="$fake_bin:$PATH" GH_LOG="$gh_log" FAKE_TAG=v0.0.4 FAKE_TARGET="$base_tag" \ + FAKE_DIGEST="sha256:$V004_SHA256" FAKE_ASSET="$ASSET_NAME" FAKE_ZIP="$good_zip" \ + FAKE_DOWNLOAD_MODE=file TAG=v0.0.4 TARGET="$base_tag" EXPECTED_SHA256="$good_sha" \ + RELEASE_DIST_DIR="$work/recovered" \ + "$SCRIPT" recover +expect_failure missing-download \ + env PATH="$fake_bin:$PATH" GH_LOG="$gh_log" FAKE_TAG=v0.0.4 FAKE_TARGET="$base_tag" \ + FAKE_DIGEST="sha256:$good_sha" FAKE_ASSET="$ASSET_NAME" FAKE_ZIP="$good_zip" \ + FAKE_DOWNLOAD_MODE=missing TAG=v0.0.4 TARGET="$base_tag" EXPECTED_SHA256="$good_sha" \ + RELEASE_DIST_DIR="$work/recovered" \ + "$SCRIPT" recover +expect_failure extra-download-file \ + env PATH="$fake_bin:$PATH" GH_LOG="$gh_log" FAKE_TAG=v0.0.4 FAKE_TARGET="$base_tag" \ + FAKE_DIGEST="sha256:$good_sha" FAKE_ASSET="$ASSET_NAME" FAKE_ZIP="$good_zip" \ + FAKE_DOWNLOAD_MODE=extra TAG=v0.0.4 TARGET="$base_tag" EXPECTED_SHA256="$good_sha" \ + RELEASE_DIST_DIR="$work/recovered" \ + "$SCRIPT" recover + +rm -rf "$work/recovered" +: >"$gh_log" +run_recover file "$good_zip" "$good_sha" "$base_tag" "sha256:$good_sha" >/dev/null +test -f "$work/recovered/$ASSET_NAME" +cmp -s "$good_zip" "$work/recovered/$ASSET_NAME" +if grep -Eq 'release (create|delete|edit|upload)' "$gh_log"; then + echo "recover mutated a GitHub release" >&2 + exit 1 +fi +grep -q 'release download' "$gh_log" +reached=$((reached + 1)) + +expect_failure recheck-target-mismatch \ + env PATH="$fake_bin:$PATH" GH_LOG="$gh_log" FAKE_TAG=v0.0.4 FAKE_TARGET="$other_target" \ + FAKE_DIGEST="sha256:$good_sha" FAKE_ASSET="$ASSET_NAME" \ + TAG=v0.0.4 TARGET="$base_tag" EXPECTED_SHA256="$good_sha" \ + "$SCRIPT" recheck +expect_failure recheck-digest-mismatch \ + env PATH="$fake_bin:$PATH" GH_LOG="$gh_log" FAKE_TAG=v0.0.4 FAKE_TARGET="$base_tag" \ + FAKE_DIGEST="sha256:$V004_SHA256" FAKE_ASSET="$ASSET_NAME" \ + TAG=v0.0.4 TARGET="$base_tag" EXPECTED_SHA256="$good_sha" \ + "$SCRIPT" recheck +: >"$gh_log" +PATH="$fake_bin:$PATH" \ + GH_LOG="$gh_log" \ + FAKE_TAG=v0.0.4 \ + FAKE_TARGET="$base_tag" \ + FAKE_DIGEST="sha256:$V004_SHA256" \ + FAKE_ASSET="$ASSET_NAME" \ + TAG=v0.0.4 \ + TARGET="$base_tag" \ + EXPECTED_SHA256="$V004_SHA256" \ + "$SCRIPT" recheck >/dev/null +if grep -Eq 'release (create|delete|edit|upload|download)' "$gh_log"; then + echo "recheck downloaded or mutated a release" >&2 + exit 1 +fi +reached=$((reached + 1)) + +expect_failure assert-bump-retry \ + env PATH="$tripwire:$PATH" RETRY=true \ + "$SCRIPT" assert-bump +expect_failure assert-bump-empty \ + env PATH="$tripwire:$PATH" \ + "$SCRIPT" assert-bump +expect_failure assert-bump-retry-inputs \ + env PATH="$tripwire:$PATH" RETRY=false RETRY_TAG=v0.0.4 RETRY_SHA256="$V004_SHA256" \ + "$SCRIPT" assert-bump +expect_failure unknown-command \ + env PATH="$tripwire:$PATH" \ + "$SCRIPT" create +PATH="$tripwire:$PATH" RETRY=false "$SCRIPT" assert-bump >/dev/null +reached=$((reached + 1)) + +python3 - "$WORKFLOW" "$ASSET_NAME" <<'PY' +import sys +from pathlib import Path + +workflow, asset = sys.argv[1:] +text = Path(workflow).read_text() +lines = text.splitlines() +failures = [] + +def need(cond, message): + if not cond: + failures.append(message) + +header, jobs = text.split("jobs:", 1) +test_job, publish = jobs.split(" publish:", 1) +need("contents: read" in header and "contents: write" not in header, "workflow permissions") +need(text.count("contents: write") == 1 and "contents: write" in publish, "contents write only on publish") +need(text.count("environment: release") == 1 and "environment: release" in publish, "release environment only on publish") +need("environment:" not in test_job and "secrets.GDAM_SECRET_KEY" not in test_job, "test job has no release secret") +need("retry-tag:" in header and "retry-sha256:" in header, "retry inputs") +need(all(item in header for item in ("- patch", "- minor", "- major")), "bump choices") +need(text.count("gh release create") == 1, "single release create") +create_at = next(i for i, line in enumerate(lines) if "gh release create" in line) +create_window = "\n".join(lines[max(0, create_at - 20):create_at + 1]) +need("if: needs.test.outputs.retry != 'true'" in create_window, "create guarded by retry if") +need("./scripts/release_recovery.sh assert-bump" in create_window, "create calls assert-bump") +need(create_window.index("assert-bump") < create_window.index("gh release create"), "assert-bump before create") +need("version: ${{ needs.test.outputs.version }}" not in text, "unsupported version input removed") +publish_at = text.index(" - name: Publish exact GitHub asset to GDAM\n") +publish_block = text[publish_at:text.index("secret-key:", publish_at)] +need("version:" not in publish_block, "publish step has no version input") +need("addon: '@aviorstudio/gd-network'" in text, "explicit addon") +need(f"asset: '{asset}'" in text, "explicit asset") +need("version: 'v0.0.8'" in text and "*0.0.8*" in text, "pinned GDAM v0.0.8 check") +need("aviorstudio/gdam-actions/install@d735444eb470194585def44521d5d91df2260e63" in text, "install pin") +need("aviorstudio/gdam-actions/publish@d735444eb470194585def44521d5d91df2260e63" in text, "publish pin") +need("./scripts/release_recovery.sh plan" in text, "plan script") +need("./scripts/release_recovery.sh recover" in text, "recover script") +need("./scripts/release_recovery.sh recheck" in text, "recheck script") +need("if: steps.release.outputs.retry != 'true'" in text, "retry skips rebuild") +need("if: needs.test.outputs.retry == 'true'" in text, "publish recheck is retry-only") +if failures: + print("workflow contract failed:", *failures, sep="\n", file=sys.stderr) + sys.exit(1) +print("WORKFLOW_CONTRACT_OK") +PY +reached=$((reached + 1)) + +if [ "$controls" -ne 26 ] || [ "$reached" -ne 11 ]; then + echo "Expected 26 negative controls and 11 reached paths, observed controls=$controls reached=$reached" >&2 + exit 1 +fi +echo "PASS release_recovery_test controls=$controls reached=$reached"