Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions .github/scripts/next-release.py
Original file line number Diff line number Diff line change
@@ -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<type>[a-z]+)(?:\([^)]*\))?(?P<breaking>!)?:\s*(?P<subject>.+)$")
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())
57 changes: 0 additions & 57 deletions .github/workflows/ci.yaml

This file was deleted.

160 changes: 160 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[package]
name = "kubetitan-mvp"
name = "kubetitan"
version = "0.1.0"
edition = "2024"
description = "A native Kubernetes IDE"
Expand Down
2 changes: 1 addition & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down