Skip to content

feat: Implement command history, autocomplete, and esc to back out (#71) #120

feat: Implement command history, autocomplete, and esc to back out (#71)

feat: Implement command history, autocomplete, and esc to back out (#71) #120

Workflow file for this run

# PR Validation Workflow
#
# This workflow validates pull requests to the main branch by running tests,
# checking code coverage, and enforcing quality gates before allowing merge.
#
# Triggers: Pull requests targeting 'main' branch
# Performance Target: ≤10 minutes total
# Coverage Strategy: Prevent regression (no decrease), track line coverage
name: PR Validation
on:
push:
branches: [main] # Build on main to populate cache for PRs
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
# Cancel in-progress runs for the same PR to save resources
concurrency:
group: pr-validation-${{ github.ref }}
cancel-in-progress: true
# Environment variables available to all jobs
env:
DOTNET_VERSION: '10.0.x'
# Maximum allowed coverage decrease (percentage points)
# PRs that decrease coverage by more than this will fail
MAX_COVERAGE_DECREASE: 0.5
jobs:
select-runner:
name: Select Linux Runner
runs-on: ubuntu-latest
outputs:
linux: ${{ steps.detect.outputs.linux }}
is-self-hosted: ${{ steps.detect.outputs.is-self-hosted }}
steps:
- name: Detect available self-hosted runners
id: detect
env:
GH_TOKEN: ${{ secrets.RUNNER_QUERY_TOKEN }}
run: |
echo "🔍 Checking for online self-hosted Linux runners"
# Fallback to ubuntu-latest if token not available or API fails
if [ -z "$GH_TOKEN" ]; then
echo "⚠️ RUNNER_QUERY_TOKEN secret not configured; using ubuntu-latest"
echo "linux=ubuntu-latest" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! RUNNERS=$(gh api repos/${{ github.repository }}/actions/runners --paginate 2>&1); then
echo "⚠️ Unable to query runner API; using ubuntu-latest"
echo "Error: $RUNNERS"
echo "linux=ubuntu-latest" >> "$GITHUB_OUTPUT"
exit 0
fi
# Debug: show what we got
echo "📊 Runner API response:"
echo "$RUNNERS" | jq -r '.runners[]? | " - \(.name): status=\(.status), os=\(.os), labels=\(.labels | map(.name) | join(","))"'
# Match runners with status=online, os=Linux (case-insensitive), and self-hosted label
LINUX_COUNT=$(echo "$RUNNERS" | jq '[.runners[]? | select(.status == "online" and (.os | ascii_downcase) == "linux" and (.labels[]?.name == "self-hosted"))] | length')
if [ "$LINUX_COUNT" -gt 0 ]; then
echo "🤖 Found $LINUX_COUNT online self-hosted Linux runner(s); selecting self-hosted"
echo "linux=self-hosted" >> "$GITHUB_OUTPUT"
echo "is-self-hosted=true" >> "$GITHUB_OUTPUT"
else
echo "☁️ No online self-hosted Linux runners found; using ubuntu-latest"
echo "linux=ubuntu-latest" >> "$GITHUB_OUTPUT"
echo "is-self-hosted=false" >> "$GITHUB_OUTPUT"
fi
# Job 1: Build
# Compiles code and ensures zero warnings
# Performance Target: ≤2 minutes
build:
name: Build
needs: select-runner
runs-on: ${{ needs.select-runner.outputs.linux }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for better caching
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Cache NuGet packages
# Skip for self-hosted runners - packages persist locally, no need for remote cache
if: needs.select-runner.outputs.is-self-hosted != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
# v2: Fresh cache after fixing RuntimeIdentifier-based package selection
key: ${{ runner.os }}-nuget-v2-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-v2-
- name: Restore dependencies
run: dotnet restore -r linux-x64
# -r linux-x64: Triggers platform-specific Whisper.net.Runtime (~200MB)
# instead of Whisper.net.AllRuntimes (~2GB). See csproj RuntimeIdentifier conditions.
- name: Build solution
run: |
dotnet build \
--configuration Release \
--no-restore \
--warnaserror
# --no-restore: Uses packages from restore step
# --warnaserror: Treat warnings as errors
# Job 2: Test
# Runs all unit and integration tests
# Performance Target: ≤5 minutes
# Depends on: build (ensures code compiles before testing)
test:
name: Test
runs-on: ${{ needs.select-runner.outputs.linux }}
needs: [select-runner, build]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Cache NuGet packages
if: needs.select-runner.outputs.is-self-hosted != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
# v2: Fresh cache after fixing RuntimeIdentifier-based package selection
key: ${{ runner.os }}-nuget-v2-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-v2-
- name: Restore dependencies
run: dotnet restore -r linux-x64
# -r linux-x64: Platform-specific packages. See csproj RuntimeIdentifier conditions.
- name: Run tests
run: |
dotnet test \
--configuration Release \
--no-restore \
--logger "trx;LogFileName=test-results.trx" \
--verbosity normal
- name: Upload test results
if: always() # Upload even if tests fail for debugging
uses: actions/upload-artifact@v4
with:
name: test-results
path: '**/TestResults/*.trx'
retention-days: 7
# Job 3: Coverage
# Calculates code coverage and enforces 80% threshold
# Performance Target: ≤3 minutes
# Depends on: build (needs compiled code for coverage analysis)
coverage:
name: Code Coverage
runs-on: ${{ needs.select-runner.outputs.linux }}
needs: [select-runner, build]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history for baseline comparison
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Cache NuGet packages
if: needs.select-runner.outputs.is-self-hosted != 'true'
uses: actions/cache@v4
with:
path: ~/.nuget/packages
# v2: Fresh cache after fixing RuntimeIdentifier-based package selection
key: ${{ runner.os }}-nuget-v2-${{ hashFiles('**/*.csproj') }}
restore-keys: |
${{ runner.os }}-nuget-v2-
- name: Restore dependencies
run: dotnet restore -r linux-x64
# -r linux-x64: Platform-specific packages. See csproj RuntimeIdentifier conditions.
- name: Run tests with coverage
run: |
dotnet test \
--configuration Release \
--no-restore \
--collect:"XPlat Code Coverage" \
--results-directory ./coverage
- name: Install ReportGenerator
run: |
dotnet tool install --global dotnet-reportgenerator-globaltool
echo "$HOME/.dotnet/tools" >> $GITHUB_PATH
- name: Generate coverage report
run: |
reportgenerator \
-reports:"./coverage/**/coverage.cobertura.xml" \
-targetdir:"./coverage/report" \
-reporttypes:"Html;Cobertura;TextSummary"
- name: Parse coverage percentage
id: coverage
run: |
# Extract line coverage percentage from Cobertura XML
COVERAGE=$(grep -oP 'line-rate="\K[0-9.]+' ./coverage/report/Cobertura.xml | head -1)
# Convert to percentage using awk (no bc dependency)
COVERAGE_PERCENT=$(awk "BEGIN {printf \"%.0f\", $COVERAGE * 100}")
echo "percentage=$COVERAGE_PERCENT" >> $GITHUB_OUTPUT
echo "📊 Line Coverage: $COVERAGE_PERCENT%"
# Display full coverage summary (line, branch, method)
cat ./coverage/report/Summary.txt
# Retrieve baseline coverage from cache for comparison
- name: Restore baseline coverage
uses: actions/cache@v4
id: baseline-cache
with:
path: ./coverage/baseline.txt
key: coverage-baseline-${{ github.base_ref }}
- name: Check coverage regression
if: steps.baseline-cache.outputs.cache-hit == 'true'
run: |
CURRENT_COVERAGE=${{ steps.coverage.outputs.percentage }}
BASELINE_COVERAGE=$(cat ./coverage/baseline.txt || echo "0")
MAX_DECREASE=${{ env.MAX_COVERAGE_DECREASE }}
# Calculate the difference using awk (no bc dependency)
DIFF=$(awk "BEGIN {printf \"%.1f\", $CURRENT_COVERAGE - $BASELINE_COVERAGE}")
echo "📊 Current line coverage: $CURRENT_COVERAGE%"
echo "📊 Baseline line coverage: $BASELINE_COVERAGE%"
echo "📊 Change: $DIFF percentage points"
echo "📊 Max allowed decrease: -$MAX_DECREASE percentage points"
# Check if coverage decreased more than allowed using awk
REGRESSION=$(awk "BEGIN {print ($DIFF < -$MAX_DECREASE) ? 1 : 0}")
if [ "$REGRESSION" -eq 1 ]; then
echo "❌ Coverage regression detected: decreased by $DIFF percentage points"
echo "Please add tests to maintain or improve coverage."
exit 1
else
echo "✅ Coverage check passed: no significant regression"
# Check if coverage improved
IMPROVED=$(awk "BEGIN {print ($DIFF > 0) ? 1 : 0}")
if [ "$IMPROVED" -eq 1 ]; then
echo "🎉 Coverage improved by $DIFF percentage points!"
fi
fi
- name: Upload coverage report
if: always() # Upload even if threshold check fails
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: ./coverage/report/
retention-days: 30
- name: Calculate coverage diff
id: coverage-diff
if: steps.baseline-cache.outputs.cache-hit == 'true'
run: |
CURRENT_COVERAGE=${{ steps.coverage.outputs.percentage }}
BASELINE_COVERAGE=$(cat ./coverage/baseline.txt || echo "0")
DIFF=$((CURRENT_COVERAGE - BASELINE_COVERAGE))
echo "diff=$DIFF" >> $GITHUB_OUTPUT
echo "baseline=$BASELINE_COVERAGE" >> $GITHUB_OUTPUT
echo "Coverage diff: ${DIFF}% (baseline: ${BASELINE_COVERAGE}%, current: ${CURRENT_COVERAGE}%)"
- name: Comment PR with coverage diff
if: |
steps.coverage-diff.outputs.diff != '' &&
(steps.coverage-diff.outputs.diff >= 1 || steps.coverage-diff.outputs.diff <= -1)
uses: actions/github-script@v7
with:
script: |
const diff = ${{ steps.coverage-diff.outputs.diff }};
const baseline = ${{ steps.coverage-diff.outputs.baseline }};
const current = ${{ steps.coverage.outputs.percentage }};
const maxDecrease = ${{ env.MAX_COVERAGE_DECREASE }};
const emoji = diff >= 0 ? '📈' : '📉';
const sign = diff >= 0 ? '+' : '';
const regressionCheck = diff >= -maxDecrease;
const status = regressionCheck ? '✅' : '❌';
const body = `## ${emoji} Code Coverage Report\n\n` +
`${status} **Current Line Coverage:** ${current}%\n` +
`📊 **Baseline Coverage:** ${baseline}%\n` +
`${emoji} **Change:** ${sign}${diff} percentage points\n\n` +
`**Regression Check:** ` + (regressionCheck ? '✅ Passed' : `❌ Failed (decreased by more than ${maxDecrease}%)`) + '\n\n' +
`*Line coverage is the primary metric. See artifacts for branch and method coverage details.*\n\n` +
`<details>\n<summary>📊 Full Coverage Report</summary>\n\n` +
`Download the complete coverage report (HTML, line/branch/method metrics) from the workflow artifacts.\n` +
`</details>`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body
});
- name: Save current coverage as baseline
if: github.event.pull_request.merged == true
run: |
echo "${{ steps.coverage.outputs.percentage }}" > ./coverage/baseline.txt
- name: Update baseline cache
if: github.event.pull_request.merged == true
uses: actions/cache/save@v4
with:
path: ./coverage/baseline.txt
key: coverage-baseline-main
# Job 4: Verify Release Build
# Verifies that release builds will work when tagged
# Performance Target: ≤3 minutes
# Builds one platform to ensure release.yml will succeed
verify-release-build:
name: Verify Release Build
runs-on: ${{ needs.select-runner.outputs.linux }}
needs: [select-runner, build]
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Restore dependencies
run: dotnet restore -r linux-x64
# -r linux-x64: Platform-specific packages. See csproj RuntimeIdentifier conditions.
- name: Build release executable (Linux x64)
run: |
# Use PR number for PRs, run number for push to main
VERSION="0.0.0-pr.${{ github.event.pull_request.number || github.run_number }}"
echo "📦 Building with version: $VERSION"
dotnet publish src/TenSecondTom.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained true \
--output ./publish \
-p:PublishSingleFile=true \
-p:Version=$VERSION
- name: Verify build output
run: |
if [ ! -f "./publish/tom" ]; then
echo "❌ Error: Release build did not produce executable"
exit 1
fi
SIZE=$(stat -c%s ./publish/tom)
SIZE_MB=$((SIZE / 1024 / 1024))
echo "📦 Executable size: ${SIZE_MB}MB"
if [ $SIZE_MB -gt 50 ]; then
echo "⚠️ Warning: Executable size exceeds 50MB"
fi
echo "✅ Release build verification passed"
- name: Smoke test
run: |
chmod +x ./publish/tom
./publish/tom --version
if [ $? -eq 0 ]; then
echo "✅ Release executable runs successfully"
else
echo "❌ Release executable failed to run"
exit 1
fi
# Job 5: Validate
# Aggregates status from all previous jobs
# This job ensures all quality gates passed before allowing merge
validate:
name: Validation Complete
runs-on: ${{ needs.select-runner.outputs.linux }}
needs: [select-runner, build, test, coverage, verify-release-build]
steps:
- name: All checks passed
run: |
echo "✅ All validation checks passed successfully!"
echo " - Build: Completed with zero warnings"
echo " - Tests: All tests passed"
echo " - Coverage: No significant regression detected"
echo " - Release Build: Verified and smoke tested"
echo ""
echo "Pull request is ready to merge."