diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fcf301d..369cf50 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: Install git-cliff env: @@ -53,7 +54,17 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_VERSION: ${{ steps.version.outputs.release_version }} run: | - git-cliff --config cliff.toml --tag "${RELEASE_VERSION}" --output CHANGELOG.md + previous_tag="$(python3 scripts/release.py latest-aio-tag)" + if [[ -n "${previous_tag}" ]]; then + git-cliff --config cliff.toml --tag "${RELEASE_VERSION}" "${previous_tag}..HEAD" --output CHANGELOG.md + else + git-cliff --config cliff.toml --tag "${RELEASE_VERSION}" --output CHANGELOG.md + fi + parsed_version="$(python3 scripts/release.py latest-changelog-version)" + if [[ "${parsed_version}" != "${RELEASE_VERSION}" ]]; then + echo "Generated changelog top section ${parsed_version} does not match ${RELEASE_VERSION}" >&2 + exit 1 + fi - name: Create release PR if: steps.changes.outputs.has_changes == 'true' @@ -80,18 +91,21 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: Determine release version id: version run: | release_version="$(python3 scripts/release.py latest-changelog-version)" echo "release_version=${release_version}" >> "${GITHUB_OUTPUT}" - release_commit="$(git log --format='%H%x09%s' HEAD | awk -F '\t' -v title="chore(release): ${release_version}" '$2 == title {print $1; exit}')" - if [[ -z "${release_commit}" ]]; then - echo "Unable to find a merged release commit for ${release_version} on main." >&2 + release_commit="$(python3 scripts/release.py find-release-commit "${release_version}")" + echo "release_commit=${release_commit}" >> "${GITHUB_OUTPUT}" + echo "Matched release commit ${release_commit} for ${release_version}" + changelog_version="$(python3 scripts/release.py latest-changelog-version)" + if [[ "${changelog_version}" != "${release_version}" ]]; then + echo "CHANGELOG top entry ${changelog_version} does not match ${release_version}" >&2 exit 1 fi - echo "release_commit=${release_commit}" >> "${GITHUB_OUTPUT}" - name: Extract release notes id: notes @@ -106,25 +120,40 @@ jobs: - name: Create Git tag if missing env: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_VERSION: ${{ steps.version.outputs.release_version }} RELEASE_COMMIT: ${{ steps.version.outputs.release_commit }} + GITHUB_REPOSITORY: ${{ github.repository }} run: | if git rev-parse "${RELEASE_VERSION}" >/dev/null 2>&1; then + echo "Tag ${RELEASE_VERSION} already exists; skipping." exit 0 fi + + auth_token="${RELEASE_TOKEN:-${GITHUB_TOKEN}}" + if [[ -z "${auth_token}" ]]; then + echo "No token available for tag creation." >&2 + exit 1 + fi git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --local --unset-all http.https://github.com/.extraheader || true git tag "${RELEASE_VERSION}" "${RELEASE_COMMIT}" - git push origin "${RELEASE_VERSION}" + git push "https://x-access-token:${auth_token}@github.com/${GITHUB_REPOSITORY}.git" "${RELEASE_VERSION}" - name: Publish GitHub release env: + RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} RELEASE_VERSION: ${{ steps.version.outputs.release_version }} RELEASE_NOTES: ${{ steps.notes.outputs.release_notes }} RELEASE_COMMIT: ${{ steps.version.outputs.release_commit }} run: | + export GITHUB_TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN}}" + if gh release view "${RELEASE_VERSION}" >/dev/null 2>&1; then + echo "GitHub release ${RELEASE_VERSION} already exists; skipping." exit 0 fi notes_file="$(mktemp)" diff --git a/scripts/release.py b/scripts/release.py index c0ba3a4..7350096 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -11,6 +11,7 @@ DEFAULT_CHANGELOG = ROOT / "CHANGELOG.md" DEFAULT_DOCKERFILE = ROOT / "Dockerfile" DEFAULT_UPSTREAM = ROOT / "upstream.toml" +AIO_TAG_PATTERN = "*-aio.*" def load_upstream_version_key(path: pathlib.Path) -> str: @@ -46,6 +47,19 @@ def git_tags() -> list[str]: return [line.strip() for line in output.splitlines() if line.strip()] +def latest_aio_release_tag() -> str | None: + completed = subprocess.run( + ["git", "describe", "--tags", "--abbrev=0", "--match", AIO_TAG_PATTERN, "HEAD"], + cwd=ROOT, + text=True, + capture_output=True, + ) + if completed.returncode != 0: + return None + tag = completed.stdout.strip() + return tag or None + + def latest_release_tag(dockerfile: pathlib.Path, upstream: pathlib.Path) -> str | None: upstream_version = read_upstream_version(dockerfile, upstream) pattern = re.compile(rf"^{re.escape(upstream_version)}-aio\.(\d+)$") @@ -61,7 +75,7 @@ def latest_release_tag(dockerfile: pathlib.Path, upstream: pathlib.Path) -> str def has_unreleased_changes(dockerfile: pathlib.Path, upstream: pathlib.Path) -> bool: - latest_tag = latest_release_tag(dockerfile, upstream) + latest_tag = latest_aio_release_tag() if latest_tag is None: return True output = subprocess.check_output(["git", "log", "--format=%s", f"{latest_tag}..HEAD"], cwd=ROOT, text=True) @@ -111,6 +125,24 @@ def extract_release_notes(version: str, changelog: pathlib.Path) -> str: return notes +def find_release_commit(version: str) -> str: + exact = f"chore(release): {version}" + with_suffix = re.compile(rf"^{re.escape(exact)} \(#\d+\)$") + + output = subprocess.check_output(["git", "log", "--format=%H\t%s", "HEAD"], cwd=ROOT, text=True) + for line in output.splitlines(): + if not line.strip(): + continue + sha, subject = line.split("\t", 1) + if subject == exact or with_suffix.match(subject): + return sha + + raise SystemExit( + f"Unable to find a merged release commit for {version} on main. " + f"Expected '{exact}' or '{exact} (#123)'." + ) + + def main() -> None: parser = argparse.ArgumentParser(description="Release helpers for AIO repos.") subparsers = parser.add_subparsers(dest="command", required=True) @@ -123,11 +155,14 @@ def main() -> None: changes_parser = subparsers.add_parser("has-unreleased-changes") changes_parser.add_argument("--dockerfile", type=pathlib.Path, default=DEFAULT_DOCKERFILE) changes_parser.add_argument("--upstream-config", type=pathlib.Path, default=DEFAULT_UPSTREAM) + subparsers.add_parser("latest-aio-tag") latest_parser = subparsers.add_parser("latest-changelog-version") latest_parser.add_argument("--changelog", type=pathlib.Path, default=DEFAULT_CHANGELOG) notes_parser = subparsers.add_parser("extract-release-notes") notes_parser.add_argument("version") notes_parser.add_argument("--changelog", type=pathlib.Path, default=DEFAULT_CHANGELOG) + commit_parser = subparsers.add_parser("find-release-commit") + commit_parser.add_argument("version") args = parser.parse_args() if args.command == "upstream-version": print(read_upstream_version(args.dockerfile, args.upstream_config)) @@ -135,8 +170,14 @@ def main() -> None: print(next_release_version(args.dockerfile, args.upstream_config)) elif args.command == "has-unreleased-changes": print("true" if has_unreleased_changes(args.dockerfile, args.upstream_config) else "false") + elif args.command == "latest-aio-tag": + latest_tag = latest_aio_release_tag() + if latest_tag: + print(latest_tag) elif args.command == "latest-changelog-version": print(latest_changelog_version(args.changelog)) + elif args.command == "find-release-commit": + print(find_release_commit(args.version)) else: print(extract_release_notes(args.version, args.changelog))