diff --git a/.github/workflows/cut-release.yml b/.github/workflows/cut-release.yml new file mode 100644 index 0000000..2101ce0 --- /dev/null +++ b/.github/workflows/cut-release.yml @@ -0,0 +1,271 @@ +# Cut a release from main via workflow_dispatch. +# +# Steps: +# 1. Resolve the next version (patch/minor/major, or an explicit version) +# 2. Promote CHANGELOG.md "## Unreleased" → "## vX.Y.Z - " +# 3. Run tests +# 4. Open a release PR (main is PR-protected), merge it when allowed +# 5. Create and push an annotated tag +# 6. Publish via GoReleaser (GitHub release + Homebrew) and npm +# +# Why publish here instead of relying on the tag-triggered Release workflow? +# Tag pushes authenticated with GITHUB_TOKEN do not start other workflows, so +# this job runs the publish steps itself after tagging. +# +# Usage (GitHub UI): +# Actions → Cut Release → Run workflow +# +# Usage (CLI): +# gh workflow run "Cut Release" -f bump=patch +# gh workflow run "Cut Release" -f version=1.2.3 +# gh workflow run "Cut Release" -f bump=patch -f dry_run=true +# gh workflow run "Cut Release" -f version=1.2.3 -f skip_changelog=true +# +# If merge is blocked by required reviews, the workflow opens the PR and stops. +# After the PR is approved+merged, re-run with the same version and +# skip_changelog=true to tag and publish. + +name: Cut Release + +on: + workflow_dispatch: + inputs: + bump: + description: Semver bump when "version" is empty + type: choice + options: + - patch + - minor + - major + default: patch + version: + description: Explicit version (e.g. 1.2.3). Overrides bump. Optional. + required: false + type: string + default: "" + skip_changelog: + description: Skip CHANGELOG edits (tag + publish current main only) + type: boolean + default: false + dry_run: + description: Resolve version / validate only; no PR, tag, or publish + type: boolean + default: false + +# Prevent two concurrent cuts from racing on tags / CHANGELOG. +concurrency: + group: cut-release + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + id-token: write # npm OIDC trusted publishing + +jobs: + cut: + name: Cut ${{ inputs.version || inputs.bump }} + runs-on: ubuntu-latest + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.24" + + - name: Resolve version and promote changelog + id: meta + run: | + set -euo pipefail + args=(--repo . --github-output "$GITHUB_OUTPUT" --bump "${{ inputs.bump }}") + if [ -n "${{ inputs.version }}" ]; then + args+=(--version "${{ inputs.version }}") + fi + if [ "${{ inputs.skip_changelog }}" = "true" ]; then + args+=(--skip-changelog) + fi + if [ "${{ inputs.dry_run }}" = "true" ]; then + args+=(--dry-run) + fi + python3 scripts/cut_release.py "${args[@]}" + + - name: Run tests + run: go test ./... + + - name: Dry-run summary + if: ${{ inputs.dry_run }} + run: | + { + echo "## Cut Release (dry-run)" + echo "" + echo "- version: \`${{ steps.meta.outputs.version }}\`" + echo "- tag: \`${{ steps.meta.outputs.tag }}\`" + echo "- changelog_changed: \`${{ steps.meta.outputs.changelog_changed }}\`" + echo "" + echo "No PR, tag, or publish was performed." + } >> "$GITHUB_STEP_SUMMARY" + + - name: Configure git identity + if: ${{ !inputs.dry_run }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Open release PR (changelog) + id: pr + if: ${{ !inputs.dry_run && !inputs.skip_changelog && steps.meta.outputs.changelog_changed == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + VERSION="${{ steps.meta.outputs.version }}" + TAG="${{ steps.meta.outputs.tag }}" + BRANCH="release/${TAG}" + + git checkout -B "$BRANCH" + git add CHANGELOG.md + git commit -m "chore(release): ${TAG}" + git push -u origin "$BRANCH" --force + + if gh pr view "$BRANCH" --json number --jq .number >/dev/null 2>&1; then + PR_URL=$(gh pr view "$BRANCH" --json url --jq .url) + PR_NUMBER=$(gh pr view "$BRANCH" --json number --jq .number) + else + PR_URL=$(gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "chore(release): ${TAG}" \ + --body "$(cat <> "$GITHUB_OUTPUT" + echo "number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + echo "url=${PR_URL}" >> "$GITHUB_OUTPUT" + echo "Opened ${PR_URL}" + + - name: Merge release PR + id: merge + if: ${{ !inputs.dry_run && !inputs.skip_changelog && steps.meta.outputs.changelog_changed == 'true' }} + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + PR_NUMBER="${{ steps.pr.outputs.number }}" + + # Wait for required checks (e.g. build) before attempting merge. + echo "Waiting for PR checks…" + if ! gh pr checks "$PR_NUMBER" --watch --fail-fast; then + echo "::warning::PR checks did not pass; merge will likely fail until they are green" + fi + + # Prefer a regular merge; fall back to admin merge when the token can bypass. + if gh pr merge "$PR_NUMBER" --squash --delete-branch; then + echo "merged=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if gh pr merge "$PR_NUMBER" --squash --delete-branch --admin; then + echo "merged=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + { + echo "## Cut Release blocked on review" + echo "" + echo "Opened ${{ steps.pr.outputs.url }} but could not merge (branch protection requires review and/or checks)." + echo "" + echo "1. Approve and merge the PR" + echo "2. Re-run **Cut Release** with:" + echo " - version: \`${{ steps.meta.outputs.version }}\`" + echo " - skip_changelog: \`true\`" + } >> "$GITHUB_STEP_SUMMARY" + + echo "merged=false" >> "$GITHUB_OUTPUT" + echo "::error::Release PR needs approval before tagging. Merge ${{ steps.pr.outputs.url }} then re-run with version=${{ steps.meta.outputs.version }} and skip_changelog=true" + exit 1 + + # If the merge step fails (needs review), the job stops here. Re-run with + # skip_changelog=true after the PR is merged. + - name: Sync main + if: ${{ !inputs.dry_run }} + run: | + set -euo pipefail + git checkout main + git pull --ff-only origin main + + - name: Create and push tag + if: ${{ !inputs.dry_run }} + run: | + set -euo pipefail + TAG="${{ steps.meta.outputs.tag }}" + + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "::error::Tag ${TAG} already exists on main" + exit 1 + fi + if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then + echo "::error::Tag ${TAG} already exists on origin" + exit 1 + fi + + git tag -a "$TAG" -m "${TAG}" + # GITHUB_TOKEN tag pushes do not trigger other workflows (by design), + # so publish steps below run in this job instead of release.yml. + git push origin "$TAG" + echo "Created and pushed ${TAG} at $(git rev-parse HEAD)" + + - name: Run GoReleaser + if: ${{ !inputs.dry_run }} + uses: goreleaser/goreleaser-action@v6 + with: + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + + - name: Set up Node.js + if: ${{ !inputs.dry_run }} + uses: actions/setup-node@v4 + with: + node-version: "24" + + - name: Use latest npm + if: ${{ !inputs.dry_run }} + run: npm install -g npm@latest + + - name: Publish to npm (OIDC trusted publishing) + if: ${{ !inputs.dry_run }} + run: | + set -euo pipefail + cd npm + npm version "${{ steps.meta.outputs.version }}" --no-git-tag-version + npm publish --provenance --access public + + - name: Release summary + if: ${{ !inputs.dry_run }} + run: | + { + echo "## Cut Release complete" + echo "" + echo "- version: \`${{ steps.meta.outputs.version }}\`" + echo "- tag: \`${{ steps.meta.outputs.tag }}\`" + echo "- release: https://github.com/${{ github.repository }}/releases/tag/${{ steps.meta.outputs.tag }}" + echo "- npm: \`@xdevplatform/xurl@${{ steps.meta.outputs.version }}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index c23529f..260e608 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ xurl .xurl_test .DS_Store +__pycache__/ +*.py[cod] # Added by goreleaser init: dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bd6fd2..38f29a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All user-visible bugs and enhancements should be recorded here. ## Unreleased +### Added + +- [2026-07-15] **Cut Release** GitHub Actions workflow (`workflow_dispatch`) to promote `CHANGELOG.md`, open/merge a release PR, tag, and publish (GitHub release, Homebrew, npm) in one run. + ### Fixed - [2026-07-15] `xurl auth oauth2` no longer always warns that the "default" app has no client credentials when `--app` is omitted. The check used `GetApp("")` (empty-key map lookup, always nil) instead of the real default app, so it false-alarmed even when the active default (e.g. `app-2`) had credentials. The warning now resolves `default_app` and names that app correctly. diff --git a/scripts/cut_release.py b/scripts/cut_release.py new file mode 100755 index 0000000..bc6bf2d --- /dev/null +++ b/scripts/cut_release.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Cut an xurl release: resolve the next version and promote CHANGELOG.md. + +Used by .github/workflows/cut-release.yml (and runnable locally). +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import re +import subprocess +import sys +from pathlib import Path + +SEMVER_TAG_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)$") +# Do not let \s consume the trailing newline — that would insert an extra blank +# line when we rebuild the Unreleased section. +UNRELEASED_HEADING = re.compile(r"^## Unreleased[ \t]*$", re.MULTILINE) +VERSION_HEADING = re.compile(r"^## v(\d+\.\d+\.\d+)(?:\s|$)", re.MULTILINE) + + +def parse_semver(tag: str) -> tuple[int, int, int]: + m = SEMVER_TAG_RE.match(tag.strip()) + if not m: + raise ValueError(f"not a semver tag: {tag!r}") + return int(m.group(1)), int(m.group(2)), int(m.group(3)) + + +def format_semver(parts: tuple[int, int, int]) -> str: + return f"{parts[0]}.{parts[1]}.{parts[2]}" + + +def bump_semver(version: str, bump: str) -> str: + major, minor, patch = parse_semver(version) + if bump == "major": + return format_semver((major + 1, 0, 0)) + if bump == "minor": + return format_semver((major, minor + 1, 0)) + if bump == "patch": + return format_semver((major, minor, patch + 1)) + raise ValueError(f"unknown bump: {bump!r}") + + +def git_tags(repo: Path) -> list[str]: + result = subprocess.run( + ["git", "tag", "--list", "v*"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def latest_semver_tag(tags: list[str]) -> str | None: + best: tuple[int, int, int] | None = None + best_raw: str | None = None + for tag in tags: + try: + parts = parse_semver(tag) + except ValueError: + continue + if best is None or parts > best: + best = parts + best_raw = tag if tag.startswith("v") else f"v{tag}" + return best_raw + + +def resolve_version( + *, + explicit: str | None, + bump: str, + tags: list[str], +) -> str: + if explicit: + version = explicit.strip().removeprefix("v") + parse_semver(version) # validate + return version + + latest = latest_semver_tag(tags) + if latest is None: + if bump == "major": + return "1.0.0" + if bump == "minor": + return "0.1.0" + return "0.0.1" + return bump_semver(latest, bump) + + +def unreleased_body(changelog: str) -> str: + match = UNRELEASED_HEADING.search(changelog) + if not match: + raise ValueError("CHANGELOG.md has no '## Unreleased' heading") + + start = match.end() + rest = changelog[start:] + next_heading = re.search(r"^## ", rest, re.MULTILINE) + body = rest[: next_heading.start()] if next_heading else rest + return body.strip("\n") + + +def has_releasable_changes(body: str) -> bool: + """True when Unreleased contains at least one bullet entry.""" + for line in body.splitlines(): + stripped = line.strip() + if stripped.startswith("- ") or stripped.startswith("* "): + return True + return False + + +def promote_changelog(changelog: str, version: str, date: str) -> str: + """Move the Unreleased body under ## vX.Y.Z - date; leave Unreleased empty.""" + parse_semver(version) + if VERSION_HEADING.search(changelog): + for m in VERSION_HEADING.finditer(changelog): + if m.group(1) == version: + raise ValueError(f"CHANGELOG.md already has a section for v{version}") + + body = unreleased_body(changelog) + if not has_releasable_changes(body): + raise ValueError( + "CHANGELOG.md '## Unreleased' has no bullet entries to ship; " + "add notes or pass --skip-changelog" + ) + + match = UNRELEASED_HEADING.search(changelog) + assert match is not None + start = match.end() + rest = changelog[start:] + next_heading = re.search(r"^## ", rest, re.MULTILINE) + end = start + next_heading.start() if next_heading else len(changelog) + + # ## Unreleased + # + # ## vX.Y.Z - date + # + # + # ## previous... + shipped = body.strip() + replacement = f"\n\n## v{version} - {date}\n\n{shipped}\n\n" + tail = changelog[end:].lstrip("\n") + return changelog[: match.end()] + replacement + tail + + +def write_github_output(path: Path, values: dict[str, str]) -> None: + with path.open("a", encoding="utf-8") as fh: + for key, value in values.items(): + fh.write(f"{key}={value}\n") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--repo", + type=Path, + default=Path.cwd(), + help="Repository root (default: cwd)", + ) + parser.add_argument( + "--changelog", + type=Path, + default=None, + help="Path to CHANGELOG.md (default: /CHANGELOG.md)", + ) + parser.add_argument( + "--bump", + choices=("patch", "minor", "major"), + default="patch", + help="Semver bump when --version is not set", + ) + parser.add_argument( + "--version", + default="", + help="Explicit version (e.g. 1.2.3). Overrides --bump.", + ) + parser.add_argument( + "--date", + default="", + help="Release date YYYY-MM-DD (default: today UTC)", + ) + parser.add_argument( + "--skip-changelog", + action="store_true", + help="Only resolve the version; do not edit CHANGELOG.md", + ) + parser.add_argument( + "--github-output", + type=Path, + default=None, + help="Append version= / tag= lines for GitHub Actions", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the resolved version / new changelog; do not write files", + ) + args = parser.parse_args(argv) + + repo = args.repo.resolve() + changelog_path = (args.changelog or (repo / "CHANGELOG.md")).resolve() + date = args.date or dt.datetime.now(dt.timezone.utc).date().isoformat() + + tags = git_tags(repo) if (repo / ".git").exists() or (repo / ".git").is_file() else [] + # When .git is missing (rare), still allow explicit --version. + if not tags and not args.version: + # Still try git_tags — it works when cwd is a work tree. + try: + tags = git_tags(repo) + except subprocess.CalledProcessError as exc: + print(f"error: failed to list git tags: {exc}", file=sys.stderr) + return 1 + + try: + version = resolve_version( + explicit=args.version or None, + bump=args.bump, + tags=tags, + ) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + tag = f"v{version}" + print(f"version={version}") + print(f"tag={tag}") + + changelog_changed = "false" + if not args.skip_changelog: + text = changelog_path.read_text(encoding="utf-8") + try: + updated = promote_changelog(text, version, date) + except ValueError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + if updated != text: + changelog_changed = "true" + if args.dry_run: + print("--- CHANGELOG.md (dry-run) ---") + print(updated) + else: + changelog_path.write_text(updated, encoding="utf-8") + print(f"updated {changelog_path}") + else: + print("CHANGELOG.md already up to date") + + if args.github_output: + write_github_output( + args.github_output, + { + "version": version, + "tag": tag, + "changelog_changed": changelog_changed, + }, + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/cut_release_test.py b/scripts/cut_release_test.py new file mode 100644 index 0000000..874be87 --- /dev/null +++ b/scripts/cut_release_test.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Unit tests for scripts/cut_release.py.""" + +from __future__ import annotations + +import unittest + +from cut_release import ( + bump_semver, + has_releasable_changes, + latest_semver_tag, + promote_changelog, + resolve_version, + unreleased_body, +) + + +SAMPLE = """# Changelog + +All user-visible bugs and enhancements should be recorded here. + +## Unreleased + +### Fixed + +- [2026-07-15] example fix + +## v1.2.2 - 2026-06-29 + +### Fixed + +- [2026-06-29] older fix +""" + + +class CutReleaseTest(unittest.TestCase): + def test_bump_semver(self) -> None: + self.assertEqual(bump_semver("1.2.2", "patch"), "1.2.3") + self.assertEqual(bump_semver("1.2.2", "minor"), "1.3.0") + self.assertEqual(bump_semver("1.2.2", "major"), "2.0.0") + self.assertEqual(bump_semver("v1.2.2", "patch"), "1.2.3") + + def test_latest_semver_tag(self) -> None: + self.assertEqual( + latest_semver_tag(["v1.2.0", "v1.2.2", "v1.1.9", "not-a-tag"]), + "v1.2.2", + ) + self.assertIsNone(latest_semver_tag(["foo", "bar"])) + + def test_resolve_version(self) -> None: + self.assertEqual( + resolve_version(explicit="1.2.3", bump="patch", tags=["v1.2.2"]), + "1.2.3", + ) + self.assertEqual( + resolve_version(explicit="v1.2.3", bump="patch", tags=["v1.2.2"]), + "1.2.3", + ) + self.assertEqual( + resolve_version(explicit=None, bump="patch", tags=["v1.2.2"]), + "1.2.3", + ) + self.assertEqual( + resolve_version(explicit=None, bump="patch", tags=[]), + "0.0.1", + ) + + def test_unreleased_body(self) -> None: + body = unreleased_body(SAMPLE) + self.assertIn("example fix", body) + self.assertNotIn("v1.2.2", body) + + def test_has_releasable_changes(self) -> None: + self.assertTrue(has_releasable_changes("### Fixed\n\n- fix\n")) + self.assertFalse(has_releasable_changes("\n\n")) + self.assertFalse(has_releasable_changes("### Fixed\n\n")) + + def test_promote_changelog(self) -> None: + out = promote_changelog(SAMPLE, "1.2.3", "2026-07-15") + self.assertIn("## Unreleased\n\n## v1.2.3 - 2026-07-15\n\n### Fixed\n", out) + self.assertIn("- [2026-07-15] example fix", out) + self.assertIn("## v1.2.2 - 2026-06-29", out) + # Unreleased should no longer contain the shipped bullet. + body = unreleased_body(out) + self.assertFalse(has_releasable_changes(body)) + + def test_promote_rejects_duplicate_version(self) -> None: + with self.assertRaises(ValueError): + promote_changelog(SAMPLE, "1.2.2", "2026-07-15") + + def test_promote_rejects_empty_unreleased(self) -> None: + empty = SAMPLE.replace( + "## Unreleased\n\n### Fixed\n\n- [2026-07-15] example fix\n", + "## Unreleased\n\n", + ) + with self.assertRaises(ValueError): + promote_changelog(empty, "1.2.3", "2026-07-15") + + +if __name__ == "__main__": + unittest.main()