From 91f3c7495a5ae50d581744b33ec958f3f8d069f1 Mon Sep 17 00:00:00 2001 From: Andrea Cristalli Date: Fri, 7 Aug 2026 10:05:23 +0200 Subject: [PATCH 1/3] ci: add release workflow to build cross-platform binaries on tags --- .github/workflows/release.yml | 95 +++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b4b0027 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,95 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build-binaries: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + ext: '' + asset: stern-ui-linux-amd64 + - goos: linux + goarch: arm64 + ext: '' + asset: stern-ui-linux-arm64 + - goos: darwin + goarch: arm64 + ext: '' + asset: stern-ui-darwin-arm64 + - goos: windows + goarch: amd64 + ext: .exe + asset: stern-ui-windows-amd64.exe + - goos: windows + goarch: arm64 + ext: .exe + asset: stern-ui-windows-arm64.exe + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up tools (Go, Node) via mise + uses: jdx/mise-action@v4 + + - name: Install frontend dependencies + run: npm install + working-directory: frontend + + - name: Build frontend (produces embedded dist/) + run: npm run build + working-directory: frontend + + - name: Build binary + run: go build -trimpath -ldflags "-s -w" -o "dist/${{ matrix.asset }}" main.go + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + + - name: Upload release artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset }} + path: dist/${{ matrix.asset }} + if-no-files-found: error + + upload-release: + needs: build-binaries + runs-on: ubuntu-latest + steps: + - name: Download all binaries + uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + - name: Wait for GitHub release to be created + env: + GH_TOKEN: ${{ github.token }} + run: | + for i in $(seq 1 60); do + if gh release view "${{ github.ref_name }}" >/dev/null 2>&1; then + echo "Release ${{ github.ref_name }} found" + exit 0 + fi + echo "Waiting for release ${{ github.ref_name }}... ($i)" + sleep 10 + done + echo "Release ${{ github.ref_name }} not created after 10 minutes" >&2 + exit 1 + + - name: Upload binaries to release + env: + GH_TOKEN: ${{ github.token }} + run: gh release upload "${{ github.ref_name }}" dist/* --clobber From aa06fe014ee81176df6a0c1f3c5e42639914c901 Mon Sep 17 00:00:00 2001 From: Andrea Cristalli Date: Fri, 7 Aug 2026 10:07:54 +0200 Subject: [PATCH 2/3] docs: update CHANGELOG for release workflow --- CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 03644a8..5c915ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- GitHub Actions CI workflow that compiles and runs all tests (pre-commit, golangci-lint, backend and frontend tests) on push and pull requests +- GitHub Actions CI workflow that compiles and runs all tests (pre-commit, golangci-lint, backend and frontend tests) on pull requests +- GitHub Actions release workflow that builds cross-platform binaries (linux amd64/arm64, macos arm64, windows amd64/arm64) and attaches them to the GitHub release on version tags - Renovate configuration for automated dependency update PRs (frontend, Go, and Docker) ### Changed @@ -18,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Centralized developer tooling versions (Go, Node, golangci-lint) in a single `mise.toml`, consumed by CI via `jdx/mise-action` and auto-updated by Renovate - Docker frontend build stage now uses Node 24 instead of the deprecated Node 20 base image - CI now provisions all tool runtimes through mise instead of separate setup-node/setup-go/golangci-lint actions +- CI workflow now runs on pull requests only (previously also ran on pushes to main/develop) ### Fixed From 784ff0ba6aa400c503e16e73f8e2a357d60aadae Mon Sep 17 00:00:00 2001 From: Andrea Cristalli Date: Fri, 7 Aug 2026 10:22:09 +0200 Subject: [PATCH 3/3] refactor(scripts): release via branch, PR, CI wait, merge, then tag --- scripts/README.md | 35 ++++++--- scripts/release.sh | 177 ++++++++++++++++++++++++++++++++++++--------- 2 files changed, 169 insertions(+), 43 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index a74ba8c..c7f2053 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -66,14 +66,21 @@ If you prefer to run the script directly without Task: ### How It Works -The script automatically: +When bumping a version, the script automatically: 1. Checks prerequisites (git, gh CLI, clean working directory) 2. Parses the current version from CHANGELOG.md 3. Determines version to release (bump or use current) -4. Updates CHANGELOG.md with new Unreleased section (if bumping) -5. Creates and pushes git tag -6. Commits changelog changes -7. Creates GitHub release with extracted release notes +4. Creates a `release/vX.Y.Z` branch from the default branch +5. Updates CHANGELOG.md with new Unreleased section +6. Commits the changelog (retrying if pre-commit hooks modify files) +7. Pushes the release branch and opens a pull request into `main` +8. Waits for the CI checks to pass +9. Merges the pull request +10. Checks out `main` and pulls the merged changes +11. Creates and pushes the `vX.Y.Z` tag, then creates the GitHub release + (a release workflow builds and attaches the platform binaries) + +When using `--current`, no changelog changes are needed, so it tags `main` directly without a branch/PR. ### Example Output @@ -124,12 +131,20 @@ Proceed with release 0.2.0? (y/N) y ℹ Starting release process... +ℹ Creating release branch release/v0.2.0 from main... ℹ Updating CHANGELOG.md... ✓ CHANGELOG.md updated -ℹ Creating git tag v0.2.0... +✓ Commit succeeded +ℹ Pushing release branch release/v0.2.0... +ℹ Creating pull request... +ℹ Pull request created: https://github.com/Esysc/stern-ui/pull/N +ℹ Waiting for CI checks on https://github.com/Esysc/stern-ui/pull/N... +✓ All CI checks passed +ℹ Merging pull request... +ℹ Switching back to main and pulling merged changes... ✓ Tag v0.2.0 created -ℹ Pushing changes to remote... -✓ Changes and tag pushed to remote +ℹ Pushing tag v0.2.0... +✓ Tag v0.2.0 pushed ✓ GitHub release created: v0.2.0 ✓ Release 0.2.0 completed! @@ -144,12 +159,12 @@ Proceed with release 0.2.0? (y/N) y git commit -m "feat: add new feature" ``` -3. Run the release: +3. Run the release (from the default branch, `main`): ```bash task release -- minor ``` -The script will automatically create an [Unreleased] section in the CHANGELOG for the next release. +The script will create a `release/vX.Y.Z` branch, open a PR, wait for CI to pass, merge it, and then tag `main`. It also creates a fresh `[Unreleased]` section in the CHANGELOG for the next release. ### Troubleshooting diff --git a/scripts/release.sh b/scripts/release.sh index 60733be..66f0003 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -87,7 +87,8 @@ usage() { cat << EOF Usage: $(basename "$0") [OPTIONS] [VERSION_TYPE] -Automate the release process: version bumping, tagging, and GitHub release creation. +Automate the release process: create a release branch, update the changelog, +open a pull request, wait for CI, merge, then tag the default branch and create the GitHub release. ARGUMENTS: VERSION_TYPE Version bump type: patch, minor, or major @@ -168,7 +169,8 @@ parse_args() { # Parse current version from CHANGELOG get_current_version() { # Extract the first version tag from CHANGELOG (format: ## [X.Y.Z]) - local version=$(grep -m 1 -oP '##\s+\[\K[0-9]+\.[0-9]+\.[0-9]+(?=\])' "$CHANGELOG_FILE" || echo "") + local version + version=$(grep -m 1 -oP '##\s+\[\K[0-9]+\.[0-9]+\.[0-9]+(?=\])' "$CHANGELOG_FILE" || echo "") if [ -z "$version" ]; then error "Could not parse current version from CHANGELOG.md" @@ -215,7 +217,7 @@ prompt_version_bump() { echo "" while true; do - read -p "Enter choice (1-3): " choice + read -rp "Enter choice (1-3): " choice case $choice in 1) echo "patch"; return ;; 2) echo "minor"; return ;; @@ -298,9 +300,10 @@ generate_changelog_content() { update_changelog() { local new_version=$1 local old_version=$2 - local date=$(date +%Y-%m-%d) - local temp_file=$(mktemp) - local temp_content=$(mktemp) + local date temp_file temp_content + date=$(date +%Y-%m-%d) + temp_file=$(mktemp) + temp_content=$(mktemp) info "Generating changelog content from commits..." @@ -361,10 +364,11 @@ update_changelog() { # Extract release notes for the current version extract_release_notes() { local version=$1 - local temp_file=$(mktemp) + local temp_file + temp_file=$(mktemp) # Extract content between [version] and the next version or end - awk -v version="$version" ' + if ! awk -v version="$version" ' BEGIN { in_version = 0; found = 0 } # Match version header @@ -382,9 +386,8 @@ extract_release_notes() { in_version { print } END { if (!found) exit 1 } - ' "$CHANGELOG_FILE" > "$temp_file" - - if [ $? -ne 0 ]; then + ' "$CHANGELOG_FILE" > "$temp_file"; then + rm -f "$temp_file" error "Could not extract release notes for version $version" fi @@ -439,30 +442,83 @@ create_tag() { success "Tag $tag created" } -# Push changes and tag -push_to_remote() { - local tag=$1 +# Commit with retry in case pre-commit hooks modify the changelog +commit_with_retry() { + local message=$1 + local max_attempts=3 + local attempt=1 + + while [ "$attempt" -le "$max_attempts" ]; do + info "Committing (attempt $attempt/$max_attempts)..." + if git commit -m "$message"; then + success "Commit succeeded" + return 0 + fi + warning "Commit failed (pre-commit may have modified files). Staging changes and retrying..." + git add -u + attempt=$((attempt + 1)) + done + + error "Failed to commit after $max_attempts attempts" +} - info "Pushing changes to remote..." +# Create a release pull request +create_release_pr() { + local branch=$1 + local version=$2 + local base=$3 + local temp_notes + temp_notes=$(mktemp) - # Only commit changelog if it was modified - if [ "$USE_CURRENT_VERSION" = false ]; then - git add "$CHANGELOG_FILE" - git commit -m "chore: prepare release $tag" - git push origin "$(git branch --show-current)" - fi + info "Creating pull request..." + extract_release_notes "$version" > "$temp_notes" - info "Pushing tag $tag..." - git push origin "$tag" + local url + url=$(gh pr create \ + --base "$base" \ + --head "$branch" \ + --title "Release v$version" \ + --body-file "$temp_notes") - success "Changes and tag pushed to remote" + rm "$temp_notes" + echo "$url" +} + +# Wait for CI checks to pass +wait_for_checks() { + local pr_url=$1 + local timeout_secs=${CHECK_TIMEOUT_SECS:-900} + local interval=15 + local elapsed=0 + + info "Waiting for CI checks on $pr_url..." + + while [ "$elapsed" -lt "$timeout_secs" ]; do + # Abort early if any check failed + if gh pr view "$pr_url" --json statusCheckRollup \ + --jq '[.statusCheckRollup[] | select(.conclusion == "FAILURE" or .conclusion == "CANCELLED" or .conclusion == "TIMED_OUT")] | length' \ + | grep -q '[1-9]'; then + error "One or more CI checks failed on $pr_url" + fi + + if gh pr checks "$pr_url" >/dev/null 2>&1; then + success "All CI checks passed" + return 0 + fi + + sleep "$interval" + elapsed=$((elapsed + interval)) + done + + error "Timed out waiting for CI checks on $pr_url" } # Create GitHub release create_github_release() { local version=$1 local tag="v$version" - local temp_notes=$(mktemp) + local temp_notes + temp_notes=$(mktemp) info "Creating GitHub release..." @@ -547,16 +603,71 @@ main() { fi fi - # Only update changelog if bumping version - if [ "$USE_CURRENT_VERSION" = false ]; then - update_changelog "$new_version" "$current_version" - else - info "Skipping CHANGELOG update (using current version)" + # Determine the default branch (main or master) + DEFAULT_BRANCH="main" + current_branch=$(git branch --show-current) + if [[ "$current_branch" == "master" ]]; then + DEFAULT_BRANCH="master" fi - create_tag "$new_version" - push_to_remote "v$new_version" - create_github_release "$new_version" + local tag="v$new_version" + + if [ "$USE_CURRENT_VERSION" = true ]; then + # No changelog changes needed: tag the default branch directly + info "Using current version, tagging $DEFAULT_BRANCH directly..." + git checkout "$DEFAULT_BRANCH" >/dev/null 2>&1 + git pull origin "$DEFAULT_BRANCH" >/dev/null 2>&1 + + create_tag "$new_version" + info "Pushing tag $tag..." + git push origin "$tag" + success "Tag $tag pushed" + + create_github_release "$new_version" + else + # Create the release branch from the default branch + release_branch="release/v$new_version" + info "Creating release branch $release_branch from $DEFAULT_BRANCH..." + git checkout "$DEFAULT_BRANCH" >/dev/null 2>&1 + git pull origin "$DEFAULT_BRANCH" >/dev/null 2>&1 + git checkout -b "$release_branch" + + # Update changelog before committing the release branch + update_changelog "$new_version" "$current_version" + + # Commit changelog, retrying if pre-commit hooks modify files + git add "$CHANGELOG_FILE" + commit_with_retry "chore: prepare release $tag" + + # Push the release branch + info "Pushing release branch $release_branch..." + git push -u origin "$release_branch" + + # Open the pull request + pr_url=$(create_release_pr "$release_branch" "$new_version" "$DEFAULT_BRANCH") + info "Pull request created: $pr_url" + + # Wait for CI checks to pass + wait_for_checks "$pr_url" + + # Merge the pull request (admin bypasses the required review) + info "Merging pull request..." + gh pr merge "$pr_url" --merge --admin + + # Switch back to the default branch (main) and pull the merged changes + info "Switching back to $DEFAULT_BRANCH and pulling merged changes..." + git checkout "$DEFAULT_BRANCH" + git pull origin "$DEFAULT_BRANCH" + + # Create and push the tag on the default branch + create_tag "$new_version" + info "Pushing tag $tag..." + git push origin "$tag" + success "Tag $tag pushed" + + # Create the GitHub release (the workflow builds and attaches packages) + create_github_release "$new_version" + fi echo "" success "═══════════════════════════════════════"