From 1aef11f89563655a61b5555910a0a53812cb6b8b Mon Sep 17 00:00:00 2001 From: Trent Stromkins Date: Wed, 5 Aug 2026 13:03:25 -0700 Subject: [PATCH] Add VPS deploy job to CI; canonical repo is now bmx269/smallrobot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy job rsyncs the built artifact to the Virtualmin host over SSH — on push to main, and via manual dispatch from any branch so `vue` can get a staging deploy before it merges. Builds stay in CI, never on the VPS, so the deploy only ever ships an artifact that already passed typecheck, tests and generate. Until the four DEPLOY_* secrets exist the job skips with a notice instead of failing, keeping CI green while hosting is being stood up. (Secrets cannot be read in a job-level `if`, so presence is exposed through an env var.) One trap defused: actions/upload-artifact v4.4+ excludes hidden files by default, which would have silently dropped .htaccess — the file the whole Apache setup depends on — from the artifact. include-hidden-files is now explicit, and the deploy verifies .htaccess landed on the server after rsync. Deploying as the Virtualmin domain user rather than root means files arrive owned correctly with no chown pass. rsync --delete keeps the docroot an exact mirror of the build; the known non-atomicity (a second of torn state mid-deploy) is documented in place along with the symlink-swap upgrade path. Repo migration: the smallrobotco org remote is removed and bmx269/smallrobot is now `origin`. Netlify watches the org repo, which is exactly why nothing should be pushed there during the migration — the fork's main is at the same commit (bf224ed), so nothing is lost. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 74 ++++++++++++++++++++++++++++++++++++++++ README.md | 30 ++++++++++++++-- 2 files changed, 102 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f12a042c..9b81c8f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,9 @@ on: push: branches: [main, vue] pull_request: + # Manual runs deploy whatever branch they are dispatched from — this is how a staging + # deploy of `vue` works before it merges to main. + workflow_dispatch: # Replaces .circleci/config.yml (Node 8) and .travis.yml (Node 6), both of which # had been dead for years. See docs/UPGRADE-PLAN.md. @@ -35,3 +38,74 @@ jobs: name: static-site path: .output/public retention-days: 7 + # Since v4.4 hidden files are excluded by default, which would silently drop + # .htaccess — the file the whole Apache setup depends on. The verify step in + # the deploy job would catch it, but better it never breaks. + include-hidden-files: true + + # --------------------------------------------------------------------------- + # Deploy to the Virtualmin VPS (docs/UPGRADE-PLAN.md §5a). + # + # Builds happen in CI, never on the VPS, so a broken build cannot take the site + # down — the deploy only ever ships an artifact that already passed typecheck, + # tests and generate. + # + # Runs on push to main and on manual dispatch (any branch, for staging). Until the + # repository has deploy secrets configured, the job short-circuits with a notice + # instead of failing, so CI stays green while hosting is being set up. + # + # Required repository secrets: + # DEPLOY_SSH_KEY private key for a dedicated deploy keypair; its public half + # goes in ~/.ssh/authorized_keys of the Virtualmin domain user + # DEPLOY_HOST the VPS hostname + # DEPLOY_USER the Virtualmin domain user (NOT root) — files land owned by + # the right user with no chown gymnastics + # DEPLOY_PATH document root, e.g. /home/smallrobot/public_html + # --------------------------------------------------------------------------- + deploy: + needs: build + if: github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref == 'refs/heads/main') + runs-on: ubuntu-latest + # `secrets` is not usable in a job-level `if`, so expose presence via env instead. + env: + DEPLOY_CONFIGURED: ${{ secrets.DEPLOY_SSH_KEY != '' && secrets.DEPLOY_HOST != '' }} + steps: + - name: Check deploy secrets + if: env.DEPLOY_CONFIGURED != 'true' + run: echo "::notice::Deploy secrets not configured — skipping deploy. Set DEPLOY_SSH_KEY, DEPLOY_HOST, DEPLOY_USER, DEPLOY_PATH to enable." + + - uses: actions/download-artifact@v4 + if: env.DEPLOY_CONFIGURED == 'true' + with: + name: static-site + path: site + + - name: Set up SSH + if: env.DEPLOY_CONFIGURED == 'true' + run: | + mkdir -p ~/.ssh + printf '%s\n' "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + # Fresh runner every time, so pin the host key on first contact. For a + # stricter setup, add a DEPLOY_KNOWN_HOSTS secret and write it here instead. + ssh-keyscan -H "${{ secrets.DEPLOY_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null + + - name: Rsync to VPS + if: env.DEPLOY_CONFIGURED == 'true' + run: | + # --delete keeps the docroot an exact mirror of the build: files removed from + # the site (old hashed bundles, deleted pages) disappear rather than rot. + # Not atomic — a visitor mid-deploy could see a torn state for a second or + # two. Acceptable here; the upgrade path is rsync to a timestamped dir and a + # symlink swap, which needs the vhost docroot pointed at the symlink first. + rsync -az --delete \ + -e "ssh -i ~/.ssh/deploy_key" \ + site/ "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}/" + + - name: Verify deployment + if: env.DEPLOY_CONFIGURED == 'true' + run: | + # The htaccess ships with the site; if it made it, everything did. + ssh -i ~/.ssh/deploy_key "${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}" \ + "test -f '${{ secrets.DEPLOY_PATH }}/.htaccess' && test -f '${{ secrets.DEPLOY_PATH }}/index.html'" + echo "::notice::Deployed to ${{ secrets.DEPLOY_HOST }}:${{ secrets.DEPLOY_PATH }}" diff --git a/README.md b/README.md index b5d45e5f..b0c465b5 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,34 @@ production (`npm run diff:prod`). cutover; see [docs/UPGRADE-PLAN.md](docs/UPGRADE-PLAN.md) §5a. 2. **A visual pass** — text parity is verified, layout is not. 3. **Delete `legacy/`** once 1 and 2 are settled. -4. **Hosting** — Virtualmin vhost, `.htaccess` SPA fallback, TLS, rsync deploy, and the - three `smallrobot.org` → `smallrobot.co` 301s. +4. **Hosting** — Virtualmin vhost + TLS + DNS. The Apache config (`public/.htaccess`, + tested against real httpd), the service-worker kill switch (`public/sw.js`), the + `smallrobot.org` 301s, and the CI deploy job are all in place; what remains is + server-side setup and repository secrets (see below). + +## Repository + +Canonical repo is `bmx269/smallrobot`. The old `smallrobotco/smallrobot` org repo is +retired — Netlify watches it, which is exactly why nothing gets pushed there during the +migration. + +## Deployment + +CI deploys `.output/public` to the VPS by rsync over SSH — on every push to `main`, and +manually via *Actions → CI → Run workflow* from any branch (that is how `vue` gets a +staging deploy before merging). Until the secrets below exist, the deploy job skips +itself with a notice rather than failing. + +| Secret | Value | +|---|---| +| `DEPLOY_SSH_KEY` | private half of a dedicated deploy keypair; public half goes in the domain user's `~/.ssh/authorized_keys` | +| `DEPLOY_HOST` | VPS hostname | +| `DEPLOY_USER` | the Virtualmin domain user (not root) | +| `DEPLOY_PATH` | document root, e.g. `/home/smallrobot/public_html` | + +Server prerequisites, one-time (see docs/UPGRADE-PLAN.md §5a): Virtualmin virtual +server for `smallrobot.co` with `smallrobot.org` as an alias, Let's Encrypt for both, +and `AllowOverride All` on the docroot so `.htaccess` is honoured. ## Requirements