Skip to content

chore(release): v3.1.6 #220

chore(release): v3.1.6

chore(release): v3.1.6 #220

Workflow file for this run

name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
jobs:
# ── Quality gate ─────────────────────────────────────────────────────
# Runs typecheck + unit tests on Ubuntu before any platform build starts,
# so a broken tag never reaches the release matrix. One cheap job
# instead of a separate `ci.yml` that fires on every push to main (that's
# the EchoBird pattern — a separate push-triggered CI is what makes every
# release look like "a big pile of CI"). Here the gate runs ONLY on tag
# push / workflow_dispatch, gated behind `needs:`, so a normal
# commit-to-main push pays zero CI cost.
quality:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: src-ui/package-lock.json
- name: Install frontend deps
run: |
cd src-ui
npm ci
# Full frontend build (tsc -b typecheck + vite build). The matrix
# also builds the frontend, but repeating it here is ~1s and keeps
# the gate self-contained + identical to local `npm run build`, so a
# type error the matrix would hit is caught before any platform
# runner spins up. NOTE: `npm run lint` is intentionally NOT gated
# here — the repo has long-standing lint errors (empty catch blocks,
# react-refresh exports) that predate this workflow and would block
# every release until cleaned up. Add lint back once those are fixed.
- name: Frontend build (typecheck)
run: |
cd src-ui
npm run build
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
# Tauri's Linux build pulls glib-sys / gobject-sys / webkit2gtk-sys
# crates, which pkg-config against the system dev libraries. The
# release matrix installs these for the ubuntu builds; the quality
# gate needs the SAME set or `cargo test` fails to compile the
# glib-sys build script (glib-2.0.pc not found). Mirror the matrix's
# install verbatim so the gate stays in lockstep with what actually
# ships.
- name: Install system dependencies (Linux)
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
key: quality
# Unit tests — includes the shell_probe detection / fallback-chain
# suite. On Linux this also exercises the Unix cfg branches that
# Windows-hosted dev never runs locally (#[cfg(not(target_os =
# "windows"))] tests are filtered out on Windows).
- name: Cargo test
run: cargo test --no-fail-fast
release:
needs: quality
strategy:
fail-fast: false
matrix:
include:
- platform: windows-latest
args: ''
label: Windows x64
- platform: macos-latest
args: '--target aarch64-apple-darwin'
target: aarch64-apple-darwin
label: macOS Apple Silicon
# GitHub Actions exposed a dedicated x86_64 macOS runner
# (`macos-15-intel`) in May 2026, after the `macos-13` Intel runner
# was deprecated. Without it, Intel Mac users had to run Coffee CLI
# under Rosetta from the arm64 DMG. Now we ship a native x64 DMG too.
- platform: macos-15-intel
args: '--target x86_64-apple-darwin'
target: x86_64-apple-darwin
label: macOS Intel
- platform: ubuntu-22.04
args: ''
label: Linux x64
- platform: ubuntu-22.04-arm
args: ''
label: Linux arm64
runs-on: ${{ matrix.platform }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve release tag
id: tag
shell: bash
run: |
# Tag-pushes give refs/tags/v1.2.3; workflow_dispatch falls back to
# the latest tag in the working tree. Bare version (no leading "v")
# is what tauri stamps into bundle filenames; both forms are
# exposed for downstream steps.
TAG="${GITHUB_REF#refs/tags/}"
if [ "$TAG" = "$GITHUB_REF" ]; then
TAG=$(git describe --tags --abbrev=0)
fi
VERSION="${TAG#v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Install system dependencies (Linux)
if: startsWith(matrix.platform, 'ubuntu-')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf rpm
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 24
cache: npm
cache-dependency-path: src-ui/package-lock.json
- name: Install frontend deps
run: |
cd src-ui
npm ci
# All platforms build identically on stable xterm 6.0.0. The Linux CJK
# IME punctuation-duplication bug (issue #37 / xtermjs#5374) is fixed in
# the app layer instead — see TierTerminal.tsx (__IS_LINUX__-gated
# suppression of xterm's composition events) — so there is no longer a
# Linux-only dependency override or a Linux-only tsc-skip build path.
- name: Build frontend (tsc + vite)
run: |
cd src-ui
npm run build
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target || '' }}
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
key: ${{ matrix.label }}
- name: Install Tauri CLI
run: cargo install tauri-cli --version "^2.0" --locked
# ── macOS: build .app, re-sign without hardened runtime, then bundle DMG ──
# tauri-bundler's default codesign for adhoc identities adds
# --options runtime, which without Apple notarization causes Gatekeeper
# to silently reject the GUI launch (click icon, nothing happens). We
# build the .app first, codesign --deep WITHOUT runtime, then bundle
# the DMG from the re-signed .app. Gatekeeper accepts adhoc-signed
# apps that aren't claiming hardened runtime.
- name: Build .app only (macOS)
if: runner.os == 'macOS'
run: cargo tauri build --bundles app ${{ matrix.args }}
- name: Re-sign without hardened runtime (macOS)
if: runner.os == 'macOS'
run: |
APP=$(find target/${{ matrix.target }}/release/bundle/macos -maxdepth 2 -name "*.app" | head -1)
if [ -z "$APP" ]; then
echo "::error::No .app found"
find target -name "*.app" 2>/dev/null
exit 1
fi
echo "Re-signing: $APP"
codesign --force --deep --sign - "$APP"
codesign -dv --verbose=4 "$APP" 2>&1
# Sanity assertion: no 'runtime' flag, sealed resources present.
if codesign -dv --verbose=4 "$APP" 2>&1 | grep -qE "flags=.*runtime"; then
echo "::error::runtime flag still present after re-sign"
exit 1
fi
if codesign -dv --verbose=4 "$APP" 2>&1 | grep -q "Sealed Resources=none"; then
echo "::error::Sealed Resources=none after re-sign"
exit 1
fi
- name: Build DMG from re-signed .app (macOS)
if: runner.os == 'macOS'
run: cargo tauri bundle --bundles dmg ${{ matrix.args }}
# ── Windows / Linux: standard one-shot bundle build ──
- name: Build all bundles (Windows / Linux)
if: runner.os != 'macOS'
run: cargo tauri build ${{ matrix.args }}
# ── Stage + rename artifacts to platform-labelled format ──
# Tauri's bundler emits names like `Coffee CLI_1.9.2_amd64.deb` which
# don't tell the end user which OS they're for and use inconsistent
# arch slugs (amd64/x86_64/x64 across formats, plus rpm's `-1` release
# suffix). We rename to the EchoBird-style convention:
# Coffee.CLI_<version>_<OS>_<arch>.<ext>
# so a glance at the GitHub Releases page makes the OS/arch obvious
# and the install scripts have a single uniform pattern to grep.
- name: Stage and rename artifacts
id: stage
shell: bash
env:
VERSION: ${{ steps.tag.outputs.version }}
run: |
set -e
STAGING="$RUNNER_TEMP/coffee-release"
mkdir -p "$STAGING"
rename_one() {
local src="$1"
[ -f "$src" ] || return 0
local base
base=$(basename "$src")
local out=""
case "$base" in
# Windows
*_x64-setup.exe) out="Coffee.CLI_${VERSION}_Windows_x64-setup.exe" ;;
*_x64_*.msi|*_x64.msi) out="Coffee.CLI_${VERSION}_Windows_x64.msi" ;;
# macOS — separate native DMGs for Apple Silicon and Intel.
# Tauri's bundler uses `aarch64` / `x64` suffixes on the dmg
# name (matching the target triple's first segment).
*_aarch64.dmg) out="Coffee.CLI_${VERSION}_macOS_arm64.dmg" ;;
*_x64.dmg) out="Coffee.CLI_${VERSION}_macOS_x64.dmg" ;;
# Linux .deb (Tauri uses Debian "amd64"/"arm64")
*_amd64.deb) out="Coffee.CLI_${VERSION}_Linux_x64.deb" ;;
*_arm64.deb) out="Coffee.CLI_${VERSION}_Linux_arm64.deb" ;;
# Linux .AppImage (upstream AppImage uses "amd64"/"aarch64")
*_amd64.AppImage) out="Coffee.CLI_${VERSION}_Linux_x64.AppImage" ;;
*_aarch64.AppImage) out="Coffee.CLI_${VERSION}_Linux_arm64.AppImage" ;;
# Linux .rpm (uses dot-separated `<name>-<ver>-<release>.<arch>.rpm`)
*.x86_64.rpm) out="Coffee.CLI_${VERSION}_Linux_x64.rpm" ;;
*.aarch64.rpm) out="Coffee.CLI_${VERSION}_Linux_arm64.rpm" ;;
*) echo " skip: $base"; return 0 ;;
esac
cp "$src" "$STAGING/$out"
echo " staged: $base → $out"
}
echo "Bundle scan roots:"
for root in \
target/release/bundle \
target/aarch64-apple-darwin/release/bundle \
target/x86_64-apple-darwin/release/bundle \
; do
[ -d "$root" ] || continue
echo " $root"
while IFS= read -r f; do
rename_one "$f"
done < <(find "$root" -type f \
\( -name "*.exe" -o -name "*.msi" \
-o -name "*.dmg" \
-o -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) )
done
echo ""
echo "Final staged assets:"
ls -la "$STAGING"
echo "staging=$STAGING" >> "$GITHUB_OUTPUT"
# ── Create release if missing, then upload all staged artifacts ──
# Each matrix job races to create the release; `gh release create`
# fails idempotently if another job already created it (caught and
# ignored), then `gh release upload --clobber` overwrites any
# earlier-job placeholder of the same filename.
- name: Upload to GitHub release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
shell: bash
run: |
set -e
TAG="${{ steps.tag.outputs.tag }}"
STAGING="${{ steps.stage.outputs.staging }}"
# Wait briefly in case another matrix job is mid-create.
for i in 1 2 3 4 5 6; do
if gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
break
fi
echo "Release $TAG not yet present (attempt $i/6); waiting 10s..."
sleep 10
done
if ! gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
echo "Creating release $TAG (draft)"
# Releases are created as drafts so the maintainer can review CI
# artifacts and edit the changelog before users see the new
# version. Clicking "Publish release" in the GitHub UI fires the
# release.published event consumed by
# .github/workflows/bump-version-json.yml, which bumps
# Web-Home/version.json on main → CF Pages redeploys → in-app
# update check picks up the new version. Until then, in-app
# users and install scripts still see the previous release.
NOTES_FILE="$GITHUB_WORKSPACE/install/release-notes-template.md"
if [ -f "$NOTES_FILE" ]; then
gh release create "$TAG" --repo "${{ github.repository }}" \
--draft \
--title "Coffee CLI $TAG" \
--notes-file "$NOTES_FILE" \
|| echo "Release already exists (created by sibling matrix job)"
else
gh release create "$TAG" --repo "${{ github.repository }}" \
--draft \
--title "Coffee CLI $TAG" \
--notes "Coffee CLI $TAG. See README for install instructions." \
|| echo "Release already exists (created by sibling matrix job)"
fi
fi
# Upload everything in staging. --clobber overwrites if a previous
# job already pushed an asset with the same name.
shopt -s nullglob
for f in "$STAGING"/*; do
echo "uploading: $(basename "$f")"
gh release upload "$TAG" "$f" --repo "${{ github.repository }}" --clobber
done