Skip to content

docs: add git self-sync to persistent agent definitions (Stories 81.1… #3310

docs: add git self-sync to persistent agent definitions (Stories 81.1…

docs: add git self-sync to persistent agent definitions (Stories 81.1… #3310

Workflow file for this run

name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
# Detect which files changed to skip expensive jobs on docs-only PRs.
# Push-to-main always runs the full suite (no path filtering).
changes:
name: Detect Changes
runs-on: ubuntu-latest
outputs:
code: ${{ steps.filter.outputs.code }}
docker: ${{ steps.filter.outputs.docker }}
perf: ${{ steps.filter.outputs.perf }}
scripts: ${{ steps.filter.outputs.scripts }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
filters: |
code:
- '**.go'
- 'go.mod'
- 'go.sum'
- '.golangci.yml'
- '.github/workflows/**'
- 'scripts/hooks/**'
docker:
- 'Dockerfile*'
- 'docker-compose*'
perf:
- 'internal/core/**'
- 'internal/adapters/textfile/**'
- 'go.mod'
scripts:
- 'scripts/**'
- 'justfile'
# Lightweight pass for non-code PRs so required checks aren't stuck pending.
skip-pass:
name: CI Skipped (no code changes)
needs: changes
if: github.event_name == 'pull_request' && needs.changes.outputs.code != 'true' && needs.changes.outputs.docker != 'true'
runs-on: ubuntu-latest
steps:
- run: echo "No code changes — Go CI skipped."
# ShellCheck lints shell scripts for common bugs (unquoted vars, missing error
# handling, bashisms in sh scripts, etc.). Currently informational — does NOT
# block merge. To promote to required:
# 1. Fix existing ShellCheck warnings in scripts/*.sh
# 2. Remove `continue-on-error: true` below
# 3. Add "ShellCheck" to the branch ruleset's required status checks
shellcheck:
name: ShellCheck
needs: changes
if: needs.changes.outputs.scripts == 'true' || github.event_name == 'push'
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v6
- name: Install ShellCheck
run: sudo apt-get update && sudo apt-get install -y shellcheck
- name: Lint top-level scripts
run: shellcheck scripts/*.sh
- name: Lint hook scripts
run: shellcheck scripts/hooks/*.sh
quality-gate:
name: Quality Gate
needs: changes
if: needs.changes.outputs.code == 'true' || needs.changes.outputs.docker == 'true' || github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
cache-dependency-path: go.sum
- name: Install gofumpt
run: go install mvdan.cc/gofumpt@v0.7.0
- name: Check formatting (gofumpt)
run: |
UNFORMATTED=$(gofumpt -l .)
if [ -n "$UNFORMATTED" ]; then
echo "::error::Files need formatting with gofumpt:"
echo "$UNFORMATTED"
exit 1
fi
- name: Run go vet
run: go vet ./...
- name: Run govulncheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
- name: Run golangci-lint
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
with:
version: v2.10.1
- name: Run tests with coverage and race detector
run: go test ./... -v -count=1 -race -coverprofile=coverage.out -covermode=atomic
- name: Display coverage summary
run: go tool cover -func=coverage.out
- name: Enforce coverage floor
env:
COVERAGE_THRESHOLD: '75'
run: |
COVERAGE=$(go tool cover -func=coverage.out | grep '^total:' | awk '{print $NF}' | tr -d '%')
echo "Total coverage: ${COVERAGE}% (threshold: ${COVERAGE_THRESHOLD}%)"
if [ "$(echo "$COVERAGE < $COVERAGE_THRESHOLD" | bc -l)" -eq 1 ]; then
echo "::error::Coverage ${COVERAGE}% is below the ${COVERAGE_THRESHOLD}% threshold"
exit 1
fi
- name: Post coverage comment on PR
if: github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const { execSync } = require('child_process');
const summary = execSync('go tool cover -func=coverage.out').toString();
const totalLine = summary.split('\n').find(l => l.startsWith('total:'));
const coverage = totalLine ? totalLine.match(/[\d.]+%/)?.[0] : 'unknown';
const body = `## Coverage Report\n\n**Total coverage: ${coverage}**\n\n<details>\n<summary>Package breakdown</summary>\n\n\`\`\`\n${summary}\`\`\`\n</details>`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.startsWith('## Coverage Report'));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
- name: Validate build
run: go build -o /dev/null ./cmd/threedoors
benchmarks:
name: Performance Benchmarks
needs: changes
if: github.event_name == 'push' || (needs.changes.outputs.code == 'true' && needs.changes.outputs.perf == 'true')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
cache-dependency-path: go.sum
- name: Run benchmarks
run: go test -bench=. -benchmem -count=3 -run='^$' ./internal/core/ ./internal/adapters/textfile/ | tee benchmark-results.txt
- name: Validate NFR13 (<100ms threshold)
run: go test -run='NFR13' -v -count=1 ./internal/core/ ./internal/adapters/textfile/
- name: Upload benchmark results
if: always()
uses: actions/upload-artifact@v7
with:
name: benchmark-results
path: benchmark-results.txt
retention-days: 90
test-docker-e2e:
name: Docker E2E Tests
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Cache Docker layers
uses: actions/cache@v5
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ hashFiles('Dockerfile.test', 'go.mod', 'go.sum') }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Build Docker test image
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.test
load: true
tags: threedoors-test:latest
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
- name: Create test-results directory
run: mkdir -p test-results
- name: Run Docker E2E tests
run: docker compose -f docker-compose.test.yml run --rm -T test
- name: Check for golden file diffs
if: always()
run: |
if git diff --name-only | grep -q '\.golden$'; then
echo "::warning::Golden file changes detected:"
git diff -- '*.golden'
else
echo "No golden file changes detected."
fi
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: docker-e2e-results
path: test-results/
retention-days: 14
# Rotate buildx cache to prevent unbounded growth
- name: Rotate cache
if: always()
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache 2>/dev/null || true
build-binaries:
name: Build Binaries
needs: quality-gate
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
cache-dependency-path: go.sum
- name: Generate version
id: version
run: |
VERSION="0.1.0-alpha.$(date -u +%Y%m%d).$(date -u +%H%M%S).${GITHUB_SHA::7}"
TAG="alpha-$(date -u +%Y%m%d)-$(date -u +%H%M%S)-${GITHUB_SHA::7}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Build binaries
run: |
LDFLAGS="-X main.version=${{ steps.version.outputs.version }}"
GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o threedoors-darwin-arm64 ./cmd/threedoors
GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o threedoors-darwin-amd64 ./cmd/threedoors
GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o threedoors-linux-amd64 ./cmd/threedoors
- name: Build alpha binaries
run: |
LDFLAGS="-X main.version=${{ steps.version.outputs.version }} -X main.channel=alpha"
GOOS=darwin GOARCH=arm64 go build -ldflags "$LDFLAGS" -o threedoors-a-darwin-arm64 ./cmd/threedoors
GOOS=darwin GOARCH=amd64 go build -ldflags "$LDFLAGS" -o threedoors-a-darwin-amd64 ./cmd/threedoors
GOOS=linux GOARCH=amd64 go build -ldflags "$LDFLAGS" -o threedoors-a-linux-amd64 ./cmd/threedoors
- name: Upload binaries
uses: actions/upload-artifact@v7
with:
name: binaries
path: threedoors-*
retention-days: 14
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
sign-and-notarize:
name: Sign & Notarize
needs: build-binaries
if: github.event_name == 'push' && vars.SIGNING_ENABLED == 'true'
environment: release
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- name: Download binaries
uses: actions/download-artifact@v8
with:
name: binaries
- name: Import certificates
env:
APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_INSTALLER_CERTIFICATE_P12: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_P12 }}
APPLE_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_PASSWORD }}
run: |
# Create temporary keychain
security create-keychain -p "" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "" build.keychain
# Import application certificate
echo "$APPLE_CERTIFICATE_P12" | base64 --decode > cert.p12
security import cert.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
rm cert.p12
# Import installer certificate (for pkg signing)
echo "$APPLE_INSTALLER_CERTIFICATE_P12" | base64 --decode > installer-cert.p12
security import installer-cert.p12 -k build.keychain -P "$APPLE_INSTALLER_CERTIFICATE_PASSWORD" -T /usr/bin/pkgbuild -T /usr/bin/productbuild -T /usr/bin/productsign
rm installer-cert.p12
# Install Apple Developer ID G2 intermediate certificate
curl -sfo /tmp/DeveloperIDG2CA.cer https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer
security add-certificates -k build.keychain /tmp/DeveloperIDG2CA.cer
rm /tmp/DeveloperIDG2CA.cer
# Allow Apple tools (codesign, pkgbuild, productsign) to access keychain
security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain
- name: Verify certificate import
run: |
echo "Listing signing identities:"
security find-identity -v build.keychain
echo ""
echo "Checking for installer identity:"
security find-identity -v build.keychain | grep -i "installer" || echo "WARNING: No installer identity found in keychain"
- name: Sign darwin binaries
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: |
codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --identifier "com.arcavenae.threedoors" --timestamp threedoors-darwin-arm64
codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --identifier "com.arcavenae.threedoors" --timestamp threedoors-darwin-amd64
codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --identifier "com.arcavenae.threedoors-a" --timestamp threedoors-a-darwin-arm64
codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --identifier "com.arcavenae.threedoors-a" --timestamp threedoors-a-darwin-amd64
- name: Verify signatures
run: |
codesign --verify --deep --strict threedoors-darwin-arm64
codesign --verify --deep --strict threedoors-darwin-amd64
codesign --verify --deep --strict threedoors-a-darwin-arm64
codesign --verify --deep --strict threedoors-a-darwin-amd64
- name: Build app bundles
run: |
VERSION="${{ needs.build-binaries.outputs.version }}"
chmod +x scripts/create-app.sh
./scripts/create-app.sh threedoors-darwin-arm64 "$VERSION" .
mv ThreeDoors.app ThreeDoors-arm64.app
./scripts/create-app.sh threedoors-darwin-amd64 "$VERSION" .
mv ThreeDoors.app ThreeDoors-amd64.app
- name: Sign app bundles
env:
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
run: |
codesign --force --deep --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp ThreeDoors-arm64.app
codesign --force --deep --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp ThreeDoors-amd64.app
- name: Build dmg images
run: |
VERSION="${{ needs.build-binaries.outputs.version }}"
chmod +x scripts/create-dmg.sh
./scripts/create-dmg.sh ThreeDoors-arm64.app "$VERSION" threedoors-arm64.dmg
./scripts/create-dmg.sh ThreeDoors-amd64.app "$VERSION" threedoors-amd64.dmg
- name: Build pkg installers
env:
APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY }}
run: |
# Verify installer identity exists in keychain before building
if ! security find-identity -v build.keychain | grep -q "$APPLE_INSTALLER_IDENTITY"; then
echo "::error::APPLE_INSTALLER_IDENTITY not found in keychain. Expected format: 'Developer ID Installer: Name (TeamID)'"
echo "Available identities:"
security find-identity -v build.keychain
exit 1
fi
VERSION="${{ needs.build-binaries.outputs.version }}"
chmod +x scripts/create-pkg.sh
./scripts/create-pkg.sh threedoors-darwin-arm64 "$VERSION" "$APPLE_INSTALLER_IDENTITY" threedoors-arm64.pkg
./scripts/create-pkg.sh threedoors-darwin-amd64 "$VERSION" "$APPLE_INSTALLER_IDENTITY" threedoors-amd64.pkg
- name: Notarize and staple all artifacts
env:
APPLE_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_NOTARIZATION_APPLE_ID }}
APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }}
APPLE_NOTARIZATION_TEAM_ID: ${{ secrets.APPLE_NOTARIZATION_TEAM_ID }}
run: |
for ARTIFACT in threedoors-arm64.pkg threedoors-amd64.pkg threedoors-arm64.dmg threedoors-amd64.dmg; do
echo "Notarizing $ARTIFACT..."
xcrun notarytool submit "$ARTIFACT" \
--apple-id "$APPLE_NOTARIZATION_APPLE_ID" \
--password "$APPLE_NOTARIZATION_PASSWORD" \
--team-id "$APPLE_NOTARIZATION_TEAM_ID" \
--wait --timeout 14400
xcrun stapler staple "$ARTIFACT"
done
- name: Upload signed binaries and installers
uses: actions/upload-artifact@v7
with:
name: signed-binaries
path: |
threedoors-darwin-arm64
threedoors-darwin-amd64
threedoors-a-darwin-arm64
threedoors-a-darwin-amd64
threedoors-arm64.pkg
threedoors-amd64.pkg
threedoors-arm64.dmg
threedoors-amd64.dmg
retention-days: 14
- name: Cleanup keychain
if: always()
run: security delete-keychain build.keychain || true
release:
name: Create Release
needs: [build-binaries, sign-and-notarize]
if: github.event_name == 'push' && !cancelled() && needs.build-binaries.result == 'success'
environment: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# Try signed binaries first, fall back to unsigned
- name: Download signed binaries
if: needs.sign-and-notarize.result == 'success'
uses: actions/download-artifact@v8
with:
name: signed-binaries
- name: Download unsigned binaries (fallback)
if: needs.sign-and-notarize.result != 'success'
uses: actions/download-artifact@v8
with:
name: binaries
- name: Download all binaries (for linux)
if: needs.sign-and-notarize.result == 'success'
uses: actions/download-artifact@v8
with:
name: binaries
path: unsigned-binaries
- name: Copy linux binaries from unsigned artifacts
if: needs.sign-and-notarize.result == 'success'
run: |
cp unsigned-binaries/threedoors-linux-amd64 .
cp unsigned-binaries/threedoors-a-linux-amd64 .
rm -rf unsigned-binaries
- name: List release files
run: ls -la threedoors-* 2>/dev/null || true
- name: Create GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.build-binaries.outputs.tag }}
VERSION: ${{ needs.build-binaries.outputs.version }}
COMMIT: ${{ github.sha }}
SIGNED: ${{ needs.sign-and-notarize.result == 'success' && 'Yes (Apple Developer ID)' || 'No (unsigned)' }}
run: |
BODY="Automated alpha release from merge to main.
**Version:** ${VERSION}
**Commit:** ${COMMIT}
**Signed:** ${SIGNED}
**Binaries:**
- \`threedoors-darwin-arm64\` — macOS Apple Silicon
- \`threedoors-darwin-amd64\` — macOS Intel
- \`threedoors-linux-amd64\` — Linux x86_64
- \`threedoors-a-darwin-arm64\` — Alpha macOS Apple Silicon
- \`threedoors-a-darwin-amd64\` — Alpha macOS Intel
- \`threedoors-a-linux-amd64\` — Alpha Linux x86_64"
if [ "$SIGNED" = "Yes (Apple Developer ID)" ]; then
BODY="${BODY}
- \`threedoors-arm64.pkg\` — macOS Installer (Apple Silicon)
- \`threedoors-amd64.pkg\` — macOS Installer (Intel)
- \`threedoors-arm64.dmg\` — macOS Disk Image (Apple Silicon)
- \`threedoors-amd64.dmg\` — macOS Disk Image (Intel)"
fi
BODY="${BODY}
**Homebrew (alpha):** \`brew install arcavenae/tap/threedoors-a\`"
ASSETS=()
for f in threedoors-*; do
[ -f "$f" ] && ASSETS+=("$f")
done
gh release create "$TAG" \
--title "Alpha ${TAG}" \
--notes "$BODY" \
--prerelease \
"${ASSETS[@]}"
# Gated by repository variable — set vars.ALPHA_TAP_ENABLED to 'true'
# in GitHub Settings > Actions > Variables to activate alpha formula publishing.
# The alpha GitHub release is always created; only the tap push is controlled.
- name: Update alpha Homebrew formula
if: vars.ALPHA_TAP_ENABLED == 'true'
env:
HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
run: |
VERSION="${{ needs.build-binaries.outputs.version }}"
TAG="${{ needs.build-binaries.outputs.tag }}"
BASE_URL="https://github.com/arcavenae/ThreeDoors/releases/download/${TAG}"
# Compute SHA256 checksums for alpha binaries
SHA_ARM64=$(sha256sum threedoors-a-darwin-arm64 | cut -d' ' -f1)
SHA_AMD64=$(sha256sum threedoors-a-darwin-amd64 | cut -d' ' -f1)
SHA_LINUX=$(sha256sum threedoors-a-linux-amd64 | cut -d' ' -f1)
# Generate formula
cat > threedoors-a.rb <<FORMULA
class ThreedoorsA < Formula
desc "TUI task manager — alpha channel (updated on every main push)"
homepage "https://github.com/arcavenae/ThreeDoors"
version "${VERSION}"
license "MIT"
if OS.mac? && Hardware::CPU.arm?
url "${BASE_URL}/threedoors-a-darwin-arm64"
sha256 "${SHA_ARM64}"
elsif OS.mac?
url "${BASE_URL}/threedoors-a-darwin-amd64"
sha256 "${SHA_AMD64}"
elsif OS.linux?
url "${BASE_URL}/threedoors-a-linux-amd64"
sha256 "${SHA_LINUX}"
end
def install
if OS.mac? && Hardware::CPU.arm?
bin.install "threedoors-a-darwin-arm64" => "threedoors-a"
elsif OS.mac?
bin.install "threedoors-a-darwin-amd64" => "threedoors-a"
elsif OS.linux?
bin.install "threedoors-a-linux-amd64" => "threedoors-a"
end
end
test do
assert_match "ThreeDoors", shell_output("#{bin}/threedoors-a --version 2>&1")
end
end
FORMULA
# Remove leading whitespace from heredoc indentation
sed -i 's/^ //' threedoors-a.rb
# Validate Ruby syntax before pushing
ruby -c threedoors-a.rb
# Clone tap repo, update formula, push
git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/arcavenae/homebrew-tap.git" tap-repo
mkdir -p tap-repo/Formula
cp threedoors-a.rb tap-repo/Formula/threedoors-a.rb
cd tap-repo
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/threedoors-a.rb
if git diff --cached --quiet; then
echo "Formula unchanged, skipping push"
else
git commit -m "chore(formula): update threedoors-a to ${TAG}"
git push origin main
fi
- name: Clean up old alpha releases
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release list --limit 200 \
| grep 'alpha-' \
| tail -n +31 \
| awk '{print $1}' \
| xargs -I{} gh release delete {} --yes --cleanup-tag