# Tests and packaging. # # Nothing here downloads the upstream wordlist. The image is built from the # committed corpus, data/dictionary.txt, exactly as it ships; the test suites # play against the small database derived from the checked-in word sample, # which goes through the same builder the real one does. # # The wire contract has its own workflow: see proto.yml. name: ci on: push: branches: [main] pull_request: release: types: [published] permissions: contents: read jobs: go: name: Go runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-go@v7 with: go-version: stable cache-dependency-path: server/go.sum - name: Vet run: go vet ./... working-directory: server # gofmt -l lists files that are not gofmt-clean; -d would only show the # diff. Non-empty output is the failure signal, so it decides the exit # code itself rather than leaning on the emptiness of a report nobody # reads. - name: Format check run: | unformatted="$(gofmt -l .)" if [ -n "$unformatted" ]; then echo "not gofmt-clean:" echo "$unformatted" exit 1 fi working-directory: server # Default linters only: this is a signal every PR has to pass, not a # style debate, so nothing beyond golangci-lint's own defaults is # enabled here. - name: Lint uses: golangci/golangci-lint-action@v9 with: working-directory: server # The module's go directive is only the minimum. CI runs the newest # toolchain, the way the image's golang:1 does, so vet and race results # come from the compiler that ships. govulncheck reads the call graph # against the current advisory database. - name: Vulnerability scan run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... working-directory: server # -race because the whole transport layer is goroutines and timers, and a # data race there is exactly the kind of defect that passes without it. - name: Test run: go test ./... -race working-directory: server web: name: Frontend runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-node@v7 with: node-version: 24 cache: npm cache-dependency-path: web/package-lock.json - run: npm ci working-directory: web - name: Type check run: npm run check working-directory: web - name: Lint run: npm run lint working-directory: web # Runtime dependencies only: the frontend ships as static files, so a # dev-tool advisory cannot reach production. - name: Audit run: npm audit --omit=dev --audit-level=high working-directory: web # npm test builds first, so this also proves the bundle compiles and # carries no wordlist. - name: Test run: npm test working-directory: web e2e: name: End to end runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-go@v7 with: go-version: stable cache-dependency-path: server/go.sum - uses: actions/setup-node@v7 with: node-version: 24 cache: npm cache-dependency-path: web/package-lock.json - name: Build the fixture dictionary run: go run ./cmd/build-dictionary --words ../testdata/fixture-words.txt --out ../data/fixture.db --min-words 150 working-directory: server - run: npm ci working-directory: web - name: Install the browser run: npx playwright install --with-deps chromium working-directory: web - name: Run the suite run: npm run test:e2e working-directory: web # test-results holds the traces a failure leaves behind, which is what is # actually worth downloading; the HTML report is not generated here. - uses: actions/upload-artifact@v7 if: failure() with: name: playwright-traces path: web/test-results/ retention-days: 7 image: name: Container image runs-on: ubuntu-latest # e2e too: the moment this job gains a publish step, an image built past a # red browser suite is an image nobody meant to ship. needs: [go, web, e2e] steps: - uses: actions/checkout@v7 with: persist-credentials: false # The real dictionary, from the committed corpus: the image every run # builds is the one that ships, so a corpus the builder rejects fails # here rather than at deploy time. - name: Build run: docker build -t noitu:ci . # CC BY-SA 4.0 applies to the derived wordlist wherever it is # distributed, and an image is distribution. This is the assertion that # the obligation actually shipped. - name: The licence travels with the data run: | set -eu docker create --name check noitu:ci docker export check | tar -t > files.txt docker rm check for required in app/LICENSE app/data/LICENSE app/data/ATTRIBUTION.md app/NOTICE app/data/noitu.db; do grep -qx "$required" files.txt || { echo "missing from the image: $required"; exit 1; } done # The upstream dump, compressed or not, must never reach the final # image. if grep -Eq '\.(bz2|xml)$' files.txt; then echo "the upstream dump leaked into the image" exit 1 fi - name: It serves a game run: | set -eu docker run -d --name noitu -p 8080:8080 noitu:ci for _ in $(seq 1 30); do if curl -fsS http://localhost:8080/healthz >/dev/null 2>&1; then break; fi sleep 1 done curl -fsS http://localhost:8080/healthz # The image has no curl, so the HEALTHCHECK command is the binary # itself; run it the way the container runtime does. docker exec noitu /app/noitu-server -healthcheck # A deep link is a client route, so the binary has to answer it with # the app shell rather than a 404. curl -fsS -o /dev/null -w '%{http_code}\n' 'http://localhost:8080/play?difficulty=2' | grep -qx 200 docker rm -f noitu