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
85 changes: 67 additions & 18 deletions .github/workflows/autorelease-email.yml
Original file line number Diff line number Diff line change
@@ -1,25 +1,47 @@
name: Autorelease email digest

# One deterministic TL;DR email per completed pipeline run. The digest is
# One deterministic TL;DR email per completed pipeline run attempt. The digest is
# rendered by `./autorelease/control.py email-digest` from retained run state
# only, so a template is selected — never written — at runtime, and no
# model-authored prose can reach the outbound channel.
#
# Each run reaches this workflow by exactly one route. The watcher is started by
# its schedule or by a person, so its completion fires `workflow_run`. The publish
# transaction is dispatched by the watcher with GITHUB_TOKEN, and GitHub starts no
# `workflow_run` for runs that token started, so the publish workflow calls this
# one from its own last job instead and passes the conclusion its jobs reached.
on:
workflow_run:
workflows:
- PHP autorelease watcher
- Autorelease publish transaction
types:
- completed
workflow_call:
inputs:
run_id:
description: Run whose retained state the digest describes
required: true
type: string
run_attempt:
description: Attempt of that run
required: true
type: string
workflow:
description: Calling pipeline workflow; only publish calls this one
required: true
type: string
conclusion:
description: Conclusion the calling run's jobs reached
required: true
type: string
secrets:
RESEND_API_KEY:
required: false

permissions:
contents: read
actions: read

concurrency:
group: autorelease-email-${{ github.event.workflow_run.id }}
cancel-in-progress: false

