GitHub Actions + vt CLI
This page uses GitHub Actions to run the vt CLI for
you: vt push to deploy a repo to a val, and vt pull to back a val up to a
repo. The first two recipes are independent and one-directional — pick the one
where you edit; the two-way section combines them.
Both directions authenticate the same way: the vt CLI reads a Val Town API
key from the VAL_TOWN_API_KEY environment variable, which you store as a
GitHub Actions secret. Create the key at
val.town/settings/api with val
read+write scope. If the secret is missing, vt exits with a clear error.
GitHub as the source of truth
You edit in the repo; the val follows. Every push to main runs vt push,
which makes the val mirror the repo.
Caution
This sync is one-way.
vt pushoverwrites web editor edits to existing files and deletes files that exist only on the Val Town side, with no prompt. If you also edit in the web editor, see Two-way sync.
Setup
Create the val and a local working copy:
vt create my-val --private --no-editor-files --org-name meThe
--org-name meflag targets your personal account. Without it, vt shows an interactive org picker if you belong to any orgs, and exits with an error in a non-interactive shell.Add your files. The trigger type comes from the filename when the file is first created:
main.http.tsxbecomes an HTTP trigger, plainmain.tsxbecomes a script. Renaming an existing file does not change its type — delete and recreate it instead.Create
.vtignorein the root so your workflow files don't sync into the val:.github .gitignorevt ignores
.gititself by default, so you don't need to list it.Run
vt pushonce locally so the val matches the repo. Thengit init -b mainand commit everything, including.vt/state.json. That file tells CI which val and branch to push to. ItsversionandlastRunfields go stale immediately, which is harmless — vt diffs against live remote state.Add the workflow:
name: Deploy to Val Town on: push: branches: [main] workflow_dispatch: concurrency: group: deploy-to-val-town cancel-in-progress: false jobs: deploy: runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v5 - uses: denoland/setup-deno@v2 with: deno-version: v2.x - name: Install vt CLI run: deno install -gArf jsr:@valtown/vt@0.1.56 - name: Push to Val Town env: VAL_TOWN_API_KEY: ${{ secrets.VAL_TOWN_API_KEY }} run: vt pushPinning the CLI version (
@0.1.56) avoids surprise behavior changes. Theconcurrencygroup keeps overlapping pushes from interleaving.Create the repo and push:
gh repo create my-repo --private --source . --pushSet the secret:
gh secret set VAL_TOWN_API_KEY --repo you/my-repoThe first push usually lands before the secret does, so the first run fails at the guard. Re-run it or push again.
Verify: edit a file, push, get the run id from
gh run list, and watch it withgh run watch <run-id> --exit-status. Then check the val itself:curl -H "Authorization: Bearer $VAL_TOWN_API_KEY" \ "https://api.val.town/v2/vals/VAL_ID/files/content?path=main.http.tsx"The val id is in
.vt/state.json. The val.run endpoint serves the new code immediately.
Val Town as the source of truth
You edit the val; the repo follows. A workflow clones the val with vt clone,
copies the files into the repo, and commits only when something changed. The
design is stateless — no vt state lives in the repo, so there is nothing to
keep in sync.
Setup
Create a Val Town API key at val.town/settings/api. The backup itself only reads the val.
Create the repo that will hold the mirror and add the secret:
gh secret set VAL_TOWN_API_KEY --repo you/my-backup-repoAdd the workflow, editing the
VALenv var to yourhandle/valName:name: Backup val from Val Town on: workflow_dispatch: # For a real backup, run on a schedule too, e.g. every 6 hours: # schedule: # - cron: "0 */6 * * *" permissions: contents: write env: VAL: stevekrouse/my-val # <- your handle/valName jobs: backup: runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v5 - uses: denoland/setup-deno@v2 with: deno-version: v2.x - name: Install the vt CLI run: deno install -gArf jsr:@valtown/vt@0.1.56 - name: Clone the val env: VAL_TOWN_API_KEY: ${{ secrets.VAL_TOWN_API_KEY }} run: vt clone "$VAL" "$RUNNER_TEMP/val" --no-editor-files - name: Copy val files into the repo run: | rsync -a --delete \ --exclude='.git' \ --exclude='.github' \ --exclude='.vt' \ "$RUNNER_TEMP/val/" ./ - name: Commit and push if anything changed run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add -A if git diff --cached --quiet; then echo "Val unchanged; nothing to commit." else git commit -m "Backup $VAL" git push fiTest with a manual run:
gh workflow run backup.yml --repo you/my-backup-repo gh run list --repo you/my-backup-repo --limit 1 # get the run id gh run watch <run-id> --exit-statusThe first run commits all val files.
Verify the loop: edit the val, dispatch the workflow, and confirm a commit lands with exactly your edit. Dispatch again without changes and the log says
Val unchanged; nothing to commit.with no new commit.Once it works, uncomment the
schedule:block.
Notes on this recipe:
rsync --deletemakes the repo an exact mirror of the val. Any repo-owned files at the root (README, LICENSE) get deleted on the next run — keep them inside.github/, which is excluded. The flip side: if your val contains a.github/directory, it will not be mirrored.permissions: contents: writeis required, or the push gets a 403. The explicitgit configlines are also required, or the commit fails with "Author identity unknown". The41898282+...email is what makes GitHub render the bot avatar.vt clonealways creates.vt/state.json, and itslastRunfield changes on every invocation. The rsync--exclude='.vt'keeps it out of the repo; without that, every run would produce a junk commit.- GitHub disables scheduled workflows after 60 days of repo inactivity, which
can happen to a backup repo that only changes when the val changes. Keep
the
workflow_dispatchtrigger for manual runs and re-enables.
Two-way sync
Run both workflows against the same repo and val: the deploy workflow above
plus a pull workflow on a cron that runs vt pull -f and commits val-side
edits back. This works, and the loop provably settles: pushing identical
content does not create a new val version, pulling identical content does not
create a commit, and the pull job's commits never retrigger the deploy
workflow.
Treat it as "GitHub is the source of truth, plus a courtesy capture of web editor edits" — not as symmetric multi-master sync:
- Conflicts are resolved by file-level overwrite: the last sync wins, with no merge and no warning. Nothing is lost outright — the losing edit survives in val version history or git history — but it does get overwritten.
- A web editor edit made after the last pull run and before the next git push is silently clobbered by that push's deploy. A tighter pull schedule shrinks this window but never closes it.
- For teams editing in both places at high frequency, use branches instead.
Use this deploy workflow instead of the one above. It is the same except for
the shared concurrency group and a loop guard:
name: Deploy to Val Town
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: vt-sync
cancel-in-progress: false
jobs:
push-to-val:
runs-on: ubuntu-latest
timeout-minutes: 5
# Belt-and-suspenders loop guard: skip if this push IS a sync-back commit.
# (Normally unnecessary: pull.yml pushes with the default GITHUB_TOKEN,
# which never triggers other workflows. This guard matters only if someone
# swaps in a PAT.)
if: ${{ !contains(github.event.head_commit.message, '[vt-sync]') }}
steps:
- uses: actions/checkout@v5
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Install vt
run: deno install -gArf jsr:@valtown/vt@0.1.56
- name: Push to Val Town
env:
VAL_TOWN_API_KEY: ${{ secrets.VAL_TOWN_API_KEY }}
run: vt pushname: Pull from Val Town
on:
workflow_dispatch: {}
# In real use, uncomment to poll the val for web-editor changes:
# schedule:
# - cron: "*/30 * * * *"
concurrency:
group: vt-sync
cancel-in-progress: false
permissions:
contents: write
jobs:
pull-from-val:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v5
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: Install vt
run: deno install -gArf jsr:@valtown/vt@0.1.56
- name: Pull from Val Town
env:
VAL_TOWN_API_KEY: ${{ secrets.VAL_TOWN_API_KEY }}
run: vt pull -f
- name: Commit val-side changes back to the repo
run: |
# vt rewrites .vt/state.json (lastRun pid/time, branch version) on
# every invocation. Committing that noise would make every pull
# produce a commit, so discard it before diffing.
git checkout -- .vt/state.json
git add -A
if git diff --cached --quiet; then
echo "Val matches repo; nothing to commit."
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git commit -m "[vt-sync] Pull changes from Val Town"
git pushCaution
The
-fonvt pullis not optional in CI. The moment the repo and the val diverge — which, in a sync setup, is exactly when a pull matters — plainvt pullneeds to confirm before overwriting local state, and in a non-interactive shell it exits with an error instead.-fskips that confirmation.
How the pieces fit:
- The
git checkout -- .vt/state.jsonline is what makes redundant pulls converge. It looks deletable; it is not. - The same
.vtignorefrom the deploy setup protects both directions: because ignored paths are invisible to vt,.githubin.vtignoreis what stopsvt pull -ffrom deleting your workflow files out of the checkout. - The pull job pushes with the default
GITHUB_TOKEN, and pushes made with that token never triggeron: pushworkflows — that is the primary loop breaker. The[vt-sync]commit-message guard only matters if you swap in a PAT. - Even with no guards, the loop self-extinguishes: pushing identical content does not change the val's version, and a redundant pull finds an empty diff and skips committing.
workflow_dispatchon the deploy workflow doubles as a manual force-deploy (the guard passes because there is no head commit on a dispatch).
To verify the full cycle: push a file edit and confirm the val updates; edit
the val in the web editor and dispatch pull.yml, confirming a [vt-sync]
commit lands with exactly that diff; then dispatch both again and confirm no
new val version and no new commit.
Last updated