feat: #44 deploy the server build to where crate-api runs it #34
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI/CD | |
| on: | |
| # No branch filter here on purpose. We stack PRs (a branch PR based on | |
| # another branch), and a `branches: [main]` filter silently skips CI for | |
| # every stacked PR. That is how this was found: #34 targets | |
| # feat/1-repo-tooling and got no run at all. | |
| pull_request: | |
| push: | |
| branches: [main] | |
| concurrency: | |
| group: ${{ github.workflow }}-${{ github.ref }} | |
| cancel-in-progress: true | |
| jobs: | |
| quality: | |
| name: Format, lint, typecheck, test, build | |
| # Deliberately unpinned beyond the platform: quality is a pure function of | |
| # the checked-out tree, so it can run on any self-hosted runner we add | |
| # later without changing this file. Only `deploy` is machine-specific. | |
| runs-on: [self-hosted, linux, x64] | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Install pnpm | |
| uses: pnpm/action-setup@v4 | |
| with: | |
| version: 10.33.0 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| cache: pnpm | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Check formatting | |
| run: pnpm format:check | |
| - name: Lint | |
| run: pnpm lint | |
| - name: Typecheck | |
| run: pnpm typecheck | |
| - name: Test | |
| run: pnpm test | |
| - name: Build | |
| run: pnpm build | |
| # Bundle budget, one of our bærekraft measures. The budget is deliberately | |
| # loose today and tightens as the app grows; the point is that CI fails | |
| # before a dependency quietly doubles our payload. | |
| - name: Enforce client bundle budget | |
| run: | | |
| set -euo pipefail | |
| MAX_BYTES=512000 | |
| shopt -s nullglob | |
| found=0 | |
| for f in client/dist/assets/*.js; do | |
| found=1 | |
| size=$(stat -c%s "$f") | |
| echo "$f = ${size} bytes (budget ${MAX_BYTES})" | |
| if [ "$size" -gt "$MAX_BYTES" ]; then | |
| echo "::error::Bundle budget exceeded: $f is ${size} bytes (max ${MAX_BYTES})." | |
| exit 1 | |
| fi | |
| done | |
| if [ "$found" -eq 0 ]; then | |
| echo "::error::No client JS bundle found in client/dist/assets/." | |
| exit 1 | |
| fi | |
| deploy: | |
| name: Deploy to the group VM | |
| needs: quality | |
| if: github.ref == 'refs/heads/main' && github.event_name == 'push' | |
| # Pinned to the `project2` label on purpose. This job writes to | |
| # /var/www/html/project2 and restarts crate-api, both of which only exist | |
| # on it2810-19. If a second runner is ever registered to this repo from a | |
| # different machine, this must not land there. | |
| runs-on: [self-hosted, linux, x64, project2] | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| - name: Install pnpm | |
| uses: pnpm/action-setup@v4 | |
| with: | |
| version: 10.33.0 | |
| - name: Set up Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 24 | |
| cache: pnpm | |
| - name: Install dependencies | |
| run: pnpm install --frozen-lockfile | |
| - name: Build client | |
| run: pnpm build | |
| - name: Publish to Apache | |
| run: | | |
| set -euo pipefail | |
| find /var/www/html/project2 -mindepth 1 -delete | |
| cp -r client/dist/. /var/www/html/project2/ | |
| chmod -R a+rX /var/www/html/project2 | |
| curl -fsS -o /dev/null -w 'GET /project2/ : %{http_code}\n' http://localhost/project2/ | |
| # Deploy the API. The unit runs `node dist/index.js` out of | |
| # ~/T19-Project-2/server, so that directory has to hold the build of the | |
| # commit we just tested. It is a git clone of main (see #44), so point it | |
| # at $GITHUB_SHA rather than copying files around, then install and build | |
| # there. | |
| # | |
| # Deliberately not what earlier versions did: | |
| # | |
| # - The unit check is `[ -f ... ]`, not `systemctl list-unit-files | grep`. | |
| # `grep -q` exits on the first match and closes the pipe, `systemctl` | |
| # gets SIGPIPE and dies, and with `pipefail` the whole pipeline reports | |
| # failure. So the `if` took the else branch and skipped a service that | |
| # was definitely there. The log said "Failed to print table: Broken | |
| # pipe". Reported by rachelks. | |
| # | |
| # - The restart needs `sudo` and the runner has no TTY. That needs a | |
| # NOPASSWD rule scoped to exactly these commands. Without it the step | |
| # dies with "sudo: A terminal is required to authenticate". | |
| # | |
| # - No `sleep`. `Type=simple` reports active before Node binds the port. | |
| # Measured at about 1850 ms, so a fixed `sleep 2` has already produced a | |
| # 503 through Apache. Poll instead. | |
| - name: Deploy the API and restart crate-api | |
| run: | | |
| set -euo pipefail | |
| DEPLOY_ROOT="${HOME}/T19-Project-2" | |
| if [ ! -f /etc/systemd/system/crate-api.service ]; then | |
| echo "crate-api.service is not installed. Skipping API deploy." | |
| exit 0 | |
| fi | |
| if [ ! -d server ]; then | |
| echo "No server/ in this repository yet (arrives with #42). Skipping API deploy." | |
| exit 0 | |
| fi | |
| if [ ! -d "${DEPLOY_ROOT}/.git" ]; then | |
| echo "::error::${DEPLOY_ROOT} is not a git clone. See #44." | |
| exit 1 | |
| fi | |
| echo "Deploying ${GITHUB_SHA} to ${DEPLOY_ROOT}" | |
| git -C "${DEPLOY_ROOT}" fetch --quiet origin | |
| # --force so a dirty tree cannot block the deploy. .env is gitignored | |
| # and so survives the checkout; it holds DATABASE_URL. | |
| git -C "${DEPLOY_ROOT}" checkout --force "${GITHUB_SHA}" | |
| echo " at $(git -C "${DEPLOY_ROOT}" rev-parse --short HEAD)" | |
| if [ ! -f "${DEPLOY_ROOT}/.env" ]; then | |
| echo "::error::${DEPLOY_ROOT}/.env is missing. crate-api will not start without DATABASE_URL." | |
| exit 1 | |
| fi | |
| pnpm install --dir "${DEPLOY_ROOT}" --frozen-lockfile | |
| pnpm --dir "${DEPLOY_ROOT}" --filter @t19/server build | |
| if [ ! -f "${DEPLOY_ROOT}/server/dist/index.js" ]; then | |
| echo "::error::server built but server/dist/index.js is missing." | |
| exit 1 | |
| fi | |
| sudo /usr/bin/systemctl restart crate-api | |
| printf 'Waiting for the API' | |
| for _ in $(seq 1 30); do | |
| code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 \ | |
| -X POST http://localhost/graphql \ | |
| -H 'content-type: application/json' \ | |
| -d '{"query":"{ __typename }"}' 2>/dev/null || echo 000) | |
| if [ "$code" = "200" ]; then | |
| echo | |
| echo "POST /graphql : 200" | |
| break | |
| fi | |
| printf '.' | |
| sleep 1 | |
| done | |
| if [ "${code:-000}" != "200" ]; then | |
| echo | |
| echo "::error::crate-api restarted but /graphql never returned 200 (last code ${code})." | |
| exit 1 | |
| fi | |
| # Smoke suite, against the real port and through the same Apache | |
| # proxy the browser uses. This is the acceptance line of #6. | |
| # | |
| # GraphQL answers HTTP 200 even when the payload carries errors, so | |
| # the status code alone proves nothing. Every check has to read the | |
| # body and reject an "errors" key. Pointed out by rachelks on #45. | |
| echo | |
| echo "Smoke suite:" | |
| check() { | |
| local label="$1" body="$2" resp code json | |
| resp=$(curl -s -X POST http://localhost/graphql \ | |
| -H 'content-type: application/json' \ | |
| -d "${body}" -w '\n%{http_code}') | |
| code="${resp##*$'\n'}" | |
| json="${resp%$'\n'*}" | |
| if [ "${code}" != "200" ]; then | |
| echo "::error::${label}: HTTP ${code} body: ${json}" | |
| return 1 | |
| fi | |
| if printf '%s' "${json}" | grep -q '"errors"'; then | |
| echo "::error::${label}: GraphQL errors body: ${json}" | |
| return 1 | |
| fi | |
| printf ' %-22s %s ok\n' "${label}" "${code}" | |
| } | |
| check '{ __typename }' '{"query":"{ __typename }"}' | |
| check 'searchAlbums' '{"query":"{ searchAlbums(input:{query:\"\"},first:1){totalCount pageInfo{hasNextPage}} }"}' | |
| if curl -fsS -o /dev/null http://localhost/project2/; then | |
| echo " GET /project2/ 200 ok" | |
| else | |
| echo "::error::GET /project2/ failed" | |
| exit 1 | |
| fi |