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
81 changes: 81 additions & 0 deletions .github/workflows/native-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# This source file is part of the Heartwood open-source project
#
# SPDX-FileCopyrightText: 2026 Stanford University and the project authors (see CONTRIBUTORS.md)
#
# SPDX-License-Identifier: MIT

name: Native Release Assets

on:
pull_request:
paths:
- ".github/workflows/native-release.yml"
- "deploy/**"
- "packages/**"
- "pyproject.toml"
- "uv.lock"
release:
types: [published]
workflow_dispatch:

permissions:
contents: read

jobs:
package:
name: Build And Verify Native Assets
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Build native assets
env:
RELEASE_VERSION: ${{ github.event.release.tag_name || github.sha }}
run: deploy/package-native.sh dist "${RELEASE_VERSION}"
- name: Verify local installer path
run: >-
dist/heartwood-installer
--bundle dist/heartwood-native.tar.gz
--checksums dist/SHA256SUMS
--root "${RUNNER_TEMP}/heartwood"
--platform generic
--dry-run
- name: Test native installation layout
run: deploy/tests/native_installer_smoke.sh dist
- name: Upload workflow artifact
uses: actions/upload-artifact@v7
with:
name: heartwood-native-assets
path: |
dist/heartwood-installer
dist/heartwood-native.tar.gz
dist/SHA256SUMS
if-no-files-found: error

