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
1 change: 1 addition & 0 deletions .github/workflows/release-notes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ jobs:
enable-cache: false
- id: towncrier
name: ⚙️ Read Towncrier configuration
if: inputs.changelog == ''
uses: $/read-towncrier-config
- name: 📦 Install locked release tools
uses: $/install-locked-tools
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -871,7 +871,8 @@ workflow.
`release-notes.yaml` takes these inputs and returns one output:

- `tag` **(required)**: release tag, such as `1.2.3` or `pyproject-fmt/1.2.3`.
- `changelog`: tracked changelog path. Empty reads `filename` from the committed Towncrier configuration.
- `changelog`: tracked changelog path. Empty reads `filename` from the committed Towncrier configuration, so a
repository without one, such as a multi-package workspace, must set it.
- `artifact-url` and `artifact-label`: passed to `render-release-notes`.
- `package`: package section of a shared Markdown changelog. The previous tag is then the one before the tag in the same
package namespace.
Expand Down Expand Up @@ -1138,6 +1139,12 @@ leaves the draft for a later retry. The Action removes incomplete `starter` asse
the release by ID for up to ten seconds after draft creation or asset upload; a mismatch still fails when that window
ends.

A GitHub API call that fails with a server error or a network error is retried up to three times, with backoff; a 4xx
fails at once. Draft creation is the exception: a request GitHub had already applied would be sent again and could leave
a second draft, so that call is not retried. If the publish call fails, the Action reads the release again and succeeds
when GitHub already published it with the expected assets and notes, which covers a response lost after GitHub applied
the change.

## Reconcile a post-release pull request

