Skip to content

Back up every file of user state, not just projects.json #2066

Back up every file of user state, not just projects.json

Back up every file of user state, not just projects.json #2066

Workflow file for this run

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