Skip to content

Commit 0ced86b

Browse files
committed
ci: add fail-closed self-hosted orchestration
1 parent 832cb00 commit 0ced86b

14 files changed

Lines changed: 1788 additions & 59 deletions

.github/workflows/ci-self-hosted.yml

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,17 @@ concurrency:
1111
group: cppmega-mlx-ci-${{ github.ref }}
1212
cancel-in-progress: true
1313

14+
permissions:
15+
contents: read
16+
1417
jobs:
1518
mac-contracts:
1619
name: macOS MLX data, model, and eval contracts
1720
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
1821
runs-on: [self-hosted, macOS, ARM64, cppmega-mlx-macos]
1922
timeout-minutes: 30
23+
env:
24+
SELF_HOSTED_RECEIPT_DIR: ${{ runner.temp }}/cppmega-mlx-ci-macos-${{ github.run_id }}-${{ github.run_attempt }}
2025
steps:
2126
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
2227
- name: Run focused MLX contract suite
@@ -25,47 +30,49 @@ jobs:
2530
set -euo pipefail
2631
python_bin=/Volumes/external/sources/cppmega.mlx/.venv/bin/python
2732
test -x "${python_bin}"
28-
"${python_bin}" -m pytest -q \
29-
tests/test_ast_fim.py \
30-
tests/test_audit_sidecar_parquet.py \
31-
tests/test_cpp_jsonl_generation_compile_eval.py \
32-
tests/test_data_package_imports.py \
33-
tests/test_domain_graph_routes.py \
34-
tests/test_eval_domain_routed_codegen.py \
35-
tests/test_inference_generation.py \
36-
tests/test_megatron_indexed.py \
37-
tests/test_pack_enriched_rows.py \
38-
tests/test_streaming_conveyor_progress.py \
39-
tests/test_process_commits_fail_loud.py \
40-
tests/test_repair_packed_document_boundaries.py \
41-
tests/test_workflow_runner_policy.py
42-
git diff --check
33+
"${python_bin}" scripts/run_self_hosted_ci.py lane \
34+
--lane macos-mlx \
35+
--python "${python_bin}" \
36+
--repo-root "${GITHUB_WORKSPACE}" \
37+
--receipt-dir "${SELF_HOSTED_RECEIPT_DIR}" \
38+
--timeout-seconds 1500
39+
- name: Upload macOS runner receipt
40+
if: always()
41+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
42+
with:
43+
name: self-hosted-ci-macos-${{ github.run_id }}-${{ github.run_attempt }}
44+
path: ${{ env.SELF_HOSTED_RECEIPT_DIR }}
45+
if-no-files-found: error
46+
retention-days: 14
4347

4448
linux-portable:
4549
name: Linux portable syntax and conveyor contracts
4650
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
4751
runs-on: [self-hosted, Linux, X64, cppmega-mlx]
4852
timeout-minutes: 20
53+
env:
54+
SELF_HOSTED_RECEIPT_DIR: ${{ runner.temp }}/cppmega-mlx-ci-linux-${{ github.run_id }}-${{ github.run_attempt }}
4955
steps:
5056
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
5157
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
5258
with:
5359
python-version: "3.13"
54-
- name: Install portable test dependencies
55-
run: python -m pip install --disable-pip-version-check pytest numpy pyarrow tokenizers zstandard
5660
- name: Run portable script contracts
5761
shell: bash
5862
run: |
5963
set -euo pipefail
60-
# cppmega.mlx imports Apple's MLX runtime at package import time. The
61-
# Linux runner exercises only portable scripts and deliberately skips
62-
# the macOS-only root conftest/package initialization.
63-
python -m pytest -q --noconftest \
64-
tests/test_audit_sidecar_parquet.py \
65-
tests/test_data_package_imports.py \
66-
tests/test_streaming_conveyor_progress.py \
67-
tests/test_process_commits_fail_loud.py \
68-
tests/test_repair_packed_document_boundaries.py \
69-
tests/test_workflow_runner_policy.py
70-
python -m compileall -q scripts tools/clang_indexer
71-
git diff --check
64+
python scripts/run_self_hosted_ci.py lane \
65+
--lane linux-portable \
66+
--python python \
67+
--repo-root "${GITHUB_WORKSPACE}" \
68+
--receipt-dir "${SELF_HOSTED_RECEIPT_DIR}" \
69+
--timeout-seconds 900 \
70+
--bootstrap-portable
71+
- name: Upload Linux runner receipt
72+
if: always()
73+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
74+
with:
75+
name: self-hosted-ci-linux-${{ github.run_id }}-${{ github.run_attempt }}
76+
path: ${{ env.SELF_HOSTED_RECEIPT_DIR }}
77+
if-no-files-found: error
78+
retention-days: 14

