diff --git a/.github/scripts/next-release.py b/.github/scripts/next-release.py new file mode 100644 index 0000000..216f638 --- /dev/null +++ b/.github/scripts/next-release.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Work out the next version and the release notes from conventional commits. + +Run from the repository root. Prints GitHub-Actions-style `key=value` lines on +stdout (so the workflow can append them to $GITHUB_OUTPUT) and writes the notes +markdown to the path given by --notes. + +Rules, and why: + +- Squash merges carry the PR title as the commit subject, so a conventional PR + title is all this needs: `feat: …`, `fix(app): …`, `feat!:` / `BREAKING + CHANGE`. +- Pre-1.0, a breaking change and a feature both bump the minor: `0.x` has no + compatibility promise to break, and burning 1.0.0 on the second week is how + projects end up at 7.0.0 with no users. +- Fixes and anything else that changes behaviour bump the patch. +- Only chores, docs, CI or tests since the last tag: no release at all. A tag + every time somebody fixes a typo in the README is noise. +- No previous tag: the version in Cargo.toml is the first release, unbumped. +""" +import argparse +import re +import subprocess +import sys + +# type(scope)!: subject +CONVENTIONAL = re.compile(r"^(?P[a-z]+)(?:\([^)]*\))?(?P!)?:\s*(?P.+)$") +TYPES = { + "feat": "Features", + "fix": "Fixes", + "perf": "Performance", + "refactor": "Refactoring", + "docs": "Documentation", + "build": "Build", + "ci": "CI", + "test": "Tests", + "chore": "Chores", + "style": "Style", +} +RELEASING = {"feat", "fix", "perf"} + + +def git(*args: str) -> str: + return subprocess.run(["git", *args], capture_output=True, text=True, check=True).stdout.strip() + + +def last_tag() -> str | None: + tags = git("tag", "--list", "v*", "--sort=-v:refname").splitlines() + return tags[0] if tags else None + + +def commits(since: str | None) -> list[tuple[str, str]]: + """(subject, short sha) since the tag, oldest first, merge commits dropped.""" + rng = f"{since}..HEAD" if since else "HEAD" + out = git("log", rng, "--no-merges", "--format=%s%x00%h") + rows = [] + for line in reversed(out.splitlines()): + if not line.strip(): + continue + subject, _, sha = line.partition("\x00") + rows.append((subject.strip(), sha.strip())) + return rows + + +def parse(subject: str) -> tuple[str | None, bool, str]: + m = CONVENTIONAL.match(subject) + if not m: + # no type: treat it as an unreleased fix rather than dropping it + return None, False, subject + return m.group("type"), bool(m.group("breaking")), m.group("subject") + + +def bump(current: str, kinds: set[str], breaking: bool) -> str: + major, minor, patch = (int(part) for part in current.split(".")) + if major == 0: + if breaking or "feat" in kinds: + minor, patch = minor + 1, 0 + else: + patch += 1 + else: + if breaking: + major, minor, patch = major + 1, 0, 0 + elif "feat" in kinds: + minor, patch = minor + 1, 0 + else: + patch += 1 + return f"{major}.{minor}.{patch}" + + +def notes(rows: list[tuple[str, str]]) -> str: + groups: dict[str, list[str]] = {} + breaking: list[str] = [] + for subject, sha in rows: + kind, is_breaking, text = parse(subject) + heading = TYPES.get(kind or "", "Other changes") + line = f"- {text} ({sha})" + groups.setdefault(heading, []).append(line) + if is_breaking: + breaking.append(f"- {text} ({sha})") + + order = ["Features", "Fixes", "Performance", "Refactoring", "Documentation", "Build", "CI", "Tests", "Chores", "Other changes"] + out = [] + if breaking: + out.append("## Breaking changes") + out.append("") + out.extend(breaking) + out.append("") + for heading in order: + if heading in groups: + out.append(f"## {heading}") + out.append("") + out.extend(groups[heading]) + out.append("") + return "\n".join(out).strip() + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--current", required=True, help="version in Cargo.toml") + ap.add_argument("--notes", required=True, help="where to write the notes markdown") + ap.add_argument("--dry-run-range", help="use this git range instead of the last tag") + args = ap.parse_args() + + since = args.dry_run_range or last_tag() + rows = commits(since) + kinds: set[str] = set() + breaking = False + for subject, _ in rows: + kind, is_breaking, _text = parse(subject) + if kind: + kinds.add(kind) + breaking = breaking or is_breaking + + if since is None: + version, kind_of_bump = args.current, "initial" + elif not (kinds & RELEASING) and not breaking: + version, kind_of_bump = "", "none" + else: + version, kind_of_bump = bump(args.current, kinds, breaking), "release" + + text = notes(rows) if version else "" + with open(args.notes, "w") as fh: + fh.write(text + "\n" if text else "") + + print(f"version={version}") + print(f"bump={kind_of_bump}") + print(f"since={since or ''}") + print(f"commits={len(rows)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml deleted file mode 100644 index 2f43c05..0000000 --- a/.github/workflows/ci.yaml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI - -# Lives under app/ on purpose: in the monorepo this file is inert (GitHub only -# reads .github/workflows at the repo root), and in the standalone app repo it is -# the CI. The app travels with its own build, wherever its source lives. - -on: - push: - branches: - - main - pull_request: - workflow_dispatch: - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - check: - timeout-minutes: 25 - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: System libraries for egui/winit - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libxkbcommon-x11-dev \ - libx11-dev libxcursor-dev libxi-dev libxrandr-dev \ - libgl1-mesa-dev libwayland-dev pkg-config - - - name: Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - - name: Cache the build - uses: Swatinem/rust-cache@v2 - with: - workspaces: ". -> target" - - - name: Formatting - run: cargo fmt --check - - - name: Clippy - run: cargo clippy --all-targets -- -D warnings - - - name: Tests - run: cargo test - - - name: Build - run: cargo build --release diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..5694c50 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,160 @@ +name: Release + +# One flow, on every merge to main: check it, build it, tag it, publish it. +# +# - Pull requests run the checks only. +# - A push to main runs the checks, then works out the next version from the +# conventional-commit messages since the last tag, builds that version, and +# publishes a GitHub release with the binary attached and notes grouped by +# commit type. +# - Nothing to release (only chores, docs or CI since the last tag) means no tag: +# a release for every README typo is noise. +# +# The version bump is committed back with `[skip ci]`, which is what stops the +# push from starting this workflow again. + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +# never cancel a run that is half-way through publishing a release +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + check: + timeout-minutes: 25 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: System libraries for egui/winit + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libxkbcommon-x11-dev \ + libx11-dev libxcursor-dev libxi-dev libxrandr-dev \ + libgl1-mesa-dev libwayland-dev pkg-config + + - name: Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Cache the build + uses: Swatinem/rust-cache@v2 + with: + workspaces: ". -> target" + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Formatting + run: cargo fmt --check --all + + - name: Clippy + run: cargo clippy --all-targets --locked -- -D warnings + + - name: Tests + run: cargo test --locked + + release: + needs: check + if: github.event_name != 'pull_request' + timeout-minutes: 40 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # the version and the notes come from the history since the last tag + fetch-depth: 0 + + - name: System libraries for egui/winit + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libxkbcommon-x11-dev \ + libx11-dev libxcursor-dev libxi-dev libxrandr-dev \ + libgl1-mesa-dev libwayland-dev pkg-config + + - name: Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Cache the build + uses: Swatinem/rust-cache@v2 + with: + workspaces: ". -> target" + save-if: ${{ github.ref == 'refs/heads/main' }} + + - name: Work out the next version + id: next + run: | + current=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1) + echo "current version: $current" + python3 .github/scripts/next-release.py --current "$current" --notes notes.md | tee out.txt + echo "version=$(sed -n 's/^version=//p' out.txt)" >> "$GITHUB_OUTPUT" + echo "bump=$(sed -n 's/^bump=//p' out.txt)" >> "$GITHUB_OUTPUT" + echo "--- notes ---"; cat notes.md + + - name: Nothing to release + if: steps.next.outputs.version == '' + run: echo "no feat/fix/perf since the last tag, so no release this time" + + - name: Build + if: steps.next.outputs.version != '' + env: + VERSION: ${{ steps.next.outputs.version }} + run: | + # the tag is the source of truth for the version, so the binary agrees + sed -i "s/^version = \".*\"/version = \"$VERSION\"/" Cargo.toml + cargo build --release + + - name: Package + if: steps.next.outputs.version != '' + run: | + mkdir -p dist + cp target/release/kubetitan dist/kubetitan + cp LICENSE README.md dist/ + # -f names the archive inside dist/ explicitly: with `-C dist -f …` the + # archive lands wherever tar opens it, which is not worth guessing + tar -C dist -czf "dist/kubetitan-${RUNNER_OS,,}-x86_64.tar.gz" kubetitan LICENSE README.md + rm -f dist/kubetitan dist/LICENSE dist/README.md + ( cd dist && sha256sum *.tar.gz > SHA256SUMS ) + ls -l dist + + - name: Commit the version bump + if: steps.next.outputs.version != '' + env: + VERSION: ${{ steps.next.outputs.version }} + run: | + git config user.name "KubeTitan Release" + git config user.email "release@kubetitan.com" + git add Cargo.toml Cargo.lock + # [skip ci] keeps this push from starting the workflow again + git commit -m "chore(release): v$VERSION [skip ci]" || echo "nothing to commit" + git push + + - name: Publish + if: steps.next.outputs.version != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.next.outputs.version }} + run: | + tag="v$VERSION" + if gh release view "$tag" >/dev/null 2>&1; then + echo "release $tag already exists, uploading/replacing assets" + gh release upload "$tag" dist/*.tar.gz dist/SHA256SUMS --clobber + else + gh release create "$tag" dist/*.tar.gz dist/SHA256SUMS \ + --title "KubeTitan $tag" \ + --notes-file notes.md \ + --target "$(git rev-parse HEAD)" + fi + echo "released $tag" diff --git a/Cargo.lock b/Cargo.lock index fc719c7..da19ad3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2305,7 +2305,7 @@ dependencies = [ ] [[package]] -name = "kubetitan-mvp" +name = "kubetitan" version = "0.1.0" dependencies = [ "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 4b33ae4..28d762c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "kubetitan-mvp" +name = "kubetitan" version = "0.1.0" edition = "2024" description = "A native Kubernetes IDE" diff --git a/src/main.rs b/src/main.rs index 8363bc3..fab39aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -89,7 +89,7 @@ fn main() -> Result<()> { ..Default::default() }; eframe::run_native( - "KubeTitan-RS", + "KubeTitan", options, Box::new(move |cc| { theme::install(&cc.egui_ctx);