Skip to content

feat(worktrees): reflog-backed undo for worktree/branch deletion #508

feat(worktrees): reflog-backed undo for worktree/branch deletion

feat(worktrees): reflog-backed undo for worktree/branch deletion #508

Workflow file for this run

name: CI
on:
push:
branches: [main, electron]
tags: ['v*']
pull_request:
branches: [main, electron]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Deny all permissions by default; each job grants only what it needs.
# This limits blast radius if a step is compromised (supply chain attack).
permissions: {}
jobs:
# ── Lint & typecheck (fast, ubuntu-only) ──────────────────────────────────
lint:
name: Lint & Typecheck
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: actions/cache@v6
with:
path: .turbo
key: turbo-lint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
restore-keys: |
turbo-lint-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
turbo-lint-${{ runner.os }}-
- run: pnpm typecheck
- run: pnpm lint
# Gate only on files/dependencies/binaries — high-confidence categories
# with no legitimate exceptions. Unused-exports/types are excluded
# because packages/ui and e2e/helpers* intentionally export library-style
# helpers that aren't consumed anywhere in this repo yet; run `pnpm knip`
# locally (no flags) to see the full report including those.
- run: pnpm knip --exclude exports,types
# ── Unit / Vite tests (all 3 platforms) ───────────────────────────────────
test:
name: Unit Tests (${{ matrix.os }})
permissions:
contents: read
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-14, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: actions/cache@v6
with:
path: .turbo
key: turbo-test-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
restore-keys: |
turbo-test-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
turbo-test-${{ runner.os }}-
- run: pnpm test --filter=!@sproutgit/e2e
# ── Compute the version this run will build/release ───────────────────────
# Tag pushes use the tag itself. Main-branch pushes derive {major}.{minor}
# from app/package.json (the committed baseline) and auto-increment the
# patch by looking at existing v{major}.{minor}.* tags — so patch resets to
# 0 the next time major/minor is bumped in package.json, instead of growing
# forever off github.run_number regardless of what's actually committed.
version:
name: Compute version
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.compute.outputs.version }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # need full tag history to find the last patch for this major.minor
- uses: actions/setup-node@v6
with:
node-version: 22
- name: Compute version
id: compute
shell: bash
run: |
if [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
VERSION="${GITHUB_REF_NAME#v}"
elif [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
MAJOR_MINOR=$(node -e "console.log(require('./app/package.json').version.split('.').slice(0,2).join('.'))")
ESCAPED="${MAJOR_MINOR//./\\.}"
# `|| true` on the pipeline: grep exits 1 when no v{major}.{minor}.*
# tags exist yet (the first release for a new major.minor), and
# GitHub Actions' bash steps run with -e/pipefail by default — an
# unmatched grep would otherwise abort the step instead of
# correctly falling through to patch 0 below.
LAST_PATCH=$(git tag -l "v${MAJOR_MINOR}.*" | sed -E "s/^v${ESCAPED}\.//" | grep -E '^[0-9]+$' | sort -n | tail -1 || true)
NEXT_PATCH=$([ -z "$LAST_PATCH" ] && echo 0 || echo $((LAST_PATCH + 1)))
VERSION="${MAJOR_MINOR}.${NEXT_PATCH}"
else
VERSION="0.0.0-dev.${GITHUB_RUN_NUMBER}"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
# ── Build packages + app (all 3 platforms) ────────────────────────────────
build:
name: Build (${{ matrix.platform }})
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
needs: [lint, test, version]
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
# macOS ships both arches from one arm64 host: cctools/lipo can
# target x64 without an x64 machine, unlike Windows/Linux native
# modules (node-pty) which need to actually compile on that arch.
- os: macos-14
platform: mac
arch: universal
- os: windows-latest
platform: win
arch: x64
# Hosted arm64 runner so node-pty's node-gyp fallback compiles
# natively instead of cross-compiling. Confirm availability/plan
# requirements for this runner label before relying on it.
- os: windows-11-arm
platform: win
arch: arm64
- os: ubuntu-latest
platform: linux
arch: x64
- os: ubuntu-24.04-arm
platform: linux
arch: arm64
runs-on: ${{ matrix.os }}
steps:
# runner.temp is only available within steps, not job-level env.
- name: Set Electron cache paths
shell: bash
run: |
echo "ELECTRON_CACHE=${{ runner.temp }}/electron-cache" >> "$GITHUB_ENV"
echo "ELECTRON_BUILDER_CACHE=${{ runner.temp }}/electron-builder-cache" >> "$GITHUB_ENV"
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- uses: actions/cache@v6
with:
path: .turbo
key: turbo-build-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
restore-keys: |
turbo-build-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
turbo-build-${{ runner.os }}-
- uses: actions/cache@v6
with:
path: |
${{ runner.temp }}/electron-cache
${{ runner.temp }}/electron-builder-cache
key: electron-${{ runner.os }}-${{ hashFiles('app/package.json') }}
- run: pnpm build --filter=!@sproutgit/app --filter=!sproutgit-website
# Stamp app/package.json with the version the release job will actually
# tag (computed once in the `version` job above, shared by every
# platform in this matrix), so electron-builder embeds a matching
# version in the artifacts instead of the placeholder committed to the repo.
- name: Set package version
working-directory: app
shell: bash
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
pkg.version = process.env.VERSION;
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
"
- name: Package app
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
working-directory: app
# Explicit bash: windows-latest/windows-11-arm default to pwsh, which
# can't parse the `if` below (Git Bash ships on all GitHub-hosted
# Windows runners, so this works cross-platform).
shell: bash
# Run vite build then electron-builder separately so we can pass
# --publish never. This prevents electron-builder from trying to
# upload to GitHub Releases (the release job does that), and ensures
# GH_TOKEN is never needed in the build matrix — narrowing the
# attack surface if a dependency is compromised.
run: |
pnpm exec electron-vite build
if [ "${{ matrix.arch }}" = "universal" ]; then
pnpm exec electron-builder --${{ matrix.platform }} --publish never
else
pnpm exec electron-builder --${{ matrix.platform }} --${{ matrix.arch }} --publish never
fi
- uses: actions/upload-artifact@v7
with:
name: SproutGit-${{ matrix.platform }}-${{ matrix.arch }}
path: |
app/dist-electron/*.dmg
app/dist-electron/*.zip
app/dist-electron/*.exe
app/dist-electron/*.AppImage
app/dist-electron/*.deb
app/dist-electron/latest*.yml
if-no-files-found: ignore
# ── E2E tests (all 3 platforms) ───────────────────────────────────────────
e2e:
name: E2E Tests (${{ matrix.platform }})
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
needs: [lint, test]
permissions:
contents: read
strategy:
fail-fast: false
matrix:
include:
- os: macos-14
platform: mac
- os: windows-latest
platform: win
- os: ubuntu-latest
platform: linux
runs-on: ${{ matrix.os }}
steps:
# runner.temp is only available within steps, not job-level env.
- name: Set Electron cache path
shell: bash
run: echo "ELECTRON_CACHE=${{ runner.temp }}/electron-cache" >> "$GITHUB_ENV"
- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
# Restore Electron binary cache BEFORE pnpm install so the postinstall
# script can use the cached zip instead of downloading from the network.
# If placed after install, the cache is restored too late to help.
- uses: actions/cache@v6
with:
path: |
${{ runner.temp }}/electron-cache
key: electron-${{ runner.os }}-${{ hashFiles('app/package.json') }}
- run: pnpm install --frozen-lockfile
- name: Install Electron system dependencies (Linux)
if: runner.os == 'Linux'
run: |
sudo apt-get update -q
sudo apt-get install -y -q \
xvfb \
libgbm1 libnss3 libatk1.0-0 libatk-bridge2.0-0 \
libdrm2 libxkbcommon0 libxdamage1 libxrandr2 libxfixes3 \
libxcomposite1 libxcursor1 libxi6 libxss1 libxtst6 \
libasound2t64 libpango-1.0-0 libpangocairo-1.0-0 \
libdbus-1-3 libglib2.0-0
# Build all packages, then the renderer (no electron-builder packaging needed)
- run: pnpm build --filter=!@sproutgit/app --filter=!sproutgit-website
- name: Build renderer
working-directory: app
run: pnpm exec electron-vite build
# Linux needs a virtual display for Electron
- name: Run E2E tests (Linux)
if: matrix.platform == 'linux'
run: xvfb-run --auto-servernum --server-args="-screen 0 1280x800x24" pnpm --filter @sproutgit/e2e test
- name: Run E2E tests (macOS / Windows)
if: matrix.platform != 'linux'
run: pnpm --filter @sproutgit/e2e test
# ── Diagnostics on failure ───────────────────────────────────────────
- name: Dump E2E diagnostics (Linux/macOS)
if: failure() && runner.os != 'Windows'
run: |
echo "=== Chromium internal log (--log-file) ==="
cat "$(node -e "const{tmpdir}=require('os');process.stdout.write(tmpdir())")/electron-e2e-chromium.log" 2>/dev/null | tail -n 200 || echo "(not found)"
echo ""
echo "=== electron-log main.log ==="
if [ "$(uname)" = "Darwin" ]; then
cat "${HOME}/Library/Logs/SproutGit/main.log" 2>/dev/null | tail -n 200 || echo "(not found)"
else
cat "${HOME}/.config/SproutGit/logs/main.log" 2>/dev/null | tail -n 200 || echo "(not found)"
echo ""
echo "=== Electron binary / ldd ==="
ELECTRON_BIN=$(node -e "process.stdout.write(require('electron'))" 2>/dev/null) || true
ldd "${ELECTRON_BIN}" 2>/dev/null | grep "not found" || echo "(no missing libs)"
echo "DISPLAY=${DISPLAY}"
fi
echo ""
echo "=== sg-e2e-latest.log ==="
cat "$(node -e "const{tmpdir}=require('os');process.stdout.write(tmpdir())")/sg-e2e-latest.log" 2>/dev/null || echo "(not found)"
working-directory: e2e
- name: Dump E2E diagnostics (Windows)
if: failure() && runner.os == 'Windows'
shell: pwsh
run: |
$tmp = [System.IO.Path]::GetTempPath()
Write-Host "=== Chromium internal log (--log-file) ==="
$chromLog = Join-Path $tmp "electron-e2e-chromium.log"
if (Test-Path $chromLog) { Get-Content $chromLog | Select-Object -Last 200 } else { Write-Host "(not found)" }
Write-Host ""
Write-Host "=== electron-log main.log ==="
$appLog = "$env:APPDATA\SproutGit\logs\main.log"
if (Test-Path $appLog) { Get-Content $appLog | Select-Object -Last 200 } else { Write-Host "(not found)" }
Write-Host ""
Write-Host "=== sg-e2e-latest.log ==="
$sgLog = Join-Path $tmp "sg-e2e-latest.log"
if (Test-Path $sgLog) { Get-Content $sgLog } else { Write-Host "(not found)" }
working-directory: e2e
# ────────────────────────────────────────────────────────────────────
- uses: actions/upload-artifact@v7
if: failure()
with:
name: e2e-results-${{ matrix.platform }}
path: |
e2e/test-results/
tmp/playwright-report/
if-no-files-found: ignore
# ── Release ───────────────────────────────────────────────────────────────
release:
name: Create release
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
needs: [build, e2e, version]
runs-on: ubuntu-latest
permissions:
contents: write # needed to create the GitHub Release
steps:
- uses: actions/download-artifact@v8
with:
path: artifacts
merge-multiple: true
- uses: softprops/action-gh-release@v3
with:
tag_name: v${{ needs.version.outputs.version }}
files: artifacts/**
generate_release_notes: true
prerelease: ${{ contains(github.ref_name, '-') }}