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"