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
268 changes: 268 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,268 @@
name: Prepare release

# Step 1 of a release (see the Guardian SDK Release Guide on Confluence, AA1 1156121307).
# Start it from the Actions tab (Prepare release, Run workflow, branch master) with
# the version to ship.
#
# It creates release/X.Y.Z from master, sets .version and the README install line,
# drafts the CHANGELOG.md entry, commits all of it as ONE verified commit (created
# through the GitHub API so GitHub signs it; a `git commit` here would be unsigned)
# and opens the "Release X.Y.Z" PR. Review and edit the CHANGELOG entry on the PR,
# then squash-merge it: the Release workflow (release.yml) starts on that merge.
#
# Re-running for the same version is safe:
# - open release PR for X.Y.Z -> nothing to do, prints the PR
# - branch exists, no PR yet -> finishes the commit and opens the PR
# - release PR already merged -> correction mode: recreates release/X.Y.Z from
# master (the old branch was squash-merged, so a PR from it would show the old
# commits again) so a fix can go through a new PR before the version ships.

on:
workflow_dispatch:
inputs:
version:
description: "Version to release (X.Y.Z, no v prefix)"
required: true
type: string

permissions:
contents: read

concurrency:
group: prepare-release-${{ inputs.version }}
cancel-in-progress: false

jobs:
prepare:
name: Prepare release
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write # create the branch and the release commit
pull-requests: write # open the release PR
actions: write # approve the PR checks on the release PR (see the last step)
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
VERSION: ${{ inputs.version }}
BRANCH: release/${{ inputs.version }}
steps:
- name: Require master
if: github.ref != 'refs/heads/master'
run: |
echo "::error::Run Prepare release on master (it ran on ${GITHUB_REF_NAME})."
exit 1

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # pin@v7.0.1
with:
fetch-depth: 0 # all tags, for the latest-version check and the changelog range

- name: Validate version and work out what to do
id: plan
run: |
set -euo pipefail
if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::'$VERSION' is not X.Y.Z."
exit 1
fi

pom="https://repo1.maven.org/maven2/com/auth0/android/guardian/${VERSION}/guardian-${VERSION}.pom"
if [[ "$(curl -s -o /dev/null -w '%{http_code}' "$pom")" == "200" ]]; then
echo "::error::$VERSION is already on Maven Central. Versions are immutable there; release the next version instead."
exit 1
fi

latest="$(git tag -l | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true)"
echo "latest=$latest" >> "$GITHUB_OUTPUT"

open_pr="$(gh pr list --repo "$REPO" --head "$BRANCH" --base master --state open --json url --jq '.[0].url // ""')"
if [[ -n "$open_pr" ]]; then
echo "Release PR for $VERSION is already open: $open_pr"
echo "mode=done" >> "$GITHUB_OUTPUT"
exit 0
fi

merged_pr="$(gh pr list --repo "$REPO" --head "$BRANCH" --base master --state merged --json url --jq '.[0].url // ""')"
if [[ -n "$merged_pr" ]]; then
echo "The release PR for $VERSION was already merged ($merged_pr) and $VERSION is not on Maven Central yet: correction mode."
echo "mode=correction" >> "$GITHUB_OUTPUT"
exit 0
fi

# A fresh release: the version must be new everywhere and higher than the last one.
if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then
echo "::error::Tag $VERSION already exists."
exit 1
fi
if gh release view "$VERSION" --repo "$REPO" >/dev/null 2>&1; then
echo "::error::A GitHub Release $VERSION already exists."
exit 1
fi
if [[ -n "$latest" ]]; then
highest="$(printf '%s\n%s\n' "$latest" "$VERSION" | sort -V | tail -1)"
if [[ "$VERSION" == "$latest" || "$highest" != "$VERSION" ]]; then
echo "::error::$VERSION is not higher than the latest release, $latest."
exit 1
fi
fi
echo "mode=release" >> "$GITHUB_OUTPUT"

