From 75007aec7a05ce967e1a6a51168f39f4cdbe9166 Mon Sep 17 00:00:00 2001 From: Justin Chung <20733699+justin13888@users.noreply.github.com> Date: Thu, 11 Jun 2026 02:06:00 -0400 Subject: [PATCH] ci: add prepare-release + tag-triggered publish workflows --- .github/workflows/prepare-release.yml | 66 ++++++++++++ .github/workflows/release.yml | 139 ++++++++++++++++++++++++++ CONTRIBUTING.md | 34 ++++++- justfile | 6 +- 4 files changed, 242 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/prepare-release.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 0000000..62c22bf --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,66 @@ +name: Prepare release + +# Manually triggered. Computes the next version (or uses an explicit one), writes it +# into every package via `just set-version`, regenerates the CHANGELOG, and opens a +# release PR. Merging that PR to master triggers release.yml (build + GitHub Release). +# +# Requires a repo secret `RELEASE_PAT` (a PAT with `contents` + `pull-requests` write): +# PRs opened with the default GITHUB_TOKEN do NOT trigger CI, so the release PR's checks +# ("all checks before release") would never run. The PAT makes the PR trigger CI. +on: + workflow_dispatch: + inputs: + version: + description: "Explicit version X.Y.Z (leave blank to compute from commits)" + required: false + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + prepare: + name: Open release PR + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_PAT }} + - name: Set up mise tools (just, convco) + uses: jdx/mise-action@v2 + - name: Resolve version + id: v + run: | + set -euo pipefail + version="${{ inputs.version }}" + if [ -z "$version" ]; then + version="$(convco version --bump)" + fi + echo "Resolved release version: $version" + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Apply version + regenerate changelog + run: | + set -euo pipefail + just set-version "${{ steps.v.outputs.version }}" + just changelog "${{ steps.v.outputs.version }}" + - name: Open release PR + env: + GH_TOKEN: ${{ secrets.RELEASE_PAT }} + run: | + set -euo pipefail + version="${{ steps.v.outputs.version }}" + branch="release/v${version}" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch -c "$branch" + git commit -am "chore(release): v${version}" + git push -u origin "$branch" --force-with-lease + gh pr create --base master --head "$branch" \ + --title "chore(release): v${version}" \ + --body "Automated release PR for **v${version}**. + + Review the CHANGELOG and version bumps, then **merge** to tag and publish the release (release.yml builds the \`capsule\` CLI binaries and creates the GitHub Release). + + Part of the release automation (#18)." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6e18aab --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,139 @@ +name: Release + +# Fires when a release commit (`chore(release): vX.Y.Z`, produced by prepare-release.yml +# and merged via its PR) lands on master. It builds the `capsule` CLI for each target and +# publishes a GitHub Release. `gh release create` also creates the tag, so the whole +# build+publish happens in this one run — no PAT or tag-push re-trigger needed. +on: + push: + branches: [master] + +permissions: + contents: write + +jobs: + detect: + name: Detect release + runs-on: ubuntu-latest + outputs: + release: ${{ steps.d.outputs.release }} + version: ${{ steps.d.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - id: d + run: | + set -euo pipefail + version="$(grep -m1 '^version = ' Cargo.toml | sed -E 's/^version = "(.*)"/\1/')" + echo "version=$version" >> "$GITHUB_OUTPUT" + if git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; then + echo "Tag v${version} already exists — nothing to release." + echo "release=false" >> "$GITHUB_OUTPUT"; exit 0 + fi + if git log --format='%s' '${{ github.event.before }}..${{ github.event.after }}' \ + | grep -qx "chore(release): v${version}"; then + echo "Release commit for v${version} detected." + echo "release=true" >> "$GITHUB_OUTPUT" + else + echo "No release commit in this push; skipping." + echo "release=false" >> "$GITHUB_OUTPUT" + fi + + build: + name: Build capsule (${{ matrix.target }}) + needs: detect + if: ${{ needs.detect.outputs.release == 'true' }} + runs-on: ${{ matrix.os }} + # Windows is best-effort (mirrors CI's Tier-2 treatment); a failure there doesn't + # block the release of the other binaries. + continue-on-error: ${{ matrix.optional }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + optional: false + - target: aarch64-apple-darwin + os: macos-14 + optional: false + - target: x86_64-apple-darwin + os: macos-14 + optional: false + - target: x86_64-pc-windows-msvc + os: windows-latest + optional: true + steps: + - uses: actions/checkout@v4 + - name: Install protoc + uses: arduino/setup-protoc@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Install Rust toolchain + run: | + rustup toolchain install + rustup target add ${{ matrix.target }} + - name: Cache cargo + uses: Swatinem/rust-cache@v2 + with: + key: release-${{ matrix.target }} + - name: Build release binary + run: cargo build -p capsule-cli --release --target ${{ matrix.target }} + - name: Package (unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + dist="capsule-v${{ needs.detect.outputs.version }}-${{ matrix.target }}" + mkdir -p "$dist" + cp "target/${{ matrix.target }}/release/capsule" "$dist/" + cp README.md LICENSE CHANGELOG.md "$dist/" 2>/dev/null || true + tar -czf "${dist}.tar.gz" "$dist" + echo "ASSET=${dist}.tar.gz" >> "$GITHUB_ENV" + - name: Package (windows) + if: runner.os == 'Windows' + shell: bash + run: | + set -euo pipefail + dist="capsule-v${{ needs.detect.outputs.version }}-${{ matrix.target }}" + mkdir -p "$dist" + cp "target/${{ matrix.target }}/release/capsule.exe" "$dist/" + 7z a "${dist}.zip" "$dist" >/dev/null + echo "ASSET=${dist}.zip" >> "$GITHUB_ENV" + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: capsule-${{ matrix.target }} + path: ${{ env.ASSET }} + if-no-files-found: error + + publish: + name: Publish GitHub Release + needs: [detect, build] + if: ${{ needs.detect.outputs.release == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up mise tools (convco) + uses: jdx/mise-action@v2 + - name: Download build artifacts + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - name: Generate release notes + run: convco changelog -m 1 --unreleased "${{ needs.detect.outputs.version }}" > NOTES.md + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + version="${{ needs.detect.outputs.version }}" + gh release create "v${version}" \ + --target "${{ github.sha }}" \ + --title "v${version}" \ + --notes-file NOTES.md \ + dist/* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8cd55a1..9c634bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,20 @@ First off, thank you for considering contributing! It’s people like you who ma 3. **Tests:** Ensure all existing tests pass and add new ones for any new features or bug fixes. 4. **Pull Request:** Open a PR against the `master` branch. Clearly describe *what* changed and *why*. You may use available PR templates as necessary. Keeping code changes within reasonable size will help us get it reviewed better and merged sooner. +## Local Setup + +Tooling versions are pinned with [mise](https://mise.jdx.dev). From the repo root: + +```sh +mise install # installs just, convco, lefthook (+ language tools) +just hooks-install # wires up the git hooks (lefthook) +``` + +The hooks run formatters/linters on commit and validate that every commit message is a +[Conventional Commit](https://www.conventionalcommits.org) (via `convco`) — so `convco` +must be on your `PATH` (hence `mise install`). The same check runs on `pre-push` and on +every PR in CI. + ## Baseline for Ownership & Provenance To maintain high security and legal standards, we require all merge commits to be signed at minimum. However, it is still strongly recommend that you sign all your Git commits if you don't already (takes 5 minutes to setup)! @@ -18,7 +32,7 @@ While we will still accept PRs with unsigned commits, to maintain transparent ow ## Coding Standards * Linting: If you have LSPs configured in your editor for the various languages in your repo, it should lint using the appropriate tool with correct versions by default. -* Commit messaging: Please use **Semantic Commits** (e.g., `feat:`, `fix:`, `docs:`, `test:`) to help us automate our changelog. +* Commit messaging: **Semantic Commits** (e.g., `feat:`, `fix:`, `docs:`, `test:`) are **required** — they drive version bumps and the changelog, and are enforced by `convco` on commit, push, and in CI. * Development Patterns: Refer to [Development](/capsule-docs/src/content/docs/development/). * AI usage: Refer to [AI.md](./AI.md). @@ -26,3 +40,21 @@ While we will still accept PRs with unsigned commits, to maintain transparent ow * [ ] I have signed the [Contributor License Agreement](CLA.md). * [ ] My code follows the project's style guidelines. + +## Releasing + +Releases are automated from Conventional Commits; one version is kept in sync across every +package (`just set-version` / the `xtask` crate). + +1. **Prepare** — run the **Prepare release** workflow (`workflow_dispatch`). It computes the + next version with `convco` (or takes an explicit one), writes it into every package, + regenerates `CHANGELOG.md`, and opens a `release/vX.Y.Z` PR. +2. **Review** — review/edit the CHANGELOG and version bumps on that PR. The full CI gate + runs on it ("all checks before release"). +3. **Merge** — merging the PR lands `chore(release): vX.Y.Z` on `master`, which triggers + **release.yml**: it builds the `capsule` CLI binaries (Linux/macOS, Windows best-effort) + and publishes a GitHub Release (the tag is created as part of this). + +**One-time setup:** add a repo secret `RELEASE_PAT` (a PAT with `contents` + `pull-requests` +write). It's used only to open the release PR — PRs opened with the default token don't +trigger CI, so without it the release PR's checks wouldn't run. diff --git a/justfile b/justfile index 9c08d9b..3b590fc 100644 --- a/justfile +++ b/justfile @@ -429,9 +429,11 @@ set-version version: cargo run -q -p xtask -- set-version {{ version }} # Regenerate CHANGELOG.md from Conventional Commits. Hand-edits land in the release PR. +# Pass the version when cutting a release so its section is titled (e.g. `just changelog 0.2.0`); +# the default leaves the newest section as "Unreleased". [group('release')] -changelog: - convco changelog > CHANGELOG.md +changelog title="Unreleased": + convco changelog --unreleased "{{ title }}" > CHANGELOG.md # ── Setup ────────────────────────────────────────────────────────────────────