Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/ascend-npu.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Ascend NPU Host Regression

on:
workflow_dispatch:
inputs:
verify_engine:
description: Require the already-managed inference engine health endpoint
type: boolean
default: false

permissions:
contents: read

concurrency:
group: sage-mate-ascend-npu-host
cancel-in-progress: false

jobs:
host-regression:
if: >-
github.repository == 'SAGE-Research/sage-mate' &&
github.ref == 'refs/heads/main' &&
github.event_name == 'workflow_dispatch'
environment: ascend-npu
runs-on:
group: sage-mate-ascend
labels:
- self-hosted
- Linux
- ARM64
- ascend
- sage-mate-ephemeral
timeout-minutes: 20
steps:
- name: Check out the trusted main revision
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
with:
persist-credentials: false
ref: ${{ github.sha }}

- name: Verify trusted dispatch context
env:
EXPECTED_SHA: ${{ github.sha }}
run: |
test "$GITHUB_EVENT_NAME" = workflow_dispatch
test "$GITHUB_REF" = refs/heads/main
test "$GITHUB_REPOSITORY" = SAGE-Research/sage-mate
test "$(git rev-parse HEAD)" = "$EXPECTED_SHA"
test "${SAGE_ASCEND_CI_EPHEMERAL:-}" = 1

- name: Run non-destructive Ascend host regression
env:
VERIFY_ENGINE: ${{ inputs.verify_engine }}
REPORT_PATH: ${{ runner.temp }}/ascend-host-report.json
run: bash tools/verify_ascend_ci_host.sh

- name: Upload public-safe host report
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: ascend-host-regression-${{ github.run_id }}
if-no-files-found: error
retention-days: 14
path: ${{ runner.temp }}/ascend-host-report.json
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog

## v4.6.31 - 2026-08-15

- Added a trusted-main-only Ascend host regression workflow backed by a
workflow-restricted, one-job ephemeral runner instead of exposing the NPU
host to public pull-request code.
- Added non-destructive ARM64/NPU, Docker device-binding, graph-mode, app
health, runner cleanup, and public-safe report gates plus an operator wrapper
that registers, dispatches, watches, and removes one runner automatically.

## v4.6.30 - 2026-08-15

- Fixed returning-visitor first paint so the welcome identity and three
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ curl -s http://127.0.0.1:55601/healthz
## CI 覆盖

- `ubuntu-latest`: lint、frontend 静态契约、Firefox 多视口布局回归、pytest、Linux CPU 一键安装检查。
- `ubuntu-latest`: Ascend 启动脚本语法和部署契约检查;真实 NPU 部署需在受控 Ascend 主机上单独执行。
- `ubuntu-latest`: Ascend 启动脚本语法和部署契约检查。
- 真实 NPU 主机回归:仅从 `main` 人工触发 `.github/workflows/ascend-npu.yml`,由
workflow-restricted 的一次性 runner 执行;公共 PR 永远不会直接使用 NPU 宿主机。

## 入口

Expand Down
23 changes: 23 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,29 @@ In particular, an Ascend launch requires `VLLM_ENGINE_MODEL_PATH` and
release installer can choose currently idle NPUs, while the lower-level launcher deliberately
fails instead of guessing.

### Trusted one-shot Ascend CI

Pull requests always use GitHub-hosted runners. The public repository must not
attach a persistent NPU host to `pull_request` jobs. Hardware regression is a
separate `workflow_dispatch` workflow on `main`, routed through the
workflow-restricted `sage-mate-ascend` runner group and the `ascend-npu`
environment.

An operator starts one ephemeral runner and dispatches one job with:

```bash
SAGE_ASCEND_CI_CONTAINER_IMAGE=<locally-installed-ascend-image> \
tools/run_ascend_ci_once.sh
```

The job checks the trusted revision, Ascend device/control nodes, `npu-smi`,
non-interactive Docker access, container device binding, graph-mode launcher
contracts, and application health. It does not start or replace the managed
model service. Set `VERIFY_ENGINE=true` only when an already-managed engine is
expected to be healthy. The runner deregisters after its single job and its
temporary work directory is removed; diagnostics are retained under the
operator's XDG cache directory.

