From a1d8d3f49f32143246e5458e2cb2a6906705cc27 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 18 Jul 2026 00:13:58 +0900 Subject: [PATCH 1/3] fix: remove invalid recovery-target release path Always target the approved event commit so each publish run can produce attestations that match its release tag. Remove the obsolete historical-release recovery flow and point authorization failures to the roll-forward runbook. --- .github/workflows/native-cli-publish.yml | 76 +++---------- docs/release-recovery-runbook.md | 5 +- scripts/resolve-native-cli-release-target.sh | 104 +----------------- scripts/test-native-cli-publish-workflow.sh | 52 +++------ .../test-resolve-native-cli-release-target.sh | 91 +-------------- 5 files changed, 39 insertions(+), 289 deletions(-) diff --git a/.github/workflows/native-cli-publish.yml b/.github/workflows/native-cli-publish.yml index d08365c87..dce3a9928 100644 --- a/.github/workflows/native-cli-publish.yml +++ b/.github/workflows/native-cli-publish.yml @@ -11,10 +11,6 @@ on: description: "Dry run (skip release upload)" type: boolean default: false - recovery-target: - description: "Build and tag the resolver-derived release commit instead of the branch head" - type: boolean - default: false jobs: build: @@ -45,20 +41,6 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: scripts/resolve-native-cli-release-target.sh >> "$GITHUB_OUTPUT" - - name: Check out resolver-derived recovery target - if: github.event_name == 'workflow_dispatch' && inputs.recovery-target && (steps.release.outputs.publish == 'true' || steps.release.outputs.release == 'true') - env: - TARGET_SHA: ${{ steps.release.outputs.sha }} - run: | - set -eu - # Why the ancestor check: recovery may only rebuild a commit that already - # landed on the approved branch; any other target must fail closed. - if ! git merge-base --is-ancestor "${TARGET_SHA}" "${GITHUB_SHA}"; then - echo "Recovery target ${TARGET_SHA} is not an ancestor of approved event commit ${GITHUB_SHA}." >&2 - exit 1 - fi - git checkout --detach "${TARGET_SHA}" - - name: Verify release target matches build commit if: steps.release.outputs.publish == 'true' || steps.release.outputs.release == 'true' env: @@ -176,7 +158,6 @@ jobs: env: GITHUB_REPOSITORY: ${{ github.repository }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RECOVERY_TARGET: ${{ github.event_name == 'workflow_dispatch' && inputs.recovery-target }} run: | set -eu metadata_value() { @@ -199,38 +180,11 @@ jobs: event_ref=$(metadata_value event_ref) BUILD_SHA="${build_sha}" TARGET_SHA="${target_sha}" - if [ "${RECOVERY_TARGET}" = "true" ]; then - if [ "${BUILD_SHA}" != "${TARGET_SHA}" ]; then - echo "Recovery build SHA must match the release target commit." >&2 - exit 1 - fi - # Why an API comparison: the metadata artifact comes from the unprivileged - # build job, so ancestry on the approved event commit is re-verified from - # an independent source before the privileged job tags that commit. - compare_status=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${TARGET_SHA}...${GITHUB_SHA}" --jq '.status') - if [ "${compare_status}" != "ahead" ] && [ "${compare_status}" != "identical" ]; then - echo "Recovery target ${TARGET_SHA} is not an ancestor of approved event commit ${GITHUB_SHA}." >&2 - exit 1 - fi - release_sha="${TARGET_SHA}" - # Why publishing is blocked here: the attestation certificate binds - # SourceRepositoryDigest to the workflow run head (GITHUB_SHA), never - # to the checked-out build commit, so assets published for an older - # release commit can never pass the dispatcher's tag-to-certificate - # verification. Recovery dispatch may only validate builds. - if [ "${should_publish}" = "true" ] && [ "${release_sha}" != "${GITHUB_SHA}" ]; then - echo "Recovery-target dispatch cannot publish release assets: the release commit ${release_sha} differs from the run head ${GITHUB_SHA}, so the attestation certificate digest would never match the release tag." >&2 - echo "Rerun the original failed workflow run whose head commit is the release commit instead." >&2 - echo "See docs/release-recovery-runbook.md for the recovery procedure." >&2 - exit 1 - fi - else - if [ "${BUILD_SHA}" != "${GITHUB_SHA}" ] || [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then - echo "Build SHA and release target must match the approved event commit." >&2 - exit 1 - fi - release_sha="${GITHUB_SHA}" + if [ "${BUILD_SHA}" != "${GITHUB_SHA}" ] || [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then + echo "Build SHA and release target must match the approved event commit." >&2 + exit 1 fi + release_sha="${GITHUB_SHA}" if [ "${event_ref}" != "refs/heads/main" ] && [ "${event_ref}" != "refs/heads/v3-beta" ]; then echo "Unsupported release ref ${event_ref}." >&2 exit 1 @@ -293,9 +247,15 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} - RECOVERY_TARGET: ${{ github.event_name == 'workflow_dispatch' && inputs.recovery-target }} run: | set -eu + report_release_creation_failure() { + error_path=$1 + cat "${error_path}" >&2 + if grep -qE 'HTTP 403|Resource not accessible by integration' "${error_path}"; then + echo "See docs/release-recovery-runbook.md for the roll-forward recovery procedure." >&2 + fi + } tag_error_path="${RUNNER_TEMP}/native-cli-release-tag-error" set +e tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha' 2> "${tag_error_path}") @@ -325,12 +285,7 @@ jobs: tag_create_status=$? set -e if [ "${tag_create_status}" -ne 0 ]; then - cat "${tag_create_error_path}" >&2 - if [ "${RECOVERY_TARGET}" = "true" ] && grep -qE 'HTTP 403|Resource not accessible by integration' "${tag_create_error_path}"; then - echo "Recovery-target release creation was rejected for a historical commit by GitHub." >&2 - echo "Create the tag and draft release as the repository owner, then rerun this workflow." >&2 - echo "See docs/release-recovery-runbook.md for the recovery procedure." >&2 - fi + report_release_creation_failure "${tag_create_error_path}" exit "${tag_create_status}" fi fi @@ -351,12 +306,7 @@ jobs: release_create_status=$? set -e if [ "${release_create_status}" -ne 0 ]; then - cat "${release_create_error_path}" >&2 - if [ "${RECOVERY_TARGET}" = "true" ] && grep -qE 'HTTP 403|Resource not accessible by integration' "${release_create_error_path}"; then - echo "Recovery-target draft release creation was rejected for a historical commit by GitHub." >&2 - echo "Create the tag and draft release as the repository owner, then rerun this workflow." >&2 - echo "See docs/release-recovery-runbook.md for the recovery procedure." >&2 - fi + report_release_creation_failure "${release_create_error_path}" exit "${release_create_status}" fi echo "release_published=false" >> "$GITHUB_OUTPUT" diff --git a/docs/release-recovery-runbook.md b/docs/release-recovery-runbook.md index fff224d81..581ae2f77 100644 --- a/docs/release-recovery-runbook.md +++ b/docs/release-recovery-runbook.md @@ -86,9 +86,8 @@ gh run rerun --repo / --failed ## Operational notes -- Do not dispatch `recovery-target` to publish a historical release. It is - limited to validation because a later run cannot produce valid attestations - for an earlier commit. +- Do not start a new workflow run to publish a historical release. A later run + cannot produce valid attestations for an earlier commit. - Cancel an older run waiting for `cli-release` approval before retrying. The workflow concurrency group does not cancel it automatically, so it can block the newer run indefinitely. diff --git a/scripts/resolve-native-cli-release-target.sh b/scripts/resolve-native-cli-release-target.sh index bfd84079a..2aa6e86ac 100755 --- a/scripts/resolve-native-cli-release-target.sh +++ b/scripts/resolve-native-cli-release-target.sh @@ -2,7 +2,6 @@ set -eu ROOT_DIR=$(CDPATH= cd "$(dirname "$0")/.." && pwd) -LEGACY_CLI_RELEASE_TAG_PREFIX="cli-v" : "${EVENT_NAME:?EVENT_NAME is required}" @@ -164,93 +163,6 @@ release_commit_updates_cli_version() { ' } -release_commit_sha_for_version() { - version=$1 - build_sha=$2 - release_branch=${EVENT_REF_NAME:-} - - git log --format='%H %s' "$build_sha" \ - | awk -F ' ' -v version="$version" -v release_branch="$release_branch" ' - function value_appears_as_release_token(remainder, value, parts, part_index) { - split(remainder, parts, " ") - for (part_index in parts) { - if (parts[part_index] == value) { - return 1 - } - } - return 0 - } - - function release_remainder_matches_version(remainder) { - if (value_appears_as_release_token(remainder, version)) { - return "version" - } - - return "" - } - - function release_remainder_matches_branch(remainder) { - if (release_branch != "" && value_appears_as_release_token(remainder, release_branch)) { - return "branch" - } - - return "" - } - - function is_release_please_subject(subject) { - plain_prefix = "chore: release " - scoped_marker = "): release " - - if (index(subject, plain_prefix) == 1) { - release_remainder = substr(subject, length(plain_prefix) + 1) - release_match = release_remainder_matches_version(release_remainder) - if (release_match != "") { - return release_match - } - - return release_remainder_matches_branch(release_remainder) - } - - if (index(subject, "chore(") != 1) { - return "" - } - - scope_end = index(subject, ")") - marker_start = scope_end - marker = substr(subject, marker_start, length(scoped_marker)) - if (scope_end == 0 || marker != scoped_marker) { - return "" - } - - release_remainder = substr(subject, marker_start + length(scoped_marker)) - release_match = release_remainder_matches_version(release_remainder) - if (release_match != "") { - return release_match - } - - return release_remainder_matches_branch(release_remainder) - } - - { - release_match = is_release_please_subject($2) - if (release_match != "") { - print $1 "\t" release_match - } - } - ' \ - | while IFS=' ' read -r candidate_sha release_match; do - if [ "$release_match" = "version" ]; then - printf '%s\n' "$candidate_sha" - return - fi - - if release_commit_updates_cli_version "$candidate_sha" "$version"; then - printf '%s\n' "$candidate_sha" - return - fi - done -} - VERSION=$(jq -r '.["cli/project-runner"]' .release-please-manifest.json) if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then echo "Could not resolve project runner release version from .release-please-manifest.json." >&2 @@ -313,17 +225,7 @@ fi TARGET_SHA=$(git rev-parse HEAD) BUILD_SHA=$TARGET_SHA -RELEASE_TARGET_SHA=$(release_commit_sha_for_version "$VERSION" "$BUILD_SHA") -if [ -z "$RELEASE_TARGET_SHA" ]; then - RELEASE_TARGET_SHA=$BUILD_SHA -fi -LEGACY_CLI_RELEASE_TAG="$LEGACY_CLI_RELEASE_TAG_PREFIX$VERSION" -if [ "$CAN_EVALUATE_CLI_RELEASE" = "true" ] && - [ "$RELEASE_TARGET_SHA" != "$BUILD_SHA" ] && - release_is_published "$LEGACY_CLI_RELEASE_TAG"; then - echo "Project runner release tag namespace changed for $VERSION; targeting build commit $BUILD_SHA instead of legacy release commit $RELEASE_TARGET_SHA." >&2 - RELEASE_TARGET_SHA=$BUILD_SHA -fi +RELEASE_TARGET_SHA=$BUILD_SHA if [ "$CAN_EVALUATE_CLI_RELEASE" != "true" ]; then SHOULD_PUBLISH=false @@ -343,8 +245,8 @@ else if [ -z "$PREVIOUS_CLI_RELEASE_TAG" ]; then echo "No previous project runner asset release found; publishing native project runner assets." >&2 SHOULD_PUBLISH=true - elif release_commit_updates_cli_version "$RELEASE_TARGET_SHA" "$VERSION"; then - echo "Project runner release metadata changed in $RELEASE_TARGET_SHA; publishing native project runner assets." >&2 + elif release_commit_updates_cli_version "$BUILD_SHA" "$VERSION"; then + echo "Project runner release metadata changed in $BUILD_SHA; publishing native project runner assets." >&2 SHOULD_PUBLISH=true elif cli_release_inputs_changed "$PREVIOUS_CLI_RELEASE_TAG" "$TARGET_SHA"; then echo "Project runner release inputs changed since $PREVIOUS_CLI_RELEASE_TAG; publishing native project runner assets." >&2 diff --git a/scripts/test-native-cli-publish-workflow.sh b/scripts/test-native-cli-publish-workflow.sh index 40343a7a0..46b08e0d1 100755 --- a/scripts/test-native-cli-publish-workflow.sh +++ b/scripts/test-native-cli-publish-workflow.sh @@ -137,24 +137,14 @@ test_checkout_free_publish_has_explicit_repository_context() { fi } -test_recovery_dispatch_is_bound_to_the_resolver_target() { - assert_contains " recovery-target:" - assert_not_contains "inputs.sha" - assert_not_contains "INPUT_SHA" - assert_contains " - name: Check out resolver-derived recovery target" - assert_contains 'if ! git merge-base --is-ancestor "${TARGET_SHA}" "${GITHUB_SHA}"; then' - assert_contains 'git checkout --detach "${TARGET_SHA}"' - assert_before " - name: Check out resolver-derived recovery target" " - name: Verify release target matches build commit" - assert_contains 'build_sha=$(git rev-parse HEAD)' - assert_contains 'if [ "${TARGET_SHA}" != "${build_sha}" ]; then' - assert_contains " RECOVERY_TARGET: \${{ github.event_name == 'workflow_dispatch' && inputs.recovery-target }}" - assert_contains 'if [ "${BUILD_SHA}" != "${TARGET_SHA}" ]; then' - assert_contains 'compare_status=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${TARGET_SHA}...${GITHUB_SHA}" --jq '\''.status'\'')' - assert_contains 'if [ "${compare_status}" != "ahead" ] && [ "${compare_status}" != "identical" ]; then' +test_publish_has_no_recovery_target_mode() { + # Verify publishing always uses the event-head release target without a recovery-only path. + assert_not_contains "recovery-target" + assert_not_contains "RECOVERY_TARGET" + assert_not_contains "Recovery-target" + assert_not_contains "Check out resolver-derived recovery target" assert_contains 'printf '\''RELEASE_SHA=%s\n'\'' "${release_sha}" >> "$GITHUB_ENV"' - assert_count 3 'if [ "${tag_sha}" != "${RELEASE_SHA}" ]; then' assert_contains 'if [ -n "${tag_sha}" ] && [ "${tag_sha}" != "${RELEASE_SHA}" ]; then' - assert_not_contains '--target "${GITHUB_SHA}"' assert_contains '--target "${RELEASE_SHA}"' } @@ -233,24 +223,13 @@ test_release_tag_is_created_before_the_release() { assert_before 'gh api "repos/${GITHUB_REPOSITORY}/git/refs"' 'gh release create "${RELEASE_TAG}"' } -test_recovery_target_403_errors_explain_the_manual_recovery() { - # Verify recovery-target 403 failures explain the owner-assisted recovery procedure. - assert_contains 'RECOVERY_TARGET: ${{ github.event_name == '\''workflow_dispatch'\'' && inputs.recovery-target }}' - assert_contains 'grep -qE '\''HTTP 403|Resource not accessible by integration'\''' - assert_contains 'Recovery-target release creation was rejected for a historical commit by GitHub.' - assert_contains 'Recovery-target draft release creation was rejected for a historical commit by GitHub.' - assert_contains 'See docs/release-recovery-runbook.md for the recovery procedure.' -} - -test_recovery_dispatch_refuses_to_publish_from_a_different_head() { - # Verify recovery dispatch fails closed before publishing assets: the - # attestation certificate digest is always the run head, so publishing for an - # older release commit would create a release that never passes verification. - assert_contains 'if [ "${should_publish}" = "true" ] && [ "${release_sha}" != "${GITHUB_SHA}" ]; then' - assert_contains 'Recovery-target dispatch cannot publish release assets' - assert_contains 'Rerun the original failed workflow run whose head commit is the release commit instead.' - assert_before 'release_sha="${TARGET_SHA}"' 'Recovery-target dispatch cannot publish release assets' - assert_before 'Recovery-target dispatch cannot publish release assets' 'printf '\''RELEASE_SHA=%s\n'\'' "${release_sha}" >> "$GITHUB_ENV"' +test_release_creation_403_points_to_roll_forward_recovery() { + # Verify authorization failures direct operators to the supported recovery procedure. + assert_contains 'if grep -qE '\''HTTP 403|Resource not accessible by integration'\'' "${error_path}"; then' + assert_contains 'report_release_creation_failure "${tag_create_error_path}"' + assert_contains 'report_release_creation_failure "${release_create_error_path}"' + assert_contains 'See docs/release-recovery-runbook.md for the roll-forward recovery procedure.' + assert_not_contains 'Create the tag and draft release as the repository owner, then rerun this workflow.' } test_draft_creation_accepts_only_the_known_missing_tag_responses() { @@ -295,15 +274,14 @@ test_post_publish_automation_remains_outside_the_privileged_job() { test_build_and_publish_jobs_have_separate_trust_boundaries test_unprivileged_build_uses_only_the_approved_event_commit -test_recovery_dispatch_is_bound_to_the_resolver_target +test_publish_has_no_recovery_target_mode test_publish_validates_metadata_without_checking_out_source test_checkout_free_publish_has_explicit_repository_context test_assets_are_attested_after_the_manifest_is_verified test_remote_attestation_digests_match_the_release_tag_before_publishing test_publish_rejects_manifest_and_existing_tag_mismatches test_release_tag_is_created_before_the_release -test_recovery_target_403_errors_explain_the_manual_recovery -test_recovery_dispatch_refuses_to_publish_from_a_different_head +test_release_creation_403_points_to_roll_forward_recovery test_draft_creation_accepts_only_the_known_missing_tag_responses test_publish_rechecks_the_tag_and_uses_least_privilege_post_publish_permissions test_build_verifies_assets_before_writing_the_publish_input diff --git a/scripts/test-resolve-native-cli-release-target.sh b/scripts/test-resolve-native-cli-release-target.sh index 556f59506..fcbfbd8d3 100755 --- a/scripts/test-resolve-native-cli-release-target.sh +++ b/scripts/test-resolve-native-cli-release-target.sh @@ -38,24 +38,12 @@ case "$1" in rev-parse) printf '%s\n' "${BUILD_SHA_VALUE:-target-sha}" ;; - log) - printf '%s\t%s\n' "${BUILD_SHA_VALUE:-target-sha}" "$BUILD_COMMIT_SUBJECT" - if [ -n "${RELEASE_COMMIT_SHA:-}" ] && [ "$RELEASE_COMMIT_SHA" != "${BUILD_SHA_VALUE:-target-sha}" ]; then - printf '%s\t%s\n' "$RELEASE_COMMIT_SHA" "$RELEASE_COMMIT_SUBJECT" - fi - ;; show) commit_sha=$3 if [ "$commit_sha" = "${BUILD_SHA_VALUE:-target-sha}" ] && [ "${BUILD_COMMIT_UPDATES_CLI:-false}" = "true" ]; then emit_cli_release_diff "$CURRENT_VERSION" exit 0 fi - - if [ -n "${RELEASE_COMMIT_SHA:-}" ] && [ "$commit_sha" = "$RELEASE_COMMIT_SHA" ] && [ "${RELEASE_COMMIT_UPDATES_CLI:-false}" = "true" ]; then - emit_cli_release_diff "$CURRENT_VERSION" - exit 0 - fi - exit 0 ;; *) @@ -108,11 +96,6 @@ if [ "$1" = "release" ] && [ "$2" = "view" ]; then exit 0 fi - if [ "$tag" = "cli-v$CURRENT_VERSION" ]; then - release_json "$LEGACY_CURRENT_RELEASE_STATE" true - exit 0 - fi - if [ -n "$PREVIOUS_RELEASE_TAG" ] && [ "$tag" = "$PREVIOUS_RELEASE_TAG" ]; then release_json published "$PREVIOUS_RELEASE_HAS_ASSETS" exit 0 @@ -196,18 +179,7 @@ run_success_case() { expected_release=${13} expected_sha=${14:-target-sha} build_sha_value=${15:-target-sha} - release_commit_sha=${16:-target-sha} - release_commit_subject=${17:-} - build_commit_subject=${18:-} - build_commit_updates_cli=${19:-false} - release_commit_updates_cli=${20:-false} - legacy_current_release_state=${21:-missing} - if [ -z "$release_commit_subject" ]; then - release_commit_subject="chore(v3-beta): release $current_version" - fi - if [ -z "$build_commit_subject" ]; then - build_commit_subject=$release_commit_subject - fi + build_commit_updates_cli=${16:-false} work_dir="$TMP_DIR/$name" mock_bin="$work_dir/bin" @@ -222,12 +194,7 @@ run_success_case() { CURRENT_RELEASE_STATE="$current_release_state" \ CURRENT_RELEASE_HAS_ASSETS="$current_release_has_assets" \ BUILD_SHA_VALUE="$build_sha_value" \ - BUILD_COMMIT_SUBJECT="$build_commit_subject" \ BUILD_COMMIT_UPDATES_CLI="$build_commit_updates_cli" \ - RELEASE_COMMIT_SHA="$release_commit_sha" \ - RELEASE_COMMIT_SUBJECT="$release_commit_subject" \ - RELEASE_COMMIT_UPDATES_CLI="$release_commit_updates_cli" \ - LEGACY_CURRENT_RELEASE_STATE="$legacy_current_release_state" \ PREVIOUS_RELEASE_TAG="$previous_release_tag" \ PREVIOUS_RELEASE_HAS_ASSETS="$previous_release_has_assets" \ CLI_SOURCE_CHANGED="$cli_source_changed" \ @@ -273,10 +240,6 @@ run_failure_case() { CURRENT_RELEASE_STATE="$current_release_state" \ CURRENT_RELEASE_HAS_ASSETS=false \ BUILD_SHA_VALUE=target-sha \ - BUILD_COMMIT_SUBJECT="chore(v3-beta): release $current_version" \ - RELEASE_COMMIT_SHA=target-sha \ - RELEASE_COMMIT_SUBJECT="chore(v3-beta): release $current_version" \ - LEGACY_CURRENT_RELEASE_STATE=missing \ PREVIOUS_RELEASE_TAG=uloop-project-runner-v3.0.0-beta.1 \ PREVIOUS_RELEASE_HAS_ASSETS=true \ CLI_SOURCE_CHANGED=false \ @@ -332,12 +295,12 @@ test_cli_requirement_change_publishes() { # Verifies Project runner release metadata-only release commits still publish native Project runner assets. test_cli_release_metadata_change_publishes() { - run_success_case cli-release-metadata-change 3.0.0-beta.3 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true false false false true true target-sha target-sha target-sha "chore: release v3-beta" "chore: release v3-beta" true false + run_success_case cli-release-metadata-change 3.0.0-beta.3 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true false false false true true target-sha target-sha true } -# Verifies the first renamed project runner tag targets the commit that contains the rename. -test_renamed_tag_with_existing_legacy_release_targets_build_commit() { - run_success_case renamed-tag-targets-build 3.0.0-beta.43 push v3-beta missing false "" false true false false true true build-sha build-sha release-sha "chore(v3-beta): release 3.0.0-beta.43" "refactor: rename project runner" false true published +# Verifies publishing always targets the approved event-head commit. +test_release_target_is_event_head() { + run_success_case event-head-target 3.0.0-beta.2 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true true false false true true build-sha build-sha } # Verifies the first Project runner asset release is published when no previous asset tag exists. @@ -345,41 +308,6 @@ test_missing_previous_cli_release_publishes() { run_success_case bootstrap 3.0.0-beta.0 push v3-beta missing false "" false false false false true true } -# Verifies a recovered release still tags the original release PR merge commit. -test_recovery_targets_release_commit() { - run_success_case recovery-target 3.0.0-beta.2 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true true false false true true release-sha build-sha release-sha "chore(v3-beta): release 3.0.0-beta.2" "fix: follow-up change" -} - -# Verifies grouped manifest release commits remain the recovery target. -test_recovery_targets_grouped_release_commit() { - run_success_case recovery-grouped-target 3.0.0-beta.2 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true true false false true true release-sha build-sha release-sha "chore: release v3-beta" "fix: follow-up change" false true -} - -# Verifies package-only grouped release commits do not steal the Project runner release target. -test_recovery_ignores_grouped_package_only_release_commit() { - run_success_case recovery-grouped-package-only-target 3.0.0-beta.2 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true true false false true true release-sha build-sha release-sha "chore(v3-beta): release 3.0.0-beta.2" "chore: release v3-beta" false false -} - -# Verifies recovery ignores follow-up commits that only mention the release version. -test_recovery_ignores_non_release_subject_mentions() { - run_success_case recovery-non-release-subject 3.0.0-beta.2 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true true false false true true release-sha build-sha release-sha "chore(v3-beta): release 3.0.0-beta.2" "fix: keep release 3.0.0-beta.2 on the release commit" -} - -# Verifies recovery ignores metadata-touching commits unless their subject is a release commit. -test_recovery_ignores_non_release_metadata_commit() { - run_success_case recovery-non-release-metadata 3.0.0-beta.2 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true true false false true true release-sha build-sha release-sha "chore(v3-beta): release 3.0.0-beta.2" "fix: repair release metadata" true false -} - -# Verifies recovery ignores non-release subjects with a later release marker. -test_recovery_requires_release_marker_after_scope() { - run_success_case recovery-scoped-marker 3.0.0-beta.2 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true true false false true true release-sha build-sha release-sha "chore(v3-beta): release 3.0.0-beta.2" "chore(v3-beta) follow-up): release 3.0.0-beta.2" -} - -# Verifies version matching does not confuse beta.2 with beta.20. -test_recovery_target_uses_exact_version_boundary() { - run_success_case recovery-boundary 3.0.0-beta.2 push v3-beta missing false uloop-project-runner-v3.0.0-beta.1 true true false false true true release-sha build-sha release-sha "chore(v3-beta): release 3.0.0-beta.2" "chore(v3-beta): release 3.0.0-beta.20" -} - # Verifies main refuses prerelease versions. test_main_prerelease_fails() { run_failure_case main-prerelease 3.0.0-beta.2 push main "Refusing to publish prerelease version 3.0.0-beta.2 from main." @@ -418,15 +346,8 @@ test_published_current_release_can_receive_cli_assets test_cli_contract_change_publishes test_cli_requirement_change_publishes test_cli_release_metadata_change_publishes -test_renamed_tag_with_existing_legacy_release_targets_build_commit +test_release_target_is_event_head test_missing_previous_cli_release_publishes -test_recovery_targets_release_commit -test_recovery_targets_grouped_release_commit -test_recovery_ignores_grouped_package_only_release_commit -test_recovery_ignores_non_release_subject_mentions -test_recovery_ignores_non_release_metadata_commit -test_recovery_requires_release_marker_after_scope -test_recovery_target_uses_exact_version_boundary test_main_prerelease_fails test_v3_beta_stable_fails test_release_lookup_error_fails From 5513dc97cecfe6c7dff7600e24cab63f8296cc4f Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 18 Jul 2026 00:19:17 +0900 Subject: [PATCH 2/3] docs: align recovery guidance with event-head publishing Remove obsolete historical-resolver behavior from the recovery runbook after deleting the recovery-target mode. --- docs/release-recovery-runbook.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/release-recovery-runbook.md b/docs/release-recovery-runbook.md index 581ae2f77..f88370ac7 100644 --- a/docs/release-recovery-runbook.md +++ b/docs/release-recovery-runbook.md @@ -49,9 +49,9 @@ Revival is structurally impossible for three independent reasons: the failure. 2. A new workflow run attests its own `github.sha`, which is the current branch head. It cannot create attestations for an earlier release commit. -3. The release resolver binds the historical version to its original release - commit. A later publish run therefore produces a tag-to-attestation digest - mismatch even when an owner pre-creates the tag or draft release. +3. The current workflow permits a release target only when it equals the + approved event commit. A new run for a historical version therefore fails + before it can create a tag or publish assets. For example, beta.48's tag pointed at `2d8d1b94` while its published asset attestations carried `2c73c6ac`. Reusing that version would preserve the same @@ -92,8 +92,8 @@ gh run rerun --repo / --failed workflow concurrency group does not cancel it automatically, so it can block the newer run indefinitely. - Delete a broken release only after the version bump is merged. Deleting it - first can make the resolver target the historical commit again and cause - later push builds to fail. + first leaves the historical version unresolved and can make later push builds + attempt to recreate it and fail. - Do not grant GitHub Actions tag-ruleset bypass permissions. That does not resolve the attestation invariant and expands bot authority unnecessarily. - A broken release may remain published while the roll-forward release is cut; From cea8e1ca1493ce5145b1f8c383ae13cd7c5b8e34 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 18 Jul 2026 00:26:21 +0900 Subject: [PATCH 3/3] test: retain release tag SHA recheck coverage Keep the three publish-stage release tag SHA checks covered while removing the recovery-target-specific tests. --- scripts/test-native-cli-publish-workflow.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/test-native-cli-publish-workflow.sh b/scripts/test-native-cli-publish-workflow.sh index 46b08e0d1..ee00f0ae8 100755 --- a/scripts/test-native-cli-publish-workflow.sh +++ b/scripts/test-native-cli-publish-workflow.sh @@ -144,6 +144,7 @@ test_publish_has_no_recovery_target_mode() { assert_not_contains "Recovery-target" assert_not_contains "Check out resolver-derived recovery target" assert_contains 'printf '\''RELEASE_SHA=%s\n'\'' "${release_sha}" >> "$GITHUB_ENV"' + assert_count 3 'if [ "${tag_sha}" != "${RELEASE_SHA}" ]; then' assert_contains 'if [ -n "${tag_sha}" ] && [ "${tag_sha}" != "${RELEASE_SHA}" ]; then' assert_contains '--target "${RELEASE_SHA}"' }