# Default-safe PR CI: networked action-pin identity plus offline gates. # # Security posture (do not weaken without a security review): # - Triggered by plain `pull_request` / `push`, a secret-free schedule, or a # manual dispatch only. The privileged # `..._target` trigger variant is forbidden in this workflow: it would # let PR-controlled code run with access to repository secrets. # - `permissions` is read-only (contents: read) for every job. # - No repository secrets are referenced or injected anywhere in this # workflow. Pinact receives only the job's read-only GitHub token. # - GitHub-hosted runners only — no self-hosted runners. # - The default offline gates run pytest plus deterministic `tier:pr` # harness scenarios. Pytest's addopts deselect opt-in markers (external / # network / live_llm / gpu / ssh / lab / docker / browser), and the harness # CLI requires explicit offline eligibility, so neither path uses secrets or # live LLM/network/GPU/SSH/Docker/browser/lab resources. # # Secret-bearing live-provider/GPU/SSH/lab jobs still belong in a separate # manually triggered workflow. This file's browser and macOS jobs use only # loopback/local resources and receive no repository secrets. name: CI on: push: branches: [main, next] pull_request: schedule: - cron: "23 18 * * *" workflow_dispatch: permissions: contents: read concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: branch-name: name: Branch naming policy # Enforces the .github/CONTRIBUTING.md branch-naming table on PR head branches. # dependabot branches (dependabot/...) are exempt. `main` is allowed so # casual contributors can PR from their fork's main branch. # # Exempt on the *branch*, not on the actor. `github.actor` is whoever # triggered the latest run, so the moment a maintainer clicks "Update # branch" on a Dependabot PR the actor stops being dependabot[bot], this # job starts running, and it fails a branch name it was never meant to # judge. Because this is a required check under a strict-up-to-date # ruleset -- where every Dependabot PR after the first merge must be # updated -- an actor-based exemption makes Dependabot PRs unmergeable # through the front door. The head ref is the thing the policy is about. if: github.event_name == 'pull_request' && !startsWith(github.head_ref, 'dependabot/') timeout-minutes: 5 runs-on: ubuntu-latest steps: - name: Check head branch name env: # Never inline ${{ github.head_ref }} into `run:` — branch names # are attacker-controlled input; pass through env instead. HEAD_REF: ${{ github.head_ref }} run: | policy='^(main|next|(feat|fix|docs|test|refactor|chore|ui|harness|science|release|hotfix)/[a-z0-9][a-z0-9._-]{1,80})$' if printf '%s' "$HEAD_REF" | grep -Eq "$policy"; then echo "ok: $HEAD_REF" else echo "::error::Branch name '$HEAD_REF' violates the naming policy in .github/CONTRIBUTING.md (expected main, next, or / with prefix one of feat|fix|docs|test|refactor|chore|ui|harness|science|release|hotfix)" exit 1 fi action-pins: name: Action pins match version comments # The offline governance test proves the local shape (full SHA plus a # version comment), but only a networked check can prove that the tag in # that comment actually dereferences to the SHA beside it. Keep this in # ordinary PR CI so a stale or fabricated annotation cannot merge green. timeout-minutes: 10 runs-on: ubuntu-latest steps: - name: Checkout workflows uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Verify action pins and version comments uses: suzuki-shunsuke/pinact-action@896d595f299e71d65b9d28349d6956abe144390a # v3.0.0 with: # Validation only: this job has read-only permissions and must never # rewrite a contributor's branch. `verify` makes pinact resolve each # version comment through the GitHub API and compare it with the SHA. fix: "false" verify: "true" lint: name: Lint (pre-commit) timeout-minutes: 20 runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - name: Install dev dependencies run: uv sync --locked - name: pre-commit (black, isort, ruff, mypy, hygiene hooks) run: uv run pre-commit run --all-files - name: CSS custom-property references must be defined if: success() || failure() run: uv run python scripts/check_css_tokens.py # Its own job, not a second step in `lint`. As a step it could only ever run # when pre-commit had already passed, so a branch that failed formatting and # added an undocumented directory reported one problem and hid the other -- # and the doc gap is the one a reviewer cannot see in the diff. docs-coverage: name: Bilingual per-directory documentation timeout-minutes: 15 runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - name: Install dev dependencies run: uv sync --locked - name: Verify bilingual per-directory documentation coverage run: uv run python scripts/check_directory_readmes.py type-check: name: Type safety (core orchestration boundary) timeout-minutes: 15 runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - name: Install locked development dependencies run: uv sync --locked - name: Check the strict agent, Host, and orchestration contracts run: uv run mypy source-security: name: Source credential scan timeout-minutes: 10 runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Scan tracked release sources without echoing matches run: python scripts/source_secret_scan.py skills-installer: name: Skills installer (npx) timeout-minutes: 10 runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # The installer is the only shipped surface with no Python in it, and the # only one that writes into a directory the user already owns. Its own # job because a red pytest must not be able to hide an extraction that # escapes its target or an uninstall that deletes a file it did not write. - name: Self-test the Skill installer run: node tools/skills-installer/selftest.mjs skills-package: name: npm package carries the Skills timeout-minutes: 10 runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Separate from the self-test above, and for the reason this whole file # exists: "the installer behaves" and "the published package contains # anything to install" are different questions. A `files` entry that # stopped matching the tree would publish a working CLI with an empty # Skill library, and a red self-test must not be what hides that. - name: The npm package still carries the CLI and the Skill tree run: node tools/skills-installer/check_package.mjs release-artifacts: name: Build and install wheel/sdist timeout-minutes: 25 runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" - name: Build standards-compliant release artifacts run: uv build --no-sources --out-dir dist --clear - name: Verify metadata and all runtime resources run: python scripts/verify_release_artifacts.py dist - name: Install the core wheel with no index and no dependencies env: PIP_DISABLE_PIP_VERSION_CHECK: "1" PIP_NO_INDEX: "1" run: | python -m venv "${RUNNER_TEMP}/openai4s-release-venv" wheel_path="$(find dist -maxdepth 1 -name 'openai4s-*.whl' -print -quit)" "${RUNNER_TEMP}/openai4s-release-venv/bin/python" -m pip install --no-deps "$wheel_path" - name: Import installed modules and exercise the installed CLI env: PYTHONPATH: "" run: | cd "${RUNNER_TEMP}" "${RUNNER_TEMP}/openai4s-release-venv/bin/python" "${GITHUB_WORKSPACE}/scripts/release_import_smoke.py" # The one interpreter pyproject claims that no offline-tests leg runs. # CPython 3.11 rejects a dataclass default 3.10 and 3.12+ both accept # (a ``MappingProxyType`` was unhashable there), and one such default # made the whole agent package unimportable on 3.11 while every job # stayed green. Installing and importing the same wheel under 3.11 is # cheap and catches that class without a fourth ten-minute matrix leg. - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.11" - name: Install the core wheel under the untested-but-claimed CPython 3.11 env: PIP_DISABLE_PIP_VERSION_CHECK: "1" PIP_NO_INDEX: "1" run: | python3.11 -m venv "${RUNNER_TEMP}/openai4s-release-venv-311" wheel_path="$(find dist -maxdepth 1 -name 'openai4s-*.whl' -print -quit)" "${RUNNER_TEMP}/openai4s-release-venv-311/bin/python" -m pip install --no-deps "$wheel_path" - name: Import installed modules and exercise the installed CLI on 3.11 env: PYTHONPATH: "" run: | cd "${RUNNER_TEMP}" "${RUNNER_TEMP}/openai4s-release-venv-311/bin/python" "${GITHUB_WORKSPACE}/scripts/release_import_smoke.py" windows-launcher: name: Windows launcher parses on Windows # The Windows package's launcher is the only shipped file nothing on Linux # or macOS can even parse, so a syntax error in it is invisible to every # other job here and surfaces on the first user's machine. This runner has # no WSL, which also makes it the right place to prove the refusal path # refuses instead of proceeding. runs-on: windows-latest # Every job here carries one. A PowerShell parse that hangs would otherwise # hold a Windows runner for the six-hour default, and this job arrived from # a branch that predated the rule. timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Parse the launcher shell: pwsh run: | $errors = $null $tokens = $null [void][System.Management.Automation.Language.Parser]::ParseFile( "$PWD\scripts\windows\openai4s.ps1", [ref] $tokens, [ref] $errors) if ($errors -and $errors.Count -gt 0) { $errors | ForEach-Object { Write-Host "::error::$($_.Extent.StartLineNumber): $($_.Message)" } throw "scripts/windows/openai4s.ps1 does not parse" } Write-Host "parsed OK ($($tokens.Count) tokens)" - name: Windows PowerShell 5.1 launcher contracts shell: pwsh run: powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File tests/test_windows_launcher.ps1 - name: Refuse, with guidance, on a machine that has no WSL shell: pwsh env: OPENAI4S_NONINTERACTIVE: "1" run: | $out = & powershell.exe -NoProfile -ExecutionPolicy Bypass ` -File "$PWD\scripts\windows\openai4s.ps1" 2>&1 | Out-String # Captured immediately, and the step ends with an explicit `exit 0`. # A refusal is a NON-ZERO exit -- that is the thing being asserted -- # and the pwsh shell appends `exit $LASTEXITCODE`, so leaving it set # fails the step for the launcher behaving correctly. $code = $LASTEXITCODE Write-Host $out if ($code -eq 0) { throw "the launcher succeeded on a machine with no WSL" } if ($out -notmatch 'wsl --install') { throw "the refusal does not tell the user how to install WSL" } Write-Host "refused with exit $code and named the way out" exit 0 test: name: Offline tests (py${{ matrix.python-version }}) timeout-minutes: 45 runs-on: ubuntu-latest strategy: fail-fast: false matrix: # 3.13 is here because the macOS .dmg embeds it, and 3.14 because the # container image runs it. 3.10 is the floor from requires-python. python-version: ["3.10", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # Full history on exactly one matrix entry. `tests/test_plan_crosswalk.py` # asserts that the crosswalk's `baseline` and `audited_at` are real # commits and that the audit is reachable from HEAD -- and the default # depth-1 checkout does not contain those objects, so the check can # only skip. Deepening one job makes it a check CI actually performs # instead of one that is enforced on developer machines and nowhere # else; deepening all four would pay for the same answer twice more. # # Written as `!= '3.12' && 1 || 0` on purpose. The `cond && 0 || 1` # form never yields 0: `0` is falsy in the expression language, so # `true && 0` falls through to `|| 1`, and every row -- the 3.12 one # included -- fetched depth 1 while this comment claimed otherwise. fetch-depth: ${{ matrix.python-version != '3.12' && 1 || 0 }} - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: ${{ matrix.python-version }} # Keep one science-only job to exercise the no-RDKit fallback. Ubuntu's # Python 3.12 job also covers the optional chemistry rendering path. - name: Install dev and science dependencies if: matrix.python-version == '3.10' run: uv sync --locked --extra science - name: Install dev, science, and chemistry dependencies if: matrix.python-version == '3.12' run: uv sync --locked --extra science --extra chemistry # The .dmg and container interpreters. The science extra is not optional # here even though the core is stdlib-only: the science-dependent test # modules importorskip pandas/matplotlib, so without the extra they # silently skip and the jobs stop validating the interpreters we ship. # One step for both rather than one per interpreter: two conditions # for the same install drift apart, and the row that loses the extra # keeps passing while it stops validating anything. (Job-level `if:` # cannot see `matrix`, which is why this is a step-level condition.) - name: Install dev and science dependencies if: matrix.python-version == '3.13' || matrix.python-version == '3.14' run: uv sync --locked --extra science - name: Byte-compile all first-party sources run: uv run python -m compileall -q openai4s openai4s_compute_provider harness tests - name: Collect tests (strict markers, default deselection) run: uv run pytest --collect-only -q - name: Run the offline suite (no secrets, no network, no hardware) # addopts already passes -q; a second -q would suppress the summary line. # # `-n auto` because this one command *was* CI: 1094 of this job's 1122 # seconds were spent here, and every other job put together came # to under eight minutes. Nothing else on the critical path was worth # optimising while the suite ran on one core. # # `--dist loadfile`, not the default `load`. The default hands out # individual tests, which asserts that any two tests in a module may run # in different processes -- a property nothing here has ever had to # hold, because nothing here has ever run in parallel. loadfile asserts # only that two *files* are independent, which is the boundary the suite # was actually written to: `tests/conftest.py` redirects `~/.openai4s` # per test, and every test server binds ("127.0.0.1", 0) rather than a # fixed port, so files do not contend. Within a file, ordering and any # module-level state stay exactly as they are today. # # Scaling is linear at this width (1572 worker-seconds over 4 workers in # 393s), so the per-file granularity is costing nothing: no single file # is long enough to be the tail. run: uv run pytest -n auto --maxprocesses=4 --dist loadfile # After the suite, not before it: the pytest gate must never sit behind # a command that can fail it (tests/test_ci_gate_independence.py). The # importorskip guards make a missing science stack a *silent* skip, and # every matrix entry above installs it on purpose — so assert it really # landed, or a lockfile/extras drift turns this job green while it # validates nothing the .dmg ships. - name: Assert the science stack actually installed run: uv run python -c "import matplotlib, pandas" singlecell-skill: name: Single-cell workflow (Python 3.11) timeout-minutes: 30 runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.11" - name: Install the locked CPU single-cell stack run: uv sync --locked --extra singlecell - name: Run deterministic offline single-cell contracts # The import probe is a precondition of the same gate, like # `--collect-only` for the suite gate: the tests importorskip the # stack, so a broken install would otherwise turn into silently # skipped science contracts rather than a red check. run: | uv run python -c "import harmonypy, pertpy, pydeseq2, scanpy, skmisc" uv run python -m pytest tests/test_single_cell_rna_analysis_skill.py -m "not network" # Three gates that used to be steps in `test`, after `pytest`. A step can only # run when every step before it passed, so all three were unreachable whenever # one unrelated test was red — and a run that reported "tests failed" was # indistinguishable from one where the harness goldens had also drifted and # the response contract had also moved. Independent jobs answer three # questions instead of one, and each is legible on its own. harness-contracts: name: Harness scenario contracts timeout-minutes: 30 runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - name: Install dev dependencies run: uv sync --locked - name: Run deterministic harness contracts run: uv run python -m harness.cli run --tier pr --offline # The container image, built and run. `tests/test_container_deployment.py` # keeps the manifests honest against the code, which is a different question # from whether the image works — and a Dockerfile no gate builds is not # evidence of anything. This is the only place that answers the second # question, so it runs on every PR rather than nightly: a source change can # break the image, and the failure would otherwise surface on a user's # machine after a release. # # No Docker setup step: GitHub-hosted Linux runners already have a daemon, # and the script is deliberately the whole gate so it runs identically on a # laptop. container-image: name: Container image builds and serves timeout-minutes: 30 runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Build the image and prove the daemon runs inside it run: bash scripts/container_smoke.sh # Every route the router can match must have a recorded response *kind* and # status, not only the ones that return JSON. Cheap: it drives the surface # directly rather than rerunning the suite. response-contract: name: Route response contract timeout-minutes: 30 runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - name: Install dev and science dependencies run: uv sync --locked --extra science - name: Check every route still has a response contract run: uv run python scripts/capture_response_contract.py --check # Reruns the suite with the response capture installed and compares what the # routes returned against docs/response-schemas.json. The shapes are the # server's, not the interpreter's, so one interpreter is enough. # # This one genuinely cannot answer while the suite is red — a failing suite # exercises fewer routes, so any drift it reported would be an artefact of the # failures rather than a real change, and the script refuses rather than # guessing. Its own job anyway: "the schema gate did not get to run" and "the # schema gate ran and passed" are different facts, and as a step behind pytest # they looked identical on the summary page. # # This job is a *second* run of the whole suite. Each worker publishes its # share during `pytest_sessionfinish`, before xdist can report that worker as # successful, and records xdist's run id plus expected worker count. Four # workers writing the same destination directly would leave whichever # finished last -- a fraction of the evidence in a document that still # looked complete, which is precisely the wrong-rather-than-absent # provenance the artifact's own note warns about. # # It is split now anyway. Each worker leaves its un-elided shapes beside the # destination and `scripts/capture_response_schemas.py` merges them once # pytest has exited -- after every writer process is gone -- through the same # `merge` call `Recorder.observe` makes. Assembly rejects missing or mixed-run # shares before writing the capture. Equality and completeness are asserted # in tests/test_response_capture_assembly.py, not assumed. response-schemas: name: Frozen response shapes timeout-minutes: 45 runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - name: Install dev, science, and chemistry dependencies run: uv sync --locked --extra science --extra chemistry - name: Check the frozen response shapes still match reality run: uv run python scripts/capture_response_schemas.py --check browser-smoke: name: Browser workbench E2E (${{ matrix.engine }}) timeout-minutes: 45 runs-on: ubuntu-latest # Chromium is the engine the app is developed against, so it is the one # least likely to surprise anyone. The defects a matrix catches are the # ones a single engine cannot: a WebSocket close code Firefox reports # differently, a fetch abort WebKit surfaces as another error, a DOM API # one engine has and another does not. `fail-fast: false` so one engine's # failure still leaves the other two's results visible. strategy: fail-fast: false matrix: engine: [chromium, firefox, webkit] steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" # F-22: the UMD parser contract is Node-only (no daemon, no Playwright). # A step on this job, not a new job — the 17 PR-job count is locked. - name: Scientific renderer parser smoke run: node tests/scientific_renderers_smoke.cjs # F-23: Vitest on this Node job, not a new job — the 17 PR-job count # stays locked. frontend/package-lock.json is the rebuild pin. - name: Frontend unit tests (Vitest) run: | npm ci --prefix frontend npm test --prefix frontend # F-23: committed dist must match `npm run build`. A skipped rebuild # would ship a workbench that is not the source this PR changed. - name: Rebuild the committed workbench and fail on drift run: | npm run build --prefix frontend git diff --exit-code -- openai4s/server/webui/dist # The C1 workbench scene creates a real unsaved matplotlib figure so # it covers the daemon's figure capture and immutable version binding. - name: Install the server, scientific browser fixtures, and browser driver run: | if [ "${{ matrix.engine }}" = "chromium" ]; then uv sync --locked --extra science uv run python -c "import matplotlib" else uv sync --locked fi npm ci --ignore-scripts ./node_modules/.bin/playwright install --with-deps ${{ matrix.engine }} - name: Start the daemon and open the real workbench env: OPENAI4S_DATA_DIR: ${{ runner.temp }}/openai4s-browser-smoke OPENAI4S_KERNEL_SANDBOX: "off" # A CI runner is a Linux host with no keychain and no session bus, # so `auto` correctly fails closed here — that is the policy working, # not a bug. An ephemeral daemon holding no real credential is the # one case where plaintext is the honest answer, so say so explicitly # rather than weakening the default for everyone. OPENAI4S_SECRET_STORE: plaintext OPENAI4S_NOTEBOOK_REPL: "1" OPENAI4S_HOST: 127.0.0.1 OPENAI4S_PORT: "8760" run: | uv run openai4s serve >"${RUNNER_TEMP}/openai4s-browser.log" 2>&1 & daemon_pid=$! trap 'kill "$daemon_pid" 2>/dev/null || true' EXIT for attempt in $(seq 1 40); do # `/health`, not `/`. The token gate is on by default here too, so # `--fail` read the 401 on `/` as "not ready" and this loop never # broke -- forty quarter-second sleeps, then the harness ran # anyway. It looked like a readiness check and was a fixed ten # seconds, which is the failure `browser-team-mode` had already # diagnosed and fixed one job below without the fix reaching here. if curl --fail --silent http://127.0.0.1:8760/health >/dev/null; then break fi if ! kill -0 "$daemon_pid" 2>/dev/null; then cat "${RUNNER_TEMP}/openai4s-browser.log" exit 1 fi sleep 0.25 done # The full workbench walk stays Chromium-only: it is long, and its # value is depth rather than engine coverage. The matrix file is the # breadth pass — session, artifact, consent, cancel, recovery — and # it runs in every engine. if [ "${{ matrix.engine }}" = "chromium" ]; then node tests/browser_smoke.mjs # The admission fault case: the POST reaches the daemon and # succeeds, the response is destroyed on the way back, and the page # has to reconcile from an id it stored before dispatch. Chromium # only, for the same reason the walk is -- it is depth, not breadth # -- but it has to run somewhere, because a browser file no gate # invokes is not evidence of anything. node tests/browser_admission_fault.mjs # The P1-A/P1-B controls. Chromium-only for the same reason as the # two above, and listed here because the audit's finding was not # "these controls are broken" but "no browser file mentions them" -- # a file this job does not invoke would reproduce that finding # exactly, with a green tick on top. node tests/browser_p1_controls.mjs # Stage 1 trusted-delivery acceptance owns a second disposable # daemon on a random loopback port. It drives the production # completion and stored-message renderers through 100 immutable # version links, verifies every downloaded checksum across reload # and reopen, and proves same-head capture observations survive. node tests/browser_stage1_trusted_delivery.mjs # The committed Preact Artifact Viewer, on its own disposable # daemon. Both localhost/127.0.0.1 directions must run report # scripts and sibling images while refusing cross-session reads # and self-navigation to the app origin with the same grant. # Also checks exact versions, inert fallback and real Ketcher # edit/save. Header checks cannot prove the UI uses these paths. node tests/browser_sandbox_preview.mjs fi node tests/browser_matrix.mjs --browser=${{ matrix.engine }} - name: Show daemon log after a failure if: failure() run: cat "${RUNNER_TEMP}/openai4s-browser.log" || true browser-stage0: name: Stage 0 acceptance in a real browser timeout-minutes: 25 runs-on: ubuntu-latest # Its own job for the same reason `browser-team-mode` is: it needs a daemon # `browser-smoke` cannot provide. Stage 0's whole subject is a DISPOSABLE # daemon -- it refuses port 8760, the default data directory, a non-loopback # or credential-bearing URL, and an `OPENAI4S_TOKEN` override before it # reads a token at all -- while the smoke daemon is on 8760 with # `OPENAI4S_NOTEBOOK_REPL=1`, which Stage 0 exists to assert is off. # # It exists because `tests/browser_stage0_acceptance.mjs` was invoked by no # job at all -- the finding the two comments in `browser-smoke` above # already name twice, sitting unfixed one file away from where they name it. steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" - name: Rebuild the committed workbench and fail on drift run: | npm ci --prefix frontend npm run build --prefix frontend git diff --exit-code -- openai4s/server/webui/dist - name: Install the zero-dependency server run: uv sync --locked # Before the browser: the harness's own redaction, schema, binding and # production-projection checks. No Playwright and no daemon -- but it is # not free of Python: the production-projection check spawns # `.venv/bin/python` to drive the real completion projector, which is the # point of calling it a *production* check. Ordered after `uv sync` for # that reason and before the browser download for the other one: it fails # in seconds when the summary contract has drifted, instead of after a # Chromium install and a daemon boot. (Run ahead of `uv sync` on the # first attempt, where it reported `spawnSync ENOENT` -- invisible # locally, where a worktree already has its .venv.) - name: Stage 0 harness self-check (no browser, no daemon) run: OPENAI4S_STAGE0_SELF_TEST=1 node tests/browser_stage0_acceptance.mjs - name: Install the browser driver run: | npm ci --ignore-scripts ./node_modules/.bin/playwright install --with-deps chromium - name: Accept Stage 0 against a disposable daemon env: OPENAI4S_DATA_DIR: ${{ runner.temp }}/openai4s-stage0 OPENAI4S_KERNEL_SANDBOX: "off" # Same reason as `browser-smoke`: a runner has no keychain, so `auto` # correctly fails closed, and an ephemeral daemon holding no real # credential is the one case where plaintext is the honest answer. OPENAI4S_SECRET_STORE: plaintext # The property under test. Stage 0's baseline is a read-only # Notebook, so this must be off and the harness asserts it is. OPENAI4S_NOTEBOOK_REPL: "0" OPENAI4S_HOST: 127.0.0.1 # Deliberately not 8760: the harness refuses the default port before # it will read a token, because a daemon on it is not disposable. OPENAI4S_PORT: "8790" run: | mkdir -p "${OPENAI4S_DATA_DIR}" uv run openai4s serve >"${RUNNER_TEMP}/openai4s-stage0.log" 2>&1 & daemon_pid=$! trap 'kill "$daemon_pid" 2>/dev/null || true' EXIT for attempt in $(seq 1 80); do # `/health` is the one route reachable without a credential # (gateway.py `_UNAUTHENTICATED_PATHS`). Probing `/` with `--fail` # would read the token gate's 401 as "not ready" and spin out the # whole loop against a daemon that had been serving all along. if curl --fail --silent \ "http://127.0.0.1:${OPENAI4S_PORT}/health" >/dev/null; then break fi if ! kill -0 "$daemon_pid" 2>/dev/null; then cat "${RUNNER_TEMP}/openai4s-stage0.log" exit 1 fi sleep 0.25 done # `env -u OPENAI4S_TOKEN`: the harness refuses to run when a token is # supplied out of band, because then it would not be exercising the # owner-only file hand-off it is there to prove. env -u OPENAI4S_TOKEN \ OPENAI4S_BROWSER_DISPOSABLE=1 \ OPENAI4S_BROWSER_URL="http://127.0.0.1:${OPENAI4S_PORT}/" \ node tests/browser_stage0_acceptance.mjs - name: Show daemon log after a failure if: failure() run: cat "${RUNNER_TEMP}/openai4s-stage0.log" || true browser-team-mode: name: Team mode in a real browser timeout-minutes: 20 runs-on: ubuntu-latest # Its own job, not a step in `browser-smoke`, because it needs a daemon # that job cannot provide: OPENAI4S_TEAM_MODE=1 plus two seeded accounts, # where the smoke daemon is deliberately single-user and credential-less. # # It exists because `tests/browser_team_mode.mjs` was added, described in # tests/README.md as "the only gate that needs its own daemon", and # invoked by nothing -- which is the finding the comment in `browser-smoke` # above already names: a browser file no gate invokes is not evidence of # anything. This is the only end-to-end coverage of team mode as a person # meets it: the login redirect, the identity chip, the admin governance # panel, the guest's replay-only world, and the cross-user 404s. # # Chromium only: the value here is depth on one engine, not breadth. The # engine matrix belongs to `browser_matrix.mjs`. steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: "22" - name: Rebuild the committed workbench and fail on drift run: | npm ci --prefix frontend npm run build --prefix frontend git diff --exit-code -- openai4s/server/webui/dist - name: Install the zero-dependency server and browser driver run: | uv sync --locked npm ci --ignore-scripts ./node_modules/.bin/playwright install --with-deps chromium - name: Seed two accounts and run the team-mode walk env: OPENAI4S_DATA_DIR: ${{ runner.temp }}/openai4s-team-mode OPENAI4S_TEAM_MODE: "1" OPENAI4S_KERNEL_SANDBOX: "off" OPENAI4S_SECRET_STORE: plaintext OPENAI4S_HOST: 127.0.0.1 OPENAI4S_PORT: "8794" run: | # Generated here and passed by environment, never on argv: a # password in a command line is readable from the process table, # which is the same reason `user add` only takes --password-stdin. admin_pw="$(python3 -c 'import secrets;print(secrets.token_urlsafe(18))')" member_pw="$(python3 -c 'import secrets;print(secrets.token_urlsafe(18))')" # Seed before the daemon starts: `user add` is direct database # access and wants no daemon, and seeding first means the walk # never races account creation. printf '%s\n' "$admin_pw" \ | uv run openai4s user add erika --role admin --password-stdin printf '%s\n' "$member_pw" \ | uv run openai4s user add mallory --password-stdin uv run openai4s serve >"${RUNNER_TEMP}/openai4s-team-mode.log" 2>&1 & daemon_pid=$! trap 'kill "$daemon_pid" 2>/dev/null || true' EXIT for attempt in $(seq 1 40); do # --fail treats the 401/303 of a team daemon as "not ready", so # probe a path that answers before login instead. if curl --fail --silent http://127.0.0.1:8794/health >/dev/null; then break fi if ! kill -0 "$daemon_pid" 2>/dev/null; then cat "${RUNNER_TEMP}/openai4s-team-mode.log" exit 1 fi sleep 0.25 done OPENAI4S_TEAM_ADMIN_PW="$admin_pw" \ OPENAI4S_TEAM_MEMBER_PW="$member_pw" \ OPENAI4S_BROWSER_URL=http://127.0.0.1:8794 \ node tests/browser_team_mode.mjs - name: Show daemon log after a failure if: failure() run: cat "${RUNNER_TEMP}/openai4s-team-mode.log" || true macos-sandbox-smoke: name: macOS sandbox enforcement (nightly) if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' timeout-minutes: 25 runs-on: macos-14 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - name: Install the zero-dependency runtime run: uv sync --locked - name: Require real Seatbelt isolation and child-env scrubbing env: OPENAI4S_KERNEL_SANDBOX: enforce run: uv run python -m harness.smoke.macos_sandbox # This job proves the private-PID interrupt contract independently of the # broader Linux sandbox smoke. The job deliberately allows raw worker # networking so its process evidence is independent of network-namespace # setup, and asserts that exception in the smoke. It therefore proves no # egress claim. Team KernelReadIsolation, --unshare-pid, the real --info-fd + # procfs + pidfd adoption path, Python/R SIGINT delivery, and post-interrupt # persistence all remain mandatory and fail closed. Ubuntu 24.04 denies an # unprofiled bwrap's user namespace, so this ephemeral runner loads Ubuntu's # restricted bwrap profile; it does not disable the host-wide AppArmor # user-namespace policy. linux-bwrap-kernel-interrupt: name: Linux bubblewrap Python/R persistent interrupt timeout-minutes: 15 runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - name: Refresh Ubuntu package metadata run: sudo apt-get update - name: Install bubblewrap, its restricted AppArmor profile, and real R run: >- sudo apt-get install --yes --no-install-recommends apparmor apparmor-profiles bubblewrap r-base-core r-cran-jsonlite - name: Load Ubuntu's bwrap-only user-namespace policy run: >- sudo apparmor_parser --replace /usr/share/apparmor/extra-profiles/bwrap-userns-restrict - name: Prove bubblewrap can create the required private PID namespace run: >- /usr/bin/bwrap --die-with-parent --new-session --unshare-ipc --unshare-uts --unshare-pid --ro-bind / / --dev /dev --proc /proc -- /usr/bin/python3 -c 'import os; raise SystemExit((os.getpid(), os.getppid()) != (2, 1))' - name: Install the zero-dependency runtime run: uv sync --locked - name: Interrupt real private-PID Python and R kernels, then reuse them env: OPENAI4S_KERNEL_SANDBOX: enforce # Deliberate scope boundary. The smoke asserts raw_allowed and does # not claim to prove Linux network isolation. OPENAI4S_KERNEL_ALLOW_RAW_NETWORK: "1" run: uv run python -m harness.smoke.linux_bwrap_interrupt # Independent of the interrupt job above. That job deliberately sets # OPENAI4S_KERNEL_ALLOW_RAW_NETWORK=1, so a green interrupt check is not # evidence of filesystem or egress confinement. This job is the four-check # full boundary: outside write denied, raw network denied, workspace write # allowed, child secret absent, under enforce, with the same restricted # bwrap profile. A raw-network override is a hard failure inside the smoke. # No continue-on-error. The check run is attested at the frozen SHA as # `ci-linux-sandbox-full`. The release workflow's platform-checks matrix # does not re-execute this smoke until multiple scheduled greens plus a # candidate SHA have passed (see release_gates.PLATFORM_CHECKS_UNAVAILABLE). linux-sandbox-full: name: Linux bubblewrap full filesystem/egress boundary timeout-minutes: 15 runs-on: ubuntu-24.04 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - name: Refresh Ubuntu package metadata run: sudo apt-get update - name: Install bubblewrap and its restricted AppArmor profile run: >- sudo apt-get install --yes --no-install-recommends apparmor apparmor-profiles bubblewrap - name: Load Ubuntu's bwrap-only user-namespace policy run: >- sudo apparmor_parser --replace /usr/share/apparmor/extra-profiles/bwrap-userns-restrict - name: Prove bubblewrap can create PID and network namespaces run: >- /usr/bin/bwrap --die-with-parent --new-session --unshare-ipc --unshare-uts --unshare-pid --unshare-net --ro-bind / / --dev /dev --proc /proc -- /usr/bin/python3 -c 'import os; raise SystemExit((os.getpid(), os.getppid()) != (2, 1))' - name: Install the zero-dependency runtime run: uv sync --locked - name: Require the four Linux filesystem and egress boundaries env: OPENAI4S_KERNEL_SANDBOX: enforce run: uv run python -m harness.smoke.linux_sandbox linux-bundle-smoke: name: Linux app bundle builds and imports (nightly) # The desktop bundle is only built at release time, so the first sign that # a manifest package lost its manylinux wheel — or that the embedded # interpreter no longer imports the stack — would otherwise be a release # day. Nightly and manual only: it downloads a runtime plus ~1 GB of # wheels, which is not a cost to put on every contributor's PR. if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' timeout-minutes: 25 runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - name: Build the bundle run: bash scripts/build_linux_bundle.sh - name: Run the embedded interpreter and import the whole science stack # This runner is the only place that check can happen: everything else # in this repository's CI either cannot execute a linux-x86_64 binary # or never builds the bundle at all. run: python3 scripts/verify_linux_bundle.py dist/OpenAI4S-*-linux-x86_64.tar.gz - name: Wrap it as the Windows package and verify that too run: | bash scripts/build_windows_zip.sh python3 scripts/verify_windows_zip.py dist/OpenAI4S-*-windows-x86_64.zip connector-canary: name: Science connector canary (nightly) # Schedule and manual only: a public API's outage is not a reason to fail a # contributor's PR, and the script exits non-zero only on real schema drift, # never on an upstream being unreachable. See scripts/connector_canary.py. if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' timeout-minutes: 20 runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 with: python-version: "3.12" - name: Install the zero-dependency runtime run: uv sync --locked - name: Ask UniProt, RCSB PDB and OpenAlex whether they still parse env: OPENAI4S_ALLOW_NETWORK: "1" run: uv run python scripts/connector_canary.py