Skip to content

fix(web-search): combine replay-cache isolation with deadline-safe quota evidence #1406

fix(web-search): combine replay-cache isolation with deadline-safe quota evidence

fix(web-search): combine replay-cache isolation with deadline-safe quota evidence #1406

Workflow file for this run

name: Cross-platform CI
on:
# Always create the aggregate `ci` check for pull requests. Expensive jobs
# apply the former path allowlist through the `changes` job below, so a
# docs-only PR receives explicit positive evidence instead of no check at all.
pull_request: {}
# No base-branch filter on purpose. GitHub matches `branches:` against the
# BASE ref, so `[main, dev]` silently excluded stacked child PRs — whose
# base is another open PR's head branch, an intentional review workflow per
# AGENTS.md that `enforce-target` already exempts from the wrong-base gate.
# The #951-#955 stack merged with `enforce-target`, `label`, and
# `react-doctor` as its only check-runs: no test job ever queued for 24
# changed files under `src/`.
#
# An allowlist cannot express "base is another PR's head" — stacked bases
# carry contributor prefixes (`fix/`, `feat/`, `agent/`) as readily as
# `codex/`, and contributor stacks need CI most. The `changes` job below is
# the real scope gate, using the same allowlist as the push trigger. Safe to
# widen here
# because this workflow is `pull_request` (not `pull_request_target`),
# declares `contents: read`, and reads no secrets.
#
# `push:` stays pinned to the integration lines: it gates the release path,
# and this trigger already covers review.
push:
branches: [main, preview, dev]
paths:
- "Dockerfile"
- "compose.yaml"
- ".dockerignore"
- "docker/**"
- "src/**"
- "bin/**"
- "tests/**"
- "scripts/**"
- "app/**"
- "desktop/**"
- "gui/**"
- "assets/**"
- ".gitattributes"
- ".npmignore"
- "package.json"
- "bun.lock"
- "tsconfig.json"
- "README.md"
- "LICENSE"
- ".github/workflows/ci.yml"
- ".github/workflows/release.yml"
- ".github/workflows/enforce-pr-target.yml"
- ".github/workflows/stale-needs-info.yml"
workflow_dispatch:
inputs:
lane:
description: "all (default) or macos-control"
type: choice
default: all
options: [all, macos-control]
permissions:
contents: read
# Retrigger CI after dir-fsync / oauth deadline follow-ups (tip 34a1ac46).
concurrency:
# `push` and `pull_request` want supersession: a newer head on the same ref
# makes the older one irrelevant, and cancelling it saves an hour of runners
# for an answer nobody will read.
#
# `workflow_dispatch` is the opposite. An operator dispatching a lane has
# asked for evidence about one specific commit, and the next merge into
# `dev` is not a newer answer to that question — it is an unrelated commit
# that happens to share `github.ref`. Keyed on the ref alone, the merge
# cancelled the dispatch, so the `macos control` lane — the longest job in
# this workflow at roughly fifty minutes — could not complete on any branch
# under active development. Run 35318264610 was cancelled in the same second
# its job started, three minutes after it was queued. Cancellations of that
# shape were read as runner capacity for months, and a maintainer dispatching
# the lane for release evidence usually got nothing back without noticing,
# because a cancelled job reports neither pass nor fail (#5037).
#
# `github.run_id` is unique per run, so each dispatch is a group of one: it
# cancels nothing and nothing cancels it, including a second dispatch of the
# same ref.
group: cross-platform-ci-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }}
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }}
jobs:
# Which Windows runner this run is allowed to use.
#
# READ THIS BEFORE TREATING IT AS A SECURITY BOUNDARY: it is not one.
#
# On `pull_request` this workflow is loaded from the PR head, so the `case`
# below is owned by the proposed patch exactly like an `if:` guard would be.
# A hostile PR can delete the branch and hardcode the self-hosted labels into
# `$GITHUB_OUTPUT`, and `runs-on` will honour it. That this job runs on
# `ubuntu-latest` changes nothing — the untrusted part is its OUTPUT, not its
# host. `.github/workflows/ci.yml` is in the `changes` job's `ci` filter, so
# such an edit triggers every expensive verification job.
#
# What actually keeps untrusted code off a self-hosted runner lives OUTSIDE
# this file, where a PR cannot reach it: the fork-PR approval policy
# (`all_external_contributors`) and the judgement of whoever clicks approve.
# Runner groups would be the other lever, but they are an organisation
# feature and this repository is user-owned, so the approval policy is the
# only one available here. GitHub's own guidance is to avoid self-hosted
# runners on public repositories for this reason.
#
# So read the routing below as a STABILITY/OPERATIONS control that keeps
# honest pull requests on GitHub-hosted runners and lets trusted branch runs
# avoid the hosted-Windows Bun crashes. It is not the security boundary.
#
# `push` on dev/main/preview requires the push permission, and
# `workflow_dispatch` requires write access, so both carry a trusted author.
# A trusted author is not audited code: merging a contributor PR into `dev`
# fires `push`, and its dependencies and postinstall hooks then run here.
select-windows-runner:
name: select windows runner
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
runner: ${{ steps.pick.outputs.runner }}
label: ${{ steps.pick.outputs.label }}
steps:
- name: Pick runner
id: pick
env:
# Read through env rather than interpolating directly into the script:
# `github.event_name` is a fixed vocabulary, but keeping the habit means
# no future edit here can grow a script-injection sink.
EVENT_NAME: ${{ github.event_name }}
USE_SELF_HOSTED: ${{ vars.OCX_SELF_HOSTED_WINDOWS }}
shell: bash
run: |
set -euo pipefail
trusted=no
case "$EVENT_NAME" in
push|workflow_dispatch) trusted=yes ;;
esac
# Repository variable OCX_SELF_HOSTED_WINDOWS is an OPERATIONAL switch,
# not a security control: a PR that rewrites this script ignores it for
# the same reason it ignores the event check above. Its job is to keep CI
# working when the box is off or busy. Anything other than `1` —
# including unset, the state before a runner exists — falls back to
# windows-latest.
if [ "$trusted" = "yes" ] && [ "${USE_SELF_HOSTED:-}" = "1" ]; then
echo 'runner=["self-hosted","Windows","X64","ocx-home"]' >> "$GITHUB_OUTPUT"
echo 'label=self-hosted (ocx-home)' >> "$GITHUB_OUTPUT"
else
echo 'runner="windows-latest"' >> "$GITHUB_OUTPUT"
echo 'label=windows-latest' >> "$GITHUB_OUTPUT"
fi
# Which areas this push actually touches.
#
# Deliberately a job-level filter rather than a wider workflow-level `paths:`
# one. A workflow skipped by path filtering leaves its checks Pending forever,
# so a PR requiring them can never merge; a skipped *job* reports success.
# That asymmetry is the whole reason this job exists instead of more entries
# in the `on:` block above.
changes:
name: changes
runs-on: ubuntu-latest
timeout-minutes: 5
# The workflow grants only `contents: read`, and specifying any permission
# sets every unspecified one to `none`. paths-filter reads the PR's file list
# through the API on `pull_request`, so without this it fails outright — and
# a failed filter produces empty outputs, which every `== 'true'` condition
# below would read as "nothing changed, skip".
permissions:
contents: read
pull-requests: read
outputs:
# Downstream jobs consume only the value re-emitted by the validation
# step. A missing or malformed filter output must fail this job instead
# of silently making every expensive job skip.
ci: ${{ steps.scope.outputs.ci }}
gui: ${{ steps.filter.outputs.gui }}
packaging: ${{ steps.filter.outputs.packaging }}
docs: ${{ steps.filter.outputs.docs }}
structure: ${{ steps.filter.outputs.structure }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# No job here pushes, and the self-hosted box keeps its checkout
# between jobs, so leaving a usable token in .git/config is avoidable
# residue. Matches the convention already used by the other workflows.
persist-credentials: false
- name: Detect changed areas
id: filter
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
# Without this the action compares against the repository's DEFAULT
# branch, which is `main`. A push to `dev` would then be diffed against
# `main`, so every area touched since the last promotion keeps reading
# as "changed" — the scoped jobs would run on nearly every dev push and
# the saving would silently not happen while CI stayed green.
#
# On `pull_request` the action ignores this and uses the PR's own file
# list. On a branch push it means "compare against the previous commit
# on this branch", which is the intent.
base: ${{ github.ref }}
filters: |
# Mirrors the push trigger's path allowlist. Pull requests always
# start the workflow so the aggregate check exists, while these
# paths decide whether the expensive test jobs need to run.
ci:
- 'Dockerfile'
- 'compose.yaml'
- '.dockerignore'
- 'docker/**'
- 'src/**'
- 'bin/**'
- 'tests/**'
- 'scripts/**'
- 'app/**'
- 'desktop/**'
- 'gui/**'
- 'assets/**'
- '.gitattributes'
- '.npmignore'
- 'package.json'
- 'bun.lock'
- 'tsconfig.json'
- 'README.md'
- 'LICENSE'
- '.github/workflows/ci.yml'
- '.github/workflows/release.yml'
- '.github/workflows/enforce-pr-target.yml'
- '.github/workflows/stale-needs-info.yml'
gui:
- 'gui/**'
# The docs site is built by nothing else on a pull request. `ci` above
# deliberately omits `docs-site/**` -- a prose edit has no business
# starting the cross-platform suite -- and `deploy-docs.yml` triggers
# only on `push` to `main`. That left the Astro toolchain with no
# pull-request build gate at all, so a dependency bump under
# `docs-site/` could only be proven by an author's local run and would
# otherwise surface at promotion.
#
# `.github/workflows/ci.yml` is here so an edit to the job below
# verifies itself. Without it this filter's own pull request would
# skip the thing it adds.
docs:
- 'docs-site/**'
- '.github/workflows/ci.yml'
# `structure/` is the one directory whose entire purpose is a gate,
# and it was the one directory the gate never saw. `ci` above omits
# it for the same reason it omits `docs-site/**`, so a pull request
# touching only `structure/` skipped every expensive leg — including
# the suite that contains `tests/ci-workflows/structure-ssot.test.ts`,
# the only place CI runs `bun run structure:check`. The aggregate
# then reported success over nothing. #4999, a pure doc split, is the
# live example; it stayed invisible for so long because
# `structure/AGENTS.md` makes a `structure/` edit almost always
# arrive beside a `src/` one, which the `ci` filter does match.
#
# The job this feeds runs the gate alone rather than widening `ci`,
# so a prose edit still does not start the cross-platform matrix.
# `.github/workflows/ci.yml` is listed for the same reason `docs`
# lists it: without it this filter's own pull request would skip the
# job it adds.
# This list stays PULL-REQUEST scope only, like `docs` above: the
# push trigger's `paths:` is pinned to mirror `ci` exactly, and
# `dev`, `main` and `preview` are all protected to require a pull
# request, so every `structure/` change is read at the pull request
# before it can reach an integration line.
structure:
- 'structure/**'
- '.github/workflows/ci.yml'
# Everything that ends up inside `npm pack`, or that decides what
# does. `src/**` belongs here because package.json ships `src` and
# bin/ocx.mjs executes it: without that entry an ordinary source PR
# would get no Windows verification at all, since the Windows suite
# now runs only at the shipping boundary.
packaging:
- 'package.json'
- 'bun.lock'
- 'src/**'
- 'bin/**'
- 'gui/**'
- 'assets/**'
- '.npmignore'
# `.gitattributes` decides how tracked package inputs are
# materialized on each runner, so an attribute change can put CRLF
# shebangs into the tarball without any source file moving.
- '.gitattributes'
- 'README.md'
- 'LICENSE'
- 'scripts/prepare-package.ts'
- name: Assert the scope output is usable
id: scope
shell: bash
env:
CI_SCOPE: ${{ steps.filter.outputs.ci }}
run: |
set -euo pipefail
case "$CI_SCOPE" in
true|false)
printf 'ci=%s\n' "$CI_SCOPE" >> "$GITHUB_OUTPUT"
;;
*)
printf '::error::changes.outputs.ci was %q, expected true or false\n' "$CI_SCOPE"
exit 1
;;
esac
# The suite, split by file across four Linux runners.
#
# `scripts/ci/run-bun-test-batches.sh` mirrors Bun's sorted round-robin shard
# assignment, then runs each shard in small batches so every batch gets a fresh
# Bun process. The helper prints the exact files before each batch and retries
# nothing: a test failure, a process timeout and a Bun runtime crash each fail
# the shard where they happen. A timeout or a crash is additionally swept one
# file per process, after the shard has already failed, to attribute it.
# Storage-policy API tests and api-usage are deliberately excluded here and run
# in dedicated jobs below. Bun 1.3.14 can corrupt the Linux isolate/epoll state
# around those Worker-heavy harnesses; keeping them out of the general shards
# prevents one runtime failure from wedging ~150 unrelated files while preserving
# the same coverage in fresh Bun processes.
#
# Only the suite lives here. Typecheck, lint, build, and the scans run once in
# `gates` rather than four times — they are fixed cost, and paying it per shard
# would eat what the sharding saves.
test:
name: test ${{ matrix.shard }}/4
needs: changes
if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
# A quarter of the suite. A shard that needs longer than this is wedged, not
# slow — the old 30-minute ceiling was margin for the Windows leg, which no
# longer runs here (see platform-windows).
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# No job here pushes, and the self-hosted box keeps its checkout
# between jobs, so leaving a usable token in .git/config is avoidable
# residue. Matches the convention already used by the other workflows.
persist-credentials: false
# tests/ci-workflows/release-version-line.test.ts compares package.json against the
# newest release tag. actions/checkout fetches no tags by default, so
# without this the check reads an empty tag set and passes on anything -
# the exact regression it exists to catch would ride through CI green.
#
# Tags only, not full history: `fetch-depth: 0` would clone every commit to
# answer a question about refs. A shallow fetch still brings each tag and its
# target commit, which is all the check reads - the tag list, and whether the
# newest tag names HEAD. That second read only happens on a release commit,
# where the tag points at HEAD and the commit is present by definition.
fetch-tags: true
- name: Setup project Bun
uses: ./.github/actions/setup-project-bun
# The GUI install is NOT optional here, however unrelated it looks to a
# test shard. Several files under tests/ import JSX-bearing modules from
# gui/src (ProviderRail and friends), and React is declared only in
# gui/package.json. Without this the affected shards die on
# `Cannot find module 'react/jsx-dev-runtime'` while the other shards pass.
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd gui
bun install --frozen-lockfile
# Nor is the build. Tests that fetch the served dashboard read the session
# bootstrap meta tags out of `gui/dist/index.html`, so without a build the
# server has no index to serve and those assertions see an empty string.
# The old three-platform job happened to satisfy this because every leg ran
# the GUI build as part of the same job; splitting the suite away from the
# gates removed that coincidence, so the dependency has to be explicit.
- name: Build GUI
run: |
cd gui
bun run build
- name: Test in fresh-process batches
env:
TEST_SHARD: ${{ matrix.shard }}/4
run: bash scripts/ci/run-bun-test-batches.sh "$TEST_SHARD"
# Bun 1.3.14 has shown a Linux isolate/epoll race around the storage-policy
# harness. Keep the entire six-file family in one fresh process so a runtime
# failure is bounded to this job instead of poisoning a general test shard.
storage-policy:
name: storage policy
needs: changes
if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup project Bun
uses: ./.github/actions/setup-project-bun
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd gui
bun install --frozen-lockfile
- name: Build GUI
run: |
cd gui
bun run build
- name: Test storage policy API
run: |
bun test --isolate \
./tests/storage/api-storage-policy-already-running.test.ts \
./tests/storage/api-storage-policy-mutation-busy.test.ts \
./tests/storage/api-storage-policy-put-race.test.ts \
./tests/storage/api-storage-policy-run.test.ts \
./tests/storage/api-storage-policy.test.ts \
./tests/storage/api-storage.test.ts
# Bun 1.3.14 has shown a Linux isolate wedge around startServer() plus the user
# cost overlay reconciler. Keep api-usage in one fresh process so a runtime
# failure is bounded to this job instead of poisoning a general test shard.
api-usage:
name: api usage
needs: changes
if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup project Bun
uses: ./.github/actions/setup-project-bun
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd gui
bun install --frozen-lockfile
- name: Build GUI
run: |
cd gui
bun run build
- name: Test api usage API
run: bun test --isolate ./tests/server/api-usage.test.ts
# Everything that is not the suite: type safety, privacy, lint, build, smoke.
# One runner, once per push. Splitting these across the shards would repeat a
# fixed couple of minutes four times to save nothing.
gates:
name: gates
needs: changes
if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# No job here pushes, and the self-hosted box keeps its checkout
# between jobs, so leaving a usable token in .git/config is avoidable
# residue. Matches the convention already used by the other workflows.
persist-credentials: false
- name: Setup project Bun
uses: ./.github/actions/setup-project-bun
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd gui
bun install --frozen-lockfile
- name: GUI lint
if: needs.changes.outputs.gui == 'true'
run: |
cd gui
bun run lint
- name: Typecheck
run: |
bun x tsc --noEmit
bun x tsc --noEmit -p tests/tsconfig.doctor-service-memory-contract.json
bun x tsc --ignoreConfig --noEmit --strict --target ESNext --module ESNext --moduleResolution bundler --types bun-types --skipLibCheck scripts/ci/docker-smoke.ts
- name: GUI tests
run: cd gui && bun test --isolate tests
- name: Privacy scan
run: bun run privacy:scan
# The ocx skill ships a capability -> route map generated from src/cli/capabilities.ts.
# `bun run test` already covers this via tests/ci-workflows/skill-ocx.test.ts; this step exists so the
# failure names the fix instead of surfacing as a byte-comparison diff in a test log.
- name: Check the generated ocx skill surface is current
run: bun run skill:surface:check
- name: Check release helper syntax
run: bun build scripts/release.ts --target=bun --outdir=.tmp/ci-release-script-check
- name: GUI build
if: needs.changes.outputs.gui == 'true'
run: |
cd gui
bun run build
- name: Record dashboard preview source
if: needs.changes.outputs.gui == 'true'
run: |
git rev-parse HEAD > gui/dist/build-commit.txt
git rev-parse HEAD:gui > gui/dist/build-gui-tree.txt
- name: Upload dashboard preview
if: needs.changes.outputs.gui == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: dashboard-preview-${{ github.sha }}
path: gui/dist
retention-days: 7
if-no-files-found: error
- name: CLI help smoke
run: bun run src/cli/index.ts help
platform-macos:
name: macos ${{ matrix.shard }}/2
needs: changes
if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'
runs-on: macos-latest
# Two shards. Unsharded, this job was the critical path on every green dev
# push (mean 14.9 min against a 4.7 min Linux maximum; devlog
# 260905_test_modularization_and_windows/003). Two halves finish in ~7.7 and
# cost 0.6 extra macOS minutes of setup per run. The whole-pool control that
# the single job used to provide lives in macos-control below, on dispatch.
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
shard: [1, 2]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# No job here pushes, and the self-hosted box keeps its checkout
# between jobs, so leaving a usable token in .git/config is avoidable
# residue. Matches the convention already used by the other workflows.
persist-credentials: false
# tests/ci-workflows/release-version-line.test.ts compares package.json against the
# newest release tag. actions/checkout fetches no tags by default, so
# without this the check reads an empty tag set and passes on anything -
# the exact regression it exists to catch would ride through CI green.
#
# Tags only, not full history: `fetch-depth: 0` would clone every commit to
# answer a question about refs. A shallow fetch still brings each tag and its
# target commit, which is all the check reads - the tag list, and whether the
# newest tag names HEAD. That second read only happens on a release commit,
# where the tag points at HEAD and the commit is present by definition.
fetch-tags: true
- name: Setup project Bun
uses: ./.github/actions/setup-project-bun
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd gui
bun install --frozen-lockfile
# Same reason as the shards: the suite serves gui/dist and reads it back.
- name: Build GUI
run: |
cd gui
bun run build
# No attempt is ever repeated here. A Bun panic is the interpreter dying mid-suite,
# which is process death a user would have seen; a second execution that happens not
# to die does not un-kill the first, and a leg that reports green on it is reporting
# something that did not happen. This leg retried a crash exactly once until
# 2026-09-17, the Linux batch runner swept crashed batches into green, and the result
# was that Bun 1.4.2's preload segfault stayed invisible on every lane except Windows.
#
# `is_bun_runtime_crash` from scripts/ci/bun-crash-signatures.sh survives, and this leg,
# the Windows leg, the macOS control and the Linux batch runner all still source that one
# definition. Its job is now diagnosis only: it decides which failure message is printed,
# never whether the leg fails.
- name: Test
env:
MACOS_TEST_SHARD: ${{ matrix.shard }}
run: |
# GitHub Actions starts bash `run:` blocks with `-e`. Disable
# errexit so a Bun crash reaches PIPESTATUS and the classifier below,
# instead of aborting the step before either can be read.
set +e
set -uo pipefail
# One shared classifier for every lane; see scripts/ci/bun-crash-signatures.sh.
source scripts/ci/bun-crash-signatures.sh
run_macos_suite() {
local suite_log suite_status
suite_log="$(mktemp -t ocx-macos-suite.XXXXXX)" || return $?
# The per-test ceiling applies to every invocation, including each isolated
# serial file. One attempt, whatever the outcome.
bun test --isolate --timeout 60000 "$@" 2>&1 | tee "$suite_log"
suite_status="${PIPESTATUS[0]}"
if [ "$suite_status" -eq 0 ]; then
rm -f "$suite_log"
return 0
fi
if is_bun_runtime_crash "$suite_status" "$suite_log"; then
echo "::error::Bun runtime crash in the macOS suite (exit ${suite_status}); a crash is process death, not a test result, and it fails this leg on the first occurrence."
else
echo "::error::macOS suite failed (exit ${suite_status})."
fi
rm -f "$suite_log"
return "$suite_status"
}
case "$MACOS_TEST_SHARD" in
1|2) ;;
*) echo "::error::Invalid macOS test shard"; exit 64 ;;
esac
serial_manifest="$(bun -e 'import { SERIAL_FULL_SUITE_FILES } from "./scripts/test.ts"; console.log(SERIAL_FULL_SUITE_FILES.join("\n"));')"
manifest_status=$?
if [ "$manifest_status" -ne 0 ]; then
exit "$manifest_status"
fi
serial_files=()
ignore_args=()
serial_count=0
while IFS= read -r file; do
if [[ ! "$file" =~ ^[[:alnum:]_./-]+$ || "$file" == /* || "/$file/" == *"/../"* || "/$file/" == *"/./"* ]]; then
echo "::error::Invalid serial test path"
exit 1
fi
for ((index=0; index<serial_count; index++)); do
if [ "${serial_files[$index]}" = "$file" ]; then
echo "::error::Duplicate serial test path"
exit 1
fi
done
# The established basename glob must exclude exactly the owned file.
# Refuse missing files or collisions instead of silently losing tests.
base="${file##*/}"
matches="$(find tests -type f -name "$base")" || exit $?
if [ "$matches" != "tests/$file" ]; then
echo "::error::Missing or ambiguous serial test path: $file"
exit 1
fi
serial_files[$serial_count]="$file"
ignore_args+=("--path-ignore-patterns" "**/$base")
serial_count=$((serial_count + 1))
done <<< "$serial_manifest"
if [ "$serial_count" -eq 0 ]; then
echo "::error::Empty serial test manifest"
exit 1
fi
run_macos_suite tests "--shard=$MACOS_TEST_SHARD/2" "${ignore_args[@]}"
suite_status=$?
if [ "$suite_status" -ne 0 ]; then exit "$suite_status"; fi
for ((index=0; index<serial_count; index++)); do
if [ "$((index % 2 + 1))" -ne "$MACOS_TEST_SHARD" ]; then continue; fi
file="${serial_files[$index]}"
echo "::group::macOS isolated $file"
run_macos_suite --parallel=1 "./tests/$file"
suite_status=$?
echo "::endgroup::"
if [ "$suite_status" -ne 0 ]; then exit "$suite_status"; fi
done
- name: CLI help smoke
run: bun run src/cli/index.ts help
macos-control:
name: macos control
needs: changes
if: github.event_name == 'workflow_dispatch'
runs-on: macos-latest
# The unsharded control for the sharded Linux lane: the only place the whole
# suite runs in one pool, so it is the place that catches what sharding
# hides. The flakes it keeps surfacing are timing, not logic, and the fix
# is the tests, not a fourth lane.
#
# Sized to measured work, not to a guess. At 30 this lane never once finished:
# every run was cancelled slightly past halfway and the cancellations were read
# as runner capacity for months (#4905). An authorized one-off measurement let it
# complete for the first time and it took 50m39s wall, Bun reporting 3034.18s over
# 26526 tests in 1343 files. 75 leaves roughly 24 minutes of headroom on that
# number, which is the growth room the suite needs without letting a genuine hang
# sit for an hour before anyone sees it.
#
# This bound is not a fix for anything the run reports. That first complete run
# surfaced four tests that exceed their own timeouts under shared-process pressure,
# tracked separately in #4997; raising this budget is what made them observable and
# must not be mistaken for resolving them.
timeout-minutes: 75
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# No job here pushes, and the self-hosted box keeps its checkout
# between jobs, so leaving a usable token in .git/config is avoidable
# residue. Matches the convention already used by the other workflows.
persist-credentials: false
# tests/ci-workflows/release-version-line.test.ts compares package.json against the
# newest release tag. actions/checkout fetches no tags by default, so
# without this the check reads an empty tag set and passes on anything -
# the exact regression it exists to catch would ride through CI green.
#
# Tags only, not full history: `fetch-depth: 0` would clone every commit to
# answer a question about refs. A shallow fetch still brings each tag and its
# target commit, which is all the check reads - the tag list, and whether the
# newest tag names HEAD. That second read only happens on a release commit,
# where the tag points at HEAD and the commit is present by definition.
fetch-tags: true
- name: Setup project Bun
uses: ./.github/actions/setup-project-bun
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd gui
bun install --frozen-lockfile
# Same reason as the shards: the suite serves gui/dist and reads it back.
- name: Build GUI
run: |
cd gui
bun run build
# This is the lane that exists to see what sharding hides, so it is the last place a
# repeated attempt belongs. One execution, whatever the outcome; the shared classifier
# decides which message is printed, never whether the leg fails.
- name: Test
run: |
# GitHub Actions starts bash `run:` blocks with `-e`. Disable
# errexit so a Bun crash reaches PIPESTATUS and the classifier below,
# instead of aborting the step before either can be read.
set +e
set -uo pipefail
# One shared classifier for every lane; see scripts/ci/bun-crash-signatures.sh.
source scripts/ci/bun-crash-signatures.sh
suite_log="$(mktemp -t ocx-macos-suite.XXXXXX)"
# --timeout: Bun's default 5s per-test ceiling is the recurring flake
# class on this loaded shared runner (real retry windows + server
# round-trips exceed 5s under contention; a 10s-floor in-test
# watchdog fired at 10.16s there). 60s keeps hangs bounded (the 30m
# job timeout is the outer backstop) while removing the timing
# flakes — assertions are untouched. Pairs with the 30s CI floor in
# tests/helpers/ci-watchdog.ts.
bun test --isolate --timeout 60000 tests 2>&1 | tee "$suite_log"
suite_status="${PIPESTATUS[0]}"
if [ "$suite_status" -eq 0 ]; then
exit 0
fi
if is_bun_runtime_crash "$suite_status" "$suite_log"; then
echo "::error::Bun runtime crash in the macOS control suite (exit ${suite_status}); a crash is process death, not a test result, and it fails this leg on the first occurrence."
else
echo "::error::macOS control suite failed (exit ${suite_status})."
fi
exit "$suite_status"
- name: CLI help smoke
run: bun run src/cli/index.ts help
# Windows runs only when a maintainer asks for it by hand.
#
# It left the PR lane first (16m23s against a 6-minute Linux critical path;
# the ceiling was raised twice rather than the gap closed — #711 vs #653,
# issue #717, then #827's 20-minute kill), then left the shipping boundary
# when the sharded promotion run surfaced ~207 Windows-only test failures
# that had been invisible while dev skipped the leg. Those are real defects,
# tracked as issue #1059, but they are pre-existing on every released
# version — gating the release on them blocks shipping fixes to the platforms
# that pass, for a platform that has never shipped green.
#
# The leg stays in the workflow, sharded and dispatchable, so the failure
# list can be burned down without losing the ability to measure progress.
# release.yml gates on a successful push-event run of this workflow, which
# now means Linux + macOS + the gates; Windows re-enters the gate when the
# tracked failures are fixed, not before.
platform-windows:
name: windows ${{ matrix.shard }}/9
needs: select-windows-runner
if: >-
github.event_name == 'workflow_dispatch' && (github.event.inputs.lane == '' || github.event.inputs.lane == 'all')
runs-on: ${{ fromJSON(needs.select-windows-runner.outputs.runner) }}
# Sharded like the Linux legs. The single-leg run reached 30 minutes on a
# green suite and was killed in cleanup; four shards put each leg inside the
# same budget the Linux shards already hold.
#
# 15 was that Linux budget, and on this leg it truncated the evidence rather
# than bounding a hang: shard 1/4 of run 32340498394 was CANCELLED at exactly
# 15m12s while still executing tests, so its result was neither pass nor fail
# and the composed-acceptance cases it carries could not be read at all. The
# other shards finished in 14-15 minutes, which is the wrong side of the
# margin. 25 leaves the outer bound in place — a wedged shard still dies —
# while making a completed shard the normal outcome. The crash retry that used to
# double a shard's work is gone; the ceiling is kept at the value chosen for it
# rather than re-tightened, because narrowing it would trade a removed mask for a
# new truncation, and a cancelled shard is neither a pass nor a fail.
#
# Four shards then grew into the ceiling: across five runs of one branch, completed
# shards took 17-25 minutes and run 33934756997 cancelled a green 3/4 at 25m12s —
# the same truncation as above. The bound is kept; the work per shard is cut instead.
# Six shards put each leg at roughly two-thirds of the four-shard wall time, back
# inside the margin 25 was chosen to provide.
# Shard 1 of run 34036848646 then reached that wall with 2736 passing tests
# and no test failures. The matched tests were 25% slower than the prior
# complete run; about one minute of tests remained. That change kept every
# test deadline and all six shards, but left the whole batch and cleanup a
# 30-minute bound.
#
# Six shards then grew into the 30-minute ceiling too. Across seven lane=all
# dispatches the six shard totals were 114.3-133.2 minutes. The worst observed
# shard imbalance was 1.43x its dispatch's per-shard average. Eight shards leave
# no margin: 133.2 / 8 * 1.43 * 1.25 = 29.8 minutes after the already-observed
# 25% run-to-run slowdown. Nine gives 133.2 / 9 * 1.43 * 1.25 = 26.5 minutes.
# Keep the 30-minute bound and pay for three more concurrent runners plus their
# repeated checkout/install/build setup so test work, rather than the ceiling,
# shrinks. The per-batch timeout below is independent: it aborts a stuck Bun
# process but adds no delay to a healthy one, so calibrating it does not change
# this total-work projection.
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9]
steps:
- name: Show selected runner
shell: bash
run: echo "windows leg on ${{ needs.select-windows-runner.outputs.label }}"
# A self-hosted runner keeps its working directory between jobs. Without an
# explicit wipe, a file deleted in the commit under test survives on disk
# and the suite passes against a tree that no longer exists in git.
# `--ephemeral` registration de-registers the runner after each job but does
# not clean the workspace, so this step is what makes the checkout honest.
- name: Clean workspace (self-hosted only)
if: runner.environment == 'self-hosted'
shell: bash
# `|| true` used to swallow this, which defeats the point: a clean that
# fails on permissions leaves the deleted files in place and the checkout
# below then validates a tree that no longer exists in git. Only the
# not-a-repository case is tolerated — that is the first run on a fresh
# box, where there is nothing to clean.
run: |
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git clean -xffd .
fi
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# No job here pushes, and the self-hosted box keeps its checkout
# between jobs, so leaving a usable token in .git/config is avoidable
# residue. Matches the convention already used by the other workflows.
persist-credentials: false
# Same reason as the Linux shards and the macOS control: this leg runs the
# whole suite, and tests/ci-workflows/release-version-line.test.ts reads release tags.
# Without tags the check sees an empty set and cannot fail.
fetch-tags: true
- name: Setup project Bun
uses: ./.github/actions/setup-project-bun
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd gui
bun install --frozen-lockfile
# Same reason as the shards: the suite serves gui/dist and reads it back.
- name: Build GUI
run: |
cd gui
bun run build
- name: Test in fresh-process batches
# --timeout: the Linux batches and the macOS control both pass 60000; this leg was
# the only one left on Bun's 5s default, and it is the slowest hardware on the board.
# Three of its failures were the default firing on tests that had not hung — the
# composed-acceptance cases spawn a real `ocx start` and were still working at 41s.
#
# Nothing is retried. Run 35171877721 proved Linux's 12-file/120-second defaults are
# not Windows defaults: 58 completed primary batches took 4.6-105.8s and seven more
# hit 120s. Six of those passed every file alone; splitting their attribution times
# into six-file halves gives a 148.0s maximum. The seventh carried
# codex-inject-integration.test.ts, which passed in 312.0s and 317.6s in green runs
# 35164979005 and 35161399172. Replacing its censored 120s attribution with 317.6s projects
# that six-file half at 337.4s; 25% run variance makes 421.8s, so 480s leaves 58.2s.
# Six-file batches add twelve Bun processes per shard, but the two green shards measured
# only 0.106-0.168s of wrapper overhead per process: at most ~2.1s against the margin.
# A timeout or crash still fixes the shard red before singleton attribution. scope=all
# preserves the full Windows suite; Linux keeps its correctly sized 12-file/120s defaults.
#
# The preload's Windows-only user lock serializes separate test runners on one machine.
# These batches are already one dedicated job's sequential pieces, so treating each Bun
# process as a competing runner can queue batch N+1 behind a straggler from batch N until
# this step's 480s process bound fires without running a test. Disable that outer queue for
# this step only. Every process still creates its own isolated home and arms the live-home
# and service-manager guard before the lock boundary.
shell: bash
env:
TEST_SHARD: ${{ matrix.shard }}/9
BUN_TEST_FILE_SCOPE: all
BUN_TEST_BATCH_SIZE: "6"
BUN_TEST_BATCH_TIMEOUT_SECONDS: "480"
OCX_TEST_NO_QUEUE: "1"
run: bash scripts/ci/run-bun-test-batches.sh "$TEST_SHARD"
- name: CLI help smoke
run: bun run src/cli/index.ts help
# Keep every OS credential-store check on a disposable GitHub-hosted machine.
# A force-cancelled process cannot run its in-process finally cleanup, so no
# keyring matrix leg may use the persistent self-hosted Windows runner.
keyring-smoke:
name: keyring ${{ matrix.name }}
needs: changes
if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'
runs-on: ${{ matrix.runner }}
timeout-minutes: 8
strategy:
fail-fast: false
matrix:
include:
- name: ubuntu
runner: ubuntu-latest
- name: windows
runner: windows-latest
- name: macos
runner: macos-latest
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup project Bun
uses: ./.github/actions/setup-project-bun
- name: Install dependencies
run: bun install --frozen-lockfile
# Linux hosted runners do not provide an unlocked desktop keyring session.
# Run Secret Service with temporary state instead of skipping Linux.
- name: Install Secret Service dependencies (Linux)
if: matrix.name == 'ubuntu'
shell: bash
run: |
set -euo pipefail
sudo apt-get update
sudo apt-get install --yes --no-install-recommends dbus-x11 gnome-keyring
- name: OS keyring create/read/delete smoke
shell: bash
run: |
set -euo pipefail
if [ "$RUNNER_OS" != "Linux" ]; then
bun run scripts/keyring-smoke.ts
exit 0
fi
keyring_home="$(mktemp -d)"
runtime_dir="$(mktemp -d)"
cleanup() { rm -rf -- "$keyring_home" "$runtime_dir"; }
trap cleanup EXIT
chmod 700 "$keyring_home" "$runtime_dir"
HOME="$keyring_home" XDG_RUNTIME_DIR="$runtime_dir" dbus-run-session -- bash -euo pipefail -c '
# Create and unlock the isolated login keyring without putting its
# ephemeral password in argv, the environment, or the job log.
od -An -N32 -tx1 /dev/urandom |
tr -d "[:space:]" |
gnome-keyring-daemon --unlock --components=secrets >/dev/null
bun run scripts/keyring-smoke.ts
'
# Exercise the source-build Compose contract, including real volume reuse.
# Host fixtures cannot prove image construction or container recreation.
docker-smoke:
name: docker smoke
needs: changes
if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup project Bun
uses: ./.github/actions/setup-project-bun
- name: Build, start, and recreate the container
run: bun scripts/ci/docker-smoke.ts
# Proves the Astro toolchain still builds the site, on the only event that can
# prove it before promotion.
#
# Linux only, and one leg. The site is static output from a Node/Bun toolchain
# with no OS-specific behaviour to promise, so a Windows or macOS leg would buy
# queue time rather than coverage. `docs-site` keeps its own manifest and
# lockfile, so this installs there and nowhere else.
#
# `--frozen-lockfile` is the point of the job as much as the build is: it fails
# on a manifest and lockfile that disagree, which is exactly the shape a
# hand-edited override introduces.
docs-site-build:
name: docs site build
needs: changes
if: needs.changes.outputs.docs == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup project Bun
uses: ./.github/actions/setup-project-bun
- name: Install docs-site dependencies
working-directory: docs-site
run: bun install --frozen-lockfile
- name: Build the docs site
working-directory: docs-site
run: bun run build
# The gate for the directory that is itself a gate.
#
# `structure:check` normally reaches CI only through
# `tests/ci-workflows/structure-ssot.test.ts`, inside the suite the `ci` filter
# decides whether to run. A pull request touching only `structure/` does not
# match that filter, so on #4999 every expensive leg reported `skipped` and the
# aggregate reported success while nothing had read the change (#5001).
#
# Running the gate directly here, rather than adding `structure/**` to `ci`,
# keeps a prose edit from starting nine Windows shards and two macOS shards for
# a check that takes seconds. It runs whenever `structure/` changes, including
# alongside a `src/` change where the suite covers it too; a duplicate of a
# seconds-long check is not worth a condition that could be wrong.
structure-gate:
name: structure gate
needs: changes
if: needs.changes.outputs.structure == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup project Bun
uses: ./.github/actions/setup-project-bun
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Structure doc-map, ownership, and invariant bindings
run: bun run structure:check
npm-global-smoke:
name: npm-global ${{ matrix.os }}
needs: changes
if: needs.changes.outputs.packaging == 'true'
runs-on: ${{ matrix.os }}
# 8 minutes was too tight for the Windows leg: dependency installation alone takes
# about 7 there, leaving under a minute for pack, verify, global install, and the
# help smoke. The job was cancelled at the wall rather than failing, so whichever
# step happened to be running was reported `cancelled` — observed on step 8 four
# times and on step 7 once, which is what made it read as a flaky global install
# instead of a budget that one OS cannot meet (#3441).
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
# Deliberately NOT routed to the self-hosted box. This job runs
# `npm install -g`, which writes into the machine's global prefix and
# would leave an `ocx` on a maintainer's personal PATH. It is an
# short job on Linux and macOS, so there is nothing to win by moving it.
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
# No job here pushes, and the self-hosted box keeps its checkout
# between jobs, so leaving a usable token in .git/config is avoidable
# residue. Matches the convention already used by the other workflows.
persist-credentials: false
# Deliberately NO setup-bun: prove `npm install -g` works without a
# separately-installed Bun. The launcher uses the bundled `bun` dependency.
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
- name: Install package dependencies
run: npm install
- name: Build package assets
run: npm run build:gui
- name: Pack
shell: bash
run: npm pack --json > pack.json
- name: Verify packed GUI asset
run: node -e "const p=require('./pack.json')[0]; if (!p.files.some(f => f.path === 'gui/dist/index.html')) { console.error('missing gui/dist/index.html in npm pack'); process.exit(1); }"
- name: Install globally (downloads bundled bun)
shell: bash
run: npm install -g ./bitkyc08-opencodex-*.tgz
- name: ocx help via bundled bun
run: ocx help
# The one check name that means "CI passed".
#
# Shard names move whenever the shard count changes, and the platform legs come
# and go by trigger. Neither is a stable thing to require in branch protection.
# This job is: it depends on every other job and asserts each result. `dev` has
# no branch protection configured today, so nothing has to be re-pointed — but
# whoever enables it has one obvious check to require.
#
# Pull requests always trigger this workflow. The `changes` job keeps
# expensive jobs scoped, but this aggregate still records explicit success
# when every producer is deliberately skipped for an out-of-scope docs change.
#
# `if: always()` is load-bearing. Without it, a failed or skipped dependency
# skips this job too — and GitHub reports a skipped job as success, so the gate
# would go green precisely when something went wrong.
widget:
name: macos widget + bundle
needs: [changes, gates]
if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'
runs-on: macos-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
bun-version: 1.3.14
- name: Install dependencies
run: |
bun install --frozen-lockfile
cd desktop
bun install --frozen-lockfile
- name: Setup Rust
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master
with:
toolchain: stable
- name: Test MenuBarCore
run: bun run test:macos
- name: Build dashboard
run: bun run build:gui
- name: Prepare desktop sidecar
run: bun desktop/scripts/prepare-sidecar.ts
- name: Build WidgetKit appex
run: bash desktop/scripts/build-widget.sh
- name: Build unsigned desktop app
working-directory: desktop
# `createUpdaterArtifacts` is on and the updater public key is committed, so a plain
# `tauri build` stops with "A public key has been found, but no private key" unless
# TAURI_SIGNING_PRIVATE_KEY is set. This job proves the appex and the app bundle build
# and that the widget is embedded; it does not ship an update, and a verification
# build has no business holding the release key. Updater artifacts are therefore off
# here and the signing path stays in release.yml, which already reads the secret and
# refuses to publish a manifest when it is absent.
run: bunx tauri build --ci --bundles app --config '{"bundle":{"createUpdaterArtifacts":false}}'
- name: Verify WidgetKit appex and desktop app
run: |
app=desktop/src-tauri/target/release/bundle/macos/OpenCodex.app
# Tauri renames the main binary only when `mainBinaryName` is set, and this config
# does not set it, so the bundled executable keeps the Cargo bin name rather than
# the product name. Read the name the bundle itself declares instead of restating
# it here, so this check follows the config instead of drifting from it.
executable="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app/Contents/Info.plist")"
test -n "$executable"
test -x "$app/Contents/MacOS/$executable"
test -x "$app/Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget"
test -x "$app/Contents/MacOS/ocx"
codesign -dv "$app/Contents/PlugIns/OpenCodexWidget.appex"
# The widget is only offered in the gallery when its bundle is actually linked in, and
# nothing else here would notice its absence: the appex builds, signs and registers
# exactly the same way with the WidgetBundle dropped by the linker.
nm -a "$app/Contents/PlugIns/OpenCodexWidget.appex/Contents/MacOS/OpenCodexWidget" \
| grep -q "OpenCodexWidget0abC6BundleV" \
|| { echo "::error::the widget bundle is not linked into the extension"; exit 1; }
desktop-shell:
name: desktop shell
needs: [changes, gates]
if: github.event_name != 'pull_request' || needs.changes.outputs.ci == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
persist-credentials: false
- name: Install Tauri Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: Setup Rust
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de # master
with:
toolchain: stable
components: rustfmt, clippy
- name: Prepare desktop check resources
run: |
set -euo pipefail
triple="$(rustc -vV | sed -n 's/^host: //p')"
mkdir -p desktop/src-tauri/binaries desktop/src-tauri/resources/gui/dist
: > "desktop/src-tauri/binaries/ocx-${triple}"
chmod +x "desktop/src-tauri/binaries/ocx-${triple}"
: > desktop/src-tauri/resources/gui/dist/.keep
- name: Check Rust formatting
run: cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --check
- name: Run Rust clippy
run: cargo clippy --manifest-path desktop/src-tauri/Cargo.toml --all-targets -- -D warnings
- name: Run Rust tests
run: cargo test --manifest-path desktop/src-tauri/Cargo.toml
ci:
name: ci
if: always()
# Every producer, including the ones that only feed other jobs. `needs` holds
# direct dependencies only, so a failing `select-windows-runner` would
# otherwise reach this gate as nothing at all while its dependents report
# `skipped`, which is the shape the step below is written to catch.
needs: [changes, select-windows-runner, test, storage-policy, api-usage, gates, platform-macos, macos-control, platform-windows, keyring-smoke, docker-smoke, docs-site-build, structure-gate, npm-global-smoke, widget, desktop-shell]
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
# Read-only, and only for this job: the Windows assertion below reads the run's own
# job list through the Actions API. The workflow default stays `contents: read`.
actions: read
steps:
- name: Assert every job this event requested succeeded
shell: bash
env:
RESULTS: ${{ toJSON(needs) }}
EVENT_NAME: ${{ github.event_name }}
LANE: ${{ github.event.inputs.lane }}
CHANGES_CI: ${{ needs.changes.outputs.ci }}
CHANGES_PACKAGING: ${{ needs.changes.outputs.packaging }}
CHANGES_DOCS: ${{ needs.changes.outputs.docs }}
CHANGES_STRUCTURE: ${{ needs.changes.outputs.structure }}
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
echo "$RESULTS" | jq .
# This gate used to accept `skipped` from any job, unconditionally, because that is
# how a trigger-scoped job reports when the workflow declines to run it. The cost of
# that shortcut is that it cannot tell "this event did not ask for the job" apart
# from "this event asked and the job never started" — and the second one is real:
# on run 35112645195 a Windows job reported `skipped` with zero steps while the
# aggregate concluded success.
#
# So derive what THIS event asked for, from the same conditions the jobs carry, and
# require `success` from every requested job and `skipped` from every other one.
# Both directions are checked: a job that runs when the gate did not expect it means
# this list and that job's `if:` have drifted, which is worth a human reading.
scoped=requested
if [ "$EVENT_NAME" = "pull_request" ] && [ "$CHANGES_CI" != "true" ]; then
scoped=not-requested
fi
packaging=not-requested
if [ "$CHANGES_PACKAGING" = "true" ]; then
packaging=requested
fi
docs=not-requested
if [ "$CHANGES_DOCS" = "true" ]; then
docs=requested
fi
structure=not-requested
if [ "$CHANGES_STRUCTURE" = "true" ]; then
structure=requested
fi
dispatch=not-requested
windows=not-requested
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
dispatch=requested
# `lane=macos-control` is the one dispatch that deliberately omits Windows.
if [ -z "$LANE" ] || [ "$LANE" = "all" ]; then
windows=requested
fi
fi
# One line per job, mirroring that job's own `if:`. Adding a job to this workflow
# without adding it here fails the gate by name rather than passing unnoticed.
GATED_JOBS="changes select-windows-runner test storage-policy api-usage gates"
GATED_JOBS="$GATED_JOBS platform-macos keyring-smoke docker-smoke npm-global-smoke"
GATED_JOBS="$GATED_JOBS macos-control platform-windows docs-site-build"
GATED_JOBS="$GATED_JOBS structure-gate widget"
GATED_JOBS="$GATED_JOBS desktop-shell"
expected_for() {
case "$1" in
changes|select-windows-runner) echo requested ;;
test|storage-policy|api-usage|gates|platform-macos|keyring-smoke|docker-smoke|widget)
echo "$scoped" ;;
desktop-shell)
echo "$scoped" ;;
npm-global-smoke) echo "$packaging" ;;
docs-site-build) echo "$docs" ;;
structure-gate) echo "$structure" ;;
macos-control) echo "$dispatch" ;;
platform-windows) echo "$windows" ;;
*) echo undeclared ;;
esac
}
bad=""
fail() {
bad="${bad}$1
"
}
while IFS='=' read -r job result; do
[ -n "$job" ] || continue
case "$(expected_for "$job")" in
undeclared)
fail "$job has no expectation in this gate; add it here when you add the job" ;;
requested)
[ "$result" = "success" ] \
|| fail "$job was requested by $EVENT_NAME but reported '$result'" ;;
*)
[ "$result" = "skipped" ] \
|| fail "$job was not requested by $EVENT_NAME but reported '$result'" ;;
esac
done <<RESULTS_EOF
$(echo "$RESULTS" | jq -r 'to_entries | .[] | "\(.key)=\(.value.result)"')
RESULTS_EOF
# The inverse: a job named here that has left `needs` would otherwise go unchecked.
for job in $GATED_JOBS; do
echo "$RESULTS" | jq -e --arg job "$job" 'has($job)' >/dev/null \
|| fail "$job is expected by this gate but is missing from its needs list"
done
# A matrix reports one rolled-up result, and that rollup cannot see a leg that never
# started: five successes and one skipped leg roll up to `success`. No matrix here
# carries a per-leg `if:`, so a leg can only be skipped when its whole job is, which
# the expectation table above already catches. What that table cannot catch is a
# matrix that produced FEWER legs than the nine a dispatch is run to read, so count
# them by name. The nine Windows legs are the entire output of that dispatch.
if [ "$windows" = requested ]; then
shards=9
# `filter=latest` (the default) is the latest execution of each job in the run,
# which is what a human reading the run sees. Asking for one ATTEMPT instead would
# fail every partial re-run: "Re-run failed jobs" puts only the repaired shard in
# the new attempt, so the five that already passed would read as missing.
legs="$(gh api --paginate \
"repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}/jobs?filter=latest&per_page=100" \
--jq '.jobs[] | select(.name | test("^windows [0-9]+/[0-9]+$")) | "\(.name)=\(.conclusion)"' \
| LC_ALL=C sort -u)"
printf 'windows legs:\n%s\n' "$legs"
shard=1
while [ "$shard" -le "$shards" ]; do
printf '%s\n' "$legs" | grep -Fqx "windows ${shard}/${shards}=success" \
|| fail "windows ${shard}/${shards} did not report success"
shard=$(( shard + 1 ))
done
found="$(printf '%s\n' "$legs" | grep -c . || true)"
[ "$found" -eq "$shards" ] \
|| fail "expected ${shards} windows shard results, found ${found}"
fi
if [ -n "$bad" ]; then
printf '%s' "$bad" | sed -e 's/^ *//' -e '/^$/d' -e 's/^/::error::/'
exit 1
fi
echo "Every job requested by ${EVENT_NAME} succeeded; every job it did not request was skipped."