From 38bb28b3c1e06db07f54afc3fac3b4ebe75cb65e Mon Sep 17 00:00:00 2001 From: Teakowa Date: Mon, 17 Aug 2026 22:13:45 +0800 Subject: [PATCH] feat(release): add one-click release workflow Fixes #14 --- .github/workflows/release.yml | 404 ++++++++++++++++++++++++++++++ README.md | 9 + crates/workshop-rs-cli/Cargo.toml | 2 +- docs/release.md | 44 ++++ scripts/release.py | 158 ++++++++++++ scripts/test_release.py | 46 ++++ 6 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/release.md create mode 100644 scripts/release.py create mode 100644 scripts/test_release.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c6623b5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,404 @@ +name: Release + +on: + workflow_dispatch: + inputs: + bump: + description: SemVer bump for the next release + required: true + default: patch + type: choice + options: + - patch + - minor + - major + +permissions: + contents: read + +concurrency: + group: workshop-rs-release + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + +jobs: + plan: + name: Plan release + runs-on: ubuntu-latest + outputs: + version: ${{ steps.plan.outputs.version }} + mode: ${{ steps.plan.outputs.mode }} + ref: ${{ steps.plan.outputs.ref }} + commit: ${{ steps.plan.outputs.commit }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + - name: Detect a completed release + id: state + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + current="$(sed -nE 's/^version = "([0-9]+\.[0-9]+\.[0-9]+)"$/\1/p' Cargo.toml | head -n1)" + tag="v${current}" + complete=false + + if git rev-parse --verify --quiet "refs/tags/${tag}" >/dev/null; then + if gh release view "${tag}" --json isDraft,publishedAt,assets >/tmp/release.json 2>/dev/null \ + && jq -e '.isDraft == false and .publishedAt != null' /tmp/release.json >/dev/null \ + && curl --fail --silent --show-error -A "workshop-rs-release/${current}" "https://crates.io/api/v1/crates/workshop-rs/${current}" >/dev/null \ + && curl --fail --silent --show-error -A "workshop-rs-release/${current}" "https://crates.io/api/v1/crates/workshop-rs-cli/${current}" >/dev/null; then + complete=true + assets="$(jq -r '.assets[].name' /tmp/release.json)" + for asset in \ + "workshop-rs-cli-${current}-x86_64-unknown-linux-gnu.tar.gz" \ + "workshop-rs-cli-${current}-aarch64-unknown-linux-gnu.tar.gz" \ + "workshop-rs-cli-${current}-x86_64-apple-darwin.tar.gz" \ + "workshop-rs-cli-${current}-aarch64-apple-darwin.tar.gz" \ + "workshop-rs-cli-${current}-x86_64-pc-windows-msvc.zip" \ + SHA256SUMS.txt; do + grep --fixed-strings --line-regexp "${asset}" <<<"${assets}" >/dev/null || complete=false + done + fi + fi + + echo "release-complete=${complete}" >>"${GITHUB_OUTPUT}" + + - name: Compute release plan + id: plan + env: + BUMP: ${{ inputs.bump }} + RELEASE_COMPLETE: ${{ steps.state.outputs.release-complete }} + run: | + set -euo pipefail + args=(plan --bump "${BUMP}") + if [[ "${RELEASE_COMPLETE}" == "true" ]]; then + args+=(--release-complete) + fi + python3 scripts/release.py "${args[@]}" | tee /tmp/release-plan.env + while IFS='=' read -r key value; do + echo "${key}=${value}" >>"${GITHUB_OUTPUT}" + done &2 + exit 1 + + prepare: + name: Prepare and verify release + needs: plan + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + version: ${{ steps.release.outputs.version }} + tag: ${{ steps.release.outputs.tag }} + commit: ${{ steps.release.outputs.commit }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.plan.outputs.ref }} + fetch-depth: 0 + + - name: Install stable toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + components: rustfmt, clippy + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: release + cache-targets: true + cache-all-crates: false + cache-workspace-crates: false + cache-bin: false + save-if: false + + - name: Apply the deterministic version bump + if: needs.plan.outputs.mode == 'new' + env: + VERSION: ${{ needs.plan.outputs.version }} + run: python3 scripts/release.py bump --version "${VERSION}" + + - name: Refresh the lockfile after version mutation + if: needs.plan.outputs.mode == 'new' + run: cargo check --workspace + + - name: Run release gates + run: | + set -euo pipefail + python3 scripts/test_release.py + cargo fmt --all --check + cargo clippy --locked --workspace --all-targets -- -D warnings + cargo test --locked --workspace --all-targets + cargo run --locked -p workshop-rs --bin workshop-catalog-gen -- check + + - name: Verify publishable packages + run: | + set -euo pipefail + cargo package --locked --allow-dirty -p workshop-rs + cargo publish --locked --allow-dirty --dry-run -p workshop-rs --no-verify + + - name: Commit and tag the release revision + id: release + env: + MODE: ${{ needs.plan.outputs.mode }} + VERSION: ${{ needs.plan.outputs.version }} + PLAN_COMMIT: ${{ needs.plan.outputs.commit }} + shell: bash + run: | + set -euo pipefail + tag="v${VERSION}" + + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + + if [[ "${MODE}" == "new" ]]; then + git add Cargo.toml crates/workshop-rs-cli/Cargo.toml Cargo.lock + git diff --cached --check + git commit -m "chore(release): prepare ${tag}" + git push origin HEAD:main + elif [[ -n "${PLAN_COMMIT}" ]]; then + git checkout --detach "${PLAN_COMMIT}" + fi + + commit="$(git rev-parse HEAD)" + if git rev-parse --verify --quiet "refs/tags/${tag}" >/dev/null; then + tagged="$(git rev-list -n1 "${tag}")" + [[ "${tagged}" == "${commit}" ]] || { + echo "${tag} already points to ${tagged}, expected ${commit}" >&2 + exit 1 + } + else + git tag -a "${tag}" -m "Release ${tag}" "${commit}" + git push origin "${tag}" + fi + + echo "version=${VERSION}" >>"${GITHUB_OUTPUT}" + echo "tag=${tag}" >>"${GITHUB_OUTPUT}" + echo "commit=${commit}" >>"${GITHUB_OUTPUT}" + + build: + name: Build ${{ matrix.target }} + needs: prepare + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + runner: ubuntu-latest + - target: aarch64-unknown-linux-gnu + runner: ubuntu-latest + - target: x86_64-apple-darwin + runner: macos-13 + - target: aarch64-apple-darwin + runner: macos-14 + - target: x86_64-pc-windows-msvc + runner: windows-latest + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare.commit }} + + - name: Install Linux cross linker + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update + sudo apt-get install --yes gcc-aarch64-linux-gnu + + - name: Install Rust target + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + targets: ${{ matrix.target }} + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: release-${{ matrix.target }} + cache-targets: true + cache-all-crates: false + cache-workspace-crates: false + cache-bin: false + save-if: false + + - name: Build CLI + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: aarch64-linux-gnu-gcc + run: cargo build --locked --release -p workshop-rs-cli --target ${{ matrix.target }} + + - name: Package Unix artifact + if: runner.os != 'Windows' + env: + VERSION: ${{ needs.prepare.version }} + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + mkdir -p dist/workshop-rs-cli-${VERSION}-${TARGET} + cp "target/${TARGET}/release/workshop-rs-cli" "dist/workshop-rs-cli-${VERSION}-${TARGET}/" + tar -C dist -czf "workshop-rs-cli-${VERSION}-${TARGET}.tar.gz" "workshop-rs-cli-${VERSION}-${TARGET}" + + - name: Package Windows artifact + if: runner.os == 'Windows' + shell: pwsh + env: + VERSION: ${{ needs.prepare.version }} + TARGET: ${{ matrix.target }} + run: | + $directory = "workshop-rs-cli-$env:VERSION-$env:TARGET" + New-Item -ItemType Directory -Path "dist/$directory" | Out-Null + Copy-Item "target/$env:TARGET/release/workshop-rs-cli.exe" "dist/$directory/" + Compress-Archive -Path "dist/$directory" -DestinationPath "workshop-rs-cli-$env:VERSION-$env:TARGET.zip" + + - name: Upload platform artifact + uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.target }} + path: | + *.tar.gz + *.zip + if-no-files-found: error + + publish: + name: Publish crates + needs: prepare + runs-on: ubuntu-latest + environment: release + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare.commit }} + + - name: Install stable toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Publish library then CLI + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + VERSION: ${{ needs.prepare.version }} + shell: bash + run: | + set -euo pipefail + [[ -n "${CARGO_REGISTRY_TOKEN}" ]] || { + echo 'CARGO_REGISTRY_TOKEN is not configured in the release environment.' >&2 + exit 1 + } + + published() { + curl --fail --silent --show-error -A "workshop-rs-release/${VERSION}" "https://crates.io/api/v1/crates/$1/${VERSION}" >/dev/null + } + + publish_if_needed() { + if published "$1"; then + echo "$1 ${VERSION} is already published; resuming." + else + cargo publish --locked --token "${CARGO_REGISTRY_TOKEN}" -p "$1" + fi + } + + publish_if_needed workshop-rs + for attempt in $(seq 1 30); do + if published workshop-rs; then + break + fi + [[ "${attempt}" -eq 30 ]] && { + echo 'workshop-rs did not become visible in the registry.' >&2 + exit 1 + } + sleep 10 + done + + cargo package --locked --allow-dirty -p workshop-rs-cli + cargo publish --locked --allow-dirty --dry-run -p workshop-rs-cli --no-verify + publish_if_needed workshop-rs-cli + + release: + name: Assemble GitHub Release + needs: [prepare, build, publish] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.prepare.commit }} + fetch-depth: 0 + + - name: Install stable toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + + - name: Download platform artifacts + uses: actions/download-artifact@v4 + with: + pattern: release-* + path: dist + merge-multiple: true + + - name: Generate checksums and catalog notes + env: + VERSION: ${{ needs.prepare.version }} + COMMIT: ${{ needs.prepare.commit }} + run: | + set -euo pipefail + cd dist + sha256sum *.tar.gz *.zip > SHA256SUMS.txt + cd .. + { + echo '' + echo + echo '## Release identity' + echo + echo "- Version: `${VERSION}`" + echo "- Revision: `${COMMIT}`" + echo + echo '### Catalog identity' + echo + echo '```json' + cargo run --locked -p workshop-rs-cli -- version --json + echo '```' + } >/tmp/catalog-notes.md + + - name: Create or resume a draft release + env: + GH_TOKEN: ${{ github.token }} + TAG: v${{ needs.prepare.version }} + COMMIT: ${{ needs.prepare.commit }} + run: | + set -euo pipefail + if ! gh release view "${TAG}" >/dev/null 2>&1; then + gh release create "${TAG}" --target "${COMMIT}" --draft --generate-notes --title "workshop-rs ${TAG}" + fi + + body="$(gh release view "${TAG}" --json body --jq .body)" + if [[ "${body}" != *''* ]]; then + printf '%s\n\n%s\n' "${body}" "$(cat /tmp/catalog-notes.md)" >/tmp/release-notes.md + gh release edit "${TAG}" --notes-file /tmp/release-notes.md + fi + + - name: Upload artifacts and checksums + env: + GH_TOKEN: ${{ github.token }} + TAG: v${{ needs.prepare.version }} + run: gh release upload "${TAG}" dist/* --clobber + + - name: Publish the completed GitHub Release + env: + GH_TOKEN: ${{ github.token }} + TAG: v${{ needs.prepare.version }} + run: | + set -euo pipefail + if [[ "$(gh release view "${TAG}" --json isDraft --jq .isDraft)" == "true" ]]; then + gh release edit "${TAG}" --draft=false + fi diff --git a/README.md b/README.md index ef70ed3..b8434e2 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,15 @@ cargo run -p workshop-rs --bin workshop-catalog-gen -- check CI runs the same checks on stable and the pinned toolchain (1.85.0). +## Releases + +Maintainers publish a versioned library and CLI release from the manually +triggered `Release` GitHub Actions workflow. Select the `patch`, `minor`, or +`major` bump while dispatching from `main`; the workflow runs the quality and +catalog gates, publishes the library before the CLI, and attaches checksummed +cross-platform CLI artifacts. Repository setup and retry behavior are +documented in [docs/release.md](docs/release.md). + ## License MIT — see [LICENSE](LICENSE). Committed mapping data carries recorded diff --git a/crates/workshop-rs-cli/Cargo.toml b/crates/workshop-rs-cli/Cargo.toml index e65dc8b..4c92c18 100644 --- a/crates/workshop-rs-cli/Cargo.toml +++ b/crates/workshop-rs-cli/Cargo.toml @@ -11,7 +11,7 @@ repository.workspace = true workspace = true [dependencies] -workshop-rs = { path = "../workshop-rs" } +workshop-rs = { path = "../workshop-rs", version = "0.1.0" } serde_json = "1" [dev-dependencies] diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..cc1f9c3 --- /dev/null +++ b/docs/release.md @@ -0,0 +1,44 @@ +# Release automation + +`Release` is a manually triggered GitHub Actions workflow. Dispatch it from +the repository's `main` branch and select `patch`, `minor`, or `major`; the +normal patch path requires no version text entry. + +## Repository configuration + +The repository needs: + +1. An environment named `release` with required reviewers enabled for the + publication jobs. +2. An environment secret named `CARGO_REGISTRY_TOKEN`. The token must be + allowed to publish both `workshop-rs` and `workshop-rs-cli`; it is never + printed by the workflow. +3. A ruleset/environment exception allowing the release workflow's + `github-actions[bot]` to push the deterministic version commit to `main` + and the immutable `vX.Y.Z` tag. Normal development remains PR-only. + +The workflow grants `contents: read` by default and `contents: write` only to +the prepare and GitHub Release jobs. Registry publication is gated by the +protected `release` environment. A future crates.io trusted-publishing +configuration may replace the registry token, but it must preserve the same +environment approval and package-order guarantees. + +## Release identity and retries + +The workspace version, both package versions, Cargo.lock, the release commit, +the `vX.Y.Z` tag, registry packages, and GitHub Release all refer to one +revision. The library is published before the CLI because the CLI declares a +matching registry-compatible `workshop-rs` dependency while retaining its +local path for development. + +The workflow detects an incomplete version/tag/release and resumes it. It +skips crates already visible at the target version, reuses an existing +immutable tag, and resumes a draft GitHub Release. Once a tag has a published +GitHub Release and both crates are present, a new dispatch computes the next +selected bump instead of reusing the completed version. + +Platform artifacts are built for Linux x86_64/aarch64, macOS x86_64/aarch64, +and Windows x86_64. The final GitHub Release contains the five archives, +`SHA256SUMS.txt`, generated notes, the exact revision, and the CLI's +machine-readable catalog identity (catalog version, digest, and locale +coverage). diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..30a6d5f --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Deterministic version planning and manifest mutation for the release workflow.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +from dataclasses import dataclass +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +VERSION_RE = re.compile(r'^version\s*=\s*"(?P[^"\n]+)"\s*$', re.MULTILINE) +SEMVER_RE = re.compile(r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)$") + + +@dataclass(frozen=True, order=True) +class Version: + major: int + minor: int + patch: int + + @classmethod + def parse(cls, value: str) -> "Version": + match = SEMVER_RE.fullmatch(value) + if not match: + raise ValueError(f"unsupported SemVer: {value}") + return cls(*(int(match.group(name)) for name in ("major", "minor", "patch"))) + + def bump(self, kind: str) -> "Version": + if kind == "patch": + return Version(self.major, self.minor, self.patch + 1) + if kind == "minor": + return Version(self.major, self.minor + 1, 0) + if kind == "major": + return Version(self.major + 1, 0, 0) + raise ValueError(f"unsupported bump: {kind}") + + def __str__(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}" + + +@dataclass(frozen=True) +class ReleasePlan: + version: Version + mode: str + ref: str + commit: str + + +def workspace_version(root: Path = ROOT) -> Version: + text = (root / "Cargo.toml").read_text() + match = VERSION_RE.search(text) + if not match: + raise ValueError("workspace Cargo.toml has no package version") + return Version.parse(match.group("version")) + + +def valid_tags(tags: list[str]) -> list[Version]: + versions: list[Version] = [] + for tag in tags: + if tag.startswith("v"): + try: + versions.append(Version.parse(tag[1:])) + except ValueError: + continue + return sorted(set(versions)) + + +def plan_release( + current: Version, + tags: list[str], + bump: str, + release_complete: bool = False, + release_commit: str = "", +) -> ReleasePlan: + tag = f"v{current}" + versions = valid_tags(tags) + + if tag in tags and not release_complete: + return ReleasePlan(current, "resume", tag, release_commit) + + if tag in tags and release_complete: + next_version = current.bump(bump) + return ReleasePlan(next_version, "new", "HEAD", "") + + if versions and current > versions[-1]: + return ReleasePlan(current, "resume", release_commit or "HEAD", release_commit) + + return ReleasePlan(current.bump(bump), "new", "HEAD", "") + + +def git(*args: str) -> str: + return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() + + +def release_commit_for(version: Version) -> str: + subject = f"chore(release): prepare v{version}" + try: + return git("log", "--all", "--format=%H", "--fixed-strings", "--grep", subject, "-n", "1") + except subprocess.CalledProcessError: + return "" + + +def update_version(path: Path, version: Version) -> None: + text = path.read_text() + replacement = f'version = "{version}"' + updated, count = VERSION_RE.subn(replacement, text, count=1) + if count != 1: + raise ValueError(f"expected one workspace version in {path}") + path.write_text(updated) + + +def bump_manifests(version: Version, root: Path = ROOT) -> None: + update_version(root / "Cargo.toml", version) + cli_manifest = root / "crates/workshop-rs-cli/Cargo.toml" + text = cli_manifest.read_text() + dependency = re.compile( + r'(workshop-rs\s*=\s*\{\s*path\s*=\s*"\.\./workshop-rs",\s*version\s*=\s*")[^"]+("\s*\})' + ) + updated, count = dependency.subn(rf"\g<1>{version}\g<2>", text, count=1) + if count != 1: + raise ValueError("workshop-rs-cli has no publishable workshop-rs path/version dependency") + cli_manifest.write_text(updated) + + +def print_plan(plan: ReleasePlan) -> None: + print(f"version={plan.version}") + print(f"mode={plan.mode}") + ref = git("rev-parse", "HEAD") if plan.ref == "HEAD" else plan.ref + print(f"ref={ref}") + print(f"commit={plan.commit}") + + +def main() -> None: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + + plan_parser = subparsers.add_parser("plan") + plan_parser.add_argument("--bump", choices=("patch", "minor", "major"), default="patch") + plan_parser.add_argument("--release-complete", action="store_true") + + bump_parser = subparsers.add_parser("bump") + bump_parser.add_argument("--version", required=True) + + args = parser.parse_args() + if args.command == "plan": + current = workspace_version() + tags = git("tag", "--list", "v*").splitlines() + commit = release_commit_for(current) + print_plan(plan_release(current, tags, args.bump, args.release_complete, commit)) + elif args.command == "bump": + bump_manifests(Version.parse(args.version)) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_release.py b/scripts/test_release.py new file mode 100644 index 0000000..fff2420 --- /dev/null +++ b/scripts/test_release.py @@ -0,0 +1,46 @@ +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory + +from release import Version, bump_manifests, plan_release + + +class ReleasePlanningTests(unittest.TestCase): + def test_first_release_uses_selected_bump(self): + plan = plan_release(Version.parse("0.1.0"), [], "patch") + self.assertEqual((str(plan.version), plan.mode, plan.ref), ("0.1.1", "new", "HEAD")) + + def test_unfinished_tag_resumes_without_bumping(self): + plan = plan_release(Version.parse("0.1.1"), ["v0.1.1"], "minor") + self.assertEqual((str(plan.version), plan.mode, plan.ref), ("0.1.1", "resume", "v0.1.1")) + + def test_completed_tag_starts_the_next_release(self): + plan = plan_release(Version.parse("0.1.1"), ["v0.1.1"], "minor", release_complete=True) + self.assertEqual((str(plan.version), plan.mode, plan.ref), ("0.2.0", "new", "HEAD")) + + def test_unpublished_version_resumes_the_existing_release_commit(self): + plan = plan_release( + Version.parse("0.1.2"), ["v0.1.1"], "patch", release_commit="abc123" + ) + self.assertEqual((str(plan.version), plan.mode, plan.ref, plan.commit), ("0.1.2", "resume", "abc123", "abc123")) + + def test_bumps_reset_lower_components(self): + self.assertEqual(str(Version.parse("1.2.3").bump("minor")), "1.3.0") + self.assertEqual(str(Version.parse("1.2.3").bump("major")), "2.0.0") + + def test_bump_updates_workspace_and_publishable_cli_dependency(self): + with TemporaryDirectory() as directory: + root = Path(directory) + cli = root / "crates/workshop-rs-cli" + cli.mkdir(parents=True) + (root / "Cargo.toml").write_text('[workspace.package]\nversion = "0.1.0"\n') + (cli / "Cargo.toml").write_text( + '[dependencies]\nworkshop-rs = { path = "../workshop-rs", version = "0.1.0" }\n' + ) + bump_manifests(Version.parse("0.2.0"), root) + self.assertIn('version = "0.2.0"', (root / "Cargo.toml").read_text()) + self.assertIn('version = "0.2.0"', (cli / "Cargo.toml").read_text()) + + +if __name__ == "__main__": + unittest.main()