ctx / .github /workflows /huggingface-sync.yml
Stevesolun's picture
Sync ctx 0d3237d
29c07f3 verified
Raw
History Blame Contribute Delete
8.55 kB
name: Sync Hugging Face
on:
push:
branches:
- main
workflow_dispatch:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
HF_TOKEN: ${{ secrets.HF_TOKEN }}
permissions:
contents: read
concurrency:
group: "huggingface-sync-main"
cancel-in-progress: true
jobs:
sync:
name: Sync model repo
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
lfs: false
- name: Skip when HF_TOKEN is not configured
if: ${{ env.HF_TOKEN == '' }}
run: |
echo "::notice title=Hugging Face sync skipped::Set the HF_TOKEN repository secret to publish Stevesolun/ctx automatically."
- name: Classify sync scope
id: scope
if: ${{ env.HF_TOKEN != '' }}
shell: bash
env:
BEFORE_SHA: ${{ github.event.before }}
EVENT_NAME: ${{ github.event_name }}
HEAD_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
echo "sync_mode=full" >> "$GITHUB_OUTPUT"
exit 0
fi
BASE="$BEFORE_SHA"
if [[ -z "$BASE" || "$BASE" =~ ^0+$ ]] || ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
BASE="$(git rev-parse "$HEAD_SHA^")"
fi
git diff --name-only "$BASE" "$HEAD_SHA" > changed-files.txt
python - <<'PY' >> "$GITHUB_OUTPUT"
from pathlib import Path
files = [
line.strip().replace("\\", "/")
for line in Path("changed-files.txt").read_text(encoding="utf-8").splitlines()
if line.strip()
]
card_only_prefixes = (".github/", "docs/", "src/tests/")
card_only_files = {"README.md", "CHANGELOG.md"}
card_only = bool(files) and all(
path in card_only_files or path.startswith(card_only_prefixes)
for path in files
)
print(f"sync_mode={'card' if card_only else 'full'}")
PY
- name: Hydrate required graph artifacts from release assets
if: ${{ env.HF_TOKEN != '' && steps.scope.outputs.sync_mode == 'full' }}
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
echo "Resolving graph artifacts from matching release assets to avoid Git LFS bandwidth."
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import subprocess
import time
import urllib.request
repo = os.environ["GITHUB_REPOSITORY"]
release_asset_wait_seconds = 300
release_asset_poll_seconds = 10
expected_graph_assets = {
"graph/wiki-graph.tar.gz": {
"sha256": "d051d4f21208abe3e73975a9c30650150138d88919c27979d725be936a6ea10a",
"size": 296113770,
},
"graph/wiki-graph-runtime.tar.gz": {
"sha256": "993fc08377fdb09edcff4414c59b10fc121189b4a161bf796e3f8f6600907bb1",
"size": 122141091,
},
"graph/skills-sh-catalog.json.gz": {
"sha256": "d1bb927e93d65ef86cdbc6d68c7f57511d64c8f13026e8193287a9c4ca6f5815",
"size": 10763626,
},
}
def load_releases() -> list[dict]:
return json.loads(subprocess.check_output(
["gh", "api", f"repos/{repo}/releases?per_page=50"],
text=True,
))
def hydrate_from_release(path_name: str, hydrated_min_size: int) -> None:
artifact = Path(path_name)
fallback = expected_graph_assets[path_name]
expected_oid = fallback["sha256"]
expected_size = int(fallback["size"])
if artifact.exists():
pointer = artifact.read_text(encoding="utf-8", errors="replace")
for line in pointer.splitlines():
if line.startswith("oid sha256:"):
expected_oid = line.split(":", 1)[1].strip()
elif line.startswith("size "):
expected_size = int(line.split(" ", 1)[1].strip())
if not pointer.startswith("version https://git-lfs.github.com/spec/v1") and artifact.stat().st_size > hydrated_min_size:
print(f"{artifact} is already hydrated")
return
deadline = time.monotonic() + release_asset_wait_seconds
while True:
candidates = []
for release in load_releases():
tag_name = str(release.get("tag_name") or "")
is_graph_cache = tag_name.startswith("graph-artifacts-")
if release.get("draft") or (
release.get("prerelease") and not is_graph_cache
):
continue
for asset in release.get("assets", []):
if asset.get("name") != artifact.name:
continue
digest = str(asset.get("digest") or "")
size = int(asset.get("size") or 0)
if size != expected_size:
continue
if digest and digest != f"sha256:{expected_oid}":
continue
candidates.append((tag_name, asset))
if candidates:
break
if time.monotonic() >= deadline:
raise SystemExit(
f"No release asset matches {path_name} "
f"sha256:{expected_oid} size:{expected_size}"
)
print(
f"Waiting for matching release asset {artifact.name} "
f"sha256:{expected_oid} size:{expected_size}"
)
time.sleep(release_asset_poll_seconds)
source_tag, asset = candidates[0]
tmp = artifact.with_name(f"{artifact.name}.download")
sha = hashlib.sha256()
total = 0
with urllib.request.urlopen(asset["browser_download_url"], timeout=300) as resp: # noqa: S310
with tmp.open("wb") as fh:
while True:
chunk = resp.read(1024 * 1024)
if not chunk:
break
sha.update(chunk)
total += len(chunk)
fh.write(chunk)
actual_oid = sha.hexdigest()
if actual_oid != expected_oid or total != expected_size:
tmp.unlink(missing_ok=True)
raise SystemExit(
f"Downloaded {path_name} does not match expected artifact: "
f"sha256:{actual_oid} size:{total}"
)
tmp.replace(artifact)
print(
f"Hydrated {path_name} from {source_tag} release asset "
f"sha256:{actual_oid} size:{total}"
)
hydrate_from_release("graph/wiki-graph.tar.gz", 100_000_000)
hydrate_from_release("graph/wiki-graph-runtime.tar.gz", 10_000_000)
hydrate_from_release("graph/skills-sh-catalog.json.gz", 1_000_000)
PY
- name: Set up Python
if: ${{ env.HF_TOKEN != '' }}
uses: actions/setup-python@v6
with:
python-version: "3.11"
cache: "pip"
cache-dependency-path: pyproject.toml
- name: Install sync dependencies
if: ${{ env.HF_TOKEN != '' }}
run: |
python -m pip install --upgrade pip
python -m pip install -e ".[dev]" huggingface_hub
- name: Sync Hugging Face model repo
if: ${{ env.HF_TOKEN != '' }}
env:
SYNC_MODE: ${{ steps.scope.outputs.sync_mode }}
run: |
if [[ "$SYNC_MODE" == "card" ]]; then
python scripts/sync_huggingface.py --repo . --repo-id Stevesolun/ctx --repo-type model --card-only
else
python scripts/sync_huggingface.py --repo . --repo-id Stevesolun/ctx --repo-type model
fi