The loopback addresses and ports shown in `.env.example` are editable sample configuration, not
values embedded in generated systemd units. To move the deployment, copy its secret material
separately and generate a new `.env` for the destination host.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "sage-mate"
version = "4.6.30"
version = "4.6.31"
description = "Sage Mate multi-profile local and hosted assistant built on SAGE and vllm-hust"
readme = "README.md"
license = {file = "LICENSE"}
Expand Down
2 changes: 1 addition & 1 deletion src/sage_faculty_twin/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Sage Mate application package."""

__version__ = "4.6.30"
__version__ = "4.6.31"

__all__ = ["__version__"]
55 changes: 55 additions & 0 deletions tests/test_ascend_ci_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from __future__ import annotations

import re
from pathlib import Path


ROOT = Path(__file__).resolve().parents[1]


def test_ascend_workflow_is_manual_main_only_and_read_only() -> None:
workflow = (ROOT / ".github" / "workflows" / "ascend-npu.yml").read_text(
encoding="utf-8"
)
trigger_block = workflow.split("permissions:", 1)[0]
assert "workflow_dispatch:" in trigger_block
assert "pull_request:" not in trigger_block
assert "push:" not in trigger_block
assert "contents: read" in workflow
assert "github.ref == 'refs/heads/main'" in workflow
assert "github.event_name == 'workflow_dispatch'" in workflow
assert "environment: ascend-npu" in workflow
assert "group: sage-mate-ascend" in workflow
assert "- sage-mate-ephemeral" in workflow


def test_ascend_workflow_pins_third_party_actions() -> None:
workflow = (ROOT / ".github" / "workflows" / "ascend-npu.yml").read_text(
encoding="utf-8"
)
references = re.findall(r"^\s*uses:\s*\S+@([^\s]+)$", workflow, re.MULTILINE)
assert references
assert all(re.fullmatch(r"[0-9a-f]{40}", revision) for revision in references)


def test_runner_launcher_requires_ephemeral_restricted_group() -> None:
launcher = (ROOT / "tools" / "run_ascend_ci_once.sh").read_text(encoding="utf-8")
assert "--ephemeral" in launcher
assert "restricted_to_workflows == true" in launcher
assert 'visibility == "selected"' in launcher
assert "@refs/heads/main" in launcher
assert 'orgs/$organization/actions/runners/registration-token' in launcher
assert 'runner-groups/$group_id/repositories' in launcher
assert "runner remained registered" in launcher


def test_host_probe_is_non_destructive_and_keeps_graph_mode() -> None:
probe = (ROOT / "tools" / "verify_ascend_ci_host.sh").read_text(encoding="utf-8")
assert "workflow_dispatch" in probe
assert "refs/heads/main" in probe
assert "--network none" in probe
assert "VLLM_ENGINE_ENFORCE_EAGER=0" in probe
assert "--enforce-eager" in probe
assert '"result":"failed"' in probe
assert "systemctl start" not in probe
assert "systemctl restart" not in probe
123 changes: 123 additions & 0 deletions tools/run_ascend_ci_once.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail

repository="${SAGE_ASCEND_CI_REPOSITORY:-SAGE-Research/sage-mate}"
organization="${repository%%/*}"
runner_group="${SAGE_ASCEND_CI_RUNNER_GROUP:-sage-mate-ascend}"
workflow="${SAGE_ASCEND_CI_WORKFLOW:-ascend-npu.yml}"
container_image="${SAGE_ASCEND_CI_CONTAINER_IMAGE:-}"
verify_engine="${VERIFY_ENGINE:-false}"
runner_cache_root="${XDG_CACHE_HOME:-$HOME/.cache}/sage-mate-actions-runner"
runner_runtime="$(mktemp -d "${TMPDIR:-/tmp}/sage-mate-actions-runner.XXXXXX")"
runner_name="sage-mate-ascend-$(hostname -s)-$$"
listener_pid=""

fail() {
echo "ERROR: $*" >&2
exit 1
}

cleanup() {
if [[ -n "$listener_pid" ]] && kill -0 "$listener_pid" 2>/dev/null; then
kill "$listener_pid" 2>/dev/null || true
wait "$listener_pid" 2>/dev/null || true
fi
if [[ -d "$runner_runtime/_diag" ]]; then
mkdir -p "$runner_cache_root/diag"
cp -a "$runner_runtime/_diag/." "$runner_cache_root/diag/" 2>/dev/null || true
fi
rm -rf -- "$runner_runtime"
}
trap cleanup EXIT INT TERM

command -v gh >/dev/null || fail "gh is required"
command -v curl >/dev/null || fail "curl is required"
command -v jq >/dev/null || fail "jq is required"
command -v sha256sum >/dev/null || fail "sha256sum is required"
[[ -n "$container_image" ]] || fail "SAGE_ASCEND_CI_CONTAINER_IMAGE is required"
[[ "$(uname -m)" == "aarch64" ]] || fail "this runner package requires aarch64"

group_json="$(gh api "orgs/$organization/actions/runner-groups")"
group_id="$(jq -r \
--arg group "$runner_group" \
--arg workflow "${repository}/.github/workflows/${workflow}@refs/heads/main" \
'.runner_groups[] | select(.name == $group) |
select(.visibility == "selected") |
select(.allows_public_repositories == true) |
select(.restricted_to_workflows == true) |
select(.selected_workflows | index($workflow)) | .id' \
<<<"$group_json")"
[[ "$group_id" =~ ^[0-9]+$ ]] || fail "runner group is not restricted to the trusted main workflow"
gh api "orgs/$organization/actions/runner-groups/$group_id/repositories" \
--jq ".repositories[] | select(.full_name == \"$repository\") | .full_name" \
| grep -qx "$repository" || fail "runner group is not restricted to $repository"

release_json="$(gh api repos/actions/runner/releases/latest)"
runner_tag="$(jq -r '.tag_name' <<<"$release_json")"
runner_version="${runner_tag#v}"
asset_name="actions-runner-linux-arm64-${runner_version}.tar.gz"
asset_url="$(jq -r --arg name "$asset_name" '.assets[] | select(.name == $name) | .url' <<<"$release_json")"
asset_digest="$(jq -r --arg name "$asset_name" '.assets[] | select(.name == $name) | .digest' <<<"$release_json")"
[[ -n "$asset_url" && "$asset_url" != "null" ]] || fail "latest ARM64 runner asset not found"
[[ "$asset_digest" == sha256:* ]] || fail "runner release has no SHA-256 digest"

archive="$runner_runtime/$asset_name"
gh api -H 'Accept: application/octet-stream' "$asset_url" >"$archive"
echo "${asset_digest#sha256:} $archive" | sha256sum --check --status || fail "runner archive checksum mismatch"
tar -xzf "$archive" -C "$runner_runtime"

registration_token="$(gh api --method POST "orgs/$organization/actions/runners/registration-token" --jq .token)"
(
cd "$runner_runtime"
./config.sh \
--url "https://github.com/$organization" \
--token "$registration_token" \
--runnergroup "$runner_group" \
--name "$runner_name" \
--labels 'ascend,sage-mate-ephemeral' \
--work _work \
--ephemeral \
--unattended
)

(
cd "$runner_runtime"
SAGE_ASCEND_CI_EPHEMERAL=1 \
SAGE_ASCEND_CI_CONTAINER_IMAGE="$container_image" \
./run.sh
) &
listener_pid=$!

for _ in $(seq 1 30); do
if gh api "orgs/$organization/actions/runners" \
--jq ".runners[] | select(.name == \"$runner_name\" and .status == \"online\") | .name" \
| grep -qx "$runner_name"; then
break
fi
sleep 1
done
gh api "orgs/$organization/actions/runners" \
--jq ".runners[] | select(.name == \"$runner_name\" and .status == \"online\") | .name" \
| grep -qx "$runner_name" || fail "ephemeral runner did not become online"

dispatch_started="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
gh workflow run "$workflow" --repo "$repository" --ref main -f "verify_engine=$verify_engine"
run_id=""
for _ in $(seq 1 30); do
run_id="$(gh run list --repo "$repository" --workflow "$workflow" --event workflow_dispatch --branch main \
--limit 10 --json databaseId,createdAt \
--jq ".[] | select(.createdAt >= \"$dispatch_started\") | .databaseId" | head -n 1)"
[[ -n "$run_id" ]] && break
sleep 1
done
[[ -n "$run_id" ]] || fail "dispatched workflow run was not found"

echo "Watching Ascend workflow run $run_id"
gh run watch "$run_id" --repo "$repository" --exit-status --interval 10
wait "$listener_pid"
listener_pid=""

if gh api "orgs/$organization/actions/runners" --jq ".runners[] | select(.name == \"$runner_name\") | .name" | grep -q .; then
fail "ephemeral runner remained registered after its job"
fi
echo "Ascend one-shot CI completed and runner deregistered: run=$run_id"
Loading
Loading