Skip to content

Create Release (minor) #45

Create Release (minor)

Create Release (minor) #45

name: Create Release
run-name: "Create Release (${{ inputs.release_type }})"
on:
workflow_dispatch:
inputs:
release_type:
description: 'Release bump type'
required: true
type: choice
options:
- patch
- minor
- major
concurrency: create-release
permissions:
contents: write
jobs:
create-release:
name: Bump version and create draft prerelease
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ vars.CPL_BOT_GITHUB_APP_ID }}
private-key: ${{ secrets.CPL_BOT_GITHUB_APP_PRIVATE_KEY }}
- name: Ensure workflow runs on main
shell: bash
run: |
set -euo pipefail
if [[ "${GITHUB_REF_NAME}" != "main" ]]; then
echo "Error: This workflow must run on main. Current ref: ${GITHUB_REF_NAME}"
exit 1
fi
- name: Checkout main
uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
fetch-tags: true
token: ${{ steps.app-token.outputs.token }}
- name: Find newest taggable main commit and wait for build tag
id: preflight-tag
shell: bash
run: |
set -euo pipefail
find_best_tag() {
local tags_text="$1"
local best_tag=""
local best_build=-1
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
local build=""
if [[ "$tag" =~ ^([0-9]+)-srv([0-9]+)-web([0-9]+)$ ]]; then
build="${BASH_REMATCH[1]}"
elif [[ "$tag" =~ ^v([0-9]+)\+srv([0-9]+)\.web([0-9]+)$ ]]; then
build="${BASH_REMATCH[1]}"
else
continue
fi
if (( build > best_build )); then
best_build=$build
best_tag="$tag"
fi
done <<< "$tags_text"
echo "$best_tag"
}
wait_for_tag() {
local target_sha="$1"
local max_attempts=90
local sleep_seconds=10
local attempt=1
while (( attempt <= max_attempts )); do
git fetch origin --tags --quiet
local tags
tags="$(git tag --points-at "$target_sha" || true)"
local best_tag
best_tag="$(find_best_tag "$tags")"
if [[ -n "$best_tag" ]]; then
echo "$best_tag"
return 0
fi
echo "Waiting for build tag on $target_sha (${attempt}/${max_attempts})..." >&2
sleep "$sleep_seconds"
((attempt++))
done
echo "Error: Timed out waiting for build tag on $target_sha" >&2
return 1
}
is_taggable_path() {
local path="$1"
case "$path" in
web/*|pingpong/*|alembic/*|saml/*|uv.lock|pyproject.toml)
return 0
;;
*)
return 1
;;
esac
}
find_newest_taggable_commit() {
local max_commits=2000
local checked=0
while IFS= read -r sha; do
((checked++))
if (( checked > max_commits )); then
break
fi
local first_parent
first_parent="$(git rev-list --parents -n 1 "$sha" | awk '{print $2}')"
local files
if [[ -n "$first_parent" ]]; then
# Compare against first parent only to avoid per-parent merge diffs.
files="$(git diff --name-only "$first_parent" "$sha" || true)"
else
# Root commit fallback.
files="$(git diff-tree --no-commit-id --name-only -r --root "$sha" || true)"
fi
while IFS= read -r file; do
[[ -z "$file" ]] && continue
if is_taggable_path "$file"; then
echo "$sha"
return 0
fi
done <<< "$files"
done < <(git rev-list --first-parent origin/main)
return 1
}
git fetch origin main --tags --prune --quiet
TAGGABLE_SHA="$(find_newest_taggable_commit || true)"
if [[ -z "$TAGGABLE_SHA" ]]; then
echo "Error: Could not find a recent taggable commit on origin/main (checked up to 2000 commits)."
exit 1
fi
TAGGABLE_TAG="$(wait_for_tag "$TAGGABLE_SHA")"
echo "PRE_COMMIT_TAGGABLE_SHA=$TAGGABLE_SHA" >> "$GITHUB_ENV"
echo "PRE_COMMIT_TAG=$TAGGABLE_TAG" >> "$GITHUB_ENV"
echo "taggable_sha=$TAGGABLE_SHA" >> "$GITHUB_OUTPUT"
echo "taggable_tag=$TAGGABLE_TAG" >> "$GITHUB_OUTPUT"
- name: Compute release version and same-version behavior
id: version
shell: bash
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
RELEASE_TYPE: ${{ inputs.release_type }}
run: |
set -euo pipefail
python << 'PY'
import json
import os
import re
import subprocess
import sys
release_type = os.environ["RELEASE_TYPE"]
if release_type not in {"major", "minor", "patch"}:
print(f"Invalid release_type: {release_type}", file=sys.stderr)
sys.exit(1)
pattern = re.compile(r"^v(\d+)\.(\d+)(?:\.(\d+))?(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$")
latest_raw = subprocess.check_output(
[
"gh",
"release",
"list",
"--exclude-drafts",
"--exclude-pre-releases",
"--limit",
"1",
"--json",
"name",
],
text=True,
)
latest_releases = json.loads(latest_raw)
if not latest_releases:
print(
"Expected at least one published release to exist, but none were found.",
file=sys.stderr,
)
sys.exit(1)
baseline_name = (latest_releases[0].get("name") or "").strip()
match = pattern.match(baseline_name)
if not match:
print(
f"Latest published release name does not match expected semver format: {baseline_name}",
file=sys.stderr,
)
sys.exit(1)
major = int(match.group(1))
minor = int(match.group(2))
patch = int(match.group(3) or "0")
if release_type == "major":
major += 1
minor = 0
patch = 0
elif release_type == "minor":
minor += 1
patch = 0
else:
patch += 1
version_normalized = f"{major}.{minor}.{patch}"
release_name = f"v{major}.{minor}" if patch == 0 else f"v{major}.{minor}.{patch}"
all_releases_raw = subprocess.check_output(
[
"gh",
"release",
"list",
"--limit",
"200",
"--json",
"name,tagName,isDraft",
],
text=True,
)
all_releases = json.loads(all_releases_raw)
same_name_releases = [rel for rel in all_releases if (rel.get("name") or "") == release_name]
if len(same_name_releases) > 1:
print(
f"Found multiple releases with the same name ({release_name}); refusing ambiguous recreation.",
file=sys.stderr,
)
sys.exit(1)
existing_draft_tag = ""
if len(same_name_releases) == 1:
rel = same_name_releases[0]
if rel.get("isDraft"):
existing_draft_tag = rel.get("tagName") or ""
else:
print(
f"Published release {release_name} already exists; refusing to overwrite published releases.",
file=sys.stderr,
)
sys.exit(1)
print(f"Baseline release: {baseline_name}")
print(f"Computed version: {version_normalized}")
print(f"Computed release name: {release_name}")
if existing_draft_tag:
print(f"Will recreate existing draft release with tag: {existing_draft_tag}")
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as out:
out.write(f"VERSION_NORMALIZED={version_normalized}\n")
out.write(f"RELEASE_NAME={release_name}\n")
out.write(f"EXISTING_DRAFT_TAG={existing_draft_tag}\n")
PY
- name: Update version files directly
shell: bash
env:
VERSION_NORMALIZED: ${{ steps.version.outputs.VERSION_NORMALIZED }}
run: |
set -euo pipefail
python << 'PY'
import os
import re
import sys
from pathlib import Path
version = os.environ["VERSION_NORMALIZED"]
pyproject = Path("pyproject.toml")
py_content = pyproject.read_text(encoding="utf-8")
py_updated, py_count = re.subn(
r'^version = ".*"$',
f'version = "{version}"',
py_content,
count=1,
flags=re.MULTILINE,
)
if py_count != 1:
print("Failed to update version in pyproject.toml", file=sys.stderr)
sys.exit(1)
pyproject.write_text(py_updated, encoding="utf-8")
package_json = Path("web/pingpong/package.json")
pkg_content = package_json.read_text(encoding="utf-8")
pkg_updated, pkg_count = re.subn(
r'("version"\s*:\s*")[^"]+(")',
rf'\g<1>{version}\2',
pkg_content,
count=1,
)
if pkg_count != 1:
print("Failed to update version in web/pingpong/package.json", file=sys.stderr)
sys.exit(1)
package_json.write_text(pkg_updated, encoding="utf-8")
uv_lock = Path("uv.lock")
lock_content = uv_lock.read_text(encoding="utf-8")
lock_updated, lock_count = re.subn(
r'(\[\[package\]\]\s+name = "pingpong"\s+version = ")[^"]+(")',
rf'\g<1>{version}\2',
lock_content,
count=1,
)
if lock_count != 1:
print("Failed to update version in uv.lock", file=sys.stderr)
sys.exit(1)
uv_lock.write_text(lock_updated, encoding="utf-8")
PY
- name: Detect version file changes
id: version-files
shell: bash
run: |
set -euo pipefail
if git diff --quiet -- pyproject.toml web/pingpong/package.json uv.lock; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "No version file changes detected."
else
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "Version files changed and will be committed."
fi
- name: Commit version bump
if: ${{ steps.version-files.outputs.changed == 'true' }}
uses: iarekylew00t/verified-bot-commit@b001460501aa4890e4429832db1cdf63e364f162
with:
message: 'release: ${{ steps.version.outputs.RELEASE_NAME }}'
token: ${{ steps.app-token.outputs.token }}
files: |
pyproject.toml
web/pingpong/package.json
uv.lock
- name: Resolve target commit SHA
id: target-sha
shell: bash
run: |
set -euo pipefail
if [[ "${{ steps.version-files.outputs.changed }}" == "true" ]]; then
TARGET_SHA="$(git rev-parse HEAD)"
echo "Using newly created release commit as target: $TARGET_SHA"
else
TARGET_SHA="${PRE_COMMIT_TAGGABLE_SHA}"
echo "No version commit created; reusing preflight taggable commit as target: $TARGET_SHA"
fi
echo "TARGET_SHA=$TARGET_SHA" >> "$GITHUB_ENV"
echo "target_sha=$TARGET_SHA" >> "$GITHUB_OUTPUT"
- name: Wait for build tag on target commit
id: target-tag
shell: bash
run: |
set -euo pipefail
if [[ "${{ steps.version-files.outputs.changed }}" != "true" ]]; then
RESOLVED_TAG="${PRE_COMMIT_TAG}"
if [[ -z "$RESOLVED_TAG" ]]; then
echo "Error: PRE_COMMIT_TAG is empty in no-change path."
exit 1
fi
echo "No version commit created; reusing preflight tag: $RESOLVED_TAG"
echo "RESOLVED_TAG=$RESOLVED_TAG" >> "$GITHUB_ENV"
echo "resolved_tag=$RESOLVED_TAG" >> "$GITHUB_OUTPUT"
exit 0
fi
find_best_tag() {
local tags_text="$1"
local best_tag=""
local best_build=-1
while IFS= read -r tag; do
[[ -z "$tag" ]] && continue
local build=""
if [[ "$tag" =~ ^([0-9]+)-srv([0-9]+)-web([0-9]+)$ ]]; then
build="${BASH_REMATCH[1]}"
elif [[ "$tag" =~ ^v([0-9]+)\+srv([0-9]+)\.web([0-9]+)$ ]]; then
build="${BASH_REMATCH[1]}"
else
continue
fi
if (( build > best_build )); then
best_build=$build
best_tag="$tag"
fi
done <<< "$tags_text"
echo "$best_tag"
}
wait_for_tag() {
local target_sha="$1"
local max_attempts=90
local sleep_seconds=10
local attempt=1
while (( attempt <= max_attempts )); do
git fetch origin --tags --quiet
local tags
tags="$(git tag --points-at "$target_sha" || true)"
local best_tag
best_tag="$(find_best_tag "$tags")"
if [[ -n "$best_tag" ]]; then
echo "$best_tag"
return 0
fi
echo "Waiting for build tag on $target_sha (${attempt}/${max_attempts})..." >&2
sleep "$sleep_seconds"
((attempt++))
done
echo "Error: Timed out waiting for build tag on $target_sha" >&2
return 1
}
RESOLVED_TAG="$(wait_for_tag "${TARGET_SHA}")"
echo "RESOLVED_TAG=$RESOLVED_TAG" >> "$GITHUB_ENV"
echo "resolved_tag=$RESOLVED_TAG" >> "$GITHUB_OUTPUT"
- name: Generate release notes file
id: notes
shell: bash
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
RELEASE_NAME: ${{ steps.version.outputs.RELEASE_NAME }}
RESOLVED_TAG: ${{ steps.target-tag.outputs.resolved_tag }}
TARGET_SHA: ${{ steps.target-sha.outputs.target_sha }}
run: |
set -euo pipefail
gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
-f "tag_name=${RESOLVED_TAG}" \
-f "target_commitish=${TARGET_SHA}" \
-f "name=${RELEASE_NAME}" > /tmp/generated-notes.json
python << 'PY'
import json
import os
import textwrap
from pathlib import Path
generated = json.loads(Path("/tmp/generated-notes.json").read_text(encoding="utf-8"))
body = (generated.get("body") or "").strip()
related_prs_section = body if body else "- None"
what_changed_heading = "## What's Changed"
heading_index = body.find(what_changed_heading)
if heading_index != -1:
after_heading = body[heading_index + len(what_changed_heading):].lstrip()
related_prs_section = after_heading if after_heading else "- None"
template = textwrap.dedent("""\
# Release Notes
This update provides important bug fixes and improvements.
## General
### New Features
- Note
### Updates & Improvements
- Note
### Resolved Issues
- Note
### Known Issues
- Note
### Deprecations
- Note
## Deployment Information
| Schema Upgrade | Migration Script | Permissions Update | Task Definition Update | Configuration Update |
| --- | --- | --- | --- | --- |
| No | No | No | No | No |
### Deployment Details
- N/A
## Related PRs
""")
notes = f"{template}{related_prs_section}\n"
notes_path = "/tmp/release-notes.md"
Path(notes_path).write_text(notes, encoding="utf-8")
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as out:
out.write(f"notes_path={notes_path}\n")
PY
- name: Create or update draft prerelease
shell: bash
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
EXISTING_DRAFT_TAG: ${{ steps.version.outputs.EXISTING_DRAFT_TAG }}
RELEASE_NAME: ${{ steps.version.outputs.RELEASE_NAME }}
RESOLVED_TAG: ${{ steps.target-tag.outputs.resolved_tag }}
TARGET_SHA: ${{ steps.target-sha.outputs.target_sha }}
NOTES_PATH: ${{ steps.notes.outputs.notes_path }}
run: |
set -euo pipefail
if [[ -n "${EXISTING_DRAFT_TAG}" ]]; then
echo "Updating existing draft release ${EXISTING_DRAFT_TAG} to tag ${RESOLVED_TAG}"
gh release edit "${EXISTING_DRAFT_TAG}" \
--title "${RELEASE_NAME}" \
--draft \
--prerelease \
--notes-file "${NOTES_PATH}" \
--tag "${RESOLVED_TAG}" \
--target "${TARGET_SHA}"
else
echo "Creating new draft prerelease ${RELEASE_NAME} on tag ${RESOLVED_TAG}"
gh release create "${RESOLVED_TAG}" \
--title "${RELEASE_NAME}" \
--draft \
--prerelease \
--notes-file "${NOTES_PATH}"
fi