.github/workflows/cuda-lane.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ jobs:
1212
runs-on: [self-hosted, cuda]
1313
timeout-minutes: 30
1414
steps:
15-
- uses: actions/checkout@v4
15+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
1616

1717
- name: Set up Python
18-
uses: actions/setup-python@v5
18+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
1919
with:
2020
python-version: "3.13"
2121

.github/workflows/e2e-matrix.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,9 @@ jobs:
4040
name: Preset matrix (smoke, self-hosted macOS)
4141
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
4242
runs-on: [self-hosted, macOS, ARM64, cppmega-mlx-macos]
43-
# Shard 1 contains the slowest generated preset cells and takes ~21 minutes
44-
# after setup on the shared Mac Studio. Keep the timeout above measured test
45-
# time so the self-hosted runner reports an actual verdict instead of a
46-
# scheduler cancellation.
47-
timeout-minutes: 40
43+
# The matrix is one spec file. --fully-parallel is required or Playwright
44+
# assigns all 912 cells to shard 1 and leaves the other shards empty.
45+
timeout-minutes: 20
4846
strategy:
4947
fail-fast: false
5048
matrix:
@@ -95,6 +93,8 @@ jobs:
9593
run: |
9694
npx playwright test --config=e2e/playwright.config.ts \
9795
e2e/scenarios/02_preset_matrix.spec.ts \
96+
--fully-parallel \
97+
--global-timeout=720000 \
9898
--shard=${{ matrix.shard }}/4
9999
- name: Upload artefacts
100100
if: always()

.github/workflows/gemini-invoke.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,11 @@ jobs:
3838
permission-pull-requests: 'write'
3939

4040
- name: 'Checkout Code'
41-
uses: 'actions/checkout@v4' # ratchet:exclude
41+
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # v4.3.1
4242

4343
- name: 'Run Gemini CLI'
4444
id: 'run_gemini'
45-
uses: 'google-github-actions/run-gemini-cli@v0' # ratchet:exclude
45+
uses: 'google-github-actions/run-gemini-cli@f77273f4c914e4bf38440cf36a0369cb64a37489' # v0
4646
env:
4747
TITLE: '${{ github.event.pull_request.title || github.event.issue.title }}'
4848
DESCRIPTION: '${{ github.event.pull_request.body || github.event.issue.body }}'

.github/workflows/gemini-plan-execute.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,11 @@ jobs:
4040
permission-pull-requests: 'write'
4141

4242
- name: 'Checkout Code'
43-
uses: 'actions/checkout@v4' # ratchet:exclude
43+
uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # v4.3.1
4444

4545
- name: 'Run Gemini CLI'
4646
id: 'run_gemini'
47-
uses: 'google-github-actions/run-gemini-cli@v0' # ratchet:exclude
47+
uses: 'google-github-actions/run-gemini-cli@f77273f4c914e4bf38440cf36a0369cb64a37489' # v0
4848
env:
4949
TITLE: '${{ github.event.pull_request.title || github.event.issue.title }}'
5050
DESCRIPTION: '${{ github.event.pull_request.body || github.event.issue.body }}'

.github/workflows/gemini-review.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ jobs:
4242
uses: 'actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8' # ratchet:actions/checkout@v6
4343

4444
- name: 'Run Gemini pull request review'
45-
uses: 'google-github-actions/run-gemini-cli@v0' # ratchet:exclude
45+
uses: 'google-github-actions/run-gemini-cli@f77273f4c914e4bf38440cf36a0369cb64a37489' # v0
4646
id: 'gemini_pr_review'
4747
env:
4848
GITHUB_TOKEN: '${{ steps.mint_identity_token.outputs.token || secrets.GITHUB_TOKEN || github.token }}'

