Skip to content

Commit db2e7f4

Browse files
authored
fix: Add resolver-bound recovery dispatch mode to native CLI publish (#1797)
1 parent 9a2bc50 commit db2e7f4

2 files changed

Lines changed: 75 additions & 14 deletions

File tree

.github/workflows/native-cli-publish.yml

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ on:
1111
description: "Dry run (skip release upload)"
1212
type: boolean
1313
default: false
14+
recovery-target:
15+
description: "Build and tag the resolver-derived release commit instead of the branch head"
16+
type: boolean
17+
default: false
1418

1519
jobs:
1620
build:
@@ -41,14 +45,29 @@ jobs:
4145
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
4246
run: scripts/resolve-native-cli-release-target.sh >> "$GITHUB_OUTPUT"
4347

48+
- name: Check out resolver-derived recovery target
49+
if: github.event_name == 'workflow_dispatch' && inputs.recovery-target && (steps.release.outputs.publish == 'true' || steps.release.outputs.release == 'true')
50+
env:
51+
TARGET_SHA: ${{ steps.release.outputs.sha }}
52+
run: |
53+
set -eu
54+
# Why the ancestor check: recovery may only rebuild a commit that already
55+
# landed on the approved branch; any other target must fail closed.
56+
if ! git merge-base --is-ancestor "${TARGET_SHA}" "${GITHUB_SHA}"; then
57+
echo "Recovery target ${TARGET_SHA} is not an ancestor of approved event commit ${GITHUB_SHA}." >&2
58+
exit 1
59+
fi
60+
git checkout --detach "${TARGET_SHA}"
61+
4462
- name: Verify release target matches build commit
4563
if: steps.release.outputs.publish == 'true' || steps.release.outputs.release == 'true'
4664
env:
4765
TARGET_SHA: ${{ steps.release.outputs.sha }}
4866
run: |
4967
set -eu
50-
if [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then
51-
echo "Release target ${TARGET_SHA} does not match approved build commit ${GITHUB_SHA}." >&2
68+
build_sha=$(git rev-parse HEAD)
69+
if [ "${TARGET_SHA}" != "${build_sha}" ]; then
70+
echo "Release target ${TARGET_SHA} does not match build source commit ${build_sha}." >&2
5271
exit 1
5372
fi
5473
@@ -104,7 +123,7 @@ jobs:
104123
set -eu
105124
mkdir -p release-input
106125
{
107-
printf 'build_sha=%s\n' "${GITHUB_SHA}"
126+
printf 'build_sha=%s\n' "$(git rev-parse HEAD)"
108127
printf 'target_sha=%s\n' "${TARGET_SHA}"
109128
printf 'release_tag=%s\n' "${RELEASE_TAG}"
110129
printf 'version=%s\n' "${RELEASE_VERSION}"
@@ -156,6 +175,8 @@ jobs:
156175
- name: Verify release metadata
157176
env:
158177
GITHUB_REPOSITORY: ${{ github.repository }}
178+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
179+
RECOVERY_TARGET: ${{ github.event_name == 'workflow_dispatch' && inputs.recovery-target }}
159180
run: |
160181
set -eu
161182
metadata_value() {
@@ -178,9 +199,26 @@ jobs:
178199
event_ref=$(metadata_value event_ref)
179200
BUILD_SHA="${build_sha}"
180201
TARGET_SHA="${target_sha}"
181-
if [ "${BUILD_SHA}" != "${GITHUB_SHA}" ] || [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then
182-
echo "Build SHA and release target must match the approved event commit." >&2
183-
exit 1
202+
if [ "${RECOVERY_TARGET}" = "true" ]; then
203+
if [ "${BUILD_SHA}" != "${TARGET_SHA}" ]; then
204+
echo "Recovery build SHA must match the release target commit." >&2
205+
exit 1
206+
fi
207+
# Why an API comparison: the metadata artifact comes from the unprivileged
208+
# build job, so ancestry on the approved event commit is re-verified from
209+
# an independent source before the privileged job tags that commit.
210+
compare_status=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${TARGET_SHA}...${GITHUB_SHA}" --jq '.status')
211+
if [ "${compare_status}" != "ahead" ] && [ "${compare_status}" != "identical" ]; then
212+
echo "Recovery target ${TARGET_SHA} is not an ancestor of approved event commit ${GITHUB_SHA}." >&2
213+
exit 1
214+
fi
215+
release_sha="${TARGET_SHA}"
216+
else
217+
if [ "${BUILD_SHA}" != "${GITHUB_SHA}" ] || [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then
218+
echo "Build SHA and release target must match the approved event commit." >&2
219+
exit 1
220+
fi
221+
release_sha="${GITHUB_SHA}"
184222
fi
185223
if [ "${event_ref}" != "refs/heads/main" ] && [ "${event_ref}" != "refs/heads/v3-beta" ]; then
186224
echo "Unsupported release ref ${event_ref}." >&2
@@ -201,6 +239,7 @@ jobs:
201239
validate_boolean publish "${should_publish}"
202240
validate_boolean release "${should_release}"
203241
validate_boolean dry_run "${dry_run}"
242+
printf 'RELEASE_SHA=%s\n' "${release_sha}" >> "$GITHUB_ENV"
204243
printf 'RELEASE_TAG=%s\n' "${release_tag}" >> "$GITHUB_ENV"
205244
printf 'SHOULD_PUBLISH=%s\n' "${should_publish}" >> "$GITHUB_ENV"
206245
printf 'SHOULD_RELEASE=%s\n' "${should_release}" >> "$GITHUB_ENV"
@@ -258,8 +297,8 @@ jobs:
258297
*) printf '%s\n' "${tag_error}" >&2; exit "${tag_status}" ;;
259298
esac
260299
fi
261-
if [ -n "${tag_sha}" ] && [ "${tag_sha}" != "${GITHUB_SHA}" ]; then
262-
echo "Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}." >&2
300+
if [ -n "${tag_sha}" ] && [ "${tag_sha}" != "${RELEASE_SHA}" ]; then
301+
echo "Release tag ${RELEASE_TAG} does not match approved release commit ${RELEASE_SHA}." >&2
263302
exit 1
264303
fi
265304
if gh release view "${RELEASE_TAG}" --json isDraft,targetCommitish > release-input/release-state.json 2>/dev/null; then
@@ -273,7 +312,7 @@ jobs:
273312
fi
274313
prerelease_flag=""
275314
case "${RELEASE_TAG}" in *-beta.*) prerelease_flag="--prerelease" ;; esac
276-
gh release create "${RELEASE_TAG}" --draft --title "${RELEASE_TAG}" --notes-file release-input/release-notes.md --target "${GITHUB_SHA}" ${prerelease_flag}
315+
gh release create "${RELEASE_TAG}" --draft --title "${RELEASE_TAG}" --notes-file release-input/release-notes.md --target "${RELEASE_SHA}" ${prerelease_flag}
277316
echo "release_published=false" >> "$GITHUB_OUTPUT"
278317
279318
- name: Attest native CLI release assets
@@ -312,8 +351,8 @@ jobs:
312351
run: |
313352
set -eu
314353
tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha')
315-
if [ "${tag_sha}" != "${GITHUB_SHA}" ]; then
316-
echo "Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}." >&2
354+
if [ "${tag_sha}" != "${RELEASE_SHA}" ]; then
355+
echo "Release tag ${RELEASE_TAG} does not match approved release commit ${RELEASE_SHA}." >&2
317356
exit 1
318357
fi
319358
while IFS=' ' read -r checksum asset_path; do
@@ -348,8 +387,8 @@ jobs:
348387
run: |
349388
set -eu
350389
tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha')
351-
if [ "${tag_sha}" != "${GITHUB_SHA}" ]; then
352-
echo "Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}." >&2
390+
if [ "${tag_sha}" != "${RELEASE_SHA}" ]; then
391+
echo "Release tag ${RELEASE_TAG} does not match approved release commit ${RELEASE_SHA}." >&2
353392
exit 1
354393
fi
355394
case "${RELEASE_TAG}" in *-beta.*) gh release edit "${RELEASE_TAG}" --draft=false --prerelease ;; *) gh release edit "${RELEASE_TAG}" --draft=false ;; esac

scripts/test-native-cli-publish-workflow.sh

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,27 @@ test_checkout_free_publish_has_explicit_repository_context() {
129129
fi
130130
}
131131

132+
test_recovery_dispatch_is_bound_to_the_resolver_target() {
133+
assert_contains " recovery-target:"
134+
assert_not_contains "inputs.sha"
135+
assert_not_contains "INPUT_SHA"
136+
assert_contains " - name: Check out resolver-derived recovery target"
137+
assert_contains 'if ! git merge-base --is-ancestor "${TARGET_SHA}" "${GITHUB_SHA}"; then'
138+
assert_contains 'git checkout --detach "${TARGET_SHA}"'
139+
assert_before " - name: Check out resolver-derived recovery target" " - name: Verify release target matches build commit"
140+
assert_contains 'build_sha=$(git rev-parse HEAD)'
141+
assert_contains 'if [ "${TARGET_SHA}" != "${build_sha}" ]; then'
142+
assert_contains " RECOVERY_TARGET: \${{ github.event_name == 'workflow_dispatch' && inputs.recovery-target }}"
143+
assert_contains 'if [ "${BUILD_SHA}" != "${TARGET_SHA}" ]; then'
144+
assert_contains 'compare_status=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${TARGET_SHA}...${GITHUB_SHA}" --jq '\''.status'\'')'
145+
assert_contains 'if [ "${compare_status}" != "ahead" ] && [ "${compare_status}" != "identical" ]; then'
146+
assert_contains 'printf '\''RELEASE_SHA=%s\n'\'' "${release_sha}" >> "$GITHUB_ENV"'
147+
assert_count 2 'if [ "${tag_sha}" != "${RELEASE_SHA}" ]; then'
148+
assert_contains 'if [ -n "${tag_sha}" ] && [ "${tag_sha}" != "${RELEASE_SHA}" ]; then'
149+
assert_not_contains '--target "${GITHUB_SHA}"'
150+
assert_contains '--target "${RELEASE_SHA}"'
151+
}
152+
132153
test_assets_are_attested_after_the_manifest_is_verified() {
133154
assert_contains " - name: Verify release asset manifest"
134155
assert_contains " - name: Attest native CLI release assets"
@@ -152,7 +173,7 @@ test_publish_rejects_manifest_and_existing_tag_mismatches() {
152173
assert_contains "Unexpected release files are not listed in the manifest."
153174
assert_contains "Release manifest is missing files or has duplicate entries."
154175
assert_contains 'gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '\''.sha'\'''
155-
assert_contains 'Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}.'
176+
assert_contains 'Release tag ${RELEASE_TAG} does not match approved release commit ${RELEASE_SHA}.'
156177
assert_not_contains "release-input/dist/release"
157178
}
158179

@@ -198,6 +219,7 @@ test_post_publish_automation_remains_outside_the_privileged_job() {
198219

199220
test_build_and_publish_jobs_have_separate_trust_boundaries
200221
test_unprivileged_build_uses_only_the_approved_event_commit
222+
test_recovery_dispatch_is_bound_to_the_resolver_target
201223
test_publish_validates_metadata_without_checking_out_source
202224
test_checkout_free_publish_has_explicit_repository_context
203225
test_assets_are_attested_after_the_manifest_is_verified

0 commit comments

Comments
 (0)