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
65 changes: 52 additions & 13 deletions .github/workflows/native-cli-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ 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 @@ -41,14 +45,29 @@ 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:
TARGET_SHA: ${{ steps.release.outputs.sha }}
run: |
set -eu
if [ "${TARGET_SHA}" != "${GITHUB_SHA}" ]; then
echo "Release target ${TARGET_SHA} does not match approved build commit ${GITHUB_SHA}." >&2
build_sha=$(git rev-parse HEAD)
if [ "${TARGET_SHA}" != "${build_sha}" ]; then
echo "Release target ${TARGET_SHA} does not match build source commit ${build_sha}." >&2
exit 1
fi

Expand Down Expand Up @@ -104,7 +123,7 @@ jobs:
set -eu
mkdir -p release-input
{
printf 'build_sha=%s\n' "${GITHUB_SHA}"
printf 'build_sha=%s\n' "$(git rev-parse HEAD)"
printf 'target_sha=%s\n' "${TARGET_SHA}"
printf 'release_tag=%s\n' "${RELEASE_TAG}"
printf 'version=%s\n' "${RELEASE_VERSION}"
Expand Down Expand Up @@ -156,6 +175,8 @@ jobs:
- name: Verify release metadata
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 @@ -178,9 +199,26 @@ jobs:
event_ref=$(metadata_value event_ref)
BUILD_SHA="${build_sha}"
TARGET_SHA="${target_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
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}"
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}"
fi
if [ "${event_ref}" != "refs/heads/main" ] && [ "${event_ref}" != "refs/heads/v3-beta" ]; then
echo "Unsupported release ref ${event_ref}." >&2
Expand All @@ -201,6 +239,7 @@ jobs:
validate_boolean publish "${should_publish}"
validate_boolean release "${should_release}"
validate_boolean dry_run "${dry_run}"
printf 'RELEASE_SHA=%s\n' "${release_sha}" >> "$GITHUB_ENV"
printf 'RELEASE_TAG=%s\n' "${release_tag}" >> "$GITHUB_ENV"
printf 'SHOULD_PUBLISH=%s\n' "${should_publish}" >> "$GITHUB_ENV"
printf 'SHOULD_RELEASE=%s\n' "${should_release}" >> "$GITHUB_ENV"
Expand Down Expand Up @@ -258,8 +297,8 @@ jobs:
*) printf '%s\n' "${tag_error}" >&2; exit "${tag_status}" ;;
esac
fi
if [ -n "${tag_sha}" ] && [ "${tag_sha}" != "${GITHUB_SHA}" ]; then
echo "Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}." >&2
if [ -n "${tag_sha}" ] && [ "${tag_sha}" != "${RELEASE_SHA}" ]; then
echo "Release tag ${RELEASE_TAG} does not match approved release commit ${RELEASE_SHA}." >&2
exit 1
fi
if gh release view "${RELEASE_TAG}" --json isDraft,targetCommitish > release-input/release-state.json 2>/dev/null; then
Expand All @@ -273,7 +312,7 @@ jobs:
fi
prerelease_flag=""
case "${RELEASE_TAG}" in *-beta.*) prerelease_flag="--prerelease" ;; esac
gh release create "${RELEASE_TAG}" --draft --title "${RELEASE_TAG}" --notes-file release-input/release-notes.md --target "${GITHUB_SHA}" ${prerelease_flag}
gh release create "${RELEASE_TAG}" --draft --title "${RELEASE_TAG}" --notes-file release-input/release-notes.md --target "${RELEASE_SHA}" ${prerelease_flag}
echo "release_published=false" >> "$GITHUB_OUTPUT"

- name: Attest native CLI release assets
Expand Down Expand Up @@ -312,8 +351,8 @@ jobs:
run: |
set -eu
tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha')
if [ "${tag_sha}" != "${GITHUB_SHA}" ]; then
echo "Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}." >&2
if [ "${tag_sha}" != "${RELEASE_SHA}" ]; then
echo "Release tag ${RELEASE_TAG} does not match approved release commit ${RELEASE_SHA}." >&2
exit 1
fi
while IFS=' ' read -r checksum asset_path; do
Expand Down Expand Up @@ -348,8 +387,8 @@ jobs:
run: |
set -eu
tag_sha=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '.sha')
if [ "${tag_sha}" != "${GITHUB_SHA}" ]; then
echo "Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}." >&2
if [ "${tag_sha}" != "${RELEASE_SHA}" ]; then
echo "Release tag ${RELEASE_TAG} does not match approved release commit ${RELEASE_SHA}." >&2
exit 1
fi
case "${RELEASE_TAG}" in *-beta.*) gh release edit "${RELEASE_TAG}" --draft=false --prerelease ;; *) gh release edit "${RELEASE_TAG}" --draft=false ;; esac
Expand Down
24 changes: 23 additions & 1 deletion scripts/test-native-cli-publish-workflow.sh
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,27 @@ 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'
assert_contains 'printf '\''RELEASE_SHA=%s\n'\'' "${release_sha}" >> "$GITHUB_ENV"'
assert_count 2 '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}"'
}

test_assets_are_attested_after_the_manifest_is_verified() {
assert_contains " - name: Verify release asset manifest"
assert_contains " - name: Attest native CLI release assets"
Expand All @@ -152,7 +173,7 @@ 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."
assert_contains 'gh api "repos/${GITHUB_REPOSITORY}/commits/${RELEASE_TAG}" --jq '\''.sha'\'''
assert_contains 'Release tag ${RELEASE_TAG} does not match approved build commit ${GITHUB_SHA}.'
assert_contains 'Release tag ${RELEASE_TAG} does not match approved release commit ${RELEASE_SHA}.'
assert_not_contains "release-input/dist/release"
}

Expand Down Expand Up @@ -198,6 +219,7 @@ 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_validates_metadata_without_checking_out_source
test_checkout_free_publish_has_explicit_repository_context
test_assets_are_attested_after_the_manifest_is_verified
Expand Down