Back up every file of user state, not just projects.json #2066
Workflow file for this run
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: Build | |
| on: | |
| pull_request: | |
| branches: [main] | |
| permissions: | |
| contents: read | |
| jobs: | |
| lint: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: "1.3.14" | |
| # Retries a transient registry tarball failure, loudly. See | |
| # decisions/2026/08/22/retry-bun-install-on-a-registry-flake.md | |
| - uses: ./.github/actions/bun-install | |
| - name: Generate build files | |
| run: bun scripts/generate-build-info.ts && bun scripts/generate-changelog.ts | |
| - name: Type check | |
| run: bun run lint | |
| test_shards: | |
| name: test (${{ matrix.shard }}/${{ strategy.job-total }}) | |
| runs-on: ubuntu-latest | |
| continue-on-error: true | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| shard: [1, 2, 3, 4, 5] | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: "1.3.14" | |
| # Retries a transient registry tarball failure, loudly. See | |
| # decisions/2026/08/22/retry-bun-install-on-a-registry-flake.md | |
| - uses: ./.github/actions/bun-install | |
| - name: Generate build files | |
| run: bun scripts/generate-build-info.ts && bun scripts/generate-changelog.ts | |
| # Each suite is teed to a per-suite log so the aggregate `test` job can | |
| # surface the exact failing tests without anyone opening this shard job. | |
| # | |
| # `shell: bash` is MANDATORY on every step that pipes: the default `run:` | |
| # shell is `bash -e {0}` WITHOUT pipefail, so `vitest | tee` reports tee's | |
| # exit code (0) and a genuinely failing suite gets recorded as success — | |
| # which silently turned this gate into a rubber stamp once already. | |
| # `shell: bash` runs `bash --noprofile --norc -eo pipefail {0}`. | |
| - name: Run mainview tests | |
| id: mainview_tests | |
| continue-on-error: true | |
| shell: bash | |
| run: | | |
| mkdir -p test-shard-result | |
| bunx vitest run --shard=${{ matrix.shard }}/${{ strategy.job-total }} \ | |
| 2>&1 | tee "test-shard-result/shard-${{ matrix.shard }}-mainview.log" | |
| - name: Run bun tests | |
| id: bun_tests | |
| continue-on-error: true | |
| shell: bash | |
| run: | | |
| mkdir -p test-shard-result | |
| bunx vitest run --config vitest.config.bun.ts --shard=${{ matrix.shard }}/${{ strategy.job-total }} \ | |
| 2>&1 | tee "test-shard-result/shard-${{ matrix.shard }}-bun.log" | |
| - name: Run CLI tests | |
| id: cli_tests | |
| continue-on-error: true | |
| shell: bash | |
| run: | | |
| mkdir -p test-shard-result | |
| bunx vitest run --config vitest.config.cli.ts --shard=${{ matrix.shard }}/${{ strategy.job-total }} \ | |
| 2>&1 | tee "test-shard-result/shard-${{ matrix.shard }}-cli.log" | |
| - name: Record shard result | |
| if: always() | |
| env: | |
| SHARD: ${{ matrix.shard }} | |
| SHARD_TOTAL: ${{ strategy.job-total }} | |
| MAINVIEW_RESULT: ${{ steps.mainview_tests.outcome }} | |
| BUN_RESULT: ${{ steps.bun_tests.outcome }} | |
| CLI_RESULT: ${{ steps.cli_tests.outcome }} | |
| run: | | |
| mkdir -p test-shard-result | |
| printf '%s %s %s %s %s\n' "$SHARD" "$SHARD_TOTAL" "$MAINVIEW_RESULT" "$BUN_RESULT" "$CLI_RESULT" \ | |
| > "test-shard-result/shard-$SHARD.txt" | |
| # For each failed suite, distil a concise failure summary the aggregate | |
| # can print verbatim; drop the bulky raw logs so the artifact stays tiny. | |
| strip_ansi() { sed -E 's/\x1b\[[0-9;]*m//g'; } | |
| for suite in mainview bun cli; do | |
| case "$suite" in | |
| mainview) outcome="$MAINVIEW_RESULT" ;; | |
| bun) outcome="$BUN_RESULT" ;; | |
| cli) outcome="$CLI_RESULT" ;; | |
| esac | |
| log="test-shard-result/shard-$SHARD-$suite.log" | |
| fail="test-shard-result/shard-$SHARD-$suite.fail" | |
| if [ "$outcome" != "success" ] && [ -f "$log" ]; then | |
| strip_ansi < "$log" \ | |
| | grep -E ' FAIL |Test Files +[0-9]+ failed|Tests +[0-9]+ failed| × ' \ | |
| | head -80 > "$fail" || true | |
| if [ ! -s "$fail" ]; then | |
| strip_ansi < "$log" | tail -80 > "$fail" || true | |
| fi | |
| fi | |
| rm -f "$log" | |
| done | |
| - name: Upload shard result | |
| if: always() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: test-shard-${{ matrix.shard }} | |
| path: test-shard-result/ | |
| if-no-files-found: error | |
| retention-days: 1 | |
| # The live terminal gate: guarantees only a REAL tmux server or a REAL native host | |
| # can prove — guard grammar against a live server, recycled pane ids across a server | |
| # restart, exactly-once delivery across three processes, ownership-verified teardown. | |
| # Before this job they ran only when a human remembered, so reviewers had to keep | |
| # marking those claims unverified. | |
| # | |
| # RUNS THE WHOLE SET ON EVERY PR, both OSes — nothing is held back for main. The set | |
| # measures ~27 s of script time locally, so there is no honest reason to narrow it; | |
| # `scripts/run-terminal-e2e.ts --set fast` exists if a measurement ever says otherwise, | |
| # and the per-script runtime table lands on the run summary so that stays a decision | |
| # made from numbers instead of a guess. | |
| # | |
| # It GATES PRs: the required `test` context needs this job, so a red terminal gate | |
| # blocks the merge instead of sitting pending like the Windows packaging jobs do. | |
| terminal_e2e: | |
| name: terminal e2e (${{ matrix.os }}) | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 20 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| # tmux behaves differently across these two, and the native host differs more. | |
| os: [ubuntu-latest, macos-latest] | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: "1.3.14" | |
| - uses: ./.github/actions/bun-install | |
| with: | |
| frozen-lockfile: "true" | |
| # The runner image may or may not ship tmux, and guard grammar is version | |
| # sensitive, so install on demand and print what we actually got. | |
| # Bounded for the same reason release-build-linux.yml bounds its apt step: `apt-get update` | |
| # stalls silently rather than failing, and an unbounded stall burns the whole job's clock. | |
| - name: Ensure tmux | |
| shell: bash | |
| timeout-minutes: 10 | |
| run: | | |
| if ! command -v tmux >/dev/null 2>&1; then | |
| if [ "$RUNNER_OS" = "macOS" ]; then | |
| brew install tmux | |
| else | |
| sudo apt-get update && sudo apt-get install -y tmux | |
| fi | |
| fi | |
| tmux -V | |
| # `native-message-e2e` loads the production RPC handlers, which import the | |
| # generated changelog bundle; without this it dies before the first check. | |
| - name: Generate build files | |
| run: bun scripts/generate-build-info.ts && bun scripts/generate-changelog.ts | |
| - name: Live terminal e2e (whole set, orphan-checked) | |
| run: bun scripts/run-terminal-e2e.ts | |
| # A gate that catches the rarest failure in the change and then discards the | |
| # evidence is a rumour. Only written when something survived, so an empty upload | |
| # is the normal case. | |
| - name: Upload survivor evidence | |
| if: failure() | |
| uses: actions/upload-artifact@v7 | |
| with: | |
| name: terminal-e2e-evidence-${{ matrix.os }} | |
| path: terminal-e2e-evidence/ | |
| if-no-files-found: ignore | |
| retention-days: 7 | |
| test: | |
| name: test | |
| if: always() | |
| needs: [test_shards, terminal_e2e] | |
| runs-on: ubuntu-latest | |
| steps: | |
| # `test` is the required status context, so gating through it makes the terminal | |
| # e2e job block a merge without touching branch protection. Both verifications | |
| # are `continue-on-error` and a final step fails the job, so a red terminal gate | |
| # never hides which test shards also failed, and vice versa. | |
| # | |
| # `skipped` must NOT fail here. It is unreachable today — this workflow has no | |
| # path filter, no concurrency group, and `terminal_e2e` has no `if:` — but if | |
| # anyone ever adds one, failing on `skipped` would turn the required `test` | |
| # context red on every PR and block the whole repo. It is loudly announced | |
| # instead, so a gate that stopped running cannot pass as a gate that passed. | |
| - name: Verify the live terminal e2e gate | |
| id: terminal_gate | |
| continue-on-error: true | |
| env: | |
| TERMINAL_E2E_RESULT: ${{ needs.terminal_e2e.result }} | |
| run: | | |
| echo "terminal e2e: $TERMINAL_E2E_RESULT" | |
| case "$TERMINAL_E2E_RESULT" in | |
| success) | |
| ;; | |
| skipped) | |
| echo "::warning title=Live terminal e2e::the terminal e2e job was SKIPPED, so nothing was proved against a live tmux server or native host on this PR" | |
| echo "## ⚠️ Live terminal e2e gate: skipped — nothing was verified live" >> "$GITHUB_STEP_SUMMARY" | |
| ;; | |
| *) | |
| echo "::error title=Live terminal e2e::the terminal e2e job did not succeed ($TERMINAL_E2E_RESULT) — open 'terminal e2e (ubuntu-latest)' / 'terminal e2e (macos-latest)' for the failing script and its runtime table" | |
| echo "## ❌ Live terminal e2e gate: $TERMINAL_E2E_RESULT" >> "$GITHUB_STEP_SUMMARY" | |
| exit 1 | |
| ;; | |
| esac | |
| - name: Download shard results | |
| uses: actions/download-artifact@v8 | |
| with: | |
| pattern: test-shard-* | |
| path: test-shard-results | |
| merge-multiple: true | |
| - name: Verify test shards | |
| id: shards | |
| continue-on-error: true | |
| run: | | |
| shopt -s nullglob | |
| result_files=(test-shard-results/shard-*.txt) | |
| failed=0 | |
| expected_total=0 | |
| declare -A seen=() | |
| failed_suites=() # "shard/total suite" for every non-success suite | |
| if [ "${#result_files[@]}" -eq 0 ]; then | |
| echo "::error::No test shard results were downloaded" | |
| exit 1 | |
| fi | |
| for result_file in "${result_files[@]}"; do | |
| if ! read -r shard total mainview bun cli < "$result_file"; then | |
| echo "::error::Malformed result in $result_file" | |
| failed=1 | |
| continue | |
| fi | |
| if [[ ! "$shard" =~ ^[1-9][0-9]*$ ]] || [[ ! "$total" =~ ^[1-9][0-9]*$ ]]; then | |
| echo "::error::Invalid shard identity in $result_file: shard=$shard total=$total" | |
| failed=1 | |
| continue | |
| fi | |
| if [ "$result_file" != "test-shard-results/shard-$shard.txt" ]; then | |
| echo "::error::Shard identity does not match artifact filename: $result_file declares $shard" | |
| failed=1 | |
| continue | |
| fi | |
| if [ "$expected_total" -eq 0 ]; then | |
| expected_total="$total" | |
| elif [ "$total" -ne "$expected_total" ]; then | |
| echo "::error::Inconsistent shard total in $result_file: expected=$expected_total actual=$total" | |
| failed=1 | |
| fi | |
| if [ "$shard" -gt "$total" ] || [[ -n "${seen[$shard]:-}" ]]; then | |
| echo "::error::Invalid or duplicate test shard $shard/$total" | |
| failed=1 | |
| continue | |
| fi | |
| seen[$shard]=1 | |
| echo "shard $shard/$total: mainview=$mainview bun=$bun cli=$cli" | |
| for suite in mainview bun cli; do | |
| case "$suite" in | |
| mainview) outcome="$mainview" ;; | |
| bun) outcome="$bun" ;; | |
| cli) outcome="$cli" ;; | |
| esac | |
| if [ "$outcome" != "success" ]; then | |
| failed=1 | |
| failed_suites+=("$shard/$total $suite") | |
| fi | |
| done | |
| done | |
| if [ "$expected_total" -eq 0 ] || [ "${#result_files[@]}" -ne "$expected_total" ]; then | |
| echo "::error::Expected $expected_total shard results, found ${#result_files[@]}" | |
| failed=1 | |
| else | |
| for ((shard = 1; shard <= expected_total; shard++)); do | |
| if [[ -z "${seen[$shard]:-}" ]]; then | |
| echo "::error::Missing result for test shard $shard/$expected_total" | |
| failed=1 | |
| fi | |
| done | |
| fi | |
| # Self-contained failure report: which shard, which suite, which tests — | |
| # visible right here and on the run summary, no need to open a shard job. | |
| if [ "${#failed_suites[@]}" -gt 0 ]; then | |
| echo "" | |
| echo "==================================================================" | |
| echo " ❌ TEST SHARDS FAILED (${#failed_suites[@]} suite(s)):" | |
| for entry in "${failed_suites[@]}"; do | |
| echo " ✗ shard ${entry%% *} · ${entry##* }" | |
| done | |
| echo "==================================================================" | |
| echo "" | |
| echo " This job only reads the shard artifacts — it never runs a test." | |
| echo " Re-running ONLY this job re-reads the same artifacts and fails" | |
| echo " identically. Re-run the whole workflow instead:" | |
| echo " gh run rerun $GITHUB_RUN_ID # all jobs" | |
| echo " gh run rerun $GITHUB_RUN_ID --failed # failed jobs (incl. shards)" | |
| echo "" | |
| { | |
| echo "## ❌ Failing test suites" | |
| echo "" | |
| for entry in "${failed_suites[@]}"; do | |
| echo "- **shard ${entry%% *}** · \`${entry##* }\`" | |
| done | |
| echo "" | |
| echo "> ⚠️ **Re-running the \`test\` job alone will not help.** It only reads the" | |
| echo "> shard artifacts; re-run the whole workflow with" | |
| echo "> \`gh run rerun $GITHUB_RUN_ID\` (or **Re-run all jobs**) so the shards" | |
| echo "> actually execute again." | |
| echo "" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| for entry in "${failed_suites[@]}"; do | |
| shard_total="${entry%% *}" | |
| suite="${entry##* }" | |
| shard="${shard_total%%/*}" | |
| fail="test-shard-results/shard-${shard}-${suite}.fail" | |
| echo "" | |
| echo "::group::❌ shard ${shard_total} · ${suite} — failing tests" | |
| if [ -s "$fail" ]; then | |
| cat "$fail" | |
| else | |
| echo "(no captured failure detail — inspect the shard job log)" | |
| fi | |
| echo "::endgroup::" | |
| first_fail="$(grep -E ' FAIL | × ' "$fail" 2>/dev/null | head -1 | sed -E 's/^[[:space:]]*//')" | |
| echo "::error title=Test shard ${shard_total} (${suite})::${first_fail:-suite ${suite} failed — see the failing-tests group above}" | |
| { | |
| echo "<details><summary>shard ${shard_total} · <code>${suite}</code></summary>" | |
| echo "" | |
| echo '```' | |
| if [ -s "$fail" ]; then cat "$fail"; else echo "(no captured detail)"; fi | |
| echo '```' | |
| echo "" | |
| echo "</details>" | |
| echo "" | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| done | |
| fi | |
| exit "$failed" | |
| - name: Fail if any gate is red | |
| env: | |
| TERMINAL_GATE: ${{ steps.terminal_gate.outcome }} | |
| SHARDS: ${{ steps.shards.outcome }} | |
| run: | | |
| echo "terminal e2e gate: $TERMINAL_GATE" | |
| echo "test shards: $SHARDS" | |
| [ "$TERMINAL_GATE" = "success" ] && [ "$SHARDS" = "success" ] | |
| build-check: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: "1.3.14" | |
| # Retries a transient registry tarball failure, loudly. See | |
| # decisions/2026/08/22/retry-bun-install-on-a-registry-flake.md | |
| - uses: ./.github/actions/bun-install | |
| - name: Generate build files | |
| run: bun scripts/generate-build-info.ts && bun scripts/generate-changelog.ts | |
| - name: Build check | |
| run: bunx vite build && bun run build:cli | |
| trivy-scan: | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: write | |
| steps: | |
| - uses: actions/checkout@v5 | |
| - name: Run Trivy vulnerability scanner | |
| uses: aquasecurity/trivy-action@master | |
| with: | |
| scan-type: 'fs' | |
| scan-ref: '.' | |
| format: 'table' | |
| output: 'trivy-results.txt' | |
| exit-code: '0' | |
| severity: 'CRITICAL,HIGH,MEDIUM' | |
| trivy-config: '' | |
| env: | |
| TRIVY_INCLUDE_DEV_DEPS: 'true' | |
| continue-on-error: true | |
| - name: Post Trivy results to PR | |
| if: always() | |
| uses: actions/github-script@v8 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const { buildTrivyCommentBody } = require('./src/bun/trivy-pr-comment.js'); | |
| let body = null; | |
| if (fs.existsSync('trivy-results.txt')) { | |
| const results = fs.readFileSync('trivy-results.txt', 'utf8'); | |
| body = buildTrivyCommentBody(results); | |
| } else { | |
| body = '## 🔍 Trivy Vulnerability Scan\n\n⚠️ Trivy results file not found.\n'; | |
| } | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| }); | |
| const trivyComment = comments.find((comment) => | |
| comment.body.includes('Trivy Vulnerability Scan'), | |
| ); | |
| if (!body) { | |
| if (trivyComment) { | |
| await github.rest.issues.deleteComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: trivyComment.id, | |
| }); | |
| } | |
| return; | |
| } | |
| if (trivyComment) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: trivyComment.id, | |
| body, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body, | |
| }); | |
| } | |
| continue-on-error: true |