publish:
name: Publish Native Release Assets
if: github.event_name == 'release'
needs: package
runs-on: ubuntu-24.04
permissions:
attestations: write
contents: write
id-token: write
steps:
- name: Download native assets
uses: actions/download-artifact@v8
with:
name: heartwood-native-assets
path: dist
- name: Attest native assets
uses: actions/attest@v4
with:
subject-path: dist/*
- name: Publish release assets
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: gh release upload "${RELEASE_TAG}" dist/* --clobber --repo "${GITHUB_REPOSITORY}"
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ The default runtime uses the generic platform policy and synthetic OMOP data-sou

See [Platform Support](docs/platform-support.md) for platform-specific evidence and limitations. All of Us, AnVIL, Seven Bridges, Velsera, DNAnexus, and UK Biobank Research Analysis Platform are design targets rather than supported platforms.

Tagged releases publish a verified native bundle for environments where a platform image is not appropriate. Download the release installer, verify it before execution, and use the installed environment-aware launcher:

```bash
./heartwood-installer --root /persistent/project/heartwood
export PATH="/persistent/project/heartwood/bin:${PATH}"
heartwood launch --model-root /persistent/project/models/<reviewed-model>
```

`heartwood launch --dry-run` reports the detected platform, storage, model, and compute plan without changing state. On Carina it asks before invoking Slurm; Terra and generic containers use their already-provisioned compute. See [Carina CLI Pilot](docs/carina-cli.md) for the synthetic native GPU workflow.

## Researcher Experience

![Heartwood synthetic reference analysis](docs/assets/web-reference-analysis.png)
Expand Down
78 changes: 12 additions & 66 deletions deploy/carina/launch-interactive.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@

set -euo pipefail

script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "${script_dir}/../.." && pwd)"

environment_root=""
model_root=""
state_root=""
Expand All @@ -29,70 +26,19 @@ done
: "${SLURM_JOB_ID:?launch Heartwood inside a Slurm compute allocation}"
: "${LOCAL_SCRATCH_JOB:?Carina job-local scratch is unavailable}"

if [[ ! -x "${environment_root}/heartwood/bin/heartwood" ]]; then
echo "Heartwood environment is unavailable; run deploy/carina/bootstrap.sh first" >&2
exit 69
fi
if [[ ! -x "${environment_root}/vllm/bin/vllm" ]]; then
echo "vLLM environment is unavailable; run deploy/carina/bootstrap.sh first" >&2
heartwood="${environment_root}/heartwood/bin/heartwood"
if [[ ! -x "${heartwood}" ]]; then
echo "Heartwood environment is unavailable; run the native installer first" >&2
exit 69
fi
"${environment_root}/heartwood/bin/python" "${script_dir}/verify_model_snapshot.py" "${model_root}"

mkdir -p "${state_root}"
staged_model="$(mktemp -d "${LOCAL_SCRATCH_JOB%/}/heartwood-model.XXXXXX")"
runtime_pid=""
cleanup() {
if [[ -n "${runtime_pid}" ]]; then
kill "${runtime_pid}" >/dev/null 2>&1 || true
for _ in {1..10}; do
if ! kill -0 "${runtime_pid}" >/dev/null 2>&1; then
break
fi
sleep 1
done
if kill -0 "${runtime_pid}" >/dev/null 2>&1; then
kill -KILL "${runtime_pid}" >/dev/null 2>&1 || true
fi
wait "${runtime_pid}" >/dev/null 2>&1 || true
fi
rm -rf "${staged_model}"
}
trap cleanup EXIT INT TERM
cp -a "${model_root}/." "${staged_model}/"

unset GH_TOKEN GITHUB_TOKEN HF_TOKEN HUGGING_FACE_HUB_TOKEN
unset OPENAI_API_KEY ANTHROPIC_API_KEY AZURE_API_KEY
export HEARTWOOD_PLATFORM=carina
export HEARTWOOD_AGENT_BACKEND=openhands-sdk
export HEARTWOOD_LOCAL_MODEL_PATH="${staged_model}"
export HEARTWOOD_LOCAL_MODEL_ALIAS="${model_id}"
export HEARTWOOD_VLLM_EXECUTABLE="${environment_root}/vllm/bin/vllm"
export PATH="${environment_root}/heartwood/bin:${PATH}"

bash "${repo_root}/images/gpu/start_vllm.sh" >"${state_root}/vllm-${SLURM_JOB_ID}.log" 2>&1 &
runtime_pid="$!"

python - <<'PY'
import json
import time
import urllib.request

deadline = time.time() + 300
while time.time() < deadline:
try:
with urllib.request.urlopen("http://127.0.0.1:8765/v1/models", timeout=2) as response:
if response.status == 200 and json.load(response).get("data"):
break
except OSError:
time.sleep(1)
else:
raise SystemExit("vLLM did not become ready within 300 seconds")
PY

workspace="${state_root}/sessions"
if [[ ! -f "${state_root}/setup.json" ]]; then
heartwood --workspace "${workspace}" setup \
--model-source local --model-id "${model_id}" --non-interactive --yes
fi
heartwood --workspace "${workspace}" --session-id carina-demo chat
exec "${heartwood}" \
--workspace "${state_root}/sessions" \
--session-id carina-demo \
launch \
--inside-allocation \
--environment-root "${environment_root}" \
--model-root "${model_root}" \
--state-root "${state_root}" \
--model-id "${model_id}"
61 changes: 2 additions & 59 deletions deploy/carina/verify_model_snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,67 +8,10 @@

from __future__ import annotations

import hashlib
import os
import re
import sys
from pathlib import Path, PurePosixPath
from pathlib import Path

_ENTRY = re.compile(r"^([0-9a-fA-F]{64}) [ *](.+)$")


def verify_snapshot(root: Path) -> None:
"""Reject unlisted, missing, linked, duplicated, or modified snapshot files."""
manifest = root / "SHA256SUMS"
if not root.is_dir() or not manifest.is_file() or manifest.is_symlink():
raise ValueError("model root must contain a regular SHA256SUMS manifest")
expected: dict[str, str] = {}
for line_number, line in enumerate(manifest.read_text(encoding="utf-8").splitlines(), 1):
match = _ENTRY.fullmatch(line)
if match is None:
raise ValueError(f"invalid SHA256SUMS entry on line {line_number}")
digest, name = match.groups()
manifest_relative = PurePosixPath(name)
if (
manifest_relative.is_absolute()
or ".." in manifest_relative.parts
or name in {"", "SHA256SUMS"}
):
raise ValueError(f"unsafe SHA256SUMS path on line {line_number}")
normalized = manifest_relative.as_posix()
if normalized in expected:
raise ValueError(f"duplicate SHA256SUMS path: {normalized}")
expected[normalized] = digest.lower()

actual: set[str] = set()
for path in root.rglob("*"):
snapshot_relative = path.relative_to(root).as_posix()
if path.is_symlink():
raise ValueError(f"model snapshot contains a symbolic link: {snapshot_relative}")
if path.is_file() and snapshot_relative != "SHA256SUMS":
actual.add(snapshot_relative)
if actual != set(expected):
missing = sorted(set(expected) - actual)
unlisted = sorted(actual - set(expected))
detail = "; ".join(
item
for item in (
f"missing: {', '.join(missing)}" if missing else "",
f"unlisted: {', '.join(unlisted)}" if unlisted else "",
)
if item
)
raise ValueError(f"model snapshot does not match SHA256SUMS coverage ({detail})")

for relative_name, expected_digest in expected.items():
hasher = hashlib.sha256()
descriptor = os.open(root / relative_name, os.O_RDONLY | os.O_NOFOLLOW)
with os.fdopen(descriptor, "rb") as file:
while chunk := file.read(1024 * 1024):
hasher.update(chunk)
digest = hasher.hexdigest()
if digest != expected_digest:
raise ValueError(f"SHA-256 mismatch: {relative_name}")
from heartwood.cli._model_snapshot import verify_snapshot


def main() -> int:
Expand Down
Loading