defaults:
run:
shell: bash
Expand All @@ -29,21 +51,29 @@ jobs:
name: Send run digest
runs-on: ubuntu-latest
timeout-minutes: 10
# Inside a call, `github.event` is the caller's dispatch event, so each value
# comes from the inputs when called and from the completed run otherwise.
concurrency:
group: autorelease-email-${{ inputs.run_id || github.event.workflow_run.id }}
cancel-in-progress: false
env:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
EMAIL_FROM: ${{ vars.AUTORELEASE_EMAIL_FROM }}
EMAIL_TO: ${{ vars.AUTORELEASE_EMAIL_TO }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
CALLED_WORKFLOW: ${{ inputs.workflow }}
RUN_ID: ${{ inputs.run_id || github.event.workflow_run.id }}
RUN_ATTEMPT: ${{ inputs.run_attempt || github.event.workflow_run.run_attempt }}
RUN_URL: ${{ inputs.run_id && format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, inputs.run_id) || github.event.workflow_run.html_url }}
RUN_NAME: ${{ github.event.workflow_run.name }}
RUN_CONCLUSION: ${{ github.event.workflow_run.conclusion }}
RUN_CONCLUSION: ${{ inputs.conclusion || github.event.workflow_run.conclusion }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Decide whether delivery is configured
id: gate
# An unconfigured repository skips quietly instead of failing, so the
# digest can merge ahead of the Resend secret and variables existing.
# The secret is only in this step and the send step, never in the job.
env:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
run: |
if [[ -n "$RESEND_API_KEY" && -n "$EMAIL_FROM" && -n "$EMAIL_TO" ]]; then
echo "configured=true" >> "$GITHUB_OUTPUT"
Expand All @@ -60,13 +90,26 @@ jobs:
if: steps.gate.outputs.configured == 'true'
run: |
mkdir -p email-run/state
[[ "$RUN_ID" =~ ^[1-9][0-9]*$ ]]
[[ "$RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]]
case "$RUN_NAME" in
"PHP autorelease watcher")
if [[ -n "$CALLED_WORKFLOW" ]]; then
if [[ "$CALLED_WORKFLOW" != publish ]]; then
echo "unrouted calling workflow: $CALLED_WORKFLOW" >&2
exit 1
fi
workflow=publish
elif [[ "$RUN_NAME" == "PHP autorelease watcher" ]]; then
workflow=watcher
else
echo "unrouted triggering workflow: $RUN_NAME" >&2
exit 1
fi
case "$workflow" in
watcher)
echo "workflow=watcher" >> "$GITHUB_OUTPUT"
artifacts=("autorelease-investigation-$RUN_ID")
;;
"Autorelease publish transaction")
publish)
echo "workflow=publish" >> "$GITHUB_OUTPUT"
# Each attempt retains its own state, and a rerun of only the failed
# jobs keeps the state an earlier attempt retained, so the newest
Expand All @@ -76,10 +119,6 @@ jobs:
artifacts+=("release-transaction-state-$RUN_ID-$attempt")
done
;;
*)
echo "unrouted triggering workflow: $RUN_NAME" >&2
exit 1
;;
esac
# A run that crashed before retaining its state has no artifact; the
# renderer then only accepts that absence for the failure templates.
Expand All @@ -106,6 +145,14 @@ jobs:
- name: Send the digest through Resend
if: steps.gate.outputs.configured == 'true'
# The secret reaches exactly one place: the Authorization header.
# Transient failures are retried under one idempotency key per run attempt, so
# a retry after a lost reply cannot send the digest twice. A delivery that still
# fails fails this job, and with it the run: the digest is the report, so a red
# run is the only one left when the channel is down, and rerunning the failed
# jobs of a publish run whose other jobs passed repeats only this one.
env:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
WORKFLOW: ${{ steps.state.outputs.workflow }}
run: |
jq \
--arg from "$EMAIL_FROM" \
Expand All @@ -114,7 +161,9 @@ jobs:
email-run/digest.json > email-run/payload.json
curl --fail-with-body --silent --show-error \
--connect-timeout 10 --max-time 30 \
--retry 3 --retry-delay 15 --retry-connrefused \
--request POST https://api.resend.com/emails \
--header "Idempotency-Key: php-bin-$WORKFLOW-$RUN_ID-$RUN_ATTEMPT" \
--header "Authorization: Bearer $RESEND_API_KEY" \
--header "Content-Type: application/json" \
--data @email-run/payload.json
12 changes: 8 additions & 4 deletions .github/workflows/autorelease-implement.yml
Original file line number Diff line number Diff line change
Expand Up @@ -300,14 +300,17 @@ jobs:
# from the earlier attempt, so the automation branch is replaced under a lease
# and any open PR on it is reused; the exact-SHA gates below still bind the
# merge to this run's validated commit.
# The fetch is forced so the tracking ref, and with it the lease, is exactly the
# branch head GitHub reports now, whatever an earlier attempt left behind.
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
git fetch origin "$branch:refs/remotes/origin/$branch"
git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch"
fi
git push --force-with-lease origin "HEAD:refs/heads/$branch"
existing="$(gh pr list --head "$branch" --state open --json number --jq '.[0].number // empty')"
if [[ -z "$existing" ]]; then
url="$(gh pr create --base main --head "$branch" --title "chore: $action_key" \
--body "Deterministically sealed autorelease patch for \`$action_key\`.\n\nValidated commit: \`$(git rev-parse HEAD)\`.")"
body="$(printf "Deterministically sealed autorelease patch for \`%s\`.\n\nValidated commit: \`%s\`." \
"$action_key" "$(git rev-parse HEAD)")"
url="$(gh pr create --base main --head "$branch" --title "chore: $action_key" --body "$body")"
existing="${url##*/}"
fi
echo "number=$existing" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -426,7 +429,8 @@ jobs:
--head "${{ steps.readiness.outputs.head_sha }}" \
--record "${{ steps.readiness.outputs.record }}" \
--digest "${{ steps.readiness.outputs.digest }}" \
--checks-output autorelease-run/readiness-checks.json
--checks-output autorelease-run/readiness-checks.json \
--require-protected-controls

# Any failed phase stops here with one deduplicated owner issue on the action key.
# A new branch whose build fails the exact module comparison carries that module
Expand Down
86 changes: 76 additions & 10 deletions .github/workflows/autorelease-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -749,25 +749,53 @@ jobs:
jq --arg version "$VERSION" \
'.severity="info" | .summary="PHP \($version) was published and verified through fresh exact and branch-shorthand mise installs." | .finalResult="passed"' \
release-run/event.json > release-run/notification-event.json
# A rerun of this job must neither file a second record nor trip over what an
# earlier attempt of this run left behind. A record already on main that completes
# exactly this release (merged by an earlier attempt, or recovered by the watcher)
# ends the step. Otherwise the run-scoped branch belongs to this run alone, so any
# PR or branch an earlier attempt left on it is withdrawn and filed afresh.
- name: Commit final event record through a checked PR
id: event_pr
env:
GH_TOKEN: ${{ github.token }}
BRANCH: autorelease/event-${{ github.run_id }}
run: |
git fetch origin main
git checkout -B "autorelease/event-${{ github.run_id }}" origin/main
base="$(git rev-parse HEAD)"
filename="$(./autorelease/control.py action-filename "$ACTION_KEY")"
cp release-run/event.json "autorelease-events/$filename"
git add "autorelease-events/$filename"
record="autorelease-events/$filename"
rm -f release-run/main-record.json
if git cat-file -e "origin/main:$record" 2>/dev/null; then
git show "origin/main:$record" > release-run/main-record.json
fi
recorded="$(./autorelease/control.py release-recorded \
--record release-run/main-record.json \
--action-key "$ACTION_KEY" \
--version "$VERSION" \
--transaction release-run/transaction.json)"
if [[ "$recorded" == "true" ]]; then
echo "The event record for $ACTION_KEY is already on main."
echo "already_recorded=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "already_recorded=false" >> "$GITHUB_OUTPUT"
gh auth setup-git
for stale in $(gh pr list --repo "${{ github.repository }}" --head "$BRANCH" --state open \
--json number --jq '.[].number'); do
gh pr close "$stale" --repo "${{ github.repository }}" --delete-branch
done
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null; then
git push origin --delete "$BRANCH"
fi
git checkout -B "$BRANCH" origin/main
base="$(git rev-parse HEAD)"
cp release-run/event.json "$record"
git add "$record"
git -c user.name=autorelease -c user.email=autorelease@invalid \
commit -m "chore: complete $ACTION_KEY"
head="$(git rev-parse HEAD)"
record="autorelease-events/$filename"
digest="sha256:$(shasum -a 256 "$record" | awk '{print $1}')"
gh auth setup-git
git push origin HEAD
url="$(gh pr create --base main --head "autorelease/event-${{ github.run_id }}" \
url="$(gh pr create --base main --head "$BRANCH" \
--title "chore: complete $ACTION_KEY" \
--body "Durable event record for the immutable verified release transaction.")"
{
Expand All @@ -779,6 +807,7 @@ jobs:
} >> "$GITHUB_OUTPUT"
- name: Validate and merge final event record
id: merge
if: steps.event_pr.outputs.already_recorded == 'false'
env:
GH_TOKEN: ${{ github.token }}
run: |
Expand All @@ -788,7 +817,8 @@ jobs:
--head "${{ steps.event_pr.outputs.head_sha }}" \
--record "${{ steps.event_pr.outputs.record }}" \
--digest "${{ steps.event_pr.outputs.digest }}" \
--checks-output release-run/event-checks.json
--checks-output release-run/event-checks.json \
--require-protected-controls
- name: Notify owner of completed release
env:
GH_TOKEN: ${{ github.token }}
Expand All @@ -801,16 +831,30 @@ jobs:
--repo "${{ github.repository }}" \
--owner "${{ vars.AUTORELEASE_OWNER }}"
# This job only runs after publication, so the release is live either way; the
# state now also says whether its event record reached main.
# state now also says whether its event record is on main. Main is the authority:
# a failed step says nothing about a record an earlier attempt already merged, or
# a merge whose reply was lost, so without a merge in this attempt main is read.
- name: Record whether the event record merged
if: always()
env:
MERGE_OUTCOME: ${{ steps.merge.outcome }}
ALREADY_RECORDED: ${{ steps.event_pr.outputs.already_recorded }}
run: |
mkdir -p release-run
recorded=false
if [[ "$MERGE_OUTCOME" == "success" ]]; then
if [[ "$MERGE_OUTCOME" == "success" || "$ALREADY_RECORDED" == "true" ]]; then
recorded=true
elif filename="$(./autorelease/control.py action-filename "$ACTION_KEY")" \
&& git fetch origin main \
&& git show "origin/main:autorelease-events/$filename" > "$RUNNER_TEMP/main-record.json"; then
transaction=()
if [[ -f release-run/transaction.json ]]; then
transaction=(--transaction release-run/transaction.json)
fi
if [[ "$(./autorelease/control.py release-recorded --record "$RUNNER_TEMP/main-record.json" \
--action-key "$ACTION_KEY" --version "$VERSION" "${transaction[@]}")" == "true" ]]; then
recorded=true
fi
fi
jq -n --argjson recorded "$recorded" --arg version "$VERSION" \
'{schemaVersion:1,released:true,recorded:$recorded,version:$version}' > release-run/transaction-state.json
Expand Down Expand Up @@ -919,3 +963,25 @@ jobs:
--backend github \
--repo "${{ github.repository }}" \
--owner "${{ vars.AUTORELEASE_OWNER }}"

# GitHub starts no `workflow_run` for a run GITHUB_TOKEN dispatched, and the watcher
# dispatches this one that way, so the run emails its own digest from this last job.
# It waits for every other job and passes the conclusion they reached: failure when
# any failed, else cancelled when any was cancelled, else success. A rerun of failed
# jobs reruns this one too, so each attempt sends one digest, as a completed
# `workflow_run` would.
email:
name: Email the run digest
needs: [preflight, build, release, verify-draft, publish, verify-public, finalize, notify-failure]
if: always()
permissions:
actions: read
contents: read
uses: ./.github/workflows/autorelease-email.yml
with:
run_id: ${{ github.run_id }}
run_attempt: ${{ github.run_attempt }}
workflow: publish
conclusion: ${{ contains(needs.*.result, 'failure') && 'failure' || contains(needs.*.result, 'cancelled') && 'cancelled' || 'success' }}
secrets:
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
3 changes: 2 additions & 1 deletion .github/workflows/autorelease-watch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -427,8 +427,9 @@ jobs:
head="$(git rev-parse HEAD)"
record_digest="sha256:$(shasum -a 256 autorelease-state/last-evidence.json | awk '{print $1}')"
gh auth setup-git
# Forced, so the lease below is exactly the branch head GitHub reports now.
if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
git fetch origin "$branch:refs/remotes/origin/$branch"
git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch"
fi
git push --force-with-lease origin "HEAD:refs/heads/$branch"
number="$(gh pr list --state open --head "$branch" --json number --jq '.[0].number // empty')"
Expand Down
Loading
Loading