Release #108
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| on: | |
| push: | |
| branches: | |
| - main | |
| workflow_dispatch: | |
| inputs: | |
| version: | |
| description: 'Version to release (leave empty for auto)' | |
| required: false | |
| channel: | |
| description: 'Release channel' | |
| required: true | |
| default: 'release' | |
| type: choice | |
| options: | |
| - alpha | |
| - release | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| issues: write | |
| jobs: | |
| prepare: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| channel: ${{ steps.determine.outputs.channel }} | |
| version: ${{ steps.version.outputs.version }} | |
| should_release: ${{ steps.check.outputs.should_release }} | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| - name: Determine release channel | |
| id: determine | |
| run: | | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | |
| echo "channel=${{ github.event.inputs.channel }}" >> $GITHUB_OUTPUT | |
| elif [ "${{ github.ref }}" = "refs/heads/main" ]; then | |
| echo "channel=alpha" >> $GITHUB_OUTPUT | |
| else | |
| echo "channel=alpha" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Check if should release | |
| id: check | |
| run: | | |
| # Allow manual trigger to force release | |
| if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then | |
| echo "should_release=true" >> $GITHUB_OUTPUT | |
| echo "📦 Manual trigger - forcing release" | |
| # Check if last commit is a version bump (skip if so) | |
| elif git log -1 --pretty=%B | grep -q "chore(release):"; then | |
| echo "should_release=false" >> $GITHUB_OUTPUT | |
| echo "⏭️ Skipping - last commit is a release commit" | |
| else | |
| echo "should_release=true" >> $GITHUB_OUTPUT | |
| fi | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v1 | |
| with: | |
| bun-version: 1.2.22 | |
| - name: Install dependencies | |
| run: bun install | |
| - name: Get version | |
| id: version | |
| run: | | |
| CURRENT_VERSION=$(node -p "require('./package.json').version") | |
| CHANNEL="${{ steps.determine.outputs.channel }}" | |
| SHORT_SHA="${GITHUB_SHA::7}" | |
| # Manual version override takes priority | |
| if [ -n "${{ github.event.inputs.version }}" ]; then | |
| NEW_VERSION="${{ github.event.inputs.version }}" | |
| echo "🎯 Using manual version override: ${NEW_VERSION}" | |
| # For manual trigger without version, use current package.json version | |
| elif [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ "$CHANNEL" = "release" ]; then | |
| NEW_VERSION="${CURRENT_VERSION}" | |
| echo "🎯 Using existing package.json version: ${NEW_VERSION}" | |
| elif [ "$CHANNEL" = "alpha" ]; then | |
| # Alpha: bump patch from current version and append -alpha.<sha> | |
| MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1) | |
| MINOR=$(echo $CURRENT_VERSION | cut -d. -f2) | |
| PATCH=$(echo $CURRENT_VERSION | cut -d. -f3 | cut -d- -f1) | |
| PATCH=$((PATCH + 1)) | |
| NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}-alpha.${SHORT_SHA}" | |
| echo "🎯 Alpha version: ${NEW_VERSION}" | |
| else | |
| # Fallback: bump patch | |
| MAJOR=$(echo $CURRENT_VERSION | cut -d. -f1) | |
| MINOR=$(echo $CURRENT_VERSION | cut -d. -f2) | |
| PATCH=$(echo $CURRENT_VERSION | cut -d. -f3 | cut -d- -f1) | |
| PATCH=$((PATCH + 1)) | |
| NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" | |
| fi | |
| echo "version=${NEW_VERSION}" >> $GITHUB_OUTPUT | |
| echo "🎯 Version: ${NEW_VERSION} (${CHANNEL})" | |
| test: | |
| needs: prepare | |
| if: needs.prepare.outputs.should_release == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v1 | |
| with: | |
| bun-version: 1.2.22 | |
| - name: Install dependencies | |
| run: bun install | |
| - name: Type check | |
| run: bun run typecheck | |
| - name: Run tests | |
| run: bun run test | |
| build: | |
| needs: [prepare, test] | |
| if: needs.prepare.outputs.should_release == 'true' | |
| runs-on: ${{ matrix.os }} | |
| strategy: | |
| matrix: | |
| include: | |
| - os: macos-latest | |
| target: darwin-arm64 | |
| artifact: autohand-macos-arm64 | |
| - os: macos-latest | |
| target: darwin-x64 | |
| artifact: autohand-macos-x64 | |
| - os: ubuntu-latest | |
| target: linux-x64 | |
| artifact: autohand-linux-x64 | |
| - os: ubuntu-latest | |
| target: linux-arm64 | |
| artifact: autohand-linux-arm64 | |
| - os: windows-latest | |
| target: windows-x64 | |
| artifact: autohand-windows-x64.exe | |
| steps: | |
| - uses: actions/checkout@v6 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v1 | |
| with: | |
| bun-version: 1.2.22 | |
| - name: Install dependencies | |
| run: bun install | |
| - name: Update version before build | |
| run: | | |
| echo "Setting version to: ${{ needs.prepare.outputs.version }}" | |
| npm version "${{ needs.prepare.outputs.version }}" --no-git-tag-version --allow-same-version | |
| - name: Build TypeScript | |
| run: bun run build | |
| - name: Compile binary | |
| run: | | |
| mkdir -p binaries | |
| bun build ./src/index.ts --compile --target=bun-${{ matrix.target }} --outfile ./binaries/${{ matrix.artifact }} | |
| - name: Verify binary | |
| if: runner.os != 'Windows' && !contains(matrix.target, 'arm64') || matrix.os == 'macos-latest' && contains(matrix.target, 'arm64') | |
| run: | | |
| chmod +x ./binaries/${{ matrix.artifact }} | |
| file ./binaries/${{ matrix.artifact }} | |
| - name: Smoke test binary | |
| if: runner.os != 'Windows' && !contains(matrix.target, 'arm64') || matrix.os == 'macos-latest' && contains(matrix.target, 'arm64') | |
| shell: bash | |
| run: | | |
| # Helper: run a command with a timeout (works on both Linux and macOS) | |
| run_with_timeout() { | |
| local secs="$1"; shift | |
| "$@" & | |
| local pid=$! | |
| ( sleep "$secs" && kill "$pid" 2>/dev/null ) & | |
| local watchdog=$! | |
| wait "$pid" 2>/dev/null | |
| local rc=$? | |
| kill "$watchdog" 2>/dev/null | |
| wait "$watchdog" 2>/dev/null || true | |
| return $rc | |
| } | |
| echo "Testing --version..." | |
| run_with_timeout 10 ./binaries/${{ matrix.artifact }} --version < /dev/null | |
| echo "" | |
| echo "Testing --help..." | |
| run_with_timeout 10 ./binaries/${{ matrix.artifact }} --help < /dev/null > /dev/null | |
| echo "Smoke test passed!" | |
| - name: Upload artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: ${{ matrix.artifact }} | |
| path: ./binaries/${{ matrix.artifact }} | |
| retention-days: 1 | |
| release: | |
| needs: [prepare, build] | |
| if: needs.prepare.outputs.should_release == 'true' | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v6 | |
| with: | |
| fetch-depth: 0 | |
| token: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v1 | |
| - name: Configure Git | |
| run: | | |
| git config user.name "github-actions[bot]" | |
| git config user.email "github-actions[bot]@users.noreply.github.com" | |
| - name: Update version in package.json (stable only) | |
| if: needs.prepare.outputs.channel == 'release' | |
| run: | | |
| bun --version | |
| VERSION="${{ needs.prepare.outputs.version }}" | |
| echo "Updating to version: $VERSION" | |
| npm version $VERSION --no-git-tag-version --allow-same-version | |
| node scripts/sync-homebrew-version.cjs | |
| git add package.json homebrew/autohand.rb | |
| git commit -m "chore(release): v$VERSION [skip ci]" || echo "No changes to commit" | |
| git push origin ${{ github.ref_name }} || echo "No changes to push" | |
| - name: Download all artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| path: artifacts | |
| - name: Prepare release binaries | |
| run: | | |
| mkdir -p release-binaries | |
| find artifacts -type f -exec cp {} release-binaries/ \; | |
| ls -lh release-binaries/ | |
| - name: Create archives for ACP registry | |
| run: | | |
| cd release-binaries | |
| # Create tar.gz for Unix platforms | |
| for binary in autohand-macos-arm64 autohand-macos-x64 autohand-linux-x64 autohand-linux-arm64; do | |
| if [ -f "$binary" ]; then | |
| chmod +x "$binary" | |
| cp "$binary" autohand | |
| tar -czvf "${binary}.tar.gz" autohand | |
| rm autohand | |
| echo "Created ${binary}.tar.gz" | |
| fi | |
| done | |
| # Create zip for Windows | |
| if [ -f "autohand-windows-x64.exe" ]; then | |
| cp autohand-windows-x64.exe autohand.exe | |
| zip autohand-windows-x64.zip autohand.exe | |
| rm autohand.exe | |
| echo "Created autohand-windows-x64.zip" | |
| fi | |
| ls -lh *.tar.gz *.zip 2>/dev/null || true | |
| - name: Generate changelog | |
| id: changelog | |
| uses: actions/github-script@v7 | |
| env: | |
| RELEASE_VERSION: ${{ needs.prepare.outputs.version }} | |
| RELEASE_CHANNEL: ${{ needs.prepare.outputs.channel }} | |
| with: | |
| script: | | |
| const { execSync } = require('child_process'); | |
| const version = process.env.RELEASE_VERSION; | |
| const channel = process.env.RELEASE_CHANNEL; | |
| // Get commits since last tag | |
| let commits; | |
| let lastTag = null; | |
| try { | |
| lastTag = execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim(); | |
| commits = execSync(`git log ${lastTag}..HEAD --pretty=format:"%s"`, { encoding: 'utf8' }); | |
| } catch { | |
| commits = execSync('git log --pretty=format:"%s" -n 20', { encoding: 'utf8' }); | |
| } | |
| const lines = commits.split('\n').filter(line => line.trim() && !line.includes('chore(release)')); | |
| // Helper to humanize commit messages | |
| const humanize = (msg) => { | |
| return msg | |
| .replace(/^feat(\([^)]+\))?:\s*/i, '') | |
| .replace(/^fix(\([^)]+\))?:\s*/i, '') | |
| .replace(/^chore(\([^)]+\))?:\s*/i, '') | |
| .replace(/^docs(\([^)]+\))?:\s*/i, '') | |
| .replace(/^refactor(\([^)]+\))?:\s*/i, '') | |
| .replace(/^test(\([^)]+\))?:\s*/i, '') | |
| .replace(/^ci(\([^)]+\))?:\s*/i, '') | |
| .replace(/^perf(\([^)]+\))?:\s*/i, '') | |
| .trim(); | |
| }; | |
| // Capitalize first letter | |
| const capitalize = (str) => str.charAt(0).toUpperCase() + str.slice(1); | |
| // Categorize commits | |
| const features = []; | |
| const fixes = []; | |
| const improvements = []; | |
| const breaking = []; | |
| for (const msg of lines) { | |
| const clean = humanize(msg); | |
| if (!clean) continue; | |
| if (msg.includes('BREAKING CHANGE') || msg.includes('!:')) { | |
| breaking.push(capitalize(clean)); | |
| } else if (msg.match(/^feat(\(|:)/i)) { | |
| features.push(capitalize(clean)); | |
| } else if (msg.match(/^fix(\(|:)/i)) { | |
| fixes.push(capitalize(clean)); | |
| } else if (msg.match(/^(refactor|perf|chore|docs|test|ci)(\(|:)/i)) { | |
| improvements.push(capitalize(clean)); | |
| } | |
| } | |
| // Build a friendly changelog | |
| let changelog = ''; | |
| // Channel badge for alpha | |
| if (channel === 'alpha') { | |
| changelog += '> **Alpha Release** — This is a pre-release build from the latest `main` branch. It may contain bugs or incomplete features.\n\n'; | |
| } | |
| // Intro | |
| if (lastTag) { | |
| changelog += `Hey there! We've been busy making Autohand better. Here's what's new since ${lastTag}:\n\n`; | |
| } else { | |
| changelog += `Hey there! Here's what's new in this release:\n\n`; | |
| } | |
| // Breaking changes (serious tone) | |
| if (breaking.length > 0) { | |
| changelog += '### Heads up! Breaking Changes\n\n'; | |
| changelog += 'These changes might require updates to your setup:\n\n'; | |
| breaking.forEach(item => { changelog += `- ${item}\n`; }); | |
| changelog += '\n'; | |
| } | |
| // Features (excited tone) | |
| if (features.length > 0) { | |
| changelog += '### New Stuff\n\n'; | |
| features.forEach(item => { changelog += `- ${item}\n`; }); | |
| changelog += '\n'; | |
| } | |
| // Fixes (helpful tone) | |
| if (fixes.length > 0) { | |
| changelog += '### Bug Fixes\n\n'; | |
| if (fixes.length === 1) { | |
| changelog += `We squashed a bug:\n\n`; | |
| } else { | |
| changelog += `We squashed ${fixes.length} bugs:\n\n`; | |
| } | |
| fixes.forEach(item => { changelog += `- ${item}\n`; }); | |
| changelog += '\n'; | |
| } | |
| // Improvements (casual tone) | |
| if (improvements.length > 0 && improvements.length <= 8) { | |
| changelog += '### Under the Hood\n\n'; | |
| changelog += 'Some housekeeping and improvements:\n\n'; | |
| improvements.forEach(item => { changelog += `- ${item}\n`; }); | |
| changelog += '\n'; | |
| } | |
| // If nothing categorized, add a generic message | |
| if (features.length === 0 && fixes.length === 0 && improvements.length === 0 && breaking.length === 0) { | |
| changelog += 'Minor updates and improvements to keep things running smoothly.\n\n'; | |
| } | |
| // Installation section | |
| const cb = '`' + '`' + '`'; | |
| changelog += '---\n\n'; | |
| changelog += '### Get it\n\n'; | |
| if (channel === 'alpha') { | |
| changelog += '**Install this alpha build:**\n'; | |
| changelog += cb + 'bash\ncurl -fsSL https://autohand.ai/install.sh | sh -s -- --alpha\n' + cb + '\n\n'; | |
| changelog += '**Or install the latest stable release:**\n'; | |
| changelog += cb + 'bash\ncurl -fsSL https://autohand.ai/install.sh | sh\n' + cb + '\n\n'; | |
| } else { | |
| changelog += '**Quickest way:**\n'; | |
| changelog += cb + 'bash\ncurl -fsSL https://autohand.ai/install.sh | sh\n' + cb + '\n\n'; | |
| changelog += '**Via npm or bun:**\n'; | |
| changelog += cb + 'bash\nnpm install -g autohand-cli\n' + cb + '\n\n'; | |
| } | |
| changelog += '**Or grab a binary below** for your platform.\n\n'; | |
| changelog += '| Platform | Architecture | Binary |\n'; | |
| changelog += '|----------|--------------|--------|\n'; | |
| changelog += '| macOS | Apple Silicon | `autohand-macos-arm64` |\n'; | |
| changelog += '| macOS | Intel | `autohand-macos-x64` |\n'; | |
| changelog += '| Linux | x64 | `autohand-linux-x64` |\n'; | |
| changelog += '| Linux | ARM64 | `autohand-linux-arm64` |\n'; | |
| changelog += '| Windows | x64 | `autohand-windows-x64.exe` |\n'; | |
| core.setOutput('changelog', changelog); | |
| return changelog; | |
| - name: Create Release | |
| uses: softprops/action-gh-release@v2 | |
| with: | |
| tag_name: v${{ needs.prepare.outputs.version }} | |
| name: ${{ needs.prepare.outputs.channel == 'release' && format('Release v{0}', needs.prepare.outputs.version) || format('Alpha v{0}', needs.prepare.outputs.version) }} | |
| body: ${{ steps.changelog.outputs.changelog }} | |
| files: | | |
| release-binaries/* | |
| install.sh | |
| draft: false | |
| prerelease: ${{ needs.prepare.outputs.channel != 'release' }} | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build JS dist for npm | |
| if: needs.prepare.outputs.channel == 'release' | |
| run: | | |
| bun install | |
| bun run build | |
| ls -lh dist/ | |
| - name: Publish to npm (release only) | |
| if: needs.prepare.outputs.channel == 'release' | |
| env: | |
| NPM_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| run: | | |
| if [ -z "$NPM_TOKEN" ]; then | |
| echo "⚠️ NPM_TOKEN not set, skipping npm publish" | |
| exit 0 | |
| fi | |
| echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/.npmrc | |
| npm publish --access public |