From cf4edddfb2d4311e6bd105321ddb1f3f0a3f4c30 Mon Sep 17 00:00:00 2001 From: Mihai Nueleanu Date: Mon, 26 Oct 2026 07:59:42 +0000 Subject: [PATCH 1/2] ci: stop building release binaries on every push The first run took over 25 minutes and the release build was most of it, still going when clippy (2.5 min) and tests (8.7 min) had finished. Two reasons, both by design and both wrong for a pull request: - `[profile.release]` sets `lto = "thin"` and `codegen-units = 1`. One codegen unit means the compiler cannot split a crate into parallel units, and thin LTO then analyses the whole crate graph at link time. That is the correct trade for a binary people download; it is 13 minutes for a smoke check. - Three compiles of the same dependencies in one job: clippy needs them for metadata, tests need a debug build, and release needs a third, entirely separate set of artifacts. Nothing is shared between profiles. So CI keeps fmt, clippy and tests, and the release build moves to where it belongs: a release workflow on a tag, which can afford to be slow. The debug build that replaces it costs seconds after `cargo test` and still catches a binary that does not link. Also `--locked` (no silent lockfile or registry drift mid-run) and `save-if: main` so pull requests read the cache instead of each writing one. --- .github/workflows/ci.yaml | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2f43c05..79de5d1 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,8 +1,14 @@ 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. +# Fast checks on everything, and nothing that ships. +# +# There is no release build here on purpose. `[profile.release]` sets +# `lto = "thin"` and `codegen-units = 1`, which is the right trade for a binary +# people download and the wrong one for a pull request: one codegen unit means no +# parallel codegen, and the linked binary alone took over 13 minutes on the first +# cold run, on top of the same dependencies being compiled a third time. A release +# build belongs in the release workflow, which runs on a tag and can afford to be +# slow. on: push: @@ -20,7 +26,7 @@ permissions: jobs: check: - timeout-minutes: 25 + timeout-minutes: 20 runs-on: ubuntu-latest steps: - name: Checkout @@ -43,15 +49,21 @@ jobs: uses: Swatinem/rust-cache@v2 with: workspaces: ". -> target" + # pull requests read the cache, they do not each write one + save-if: ${{ github.ref == 'refs/heads/main' }} - name: Formatting - run: cargo fmt --check + run: cargo fmt --check --all + # clippy and test are separate steps so a failure names the culprit; they + # share one dependency build anyway - name: Clippy - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy --all-targets --locked -- -D warnings - name: Tests - run: cargo test + run: cargo test --locked + # after `cargo test` this costs seconds, and it catches a binary that does + # not link - name: Build - run: cargo build --release + run: cargo build --locked From 8b29d1068fbe887842cc2ba6406ad13a62511b9e Mon Sep 17 00:00:00 2001 From: Mihai Nueleanu Date: Mon, 26 Oct 2026 08:09:27 +0000 Subject: [PATCH 2/2] release: one flow, from merge to published release Replaces the separate check-only workflow with a single pipeline: on every push to main the checks run, the next version is worked out from the conventional commit messages since the last tag, that version is built, and a GitHub release is created with the binary attached and notes grouped by commit type. Pull requests run the checks and nothing else. Decisions worth spelling out: - **Version from commit messages, not a bot PR.** Squash merging keeps the PR title as the commit subject, so `feat:`, `fix:` and `feat!:` are already the history. `.github/scripts/next-release.py` reads them and applies the rules: pre-1.0 a breaking change and a feature both bump the minor (0.x promises no compatibility, so 1.0.0 on week two is a trap), fixes bump the patch, and a merge that only contains chores or docs produces no release at all. - **The bump is committed with `[skip ci]`.** The tag has to agree with Cargo.toml, so the version is written in, committed and pushed; `[skip ci]` stops that push from starting the workflow again. - **The version is written before the build**, so the binary and the tag cannot disagree. - **Linux only for now.** macOS runners cost ten times the minutes on a private repository and Windows would need `config.rs` fixed first: it falls back to `$HOME/.config`, which is unset there, so state would land in the current directory. Adding a platform is one matrix line when those are sorted. - **`--locked` stays on the checks**, but the release build runs unlocked: the version bump rewrites the crate's own entry in Cargo.lock. Also renames the crate from `kubetitan-mvp` to `kubetitan`. The artifact is `kubetitan-linux-x86_64.tar.gz` containing a binary called `kubetitan`, and "mvp" in a released binary name is a promise nobody wants to keep. Verified by rehearsal, not by reading the YAML: nine version cases pass (`release-tests.sh`), the notes group correctly, and the packaging steps run against a stub binary producing the archive and its SHA256SUMS. --- .github/scripts/next-release.py | 153 ++++++++++++++++++++++++++++++ .github/workflows/ci.yaml | 69 -------------- .github/workflows/release.yaml | 160 ++++++++++++++++++++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- src/main.rs | 2 +- 6 files changed, 316 insertions(+), 72 deletions(-) create mode 100644 .github/scripts/next-release.py delete mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/release.yaml 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 79de5d1..0000000 --- a/.github/workflows/ci.yaml +++ /dev/null @@ -1,69 +0,0 @@ -name: CI - -# Fast checks on everything, and nothing that ships. -# -# There is no release build here on purpose. `[profile.release]` sets -# `lto = "thin"` and `codegen-units = 1`, which is the right trade for a binary -# people download and the wrong one for a pull request: one codegen unit means no -# parallel codegen, and the linked binary alone took over 13 minutes on the first -# cold run, on top of the same dependencies being compiled a third time. A release -# build belongs in the release workflow, which runs on a tag and can afford to be -# slow. - -on: - push: - branches: - - main - pull_request: - workflow_dispatch: - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: read - -jobs: - check: - timeout-minutes: 20 - 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" - # pull requests read the cache, they do not each write one - save-if: ${{ github.ref == 'refs/heads/main' }} - - - name: Formatting - run: cargo fmt --check --all - - # clippy and test are separate steps so a failure names the culprit; they - # share one dependency build anyway - - name: Clippy - run: cargo clippy --all-targets --locked -- -D warnings - - - name: Tests - run: cargo test --locked - - # after `cargo test` this costs seconds, and it catches a binary that does - # not link - - name: Build - run: cargo build --locked 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);