Files
deepseek-harness/.github/workflows/ci.yml
T
Tianyi Cui 3c1c6a89b1 test(python): gate installed runtime wheels across release targets (#2953)
* test(python): exercise installed wheels as black boxes

Add an installed-wheel mode that refuses source/editable imports, repository working directories, mismatched SDK/runtime versions, unpinned runtime dependencies, and executables outside the installed runtime distribution. The mode resolves the wheel-owned executable itself, so callers cannot accidentally prove an explicit checkout artifact.

Add a real-API scenario that drives two tool-using turns through the public synchronous SDK, verifies the file bytes outside the agent, checks completed turn/tool events and persistence, and projects provider failures without retaining credential-bearing error text. The existing deterministic scenario set remains the keyless behavior oracle.

Refs #2952.

* ci(python): require installed-wheel checks on every release target

Move the complete deterministic runtime scenarios behind construction and clean installation of the SDK and matching runtime wheels. Each native leg runs outside the checkout with source-resolution environment variables removed; Linux manylinux smokes assert the same installed provenance.

Expand the required pull-request call from Linux x64 to Linux x64, Linux arm64, and macOS arm64. Trusted heads receive only DEEPSEEK_API_KEY_EXTERNAL for a fail-loud live two-turn smoke on each carrier, while fork and Dependabot heads retain the full keyless path without exposing secrets.

Pin the reusable secret declaration, matrix call, aggregate dependency, untrusted-head condition, and live/keyless commands in the workflow contract test.

Refs #2952.

* docs(testing): make installed wheels the Python CI authority

Record the clean-wheel provenance boundary, complete keyless scenario set, trusted real-API contract, secret handling, and three-target required topology in a new implemented testing decision. Update the SEA distribution and portable-CI authorities plus the Python contributor reference to describe the same current state.

Archive the fully superseded Linux-x64-only decision after consolidating its rationale and alternatives into the new owner. Preserve its bilingual triplet as a sealed historical snapshot and redirect every active current-state reference.

Refs #2952.
2026-08-23 16:53:55 +08:00

496 lines
21 KiB
YAML

name: CI
on:
pull_request:
permissions:
contents: read
env:
PRIMARY_NODE_VERSION: '24'
# CI runs must never report to the production telemetry endpoint baked
# into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
DSH_TELEMETRY_DISABLED: '1'
# Cancel a superseded pull-request run on a new push so a fresh head does not
# queue a second full 9-job run behind a stale one (paid enterprise runners
# would otherwise stack with no auto-cancellation).
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Three enterprise jobs isolate coverage, static analysis, and the
# build-backed consumer tail. The consumer job owns the only Linux build so
# all three jobs enter runner allocation independently.
#
# FAILOVER (Linux): each Linux enterprise job resolves its pool through the
# DSH_CI_FAILOVER_LINUX repository variable. Unset (normal), the expressions
# pick the hosted enterprise pools below. Setting the variable to
# 'selfhosted' (repo Settings → Actions → Variables; writer-manageable
# repository state — not PR-editable, no merge required) retargets all
# three onto the in-house
# vm-backup pool and re-running the failed jobs is the entire switch —
# see .agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md. The
# in-house pool's readiness is re-proven on every master push by the
# serial-linux-selfhosted standby lane in ci-master.yml. The Windows failover
# switch is the separate DSH_CI_FAILOVER_WINDOWS variable on the windows-native
# job below.
node-24:
if: github.event_name == 'pull_request'
runs-on: >-
${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|| 'dsh-ubuntu-24-04-16core' }}
name: node 24 / static
env:
DSH_GATE_CONCURRENCY: '8'
steps:
# Fetch complete history so the archive gate can read the trusted PR base from a reused shallow checkout.
- uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
- name: Configure pnpm store path
id: pnpm-store
run: |
store_root="$HOME/.local/share/pnpm/store"
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
echo "path=$store_path" >> "$GITHUB_OUTPUT"
# Pull requests consume the default-branch cache but do not put cache
# compression and upload on the paid latency-critical path. Skipped
# under failover — see the coverage lane's identical rationale.
- uses: actions/cache/restore@v4
if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
- name: Install (immutable)
run: pnpm install --frozen-lockfile
- name: Run static gates
env:
DSH_ARCHIVE_BASE_REF: ${{ github.event.pull_request.base.sha }}
run: pnpm run check:ci:static
node-24-coverage:
if: github.event_name == 'pull_request'
runs-on: >-
${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|| 'dsh-ubuntu-24-04-16core' }}
name: node 24 / coverage
env:
# Partitioning replaces the instrumented share; this budget gives the
# exempt-heavy gate two workers on both hosted and failover runners.
DSH_COVERAGE_MAX_WORKERS: '6'
DSH_COVERAGE_PARTITIONS: '4'
DSH_GATE_CONCURRENCY: '3'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
- name: Configure pnpm store path
id: pnpm-store
run: |
store_root="$HOME/.local/share/pnpm/store"
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
echo "path=$store_path" >> "$GITHUB_OUTPUT"
# Skipped under failover: the self-hosted VM's persistent pnpm store
# already serves warm installs, while restoring the hosted archive
# would spend ~52 s pulling ~180 MB into that populated store.
- uses: actions/cache/restore@v4
if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
- name: Install dependencies and prepare bubblewrap
run: |
pnpm install --frozen-lockfile &
install_pid=$!
bash scripts/prepare-ci-bubblewrap.sh &
sandbox_pid=$!
install_status=0
wait "$install_pid" || install_status=$?
sandbox_status=0
wait "$sandbox_pid" || sandbox_status=$?
if (( install_status != 0 )); then exit "$install_status"; fi
exit "$sandbox_status"
- name: Run exhaustive coverage
run: pnpm run check:ci:coverage
node-24-consumers:
if: github.event_name == 'pull_request'
runs-on: >-
${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|| 'dsh-ubuntu-24-04-16core' }}
name: node 24 / snapshots and artifacts
env:
DSH_GATE_CONCURRENCY: '8'
DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
DSH_OXLINT_THREADS: '8'
DSH_PUBLINT_CONCURRENCY: '8'
DSH_WEB_SNAPSHOT_WORKERS: '6'
# Failover halves snapshot concurrency for the shared 64-core VM.
DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }}
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
- name: Configure pnpm store path
id: pnpm-store
run: |
store_root="$HOME/.local/share/pnpm/store"
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
echo "path=$store_path" >> "$GITHUB_OUTPUT"
# Pull requests restore the pnpm store and Playwright caches without paying
# compression and upload on the required path. No master job saves these
# hosted cache keys, so each restore-keys fallback hits the matching archived
# entry until it evicts, after which the store is cold. Skipped under failover
# — the self-hosted VM's persistent store is already warm.
- uses: actions/cache/restore@v4
if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
# Skipped under failover: the VM's persistent browser cache is already warm.
- uses: actions/cache/restore@v4
if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Install dependencies and prepare bubblewrap
run: |
pnpm install --frozen-lockfile &
install_pid=$!
bash scripts/prepare-ci-bubblewrap.sh &
sandbox_pid=$!
install_status=0
wait "$install_pid" || install_status=$?
sandbox_status=0
wait "$sandbox_pid" || sandbox_status=$?
if (( install_status != 0 )); then exit "$install_status"; fi
exit "$sandbox_status"
- name: Install Playwright Chromium and hosted dependencies
if: vars.DSH_CI_FAILOVER_LINUX != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps chromium
# The persistent VM image owns Playwright's Linux system packages; do
# not mutate the shared host with apt on every failover run.
- name: Install Playwright Chromium on the failover VM
if: vars.DSH_CI_FAILOVER_LINUX == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]'
run: pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install chromium
- name: Run compatibility, snapshot, and artifact gates
run: pnpm run check:ci:consumers
node-compat:
if: github.event_name == 'pull_request'
# Each compatibility contract receives an independent standard hosted job.
runs-on: ${{ matrix.runner }}
name: ${{ matrix.name }}
env:
DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }}
DSH_NODE_COMPAT_SKIP_TYPECHECK: '1'
strategy:
fail-fast: false
matrix:
include:
- node: '22.19'
name: node 22.19
runner: ubuntu-latest
gate_concurrency: '1'
- node: 26
name: node 26
runner: ubuntu-latest
gate_concurrency: '1'
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Install (immutable)
run: pnpm install --frozen-lockfile
- name: Run compatibility smokes
env:
DSH_BUILD_CLIENT_PROFILE: official
run: pnpm run check:node-compat
python-sdk:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
name: python 3.10 / keyless SDK
steps:
- uses: actions/checkout@v6
- uses: actions/setup-python@v6.3.0
with:
python-version: '3.10'
cache: pip
- name: Install uv
run: python -m pip install uv==0.11.23
- name: Run complete keyless Python suite
run: uv run --python 3.10 --group test --project python/sdk pytest
# The reusable builder owns each published executable, wheel, clean-install,
# keyless black-box, and trusted real-API path. All native release targets are
# required because a platform wheel cannot be validated by another carrier.
python-runtime:
if: github.event_name == 'pull_request'
name: python runtime / release-shaped matrix
uses: ./.github/workflows/build-exe-for-python-sdk.yml
with:
targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64
ci: true
secrets:
DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
# The pull-request Windows signals cover complementary hosts. The two fast
# win32 toolchain surfaces (workspace build, production site) execute with
# real, checksum-verified Windows Node under Wine on standard hosted Linux.
# The windows-native job below keeps the complete native-kernel inventory —
# including the observational portability gates this lane does not run —
# on real Windows. This job only provisions runner state (caches,
# apt); scripts/wine-windows-gates.sh owns the gate logic and is the same
# script the optional local gate `pnpm run check:windows-wine` runs.
# Current topology and fidelity limits live in
# .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md
windows:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
name: windows node 24 / wine blocking
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
- name: Configure pnpm store path
id: pnpm-store
run: |
store_root="$HOME/.local/share/pnpm/store"
echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV"
store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent)
echo "path=$store_path" >> "$GITHUB_OUTPUT"
- uses: actions/cache/restore@v4
with:
path: ${{ steps.pnpm-store.outputs.path }}
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
# Master's wine-apt-cache job in ci-master.yml seeds the default-branch
# scope every pull request can read; a save from this job only reaches
# reruns of the same merge ref.
- name: Compose Wine apt cache key
id: wine-cache-key
run: echo "key=wine-debs-${ImageOS:-linux}-${ImageVersion:-v0}" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
with:
path: ~/wine-debs
key: ${{ steps.wine-cache-key.outputs.key }}
# Runner provisioning only — a developer machine installs Wine through
# its own package manager; the gate script assumes a wine binary and
# fails loud without one. Wine from the apt cache when present; else
# download the full dependency closure once and keep it for the next
# run. The `wine` dispatcher package (not bare `wine64`) is what puts a
# binary on PATH.
- name: Install Wine
run: |
if compgen -G "$HOME/wine-debs/*.deb" > /dev/null; then
# The restored archive is the full --download-only closure of
# `wine` for this runner image, so installing the .debs directly
# with dpkg needs no repository access. apt-get would instead
# re-download the same 100+ MB closure from the mirror, which has
# stalled the job past its budget on a degraded runner network.
# If the archive cannot satisfy the closure, fall back to the apt
# network install.
if ! sudo DEBIAN_FRONTEND=noninteractive dpkg -i "$HOME"/wine-debs/*.deb; then
sudo DEBIAN_FRONTEND=noninteractive dpkg --configure -a || true
sudo apt-get install -y --no-install-recommends "$HOME"/wine-debs/*.deb
fi
else
sudo apt-get update
sudo apt-get install -y --no-install-recommends --download-only wine
mkdir -p "$HOME/wine-debs"
cp /var/cache/apt/archives/*.deb "$HOME/wine-debs/" 2>/dev/null || true
sudo apt-get install -y --no-install-recommends wine
fi
- name: Run the Wine Windows gates
run: bash scripts/wine-windows-gates.sh
- name: Shut down wineserver
if: always()
run: wineserver -k 2>/dev/null || true
# Every pull request also gets a real Windows-kernel signal. Its unmasked
# conclusion is a dependency of all-checks-passed, so failure, cancellation,
# or omission blocks the required verdict. Under normal operation it runs on
# the hosted larger runner. DSH_CI_FAILOVER_WINDOWS=selfhosted retargets it
# onto the in-house self-hosted Windows pool. Dependabot PRs are excluded
# from the self-hosted pool and stay queued for the hosted runner — see the failover
# runbook. This Windows switch is independent of the Linux
# DSH_CI_FAILOVER_LINUX variable that retargets the three required Linux jobs
# and the all-checks-passed verdict above.
windows-native:
if: github.event_name == 'pull_request'
runs-on: >-
${{ vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
|| 'dsh-windows-2025-16core' }}
name: windows node 24 / native complete
timeout-minutes: 120
env:
# Partitioned coverage finishes before the heavy uninstrumented gate;
# the latter can use four workers without competing with sixteen shards.
DSH_COVERAGE_MAX_WORKERS: '12'
DSH_COVERAGE_PARTITIONS: '16'
# Instrumented process and polling fixtures can exceed Vitest's defaults
# under the complete lane's concurrent gate load.
DSH_COVERAGE_TEST_TIMEOUT_MS: '30000'
# After the threshold merge, the heavy gate overlaps lightweight
# observational checks within this post-coverage worker budget.
DSH_GATE_CONCURRENCY: '8'
DSH_PUBLINT_CONCURRENCY: '8'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- name: Enable Developer Mode (symlink support)
shell: pwsh
run: >-
reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock"
/t REG_DWORD /f /v "AllowDevelopmentWithoutDevLicense" /d "1"
- uses: pnpm/action-setup@v4
with:
dest: ${{ runner.temp }}/setup-pnpm-js
- uses: actions/setup-node@v6
with:
node-version: ${{ env.PRIMARY_NODE_VERSION }}
# Extracting the many-file pnpm store cache is slower than a clean
# install on hosted Windows runners, and saving it adds latency after
# the gates. The self-hosted VM's persistent store makes caching
# redundant.
- name: Install (immutable)
shell: pwsh
run: pnpm install --frozen-lockfile
- name: Run complete native Windows gate inventory
shell: pwsh
run: pnpm run check:ci:windows-complete
# Single stable required check for branch protection: require "all checks
# passed" instead of enumerating matrix legs whose names change as lanes and
# node versions evolve. Every blocking job in THIS workflow must be listed in
# `needs`, including both the Wine `windows` job and the real-kernel
# `windows-native` job. (`needs` cannot reach across workflow files; the
# master-only jobs in ci-master.yml are intentionally not part of this PR
# verdict.)
# `if: always()` is load-bearing: without it a failed dependency
# would SKIP this job, and GitHub counts a skipped required check as passing
# — so this job always runs and fails on any non-success result, including
# 'cancelled' and 'skipped'.
all-checks-passed:
name: all checks passed
# This bookkeeping-only verdict must not depend on custom-pool
# provisioning — and under Linux failover it follows the same selector as
# the worker jobs it aggregates, so a standard-hosted outage cannot strand
# the branch-protection verdict either. It retargets with the Linux switch
# (DSH_CI_FAILOVER_LINUX), not the Windows one, because this bookkeeping job
# itself runs on Linux; the native dependency resolves its Windows pool
# independently.
runs-on: >-
${{ vars.DSH_CI_FAILOVER_LINUX == 'selfhosted'
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|| 'ubuntu-latest' }}
needs: [node-24, node-24-coverage, node-24-consumers, node-compat, python-sdk, python-runtime, windows, windows-native]
if: always() && github.event_name == 'pull_request'
steps:
- name: Fail if any needed job did not succeed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped')
run: |
echo "::error::Needed job results: ${{ join(needs.*.result, ', ') }}"
exit 1
- name: All checks passed
run: echo "All needed jobs succeeded (${{ join(needs.*.result, ', ') }})"