`publish-post-release-pr` consumes a candidate commit prepared without an App token. The preparation job records the
Expand Down
2 changes: 2 additions & 0 deletions changelog.d/+notes-changelog-input.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
`release-notes.yaml` reads the Towncrier configuration only when the `changelog` input is empty, so a repository without
`[tool.towncrier]`, such as a multi-package workspace, can render its release notes when it passes `changelog`.
3 changes: 3 additions & 0 deletions changelog.d/+publish-retry.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
`publish-github-release` retries a GitHub API call that fails without a client error, up to three attempts with backoff.
When the publish call fails, the Action reads the release again and succeeds if GitHub already published it with the
expected assets and notes, so a lost response no longer fails a release that is live. Draft creation is not retried.
51 changes: 40 additions & 11 deletions publish-github-release/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ runs:
TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
gh api --paginate "repos/$GH_REPO/releases?per_page=100" |
# shellcheck disable=SC1091 # GitHub supplies this Action path at runtime.
source "$GITHUB_ACTION_PATH/retry.sh"
gh_api --paginate "repos/$GH_REPO/releases?per_page=100" |
jq -s --arg tag "$TAG" '[.[][] | select(.tag_name == $tag)]' >"$RUNNER_TEMP/release-existing.json"
if jq -e 'length > 0' "$RUNNER_TEMP/release-existing.json" >/dev/null; then
jq -er '.[0].id' "$RUNNER_TEMP/release-existing.json" >"$RUNNER_TEMP/release-id"
Expand All @@ -84,6 +86,8 @@ runs:
NOTES_FILE: ${{ inputs.notes-file }}
run: |
set -euo pipefail
# shellcheck disable=SC1091 # GitHub supplies this Action path at runtime.
source "$GITHUB_ACTION_PATH/retry.sh"
if jq -e '.[0].draft == false' "$RUNNER_TEMP/release-existing.json" >/dev/null; then
echo "New Contributors skipped: GitHub Release $TAG is already published"
cp "$NOTES_FILE" "$RUNNER_TEMP/release-body.md"
Expand All @@ -96,7 +100,8 @@ runs:
request+=(-f "previous_tag_name=$previous")
fi
echo "Generated notes range: ${previous:-first release}...$TAG"
gh api -X POST "repos/$GH_REPO/releases/generate-notes" "${request[@]}" --jq .body >"$RUNNER_TEMP/generated-notes.md"
gh_api -X POST "repos/$GH_REPO/releases/generate-notes" "${request[@]}" --jq .body \
>"$RUNNER_TEMP/generated-notes.md"
python3 "$GITHUB_ACTION_PATH/../tools/new_contributors.py" \
"$NOTES_FILE" "$RUNNER_TEMP/generated-notes.md" "$RUNNER_TEMP/release-body.md"
- name: 🚀 Create or update the draft
Expand All @@ -109,11 +114,14 @@ runs:
PRERELEASE: ${{ inputs.prerelease }}
run: |
set -euo pipefail
# shellcheck disable=SC1091 # GitHub supplies this Action path at runtime.
source "$GITHUB_ACTION_PATH/retry.sh"
if jq -e 'length == 0' "$RUNNER_TEMP/release-existing.json" >/dev/null; then
jq -n --arg tag "$TAG" --arg commit "$COMMIT" --arg title "$TITLE" \
--rawfile body "$RUNNER_TEMP/release-body.md" --argjson prerelease "$PRERELEASE" \
'{tag_name: $tag, target_commitish: $commit, name: $title, body: $body,
draft: true, prerelease: $prerelease}' >"$RUNNER_TEMP/release-request.json"
# Not retried: if GitHub stored the draft before answering 5xx, a second POST could create another.
gh api -X POST "repos/$GH_REPO/releases" --input "$RUNNER_TEMP/release-request.json" \
>"$RUNNER_TEMP/release-created.json"
jq -er '.id | select(type == "number")' "$RUNNER_TEMP/release-created.json" >"$RUNNER_TEMP/release-id"
Expand All @@ -123,7 +131,7 @@ runs:
"$RUNNER_TEMP/release-existing.json" >/dev/null; then
jq -n --arg title "$TITLE" --rawfile body "$RUNNER_TEMP/release-body.md" --argjson prerelease "$PRERELEASE" \
'{name: $title, body: $body, prerelease: $prerelease}' >"$RUNNER_TEMP/release-update.json"
gh api -X PATCH "repos/$GH_REPO/releases/$(<"$RUNNER_TEMP/release-id")" \
gh_api -X PATCH "repos/$GH_REPO/releases/$(<"$RUNNER_TEMP/release-id")" \
--input "$RUNNER_TEMP/release-update.json" \
--jq '"GitHub Release \(.tag_name): updated draft \(.id) to the expected title, notes and prerelease flag"'
elif jq -e '.[0].draft' "$RUNNER_TEMP/release-existing.json" >/dev/null; then
Expand Down Expand Up @@ -155,11 +163,11 @@ runs:
if jq -e '.[0].draft' "$RUNNER_TEMP/release-existing.json" >/dev/null; then
while IFS= read -r asset_id; do
echo "Deleting incomplete draft asset $asset_id left by an earlier attempt"
gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset_id"
gh_api -X DELETE "repos/$GH_REPO/releases/assets/$asset_id"
done < <(jq -r '.[0].assets[] | select(.state != "uploaded") | .id' "$RUNNER_TEMP/release-existing.json")
fi
read_draft() {
gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-current.json" &&
gh_api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-current.json" &&
jq -e --arg phase draft --slurpfile assets "$RUNNER_TEMP/release-assets.json" \
-f "$GITHUB_ACTION_PATH/match.jq" "$RUNNER_TEMP/release-current.json" >/dev/null
}
Expand All @@ -185,20 +193,20 @@ runs:
else
for attempt in {1..6}; do
if ((attempt > 1)); then
release=$(gh api "repos/$GH_REPO/releases/$release_id")
release=$(gh_api "repos/$GH_REPO/releases/$release_id")
asset_ids=$(jq -r --arg name "$name" \
'.assets[] | select(.name == $name and .state != "uploaded") | .id' <<<"$release")
while IFS= read -r asset_id; do
[[ -n "$asset_id" ]] || continue
echo "Deleting incomplete upload $asset_id of $name before retrying"
gh api -X DELETE "repos/$GH_REPO/releases/assets/$asset_id"
gh_api -X DELETE "repos/$GH_REPO/releases/assets/$asset_id"
done <<<"$asset_ids"
fi
echo "Asset $name: uploading (attempt $attempt of 6)"
if gh release upload "$TAG" "$asset" --repo "$GH_REPO"; then
break
fi
if gh api "repos/$GH_REPO/releases/$release_id" | jq -e --arg name "$name" \
if gh_api "repos/$GH_REPO/releases/$release_id" | jq -e --arg name "$name" \
--slurpfile assets "$RUNNER_TEMP/release-assets.json" '
any(.assets[]; .name == $name and .state == "uploaded" and
(.digest as $digest | any($assets[0][]; .name == $name and .digest == $digest)))
Expand Down Expand Up @@ -229,7 +237,7 @@ runs:
source "$GITHUB_ACTION_PATH/retry.sh"
release_id=$(<"$RUNNER_TEMP/release-id")
read_complete() {
gh api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-complete.json" &&
gh_api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-complete.json" &&
jq -e --arg phase complete --slurpfile assets "$RUNNER_TEMP/release-assets.json" \
-f "$GITHUB_ACTION_PATH/match.jq" "$RUNNER_TEMP/release-complete.json" >/dev/null
}
Expand All @@ -244,15 +252,36 @@ runs:
shell: bash
env:
GH_REPO: ${{ github.repository }}
TAG: ${{ inputs.tag }}
MODE: ${{ inputs.mode }}
run: |
set -euo pipefail
# shellcheck disable=SC1091 # GitHub supplies this Action path at runtime.
source "$GITHUB_ACTION_PATH/retry.sh"
if [[ "$MODE" == stage ]]; then
echo 'Publish skipped: mode stage leaves the release as a draft'
elif jq -e '.[0].draft' "$RUNNER_TEMP/release-complete.json" >/dev/null; then
release_id=$(<"$RUNNER_TEMP/release-id")
gh api -X PATCH "repos/$GH_REPO/releases/$release_id" -F draft=false \
--jq '"GitHub Release \(.tag_name): published \(.html_url)"'
publish_draft() {
gh_api -X PATCH "repos/$GH_REPO/releases/$release_id" -F draft=false \
--jq '"GitHub Release \(.tag_name): published \(.html_url)"'
}
read_published() {
gh_api "repos/$GH_REPO/releases/$release_id" | jq -s . >"$RUNNER_TEMP/release-published.json" &&
jq -e --arg phase complete --slurpfile assets "$RUNNER_TEMP/release-assets.json" \
-f "$GITHUB_ACTION_PATH/match.jq" "$RUNNER_TEMP/release-published.json" >/dev/null &&
jq -e --rawfile body "$RUNNER_TEMP/release-body.md" '.[0] | .draft == false and .body == $body' \
"$RUNNER_TEMP/release-published.json" >/dev/null
}
# A publish call can fail after GitHub has already published the release, so the state decides.
if ! publish_draft; then
echo "::warning::publish call for GitHub Release $TAG failed; reading the release to check it" >&2
if ! retry read_published; then
echo "::error::GitHub Release $TAG is not published with the expected assets and notes" >&2
exit 1
fi
echo "GitHub Release $TAG: already published with the expected assets and notes"
fi
else
echo 'Publish skipped: the release is already published'
fi
Expand Down
13 changes: 13 additions & 0 deletions publish-github-release/retry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,16 @@ retry() {
sleep 2
done
}

# Retries a gh api call unless GitHub answered with a 4xx, which repeats itself. gh prints a non-JSON 5xx body as a
# parse error without the status, so the 4xx marker is the reliable test.
gh_api() {
local attempt errors="$RUNNER_TEMP/gh-api-error.txt"
for attempt in 1 2 3; do
if gh api "$@" 2>"$errors"; then return 0; fi
cat "$errors" >&2
if ((attempt == 3)) || grep -q 'HTTP 4[0-9][0-9]' "$errors"; then return 1; fi
echo "GitHub API call failed without a client error, attempt $attempt of 3, retrying in $((attempt * 5))s"
sleep $((attempt * 5))
done
}
Loading