.github/workflows/gemini-scheduled-triage.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ jobs:
9393
id: 'gemini_issue_analysis'
9494
if: |-
9595
${{ steps.find_issues.outputs.issues_to_triage != '[]' }}
96-
uses: 'google-github-actions/run-gemini-cli@v0' # ratchet:exclude
96+
uses: 'google-github-actions/run-gemini-cli@f77273f4c914e4bf38440cf36a0369cb64a37489' # v0
9797
env:
9898
GITHUB_TOKEN: '' # Do not pass any auth token here since this runs on untrusted inputs
9999
ISSUES_TO_TRIAGE: '${{ steps.find_issues.outputs.issues_to_triage }}'

.github/workflows/gemini-triage.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ jobs:
5959
id: 'gemini_analysis'
6060
if: |-
6161
${{ steps.get_labels.outputs.available_labels != '' }}
62-
uses: 'google-github-actions/run-gemini-cli@v0' # ratchet:exclude
62+
uses: 'google-github-actions/run-gemini-cli@f77273f4c914e4bf38440cf36a0369cb64a37489' # v0
6363
env:
6464
GITHUB_TOKEN: '' # Do NOT pass any auth tokens here since this runs on untrusted inputs
6565
ISSUE_TITLE: '${{ github.event.issue.title }}'

.github/workflows/vbgui-pages.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,17 @@ jobs:
2929
build:
3030
runs-on: [self-hosted, Linux, X64, cppmega-mlx]
3131
steps:
32-
- uses: actions/checkout@v4
32+
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
3333

3434
- name: Set up Node 22
35-
uses: actions/setup-node@v4
35+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
3636
with:
3737
node-version: "22"
3838
cache: "npm"
3939
cache-dependency-path: vbgui/package-lock.json
4040

4141
- name: Set up Python 3.13
42-
uses: actions/setup-python@v5
42+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
4343
with:
4444
python-version: "3.13"
4545

