CI/CD with GitHub Actions
Deploy on every push to main, confirm the live URL serves the new build, and fail the job when it doesn't.
This GitHub Actions workflow deploys a static site to VibeHost on every push to main, checks that the live URL serves the build it just deployed, and posts the result back to the run.
Prerequisites
- A repo with a Vite, Astro, Hugo, or any other project that emits a build directory.
- A PAT scoped to
apps:deployandapps:read, restricted to the target app ID. See Personal access tokens. - The PAT stored as a repository / environment secret named
VIBEHOST_TOKEN. - For the smoke check: an app set to
publicwith no password. Apps areprivateby default, and a gated app answers an anonymous request with a redirect to sign in, not with your site. See Gated apps.
The workflow
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-main
cancel-in-progress: false # don't cancel a deploy mid-upload
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- uses: pnpm/action-setup@v6
- name: Install
run: pnpm install --frozen-lockfile
- name: Build
run: |
pnpm build
# Stamp the build so the smoke check can tell it apart from the previous one.
echo "$GITHUB_SHA" > dist/build-id.txt
- name: Install VibeHost CLI
run: curl -fsSL -o vibehost-install.sh https://vibehost.com/install.sh && sh vibehost-install.sh && rm vibehost-install.sh
- name: Deploy
id: deploy
env:
VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN }}
run: |
~/.vibehost/cli/vibehost deploy ./dist --app my-site --json > deploy.json
# Fail unless the platform reports this deployment healthy.
jq -e '.data.status == "healthy"' deploy.json > /dev/null || { cat deploy.json; exit 1; }
echo "url=$(jq -r '.data.url' deploy.json)" >> "$GITHUB_OUTPUT"
echo "id=$(jq -r '.data.id' deploy.json)" >> "$GITHUB_OUTPUT"
- name: Smoke check
env:
URL: ${{ steps.deploy.outputs.url }}
run: |
# After a redeploy the URL can keep serving the previous build for up to about a minute.
DEADLINE=$((SECONDS + 120))
i=0
while [ "$SECONDS" -lt "$DEADLINE" ]; do
i=$((i + 1))
BUILD=$(curl -sSL --max-time 5 "$URL/build-id.txt?run=$GITHUB_RUN_ID-$i" || true)
if [ "$BUILD" = "$GITHUB_SHA" ]; then
echo "✓ $URL serves $GITHUB_SHA"
exit 0
fi
echo "Attempt $i: $URL/build-id.txt → ${BUILD:0:40} (retrying in 5s)"
sleep 5
done
echo "✗ Smoke check failed: $URL never served build $GITHUB_SHA"
exit 1
- name: Summary
if: always()
env:
URL: ${{ steps.deploy.outputs.url }}
DEPLOYMENT: ${{ steps.deploy.outputs.id }}
run: |
{
echo "## Deploy"
echo ""
echo "**Live:** $URL"
echo ""
echo "**Deployment:** $DEPLOYMENT"
} >> "$GITHUB_STEP_SUMMARY"Push to main, and the workflow runs and puts the URL in the GitHub Actions summary.
What each step does
- Checkout, setup-node, and setup-pnpm are the standard setup steps.
- Install and Build run on the runner, the same as locally. VibeHost doesn't build a static app. The build also writes the commit SHA to
build-id.txt, a file that differs on every build, so the smoke check can recognize this one. It is published with the site. - Install VibeHost CLI runs an install script that is idempotent and small, since the CLI is a single static binary.
- Deploy uses the PAT from
VIBEHOST_TOKEN. A failed deploy exits non-zero and prints its error, which fails the step. Otherwise the step checks that the deployment'sstatusishealthy. It writes the JSON to a file instead of piping it intotee: arunstep withoutshell: bashhas nopipefail, so a failed deploy piped intoteewould still pass. - Smoke check fetches
build-id.txtfrom the live URL until it returns this commit's SHA. A redeploy ishealthybefore the URL switches over: the previous build can keep answering for up to about a minute, and it answers200too, so a status code alone would pass on the old build. The loop gives up after two minutes. It follows redirects, so an app with a custom domain works too, and the query string keeps any cache from answering for the old file. - Summary writes the URL and the deployment ID to the GitHub run summary, so anyone viewing the run sees them.
The concurrency block keeps two pushes from racing. The later one waits for the earlier deploy to finish.
Single-page apps
Rewrite rules run before VibeHost looks for a file, so a catch-all rewrite to /index.html answers /build-id.txt with your index.html too, and the smoke check never sees the SHA. List an exact rule for build-id.txt ahead of the catch-all in vibehost.json:
{
"rewrites": [
{ "source": "/build-id.txt", "destination": "/build-id.txt" },
{ "source": "/(.*)", "destination": "/index.html" }
]
}Gated apps
The smoke check needs an app anyone can open. A private or workspace app, or one with a password, answers the anonymous curl with a redirect to the sign-in or password page. Following that redirect ends on a page that returns 200, so a check on the status code alone passes whether or not your build is live. The build-id.txt comparison fails there instead, because the sign-in page does not contain your SHA.
For a gated app, remove the Smoke check step. The Deploy step's status: "healthy" then confirms that the deployment succeeded, not that the URL already serves it: after a redeploy the previous build can keep answering for up to about a minute. Seeing the new build live takes a request that clears the app's gate, such as opening the URL signed in. Don't roll back because an anonymous request was turned away.
Variations
Deploy each PR to its own channel, comment the URL on the PR, and clean up when it closes. See the full recipe at PR previews.
Separate production and staging apps, each with its own PAT:
jobs:
deploy-staging:
if: github.ref == 'refs/heads/develop'
environment: staging
steps:
- # ... build ...
- env:
VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN_STAGING }}
run: ~/.vibehost/cli/vibehost deploy ./dist --app my-site-staging
deploy-production:
if: github.ref == 'refs/heads/main'
environment: production
needs: deploy-staging
steps:
- # ... build ...
- env:
VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN_PROD }}
run: ~/.vibehost/cli/vibehost deploy ./dist --app my-siteEach environment has its own app, PAT, and GitHub environment, which keeps a push to the develop branch from reaching production by accident.
Build and deploy only the apps that changed:
jobs:
changes:
runs-on: ubuntu-latest
outputs:
docs: ${{ steps.filter.outputs.docs }}
web: ${{ steps.filter.outputs.web }}
steps:
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
docs: ['apps/docs/**']
web: ['apps/web/**']
deploy-docs:
needs: changes
if: ${{ needs.changes.outputs.docs == 'true' }}
# ... build + deploy apps/docs ...
deploy-web:
needs: changes
if: ${{ needs.changes.outputs.web == 'true' }}
# ... build + deploy apps/web ...See the full monorepo recipe.
To ship a specific version, trigger on a release tag:
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
ref:
description: 'Tag or commit to deploy'
required: trueThe workflow_dispatch path lets a maintainer roll back to an older tag without touching git.
Rolling back from CI
name: Rollback
on:
workflow_dispatch:
jobs:
rollback:
runs-on: ubuntu-latest
environment: production
steps:
- run: curl -fsSL -o vibehost-install.sh https://vibehost.com/install.sh && sh vibehost-install.sh && rm vibehost-install.sh
- env:
VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN }}
run: |
~/.vibehost/cli/vibehost rollback --app my-site --jsonWith this workflow in place, the Run workflow button in the GitHub Actions UI rolls the app back.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
UNAUTHENTICATED in deploy step | PAT not set, or from the wrong workspace | Check the secret name. The PAT must come from the workspace that contains the app |
TOKEN_WORKSPACE_MISMATCH | PAT minted in workspace A, target app in workspace B | Re-mint the PAT in the correct workspace |
| Smoke check prints the previous SHA | The URL still serves the previous build | The loop waits up to two minutes for the switch. Raise the 120 in DEADLINE if you see it run out |
Smoke check prints your own index.html | A catch-all rewrite to /index.html also answers /build-id.txt | List an exact /build-id.txt rule before it. See Single-page apps |
| Smoke check prints sign-in page HTML | The app is private, workspace, or password-protected | Make the app public, or drop the smoke check for gated apps. See Gated apps |
Smoke check 404 on build-id.txt | The build step didn't write the file into the deployed directory | Write build-id.txt into the same directory you pass to vibehost deploy |
RATE_LIMITED after many runs | Workspace deploy quota hit (~30/min) | Reduce CI fanout, or upgrade plan |
| Build hangs / OOMs on the runner | Disk / memory limit on free runner | Use runs-on: ubuntu-latest-large or split the build step |
See also
- Personal access tokens includes the scope matrix.
- PR previews uses the same workflow with a channel per PR.
- Channels covers deploying to staging and then promoting.