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
97 changes: 33 additions & 64 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,6 @@ jobs:
echo "Publication fails closed when the protected Environment has not been configured." >&2
exit 1
fi

- name: Check out the trusted release controller
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
Expand Down Expand Up @@ -388,30 +387,28 @@ jobs:
RECOVER_EXISTING_TAG: ${{ inputs.recover_existing_tag }}
SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
SOURCE_TAG: ${{ needs.preflight.outputs.source_tag }}
RECOVERY_TAG_OBJECT: ${{ needs.preflight.outputs.tag_object }}
ORIGINAL_OWNER_RUN_ID: ${{ needs.preflight.outputs.owner_run_id }}
run: |
set -euo pipefail
same_run=false
tag_object="$(
git ls-remote origin "refs/tags/${SOURCE_TAG}" |
awk 'NR == 1 { print $1 }'
)"
if [ -n "${tag_object}" ] && [ "${RECOVER_EXISTING_TAG}" != "true" ]; then
tag_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object}")" \
|| {
echo "Existing ${SOURCE_TAG} is not a release tag owned by this run." >&2
exit 1
}
tag_source="$(printf '%s' "${tag_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["object"]["sha"])')"
tag_message="$(printf '%s' "${tag_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["message"])')"
run_marker="Release-Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
mapfile -t run_markers < <(grep -F 'Release-Run: ' <<< "${tag_message}" || true)
if [ "${tag_source}" != "${SOURCE_SHA}" ] \
|| [ "${#run_markers[@]}" -ne 1 ] \
|| [ "${run_markers[0]}" != "${run_marker}" ]; then
echo "Release tag ${SOURCE_TAG} appeared during candidate verification and is not owned by this run." >&2
exit 1
fi
if [ "${RECOVER_EXISTING_TAG}" = "true" ]; then
tag_object="$(
scripts/reconcile-release-tag.sh verify \
"${GITHUB_REPOSITORY}" "${SOURCE_TAG}" "${SOURCE_SHA}" \
"${ORIGINAL_OWNER_RUN_ID}" "" "${RECOVERY_TAG_OBJECT}"
)"
elif [ -n "${tag_object}" ]; then
same_run=true
tag_object="$(
scripts/reconcile-release-tag.sh verify \
"${GITHUB_REPOSITORY}" "${SOURCE_TAG}" "${SOURCE_SHA}" \
"${GITHUB_RUN_ID}" "" "${tag_object}"
)"
fi
echo "same_run=${same_run}" >> "${GITHUB_OUTPUT}"
echo "tag_object=${tag_object}" >> "${GITHUB_OUTPUT}"
Expand Down Expand Up @@ -461,61 +458,33 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ github.token }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
RECOVER_EXISTING_TAG: ${{ inputs.recover_existing_tag }}
SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }}
SOURCE_TAG: ${{ needs.preflight.outputs.source_tag }}
EXPECTED_TAG_OBJECT: ${{ needs.preflight.outputs.tag_object }}
RECOVERY_TAG_OBJECT: ${{ needs.preflight.outputs.tag_object }}
ORIGINAL_OWNER_RUN_ID: ${{ needs.preflight.outputs.owner_run_id }}
SAME_RUN: ${{ steps.publication_state.outputs.same_run }}
CURRENT_TAG_OBJECT: ${{ steps.publication_state.outputs.tag_object }}
run: |
set -euo pipefail
remote_tag_object="$(
git ls-remote origin "refs/tags/${SOURCE_TAG}" |
awk 'NR == 1 { print $1 }'
)"
if [ -z "${remote_tag_object}" ]; then
if [ "${RECOVER_EXISTING_TAG}" = "true" ]; then
echo "Release tag ${SOURCE_TAG} disappeared after preflight." >&2
exit 1
fi
tag_message="$(printf 'Astra %s\n\nRelease-Run: %s/%s/actions/runs/%s' \
"${SOURCE_TAG}" "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}")"
tag_object="$(
gh api --method POST "repos/${GITHUB_REPOSITORY}/git/tags" \
-f tag="${SOURCE_TAG}" \
-f message="${tag_message}" \
-f object="${SOURCE_SHA}" \
-f type=commit \
--jq .sha
)"
gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \
-f ref="refs/tags/${SOURCE_TAG}" \
-f sha="${tag_object}" >/dev/null
remote_tag_object="${tag_object}"
else
if [ "${RECOVER_EXISTING_TAG}" = "true" ]; then
if [ "${remote_tag_object}" != "${EXPECTED_TAG_OBJECT}" ]; then
echo "Release tag ${SOURCE_TAG} changed after preflight." >&2
exit 1
fi
elif [ "${SAME_RUN}" != "true" ] \
|| [ "${remote_tag_object}" != "${CURRENT_TAG_OBJECT}" ]; then
echo "Release tag ${SOURCE_TAG} appeared or changed during publication." >&2
exit 1
fi
fi