@@ -69,7 +69,7 @@ jobs:
6969
cp -r vbgui/dist/* vbgui/site/
7070
test -d vbgui/site/jupyterlite
7171
72-
- uses: actions/upload-pages-artifact@v3
72+
- uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
7373
with:
7474
path: vbgui/site
7575

@@ -81,4 +81,4 @@ jobs:
8181
url: ${{ steps.deployment.outputs.page_url }}
8282
steps:
8383
- id: deployment
84-
uses: actions/deploy-pages@v4
84+
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4

docs/self_hosted_ci.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# Self-hosted CI orchestration
2+
3+
`cppmega_mlx` uses GitHub Actions only as a coordinator. Every job with a
4+
`runs-on` key targets a repository-owned runner; hosted macOS, Linux, and
5+
Windows labels are rejected by `tests/test_workflow_runner_policy.py`.
6+
7+
The registration pattern comes from the live `cloud_love` setup:
8+
9+
- workflows target stable custom labels in addition to `self-hosted` and the
10+
operating-system label;
11+
- runner registration tokens are temporary GitHub secrets consumed by
12+
`cloud_love/.github/workflows/bootstrap-cppmega-runners.yml`;
13+
- no runner token, SSH password, private key, or other credential is stored in
14+
this repository;
15+
- all external workflow actions are pinned to full commit SHAs.
16+
17+
## Verified inventory
18+
19+
Snapshot taken 2026-07-14. Online/busy state is live data and must be refreshed
20+
before an operational claim:
21+
22+
| Inventory ID | Address | Role | GitHub runner and labels | Direct status at verification |
23+
|---|---:|---|---|---|
24+
| `mac-studio` | `10.0.0.8` | required macOS MLX | `mac-studio-cppmega-mlx`; `self-hosted,macOS,ARM64,cppmega-mlx-macos` | local and available |
25+
| `legion-linux` | `10.0.0.16` | required portable Linux | `davidgor-Legion-R9000P-ARX8-cppmega-mlx`; `self-hosted,Linux,X64,cppmega-mlx` | TCP/22 reachable; batch SSH authentication unavailable from the Mac Studio |
26+
| `windows-10-0-0-11` | `10.0.0.11` | inventory only | no `cppmega_mlx` runner | Windows is not eligible for either lane; batch SSH authentication unavailable |
27+
| `untrusted-10-0-0-12` | `10.0.0.12` | quarantined inventory only | none | TCP/22 reachable, but the current host key does not match the saved key |
28+
29+
Do not bypass the `10.0.0.12` host-key error with
30+
`StrictHostKeyChecking=no`. Verify the replacement machine and fingerprint out
31+
of band, update `known_hosts`, and only then assign it a test lane.
32+
33+
Refresh GitHub's runner view without printing credentials:
34+
35+
```bash
36+
gh api repos/DatasunriseOU/cppmega_mlx/actions/runners \
37+
--jq '.runners[] | {name,os,status,busy,labels:[.labels[].name]}'
38+
```
39+
40+
## Shared suites
41+
42+
Both GitHub jobs and the direct orchestrator call
43+
`scripts/run_self_hosted_ci.py lane`:
44+
45+
- `macos-mlx` runs the focused data, model, inference, evaluation, and
46+
orchestration policy tests with the established local MLX environment.
47+
- `linux-portable` creates an isolated temporary venv, installs only the
48+
already-established portable test dependencies, runs tests with
49+
`--noconftest`, compiles portable scripts, and never imports Apple's MLX
50+
runtime.
51+
52+
Each process is started in its own process group. A timeout terminates the
53+
whole group, returns exit code 124, and records `timed_out` instead of leaving
54+
children behind. Each step has its own log and the lane always writes
55+
`receipt.json` with host, source commit, timing, command, status, and exit code.
56+
The workflows upload these directories with 14-day retention even when a lane
57+
fails.
58+
59+
## Direct CLI
60+
61+
The direct path does not use GitHub Actions. It probes all selected hosts with
62+
non-interactive SSH, aborts before dispatch if any required host is
63+
unavailable, stages the exact requested Git commit, runs the same lane entry
64+
point, and collects each host's receipt and logs.
65+
66+
Read-only probe and plan:
67+
68+
```bash
69+
python3 scripts/run_self_hosted_ci.py orchestrate \
70+
--dry-run \
71+
--receipt-dir /tmp/cppmega-mlx-self-hosted
72+
```
73+
74+
Run only the local macOS lane:
75+
76+
```bash
77+
python3 scripts/run_self_hosted_ci.py orchestrate \
78+
--host mac-studio \
79+
--ref HEAD \
80+
--receipt-dir /tmp/cppmega-mlx-self-hosted
81+
```
82+
83+
Run the full matrix after key-based SSH to `10.0.0.16` is available:
84+
85+
```bash
86+
python3 scripts/run_self_hosted_ci.py orchestrate \
87+
--ref HEAD \
88+
--receipt-dir /tmp/cppmega-mlx-self-hosted
89+
```
90+
91+
The CLI never accepts a password or token argument. SSH uses `BatchMode=yes`,
92+
disables password and keyboard-interactive authentication, and preserves normal
93+
host-key verification. Configure an agent-backed SSH key outside the
94+
repository. The source worktree may be dirty; dispatch still uses the exact
95+
resolved commit and records `source_dirty` in the orchestration receipt.
96+
97+
Receipts are written under:
98+
99+
```text
100+
<receipt-dir>/<run-id>/orchestration.json
101+
<receipt-dir>/<run-id>/<host-id>/probe.json
102+
<receipt-dir>/<run-id>/<host-id>/receipt.json
103+
<receipt-dir>/<run-id>/<host-id>/*.log
104+
```
105+
106+
Exit code 2 means orchestration/preflight was blocked, exit code 1 means a
107+
dispatched lane failed, and exit code 0 means every selected required lane
108+
passed (or a dry-run found every selected required host available).
109+
110+
## Preset shard incident
111+
112+
PR run `29301626406`, job `86986403882`, proved that increasing shard1 to 40
113+
minutes was not a fix: setup finished at `03:00:37Z`, the Playwright step ran
114+
until `03:36:05Z`, and the job hit its 40-minute cap. The matrix is one spec
115+
file and the base config has `fullyParallel: false`, so Playwright's file-level
116+
sharding assigned all 912 tests to shard 1 while the other shards had no tests.
117+
118+
The self-hosted workflow now passes `--fully-parallel`, retaining four shards
119+
that contain test cells rather than files. Playwright has a 12-minute global
120+
cap and each job has a 20-minute cap, leaving setup and artifact-upload
121+
headroom while still producing logs on a test timeout.

0 commit comments

Comments
 (0)