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
116 changes: 53 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 Expand Up @@ -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:
Expand Down
Loading