# Correction mode: point release/X.Y.Z at master's head (create it if it was
# deleted after the merge). Nothing to commit: master already carries the
# version files and the CHANGELOG entry, so the fix goes on the branch next.
- name: Recreate the release branch from master (correction mode)
if: steps.plan.outputs.mode == 'correction'
run: |
set -euo pipefail
head="$(git rev-parse origin/master)"
if gh api "repos/$REPO/git/ref/heads/$BRANCH" >/dev/null 2>&1; then
gh api -X PATCH "repos/$REPO/git/refs/heads/$BRANCH" -f sha="$head" -F force=true >/dev/null
else
gh api "repos/$REPO/git/refs" -f ref="refs/heads/$BRANCH" -f sha="$head" >/dev/null
fi
{
echo "### $BRANCH recreated from master"
echo
echo "Push the fix (and any CHANGELOG correction) to \`$BRANCH\`, open a PR into master and merge it."
echo "The merge starts a new Release run for $VERSION, which cancels the older run if it has not started publishing."
echo "Do not approve the older run in the meantime."
} >> "$GITHUB_STEP_SUMMARY"

- name: Create the release branch
if: steps.plan.outputs.mode == 'release'
run: |
set -euo pipefail
if gh api "repos/$REPO/git/ref/heads/$BRANCH" >/dev/null 2>&1; then
echo "$BRANCH already exists; reusing it."
else
gh api "repos/$REPO/git/refs" -f ref="refs/heads/$BRANCH" -f sha="$(git rev-parse origin/master)" >/dev/null
echo "Created $BRANCH from master."
fi
git fetch origin "$BRANCH"
git checkout -B "$BRANCH" "origin/$BRANCH"

- name: Set the version files
if: steps.plan.outputs.mode == 'release'
run: |
set -euo pipefail
echo "$VERSION" > .version
sed -E -i "s/(com\.auth0\.android:guardian:)[0-9]+\.[0-9]+\.[0-9]+/\1${VERSION}/g" README.md
if ! grep -q "com.auth0.android:guardian:${VERSION}" README.md; then
echo "::error::Could not find the com.auth0.android:guardian install line in README.md."
exit 1
fi

# Drafts the entry in the existing CHANGELOG format from the PRs merged since
# the previous tag. PR titles are untrusted text, so they only ever pass through
# files and Python, never through the shell.
- name: Draft the CHANGELOG entry
if: steps.plan.outputs.mode == 'release'
env:
LATEST: ${{ steps.plan.outputs.latest }}
run: |
set -euo pipefail
if grep -qF "## [${VERSION}](" CHANGELOG.md; then
echo "CHANGELOG.md already has an entry for $VERSION; keeping it."
exit 0
fi
args=(-f tag_name="$VERSION" -f target_commitish=master)
[[ -n "$LATEST" ]] && args+=(-f previous_tag_name="$LATEST")
gh api "repos/$REPO/releases/generate-notes" "${args[@]}" --jq '.body' > /tmp/notes.md
python3 - <<'PY'
import datetime, os, re

version, latest, repo = os.environ["VERSION"], os.environ["LATEST"], os.environ["REPO"]
bullets = []
for line in open("/tmp/notes.md", encoding="utf-8"):
line = line.rstrip("\n")
if line.startswith("## New Contributors") or line.startswith("**Full Changelog**"):
break
m = re.match(r"^\* (.+) by @\S+ in (https://\S+/pull/\d+)$", line)
if m:
bullets.append(f"- {m.group(1)} (see [PR]({m.group(2)}))")
if not bullets:
bullets = ["- TODO: describe the changes in this release"]

date = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
entry = [f"## [{version}](https://github.com/{repo}/tree/{version}) ({date})"]
if latest:
entry.append(f"[Full Changelog](https://github.com/{repo}/compare/{latest}...{version})")
entry += ["", "**Changed**", *bullets, ""]

with open("CHANGELOG.md", encoding="utf-8") as f:
lines = f.read().split("\n")
# Keep the "# Change Log" title and insert the new entry above the newest one.
at = next((i for i, l in enumerate(lines) if l.startswith("## ")), len(lines))
with open("CHANGELOG.md", "w", encoding="utf-8") as f:
f.write("\n".join(lines[:at] + entry + lines[at:]))
PY

