diff --git a/.github/workflows/native-cli-publish.yml b/.github/workflows/native-cli-publish.yml index 82197e13e..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" @@ -425,6 +375,46 @@ jobs: fi done < release-input/release-assets.manifest + - name: Verify remote attestation digest matches release tag + if: env.SHOULD_PUBLISH == 'true' && env.DRY_RUN != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + SIGNER_WORKFLOW: ${{ github.repository }}/.github/workflows/native-cli-publish.yml + run: | + set -eu + tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha') + if [ "${tag_sha}" != "${RELEASE_SHA}" ]; then + echo "Release tag ${RELEASE_TAG} does not match approved release commit ${RELEASE_SHA}." >&2 + exit 1 + fi + verification_directory=$(mktemp -d "${RUNNER_TEMP}/native-cli-attestation-verify.XXXXXX") + while IFS=' ' read -r checksum asset_path; do + asset_name=$(basename "${asset_path}") + bundle_name="${asset_name}.sigstore.json" + asset_directory="${verification_directory}/${asset_name}" + mkdir "${asset_directory}" + gh release download "${RELEASE_TAG}" --pattern "${asset_name}" --pattern "${bundle_name}" --dir "${asset_directory}" + downloaded_asset_path="${asset_directory}/${asset_name}" + downloaded_bundle_path="${asset_directory}/${bundle_name}" + verification_output_path="${asset_directory}/verification.json" + digest_output_path="${asset_directory}/digests.txt" + gh attestation verify "${downloaded_asset_path}" --repo "${GITHUB_REPOSITORY}" --bundle "${downloaded_bundle_path}" --signer-workflow "${SIGNER_WORKFLOW}" --format json > "${verification_output_path}" + verification_count=$(jq 'length' "${verification_output_path}") + digest_count=$(jq '[.[].verificationResult.signature.certificate.sourceRepositoryDigest | select(type == "string" and length > 0)] | length' "${verification_output_path}") + if [ "${verification_count}" -eq 0 ] || [ "${verification_count}" -ne "${digest_count}" ]; then + echo "Attestation verification did not return a source repository digest for ${asset_name}." >&2 + exit 1 + fi + jq -r '.[].verificationResult.signature.certificate.sourceRepositoryDigest' "${verification_output_path}" > "${digest_output_path}" + while IFS= read -r digest; do + if [ "${digest}" != "${tag_sha}" ]; then + echo "Attestation digest for ${asset_name} does not match release tag ${RELEASE_TAG}." >&2 + exit 1 + fi + done < "${digest_output_path}" + done < release-input/release-assets.manifest + - name: Publish draft release if: env.DRY_RUN != 'true' && steps.draft.outputs.release_published != 'true' env: diff --git a/docs/release-recovery-runbook.md b/docs/release-recovery-runbook.md index 6076a7990..f88370ac7 100644 --- a/docs/release-recovery-runbook.md +++ b/docs/release-recovery-runbook.md @@ -1,163 +1,101 @@ # Release recovery runbook -## Root cause +## Purpose -In `recovery-target` mode, the release target is an earlier commit than the -workflow run's `GITHUB_SHA`. GitHub rejects the Actions `GITHUB_TOKEN` when it -tries to create a tag or release for that different commit, even when the token -has `contents: write`. This is neither a missing permission nor a tag ruleset -failure. +Use this runbook when a native CLI release has a tag commit that differs from +the `sourceRepositoryDigest` in its asset attestations. Such a release cannot +be downloaded by the dispatcher. Recover by publishing the next valid release; +do not try to repair the broken release in place. -The behavior was confirmed with the same token and `contents: write` -permission in reproduction runs [29459199798](https://github.com/hatayama/unity-cli-loop/actions/runs/29459199798) -and [29459266044](https://github.com/hatayama/unity-cli-loop/actions/runs/29459266044): +## First response: roll forward -- A tag pointing at the run head returned HTTP 201. -- A tag pointing at `2d8d1b9443843f45b48479134f846b6f540e928b`, an ancestor of - the run head, returned HTTP 403 `Resource not accessible by integration`. -- Creating a draft release directly at that past commit also returned HTTP 403. - -The precise condition is a ref target different from the run's `GITHUB_SHA`, -not merely an unreachable commit. Normal head-based releases are unaffected. - -## Publishing invariant: assets may only be published by the original run - -Attestation certificates bind `SourceRepositoryDigest` to the workflow run's -head commit (`GITHUB_SHA`), never to the commit the build checked out. The -dispatcher verifies that the release tag's commit equals the certificate -digest, so a release is only downloadable when the publishing run's head IS -the release commit. - -Therefore the only valid way to publish recovery assets is to **rerun the -original failed run whose head is the release commit** (step 5 below). Never -publish through a new `recovery-target` workflow-dispatch run on a later -branch head: its certificates would carry the later head's digest while the -tag points at the historical release commit, producing a release that -permanently fails attestation verification. The workflow now rejects this -combination in the publish job's "Verify release metadata" step; recovery -dispatch runs remain usable for build validation only. - -This is not theoretical: `uloop-project-runner-v3.0.0-beta.48` was published -on 2026-07-15 through a recovery dispatch run after the owner pre-created the -tag and draft release. The tag points at the historical release commit -(`2d8d1b94`) while the attestation certificates carry the dispatch run's head -digest (`2c73c6ac`), so every cold-cache download of beta.48 fails -verification on all platforms. - -## Recovery procedure - -Run these commands with an owner-authenticated `gh` session. Replace every -`` before running a command. - -1. Confirm the authenticated account and repository. - - ```sh - gh auth status - gh api user --jq .login - gh api repos// --jq .full_name - ``` - -2. Determine the release commit and release tag from the release PR and the - failed workflow run. +1. Confirm the affected release is broken by comparing its tag commit with an + attached asset attestation. ```sh - gh pr view --repo / --json mergeCommit --jq .mergeCommit.oid - gh run view --repo / --json headSha + gh release download --repo / \ + --pattern --dir + gh api repos///commits/ --jq .sha + gh attestation verify / --repo / --format json \ + | jq -r '.[].verificationResult.signature.certificate.sourceRepositoryDigest' ``` - The release commit must be the commit validated by the workflow. Do not use - the current branch head when recovering an older release. - -3. Create the release tag at the validated release commit. - - ```sh - gh api repos///git/refs \ - -f ref=refs/tags/ \ - -f sha= - ``` +2. Merge the pending release-please release PR for the affected branch. This + creates the next version and starts a normal head-based publish run. -4. Download the native release input artifact and create the draft release. - Creating only the tag is not sufficient: the Actions token also receives - HTTP 403 when it tries to create the draft release. +3. Verify the new runner release and the package release that pins it. The + runner tag commit, every asset's attestation digest, and the publish run's + `headSha` must match. ```sh - gh run download --repo / \ - -n native-cli-release-input -D - gh release create --repo / \ - --draft --title \ - --notes-file /release-input/release-notes.md \ - --target + gh run view --repo / --json headSha --jq .headSha + gh api repos///commits/ --jq .sha + gh attestation verify --repo / --format json \ + | jq -r '.[].verificationResult.signature.certificate.sourceRepositoryDigest' ``` - Add `--prerelease` when the release tag is a prerelease tag. - -5. Rerun the failed publish job of the original run whose head is the release - commit, and approve the release environment if prompted. Do not dispatch a - new `recovery-target` run to publish instead: a dispatch run on a later - head cannot produce valid attestations for the release commit, and the - workflow rejects it (see "Publishing invariant" above). - - ```sh - gh run rerun --repo / --failed - ``` - -6. If an older `dispatcher-publish` run is waiting for the `cli-release` - environment, cancel it before retrying the recovery. The workflow uses a - concurrency group without automatic cancellation, so an old approval-waiting - run can block the newer run indefinitely. - - ```sh - gh run list --repo / --workflow dispatcher-publish.yml \ - --branch --limit 20 - gh run cancel --repo / - ``` - -7. Recover the Unity package release only after the runner release has - completed and its assets are available. The package sync script creates a - release at the historical release commit and can hit the same 403; create - that release as the owner before rerunning the sync. - - ```sh - gh release create v --repo / \ - --title v \ - --notes-file \ - --target - ``` - - Add `--prerelease` when the package release is a prerelease. - - Run the package sync or the relevant post-publish job after the runner - release assets are complete. The sync reuses an existing correctly targeted - release. - -8. Complete the workflow-dispatch recovery manually. Push-only post-publish - steps are intentionally skipped for a `workflow_dispatch` run. - - ```sh - gh pr edit --repo / \ - --remove-label 'autorelease: pending' \ - --add-label 'autorelease: tagged' - gh workflow run release-please.yml --repo / --ref \ - -f branch= - ``` - -## Scope of the workflows - -`native-cli-publish.yml` is the workflow with `recovery-target` and now emits -this runbook path when tag or draft-release creation returns the observed 403. -The creation and reuse logic is unchanged. Its publish job refuses to publish -assets from a recovery dispatch run whose head differs from the release -commit, because such a run cannot produce matching attestations. - -`dispatcher-publish.yml` has no recovery-target input and requires its release -target to equal `GITHUB_SHA`, so it has no historical-commit creation path. -Its concurrency group still matters during recovery because an older run can -block a retry. - -`scripts/sync-release-please-package-releases.sh` can create package releases -at release-please commits from the repository history. This runbook's owner -pre-creation step covers that path; the runner release must be recovered first. - -Do not add GitHub Actions to the tag-ruleset bypass actors. That would grant -the bot tag deletion and force-update capabilities without addressing the -historical-commit restriction. +The 2026-07-17 recovery followed this procedure: runner beta.48 was left +broken, the next release-please PR produced runner beta.49, and the following +package release pinned beta.49. The normal publish run, tag, and attestations +all resolved to the same commit. + +## Why a broken release cannot be revived + +Do not create a new tag, draft, or publish run for the historical version. +Revival is structurally impossible for three independent reasons: + +1. Rerunning a workflow replays the workflow definition from the original + commit. If that workflow definition was itself broken, rerunning it repeats + 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 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 +invariant violation. + +## When rerun recovery is valid + +Rerun only when all of the following are true: + +- The original failed run's `headSha` is the intended release commit. +- The workflow definition at that commit is known to be healthy. +- The release tag and draft release, if they exist, point at that same commit. + +Check the original run before rerunning it: + +```sh +gh run view --repo / --json headSha,workflowName,url +gh api repos///commits/ --jq .sha +gh run view --repo / --log-failed +``` + +If the run used an older workflow revision that lacks a required fix, or its +head SHA differs from the intended release commit, do not rerun it. Use the +roll-forward procedure instead. + +When all conditions hold, rerun the failed jobs from the original run and +approve the `cli-release` environment if GitHub requests approval: + +```sh +gh run rerun --repo / --failed +``` + +## Operational notes + +- 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. +- Delete a broken release only after the version bump is merged. Deleting it + 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; + the dispatcher rejects it, so it is harmless once consumers use the newer + package pin. 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/sync-release-please-package-releases.sh b/scripts/sync-release-please-package-releases.sh index 8970d6c43..9bf890805 100755 --- a/scripts/sync-release-please-package-releases.sh +++ b/scripts/sync-release-please-package-releases.sh @@ -433,6 +433,58 @@ wait_for_cli_release_ready() { done } +fail_cli_release_attestation() { + asset_name=$1 + + printf 'Project runner release attestation is invalid for %s. See docs/release-recovery-runbook.md for recovery steps.\n' "$asset_name" >&2 + exit 1 +} + +verify_cli_release_attestations() { + release_tag=$1 + verification_directory="$TMP_DIR/$release_tag-attestations" + signer_workflow="$REPO_FULL_NAME/.github/workflows/native-cli-publish.yml" + + mkdir "$verification_directory" + tag_digest_file="$verification_directory/tag-digest.txt" + gh api "repos/$REPO_FULL_NAME/commits/$release_tag" --jq '.sha' > "$tag_digest_file" + tag_digest=$(strip_carriage_returns < "$tag_digest_file") + [ -n "$tag_digest" ] || fail_cli_release_attestation "$release_tag" + + asset_names=$(release_asset_requirements "scripts/verify-native-cli-release-assets.sh" "$release_tag") || fail_cli_release_attestation "$release_tag" + for asset_name in $asset_names; do + asset_directory="$verification_directory/$asset_name" + bundle_name="$asset_name.sigstore.json" + asset_path="$asset_directory/$asset_name" + bundle_path="$asset_directory/$bundle_name" + verification_output_path="$asset_directory/verification.json" + verification_count_path="$asset_directory/verification-count.txt" + digest_count_path="$asset_directory/digest-count.txt" + digest_output_path="$asset_directory/digests.txt" + normalized_digest_output_path="$asset_directory/normalized-digests.txt" + + mkdir "$asset_directory" + gh release download "$release_tag" --repo "$REPO_FULL_NAME" --pattern "$asset_name" --pattern "$bundle_name" --dir "$asset_directory" + [ -s "$asset_path" ] || fail_cli_release_attestation "$asset_name" + [ -s "$bundle_path" ] || fail_cli_release_attestation "$asset_name" + gh attestation verify "$asset_path" --repo "$REPO_FULL_NAME" --bundle "$bundle_path" --signer-workflow "$signer_workflow" --format json > "$verification_output_path" + jq 'length' "$verification_output_path" > "$verification_count_path" + jq '[.[].verificationResult.signature.certificate.sourceRepositoryDigest | select(type == "string" and length > 0)] | length' "$verification_output_path" > "$digest_count_path" + verification_count=$(strip_carriage_returns < "$verification_count_path") + digest_count=$(strip_carriage_returns < "$digest_count_path") + if [ "$verification_count" -eq 0 ] || [ "$verification_count" -ne "$digest_count" ]; then + fail_cli_release_attestation "$asset_name" + fi + jq -r '.[].verificationResult.signature.certificate.sourceRepositoryDigest' "$verification_output_path" > "$digest_output_path" + strip_carriage_returns < "$digest_output_path" > "$normalized_digest_output_path" + while IFS= read -r digest; do + if [ "$digest" != "$tag_digest" ]; then + fail_cli_release_attestation "$asset_name" + fi + done < "$normalized_digest_output_path" + done +} + wait_for_dispatcher_release_ready() { release_tag=$1 timeout_seconds=${DISPATCHER_RELEASE_WAIT_TIMEOUT_SECONDS:-600} @@ -516,6 +568,7 @@ if [ -n "$cli_version" ] && jq -e --arg package_path "$CLI_PACKAGE_PATH" '.packa exit 0 fi fetch_cli_release_tag "$cli_release_tag" + verify_cli_release_attestations "$cli_release_tag" fi minimum_dispatcher_version=$(jq -r '.minimumDispatcherVersion // empty' "$ROOT_DIR/$UNITY_PACKAGE_CLI_PIN_FILE" | strip_carriage_returns) diff --git a/scripts/test-native-cli-publish-workflow.sh b/scripts/test-native-cli-publish-workflow.sh index 24b8de61d..ee00f0ae8 100755 --- a/scripts/test-native-cli-publish-workflow.sh +++ b/scripts/test-native-cli-publish-workflow.sh @@ -62,6 +62,14 @@ publish_draft_section() { ' "$WORKFLOW" } +remote_attestation_verification_section() { + awk ' + /^ - name: Verify remote attestation digest matches release tag$/ { printing = 1; next } + printing && /^ - name:/ { exit } + printing { print } + ' "$WORKFLOW" +} + post_publish_section() { awk ' /^ post-publish:/ { printing = 1 } @@ -129,24 +137,15 @@ 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 2 'if [ "${tag_sha}" != "${RELEASE_SHA}" ]; then' + 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}"' } @@ -169,6 +168,47 @@ test_assets_are_attested_after_the_manifest_is_verified() { assert_before " - name: Verify remote release assets" " - name: Publish draft release" } +test_remote_attestation_digests_match_the_release_tag_before_publishing() { + # Verify the remote assets and their attached bundles are fail-closed checked + # against the release tag before the draft release becomes public. + assert_contains " - name: Verify remote attestation digest matches release tag" + assert_contains " if: env.SHOULD_PUBLISH == 'true' && env.DRY_RUN != 'true'" + assert_contains 'gh release download "${RELEASE_TAG}" --pattern "${asset_name}" --pattern "${bundle_name}" --dir "${asset_directory}"' + assert_contains 'gh attestation verify "${downloaded_asset_path}" --repo "${GITHUB_REPOSITORY}" --bundle "${downloaded_bundle_path}" --signer-workflow "${SIGNER_WORKFLOW}" --format json' + assert_contains '.verificationResult.signature.certificate.sourceRepositoryDigest' + assert_contains 'gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '\''.sha'\''' + assert_contains 'if [ "${tag_sha}" != "${RELEASE_SHA}" ]; then' + assert_contains 'if [ "${digest}" != "${tag_sha}" ]; then' + assert_contains 'if [ "${verification_count}" -eq 0 ] || [ "${verification_count}" -ne "${digest_count}" ]; then' + assert_before " - name: Verify remote release assets" " - name: Verify remote attestation digest matches release tag" + assert_before " - name: Verify remote attestation digest matches release tag" " - name: Publish draft release" + verification_section=$(remote_attestation_verification_section) + for required_environment in \ + 'GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}' \ + 'GITHUB_REPOSITORY: ${{ github.repository }}' \ + 'SIGNER_WORKFLOW: ${{ github.repository }}/.github/workflows/native-cli-publish.yml'; do + if ! printf '%s\n' "${verification_section}" | grep -F -- "${required_environment}" >/dev/null 2>&1; then + echo "Remote attestation verification must define ${required_environment}." >&2 + exit 1 + fi + done + tag_mismatch_guard='if [ "${tag_sha}" != "${RELEASE_SHA}" ]; then + echo "Release tag ${RELEASE_TAG} does not match approved release commit ${RELEASE_SHA}." >&2 + exit 1 + fi' + digest_mismatch_guard='if [ "${digest}" != "${tag_sha}" ]; then + echo "Attestation digest for ${asset_name} does not match release tag ${RELEASE_TAG}." >&2 + exit 1 + fi' + case "${verification_section}" in + *"${tag_mismatch_guard}"*"${digest_mismatch_guard}"*) ;; + *) + echo "Remote attestation verification must fail for tag or digest mismatches." >&2 + exit 1 + ;; + esac +} + test_publish_rejects_manifest_and_existing_tag_mismatches() { assert_contains "Unexpected release files are not listed in the manifest." assert_contains "Release manifest is missing files or has duplicate entries." @@ -184,24 +224,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() { @@ -246,14 +275,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 diff --git a/scripts/test-sync-release-please-package-releases.sh b/scripts/test-sync-release-please-package-releases.sh index 7b0b3999a..f5c3a53b7 100755 --- a/scripts/test-sync-release-please-package-releases.sh +++ b/scripts/test-sync-release-please-package-releases.sh @@ -140,6 +140,41 @@ if [ "$1" = "release" ] && [ "$2" = "edit" ]; then exit 0 fi +if [ "$1" = "api" ]; then + case "$2" in + repos/*/commits/*) + printf '%s\n' "${CLI_RELEASE_TARGET:-cli-release-sha}" + exit 0 + ;; + esac +fi + +if [ "$1" = "release" ] && [ "$2" = "download" ]; then + download_dir="" + previous_argument="" + for argument in "$@"; do + if [ "$previous_argument" = "--dir" ]; then + download_dir=$argument + break + fi + previous_argument=$argument + done + mkdir -p "$download_dir" + for argument in "$@"; do + case "$argument" in + uloop-project-runner-*.tar.gz|uloop-project-runner-*.zip|uloop-project-runner-*.sha256|*.sigstore.json) + printf '%s\n' "mock release asset" > "$download_dir/$argument" + ;; + esac + done + exit 0 +fi + +if [ "$1" = "attestation" ] && [ "$2" = "verify" ]; then + printf '[{"verificationResult":{"signature":{"certificate":{"sourceRepositoryDigest":"%s"}}}}]\n' "${CLI_ATTESTATION_DIGEST:-${CLI_RELEASE_TARGET:-cli-release-sha}}" + exit 0 +fi + echo "unexpected gh command: $*" >&2 exit 1 MOCK_GH @@ -425,6 +460,7 @@ run_sync() { cli_release_ready_after_attempts=${9:-} dispatcher_release_state=${10:-published} dispatcher_release_assets=${11:-complete} + cli_release_target=${CLI_RELEASE_TARGET:-$(cat "$work_dir/release-sha.txt")} touch "$work_dir/gh.log" touch "$work_dir/go.log" @@ -443,6 +479,8 @@ run_sync() { CLI_RELEASE_STATE="$cli_release_state" \ CLI_RELEASE_ASSETS="$cli_release_assets" \ CLI_RELEASE_TAG="${CLI_RELEASE_TAG:-uloop-project-runner-v3.0.0-beta.6}" \ + CLI_RELEASE_TARGET="$cli_release_target" \ + CLI_ATTESTATION_DIGEST="${CLI_ATTESTATION_DIGEST:-$cli_release_target}" \ CLI_RELEASE_WAIT_TIMEOUT_SECONDS="$cli_release_wait_timeout" \ CLI_RELEASE_WAIT_INTERVAL_SECONDS="$cli_release_wait_interval" \ CLI_RELEASE_READY_AFTER_ATTEMPTS="$cli_release_ready_after_attempts" \ @@ -545,6 +583,21 @@ test_waits_for_cli_assets_before_creating_root_release() { assert_contains "$work_dir/github-output.txt" "ready=false" } +# Verifies an attestation digest mismatch fails instead of waiting for a runner release that cannot recover. +test_fails_for_cli_release_attestation_digest_mismatch() { + work_dir=$(create_release_repo cli-attestation-mismatch) + release_sha=$(cat "$work_dir/release-sha.txt") + + if CLI_RELEASE_TARGET="$release_sha" CLI_ATTESTATION_DIGEST=wrong-digest run_sync "$work_dir" "" false ""; then + echo "Expected an attestation digest mismatch to fail." >&2 + exit 1 + fi + + assert_contains "$work_dir/stderr.txt" "docs/release-recovery-runbook.md" + assert_not_contains "$work_dir/output.txt" "package release sync will wait" + assert_not_contains "$work_dir/gh.log" "release create v3.0.0-beta.6" +} + # Verifies package releases wait until the minimum dispatcher release has all dispatcher assets. test_waits_for_dispatcher_assets_before_creating_root_release() { work_dir=$(create_release_repo waits-for-dispatcher-assets) @@ -717,6 +770,7 @@ test_existing_draft_root_release_is_published test_existing_draft_root_release_without_release_commit_fails test_waits_for_cli_release_before_creating_root_release test_waits_for_cli_assets_before_creating_root_release +test_fails_for_cli_release_attestation_digest_mismatch test_waits_for_dispatcher_assets_before_creating_root_release test_waits_when_dispatcher_asset_list_fails test_dispatcher_release_ready_uses_tag_generation_asset_list