EverOS/.github/workflows/release.yml

201 lines
8.3 KiB
YAML

# Publishes to PyPI via Trusted Publishing (OIDC) — no stored token — and then
# drafts the matching GitHub Release page from CHANGELOG.md.
# Fires on a version tag (vX.Y.Z, plus PEP 440 pre-releases vX.Y.ZrcN / aN / bN).
# The `release` environment gates the upload behind manual approval; configure
# required reviewers under Settings → Environments → release.
#
# One-time setup on PyPI (project owner, cannot be done from CI):
# PyPI → project `everos` → Settings → Publishing → add a GitHub trusted
# publisher: owner=EverMind-AI, repo=EverOS, workflow=release.yml,
# environment=release.
name: Release
on:
push:
tags:
# vX.Y.Z plus PEP 440 pre-release/dev suffixes (rc1 / a1 / b1 / .dev1).
- "v[0-9]+.[0-9]+.[0-9]+*"
permissions:
contents: read
jobs:
publish:
name: build + publish to PyPI
runs-on: ubuntu-latest
environment: release
permissions:
id-token: write # OIDC token for PyPI Trusted Publishing
contents: read
steps:
- uses: actions/checkout@v6
- name: Install uv
uses: astral-sh/setup-uv@v8.2.0
with:
enable-cache: true
cache-dependency-glob: uv.lock
- name: Set up Python
run: uv python install 3.12
# Guard: the tag must match the package version, so a mistyped tag can't
# publish the wrong release.
- name: Verify tag matches pyproject version
run: |
tag="${GITHUB_REF_NAME#v}"
pkg="$(grep -m1 -E '^version = ' pyproject.toml | sed -E 's/^version = "(.+)"/\1/')"
if [ "$tag" != "$pkg" ]; then
echo "::error::tag v$tag != pyproject version $pkg"
exit 1
fi
- name: Build + smoke-test the package
run: make package # builds sdist+wheel into dist/ and import-smokes it
- name: Publish to PyPI (Trusted Publishing)
uses: pypa/gh-action-pypi-publish@release/v1
# The notes page is a separate job so the publish job above keeps
# `contents: read` next to its OIDC token. It runs only after PyPI accepted
# the upload — a release page for a version nobody can install is worse than
# no page. The release is left as a DRAFT: the CHANGELOG section gives the
# body, but the lead summary that opens every EverOS release page is written
# by a human, who then clicks Publish.
github-release:
name: draft the GitHub Release
needs: publish
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # the whole tag list, to name the previous release
- name: Build the notes from CHANGELOG.md
env:
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
python3 - <<'PY'
import os, pathlib, re, subprocess, sys
tag = os.environ["TAG"]
version = tag[1:]
# vX.Y.Z is stable; anything with a PEP 440 suffix (rc1 / a1 / b1 /
# .dev1) is a pre-release and must never become /releases/latest.
prerelease = re.fullmatch(r"\d+\.\d+\.\d+", version) is None
src = pathlib.Path("CHANGELOG.md").read_text(encoding="utf-8")
match = re.search(
# Stops at the next version heading, at the link-reference block
# that closes the file, or at EOF.
rf"^## \[{re.escape(version)}\][^\n]*\n(.*?)(?=^## \[|^\[[^\]]+\]: |\Z)",
src,
re.S | re.M,
)
if match is None and not prerelease:
# A stable release with no CHANGELOG entry is a mistake in the
# release PR, not something to paper over with an empty page.
print(f"::error::CHANGELOG.md has no '## [{version}]' section")
sys.exit(1)
if match is None:
body = f"Pre-release build of `{version}`. See CHANGELOG.md on the tag.\n"
else:
# Demote the Keep-a-Changelog `### Added` group headings to `##`,
# matching how every earlier EverOS release page is structured.
body = re.sub(r"^### ", "## ", match.group(1).strip(), flags=re.M) + "\n"
# Every EverOS release page since 1.1.3 closes with an Upgrade
# section: the pip line, then whatever this release needs a reader to
# know before upgrading, then the compare link. The prose comes from
# an `### Upgrade` group in the CHANGELOG section — written in the
# release PR, where it gets reviewed — and is lifted out here so the
# boilerplate can be wrapped around it instead of colliding with it.
upgrade = ""
found = re.search(r"^## Upgrade\n(.*?)(?=^## |\Z)", body, re.S | re.M)
if found is not None:
upgrade = found.group(1).strip()
body = (body[: found.start()] + body[found.end() :]).rstrip() + "\n"
tags = subprocess.run(
["git", "tag", "--list", "v*", "--sort=-v:refname"],
capture_output=True, text=True, check=True,
).stdout.split()
previous = None
if tag in tags:
previous = next(
(t for t in tags[tags.index(tag) + 1:] if re.fullmatch(r"v\d+\.\d+\.\d+", t)),
None,
)
body += "\n## Upgrade\n\n```bash\npip install --upgrade everos # or: uv sync\n```\n"
if upgrade:
body += f"\n{upgrade}\n"
if previous is not None:
compare = f"https://github.com/{os.environ['REPO']}/compare/{previous}...{tag}"
body += f"\n**Full changelog:** [{previous}...{tag}]({compare})\n"
notes = pathlib.Path(os.environ["RUNNER_TEMP"]) / "notes.md"
notes.write_text(body, encoding="utf-8")
with open(os.environ["GITHUB_ENV"], "a", encoding="utf-8") as env:
env.write(f"RELEASE_VERSION={version}\n")
env.write(f"RELEASE_NOTES={notes}\n")
env.write(f"RELEASE_PRERELEASE={'true' if prerelease else 'false'}\n")
PY
- name: Create the draft release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
# Find an existing release through the LIST endpoint: the by-tag
# endpoint is published-only, so the Actions token cannot see a draft
# through it (cli/cli#3037) and a re-run would 422 on create. Replace
# a stale draft (deleting a draft keeps the git tag); never touch a
# release someone already published.
rel="$(gh api "repos/$REPO/releases?per_page=100" \
--jq "map(select(.tag_name == \"$TAG\"))[0] // {}")"
id="$(printf '%s' "$rel" | jq -r '.id // empty')"
draft="$(printf '%s' "$rel" | jq -r '.draft // false')"
if [ -n "$id" ] && [ "$draft" != "true" ]; then
echo "Release $TAG is already published; leaving it alone."
echo "Release $TAG already published: https://github.com/$REPO/releases/tag/$TAG" \
>> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [ -n "$id" ]; then
gh api -X DELETE "repos/$REPO/releases/$id"
fi
# Written as `if`, not `cond && assign`: the runner's shell is
# `bash -e`, where a false test at the head of an AND-OR list fails
# the whole step.
if [ "$RELEASE_PRERELEASE" = "true" ]; then
flags="--prerelease --latest=false"
else
flags="--latest"
fi
# $flags is deliberately unquoted — it carries two words.
draft_url="$(gh release create "$TAG" \
--draft $flags \
--title "EverOS $RELEASE_VERSION" \
--notes-file "$RELEASE_NOTES")"
# A draft lives at releases/tag/untagged-<hash> and keeps serving that
# stale page after publication, with no redirect to the real tag. Print
# both URLs so nobody shares the draft one by copying the address bar.
{
echo "### Release $TAG (draft)"
echo ""
echo "Write the lead summary in the draft, then click **Publish**."
echo ""
echo "- Draft (temporary, do not share): $draft_url"
echo "- Public URL once published: https://github.com/$REPO/releases/tag/$TAG"
} >> "$GITHUB_STEP_SUMMARY"