# One commit through the Git Data API (blobs, tree, commit, ref update). No
# author or committer is passed, so GitHub signs it and it shows as Verified.
- name: Commit as one verified commit
if: steps.plan.outputs.mode == 'release'
run: |
set -euo pipefail
files=(.version README.md CHANGELOG.md)
if git diff --quiet -- "${files[@]}"; then
echo "Version files and CHANGELOG are already up to date on $BRANCH."
exit 0
fi
parent="$(git rev-parse HEAD)"
base_tree="$(git rev-parse 'HEAD^{tree}')"
entries='[]'
for f in "${files[@]}"; do
git diff --quiet -- "$f" && continue
blob="$(base64 -w0 "$f" | jq -Rs '{content: ., encoding: "base64"}' \
| gh api "repos/$REPO/git/blobs" --input - --jq .sha)"
entries="$(jq -c --arg p "$f" --arg s "$blob" '. + [{path: $p, mode: "100644", type: "blob", sha: $s}]' <<<"$entries")"
done
tree="$(jq -n --arg b "$base_tree" --argjson t "$entries" '{base_tree: $b, tree: $t}' \
| gh api "repos/$REPO/git/trees" --input - --jq .sha)"
commit="$(jq -n --arg m "Release $VERSION" --arg t "$tree" --arg p "$parent" '{message: $m, tree: $t, parents: [$p]}' \
| gh api "repos/$REPO/git/commits" --input - --jq .sha)"
# Not forced: if someone pushed to the branch meanwhile, fail rather than drop their work.
gh api -X PATCH "repos/$REPO/git/refs/heads/$BRANCH" -f sha="$commit" >/dev/null
echo "Committed $commit to $BRANCH."

- name: Open the release PR
if: steps.plan.outputs.mode == 'release'
run: |
set -euo pipefail
body="$(cat <<EOF
Releases **${VERSION}**: sets \`.version\` and the README install line, and adds the CHANGELOG entry.

Before merging:
- Edit the CHANGELOG entry on this branch: group the bullets the way previous entries do (**Added**, **Fixed**, **Security**, **Changed**), drop internal-only changes, check the date.
- Squash-merge once checks are green and the PR is approved. The merge starts the Release workflow, which pauses for approval before anything is tagged or published.
EOF
)"
url="$(gh pr create --repo "$REPO" --base master --head "$BRANCH" --title "Release $VERSION" --body "$body")"
echo "Opened $url"
echo "### Release PR: $url" >> "$GITHUB_STEP_SUMMARY"

# GitHub holds the PR checks of a PR opened by github-actions[bot] at "Approval
# required". Approve that run so the checks show on the PR. (Starting pr.yml with
# workflow_dispatch instead runs the tests, but GitHub does not show the result
# on the PR.) Later pushes to the branch trigger pr.yml as usual.
- name: Start the PR checks
if: steps.plan.outputs.mode == 'release'
run: |
set -euo pipefail
sha="$(gh api "repos/$REPO/git/ref/heads/$BRANCH" --jq .object.sha)"
for _ in $(seq 1 20); do
runs="$(gh api "repos/$REPO/actions/workflows/pr.yml/runs?event=pull_request&head_sha=$sha" \
--jq '[.workflow_runs[] | {id, conclusion}]')"
held="$(jq -r '[.[] | select(.conclusion == "action_required")][0].id // ""' <<<"$runs")"
if [[ -n "$held" ]]; then
gh api -X POST "repos/$REPO/actions/runs/$held/approve" >/dev/null
echo "Approved the PR checks (run $held)."
exit 0
fi
if [[ "$(jq length <<<"$runs")" != "0" ]]; then
echo "The PR checks are already running."
exit 0
fi
sleep 3
done
echo "::warning::No PR check run appeared for $BRANCH. Close and reopen the release PR to start the checks."
Loading
Loading