From 01d86f952ee8df4da91bf07533bfb734a7511841 Mon Sep 17 00:00:00 2001 From: Thor Tveito Date: Thu, 15 Oct 2026 10:32:23 +0200 Subject: [PATCH] Feat: Deploy to the VM after every merge to main After the checks pass on a push to main, the CI job copies the built frontend to /var/www/html/project2, installs the built backend with its production dependencies in /srv/ufound-it and restarts the ufound-it-backend systemd service. It then calls the health query through Apache and fails if the backend does not answer. The backend runs outside the runner's work folder because the next CI run wipes it. Runs on main are no longer cancelled, so a deploy is not stopped halfway. Pull requests still stop after the checks. The service, sudoers rule and Apache proxy are in deploy/, and the one-time VM setup is documented in docs/ci-cd.md. AGENTS.md now has the VM address and how deploying works. Closes #68 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 64 +++++++++++++++++++++++- AGENTS.md | 4 +- deploy/apache-ufound-it.conf | 5 ++ deploy/sudoers-ufound-it | 3 ++ deploy/ufound-it-backend.service | 21 ++++++++ docs/ci-cd.md | 86 +++++++++++++++++++++++++++++--- 6 files changed, 173 insertions(+), 10 deletions(-) create mode 100644 deploy/apache-ufound-it.conf create mode 100644 deploy/sudoers-ufound-it create mode 100644 deploy/ufound-it-backend.service diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f92dcf8..58ad237 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,25 @@ on: push: branches: [main] -# Cancel outdated runs on the same branch to save runner time and energy. +# Cancel outdated pull request runs to save runner time and energy. Runs on +# main are never cancelled, so a deploy is not stopped halfway; a newer +# merge waits for the running one instead. concurrency: group: ci-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # Apache serves this folder at /project2 (see docs/ci-cd.md). + WEB_DIR: /var/www/html/project2 + # The backend runs from here, outside the runner's work folder, which is + # wiped by the next checkout. + APP_DIR: /srv/ufound-it + SERVICE: ufound-it-backend jobs: + # Deploying is part of this job, not a separate one, because a new job + # starts with a fresh checkout and would have to build again. The job + # keeps its name since branch protection requires a check named "checks". checks: runs-on: self-hosted steps: @@ -29,3 +42,50 @@ jobs: - run: npm run format:check - run: npm test - run: npm run build + + # Only pushes to main deploy. Pull requests stop after the checks. + - name: Deploy frontend + if: github.event_name == 'push' + run: | + find "$WEB_DIR" -mindepth 1 -delete + cp -r frontend/dist/. "$WEB_DIR"/ + + - name: Deploy backend + if: github.event_name == 'push' + run: | + # npm ci needs the workspace layout, so both package.json files are + # copied even though only the backend runs here. backend/.env is + # created once by hand on the VM and is left alone. + mkdir -p "$APP_DIR/backend" "$APP_DIR/frontend" + cp package.json package-lock.json "$APP_DIR"/ + cp backend/package.json "$APP_DIR/backend/" + cp frontend/package.json "$APP_DIR/frontend/" + rm -rf "$APP_DIR/backend/dist" + cp -r backend/dist "$APP_DIR/backend/" + (cd "$APP_DIR" && npm ci --omit=dev --workspace backend) + + # Node is only installed in the runner's tool cache, so the service + # gets its own copy of the same version CI tested with. Renaming + # over the old binary works while the service is still running it. + cp "$(command -v node)" "$APP_DIR/node.new" + mv -f "$APP_DIR/node.new" "$APP_DIR/node" + + sudo -n systemctl restart "$SERVICE" + + - name: Check the deployed site + if: github.event_name == 'push' + run: | + # The backend needs a moment to start, so retry before failing. + for attempt in 1 2 3 4 5 6 7 8 9 10; do + if curl -fsS http://localhost/project2/graphql \ + -H 'content-type: application/json' \ + --data '{"query":"{ health }"}' | grep -q '"ok"'; then + curl -fsS -o /dev/null http://localhost/project2/ + echo "Deployed and responding." + exit 0 + fi + sleep 2 + done + echo "The backend did not answer through Apache." >&2 + sudo -n systemctl status "$SERVICE" --no-pager || true + exit 1 diff --git a/AGENTS.md b/AGENTS.md index c3e01a7..6f61439 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,10 +131,10 @@ Never: ## Deployment -- **VM address:** TODO (`http://it2810-XX.idi.ntnu.no/project2`) +- **VM address:** http://it2810-25.idi.ntnu.no/project2/ - **Backend port:** 3001 - **GraphQL endpoint:** The frontend calls `/project2/graphql` on its own origin. Apache must proxy it to `http://localhost:3001/graphql`, like the Vite dev proxy does locally. `VITE_GRAPHQL_URL` overrides the URL at build time if needed. -- **How to deploy:** TODO +- **How to deploy:** Automatic. CI deploys every push to `main` that passes the checks. The frontend goes to `/var/www/html/project2`, the backend runs from `/srv/ufound-it` as the systemd service `ufound-it-backend`. One-time VM setup is in `docs/ci-cd.md`. ## AI usage documentation diff --git a/deploy/apache-ufound-it.conf b/deploy/apache-ufound-it.conf new file mode 100644 index 0000000..cf07d17 --- /dev/null +++ b/deploy/apache-ufound-it.conf @@ -0,0 +1,5 @@ +# Forwards the frontend's GraphQL calls to the backend, like the Vite dev +# proxy does locally. The frontend itself is served as plain files from +# /var/www/html/project2. Installed once by hand, see docs/ci-cd.md. +ProxyPass /project2/graphql http://localhost:3001/graphql +ProxyPassReverse /project2/graphql http://localhost:3001/graphql diff --git a/deploy/sudoers-ufound-it b/deploy/sudoers-ufound-it new file mode 100644 index 0000000..efd3441 --- /dev/null +++ b/deploy/sudoers-ufound-it @@ -0,0 +1,3 @@ +# Lets the GitHub Actions runner restart the backend and nothing else. +# Install with `sudo visudo -f /etc/sudoers.d/ufound-it`, see docs/ci-cd.md. +asgeija ALL=(root) NOPASSWD: /usr/bin/systemctl restart ufound-it-backend, /usr/bin/systemctl status ufound-it-backend --no-pager diff --git a/deploy/ufound-it-backend.service b/deploy/ufound-it-backend.service new file mode 100644 index 0000000..d433fe2 --- /dev/null +++ b/deploy/ufound-it-backend.service @@ -0,0 +1,21 @@ +# systemd service for the GraphQL backend on the VM. Installed once by hand, +# see docs/ci-cd.md. CI updates the files in /srv/ufound-it and restarts +# this service after every merge to main. + +[Unit] +Description=UFOund it GraphQL backend +After=network.target mysql.service + +[Service] +# The account the GitHub Actions runner runs as, since CI writes the files. +User=asgeija +WorkingDirectory=/srv/ufound-it/backend +# Same command as `npm run start -w backend`, with the Node binary CI copied. +ExecStart=/srv/ufound-it/node --env-file-if-exists=.env dist/index.js +Environment=NODE_ENV=production +Environment=PORT=3001 +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 5d42c58..b1c3dd0 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -1,13 +1,13 @@ # CI/CD -This page explains how our continuous integration (CI) works. +This page explains how our continuous integration (CI) and deployment (CD) work. ## Summary - **CI is automated.** A GitHub Actions workflow checks every pull request and every push to `main`. -- **CD is not automated yet.** Deploying to the VM is a manual step for now. We might add - automated CD later. +- **CD is automated.** Every push to `main` that passes the checks is deployed + to the VM at http://it2810-25.idi.ntnu.no/project2/. ## Continuous integration @@ -71,7 +71,81 @@ If CI fails, open the failed run from the pull request page, read the log of the failed step, and fix it on your branch. Pushing a new commit starts a new run, and also dismisses existing approvals. -## Continuous delivery +## Continuous deployment -Nothing is deployed automatically. After a merge to `main`, someone has to -update the VM by hand for now. +After the checks pass on a push to `main`, the same job deploys to the VM. +Pull requests stop after the checks. It is the same job because a new job +would start from a fresh checkout and have to build again, and the job keeps +the name `checks` because branch protection requires a check with that name. + +| Step | What it does | +| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Deploy frontend | Replaces the contents of `/var/www/html/project2` with `frontend/dist`. Apache serves it at `/project2`. | +| Deploy backend | Copies `backend/dist` to `/srv/ufound-it`, installs the backend's production dependencies there, copies the Node binary CI used, and restarts the service. | +| Check the deployed site | Calls the `health` query through Apache and loads the start page. The run fails if the backend does not answer within about 20 seconds. | + +The backend runs from `/srv/ufound-it` and not from the runner's work folder, +because the next CI run, including one for a pull request, wipes that folder. +Runs on `main` are never cancelled, so a deploy is not stopped halfway. A +second merge waits for the first run to finish. + +### One-time setup on the VM + +The workflow needs a few things that only an account with `sudo` can set up. +This is done once. The files are in [`deploy/`](../deploy). The commands +assume the runner runs as `asgeija`; change the user in the service and +sudoers files if it does not. + +1. **Folders the runner can write to.** + + ```bash + sudo mkdir -p /var/www/html/project2 /srv/ufound-it/backend + sudo chown -R asgeija: /var/www/html/project2 /srv/ufound-it + ``` + +2. **The backend's database settings.** Create `/srv/ufound-it/backend/.env` + from `backend/.env.example` with the real password. CI never touches this + file, and it must never be committed. + + ```bash + sudo -u asgeija nano /srv/ufound-it/backend/.env + chmod 600 /srv/ufound-it/backend/.env + ``` + +3. **The backend service.** + + ```bash + sudo cp deploy/ufound-it-backend.service /etc/systemd/system/ + sudo systemctl daemon-reload + sudo systemctl enable ufound-it-backend + ``` + + It starts on the first deploy, once CI has put the files in place. + +4. **Permission for the runner to restart it,** and nothing else. + + ```bash + sudo visudo -f /etc/sudoers.d/ufound-it + ``` + + Paste the line from `deploy/sudoers-ufound-it`. `visudo` checks the syntax + before saving, so a typo cannot lock anyone out of `sudo`. + +5. **The Apache proxy for `/project2/graphql`.** + + ```bash + sudo a2enmod proxy proxy_http + sudo cp deploy/apache-ufound-it.conf /etc/apache2/conf-available/ufound-it.conf + sudo a2enconf ufound-it + sudo systemctl reload apache2 + ``` + +### If a deploy fails + +- **`sudo: a password is required`** means step 4 is missing or the command in + the workflow no longer matches the sudoers line exactly. +- **The site check fails.** Read the service status at the end of the log, or + run `journalctl -u ufound-it-backend -n 50` on the VM. A wrong or missing + `.env` is the usual cause. +- **Rolling back.** Revert the merge commit on `main` with a pull request. The + revert is deployed like any other merge.