Spaces:
Sleeping
Sync the HF Space from CI on every push to main
Browse filesBuilds the image on a runner, boots it, and asserts the three things that
matter β it exits nonzero without OPENCODE_SERVER_PASSWORD, answers
/api/health with basic auth, and serves the web UI on / β before pushing
anything. A Dockerfile that only breaks on the Space would otherwise leave it
parked in BUILD_ERROR with no signal here.
The push is forced because GitHub is the source of truth; a hand-edit in the
Space's web UI should not be able to block a deploy. The HF username is derived
from the token via whoami rather than stored, which also turns a bad token into
a clear error instead of an opaque git failure. Finally the job polls the Space
until it reports RUNNING so a red check means a broken deploy, not just a
failed push.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- .github/workflows/sync-to-hf-space.yml +130 -0
- README.md +20 -0
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face Space
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
push:
|
| 5 |
+
branches: [main]
|
| 6 |
+
workflow_dispatch:
|
| 7 |
+
|
| 8 |
+
# Never let two syncs race each other onto the same Space.
|
| 9 |
+
concurrency:
|
| 10 |
+
group: hf-space-sync
|
| 11 |
+
cancel-in-progress: false
|
| 12 |
+
|
| 13 |
+
jobs:
|
| 14 |
+
# Build and boot the image here first. A Dockerfile that fails on the Space
|
| 15 |
+
# leaves it sitting in BUILD_ERROR, so it is much cheaper to find out on a
|
| 16 |
+
# runner than in production.
|
| 17 |
+
verify:
|
| 18 |
+
runs-on: ubuntu-latest
|
| 19 |
+
steps:
|
| 20 |
+
- uses: actions/checkout@v4
|
| 21 |
+
|
| 22 |
+
- name: Build image
|
| 23 |
+
run: docker build -t opencode-cloud:ci .
|
| 24 |
+
|
| 25 |
+
- name: Refuses to start without a password
|
| 26 |
+
run: |
|
| 27 |
+
if docker run --rm opencode-cloud:ci; then
|
| 28 |
+
echo "::error::Container started without OPENCODE_SERVER_PASSWORD set"
|
| 29 |
+
exit 1
|
| 30 |
+
fi
|
| 31 |
+
echo "OK: container refused to start unauthenticated"
|
| 32 |
+
|
| 33 |
+
- name: Serves an authenticated API and the web UI
|
| 34 |
+
run: |
|
| 35 |
+
PW="ci-$RANDOM-$RANDOM"
|
| 36 |
+
docker run -d --name oc-ci -p 7860:7860 -e OPENCODE_SERVER_PASSWORD="$PW" opencode-cloud:ci
|
| 37 |
+
|
| 38 |
+
for i in $(seq 1 40); do
|
| 39 |
+
code=$(curl -s -m 3 -o /dev/null -w "%{http_code}" http://127.0.0.1:7860/api/health || true)
|
| 40 |
+
[ "$code" = "401" ] && break
|
| 41 |
+
sleep 3
|
| 42 |
+
done
|
| 43 |
+
|
| 44 |
+
echo "--- container log ---"
|
| 45 |
+
docker logs oc-ci
|
| 46 |
+
|
| 47 |
+
if [ "$code" != "401" ]; then
|
| 48 |
+
echo "::error::Server never came up (last status: ${code:-none})"
|
| 49 |
+
exit 1
|
| 50 |
+
fi
|
| 51 |
+
echo "OK: unauthenticated request rejected with 401"
|
| 52 |
+
|
| 53 |
+
body=$(curl -s -m 10 -u "opencode:$PW" http://127.0.0.1:7860/api/health)
|
| 54 |
+
if [ "$body" != '{"healthy":true}' ]; then
|
| 55 |
+
echo "::error::Authenticated health check returned: $body"
|
| 56 |
+
exit 1
|
| 57 |
+
fi
|
| 58 |
+
echo "OK: authenticated health check returned $body"
|
| 59 |
+
|
| 60 |
+
if ! curl -s -m 30 -u "opencode:$PW" http://127.0.0.1:7860/ | grep -q "<title>"; then
|
| 61 |
+
echo "::error::Web UI was not served on /"
|
| 62 |
+
exit 1
|
| 63 |
+
fi
|
| 64 |
+
echo "OK: web UI served on /"
|
| 65 |
+
|
| 66 |
+
- name: Stop container
|
| 67 |
+
if: always()
|
| 68 |
+
run: docker rm -f oc-ci || true
|
| 69 |
+
|
| 70 |
+
sync:
|
| 71 |
+
needs: verify
|
| 72 |
+
runs-on: ubuntu-latest
|
| 73 |
+
env:
|
| 74 |
+
# Defaults to this GitHub repo's owner/name, which is also the Space id.
|
| 75 |
+
# Override with a repo variable (or secret) named HF_SPACE.
|
| 76 |
+
HF_SPACE: ${{ vars.HF_SPACE || secrets.HF_SPACE || github.repository }}
|
| 77 |
+
steps:
|
| 78 |
+
- uses: actions/checkout@v4
|
| 79 |
+
with:
|
| 80 |
+
# Full history: the force-push below cannot be made from a shallow clone.
|
| 81 |
+
fetch-depth: 0
|
| 82 |
+
lfs: true
|
| 83 |
+
|
| 84 |
+
- name: Push to the Space
|
| 85 |
+
env:
|
| 86 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 87 |
+
run: |
|
| 88 |
+
if [ -z "$HF_TOKEN" ]; then
|
| 89 |
+
echo "::error::HF_TOKEN secret is not set (Settings -> Secrets and variables -> Actions)"
|
| 90 |
+
exit 1
|
| 91 |
+
fi
|
| 92 |
+
|
| 93 |
+
# Any HF username works alongside a token; derive it so there is one
|
| 94 |
+
# less secret to keep in sync, and so a bad token fails here with a
|
| 95 |
+
# clear message instead of a confusing git error.
|
| 96 |
+
user=$(curl -s -f -H "Authorization: Bearer $HF_TOKEN" \
|
| 97 |
+
https://huggingface.co/api/whoami-v2 | jq -r '.name // empty')
|
| 98 |
+
if [ -z "$user" ]; then
|
| 99 |
+
echo "::error::HF_TOKEN was rejected by huggingface.co (expired, or not a write token?)"
|
| 100 |
+
exit 1
|
| 101 |
+
fi
|
| 102 |
+
echo "Authenticated to Hugging Face as $user; syncing to spaces/$HF_SPACE"
|
| 103 |
+
|
| 104 |
+
# GitHub is the source of truth: force-push so a hand-edit made in the
|
| 105 |
+
# Space's web UI cannot block the sync. Anything committed only on the
|
| 106 |
+
# Space side is discarded.
|
| 107 |
+
git push --force "https://${user}:${HF_TOKEN}@huggingface.co/spaces/${HF_SPACE}" \
|
| 108 |
+
"HEAD:refs/heads/main" 2>&1 | sed "s/${HF_TOKEN}/***/g"
|
| 109 |
+
|
| 110 |
+
- name: Wait for the Space to come back up
|
| 111 |
+
env:
|
| 112 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 113 |
+
run: |
|
| 114 |
+
for i in $(seq 1 60); do
|
| 115 |
+
stage=$(curl -s -m 20 -H "Authorization: Bearer $HF_TOKEN" \
|
| 116 |
+
"https://huggingface.co/api/spaces/${HF_SPACE}" | jq -r '.runtime.stage // "UNKNOWN"')
|
| 117 |
+
echo "[$i] $stage"
|
| 118 |
+
case "$stage" in
|
| 119 |
+
RUNNING)
|
| 120 |
+
echo "Space is running."
|
| 121 |
+
exit 0
|
| 122 |
+
;;
|
| 123 |
+
BUILD_ERROR|RUNTIME_ERROR|CONFIG_ERROR)
|
| 124 |
+
echo "::error::Space ended in $stage β check the build logs at https://huggingface.co/spaces/${HF_SPACE}"
|
| 125 |
+
exit 1
|
| 126 |
+
;;
|
| 127 |
+
esac
|
| 128 |
+
sleep 15
|
| 129 |
+
done
|
| 130 |
+
echo "::warning::Space did not reach RUNNING within 15 minutes; last stage: $stage"
|
|
@@ -74,6 +74,26 @@ with mount path `/data` for persistence. Details and caveats: [docs/RAILWAY.md](
|
|
| 74 |
|
| 75 |
---
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
## Connecting the desktop app or a local web app
|
| 78 |
|
| 79 |
You don't have to use the in-browser UI. In the app, **Settings β Servers β Add**
|
|
|
|
| 74 |
|
| 75 |
---
|
| 76 |
|
| 77 |
+
## Keeping the Space in sync with GitHub
|
| 78 |
+
|
| 79 |
+
`.github/workflows/sync-to-hf-space.yml` builds the image, boots it, checks that
|
| 80 |
+
it refuses to start unauthenticated and that it serves an authenticated API and
|
| 81 |
+
the web UI β and only then force-pushes `main` to the Space and waits for it to
|
| 82 |
+
report `RUNNING`. A broken Dockerfile fails on the runner instead of leaving the
|
| 83 |
+
Space stuck in `BUILD_ERROR`.
|
| 84 |
+
|
| 85 |
+
Configure it under **Settings β Secrets and variables β Actions**:
|
| 86 |
+
|
| 87 |
+
| Name | Kind | Notes |
|
| 88 |
+
|---|---|---|
|
| 89 |
+
| `HF_TOKEN` | **secret** | A Hugging Face **write** token. The HF username is derived from it, so it is the only secret needed. |
|
| 90 |
+
| `HF_SPACE` | variable | `owner/space-name`. Optional β defaults to this GitHub repo's `owner/name`. A public Space id isn't sensitive, so a variable keeps it readable in logs; a secret of the same name also works. |
|
| 91 |
+
|
| 92 |
+
GitHub is the source of truth: the sync **force-pushes**, so a commit made only
|
| 93 |
+
in the Space's web UI will be discarded. Edit here, not there.
|
| 94 |
+
|
| 95 |
+
---
|
| 96 |
+
|
| 97 |
## Connecting the desktop app or a local web app
|
| 98 |
|
| 99 |
You don't have to use the in-browser UI. In the app, **Settings β Servers β Add**
|