diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml new file mode 100644 index 0000000..6e8c63c --- /dev/null +++ b/.github/workflows/release-assets.yml @@ -0,0 +1,153 @@ +name: Release Assets + +run-name: Release Assets (${{ inputs.tag }}) + +on: + workflow_dispatch: + inputs: + tag: + description: "Existing release tag to build and upload assets for" + required: true + type: string + source_run_id: + description: "Optional parent release workflow run id" + required: false + type: string + source_run_url: + description: "Optional parent release workflow run URL" + required: false + type: string + +permissions: + contents: write + +env: + CARGO_TERM_COLOR: always + +jobs: + build-release-assets: + name: ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - name: Linux arm64 + runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + archive_ext: tar.gz + binary_name: commitbot + install_cmd: "" + linker: "" + - name: Linux x86_64 + runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + archive_ext: tar.gz + binary_name: commitbot + install_cmd: "" + linker: "" + - name: Amazon Linux / CentOS / RHEL arm64 + runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + archive_ext: tar.gz + binary_name: commitbot + install_cmd: sudo apt-get update && sudo apt-get install -y musl-tools + linker: musl-gcc + - name: Amazon Linux / CentOS / RHEL x86_64 + runner: ubuntu-24.04 + target: x86_64-unknown-linux-musl + archive_ext: tar.gz + binary_name: commitbot + install_cmd: sudo apt-get update && sudo apt-get install -y musl-tools + linker: musl-gcc + - name: Windows x86_64 + runner: ubuntu-24.04 + target: x86_64-pc-windows-gnu + archive_ext: zip + binary_name: commitbot.exe + install_cmd: sudo apt-get update && sudo apt-get install -y mingw-w64 + linker: "" + + steps: + - name: Resolve release tag + id: meta + shell: bash + run: | + set -euo pipefail + + if [ -n "${{ inputs.tag || '' }}" ]; then + echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" + else + echo "No release tag was provided." >&2 + exit 1 + fi + + - name: Check out tagged source + uses: actions/checkout@v4 + with: + ref: ${{ steps.meta.outputs.tag }} + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install target-specific build tooling + if: matrix.install_cmd != '' + shell: bash + run: ${{ matrix.install_cmd }} + + - name: Run tests + if: matrix.target == 'x86_64-unknown-linux-gnu' + run: cargo test --locked --all-features + + - name: Build release binary + shell: bash + env: + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER: ${{ matrix.target == 'aarch64-unknown-linux-musl' && matrix.linker || '' }} + CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER: ${{ matrix.target == 'x86_64-unknown-linux-musl' && matrix.linker || '' }} + run: cargo build --locked --release --target ${{ matrix.target }} + + - name: Package tar.gz archive + if: matrix.archive_ext == 'tar.gz' + shell: bash + run: | + set -euo pipefail + asset="commitbot-${{ steps.meta.outputs.tag }}-${{ matrix.target }}.tar.gz" + tar -C "target/${{ matrix.target }}/release" -czf "$asset" "${{ matrix.binary_name }}" + echo "ASSET_PATH=$asset" >> "$GITHUB_ENV" + + - name: Package zip archive + if: matrix.archive_ext == 'zip' + shell: bash + run: | + set -euo pipefail + asset="commitbot-${{ steps.meta.outputs.tag }}-${{ matrix.target }}.zip" + cd "target/${{ matrix.target }}/release" + zip "../../../${asset}" "${{ matrix.binary_name }}" + cd - + echo "ASSET_PATH=$asset" >> "$GITHUB_ENV" + + - name: Upload asset to GitHub release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ steps.meta.outputs.tag }}" "$ASSET_PATH" --clobber + + - name: Publish release if all assets are present + env: + GH_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + shell: bash + run: bash devops/publish-if-complete.sh "${{ steps.meta.outputs.tag }}" + + - name: Add run summary + shell: bash + run: | + { + echo "## Release Assets" + echo + echo "- Tag: \`${{ steps.meta.outputs.tag }}\`" + echo "- Target: \`${{ matrix.target }}\`" + if [[ -n "${{ inputs.source_run_url }}" ]]; then + echo "- Triggered by: ${{ inputs.source_run_url }}" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ff8d75a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,294 @@ +name: Release + +on: + workflow_dispatch: + inputs: + target_branch: + description: Release branch + required: true + type: choice + options: + - develop + - main + version: + description: Release version (X.Y.Z) + required: true + type: string + pull_request: + types: [closed] + branches: + - develop + - main + +permissions: + actions: write + contents: write + pull-requests: read + +concurrency: + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.target_branch || github.event.pull_request.base.ref }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + MAIN_BASE_VERSION: ${{ vars.MAIN_BASE_VERSION }} + DEVELOP_BASE_VERSION: ${{ vars.DEVELOP_BASE_VERSION }} + +jobs: + plan: + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true + runs-on: ubuntu-latest + outputs: + should_release: ${{ steps.plan.outputs.should_release }} + reason: ${{ steps.plan.outputs.reason }} + bump: ${{ steps.plan.outputs.bump }} + base_version: ${{ steps.plan.outputs.base_version }} + previous_version: ${{ steps.plan.outputs.previous_version }} + version: ${{ steps.plan.outputs.version }} + target_branch: ${{ steps.plan.outputs.target_branch }} + source_branch: ${{ steps.plan.outputs.source_branch }} + merge_sha: ${{ steps.context.outputs.merge_sha }} + steps: + - name: Checkout selected branch + if: github.event_name == 'workflow_dispatch' + uses: actions/checkout@v4 + with: + ref: ${{ inputs.target_branch }} + fetch-depth: 0 + + - name: Checkout merge commit + if: github.event_name == 'pull_request' + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: 0 + + - name: Fetch tags + run: git fetch --force --tags + + - name: Resolve base versions + id: bases + shell: bash + run: | + set -euo pipefail + + current_version="$( + sed -nE 's/^version = "([0-9]+\.[0-9]+\.[0-9]+)"/\1/p' Cargo.toml | head -n1 + )" + + if [[ -z "${current_version}" ]]; then + echo "Could not determine the current Cargo version." >&2 + exit 1 + fi + + echo "main_base_version=${MAIN_BASE_VERSION:-$current_version}" >> "$GITHUB_OUTPUT" + echo "develop_base_version=${DEVELOP_BASE_VERSION:-$current_version}" >> "$GITHUB_OUTPUT" + + - name: Resolve release context + id: context + shell: bash + run: | + set -euo pipefail + + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + echo "target_branch=${{ inputs.target_branch }}" >> "$GITHUB_OUTPUT" + echo "source_branch=workflow_dispatch" >> "$GITHUB_OUTPUT" + echo "release_version=${{ inputs.version }}" >> "$GITHUB_OUTPUT" + echo "merge_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + else + echo "target_branch=${{ github.event.pull_request.base.ref }}" >> "$GITHUB_OUTPUT" + echo "source_branch=${{ github.event.pull_request.head.ref }}" >> "$GITHUB_OUTPUT" + echo "release_version=" >> "$GITHUB_OUTPUT" + echo "merge_sha=${{ github.event.pull_request.merge_commit_sha }}" >> "$GITHUB_OUTPUT" + fi + + - name: Plan release + id: plan + env: + TARGET_BRANCH: ${{ steps.context.outputs.target_branch }} + SOURCE_BRANCH: ${{ steps.context.outputs.source_branch }} + RELEASE_VERSION: ${{ steps.context.outputs.release_version }} + MAIN_BASE_VERSION: ${{ steps.bases.outputs.main_base_version }} + DEVELOP_BASE_VERSION: ${{ steps.bases.outputs.develop_base_version }} + run: devops/ga-release-plan.sh + + - name: Stop on non-release branches + if: steps.plan.outputs.should_release != 'true' + shell: bash + run: | + echo "Skipping release: ${{ steps.plan.outputs.reason }}" + + prepare_release: + needs: plan + if: needs.plan.outputs.should_release == 'true' + runs-on: ubuntu-latest + outputs: + release_sha: ${{ steps.release_commit.outputs.release_sha }} + steps: + - name: Checkout target branch + uses: actions/checkout@v4 + with: + ref: ${{ needs.plan.outputs.target_branch }} + fetch-depth: 0 + + - name: Fetch tags + run: git fetch --force --tags + + - name: Verify tag does not already exist + shell: bash + run: | + if git rev-parse "refs/tags/${{ needs.plan.outputs.version }}" >/dev/null 2>&1; then + echo "Tag ${{ needs.plan.outputs.version }} already exists." + exit 1 + fi + + - name: Ensure release commit includes merged change + if: github.event_name == 'pull_request' + shell: bash + run: | + set -euo pipefail + + if ! git merge-base --is-ancestor "${{ needs.plan.outputs.merge_sha }}" HEAD; then + echo "The target branch no longer contains merge commit ${{ needs.plan.outputs.merge_sha }}." >&2 + exit 1 + fi + + - name: Configure git author + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Create release commit + id: release_commit + shell: bash + run: | + set -euo pipefail + + version="${{ needs.plan.outputs.version }}" + + perl -pi -e 's/^version = "[0-9]+\.[0-9]+\.[0-9]+"/version = "'"$version"'"/' Cargo.toml + perl -0pi -e 's/name = "commitbot"\nversion = "[0-9]+\.[0-9]+\.[0-9]+"/name = "commitbot"\nversion = "'"$version"'"/m' Cargo.lock + + if git diff --quiet -- Cargo.toml Cargo.lock; then + echo "Release version ${version} is already present in Cargo metadata." >&2 + exit 1 + fi + + git add Cargo.toml Cargo.lock + git commit -m "Release ${version}" + git tag "${version}" + + echo "release_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Push release commit and tag + shell: bash + run: | + set -euo pipefail + git push origin "HEAD:${{ needs.plan.outputs.target_branch }}" + git push origin "refs/tags/${{ needs.plan.outputs.version }}" + + create_release: + needs: [plan, prepare_release] + if: needs.plan.outputs.should_release == 'true' + runs-on: ubuntu-latest + steps: + - name: Check out release commit + uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare_release.outputs.release_sha }} + + - name: Generate release notes + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + + NOTES_ARGS=() + if [[ "${{ needs.plan.outputs.previous_version }}" != "${{ needs.plan.outputs.base_version }}" ]]; then + NOTES_ARGS+=(-f previous_tag_name="${{ needs.plan.outputs.previous_version }}") + fi + + gh api \ + -H "Accept: application/vnd.github+json" \ + "/repos/${{ github.repository }}/releases/generate-notes" \ + -f tag_name="${{ needs.plan.outputs.version }}" \ + -f target_commitish="${{ needs.prepare_release.outputs.release_sha }}" \ + "${NOTES_ARGS[@]}" \ + --jq .body > generated-release-notes.md + + bash devops/render-release-notes.sh \ + "${{ needs.plan.outputs.version }}" \ + "${{ github.repository }}" \ + generated-release-notes.md > release-notes.md + + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + + gh release create "${{ needs.plan.outputs.version }}" \ + --draft \ + --verify-tag \ + --target "${{ needs.prepare_release.outputs.release_sha }}" \ + --title "${{ needs.plan.outputs.version }}" \ + --notes-file release-notes.md + + - name: Dispatch release-assets workflow + id: dispatch_assets + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + + dispatch_started_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + source_run_url="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + gh workflow run release-assets.yml \ + --ref "${{ needs.plan.outputs.target_branch }}" \ + -f tag="${{ needs.plan.outputs.version }}" \ + -f source_run_id="${{ github.run_id }}" \ + -f source_run_url="${source_run_url}" + + assets_run_url="" + for _ in {1..12}; do + sleep 5 + + assets_run_url="$( + gh api "/repos/${{ github.repository }}/actions/workflows/release-assets.yml/runs?event=workflow_dispatch&branch=${{ needs.plan.outputs.target_branch }}&per_page=20" \ + --jq '.workflow_runs + | map(select(.head_sha == "${{ needs.prepare_release.outputs.release_sha }}" and .status != null)) + | sort_by(.created_at) + | reverse + | .[0].html_url // ""' + )" + + if [[ -n "${assets_run_url}" ]]; then + break + fi + done + + echo "dispatch_started_at=${dispatch_started_at}" >> "$GITHUB_OUTPUT" + echo "assets_run_url=${assets_run_url}" >> "$GITHUB_OUTPUT" + + - name: Add release summary + shell: bash + run: | + release_url="https://github.com/${{ github.repository }}/releases/tag/${{ needs.plan.outputs.version }}" + assets_url="${{ steps.dispatch_assets.outputs.assets_run_url }}" + + { + echo "## Release" + echo + echo "- Version: \`${{ needs.plan.outputs.version }}\`" + echo "- Release: [${{ needs.plan.outputs.version }}](${release_url})" + if [[ -n "${assets_url}" ]]; then + echo "- Release assets workflow: [Open run](${assets_url})" + else + echo "- Release assets workflow: dispatched, but the run URL was not discovered yet." + echo "- Release assets workflow page: [Open workflow](https://github.com/${{ github.repository }}/actions/workflows/release-assets.yml)" + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 6d8fcf1..c23f7b0 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ A Rust-powered CLI that writes meaningful, structured Git commit messages using [![License](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](https://github.com/MikeGarde/commitbot/blob/main/LICENSE) [![Downloads](https://img.shields.io/github/downloads/MikeGarde/commitbot/total.svg?color=blue)](https://GitHub.com/MikeGarde/commitbot/releases/) - --- **Commitbot** analyzes your staged Git changes and helps you craft clear, consistent commit messages that describe *why* changes were made — not just *what* changed. diff --git a/Taskfile.yaml b/Taskfile.yaml index 6e02ae2..4a4d4da 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -63,6 +63,13 @@ tasks: - brew uninstall mikegarde/tap/commitbot || true - brew install mikegarde/tap/commitbot + build: + desc: "Build debug binary for the host" + silent: true + cmds: + - cargo build 2>$1 + - du -h target/debug/commitbot + build:release: desc: "Build optimized release binary for the host" silent: true @@ -81,7 +88,7 @@ tasks: - | set -euo pipefail - echo "Building release binaries..." + echo "Building Mac release binaries..." cargo build --release --target aarch64-apple-darwin cargo build --release --target x86_64-apple-darwin @@ -112,9 +119,6 @@ tasks: - task: publish:dry - task: publish - task: install - - TAG=$(commitbot --version) && \ - cd ../homebrew-tap && \ - gh workflow run update-formula.yml -f formula=commitbot -f repo=mikegarde/commitbot -f tag=$TAG publish:dry: desc: "Dry-run crates.io publish to verify packaging" diff --git a/devops/ga-release-plan.sh b/devops/ga-release-plan.sh new file mode 100755 index 0000000..41e95c0 --- /dev/null +++ b/devops/ga-release-plan.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TARGET_BRANCH="${TARGET_BRANCH:-${1:-}}" +SOURCE_BRANCH="${SOURCE_BRANCH:-${2:-}}" +MAIN_BASE_VERSION="${MAIN_BASE_VERSION:-}" +DEVELOP_BASE_VERSION="${DEVELOP_BASE_VERSION:-}" +RELEASE_VERSION="${RELEASE_VERSION:-}" + +if [[ -z "${TARGET_BRANCH}" ]]; then + echo "usage: TARGET_BRANCH= SOURCE_BRANCH= RELEASE_VERSION=X.Y.Z MAIN_BASE_VERSION=X.Y.Z DEVELOP_BASE_VERSION=X.Y.Z $0" >&2 + exit 1 +fi + +semver_pattern='^[0-9]+\.[0-9]+\.[0-9]+$' + +require_semver() { + local value="$1" + local label="$2" + + if [[ ! "${value}" =~ ${semver_pattern} ]]; then + echo "${label} must be a semantic version like 1.0.0" >&2 + exit 1 + fi +} + +require_semver "${MAIN_BASE_VERSION}" "MAIN_BASE_VERSION" +require_semver "${DEVELOP_BASE_VERSION}" "DEVELOP_BASE_VERSION" + +if [[ -n "${RELEASE_VERSION}" ]]; then + require_semver "${RELEASE_VERSION}" "RELEASE_VERSION" +fi + +case "${TARGET_BRANCH}" in + main) + BASE_VERSION="${MAIN_BASE_VERSION}" + ;; + develop) + BASE_VERSION="${DEVELOP_BASE_VERSION}" + ;; + *) + echo "Unsupported target branch: ${TARGET_BRANCH}" >&2 + exit 1 + ;; +esac + +BASE_MAJOR="${BASE_VERSION%%.*}" +TAG_PATTERN="^${BASE_MAJOR}\\.[0-9]+\\.[0-9]+$" +PREVIOUS_VERSION="$( + git tag --list \ + | grep -E "${TAG_PATTERN}" || true +)" +PREVIOUS_VERSION="$( + printf '%s\n' "${PREVIOUS_VERSION}" \ + | sort -V \ + | tail -n 1 +)" +PREVIOUS_VERSION="${PREVIOUS_VERSION:-${BASE_VERSION}}" + +CURRENT_VERSION="$( + printf '%s\n%s\n' "${BASE_VERSION}" "${PREVIOUS_VERSION}" \ + | sort -V \ + | tail -n 1 +)" + +if [[ -n "${RELEASE_VERSION}" ]]; then + SHOULD_RELEASE="true" + REASON="" + BUMP="manual" + + RELEASE_MAJOR="${RELEASE_VERSION%%.*}" + if [[ "${RELEASE_MAJOR}" != "${BASE_MAJOR}" ]]; then + echo "RELEASE_VERSION ${RELEASE_VERSION} does not match the ${TARGET_BRANCH} release line ${BASE_MAJOR}.x.x" >&2 + exit 1 + fi + + if [[ "$(printf '%s\n%s\n' "${CURRENT_VERSION}" "${RELEASE_VERSION}" | sort -V | tail -n 1)" != "${RELEASE_VERSION}" ]] || [[ "${RELEASE_VERSION}" == "${CURRENT_VERSION}" ]]; then + echo "RELEASE_VERSION ${RELEASE_VERSION} must be newer than ${CURRENT_VERSION}" >&2 + exit 1 + fi + + VERSION="${RELEASE_VERSION}" +elif [[ "${SOURCE_BRANCH}" == feat/* ]]; then + SHOULD_RELEASE="true" + REASON="" + BUMP="minor" + IFS='.' read -r MAJOR MINOR PATCH <<< "${CURRENT_VERSION}" + MINOR=$((MINOR + 1)) + PATCH=0 + VERSION="${MAJOR}.${MINOR}.${PATCH}" +elif [[ "${SOURCE_BRANCH}" == bugfix/* ]]; then + SHOULD_RELEASE="true" + REASON="" + BUMP="patch" + IFS='.' read -r MAJOR MINOR PATCH <<< "${CURRENT_VERSION}" + PATCH=$((PATCH + 1)) + VERSION="${MAJOR}.${MINOR}.${PATCH}" +else + SHOULD_RELEASE="false" + REASON="Source branch must start with feat/ or bugfix/." + BUMP="none" + VERSION="" +fi + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + { + echo "should_release=${SHOULD_RELEASE}" + echo "reason=${REASON}" + echo "bump=${BUMP}" + echo "base_version=${BASE_VERSION}" + echo "previous_version=${PREVIOUS_VERSION}" + echo "version=${VERSION}" + echo "target_branch=${TARGET_BRANCH}" + echo "source_branch=${SOURCE_BRANCH}" + } >> "${GITHUB_OUTPUT}" +fi + +echo "should_release=${SHOULD_RELEASE}" +echo "reason=${REASON}" +echo "bump=${BUMP}" +echo "base_version=${BASE_VERSION}" +echo "previous_version=${PREVIOUS_VERSION}" +echo "version=${VERSION}" diff --git a/devops/publish-if-complete.sh b/devops/publish-if-complete.sh new file mode 100755 index 0000000..3904e8a --- /dev/null +++ b/devops/publish-if-complete.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +set -euo pipefail + +TAG="${1:?usage: publish-if-complete.sh }" + +EXPECTED=( + "commitbot-${TAG}-aarch64-apple-darwin.tar.gz" + "commitbot-${TAG}-x86_64-apple-darwin.tar.gz" + "commitbot-${TAG}-aarch64-unknown-linux-gnu.tar.gz" + "commitbot-${TAG}-x86_64-unknown-linux-gnu.tar.gz" + "commitbot-${TAG}-aarch64-unknown-linux-musl.tar.gz" + "commitbot-${TAG}-x86_64-unknown-linux-musl.tar.gz" + "commitbot-${TAG}-x86_64-pc-windows-gnu.zip" +) + +ACTUAL="$(gh release view "${TAG}" --json assets --jq '[.assets[].name]')" + +for asset in "${EXPECTED[@]}"; do + if ! printf '%s' "${ACTUAL}" | jq -e --arg n "${asset}" 'any(.[]; . == $n)' > /dev/null 2>&1; then + echo "Release not yet complete (missing: ${asset}). Skipping publish." + exit 0 + fi +done + +echo "All expected assets present. Publishing release ${TAG}..." +gh release edit "${TAG}" --draft=false +echo "Published." + +echo "Triggering Homebrew tap formula update..." +if gh workflow run update-formula.yml \ + --repo MikeGarde/homebrew-tap \ + -f formula=commitbot \ + -f repo=MikeGarde/commitbot \ + -f tag="${TAG}"; then + echo "Homebrew tap update dispatched." +else + echo "Warning: could not dispatch Homebrew tap update. Trigger it manually with:" + echo " gh workflow run update-formula.yml --repo MikeGarde/homebrew-tap -f formula=commitbot -f repo=MikeGarde/commitbot -f tag=${TAG}" +fi diff --git a/devops/release.sh b/devops/release.sh index dbb0256..f1cedf5 100644 --- a/devops/release.sh +++ b/devops/release.sh @@ -1,16 +1,16 @@ #!/bin/zsh set -euo pipefail -# --------------------------------------------------------- +# # Config -# --------------------------------------------------------- +# DIST_DIR="dist" STEP=${1:?usage: release.sh [major|minor|patch|X.Y.Z]} -# --------------------------------------------------------- +# # Pre-flight checks -# --------------------------------------------------------- +# if ! command -v task >/dev/null 2>&1; then echo "Error: 'task' command not found. Install Taskfile runner first." exit 1 @@ -21,6 +21,13 @@ if ! command -v gh >/dev/null 2>&1; then exit 1 fi +if ! command -v jq >/dev/null 2>&1; then + echo "Error: 'jq' not found. Install jq first." + exit 1 +fi + +REPO_SLUG=$(gh repo view --json nameWithOwner -q .nameWithOwner) + BRANCH=$(git rev-parse --abbrev-ref HEAD) if [ "$BRANCH" != "main" ]; then echo "Error: releases may only be created from 'main' (current: $BRANCH)." @@ -33,9 +40,9 @@ if ! git diff --quiet || ! git diff --cached --quiet; then exit 1 fi -# --------------------------------------------------------- +# # Read current version from Cargo.toml -# --------------------------------------------------------- +# CURRENT_VERSION=$( grep -E '^version = "[0-9]+\.[0-9]+\.[0-9]+"' Cargo.toml \ | head -n1 \ @@ -52,9 +59,9 @@ MINOR=$(echo "$CURRENT_VERSION" | cut -d. -f2) PATCH=$(echo "$CURRENT_VERSION" | cut -d. -f3) LATEST=true -# --------------------------------------------------------- +# # Compute new version -# --------------------------------------------------------- +# case "$STEP" in major) MAJOR=$((MAJOR+1)) @@ -84,12 +91,57 @@ esac NEW_VERSION="$MAJOR.$MINOR.$PATCH" -# Check that tag does not already exist +# +# Join mode: tag already exists (e.g. GA created the release first) +# if git rev-parse -q --verify "refs/tags/$NEW_VERSION" >/dev/null 2>&1; then - echo "Error: git tag $NEW_VERSION already exists." - exit 1 + echo "Tag $NEW_VERSION already exists. Joining existing draft release to upload Mac assets..." + echo + + RELEASE_IS_DRAFT=$(gh release view "$NEW_VERSION" --json isDraft --jq .isDraft 2>/dev/null || echo "not_found") + if [ "$RELEASE_IS_DRAFT" = "false" ]; then + echo "Release $NEW_VERSION is already published. Nothing to do." + exit 0 + fi + if [ "$RELEASE_IS_DRAFT" = "not_found" ]; then + echo "Error: tag $NEW_VERSION exists in git but no GitHub release was found." + exit 1 + fi + + CARGO_VERSION=$( + grep -E '^version = "[0-9]+\.[0-9]+\.[0-9]+"' Cargo.toml \ + | head -n1 \ + | sed -E 's/version = "([^"]+)"/\1/' + ) + if [ "$CARGO_VERSION" != "$NEW_VERSION" ]; then + echo "Error: Cargo.toml version ($CARGO_VERSION) does not match release version ($NEW_VERSION)." + echo "Run 'git pull' to sync the release commit, then retry." + exit 1 + fi + + echo "Building Mac binaries for $NEW_VERSION..." + task setup + task "build:release:${NEW_VERSION}" + + MAC_ASSETS=("$DIST_DIR"/commitbot-"$NEW_VERSION"-*apple-darwin*.tar.gz) + if [ ${#MAC_ASSETS[@]} -eq 0 ]; then + echo "Error: no Mac assets found in $DIST_DIR after build." + exit 1 + fi + + echo "Uploading Mac assets to existing draft release $NEW_VERSION:" + for a in "${MAC_ASSETS[@]}"; do + echo " - $a" + done + gh release upload "$NEW_VERSION" "${MAC_ASSETS[@]}" --clobber + + bash devops/publish-if-complete.sh "$NEW_VERSION" + exit 0 fi +# +# Full release flow +# echo "Current version: $CURRENT_VERSION" echo "New version: $NEW_VERSION" echo "Branch: $BRANCH" @@ -101,9 +153,9 @@ if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then exit 1 fi -# --------------------------------------------------------- +# # Bump Cargo.toml with rollback on failure -# --------------------------------------------------------- +# CARGO_BAK="Cargo.toml.bak.$$" cp Cargo.toml "$CARGO_BAK" @@ -120,22 +172,22 @@ trap cleanup_on_error INT TERM ERR echo "Updating Cargo.toml to version $NEW_VERSION..." perl -pi -e 's/^version = "[0-9]+\.[0-9]+\.[0-9]+"/version = "'"$NEW_VERSION"'"/' Cargo.toml -# --------------------------------------------------------- +# # Run tasks: setup, test, build -# --------------------------------------------------------- +# echo "Running task setup..." task setup -echo "Running task build:release:all..." +echo "Running task build:release:${NEW_VERSION}..." task "build:release:${NEW_VERSION}" # If we got here, tasks succeeded – stop rollback trap trap - INT TERM ERR rm -f "$CARGO_BAK" -# --------------------------------------------------------- +# # Commit, tag, push -# --------------------------------------------------------- +# if git diff --quiet -- Cargo.toml; then echo "Warning: Cargo.toml did not change; nothing to commit." else @@ -151,37 +203,77 @@ echo "Pushing main and tag..." git push origin main git push origin "refs/tags/$NEW_VERSION" -# --------------------------------------------------------- -# Create GitHub release from artifacts in DIST_DIR -# --------------------------------------------------------- +# +# Create draft GitHub release with Mac assets +# if [ ! -d "$DIST_DIR" ]; then echo "Error: build artifacts directory '$DIST_DIR' not found." echo "Check task build:release:all" exit 1 fi -ASSETS=("$DIST_DIR"/*) -if [ ${#ASSETS[@]} -eq 0 ]; then - echo "Error: no build artifacts found in '$DIST_DIR'." +MAC_ASSETS=("$DIST_DIR"/commitbot-"$NEW_VERSION"-*apple-darwin*.tar.gz) +if [ ${#MAC_ASSETS[@]} -eq 0 ]; then + echo "Error: no Mac assets found in '$DIST_DIR'." exit 1 fi -LATEST_FLAG="" -if [ "$LATEST" = true ]; then - LATEST_FLAG="--latest" -fi - -echo "Creating GitHub release $NEW_VERSION with assets:" -for a in "${ASSETS[@]}"; do +echo "Creating draft GitHub release $NEW_VERSION with Mac assets:" +for a in "${MAC_ASSETS[@]}"; do echo " - $a" done +NOTES_FILE="$(mktemp)" +COMBINED_NOTES_FILE="$(mktemp)" + +cleanup_notes() { + rm -f "$NOTES_FILE" "$COMBINED_NOTES_FILE" +} +trap cleanup_notes EXIT + +gh api \ + -H "Accept: application/vnd.github+json" \ + "/repos/$REPO_SLUG/releases/generate-notes" \ + -f tag_name="$NEW_VERSION" \ + -f target_commitish="$HASH" \ + --jq .body > "$NOTES_FILE" + +bash devops/render-release-notes.sh "$NEW_VERSION" "$REPO_SLUG" "$NOTES_FILE" > "$COMBINED_NOTES_FILE" + +LATEST_ARGS=() +if [ "$LATEST" = true ]; then + LATEST_ARGS+=(--latest) +fi + gh release create "$NEW_VERSION" \ + --draft \ --fail-on-no-commits \ - --generate-notes \ - $LATEST_FLAG \ + "${LATEST_ARGS[@]}" \ + --notes-file "$COMBINED_NOTES_FILE" \ --target "$HASH" \ - "${ASSETS[@]}" + "${MAC_ASSETS[@]}" + +# +# Dispatch release-assets workflow for Linux + Windows +# +echo +echo "Dispatching release-assets workflow for Linux and Windows builds..." + +SOURCE_RUN_URL="local:$(hostname)" + +gh workflow run release-assets.yml \ + --ref main \ + -f tag="$NEW_VERSION" \ + -f source_run_url="${SOURCE_RUN_URL}" + +echo "Dispatched. Linux and Windows assets will be uploaded by GitHub Actions." +echo "The release will be published automatically once all assets are present." +echo + +# +# Check if all assets happen to already be present (rare but possible) +# +bash devops/publish-if-complete.sh "$NEW_VERSION" -echo "Release $NEW_VERSION created successfully." +echo "Release $NEW_VERSION draft created successfully." exit 0 diff --git a/devops/render-release-notes.sh b/devops/render-release-notes.sh new file mode 100644 index 0000000..07cc1d9 --- /dev/null +++ b/devops/render-release-notes.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash + +set -euo pipefail + +VERSION="${1:?usage: render-release-notes.sh [notes-file]}" +REPO="${2:?usage: render-release-notes.sh [notes-file]}" +NOTES_FILE="${3:-}" + +asset_url() { + local filename="$1" + printf 'https://github.com/%s/releases/download/%s/%s' "${REPO}" "${VERSION}" "${filename}" +} + +cat <