Edit on val.town ↗

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.

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 push overwrites 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.

  1. Create the val and a local working copy:

    vt create my-val --private --no-editor-files --org-name me

    The --org-name me flag 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.

  2. Add your files. The trigger type comes from the filename when the file is first created: main.http.tsx becomes an HTTP trigger, plain main.tsx becomes a script. Renaming an existing file does not change its type — delete and recreate it instead.

  3. Create .vtignore in the root so your workflow files don't sync into the val:

    .github
    .gitignore

    vt ignores .git itself by default, so you don't need to list it.

  4. Run vt push once locally so the val matches the repo. Then git init -b main and commit everything, including .vt/state.json. That file tells CI which val and branch to push to. Its version and lastRun fields go stale immediately, which is harmless — vt diffs against live remote state.

  5. 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 push

    Pinning the CLI version (@0.1.56) avoids surprise behavior changes. The concurrency group keeps overlapping pushes from interleaving.

  6. Create the repo and push:

    gh repo create my-repo --private --source . --push
  7. Set the secret:

    gh secret set VAL_TOWN_API_KEY --repo you/my-repo

    The first push usually lands before the secret does, so the first run fails at the guard. Re-run it or push again.

  8. Verify: edit a file, push, get the run id from gh run list, and watch it with gh 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.

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.

  1. Create a Val Town API key at val.town/settings/api. The backup itself only reads the val.

  2. Create the repo that will hold the mirror and add the secret:

    gh secret set VAL_TOWN_API_KEY --repo you/my-backup-repo
  3. Add the workflow, editing the VAL env var to your handle/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
              fi
  4. Test 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-status

    The first run commits all val files.

  5. 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.

  6. Once it works, uncomment the schedule: block.

Notes on this recipe:

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:

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 push
name: 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 push

Caution

The -f on vt pull is not optional in CI. The moment the repo and the val diverge — which, in a sync setup, is exactly when a pull matters — plain vt pull needs to confirm before overwriting local state, and in a non-interactive shell it exits with an error instead. -f skips that confirmation.

How the pieces fit:

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