Skip to content

Merge remote-tracking branch 'upstream/main' #17

Merge remote-tracking branch 'upstream/main'

Merge remote-tracking branch 'upstream/main' #17

Workflow file for this run

name: CI
on:
push:
branches:
- main
- 'release/**'
pull_request:
types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled]
merge_group:
types: [checks_requested]
permissions:
contents: read
# PR runs coalesce by PR number. Merge-group candidates plus main/release pushes use
# the exact candidate SHA as the group key so each integration candidate keeps its evidence.
concurrency:
group: ci-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
jobs:
changes:
# Classify only trusted event metadata plus base...head Git data. For PRs and
# merge-group candidates the classifier is loaded from the trusted base commit,
# so candidate changes cannot weaken their own native gates. If the trusted base
# predates the classifier, fail safe to
# the full daily native matrix instead of executing classifier code from the PR head.
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
outputs:
needs_windows: ${{ steps.classify.outputs.needs_windows }}
needs_windows_core: ${{ steps.classify.outputs.needs_windows_core }}
needs_windows_runner: ${{ steps.classify.outputs.needs_windows_runner }}
needs_windows_package: ${{ steps.classify.outputs.needs_windows_package }}
needs_windows_desktop: ${{ steps.classify.outputs.needs_windows_desktop }}
needs_macos: ${{ steps.classify.outputs.needs_macos }}
needs_macos_desktop: ${{ steps.classify.outputs.needs_macos_desktop }}
# Older trusted PR bases do not expose the newer frontend/container/Plugin SDK outputs.
# A historical full-native result is the safe rollout fallback for those bases.
needs_frontend: ${{ steps.classify.outputs.needs_frontend == 'true' || (steps.classify.outputs.needs_frontend == '' && steps.classify.outputs.needs_full_native == 'true') }}
needs_desktop_frontend: ${{ steps.classify.outputs.needs_desktop_frontend == 'true' || (steps.classify.outputs.needs_desktop_frontend == '' && steps.classify.outputs.needs_full_native == 'true') }}
needs_plugin_sdk: ${{ steps.classify.outputs.needs_plugin_sdk == 'true' || (steps.classify.outputs.needs_plugin_sdk == '' && steps.classify.outputs.needs_full_native == 'true') }}
needs_docker: ${{ steps.classify.outputs.needs_docker == 'true' || (steps.classify.outputs.needs_docker == '' && steps.classify.outputs.needs_full_native == 'true') }}
needs_desktop_package: ${{ steps.classify.outputs.needs_desktop_package }}
needs_full_native: ${{ steps.classify.outputs.needs_full_native }}
categories: ${{ steps.classify.outputs.categories }}
reason: ${{ steps.classify.outputs.reason }}
steps:
- name: Checkout trusted classifier source
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event_name == 'merge_group' && github.event.merge_group.base_sha || github.sha }}
fetch-depth: 0
persist-credentials: false
- name: Classify native CI risk
id: classify
env:
EVENT_NAME: ${{ github.event_name }}
EXTERNAL_CONTRIBUTOR: ${{ github.event_name == 'pull_request' && github.event.pull_request.user.login != github.repository_owner }}
RUN_CI: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-ci') }}
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.merge_group.base_sha || github.event.before || '' }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.sha }}
PR_NUMBER: ${{ github.event.pull_request.number || '' }}
MERGE_GROUP_HEAD_REF: ${{ github.event.merge_group.head_ref || '' }}
run: |
set -euo pipefail
classifier="scripts/ci_path_risk.py"
if [ ! -f "$classifier" ]; then
{
for key in \
needs_windows \
needs_windows_core \
needs_windows_runner \
needs_windows_package \
needs_windows_desktop \
needs_macos \
needs_macos_desktop \
needs_frontend \
needs_desktop_frontend \
needs_plugin_sdk \
needs_docker \
needs_desktop_package \
needs_full_native; do
echo "$key=true"
done
echo "categories=bootstrap-full-native"
echo "reason=full-native:bootstrap-base-missing"
} >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$EVENT_NAME" = pull_request ] && \
[ "$EXTERNAL_CONTRIBUTOR" != true ] && \
[ "$RUN_CI" != true ] && \
! git cat-file -e "$HEAD_SHA^{commit}" 2>/dev/null; then
git fetch --no-tags origin "refs/pull/$PR_NUMBER/head"
fetched_head="$(git rev-parse FETCH_HEAD)"
if [ "$fetched_head" != "$HEAD_SHA" ]; then
echo "fetched PR head does not match event head SHA" >&2
exit 1
fi
fi
if [ "$EVENT_NAME" = merge_group ] && \
! git cat-file -e "$HEAD_SHA^{commit}" 2>/dev/null; then
if [ -z "$MERGE_GROUP_HEAD_REF" ]; then
echo "merge-group head ref is missing" >&2
exit 1
fi
git fetch --no-tags origin "$MERGE_GROUP_HEAD_REF"
fetched_head="$(git rev-parse FETCH_HEAD)"
if [ "$fetched_head" != "$HEAD_SHA" ]; then
echo "fetched merge-group head does not match event head SHA" >&2
exit 1
fi
fi
python3 "$classifier" \
--event-name "$EVENT_NAME" \
--external-contributor "$EXTERNAL_CONTRIBUTOR" \
--run-ci "$RUN_CI" \
--base "$BASE_SHA" \
--head "$HEAD_SHA" \
--github-output "$GITHUB_OUTPUT"
contract:
# Mandatory contract lane for every workflow invocation. It waits only for the
# cheap path classifier so unrelated frontend dependency/install work can be
# skipped; Rust/schema/workspace contracts remain unconditional.
needs: changes
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
with:
components: rustfmt
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
shared-key: linux-ci
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
if: needs.changes.outputs.needs_frontend == 'true' || needs.changes.outputs.needs_desktop_frontend == 'true' || needs.changes.outputs.needs_plugin_sdk == 'true'
with:
node-version: 22
cache: npm
cache-dependency-path: |
frontend/package-lock.json
apps/desktop/package-lock.json
npm/plugin-sdk/package-lock.json
plugins/safe-delete/package-lock.json
plugins/repo-info/package-lock.json
plugins/repo-context/package-lock.json
plugins/campus-application/package-lock.json
plugins/agent-browser/package-lock.json
- name: Install frontend dependencies
if: needs.changes.outputs.needs_frontend == 'true' || needs.changes.outputs.needs_desktop_frontend == 'true'
run: npm ci --prefix frontend
- name: Check frontend contracts
if: needs.changes.outputs.needs_frontend == 'true'
run: |
npm --prefix frontend run typecheck
npm --prefix frontend test
npm --prefix frontend run check:dist
- name: Install Desktop frontend dependencies
if: needs.changes.outputs.needs_desktop_frontend == 'true'
run: npm ci --prefix apps/desktop
- name: Check Desktop frontend contracts
if: needs.changes.outputs.needs_desktop_frontend == 'true'
run: |
npm --prefix apps/desktop run typecheck
npm --prefix apps/desktop test
npm --prefix apps/desktop run build
- name: Install Plugin SDK dependencies
if: needs.changes.outputs.needs_plugin_sdk == 'true'
run: |
npm ci --prefix npm/plugin-sdk
npm ci --prefix plugins/safe-delete
npm ci --prefix plugins/repo-info
npm ci --prefix plugins/repo-context
npm ci --prefix plugins/campus-application
npm ci --prefix plugins/agent-browser
- name: Check Plugin SDK contracts and first-party dogfood
if: needs.changes.outputs.needs_plugin_sdk == 'true'
run: |
npm --prefix npm/plugin-sdk run typecheck
npm --prefix npm/plugin-sdk run build
npm --prefix npm/plugin-sdk test
npm --prefix npm/plugin-sdk run pack:dry-run
npm --prefix plugins/safe-delete run typecheck
npm --prefix plugins/safe-delete test
npm --prefix plugins/repo-info run typecheck
npm --prefix plugins/repo-info test
npm --prefix plugins/repo-context run typecheck
npm --prefix plugins/repo-context test
npm --prefix plugins/campus-application run typecheck
npm --prefix plugins/campus-application test
npm --prefix plugins/agent-browser run typecheck
npm --prefix plugins/agent-browser test
- name: Verify workspace boundaries
run: |
bash scripts/workspace_boundary_check.sh --self-test
bash scripts/workspace_boundary_check.sh
- name: Check build identity and incremental boundary
run: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts.tests.test_build_identity
- name: Check formatting
run: cargo fmt --all -- --check
- name: Ensure test inventory dependency
run: |
if ! command -v rg >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install --no-install-recommends -y ripgrep
fi
- name: Check test inventory contract
run: |
bash scripts/test_inventory.sh --self-test
PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts.tests.test_ci_rust_test
bash scripts/test_inventory.sh
- name: Check macOS signing setup and release evidence contracts
# Fake signing commands and synthetic artifacts only; no Apple credentials.
run: PYTHONDONTWRITEBYTECODE=1 python3 -m unittest scripts.tests.test_macos_signing_setup scripts.tests.test_macos_release_evidence
- name: Check schema and metadata contracts
run: |
python3 scripts/tests/test_runner_observability_contract.py
cargo test --locked -p webcodex --lib mcp_tools_list
cargo test --locked -p webcodex --lib tool_definition_surface_counts_stay_canonical
cargo test --locked -p webcodex --lib external_provider_discovery_cannot_change_public_tool_surface
mcp-conformance:
# Dated MCP wire evidence is independent from ordinary unit/integration tests.
# The script pins the external referee and rejects missing or silently skipped
# coverage; known failures require exact check-level classification.
needs: contract
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
shared-key: linux-ci
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: Check conformance report gate
run: python3 scripts/tests/test_mcp_conformance_report.py
- name: Run pinned MCP dated-revision baseline
run: bash scripts/mcp_conformance.sh
- name: Upload MCP conformance evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: mcp-conformance-${{ github.sha }}
path: target/mcp-conformance/reports
if-no-files-found: ignore
retention-days: 7
test-linux-rust:
# Complete Linux Rust coverage is mandatory before merge, including owner PRs.
# Start it immediately in parallel with contract/frontend work; the stable
# `test` aggregate still requires both surfaces to succeed.
runs-on: ubuntu-latest
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- shard: server
packages: "-p webcodex"
- shard: runner
packages: "-p webcodex-runner -p webcodex-lsp --features runner-real-process-tests"
- shard: workspace-crates
packages: >-
--workspace
--exclude webcodex
--exclude webcodex-runner
--exclude webcodex-lsp
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
shared-key: linux-ci
- name: Run ${{ matrix.shard }} Rust tests
env:
CI_RUST_SHARD: ${{ matrix.shard }}
run: python3 scripts/ci_rust_test.py --report target/ci-test-reports/${{ matrix.shard }}.json -- cargo test --locked ${{ matrix.packages }}
- name: Upload bounded Rust test evidence
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: rust-tests-${{ matrix.shard }}-${{ github.sha }}-${{ github.run_attempt }}
path: target/ci-test-reports/*.json
if-no-files-found: warn
retention-days: 7
test-linux-tooling:
# Release/tooling checks are mandatory on every PR, but do not need contract
# output. Start them in parallel; the stable `test` aggregate joins the results.
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
shared-key: linux-ci
# The retained npm release-smoke tooling below invokes node/npm directly.
# Frontend dependency installation and frontend-specific npm caching remain
# contract-lane responsibilities and are intentionally not repeated here.
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: Check MCP App DOM and message-order contracts
run: node --test src/mcp_tests/*.test.mjs
- name: Ensure Python tooling test dependencies
run: |
if ! command -v rg >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install --no-install-recommends -y ripgrep
fi
- name: Test optional Codex observation adapter
run: python3 -m unittest discover -s integrations/codex -p 'test_*.py' -v
- name: Test configured model API ACP adapter
run: python3 -m unittest discover -s integrations/model_gateway/tests -p 'test_*.py' -v
- name: Check complete workspace production/test targets
if: github.event_name == 'pull_request' && (github.event.pull_request.user.login != github.repository_owner || contains(github.event.pull_request.labels.*.name, 'run-ci'))
run: cargo check --locked --workspace --all-targets
- name: Test deterministic release contract and tooling
run: bash scripts/release_check.sh --static-only
test:
needs: [contract, changes, test-linux-rust, test-linux-tooling]
# Preserve the historical aggregate status check and always evaluate upstream
# results so a failed or unexpectedly skipped Linux lane cannot disappear.
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Require successful Linux lanes
env:
CONTRACT_RESULT: ${{ needs.contract.result }}
CHANGES_RESULT: ${{ needs.changes.result }}
RUST_RESULT: ${{ needs.test-linux-rust.result }}
TOOLING_RESULT: ${{ needs.test-linux-tooling.result }}
run: |
set -euo pipefail
echo "contract=$CONTRACT_RESULT changes=$CHANGES_RESULT rust=$RUST_RESULT tooling=$TOOLING_RESULT"
if [ "$CONTRACT_RESULT" != success ] || \
[ "$CHANGES_RESULT" != success ] || \
[ "$RUST_RESULT" != success ] || \
[ "$TOOLING_RESULT" != success ]; then
echo "required Linux CI lane did not succeed" >&2
exit 1
fi
test-docker-server:
needs: changes
if: needs.changes.outputs.needs_docker == 'true'
runs-on: ubuntu-latest
timeout-minutes: 35
permissions:
contents: read
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Resolve exact Server image build identity
shell: bash
run: |
set -euo pipefail
version="$(node -p "require('./npm/webcodex/package.json').version")"
{
echo "WEBCODEX_CI_VERSION=$version"
echo "WEBCODEX_CI_SOURCE_SHORT=$(git rev-parse --short=12 HEAD)"
echo "WEBCODEX_CI_BUILT_AT=$(git show -s --format=%ct HEAD)"
echo "WEBCODEX_CI_IMAGE=webcodex-server-ci:${GITHUB_SHA}"
} >> "$GITHUB_ENV"
git diff --check
test -z "$(git status --porcelain --untracked-files=all)"
- name: Build amd64 Server image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
load: true
push: false
tags: ${{ env.WEBCODEX_CI_IMAGE }}
build-args: |
WEBCODEX_GIT_COMMIT=${{ env.WEBCODEX_CI_SOURCE_SHORT }}
WEBCODEX_GIT_DIRTY=false
WEBCODEX_BUILT_AT=${{ env.WEBCODEX_CI_BUILT_AT }}
cache-from: type=gha,scope=server-image-ci-amd64
# GHA cache export is a performance optimization, not image evidence.
# BuildKit can transiently return layer-blob not_found after the image
# itself has built; keep build/load/smoke authoritative in that case.
cache-to: type=gha,mode=max,scope=server-image-ci-amd64,ignore-error=true
provenance: false
sbom: false
- name: Smoke amd64 Server image
shell: bash
run: |
set -euo pipefail
image="$WEBCODEX_CI_IMAGE"
test "$(docker image inspect --format '{{.Architecture}}' "$image")" = amd64
test "$(docker image inspect --format '{{.Config.User}}' "$image")" = "webcodex:webcodex"
docker run --rm --entrypoint sh "$image" -ec '
test "$(id -u)" = 10001
test "$(id -g)" = 10001
test ! -e /usr/local/bin/webcodex-runner
'
expected_server="webcodex-server $WEBCODEX_CI_VERSION (commit $WEBCODEX_CI_SOURCE_SHORT, dirty=false, built_at=$WEBCODEX_CI_BUILT_AT)"
expected_cli="webcodex $WEBCODEX_CI_VERSION (commit $WEBCODEX_CI_SOURCE_SHORT, dirty=false, built_at=$WEBCODEX_CI_BUILT_AT)"
test "$(docker run --rm "$image" --version)" = "$expected_server"
test "$(docker run --rm --entrypoint /usr/local/bin/webcodex "$image" --version)" = "$expected_cli"
name="webcodex-server-ci-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
cleanup() { docker rm -f "$name" >/dev/null 2>&1 || true; }
trap cleanup EXIT
docker run -d --name "$name" \
-e WEBCODEX_TOKEN=0000000000000000000000000000000000000000000000000000000000000000 \
-e WEBCODEX_PUBLIC_URL=https://webcodex.invalid \
"$image" >/dev/null
for _ in $(seq 1 40); do
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}missing{{end}}' "$name")"
if [ "$health" = healthy ]; then break; fi
if [ "$health" = unhealthy ] || [ "$health" = missing ]; then
docker logs --tail 80 "$name" >&2 || true
exit 1
fi
sleep 2
done
test "$(docker inspect --format '{{.State.Health.Status}}' "$name")" = healthy
test-macos-core:
needs: changes
if: needs.changes.outputs.needs_macos == 'true'
# Daily macOS coverage uses Apple Silicon. Intel is intentionally reserved for
# explicit/release-time extended-native validation.
strategy:
fail-fast: false
matrix:
include:
- platform: darwin-arm64
runner: macos-15
host_arch: arm64
rust_host: aarch64-apple-darwin
runs-on: ${{ matrix.runner }}
timeout-minutes: 75
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
shared-key: macos-core-ci-${{ matrix.platform }}
- name: Verify native macOS host
env:
EXPECTED_HOST_ARCH: ${{ matrix.host_arch }}
EXPECTED_RUST_HOST: ${{ matrix.rust_host }}
run: |
test "$(uname -m)" = "$EXPECTED_HOST_ARCH"
rust_host="$(rustc -vV | sed -n 's/^host: //p')"
test "$rust_host" = "$EXPECTED_RUST_HOST"
- name: Check macOS workspace production surfaces
run: cargo check --locked --workspace
- name: Run native macOS Runner and Computer tests
run: cargo test --locked -p webcodex-runner -p webcodex-lsp -p webcodex-computer --features runner-real-process-tests
test-macos-desktop:
needs: changes
if: needs.changes.outputs.needs_macos_desktop == 'true'
# Keep the common Apple-Silicon Desktop package validation path-aware. The
# corresponding Intel DMG build/smoke lives in extended-native CI.
strategy:
fail-fast: false
matrix:
include:
- platform: darwin-arm64
runner: macos-15
host_arch: arm64
rust_host: aarch64-apple-darwin
runs-on: ${{ matrix.runner }}
timeout-minutes: 75
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
shared-key: macos-desktop-ci-${{ matrix.platform }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
cache-dependency-path: |
frontend/package-lock.json
apps/desktop/package-lock.json
- name: Verify native macOS host
env:
EXPECTED_HOST_ARCH: ${{ matrix.host_arch }}
EXPECTED_RUST_HOST: ${{ matrix.rust_host }}
run: |
test "$(uname -m)" = "$EXPECTED_HOST_ARCH"
rust_host="$(rustc -vV | sed -n 's/^host: //p')"
test "$rust_host" = "$EXPECTED_RUST_HOST"
- name: Install shared frontend dependencies
run: npm ci --prefix frontend
- name: Install Desktop dependencies
run: npm ci --prefix apps/desktop
- name: Check Desktop frontend
run: |
npm --prefix apps/desktop run typecheck
npm --prefix apps/desktop test
npm --prefix apps/desktop run build
- name: Check Desktop Rust
run: |
cargo check --locked --manifest-path apps/desktop/src-tauri/Cargo.toml --all-targets
cargo test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml
- name: Build exact native WebCodex runtime for Desktop
id: desktop_runtime
env:
EXPECTED_FILE_ARCH: ${{ matrix.host_arch }}
run: |
set -euo pipefail
source_sha="$(git rev-parse HEAD)"
short_source="$(git rev-parse --short=12 HEAD)"
version="$(node -p "require('./npm/webcodex/package.json').version")"
built_at="$(git show -s --format=%ct HEAD)"
export WEBCODEX_BUILT_AT="$built_at"
cargo build --locked --profile dogfood -p webcodex -p webcodex-cli -p webcodex-runner
for name in webcodex webcodex-server webcodex-runner; do
binary="target/dogfood/$name"
actual="$($binary --version)"
expected="$name $version (commit $short_source, dirty=false, built_at=$built_at)"
test "$actual" = "$expected"
test "$(/usr/bin/lipo -archs "$binary")" = "$EXPECTED_FILE_ARCH"
done
bash scripts/macos_sign_runner.sh target/dogfood/webcodex-runner -
{
echo "source=$source_sha"
echo "short=$short_source"
echo "version=$version"
echo "built_at=$built_at"
} >> "$GITHUB_OUTPUT"
- name: Stage exact runtime for native macOS Desktop
id: desktop_bundle
run: |
set -euo pipefail
stage="$RUNNER_TEMP/webcodex-desktop-${{ matrix.platform }}-bundle"
python3 scripts/prepare_desktop_bundle_macos.py \
--bin-dir target/dogfood \
--version "${{ steps.desktop_runtime.outputs.version }}" \
--source-sha "${{ steps.desktop_runtime.outputs.source }}" \
--built-at "${{ steps.desktop_runtime.outputs.built_at }}" \
--platform "${{ matrix.platform }}" \
--signing-mode adhoc \
--output-dir "$stage"
{
echo "config=$stage/tauri.bundle.conf.json"
echo "metadata=$stage/desktop-bundle.json"
} >> "$GITHUB_OUTPUT"
- name: Build native ad-hoc macOS Desktop DMG
id: desktop_dmg
working-directory: apps/desktop
env:
CI: "true"
APPLE_SIGNING_IDENTITY: "-"
run: |
set -euo pipefail
export CARGO_TARGET_DIR="$RUNNER_TEMP/webcodex-desktop-${{ matrix.platform }}-tauri"
npm exec tauri -- build \
--bundles dmg \
--config "${{ steps.desktop_bundle.outputs.config }}" \
--ci -- --locked
bundle_dir="$CARGO_TARGET_DIR/release/bundle/dmg"
shopt -s nullglob
candidates=("$bundle_dir"/*.dmg)
test "${#candidates[@]}" -eq 1
dmg="$RUNNER_TEMP/webcodex-desktop-ci-${{ steps.desktop_runtime.outputs.short }}-v${{ steps.desktop_runtime.outputs.version }}-${{ matrix.platform }}.dmg"
cp "${candidates[0]}" "$dmg"
echo "path=$dmg" >> "$GITHUB_OUTPUT"
- name: Smoke native macOS Desktop DMG
run: |
set -euo pipefail
bash scripts/desktop_install_macos_smoke.sh \
--dmg "${{ steps.desktop_dmg.outputs.path }}" \
--version "${{ steps.desktop_runtime.outputs.version }}" \
--source-sha "${{ steps.desktop_runtime.outputs.source }}" \
--built-at "${{ steps.desktop_runtime.outputs.built_at }}" \
--platform "${{ matrix.platform }}" \
--stage-metadata "${{ steps.desktop_bundle.outputs.metadata }}" \
--signing-mode adhoc
test-macos:
needs: [contract, changes, test-macos-core, test-macos-desktop]
# Stable aggregate context: every configured macOS sublane must either succeed
# when required by policy or be intentionally skipped when not required.
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Validate macOS lane policy
env:
CONTRACT_RESULT: ${{ needs.contract.result }}
CHANGES_RESULT: ${{ needs.changes.result }}
NEEDS_CORE: ${{ needs.changes.outputs.needs_macos }}
NEEDS_DESKTOP: ${{ needs.changes.outputs.needs_macos_desktop }}
CORE_RESULT: ${{ needs.test-macos-core.result }}
DESKTOP_RESULT: ${{ needs.test-macos-desktop.result }}
run: |
set -euo pipefail
echo "changes=$CHANGES_RESULT contract=$CONTRACT_RESULT core=$CORE_RESULT desktop=$DESKTOP_RESULT needs_core=$NEEDS_CORE needs_desktop=$NEEDS_DESKTOP"
if [ "$CHANGES_RESULT" != success ] || [ "$CONTRACT_RESULT" != success ]; then
echo "CI policy or contract classification did not succeed" >&2
exit 1
fi
require_lane() {
label="$1"
required="$2"
actual="$3"
expected=skipped
if [ "$required" = true ]; then expected=success; fi
if [ "$actual" != "$expected" ]; then
echo "$label macOS lane expected $expected, got $actual" >&2
exit 1
fi
}
require_lane core "$NEEDS_CORE" "$CORE_RESULT"
require_lane desktop "$NEEDS_DESKTOP" "$DESKTOP_RESULT"
# Ordinary Runner/Computer fixtures must tolerate default libtest parallelism.
# Timing-sensitive lifecycle/process-tree fixtures remain ignored here and run only
# by explicit local commands. Core/library, Runner, and packaging coverage are independent.
test-windows-core:
needs: changes
if: needs.changes.outputs.needs_windows_core == 'true'
runs-on: windows-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
shared-key: windows-core-ci
- name: Configure PowerShell 7 persistent-shell tests
shell: pwsh
run: |
$pwsh = (Get-Command pwsh -ErrorAction Stop).Source
"WEBCODEX_TEST_PWSH=$pwsh" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Run Windows persistent-shell tests (PowerShell 5.1 + PowerShell 7)
run: cargo test --locked -p webcodex-persistent-shell
- name: Check Windows workspace production surfaces
run: cargo check --locked --workspace
- name: Run Windows library and CLI tests
run: cargo test --locked -p webcodex-runner-config -p webcodex-process -p webcodex-cli
- name: Run Windows unified upgrade transactions including native v0.4.3 fixture
run: |
cargo test --locked -p webcodex-environment --lib upgrade::
- name: Test Windows unified installer routing and Desktop build freshness
run: python -m unittest scripts.tests.test_prepare_windows_unified_installer scripts.tests.test_desktop_runtime_manifest scripts.tests.test_desktop_frontend_build
- name: Test optional Codex observation adapter on Windows
run: python -m unittest discover -s integrations/codex -p 'test_*.py' -v
- name: Run Windows search fallback tests
run: |
cargo test --locked -p webcodex --lib search_project_text_grep
if ($LASTEXITCODE -ne 0) { throw "Windows grep fallback tests failed" }
cargo test --locked -p webcodex --lib search_requires_ripgrep_based_on_effective_features_not_field_presence
if ($LASTEXITCODE -ne 0) { throw "Windows search capability test failed" }
test-windows-runner:
needs: changes
if: needs.changes.outputs.needs_windows_runner == 'true'
runs-on: windows-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
shared-key: windows-runner-ci
- name: Run Windows Runner and Computer tests
# This lane starts real PowerShell/MCP child processes. Keep the Rust
# harness bounded so hosted-runner scheduling does not consume their
# production startup/request deadlines.
env:
RUST_TEST_THREADS: "2"
run: cargo test --locked -p webcodex-runner -p webcodex-lsp -p webcodex-computer --features runner-real-process-tests
test-windows-package:
needs: changes
if: needs.changes.outputs.needs_windows_package == 'true'
runs-on: windows-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
shared-key: windows-package-ci
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- name: Test npm installer and release manifest natively
run: npm --prefix npm/webcodex test
- name: Windows artifact to npm install smoke
# Builds the three Windows binaries, packages the win32-x64 artifact
# with the native PowerShell script, installs it through the npm
# installer into a temporary prefix, and verifies the wrapper plus
# --version/--help and the failure-preserves-old-binaries path. Never
# touches the npm registry.
shell: pwsh
run: powershell -NoProfile -ExecutionPolicy Bypass -File scripts/npm_install_windows_smoke.ps1
test-windows-desktop:
needs: changes
if: needs.changes.outputs.needs_windows_desktop == 'true'
runs-on: windows-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2
with:
shared-key: windows-desktop-ci
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
cache-dependency-path: |
frontend/package-lock.json
apps/desktop/package-lock.json
- name: Install shared frontend dependencies
run: npm ci --prefix frontend
- name: Install Desktop dependencies
run: npm ci --prefix apps/desktop
- name: Check Desktop frontend
run: |
npm --prefix apps/desktop run typecheck
npm --prefix apps/desktop test
npm --prefix apps/desktop run build
- name: Check Desktop Rust
shell: pwsh
run: |
cargo check --locked --manifest-path apps/desktop/src-tauri/Cargo.toml --all-targets
if ($LASTEXITCODE -ne 0) { throw "Desktop cargo check failed" }
cargo test --locked --manifest-path apps/desktop/src-tauri/Cargo.toml
if ($LASTEXITCODE -ne 0) { throw "Desktop cargo test failed" }
- name: Build exact local WebCodex runtime
id: runtime
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$source = (git rev-parse HEAD).Trim()
$short = (git rev-parse --short=12 HEAD).Trim()
$version = (node -e "console.log(require('./npm/webcodex/package.json').version)").Trim()
$builtAt = [Int64]((git show -s --format=%ct HEAD).Trim())
$env:WEBCODEX_BUILT_AT = "$builtAt"
cargo build --locked --profile dogfood -p webcodex -p webcodex-cli -p webcodex-runner
if ($LASTEXITCODE -ne 0) { throw "WebCodex runtime build failed" }
foreach ($name in @("webcodex", "webcodex-server", "webcodex-runner")) {
$binary = Join-Path "target\dogfood" "$name.exe"
$output = @(& $binary --version)
$exitCode = $LASTEXITCODE
if ($exitCode -ne 0 -or $output.Count -eq 0 -or -not $output[0]) {
throw "$name --version failed with exit code $exitCode"
}
$actual = $output[0].TrimEnd()
$expected = "$name $version (commit $short, dirty=false, built_at=$builtAt)"
if ($actual -ne $expected) { throw "Unexpected $name identity: $actual" }
}
"source=$source" >> $env:GITHUB_OUTPUT
"short=$short" >> $env:GITHUB_OUTPUT
"version=$version" >> $env:GITHUB_OUTPUT
"built_at=$builtAt" >> $env:GITHUB_OUTPUT
- name: Stage exact runtime for Desktop bundle
id: desktop_bundle
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$stage = Join-Path $env:RUNNER_TEMP "webcodex-desktop-ci-bundle"
& .\scripts\prepare_desktop_bundle.ps1 `
-BinDir "target\dogfood" `
-Version "${{ steps.runtime.outputs.version }}" `
-SourceSha "${{ steps.runtime.outputs.source }}" `
-BuiltAt ([Int64]"${{ steps.runtime.outputs.built_at }}") `
-OutputDir $stage
"config=$(Join-Path $stage 'tauri.bundle.conf.json')" >> $env:GITHUB_OUTPUT
- name: Build unsigned NSIS installer
working-directory: apps/desktop
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
$env:CARGO_TARGET_DIR = Join-Path $env:GITHUB_WORKSPACE "target\desktop-ci-tauri"
npm exec tauri -- build --bundles nsis --config "${{ steps.desktop_bundle.outputs.config }}" --ci --no-sign
if ($LASTEXITCODE -ne 0) { throw "Tauri NSIS build failed" }
$bundleDir = Join-Path $env:CARGO_TARGET_DIR "release\bundle\nsis"
$built = @(Get-ChildItem -LiteralPath $bundleDir -Filter "*-setup.exe" -File)
if ($built.Count -ne 1) { throw "Expected exactly one NSIS installer, found $($built.Count)" }
New-Item -ItemType Directory -Force -Path "target\desktop-ci-dist" | Out-Null
$installer = Join-Path "target\desktop-ci-dist" "webcodex-desktop-ci-${{ steps.runtime.outputs.short }}-v${{ steps.runtime.outputs.version }}-win32-x64-setup.exe"
Copy-Item -LiteralPath $built[0].FullName -Destination $installer
"DESKTOP_CI_INSTALLER=$([System.IO.Path]::GetFullPath($installer))" >> $env:GITHUB_ENV
- name: Smoke newly-built Desktop installer
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
& .\scripts\desktop_install_windows_smoke.ps1 `
-Installer $env:DESKTOP_CI_INSTALLER `
-Version "${{ steps.runtime.outputs.version }}" `
-SourceSha "${{ steps.runtime.outputs.source }}" `
-BuiltAt ([Int64]"${{ steps.runtime.outputs.built_at }}") `
-Platform win32-x64
$customInstallDir = Join-Path $env:RUNNER_TEMP "WebCodex Custom Install"
& .\scripts\desktop_install_windows_smoke.ps1 `
-Installer $env:DESKTOP_CI_INSTALLER `
-Version "${{ steps.runtime.outputs.version }}" `
-SourceSha "${{ steps.runtime.outputs.source }}" `
-BuiltAt ([Int64]"${{ steps.runtime.outputs.built_at }}") `
-Platform win32-x64 `
-InstallDir $customInstallDir
test-windows:
needs: [contract, changes, test-windows-core, test-windows-runner, test-windows-package, test-windows-desktop]
# Preserve the stable Windows aggregate while allowing each heavy lane to be
# selected independently by the deterministic path-risk classifier.
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Validate Windows lane policy
env:
CONTRACT_RESULT: ${{ needs.contract.result }}
CHANGES_RESULT: ${{ needs.changes.result }}
NEEDS_CORE: ${{ needs.changes.outputs.needs_windows_core }}
NEEDS_RUNNER: ${{ needs.changes.outputs.needs_windows_runner }}
NEEDS_PACKAGE: ${{ needs.changes.outputs.needs_windows_package }}
NEEDS_DESKTOP: ${{ needs.changes.outputs.needs_windows_desktop }}
CORE_RESULT: ${{ needs.test-windows-core.result }}
RUNNER_RESULT: ${{ needs.test-windows-runner.result }}
PACKAGE_RESULT: ${{ needs.test-windows-package.result }}
DESKTOP_RESULT: ${{ needs.test-windows-desktop.result }}
run: |
set -euo pipefail
echo "changes=$CHANGES_RESULT contract=$CONTRACT_RESULT core=$CORE_RESULT runner=$RUNNER_RESULT package=$PACKAGE_RESULT desktop=$DESKTOP_RESULT"
if [ "$CHANGES_RESULT" != success ] || [ "$CONTRACT_RESULT" != success ]; then
echo "CI policy or contract classification did not succeed" >&2
exit 1
fi
require_lane() {
label="$1"
required="$2"
actual="$3"
expected=skipped
if [ "$required" = true ]; then expected=success; fi
if [ "$actual" != "$expected" ]; then
echo "$label Windows lane expected $expected, got $actual" >&2
exit 1
fi
}
require_lane core "$NEEDS_CORE" "$CORE_RESULT"
require_lane runner "$NEEDS_RUNNER" "$RUNNER_RESULT"
require_lane package "$NEEDS_PACKAGE" "$PACKAGE_RESULT"
require_lane desktop "$NEEDS_DESKTOP" "$DESKTOP_RESULT"
test-native:
needs: [contract, changes, mcp-conformance, test-docker-server, test-macos, test-windows]
# Stable branch-protection context: daily native aggregates and the path-aware
# Server image smoke must resolve according to classifier policy.
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Validate native lane policy
env:
CONTRACT_RESULT: ${{ needs.contract.result }}
CHANGES_RESULT: ${{ needs.changes.result }}
NEEDS_DOCKER: ${{ needs.changes.outputs.needs_docker }}
MCP_CONFORMANCE_RESULT: ${{ needs.mcp-conformance.result }}
DOCKER_RESULT: ${{ needs.test-docker-server.result }}
MACOS_RESULT: ${{ needs.test-macos.result }}
WINDOWS_RESULT: ${{ needs.test-windows.result }}
run: |
set -euo pipefail
echo "changes=$CHANGES_RESULT contract=$CONTRACT_RESULT mcp_conformance=$MCP_CONFORMANCE_RESULT docker=$DOCKER_RESULT macos=$MACOS_RESULT windows=$WINDOWS_RESULT needs_docker=$NEEDS_DOCKER"
if [ "$CHANGES_RESULT" != success ] || [ "$CONTRACT_RESULT" != success ] || [ "$MCP_CONFORMANCE_RESULT" != success ]; then
echo "CI policy or contract classification did not succeed" >&2
exit 1
fi
if [ "$MACOS_RESULT" != success ] || [ "$WINDOWS_RESULT" != success ]; then
echo "native platform aggregate failed" >&2
exit 1
fi
expected_docker=skipped
if [ "$NEEDS_DOCKER" = true ]; then expected_docker=success; fi
if [ "$DOCKER_RESULT" != "$expected_docker" ]; then
echo "Server image lane expected $expected_docker, got $DOCKER_RESULT" >&2
exit 1
fi