CI/CD with GitHub Actions
Auto-deploy on every push to main, smoke check, fail loud.
A production-ready GitHub Actions workflow that deploys a static site to VibeHost on every push to main, smoke-checks the URL, and posts the result back to the run.
Prerequisites
- A repo with a Vite / Astro / Hugo / whatever-emits-a-build-dir project.
- A PAT scoped to
apps:deploy+apps:read, restricted to the target app ID. See Personal access tokens. - The PAT stored as a repository / environment secret named
VIBEHOST_TOKEN.
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@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- uses: pnpm/action-setup@v4
- name: Install
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm build
- name: Install VibeHost CLI
run: curl -fsSL https://vibehost.com/install.sh | sh
- name: Deploy
id: deploy
env:
VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN }}
run: |
~/.vibehost/bin/vibehost deploy ./dist \
--app my-site \
--json | tee deploy.json
URL=$(jq -r '.data.url' deploy.json)
IMMUTABLE=$(jq -r '.data.immutableUrl' deploy.json)
echo "url=$URL" >> "$GITHUB_OUTPUT"
echo "immutable=$IMMUTABLE" >> "$GITHUB_OUTPUT"
- name: Smoke check
env:
URL: ${{ steps.deploy.outputs.url }}
run: |
# Retry up to 5 times — fresh deploys can take a few seconds to be globally routable.
for i in 1 2 3 4 5; do
CODE=$(curl -fsSL -o /dev/null -w "%{http_code}" "$URL" || echo "000")
if [ "$CODE" = "200" ]; then
echo "✓ $URL → $CODE"
exit 0
fi
echo "Attempt $i: $URL → $CODE (retrying in 5s)"
sleep 5
done
echo "✗ Smoke check failed: $URL never returned 200"
exit 1
- name: Summary
if: always()
env:
URL: ${{ steps.deploy.outputs.url }}
IMMUTABLE: ${{ steps.deploy.outputs.immutable }}
run: |
{
echo "## Deploy"
echo ""
echo "**Live:** $URL"
echo ""
echo "**Immutable:** $IMMUTABLE"
} >> "$GITHUB_STEP_SUMMARY"That's the whole thing. Push to main; workflow runs; URL ends up in the GitHub Actions summary.
What each step does
- Checkout + setup-node + setup-pnpm — standard.
- Install + Build — runs on the runner, same as locally. We never run the build on VibeHost.
- Install VibeHost CLI — the install script is idempotent and small (single static binary).
- Deploy — uses the PAT from
VIBEHOST_TOKEN. Parses the JSON response for the URLs. - Smoke check — retries 5 times because a fresh deploy can take a few seconds to propagate globally.
- Summary — writes the URL to the GitHub run summary so anyone viewing the run sees it immediately.
The concurrency block ensures two pushes don't race; the later one waits for the earlier deploy to finish.
Variations
Deploy each PR to a unique channel, comment the URL on the PR, clean up on close. 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/bin/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/bin/vibehost deploy ./dist --app my-siteTwo separate apps, two separate PATs, two separate GitHub environments. Avoids accidentally pushing to production from a develop branch.
Only build + deploy apps that actually 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.
Trigger on a release tag for "ship a specific version":
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 https://vibehost.com/install.sh | sh
- env:
VIBEHOST_TOKEN: ${{ secrets.VIBEHOST_TOKEN }}
run: |
~/.vibehost/bin/vibehost rollback --app my-site --jsonWorkflow dispatch button in GitHub UI → one-click rollback. Cheap insurance.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
UNAUTHENTICATED in deploy step | PAT not set / wrong workspace | Re-check secret name; PAT must be from the workspace containing 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 404 / 503 | Deploy too fresh; not yet routable | Workflow's retry loop handles this — bump retries if you see it consistently |
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 — including scope matrix
- PR previews — same shape, channel-aware
- Channels — for staging → promote workflow