git fetch --force origin \
"refs/tags/${SOURCE_TAG}:refs/tags/${SOURCE_TAG}"
if [ "$(git cat-file -t "refs/tags/${SOURCE_TAG}")" != "tag" ]; then
echo "Release tag ${SOURCE_TAG} is not annotated." >&2
exit 1
fi
peeled_sha="$(git rev-list -n 1 "refs/tags/${SOURCE_TAG}")"
if [ "${peeled_sha}" != "${SOURCE_SHA}" ]; then
echo "Release tag ${SOURCE_TAG} points to ${peeled_sha}, expected ${SOURCE_SHA}." >&2
exit 1
mode=create
owner_run_id="${GITHUB_RUN_ID}"
default_branch="${DEFAULT_BRANCH}"
expected_tag_object=""
if [ "${RECOVER_EXISTING_TAG}" = "true" ]; then
mode=verify
owner_run_id="${ORIGINAL_OWNER_RUN_ID}"
default_branch=""
expected_tag_object="${RECOVERY_TAG_OBJECT}"
elif [ "${SAME_RUN}" = "true" ]; then
mode=verify
default_branch=""
expected_tag_object="${CURRENT_TAG_OBJECT}"
fi
scripts/reconcile-release-tag.sh "${mode}" \
"${GITHUB_REPOSITORY}" "${SOURCE_TAG}" "${SOURCE_SHA}" \
"${owner_run_id}" "${default_branch}" "${expected_tag_object}" >/dev/null

- name: Create or verify the immutable Docker version manifest
shell: bash
Expand Down
24 changes: 19 additions & 5 deletions docs/guides/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ therefore lets obsolete automation become the release control plane.
The **Release Astra** workflow is manually dispatched from the protected
default branch instead. It selects the current `main` commit, validates the
complete version and release contract, builds every candidate, and creates the
annotated tag only after all candidates pass. GitHub does not start a second
workflow for a tag created with `GITHUB_TOKEN`, so one run remains the sole
release owner.
annotated tag only after all candidates pass. The release workflow has no
tag-push trigger; its annotated tag records the sole release owner. Tags created with
`GITHUB_TOKEN` do not trigger additional tag-push workflows.

Publication is deliberately ordered:

Expand All @@ -49,6 +49,18 @@ Publication is deliberately ordered:
6. stage and publish the GitHub Release with verified client assets;
7. update stable rolling Docker tags.

The protected publication job uses the built-in `GITHUB_TOKEN`. Immediately
before creating a new tag, it requires the selected source to still be the
current `main` head. If `main` advanced during builds or approval, publication
stops before creating a tag or versioned Docker manifest. Start a new normal
release run from current `main`; rerunning the old candidates cannot fix this.

This check is not an atomic lock on `main`: a concurrent update can still cause
GitHub to reject tag creation. Existing-tag recovery remains available, but
does not promise to overcome GitHub workflow-permission restrictions on a
historical source. If recovery encounters that restriction, stop and inspect
the partial publication; never move the immutable tag or overwrite its assets.

The GitHub Release is not published until the exact Docker version exists. If
a late step fails, rerun the failed jobs from the same Actions run so its
verified artifacts are reused. The annotated tag records its owning Actions
Expand Down Expand Up @@ -94,8 +106,10 @@ A manually created tag cannot publish anything and cannot be adopted by
recovery, but it will reserve that version until an administrator removes it.

Repository Actions should default to read-only permissions. The release
controller grants `contents: write` only to the publication job that creates
the tag and GitHub Release.
controller grants `contents: write` only to the approved publication job.
That same token performs draft lookup, body preparation, staged verification,
and publication; draft visibility requires push access. No GitHub App, App
private key, or personal access token is required.

The source tree versions `@astra/sdk` and the Helm chart, but the workflow does
not yet publish either to npm or a chart registry. Treat them as explicit
Expand Down
Loading
Loading