diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 00000000..01a48ae8 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,38 @@ +# Runs the whole proof on the Gitea runner: gate, the editor suite and the three example games. +# +# `runs-on: game-maker-ci` picks the image built from tools/ci/Dockerfile. The render device and +# /dev/shm come from the runner's own container.options (/_stacks/git/runner/config.yaml), not +# from here – without them chromium falls back to software WebGL. +name: game-maker + +on: + push: + workflow_dispatch: + +jobs: + suite: + runs-on: game-maker-ci + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: Versions + run: | + node --version + chromium --version + php --version | head -1 + + - name: Install + run: npm ci --no-audit --no-fund + + - name: Gate + run: ./check.sh + + - name: Build + run: npm run build + + - name: Editor suite + run: tools/ci/run-e2e.sh + + - name: Example games + run: tools/ci/run-smokes.sh diff --git a/README.md b/README.md index b58fe983..cfc02e09 100644 --- a/README.md +++ b/README.md @@ -788,7 +788,7 @@ npm test # unit tests (core, editor, ui, viewport) npm run typecheck npm run lint # ESLint (type-aware for src and tests); lint:fix for the fixable half npm run format # Prettier over the repo, minus what .prettierignore lists -npm run check # typecheck + lint + format:check + tests, what CI would run +npm run check # typecheck + lint + format:check + tests, the gate CI runs too ./check.sh # the same, ending in `=== CHECK EXIT n ===`: piped into `tail` npm's exit # code is lost (tail's is reported), so a red run reads as green npm run build # writes dist/ and regenerates index.html @@ -914,6 +914,24 @@ he walks on the spot – a clock that ticks without the picture following would The workspace folder itself is what nginx serves, so `npm run build` is the deployment. +### Continuous integration + +Every push runs the whole proof on the Gitea runner: `.gitea/workflows/ci.yml` installs, runs +`./check.sh`, builds, then `tools/ci/run-e2e.sh` (the editor suite) and `tools/ci/run-smokes.sh` +(the three games). Both scripts source `tools/ci/serve.sh`, which puts the checkout behind +`http://127.0.0.1:8080/game-maker/` with `php -S` – the build wires its assets absolutely under +`/game-maker/dist/`, so the path segment is not optional – and writes the empty project files the +seeders need (`PUT` does not create a project, `POST` hands out a random id). Every tool takes its +server from `GAME_MAKER_BASE_URL`. + +The job runs in the image from `tools/ci/Dockerfile` under the label `game-maker-ci`: chromium +with the Vulkan drivers, `php-cli`, and the host's font packages, because the checks measure text +widths to find click points. The **render device is not in this repo** – `/dev/dri/renderD128` +and a 1 GB `/dev/shm` come from `container.options` in the runner's own `config.yaml`, which +applies to every job on that runner. Without them chromium falls back to llvmpipe, which gets +WebGL 1.0 only. Measured in the job container: `ANGLE (Intel, Vulkan 1.3.230, HD Graphics 630)`, +WebGL 2.0, and the same 671 checks as here. + ### Ideas, gaps and bugs They live in the issue tracker of the Gitea repo: . diff --git a/tools/ci/Dockerfile b/tools/ci/Dockerfile new file mode 100644 index 00000000..f38dc415 --- /dev/null +++ b/tools/ci/Dockerfile @@ -0,0 +1,21 @@ +# The image behind the Gitea Actions label `game-maker-ci`. It is built on the host and kept +# local (the runner has `force_pull: false`), so a change here needs a rebuild: +# +# DOCKER_BUILDKIT=0 docker build --network git_default -t game-maker-ci:latest tools/ci +# +# Both flags are needed on stein2: DNS is dead in Docker's default bridge network, and Buildkit +# cannot join a named network. +FROM node:22-bookworm + +# chromium + the Vulkan drivers: the e2e harness starts chromium with --use-angle=vulkan and has +# no software fallback. The render device itself comes from the runner's container.options; +# without it chromium falls back to llvmpipe and gets WebGL 1.0 only. +# php-cli serves api/index.php, which every tool loads and saves its project through. +# The fonts are the host's set on purpose: the checks measure text widths to find click points, +# so a different set moves them. +RUN apt-get update && apt-get install -y --no-install-recommends \ + chromium mesa-vulkan-drivers libvulkan1 \ + php-cli php-mbstring \ + fonts-dejavu-core fonts-droid-fallback fonts-noto-color-emoji fonts-noto-mono \ + fonts-texgyre fonts-urw-base35 \ + && rm -rf /var/lib/apt/lists/* diff --git a/tools/ci/project-shells.mjs b/tools/ci/project-shells.mjs new file mode 100755 index 00000000..635c3c09 --- /dev/null +++ b/tools/ci/project-shells.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +/** + * Creates the empty project files the seeders write into. `PUT ?r=projects/{id}` does not create + * a project and `POST` hands out a random id, so a fresh CI checkout has to put the shells with + * the known ids in place itself. The playground is left out – no tool may touch it. + * + * Usage: node tools/ci/project-shells.mjs [dataDir] + */ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { PROJECTS } from '../lib/projects.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); +const dir = process.argv[2] ?? resolve(root, 'data/projects'); + +await mkdir(dir, { recursive: true }); +for (const [name, id] of Object.entries(PROJECTS)) { + if (name === 'playground') continue; + const doc = { + id, + name, + revision: 0, + updatedAt: '1970-01-01T00:00:00Z', + project: {}, + }; + await writeFile(resolve(dir, `${id}.json`), `${JSON.stringify(doc)}\n`); + console.log(`shell ${name} ${id}`); +} diff --git a/tools/ci/run-e2e.sh b/tools/ci/run-e2e.sh new file mode 100755 index 00000000..b19cc9a2 --- /dev/null +++ b/tools/ci/run-e2e.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# The editor suite in CI: serve the checkout, seed the fixture, run every step. +# Ends in a line that survives a pipe, like check.sh does. +set -euo pipefail + +cd "$(dirname "$(readlink -f "$0")")/../.." +source tools/ci/serve.sh +game_maker_serve + +set +e +node tools/seed-e2e.mjs && node tools/e2e.mjs +code=$? +echo "=== E2E EXIT $code ===" +exit $code diff --git a/tools/ci/run-smokes.sh b/tools/ci/run-smokes.sh new file mode 100755 index 00000000..4d75bc98 --- /dev/null +++ b/tools/ci/run-smokes.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# The three example games in CI: seed each one, then play it in a headless browser. +# Ends in a line that survives a pipe, like check.sh does. +set -euo pipefail + +cd "$(dirname "$(readlink -f "$0")")/../.." +source tools/ci/serve.sh +game_maker_serve + +set +e +code=0 +for game in jumprun tetris actor; do + echo "--- $game" + node "tools/seed-$game.mjs" && node "tools/smoke-$game.mjs" || code=1 +done +echo "=== SMOKES EXIT $code ===" +exit $code diff --git a/tools/ci/serve.sh b/tools/ci/serve.sh new file mode 100644 index 00000000..34230219 --- /dev/null +++ b/tools/ci/serve.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Sourced, not run: it leaves the PHP server in the caller's background and exports +# GAME_MAKER_BASE_URL, which every tool reads. +# +# The build wires its assets absolutely under /game-maker/dist/, so the checkout has to be +# served under that path segment – hence the symlink into /srv rather than `-t .`. + +export GAME_MAKER_BASE_URL=http://127.0.0.1:8080/game-maker/ + +game_maker_serve() { + local root + root=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd) + + mkdir -p "$root/data/projects" "$root/data/textures" + node "$root/tools/ci/project-shells.mjs" + + # A server from an earlier step of the same job is fine to keep. + if curl -sf "${GAME_MAKER_BASE_URL}api/index.php?r=projects" >/dev/null 2>&1; then + echo "server already up at $GAME_MAKER_BASE_URL" + return 0 + fi + + mkdir -p /srv && ln -sfn "$root" /srv/game-maker + # Without workers the single-threaded dev server stalls: the editor fetches project and + # textures in parallel. + PHP_CLI_SERVER_WORKERS=8 php -S 0.0.0.0:8080 -t /srv >/tmp/php-server.log 2>&1 & + + for _ in $(seq 60); do + curl -sf "${GAME_MAKER_BASE_URL}api/index.php?r=projects" >/dev/null 2>&1 && break + sleep 0.5 + done + if ! curl -sf "${GAME_MAKER_BASE_URL}api/index.php?r=projects" >/dev/null 2>&1; then + echo "the server never came up" >&2 + tail -20 /tmp/php-server.log >&2 + return 1 + fi + echo "serving $root at $GAME_MAKER_BASE_URL" +} diff --git a/tools/lib/projects.mjs b/tools/lib/projects.mjs index 6ecdc1dd..b91db410 100644 --- a/tools/lib/projects.mjs +++ b/tools/lib/projects.mjs @@ -24,12 +24,14 @@ export const PROJECTS = { export const DEFAULT_BASE_URL = 'https://nockal.com/game-maker/'; /** - * Reads `[projectId] [baseUrl]` from argv, falling back to `defaultId`. Targeting the playground - * aborts: no tool may overwrite or mutate it. + * Reads `[projectId] [baseUrl]` from argv, falling back to `defaultId` and to + * `GAME_MAKER_BASE_URL` – which is how CI points every tool at its own server without + * repeating the project ids in shell scripts. Targeting the playground aborts: no tool may + * overwrite or mutate it. */ export function cliArgs(script, defaultId) { const projectId = process.argv[2] ?? defaultId; - const baseUrl = process.argv[3] ?? DEFAULT_BASE_URL; + const baseUrl = process.argv[3] ?? process.env.GAME_MAKER_BASE_URL ?? DEFAULT_BASE_URL; if (projectId === PROJECTS.playground) { console.error(`refusing to run ${script} against the playground project (${PROJECTS.playground}).`); process.exit(2);