diff --git a/.buildkite/README.md b/.buildkite/README.md new file mode 100644 index 000000000..a40569071 --- /dev/null +++ b/.buildkite/README.md @@ -0,0 +1,57 @@ +# Buildkite CI + +The Garden Buildkite integration stores a bare `buildkite-agent pipeline +upload`, so the agent finds `pipeline.yml` by its default name. The integration +makes one pipeline per repo, so all suites land in the same build. + +## Cache volumes and artifacts + +A **cache volume** carries state between builds. It is an input: content that a +step needs, but that the repo does not contain. Volume names are scoped to the +pipeline. + +An **artifact** is a file that one build produces. It is an output: evidence for +a person to read in the Buildkite UI. The agent uploads artifacts whatever the +exit code of the step, so a failed step still reports its results. + +The visual suite uses both. The volume `visual-snapshots` holds the baseline +images, which are gitignored and exist nowhere else. `artifact_paths` uploads +the actual, expected, and diff images that a failed comparison writes. The two +cannot be swapped: a volume is not visible in the UI, and an artifact does not +persist as an input to the next build. + +## One writer per volume + +A job does not read its volume in place. It gets a private copy at start. That +copy becomes the version that the next job sees, but only when the job exits 0. +Every successful job is therefore a writer, whether or not it changed a file. + +Two consequences follow. + +Keep the volume names distinct across the uploaded files. A step that commits a +version discards what a concurrent step wrote to the same volume. This is why +`rust.yml` gives test, doc, and lint a separate volume each. + +The visual suite keeps exactly one writer. `master` updates the baselines, and +`concurrency: 1` serializes the group. Other branches always exit non-zero, so +Buildkite discards their copy and no branch can republish a stale baseline set. +The branch step then cannot use the exit code to report the test result, so two +codes carry both: 100 means no differences, which `soft_fail` keeps green, and +any other non-zero code fails the build. An annotation reports the outcome in +both cases. + +## Docker images + +Two images cover all Node steps: + +- `node:24-bookworm` tracks the major version in `.nvmrc` and the `engines` + field in `package.json`. +- `mcr.microsoft.com/playwright:v1.61.1-noble` ships the browsers at + `/ms-playwright`, so no step installs them. The tag must match + `@playwright/test` in `package.json`. Playwright refuses browsers built for + another version. + +Node steps use no cache volume. Cache paths are relative to the step working +directory, and the agent home is not visible inside a container, so npm +downloads its dependencies on every run. The Rust steps run on the agent +instead, so their `~`-rooted paths work. diff --git a/.buildkite/e2e.yml b/.buildkite/e2e.yml new file mode 100644 index 000000000..ff6bd4aa9 --- /dev/null +++ b/.buildkite/e2e.yml @@ -0,0 +1,31 @@ +# No explicit build step: tests/support/globalSetup.ts runs `npm run build` in +# parallel with fixture creation, and asserts the pinned rad and radicle-httpd +# versions are on PATH. + +env: + CI: "true" + +steps: + - label: "๐ŸŽญ e2e" + key: e2e + timeout_in_minutes: 30 + artifact_paths: + - "tests/artifacts/**/*" + plugins: + # Ships the browsers at /ms-playwright, so nothing here installs them. The + # tag must match @playwright/test in package.json โ€” Playwright refuses + # browsers built for another version. + - docker#v5.14.0: + image: "mcr.microsoft.com/playwright:v1.61.1-noble" + # Chromium exhausts Docker's default 64MB /dev/shm. + ipc: host + environment: + - CI + commands: + - npm ci + # The image ships no decompressors: install-binaries unpacks .tar.xz + # releases, and the repo fixtures are .tar.bz2 archives. + - apt-get update -qq && apt-get install -y -qq xz-utils bzip2 + - mkdir -p tests/artifacts + - ./scripts/install-binaries + - npm run test:e2e -- --project chromium diff --git a/.buildkite/node-checks.yml b/.buildkite/node-checks.yml new file mode 100644 index 000000000..c0dbcff56 --- /dev/null +++ b/.buildkite/node-checks.yml @@ -0,0 +1,73 @@ +# No cache volume: cache paths are relative to the step working directory, and +# the agent's home is not visible inside a container, so npm downloads its +# dependencies on every run. + +env: + CI: "true" + +__defs: + # Tracks the major version in .nvmrc and the engines field in package.json. + node_image: &node_image + docker#v5.14.0: + image: "node:24-bookworm" + environment: + - CI + + # Ships the browsers at /ms-playwright, which is why no step installs them. + # The tag must match @playwright/test in package.json โ€” Playwright refuses to + # run against browsers built for another version. + playwright_image: &playwright_image + docker#v5.14.0: + image: "mcr.microsoft.com/playwright:v1.61.1-noble" + # Chromium exhausts Docker's default 64MB /dev/shm. + ipc: host + environment: + - CI + +steps: + - label: "๐Ÿ”Ž check" + key: check + timeout_in_minutes: 10 + plugins: + - *node_image + commands: + - npm ci + - npm run check + + - label: "๐Ÿงช unit-tests" + key: unit-tests + timeout_in_minutes: 10 + plugins: + - *node_image + commands: + - npm ci + - npm run test:unit + + # The http-client suite talks to a real radicle-httpd, so it needs the pinned + # binaries on PATH even though it runs under vitest rather than Playwright. + - label: "๐Ÿ”Œ http-client-tests" + key: http-client-tests + timeout_in_minutes: 15 + artifact_paths: + - "tests/artifacts/**/*" + plugins: + - *node_image + commands: + - npm ci + - mkdir -p tests/artifacts + - ./scripts/install-binaries + - npm run test:http-client:unit + + # playwright.buildSmoke.config.ts sets globalSetup to undefined, so this step + # needs a browser but no Radicle binaries or fixtures. + - label: "๐Ÿ— build-smoke" + key: build-smoke + timeout_in_minutes: 30 + artifact_paths: + - "tests/artifacts/**/*" + plugins: + - *playwright_image + commands: + - npm ci + - npm run build + - npm run test:e2e -- --project chromium --config playwright.buildSmoke.config.ts diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml new file mode 100644 index 000000000..612b33253 --- /dev/null +++ b/.buildkite/pipeline.yml @@ -0,0 +1,16 @@ +# Entry point for CI. garden-broker stores a bare `buildkite-agent pipeline +# upload` as the server-side configuration, so the agent looks for this file by +# its default name. The other files here are uploaded from below. +# +# The broker creates exactly one Buildkite pipeline per Radicle repo, so every +# suite lands in this single build. Keep cache volume names distinct across the +# uploaded files: volumes are scoped to the pipeline, and a step that commits a +# volume version discards what a concurrent step wrote to the same volume. + +steps: + - label: ":pipeline: upload" + commands: + - buildkite-agent pipeline upload .buildkite/node-checks.yml + - buildkite-agent pipeline upload .buildkite/e2e.yml + - buildkite-agent pipeline upload .buildkite/visual.yml + - buildkite-agent pipeline upload .buildkite/rust.yml diff --git a/.buildkite/rust.yml b/.buildkite/rust.yml new file mode 100644 index 000000000..cd9c66043 --- /dev/null +++ b/.buildkite/rust.yml @@ -0,0 +1,66 @@ +# CI for the Rust workspace (crates/radicle-httpd, crates/radicle-search). +# +# Each step gets its own named cache volume. Clippy, test, and doc each produce a +# different target directory, and a shared volume would let whichever step +# commits last discard the other two, forcing a near-full rebuild every build. +# +# These steps run on the agent rather than in a container, so ~-rooted cache +# paths work. The toolchain version is not pinned here: rustup reads +# ./rust-toolchain. +# +# Every step needs network access to seed.radicle.dev as well as crates.io โ€” the +# workspace patches radicle-job to a git dependency served from a Radicle seed. + +env: + CI: "true" + CARGO_TERM_COLOR: "always" + +__defs: + install_rust: &install_rust | + export PATH="$$HOME/.cargo/bin:$$PATH" + export CARGO_TARGET_DIR="$$HOME/.cache/cargo-target" + if ! command -v rustup >/dev/null 2>&1; then + curl -fsSL https://sh.rustup.rs | sh -s -- -y --no-modify-path --profile minimal + fi + rustup component add rustfmt clippy + rustc --version + + cargo_cache: &cargo_cache + - "~/.cargo" + - "~/.rustup" + - "~/.cache/cargo-target" + +steps: + # RUSTFLAGS is set on the build only, not on the test run. + - label: "๐Ÿฆ€ test" + key: test + timeout_in_minutes: 30 + cache: + name: rust-test + paths: *cargo_cache + commands: + - *install_rust + - RUSTFLAGS="-D warnings" cargo build --workspace --all-features + - cargo test --workspace --all-features + - cargo test -p radicle-httpd + + - label: "๐Ÿ“š doc" + key: doc + timeout_in_minutes: 30 + cache: + name: rust-doc + paths: *cargo_cache + commands: + - *install_rust + - RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features + + - label: "๐Ÿฆ€ lint" + key: lint + timeout_in_minutes: 30 + cache: + name: rust-lint + paths: *cargo_cache + commands: + - *install_rust + - RUSTFLAGS="-D warnings" cargo clippy --workspace --all-features --tests + - cargo fmt --all -- --check diff --git a/.buildkite/visual.yml b/.buildkite/visual.yml new file mode 100644 index 000000000..df34ccf12 --- /dev/null +++ b/.buildkite/visual.yml @@ -0,0 +1,107 @@ +# Visual snapshot tests. +# +# tests/visual/snapshots is gitignored, so the baselines live only in this cache +# volume. Master updates them with --update-snapshots; every other branch +# compares against them. +# +# A job does not read the volume in place: it gets a private copy at start, and +# that copy becomes the version the next job sees if the job exits 0. So every +# successful job is a writer, whether or not it changed a file. The two steps +# below therefore keep exactly one writer: +# +# - Master updates the snapshots and commits, serialized by `concurrency: 1` +# so two master builds cannot overwrite each other. +# - Branches compare, and always exit non-zero so their copy is thrown away. +# They need this even though they do not update the snapshots: without +# --update-snapshots the mode is `missing`, under which Playwright still +# writes a baseline that does not exist yet. A branch therefore needs no +# concurrency limit, and any number of branch builds can run at once. + +env: + CI: "true" + +steps: + - label: "๐Ÿ“ธ visual update snapshots" + key: visual-update-snapshots + branches: "master" + timeout_in_minutes: 30 + # One writer at a time. Buildkite runs a concurrency group in the order the + # jobs were created, so the newest master build commits last. + concurrency: 1 + concurrency_group: "radicle-explorer/visual-snapshots" + cache: + name: visual-snapshots + paths: + - "tests/visual/snapshots" + artifact_paths: + - "tests/artifacts/**/*.png" + plugins: + # The image ships WebKit as well as Chromium, which this suite needs: the + # visual-mobile project uses the iPhone 13 Mini device descriptor, whose + # defaultBrowserType is webkit. The tag must match @playwright/test in + # package.json โ€” Playwright refuses browsers built for another version. + - &playwright_docker + docker#v5.14.0: + image: "mcr.microsoft.com/playwright:v1.61.1-noble" + # Chromium exhausts Docker's default 64MB /dev/shm. + ipc: host + # buildkite-agent annotate runs inside the container. + mount-buildkite-agent: true + environment: + - CI + commands: + - npm ci + # The image ships no decompressors: install-binaries unpacks .tar.xz + # releases, and the repo fixtures are .tar.bz2 archives. + - apt-get update -qq && apt-get install -y -qq xz-utils bzip2 + - mkdir -p tests/artifacts + - ./scripts/install-binaries + # No `|| true` here: --update-snapshots reports a re-generated snapshot as + # a pass, so a non-zero exit means the run itself broke. Failing is then + # what we want, because only a job that exits 0 commits a volume version, + # and a run that died halfway would publish a half-written baseline set. + # The visual projects set retries: 0, so one flaky test skips the update + # until the next push to master. + - npm run test:e2e -- --project visual-desktop visual-mobile --update-snapshots + + - label: "๐Ÿ“ธ visual" + key: visual + branches: "!master" + timeout_in_minutes: 30 + # No concurrency limit: this job never commits a volume version. + # + # The job must always exit non-zero to discard its copy of the volume, so + # the exit code cannot also report whether the tests passed. Two codes carry + # both: 100 means no differences, and soft_fail keeps the build green. + # Anything else fails the build โ€” a real difference, but also a broken + # npm ci or a crashed browser. 100 is a sentinel that no tool in this step + # produces, so only the success path below is soft. + soft_fail: + - exit_status: 100 + cache: + name: visual-snapshots + paths: + - "tests/visual/snapshots" + artifact_paths: + - "tests/artifacts/**/*.png" + plugins: + - *playwright_docker + commands: + - npm ci + - apt-get update -qq && apt-get install -y -qq xz-utils bzip2 + - mkdir -p tests/artifacts + - ./scripts/install-binaries + # Both paths exit non-zero, so Buildkite discards this job's copy of the + # volume. A zero exit would revert an update that master committed while + # this job ran, and would also publish any baseline this run wrote itself + # for a screenshot that has none yet. + - | + if npm run test:e2e -- --project visual-desktop visual-mobile; then + buildkite-agent annotate "๐Ÿ“ธ visual: no differences" \ + --style success --context visual + exit 100 + else + buildkite-agent annotate "๐Ÿ“ธ visual: differences found, see the uploaded images" \ + --style warning --context visual + exit 1 + fi