Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 13 additions & 63 deletions .github/workflows/native-cli-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand Down
15 changes: 7 additions & 8 deletions docs/release-recovery-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -86,15 +86,14 @@ gh run rerun <run-id> --repo <owner>/<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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- 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 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;
Expand Down
104 changes: 3 additions & 101 deletions scripts/resolve-native-cli-release-target.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
51 changes: 15 additions & 36 deletions scripts/test-native-cli-publish-workflow.sh
Original file line number Diff line number Diff line change
Expand Up @@ -137,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 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}"'
}

Expand Down Expand Up @@ -233,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() {
Expand Down Expand Up @@ -295,15 +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
Expand Down
Loading