fix(install): refuse to clobber a Codex shim owned by a different install (#553) #1155
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: tests | |
| # Run the full Bats suite on PRs targeting main or integration/remote and on | |
| # pushes to main. The suite is | |
| # fast (~2 min) and this repo is public, so GitHub-hosted runners are free — | |
| # running everything every time is simpler and safer than selectively running | |
| # "only the changed tests", which would miss regressions in shared libs | |
| # (lib/*.sh) that fan out across many scripts. | |
| # | |
| # Docs-only skip (without the paths-ignore trap): | |
| # `bats (ubuntu-latest)` and `bats (macos-latest)` are REQUIRED status checks | |
| # on main. A naive `paths-ignore` would mean those checks never report on a | |
| # docs-only PR, leaving it stuck "pending" forever and unmergeable. So instead | |
| # the `bats` jobs ALWAYS run and ALWAYS complete green — a `changes` job | |
| # decides whether the diff is docs-only, and only the heavy steps (install + | |
| # run) are skipped when it is. The required context is therefore always | |
| # reported; on docs-only PRs it just reports green in seconds without running | |
| # the suite. | |
| # | |
| # What counts as "docs-only" (= safe to skip the bash suite): the docs tree | |
| # (`docs/**`), the marketing site (`site/**` — a standalone Astro app with no | |
| # bearing on the bash suite), the desktop app (`app/**` — Rust/TS the bats | |
| # suite never reads; it gets its own `app typecheck` job below instead), | |
| # `llms.txt` / `llms-full.txt`, and the top-level prose docs (README, | |
| # CHANGELOG, etc). SKILL.md is deliberately EXCLUDED — the suite asserts on it | |
| # (tests/test_install.bats checks it has no unsubstituted placeholder), so a | |
| # SKILL.md change must run the suite. Anything not on the allowlist runs it too. | |
| # | |
| # `docs_only` only decides the bats suite. `age-v1-contract` and | |
| # `storage-jsonl` are additional, narrower jobs (crypto vectors / the jsonl | |
| # storage driver) that used to gate on the same flag, so anything that turned | |
| # `docs_only` false forced both of them to run in full regardless of whether | |
| # the diff had anything to do with either -- concretely, a | |
| # `docs/remote-setup*.md`-only diff (which must flip docs_only so the suite | |
| # that reads it still runs, see below) also ran a ~5-minute age-v1 job and two | |
| # ~1-minute jsonl jobs that never touch that file (#706). `contracts_needed` | |
| # is `docs_only`'s negation for most paths, but for `tests/test_remote*.bats` | |
| # specifically it is a membership check against a DERIVED read-set (the | |
| # explicit entry points age-v1-contract/storage-jsonl invoke, plus whichever | |
| # bats files currently `grep -rl 'skip_if_no_age'`) rather than "the glob | |
| # matched, so true". A hand exception for the one file that first exposed | |
| # this (`tests/test_remote_setup_doc.bats`) fixed that one file and left four | |
| # more real files the same glob sweeps in without reading either contract | |
| # (`test_remote_forget`/`test_remote_status_liveness`/`test_remote_sync`/ | |
| # `test_remote_sync_engine`) — see the classifier below for the derivation | |
| # itself. | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| branches: [main, integration/remote] | |
| permissions: | |
| contents: read | |
| env: | |
| # Number of parallel bats shards per OS. The matrix and the shard helper must | |
| # stay in lockstep; the stable summary job below verifies that they do. | |
| SHARD_TOTAL: 4 | |
| jobs: | |
| # Cheap gate: is this PR's diff entirely documentation? Pushes to main always | |
| # run the full suite (docs_only=false) — main must stay fully verified. | |
| changes: | |
| name: detect docs-only | |
| runs-on: ubuntu-latest | |
| outputs: | |
| docs_only: ${{ steps.detect.outputs.docs_only }} | |
| app_changed: ${{ steps.detect.outputs.app_changed }} | |
| server_changed: ${{ steps.detect.outputs.server_changed }} | |
| sync_changed: ${{ steps.detect.outputs.sync_changed }} | |
| contracts_needed: ${{ steps.detect.outputs.contracts_needed }} | |
| steps: | |
| - uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 # need base + head history to diff the PR range | |
| - id: detect | |
| env: | |
| EVENT: ${{ github.event_name }} | |
| BASE_SHA: ${{ github.event.pull_request.base.sha }} | |
| HEAD_SHA: ${{ github.event.pull_request.head.sha }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$EVENT" != "pull_request" ]; then | |
| echo "Event is '$EVENT' (not pull_request) — running the full suite." | |
| echo "docs_only=false" >> "$GITHUB_OUTPUT" | |
| echo "app_changed=true" >> "$GITHUB_OUTPUT" | |
| echo "server_changed=true" >> "$GITHUB_OUTPUT" | |
| echo "sync_changed=true" >> "$GITHUB_OUTPUT" | |
| echo "contracts_needed=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| changed=$(git diff --name-only "$BASE_SHA...$HEAD_SHA") | |
| echo "Changed files:" | |
| echo "$changed" | |
| docs_only=true | |
| app_changed=false | |
| server_changed=false | |
| sync_changed=false | |
| contracts_needed=false | |
| # The actual test-file read-set for age-v1-contract/storage-jsonl, | |
| # DERIVED rather than named (#706 recurred once already from a hand | |
| # exception, and a hand exception is what this replaces): the four | |
| # explicit entry points those two jobs invoke below, plus whichever | |
| # bats files currently gate on age -- computed the exact same way | |
| # age-v1-contract discovers its own age-gated files further down in | |
| # this file (`grep -rl 'skip_if_no_age'`), so a file gaining or | |
| # losing that marker is picked up here without anyone touching this | |
| # workflow. `tests/test_remote*.bats` alone matches six files by | |
| # name and only one of them (test_remote.bats) is in this set; the | |
| # other five (including the walkthroughs' own guard test) do not | |
| # read either contract, and naming each one as its own exception is | |
| # the same defect this replaces, just moved. | |
| contract_test_files="$(printf '%s\n' \ | |
| tests/sync_cipher.test.mjs \ | |
| tests/test_sync_cipher.bats \ | |
| tests/test_jsonl_remote_sync.bats \ | |
| tests/test_storage_contract.bats \ | |
| $(grep -rl 'skip_if_no_age' tests/*.bats 2>/dev/null || true))" | |
| while IFS= read -r f; do | |
| [ -z "$f" ] && continue | |
| case "$f" in | |
| docs/remote-setup*.md) docs_only=false ;; | |
| docs/*) ;; | |
| site/*) ;; | |
| app/*) app_changed=true ;; | |
| server/*) docs_only=false; server_changed=true; contracts_needed=true ;; | |
| # Split from the broad sync-engine arm below on purpose: | |
| # `tests/test_remote*.bats` decides `sync_changed` for six real | |
| # bats files (all of them ARE the sync engine's own tests, or | |
| # at least reachable from this glob by name), but only some of | |
| # them are in `contract_test_files` above. contracts_needed is | |
| # therefore a membership check against the derived set, not an | |
| # unconditional true the way `sync_changed` on the same line | |
| # is -- that half of this arm is correct as broad, this half | |
| # was not. | |
| tests/test_remote*.bats) | |
| docs_only=false; sync_changed=true | |
| if printf '%s\n' "$contract_test_files" | grep -qxF -- "$f"; then | |
| contracts_needed=true | |
| fi | |
| ;; | |
| scripts/remote*.sh|scripts/internal/*|scripts/drivers/storage/*|scripts/lib/*|tests/*sync*.*|server/test/sync-client.integration.test.ts|.github/workflows/tests.yml) docs_only=false; sync_changed=true; contracts_needed=true ;; | |
| llms.txt|llms-full.txt) ;; | |
| README.md|CHANGELOG.md|CONTRIBUTING.md|PRIVACY.md|ARCHITECTURE.md|RELEASING.md|LICENSE) ;; | |
| # NOTE: SKILL.md is intentionally not here — the suite tests it. | |
| *) docs_only=false; contracts_needed=true ;; | |
| esac | |
| done <<< "$changed" | |
| echo "Result: docs_only=$docs_only app_changed=$app_changed server_changed=$server_changed sync_changed=$sync_changed contracts_needed=$contracts_needed" | |
| echo "docs_only=$docs_only" >> "$GITHUB_OUTPUT" | |
| echo "app_changed=$app_changed" >> "$GITHUB_OUTPUT" | |
| echo "server_changed=$server_changed" >> "$GITHUB_OUTPUT" | |
| echo "sync_changed=$sync_changed" >> "$GITHUB_OUTPUT" | |
| echo "contracts_needed=$contracts_needed" >> "$GITHUB_OUTPUT" | |
| bats-shard: | |
| name: bats (${{ matrix.os }} ${{ matrix.shard }}/4) | |
| needs: changes | |
| # Run even if `changes` somehow failed/was skipped — fail open to the full | |
| # suite rather than leaving this REQUIRED check unreported (which would | |
| # block the PR). When changes failed, docs_only is empty → heavy steps run. | |
| if: ${{ !cancelled() }} | |
| runs-on: ${{ matrix.os }} | |
| # A shard has generous headroom under this cap; a timeout is a genuine hang. | |
| timeout-minutes: 25 | |
| strategy: | |
| # Cover both GNU (Linux) and BSD (macOS) userlands — the scripts shell out | |
| # to sed/stat/mktemp/ps etc. whose flags differ between them. | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, macos-latest] | |
| shard: [1, 2, 3, 4] | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Docs-only change — skipping suite | |
| if: needs.changes.outputs.docs_only == 'true' | |
| run: echo "Diff is documentation-only; skipping the bats suite and reporting green to satisfy the required check." | |
| - name: Compute this shard's test files | |
| if: needs.changes.outputs.docs_only != 'true' | |
| env: | |
| SHARD: ${{ matrix.shard }} | |
| run: | | |
| set -euo pipefail | |
| if [ "$SHARD" -gt "$SHARD_TOTAL" ]; then | |
| echo "::error::matrix shard $SHARD exceeds SHARD_TOTAL=$SHARD_TOTAL" | |
| exit 1 | |
| fi | |
| .github/scripts/shard-tests.sh "$SHARD" "$SHARD_TOTAL" > shard-files.txt | |
| if [ ! -s shard-files.txt ]; then | |
| echo "::error::shard $SHARD of $SHARD_TOTAL was assigned no test files" | |
| exit 1 | |
| fi | |
| - name: Ensure sqlite3 is available (Linux) | |
| if: runner.os == 'Linux' && needs.changes.outputs.docs_only != 'true' | |
| run: sqlite3 --version >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y sqlite3; } | |
| # Install a modern bats-core (the suite uses BATS_TEST_TMPDIR) into a | |
| # HOME-local npm prefix. This avoids both the ubuntu runner's EACCES on | |
| # `npm i -g` under /usr/local and the macOS SIP block on /usr/lib that the | |
| # bats-core action's default install paths hit. | |
| - name: Install bats | |
| if: needs.changes.outputs.docs_only != 'true' | |
| run: | | |
| npm config set prefix "$HOME/.npm-global" | |
| npm install -g bats | |
| echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH" | |
| - name: Show tool versions | |
| if: needs.changes.outputs.docs_only != 'true' | |
| run: | | |
| bash --version | head -1 | |
| sqlite3 --version | |
| bats --version | |
| # Diagnostics for the shards that report every test ok and then sit | |
| # silent until the job's cap. The sampler writes to a file and never to | |
| # this step's stdout: a process holding that pipe is one of the things | |
| # under suspicion, so the instrument must not be able to cause what it is | |
| # measuring. It closes fds 3 and 4 for the same reason, and gives itself a | |
| # hard lifetime so it cannot outlive the job it is watching. | |
| - name: Start hang sampler | |
| if: needs.changes.outputs.docs_only != 'true' | |
| continue-on-error: true | |
| run: | | |
| set +e | |
| cat > "$RUNNER_TEMP/sampler.sh" <<'SAMPLER' | |
| #!/usr/bin/env bash | |
| out="$RUNNER_TEMP/hang-samples.txt" | |
| : > "$out" | |
| for i in $(seq 1 46); do | |
| [ -f "$RUNNER_TEMP/bats-done" ] && { echo "== bats finished, sampler stopping at sample $i" >> "$out"; break; } | |
| { | |
| echo "===== sample $i $(date -u +%H:%M:%S)" | |
| ps -ef 2>/dev/null | grep -vE '\[' | tail -n +2 | |
| # One lsof over every candidate at once. Per-pid calls put the two | |
| # ends of a pipe in separate blocks minutes apart in the log, and | |
| # a reader has to spot that two numbers match; in the 08-01 hang | |
| # nobody did, and a 14-line cap had cut the decisive line anyway. | |
| pids="$(ps -eo pid=,comm= 2>/dev/null \ | |
| | awk '$2 ~ /node|python|bats|bash/ {printf "%s%s", sep, $1; sep=","}')" | |
| if [ -n "$pids" ]; then | |
| lsof -F pcftDin -p "$pids" 2>/dev/null > "$RUNNER_TEMP/sample-lsof.txt" | |
| echo "----- pipes held by more than one process:" | |
| awk -f "$GITHUB_WORKSPACE/.github/scripts/pipe-holders.awk" "$RUNNER_TEMP/sample-lsof.txt" | |
| echo "----- every descriptor they hold (no cap):" | |
| lsof -p "$pids" 2>/dev/null | |
| fi | |
| echo "----- bats temp dirs left behind:" | |
| ls -la "${TMPDIR:-/tmp}" 2>/dev/null \ | |
| | awk 'tolower($0) ~ /bats/ { n++; if (n <= 20) print }' | |
| } >> "$out" 2>&1 | |
| sleep 30 | |
| done | |
| SAMPLER | |
| chmod +x "$RUNNER_TEMP/sampler.sh" | |
| nohup "$RUNNER_TEMP/sampler.sh" >/dev/null 2>&1 3>&- 4>&- & | |
| echo "sampler pid $!" | |
| - name: Run bats suite (this shard) | |
| if: needs.changes.outputs.docs_only != 'true' | |
| run: | | |
| set +e | |
| xargs bats --print-output-on-failure < shard-files.txt | |
| status=$? | |
| : > "$RUNNER_TEMP/bats-done" | |
| exit $status | |
| # always() so this also runs when the job is cancelled at its cap, which | |
| # is the only case that matters here. | |
| # continue-on-error and a best-effort body: this step exists to describe a | |
| # failure, and a probe that returns non-zero -- lsof on a pid that has just | |
| # exited, a grep that matches nothing -- must never be what turns a green | |
| # shard red. The runner's default shell is `bash -e`, so -e is dropped | |
| # explicitly and every probe is allowed to fail. `head` is avoided in | |
| # pipelines for the same reason: it closes the pipe early and SIGPIPEs | |
| # whatever was writing. | |
| - name: Hang forensics | |
| if: always() && needs.changes.outputs.docs_only != 'true' | |
| continue-on-error: true | |
| run: | | |
| set +e | |
| set +o pipefail 2>/dev/null || true | |
| echo "##### live snapshot at $(date -u +%H:%M:%S 2>/dev/null)" | |
| ps -ef 2>/dev/null | awk 'NR > 1 && NR <= 61' | |
| # One snapshot over every candidate, so both ends of a pipe are in the | |
| # same dump and can be matched by machine rather than by eye. | |
| pids="$(ps -eo pid=,comm= 2>/dev/null \ | |
| | awk '$2 ~ /node|python|bats|bash|sleep/ {printf "%s%s", sep, $1; sep=","}')" | |
| if [ -n "$pids" ]; then | |
| lsof -F pcftDin -p "$pids" 2>/dev/null > "$RUNNER_TEMP/forensic-lsof.txt" | |
| else | |
| : > "$RUNNER_TEMP/forensic-lsof.txt" | |
| fi | |
| echo "##### pipes held by more than one process:" | |
| echo "##### (a shard that reported every test ok and is still open is usually one of these:" | |
| echo "##### a process that outlived its caller, holding the pipe bats is waiting to see close)" | |
| awk -f "$GITHUB_WORKSPACE/.github/scripts/pipe-holders.awk" "$RUNNER_TEMP/forensic-lsof.txt" | |
| echo "##### every descriptor those processes hold (no per-pid cap):" | |
| lsof -p "$pids" 2>/dev/null | |
| echo "##### bats temp remnants:" | |
| ls -la "${TMPDIR:-/tmp}" 2>/dev/null \ | |
| | awk 'tolower($0) ~ /bats/ { n++; if (n <= 20) print }' | |
| echo "##### sampler timeline:" | |
| if [ -s "$RUNNER_TEMP/hang-samples.txt" ]; then | |
| cat "$RUNNER_TEMP/hang-samples.txt" 2>/dev/null | |
| else | |
| echo "(no samples)" | |
| fi | |
| exit 0 | |
| - name: Upload hang samples | |
| if: always() && needs.changes.outputs.docs_only != 'true' | |
| continue-on-error: true | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: hang-samples-${{ matrix.os }}-${{ matrix.shard }} | |
| path: ${{ runner.temp }}/hang-samples.txt | |
| if-no-files-found: ignore | |
| retention-days: 3 | |
| - name: Record which files this shard ran | |
| if: needs.changes.outputs.docs_only != 'true' | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: bats-manifest-${{ matrix.os }}-${{ matrix.shard }} | |
| path: shard-files.txt | |
| retention-days: 1 | |
| # Keep the required check name stable even if the shard count changes. | |
| bats: | |
| name: bats | |
| needs: [changes, bats-shard] | |
| if: ${{ !cancelled() }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Check shard results | |
| env: | |
| RESULT: ${{ needs.bats-shard.result }} | |
| run: | | |
| set -euo pipefail | |
| case "$RESULT" in | |
| success) ;; | |
| skipped) echo "::error::the bats shards did not run"; exit 1 ;; | |
| *) echo "::error::the bats shards did not all pass (result: $RESULT)"; exit 1 ;; | |
| esac | |
| - name: Docs-only change — no shard manifests to verify | |
| if: needs.changes.outputs.docs_only == 'true' | |
| run: echo "Diff is documentation-only; shard manifests are not expected." | |
| - name: Download shard manifests | |
| if: needs.changes.outputs.docs_only != 'true' | |
| uses: actions/download-artifact@v4 | |
| with: | |
| pattern: bats-manifest-* | |
| path: manifests | |
| - name: Verify the shards covered the whole suite | |
| if: needs.changes.outputs.docs_only != 'true' | |
| run: | | |
| set -euo pipefail | |
| expected="$(find tests -maxdepth 1 -name '*.bats' | LC_ALL=C sort)" | |
| status=0 | |
| for os in ubuntu-latest macos-latest; do | |
| actual="$(cat manifests/bats-manifest-"$os"-*/shard-files.txt | LC_ALL=C sort)" | |
| if [ "$actual" != "$expected" ]; then | |
| echo "::error::$os shards do not exactly cover tests/*.bats" | |
| status=1 | |
| fi | |
| done | |
| exit "$status" | |
| # The age-v1 vectors are shared normative fixtures. Exercise them with the | |
| # pinned standard age implementation instead of accepting only mock crypto | |
| # or skipping on runners that do not preinstall age. | |
| age-v1-contract: | |
| name: age-v1 contract | |
| needs: changes | |
| if: ${{ !cancelled() }} | |
| runs-on: ubuntu-latest | |
| # Was 10, sized for the vectors and one filtered test. This job now runs | |
| # every age-gated test -- about a hundred, across two files -- on top of a | |
| # go install, so the old cap would have turned a slow runner into a | |
| # cancellation rather than a result. | |
| timeout-minutes: 25 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Docs-only change — skipping contract | |
| if: needs.changes.outputs.contracts_needed == 'false' | |
| run: echo "Diff does not touch anything the age-v1 contract reads; skipping." | |
| - uses: actions/setup-go@v5 | |
| if: needs.changes.outputs.contracts_needed != 'false' | |
| with: | |
| go-version: '1.25.x' | |
| - name: Ensure SQLite and jq are available | |
| if: needs.changes.outputs.contracts_needed != 'false' | |
| run: | | |
| sqlite3 --version >/dev/null 2>&1 && jq --version >/dev/null 2>&1 || { | |
| sudo apt-get update | |
| sudo apt-get install -y sqlite3 jq | |
| } | |
| - name: Install pinned age implementation | |
| if: needs.changes.outputs.contracts_needed != 'false' | |
| run: | | |
| go install filippo.io/age/cmd/age@v1.3.1 | |
| go install filippo.io/age/cmd/age-keygen@v1.3.1 | |
| npm config set prefix "$HOME/.npm-global" | |
| npm install -g bats | |
| echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH" | |
| echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH" | |
| - name: Run age-v1 shared vectors | |
| if: needs.changes.outputs.contracts_needed != 'false' | |
| run: | | |
| age_bin="$(go env GOPATH)/bin/age" | |
| AGMSG_AGE_BIN="$age_bin" node --test tests/sync_cipher.test.mjs | |
| AGMSG_AGE_BIN="$age_bin" bats --print-output-on-failure tests/test_sync_cipher.bats | |
| AGMSG_AGE_BIN="$age_bin" bats --print-output-on-failure tests/test_jsonl_remote_sync.bats | |
| AGE_BIN="$age_bin" node docs/spec/vectors/verify-age-v1-vectors.mjs | |
| # Every test that gates itself on age, discovered from the tree rather than | |
| # listed here. A list is how #567 happened on the Windows side: a file the | |
| # list did not name ran in no job at all, and CI went on reporting green | |
| # while never executing it. The same hole was open here -- 31 tests gate on | |
| # age, the shards skip them for want of the binary, and this job named one | |
| # of them, so 30 were executed nowhere. Two of those were already red. | |
| - name: Run every age-gated test | |
| if: needs.changes.outputs.contracts_needed != 'false' | |
| run: | | |
| command -v age >/dev/null | |
| command -v age-keygen >/dev/null | |
| files="$(grep -rl 'skip_if_no_age' tests/*.bats | sort | tr '\n' ' ')" | |
| echo "age-gated files: $files" | |
| # A discovery that finds nothing must fail loudly. Silently running no | |
| # tests is indistinguishable from running them all successfully, which | |
| # is the failure this step exists to end. | |
| [ -n "$files" ] || { echo "::error::no age-gated test files found; the discovery is broken"; exit 1; } | |
| # shellcheck disable=SC2086 | |
| bats --print-output-on-failure $files | |
| # Continuously verify that the storage contract is backend-portable: run the | |
| # SAME driver-agnostic contract suite against the jsonl driver (the sqlite run | |
| # is covered by the `bats` job above). This catches silent rot in jsonl as the | |
| # contract evolves. Not a required check yet (promote once stable). duckdb is an | |
| # opt-in accelerator, absent on the runner, so the jq path is exercised here. | |
| storage-jsonl: | |
| name: storage contract (jsonl, ${{ matrix.os }}) | |
| needs: changes | |
| if: ${{ !cancelled() }} | |
| runs-on: ${{ matrix.os }} | |
| timeout-minutes: 10 | |
| strategy: | |
| # Both userlands — the jsonl driver shells out to jq/sed/sort/cksum/paste, | |
| # whose behaviour differs between GNU (Linux) and BSD (macOS). | |
| fail-fast: false | |
| matrix: | |
| os: [ubuntu-latest, macos-latest] | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Docs-only change — skipping | |
| if: needs.changes.outputs.contracts_needed == 'false' | |
| run: echo "Diff does not touch anything the jsonl storage contract reads; skipping." | |
| - name: Ensure jq + sqlite3 (Linux) | |
| if: runner.os == 'Linux' && needs.changes.outputs.contracts_needed != 'false' | |
| run: | | |
| jq --version >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y jq; } | |
| sqlite3 --version >/dev/null 2>&1 || { sudo apt-get update && sudo apt-get install -y sqlite3; } | |
| - name: Install bats | |
| if: needs.changes.outputs.contracts_needed != 'false' | |
| run: | | |
| npm config set prefix "$HOME/.npm-global" | |
| npm install -g bats | |
| echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH" | |
| - name: Show tool versions | |
| if: needs.changes.outputs.contracts_needed != 'false' | |
| run: | | |
| jq --version | |
| sqlite3 --version | |
| bats --version | |
| - name: Run the storage contract against the jsonl driver | |
| if: needs.changes.outputs.contracts_needed != 'false' | |
| env: | |
| AGMSG_STORAGE_DRIVER: jsonl | |
| run: bats --print-output-on-failure tests/test_storage_contract.bats | |
| # Windows coverage for the native helpers shipped in #103 (Git Bash runner, | |
| # sqlite3 compatibility shim, cygpath normalization). The POSIX-assuming suite | |
| # is not fully green on Windows yet, so this is staged (#125): a single focused | |
| # leg targeting the Windows-relevant install tests. Not a required check yet — | |
| # promote it to required once it is reliably green. The required checks stay | |
| # bats (ubuntu/macos). | |
| # | |
| # The old informational "full" leg (the whole suite, continue-on-error) was | |
| # removed: it ran POSIX-assuming tests that hang on Windows and hit the job | |
| # timeout. A timed-out job reports "cancelled", and continue-on-error does NOT | |
| # absorb a cancellation (only a failure), so it turned the whole tests run red. | |
| # Windows-relevant tests get fixed in separate PRs and folded into this leg. | |
| bats-windows: | |
| name: bats (windows-latest, ${{ matrix.leg }}) | |
| needs: changes | |
| if: ${{ !cancelled() }} | |
| runs-on: windows-latest | |
| # Bound the job so an unexpected hang can't pin a runner. The focused leg is | |
| # fast and green today; this is just a safety net. | |
| timeout-minutes: 12 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| # Focused leg: the install-side native Windows helpers (#103). These | |
| # are green on windows-latest today and are the candidate to promote | |
| # to a required check. | |
| - leg: install helpers | |
| filter: "[Ww]indows" | |
| target: tests/test_install.bats | |
| # The Windows runtime path, which nothing here covered until #567: a | |
| # change to how liveness is decided on Windows shipped through a CI | |
| # that never decided liveness on Windows. Everything else about that | |
| # class is proved against a `tasklist` stub on a POSIX host, which | |
| # shows what the code does when a probe says "not found" -- not that | |
| # Git Bash says it, and not that a launch survives it. | |
| # | |
| # These two run with the real tasklist and the real MSYS pid space, | |
| # and they assert the EFFECT rather than the premise: the TUI reaches | |
| # its bridged --remote handoff, and a bridge is actually started. | |
| # Reaching the handoff is not delivering a message -- they are | |
| # separate failure points and each has its own test. | |
| - leg: windows runtime (#567) | |
| filter: "windows-native" | |
| target: tests/test_codex_monitor.bats tests/test_codex_bridge_launcher.bats | |
| defaults: | |
| run: | |
| # bats is bash; on Windows runners `shell: bash` is Git Bash, the same | |
| # environment native Windows users run agmsg under. | |
| shell: bash | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: Docs-only change — skipping suite | |
| if: needs.changes.outputs.docs_only == 'true' | |
| run: echo "Diff is documentation-only; skipping the Windows bats legs." | |
| - name: Install sqlite3 | |
| if: needs.changes.outputs.docs_only != 'true' | |
| shell: pwsh | |
| run: choco install sqlite -y --no-progress | |
| - name: Put chocolatey shims on PATH (Git Bash form) | |
| if: needs.changes.outputs.docs_only != 'true' | |
| run: echo "/c/ProgramData/chocolatey/bin" >> "$GITHUB_PATH" | |
| - name: Install bats | |
| if: needs.changes.outputs.docs_only != 'true' | |
| run: | | |
| npm config set prefix "$HOME/.npm-global" | |
| npm install -g bats | |
| echo "$HOME/.npm-global/bin" >> "$GITHUB_PATH" | |
| - name: Show tool versions | |
| if: needs.changes.outputs.docs_only != 'true' | |
| run: | | |
| bash --version | head -1 | |
| sqlite3 --version || sqlite3.exe --version || echo "sqlite3 not found on PATH" | |
| bats --version | |
| - name: Run tests | |
| if: needs.changes.outputs.docs_only != 'true' | |
| run: | | |
| # matrix.target may name multiple files (space-separated) — leave it | |
| # unquoted so the shell word-splits it into separate bats path args. | |
| # It's a fixed value from the matrix above, not external input. | |
| if [ -n "${{ matrix.filter }}" ]; then | |
| bats --print-output-on-failure --filter "${{ matrix.filter }}" ${{ matrix.target }} | |
| else | |
| bats --print-output-on-failure ${{ matrix.target }} | |
| fi | |
| # The desktop app (app/**) is Rust + TypeScript — the bats suite never | |
| # reads it, and the full Tauri build only runs on app-v* release tags via | |
| # app-release.yml. Without this job an app-only PR would merge with zero | |
| # compile verification (the bats legs skip via the app/* allowlist above). | |
| # cargo check + tsc --noEmit is a few minutes, not the full-bundle cost. | |
| # Same always-report pattern as bats: the job always completes, and only | |
| # the heavy steps are gated, so it can be promoted to a required check | |
| # without ever leaving a PR stuck pending. Pushes to main always run it. | |
| # Fail-open mirrors bats too: steps skip only on an EXPLICIT | |
| # app_changed=false — if the changes job breaks and its output is empty, | |
| # the typecheck runs rather than green-washing a possibly-app diff. | |
| app-check: | |
| name: app typecheck | |
| needs: changes | |
| if: ${{ !cancelled() }} | |
| # macOS: Tauri's crates (wry etc.) compile against system frameworks that | |
| # are already on the runner — ubuntu would need the webkit2gtk/gtk dev | |
| # stack installed first. Also matches the release build environment. | |
| runs-on: macos-latest | |
| timeout-minutes: 20 | |
| defaults: | |
| run: | |
| working-directory: app | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: No app changes — skipping typecheck | |
| if: needs.changes.outputs.app_changed == 'false' | |
| run: echo "Diff does not touch app/ — reporting green without running the typecheck." | |
| - uses: pnpm/action-setup@v4 | |
| if: needs.changes.outputs.app_changed != 'false' | |
| with: | |
| version: 10 | |
| - uses: actions/setup-node@v4 | |
| if: needs.changes.outputs.app_changed != 'false' | |
| with: | |
| node-version: 20 | |
| cache: pnpm | |
| cache-dependency-path: app/pnpm-lock.yaml | |
| - uses: dtolnay/rust-toolchain@stable | |
| if: needs.changes.outputs.app_changed != 'false' | |
| - uses: Swatinem/rust-cache@v2 | |
| if: needs.changes.outputs.app_changed != 'false' | |
| with: | |
| workspaces: app/src-tauri | |
| - name: Install frontend deps | |
| if: needs.changes.outputs.app_changed != 'false' | |
| run: pnpm install --frozen-lockfile | |
| - name: TypeScript typecheck | |
| if: needs.changes.outputs.app_changed != 'false' | |
| run: pnpm exec tsc --noEmit | |
| - name: Frontend tests | |
| if: needs.changes.outputs.app_changed != 'false' | |
| run: pnpm test | |
| - name: Bundle pinned agmsg-core | |
| if: needs.changes.outputs.app_changed != 'false' | |
| run: scripts/bundle-core.sh | |
| - name: Rust check | |
| if: needs.changes.outputs.app_changed != 'false' | |
| run: cargo check --manifest-path src-tauri/Cargo.toml | |
| - name: Rust tests | |
| if: needs.changes.outputs.app_changed != 'false' | |
| run: cargo test --manifest-path src-tauri/Cargo.toml | |
| # PostgreSQL-backed contract tests for the independent reference server and | |
| # Stage-1 clients. Like app-check, this always reports and skips heavy work | |
| # only when the diff provably touches neither side of that boundary. | |
| server-check: | |
| name: remote server | |
| needs: changes | |
| if: ${{ !cancelled() }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| services: | |
| postgres: | |
| image: postgres:17-alpine | |
| env: | |
| POSTGRES_DB: agmsg_test | |
| POSTGRES_USER: postgres | |
| POSTGRES_PASSWORD: postgres | |
| ports: | |
| - 5432:5432 | |
| options: >- | |
| --health-cmd "pg_isready -U postgres -d agmsg_test" | |
| --health-interval 2s | |
| --health-timeout 2s | |
| --health-retries 20 | |
| defaults: | |
| run: | |
| working-directory: server | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: No server or sync changes — skipping checks | |
| if: needs.changes.outputs.server_changed == 'false' && needs.changes.outputs.sync_changed == 'false' | |
| run: echo "Diff does not touch server/ or the Stage-1 client — reporting green without running remote integration checks." | |
| - uses: actions/setup-node@v4 | |
| if: needs.changes.outputs.server_changed != 'false' || needs.changes.outputs.sync_changed != 'false' | |
| with: | |
| node-version: 22 | |
| cache: npm | |
| cache-dependency-path: server/package-lock.json | |
| - name: Install dependencies | |
| if: needs.changes.outputs.server_changed != 'false' || needs.changes.outputs.sync_changed != 'false' | |
| run: npm ci | |
| - name: Typecheck | |
| if: needs.changes.outputs.server_changed != 'false' || needs.changes.outputs.sync_changed != 'false' | |
| run: npm run typecheck | |
| - name: PostgreSQL integration tests | |
| if: needs.changes.outputs.server_changed != 'false' || needs.changes.outputs.sync_changed != 'false' | |
| env: | |
| TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/agmsg_test | |
| run: npm test | |
| - name: Build | |
| if: needs.changes.outputs.server_changed != 'false' || needs.changes.outputs.sync_changed != 'false' | |
| run: npm run build | |
| - name: Compiled start smoke test | |
| if: needs.changes.outputs.server_changed != 'false' || needs.changes.outputs.sync_changed != 'false' | |
| env: | |
| DATABASE_URL: postgresql://postgres:postgres@localhost:5432/agmsg_test | |
| LOG_LEVEL: silent | |
| run: | | |
| npm start > "$RUNNER_TEMP/agmsg-server.log" 2>&1 & | |
| server_pid=$! | |
| cleanup() { | |
| kill "$server_pid" 2>/dev/null || true | |
| wait "$server_pid" 2>/dev/null || true | |
| } | |
| trap cleanup EXIT | |
| for _attempt in $(seq 1 20); do | |
| if curl --fail --silent http://127.0.0.1:8787/v1/health >/dev/null; then | |
| exit 0 | |
| fi | |
| sleep 1 | |
| done | |
| cat "$RUNNER_TEMP/agmsg-server.log" | |
| exit 1 | |
| - name: Docker image and health smoke test | |
| if: needs.changes.outputs.server_changed != 'false' || needs.changes.outputs.sync_changed != 'false' | |
| env: | |
| DATABASE_URL: postgresql://postgres:postgres@localhost:5432/agmsg_test | |
| CONTAINER_NAME: agmsg-reference-ci-${{ github.run_id }} | |
| run: | | |
| docker build -t agmsg-reference-server:ci . | |
| docker run --detach --name "$CONTAINER_NAME" --network host \ | |
| --env DATABASE_URL \ | |
| --env HOST=127.0.0.1 --env PORT=8788 --env LOG_LEVEL=silent \ | |
| agmsg-reference-server:ci | |
| cleanup() { | |
| docker rm --force "$CONTAINER_NAME" >/dev/null 2>&1 || true | |
| } | |
| trap cleanup EXIT | |
| healthy=false | |
| for _attempt in $(seq 1 20); do | |
| if curl --fail --silent http://127.0.0.1:8788/v1/health >/dev/null; then | |
| healthy=true | |
| break | |
| fi | |
| sleep 1 | |
| done | |
| if [ "$healthy" != true ]; then | |
| docker logs "$CONTAINER_NAME" | |
| exit 1 | |
| fi | |
| # A container really registering a team and serving a read, through the | |
| # current protocol: the client mints the team_id and posts it to | |
| # /v1/connect, and the data plane takes the team from its header. No | |
| # token is exchanged and no credential is presented, because none | |
| # exists to present. | |
| docker exec "$CONTAINER_NAME" node --input-type=module -e ' | |
| const endpoint = "http://127.0.0.1:8788"; | |
| const team = "018f3f7e-0000-7000-8000-000000000901"; | |
| const common = {"Agmsg-Protocol-Version": "1"}; | |
| const connected = await fetch(`${endpoint}/v1/connect`, { | |
| method: "POST", | |
| headers: {...common, "Content-Type": "application/json"}, | |
| body: JSON.stringify({ | |
| team_id: team, | |
| team_name: "ci-docker-onboarding", | |
| members: [], | |
| }), | |
| }); | |
| if (connected.status !== 200) { | |
| console.error("connect:", connected.status, await connected.text()); | |
| process.exit(1); | |
| } | |
| const members = await fetch(`${endpoint}/v1/members`, { | |
| headers: {...common, "Agmsg-Team-ID": team}, | |
| }); | |
| if (members.status !== 200) { | |
| console.error("members:", members.status, await members.text()); | |
| process.exit(1); | |
| } | |
| ' | |
| # Windows leg for the app: the command layer's bash resolution and path | |
| # conversion are all behind cfg(windows) and had never run in CI — the very | |
| # 0.1.1→0.1.3 regressions (WSL bash.exe, backslash argv). This runs cargo test | |
| # on windows-latest so those paths are actually exercised. Same fail-open | |
| # convention as app-check: run unless the diff provably doesn't touch app/. | |
| app-test-windows: | |
| name: app test (windows-latest) | |
| needs: changes | |
| if: ${{ !cancelled() }} | |
| runs-on: windows-latest | |
| timeout-minutes: 25 | |
| defaults: | |
| run: | |
| working-directory: app | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - name: No app changes — skipping app tests | |
| if: needs.changes.outputs.app_changed == 'false' | |
| run: echo "Diff does not touch app/ — reporting green without running the app tests." | |
| - uses: dtolnay/rust-toolchain@stable | |
| if: needs.changes.outputs.app_changed != 'false' | |
| - uses: Swatinem/rust-cache@v2 | |
| if: needs.changes.outputs.app_changed != 'false' | |
| with: | |
| workspaces: app/src-tauri | |
| - name: Bundle pinned agmsg-core | |
| if: needs.changes.outputs.app_changed != 'false' | |
| run: scripts/bundle-core.sh | |
| shell: bash | |
| - name: Rust tests | |
| if: needs.changes.outputs.app_changed != 'false' | |
| run: cargo test --manifest-path src-tauri/Cargo.toml | |
| # Internal team names must not reach what we publish. This runs on EVERY diff, | |
| # with no docs-only skip: the docs tree is exactly where prose names people, | |
| # and a check that skips the files most likely to carry the problem is | |
| # decoration. It costs one node process over `git ls-files`. | |
| # | |
| # SHAPE ONLY, AND THAT IS A DECISION, NOT AN ACCIDENT. | |
| # | |
| # Seat names have a shape (<base>-cc<n> / <base>-co<n>), so a pattern catches | |
| # the whole class -- including seats nobody has created yet -- while putting | |
| # no name into this repository. That half runs here. | |
| # | |
| # Names with no shape (a person's handle, a project nickname) are NOT checked | |
| # by CI. Checking them needs the list of them, and the only way to give CI | |
| # that list is to hand it to GitHub as a secret -- publishing, to a third | |
| # party, the names the rule exists to keep unpublished. So that half runs | |
| # before push, from a private checkout that holds the list, and never here. | |
| # | |
| # A green here therefore means "no seat-shaped name", not "no internal name". | |
| # The script prints that in a banner rather than letting the distinction live | |
| # only in this comment. `none` is passed explicitly for the same reason: an | |
| # UNSET list is exit 2, so a run that checks half of what it claims can only | |
| # happen because someone wrote it down, never because a variable was missing. | |
| enforced-assertions: | |
| name: enforceable assertions | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| # Always runs and always reports, like every other job here: a job that | |
| # skips on some diffs is a required context that can sit pending forever | |
| # (see the paths-ignore note at the top of this file). It reads only | |
| # `tests/*.bats`, so it is cheap enough to run unconditionally rather than | |
| # to gate. | |
| steps: | |
| - uses: actions/checkout@v4 | |
| # No bats, no bash 3.2, no runners: this is a static read of the test | |
| # sources. The BEHAVIOUR it encodes was measured on both shells (#670); | |
| # what runs here is the count. | |
| - name: No new assertion that cannot fail | |
| run: .github/scripts/check-enforced-assertions.sh | |
| private-names: | |
| name: internal names | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| steps: | |
| - uses: actions/checkout@v4 | |
| - uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22 | |
| # `none` is written out here on purpose: it is the difference between | |
| # "we check the shapes" and "nobody configured anything". | |
| - name: Check the shapes we publish (unshaped names are checked before push) | |
| run: AGMSG_PRIVATE_NAMES=none node scripts/check-private-names.mjs |