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
Section titled “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.
-
Create the val and a local working copy:
Terminal window 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:.vtignore .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:
.github/workflows/deploy.yml name: Deploy to Val Townon:push:branches: [main]workflow_dispatch:concurrency:group: deploy-to-val-towncancel-in-progress: falsejobs:deploy:runs-on: ubuntu-latesttimeout-minutes: 5steps:- uses: actions/checkout@v5- uses: denoland/setup-deno@v2with:deno-version: v2.x- name: Install vt CLIrun: deno install -gArf jsr:@valtown/vt@0.1.56- name: Push to Val Townenv: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:
Terminal window gh repo create my-repo --private --source . --push -
Set the secret:
Terminal window 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:Terminal window 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
Section titled “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.
-
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:
Terminal window gh secret set VAL_TOWN_API_KEY --repo you/my-backup-repo -
Add the workflow, editing the
VALenv var to yourhandle/valName:.github/workflows/backup.yml name: Backup val from Val Townon:workflow_dispatch:# For a real backup, run on a schedule too, e.g. every 6 hours:# schedule:# - cron: "0 */6 * * *"permissions:contents: writeenv:VAL: stevekrouse/my-val # <- your handle/valNamejobs:backup:runs-on: ubuntu-latesttimeout-minutes: 10steps:- uses: actions/checkout@v5- uses: denoland/setup-deno@v2with:deno-version: v2.x- name: Install the vt CLIrun: deno install -gArf jsr:@valtown/vt@0.1.56- name: Clone the valenv: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 reporun: |rsync -a --delete \--exclude='.git' \--exclude='.github' \--exclude='.vt' \"$RUNNER_TEMP/val/" ./- name: Commit and push if anything changedrun: |git config user.name "github-actions[bot]"git config user.email "41898282+github-actions[bot]@users.noreply.github.com"git add -Aif git diff --cached --quiet; thenecho "Val unchanged; nothing to commit."elsegit commit -m "Backup $VAL"git pushfi -
Test with a manual run:
Terminal window gh workflow run backup.yml --repo you/my-backup-repogh run list --repo you/my-backup-repo --limit 1 # get the run idgh 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
Section titled “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 pushHow 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.