Skip to content
Noite
Esc
↑↓navigate↵open⌘Jpreview
On this page

CI

Run a repo's build and tests in containers with a light GitHub-Actions-style noite.ci.yaml before a deploy activates.

A repo-root noite.ci.yaml runs the app’s build and tests in a container on every push deploy, instead of the built-in install/build steps. The workflow’s dist/ is what gets released, so a failing test or build never reaches the live version - the old generation keeps serving.

This is a deliberately small subset of GitHub Actions, one container at a time, on the Noite host, implemented by the shared ryuz_workflow crate. It does not make a build fit a host it cannot fit: a 2 GB install still cannot build the ~1.3 GiB Void/Ilha template. Use a bigger box or deploy from CI for that.

A workflow

# noite.ci.yaml at the repository root
on: [push]                     # optional and informational

jobs:
  build:                       # runs in file order
    image: oven/bun:1.4.4      # optional; defaults to RUNNER_CI_IMAGE
    steps:
      - name: Install
        run: bun install --frozen-lockfile
      - name: Typecheck
        run: bun run typecheck
      - name: Build
        run: bun run build
        env:
          NODE_ENV: production
        timeout-minutes: 10
      - uses: upload-artifact
        with: { path: reports/junit.xml, name: junit }

A matrix runs one copy of the job per combination; values reach the steps as RYUZ_MATRIX_<KEY> environment variables:

jobs:
  test:
    strategy:
      matrix: { node: [20, 22] }   # test (20), test (22)
      max-parallel: 1              # deploys run jobs one at a time
    steps:
      - run: test "$RYUZ_MATRIX_NODE" = 20 || echo "other version"

Each job gets a fresh container with the whole checked-out tree at /work. Steps share that tree in order; a file written by one step is visible to the next. Jobs also share the host worktree in order, but each job starts with a fresh container and a fresh /tmp, so installs are repeated per job - keep one job unless you need a different image.

Only dist/ is copied back out after a successful workflow; that is the deploy root. If the workflow leaves no dist/, the deploy fails with noite.ci.yaml finished but produced no dist/ to deploy rather than deploying an empty tree.

Supported keys

Key Values
on ignored; a push deploy is the trigger
name workflow display name; informational
env string map, exported to every job and step
jobs.<name> one container per job; jobs run in file order (deploys set max_parallel: 1)
jobs.<name>.image / .container an image in the install’s allowlist; defaults to RUNNER_CI_IMAGE
jobs.<name>.runs-on accepted and kept as a hint for other runners; unused locally
jobs.<name>.env string map for that job’s steps; a step’s own env wins
jobs.<name>.defaults.run shell / working-directory for steps that omit them
jobs.<name>.timeout-minutes 1-360; the container is killed when it expires
jobs.<name>.strategy matrix, max-parallel (1-256), fail-fast (default true)
jobs.<name>.steps[] list of steps
steps[].name shown in the deploy log
steps[].run sh -lc inside the container (bash -eo pipefail -lc with shell: bash)
steps[].shell bash or sh
steps[].env string map, exported for that step only
steps[].working-directory relative to the repo root; must stay inside /work
steps[].timeout-minutes 1-120; default 10
steps[].uses checkout (no-op), cache (no-op; path/key accepted), upload-artifact (path required, name optional); an @ref suffix is accepted
steps[].with inputs for uses: path, key, name

Matrix values are exposed as RYUZ_MATRIX_<KEY> (key uppercased, [^A-Z0-9_] replaced by _); an explicit env of the same name wins, and two keys that map to one name are a config error. env names that configure the host container CLI or the dynamic loader (PATH, HOME, TMPDIR, LD_*, DOCKER_*, CONTAINER_*, PODMAN_*, XDG_*, proxy/TLS variables) are rejected: export them inside run instead.

Unknown keys and ${{ … }} expressions fail the deploy with the path (for example jobs.build.steps[1]: unknown key "if"). Limits: config 64 KiB, 32 declared jobs (256 after matrix expansion), 32 steps per job, 32 KiB per run, 256 env entries per mapping, 64 KiB per env value, 120 min per step, 360 min per job.

How it runs

  • The runner creates one container per job (--init, --cap-drop ALL plus CHOWN only for the ownership prep, --security-opt no-new-privileges, no host mounts, no Docker socket inside), copies the checkout in with docker cp, runs the steps as the app’s build uid (no capabilities), and removes the container even on failure or a cancelled deploy. Containers carry a ryuz_workflow.pid label and leftovers are swept at the start of the next run.
  • Resources: the build memory cap (RUNNER_BUILD_MEMORY_MB, with --memory-swap equal so the container cannot swap), one CPU less than the box, and --pids-limit 2048.
  • HOME and the package-manager cache live in a per-app named volume (noite-ci-cache-<install-tag>-<app-id>, keyed by app id so a reused slug never inherits it and tagged per install so two installs sharing a container daemon never remove each other’s), so bun install, npm and pnpm caches survive across deploys without a host bind.
  • Network defaults to Docker’s bridge so installs reach registries; RUNNER_CI_NETWORK=none gives fully offline jobs. The host’s loopback services are not reachable.
  • The app’s build environment variables are passed into the container by name (never on the host command line) and every configured value of at least 4 bytes is masked as *** in the deploy log. Values shorter than that are not masked; do not print secrets regardless. Multi-line values are masked line by line.
  • Step and job timeouts kill the container, and exit 137 is reported as out-of-memory. Artifacts are staged outside the worktree and installed without following symlinks; upload-artifact copies its path out at that step, and the deploy root dist/ is copied back after a successful job.
  • A workflow failure fails the deploy before release/activate, so the live version is untouched.

Configuration

Variable Default Effect
RUNNER_CI on 0/false/off disables the workflow and uses the built-in install/build
RUNNER_CI_IMAGE oven/bun:1.4.4 image for a job that names none
RUNNER_CI_IMAGES the default image only comma-separated exact allowlist; the default image is always allowed
RUNNER_CI_NETWORK bridge bridge (registry access) or none
RUNNER_CI_DOCKER_BIN docker Docker CLI binary (Podman escape hatch or a test seam)

Use an exact tag or a digest in image; the runner does not resolve digests for a mutable tag, and a mutable tag can change between deploys. The default image carries Bun; use node:24-bookworm-slim or your own pinned image if the app needs Node without Bun.

Not yet

Marketplace uses beyond checkout/cache/upload-artifact, services, needs (jobs are file order, bounded by max_parallel), and off-host runners. The workflow runs on the Noite host, so it consumes the host’s memory and CPU like the built-in build.

Was this page helpful?