From 17056ad32146558c219b0810d69ab02101cfeeac Mon Sep 17 00:00:00 2001 From: "Marvin (Paranoid Android)" Date: Wed, 9 Sep 2026 09:57:16 +0200 Subject: [PATCH 1/7] ci: run the GitHub Actions checks on CircleCI's self-hosted runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translates .github/workflows/build.yaml and ad4m-compat.yaml to .circleci/config.yml on `coasys/marvin`, the machine runner that already builds AD4M. Step one toward WE tests against a real AD4M executor, which a hosted runner cannot have. Same six jobs, same names, same dependency shape: lint and rust need nothing built; typecheck, test and schemas take the build's result rather than rebuilding it. Mapping, where CircleCI has no equivalent of an action: - setup-node -> nvm, reading the same .nvmrc - pnpm/action-setup -> corepack, reading the same packageManager field - actions/cache -> restore_cache/save_cache on the pnpm store - upload/download-artifact -> persist_to_workspace/attach_workspace Two things the runner changes, both handled rather than inherited: Machine runners only support `machine: true` — a `docker:` executor needs CircleCI's container runner — so every job runs on the host as `marvin`. The working directory persists between jobs (cleanup_working_directory: false), and `checkout` only resets tracked files. build.yaml's drift check reads `git status --porcelain`, which on a hosted runner is pristine by construction and here is not: a leftover untracked file fails the check on somebody else's mess, or hides real drift by already being there. Hence clean_checkout — `git clean -xfd` before and after checkout, and a hard failure if the tree is still dirty, so the check means the same thing it means upstream. `concurrency: cancel-in-progress` has no config equivalent; it is the project's "Auto-cancel redundant workflows" setting. The nightly compat job carries two documented gaps against ad4m-compat.yaml: it tests `dev` HEAD rather than the last commit green on `dev` (that control needs run history this project does not have yet), and it reports into the build log rather than a tracking issue (which needs a GH_TOKEN project variable). Both are noted in the file. --- .circleci/config.yml | 480 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 480 insertions(+) create mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..12ff95546 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,480 @@ +version: 2.1 + +# CircleCI translation of `.github/workflows/build.yaml` (and the nightly +# `ad4m-compat.yaml`), running on Coasys's self-hosted machine runner. +# +# Why this exists alongside the GitHub Actions workflows: WE's tests are going to +# need a real AD4M executor, and a hosted runner cannot have one. `coasys/marvin` +# is a physical box that already builds AD4M, so it can hold an executor, a +# Holochain conductor and a bootstrap server between jobs. This file is step one — +# the same checks GitHub Actions already runs, running there instead. +# +# +# ## What a self-hosted machine runner changes +# +# **No `docker:` executor.** Machine runners only support `machine: true`; a +# `docker:` executor needs CircleCI's *container* runner (Kubernetes). Every job +# below therefore runs directly on the host, as the `marvin` user, sharing that +# user's toolchains. +# +# **The working directory persists.** The runner is configured with +# `cleanup_working_directory: false`, so a job starts in the previous job's +# directory rather than a fresh one. That is the single biggest fidelity risk in +# this translation, because `build.yaml`'s drift check is `git status --porcelain` +# — on a hosted runner the tree is pristine by construction, here it is not. See +# `clean_checkout` below. +# +# **No `actions/*`.** `setup-node`, `pnpm/action-setup`, `actions/cache` and +# `upload/download-artifact` have no equivalents; they are `nvm`, `corepack`, +# `save_cache`/`restore_cache` and `persist_to_workspace`/`attach_workspace` +# respectively. +# +# +# ## What has no equivalent and is a project setting instead +# +# `concurrency: cancel-in-progress` — CircleCI expresses this as the project's +# "Auto-cancel redundant workflows" toggle (Project Settings → Advanced), not in +# config. It must be turned on to match the GitHub behaviour. + +# --------------------------------------------------------------------------- +# Executor +# --------------------------------------------------------------------------- + +executors: + marvin: + machine: true + resource_class: coasys/marvin + +# --------------------------------------------------------------------------- +# Commands — the composite actions from `.github/actions/` +# --------------------------------------------------------------------------- + +commands: + clean_checkout: + description: > + Check out the commit under test into a tree with no leftovers from the + previous job that used this directory. + steps: + # A machine runner reuses its working directory, and CircleCI's `checkout` + # only resets *tracked* files. Two failure modes follow, and the first one + # is silent: + # + # - `build`'s drift check reads `git status --porcelain`, which reports + # untracked files. A file some earlier job left behind is indistinguishable + # from one this build generated, so the check fails on somebody else's + # mess — or, worse, a genuinely drifting build passes because the file + # was already there and unchanged. + # - Stale `dist/` output from a previous commit makes a build that no + # longer produces a file look like it still does. + # + # `git clean -xfd` removes untracked *and* ignored files, which is what a + # hosted runner gets for free. It costs a `node_modules` reinstall per job — + # paid back by the pnpm store cache below, which survives because it lives + # in the runner's home directory rather than the repository. + - run: + name: Reset working directory + command: | + if [ -d .git ] && ! git rev-parse --git-dir >/dev/null 2>&1; then + echo "Corrupted .git directory — removing so checkout can clone fresh." + rm -rf .git + fi + if [ -d .git ]; then + git clean -xfd || true + fi + - checkout + - run: + name: Verify the tree is clean before anything runs + command: | + git clean -xfd + DIRT="$(git status --porcelain)" + if [ -n "$DIRT" ]; then + echo "Working tree is not clean after checkout — the drift check below cannot be trusted." + echo "$DIRT" + exit 1 + fi + + setup: + description: > + Node, pnpm, a warm pnpm store and a frozen install — the translation of + `.github/actions/setup`. + steps: + # The GitHub action reads `node-version-file: .nvmrc`; nvm reads the same + # file natively, so the version stays declared in exactly one place. + # + # `nvm install` is a no-op when the version is already present, so this + # costs nothing on a warm host and self-heals a cold one. + - run: + name: Node from .nvmrc + command: | + export NVM_DIR="$HOME/.nvm" + # shellcheck disable=SC1091 + [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" --no-use + nvm install >/dev/null + NODE_BIN_DIR="$(dirname "$(nvm which "$(cat .nvmrc)")")" + echo "export PATH=\"$NODE_BIN_DIR:\$PATH\"" >> "$BASH_ENV" + # shellcheck disable=SC1090 + . "$BASH_ENV" + echo "Node: $(node --version) (.nvmrc: $(cat .nvmrc))" + + # `pnpm/action-setup` with no `version` reads the repo's `packageManager` + # field. corepack does the same thing from the same field, so CI resolves + # dependencies with exactly the pnpm a developer's machine uses. + - run: + name: pnpm from packageManager + command: | + corepack enable + corepack prepare --activate + echo "pnpm: $(pnpm --version) (packageManager: $(node -p "require('./package.json').packageManager"))" + + # Cache the pnpm content-addressable store, not node_modules — the workspace + # nests packages 2–4 levels deep, so a node_modules key misses most of it. + # + # `restore_keys` in the GitHub action becomes the second, less specific key + # here: an exact lockfile match first, then the newest store from any + # previous run, which pnpm tops up rather than rebuilding. + - run: + name: Resolve pnpm store path + command: echo "export PNPM_STORE_PATH=$(pnpm store path --silent)" >> "$BASH_ENV" + - restore_cache: + keys: + - pnpm-store-v1-{{ checksum "pnpm-lock.yaml" }} + - pnpm-store-v1- + - run: + name: Install dependencies + command: pnpm install --frozen-lockfile + - save_cache: + key: pnpm-store-v1-{{ checksum "pnpm-lock.yaml" }} + paths: + - /home/marvin/.local/share/pnpm/store + + restore_build: + description: > + Unpack the tarball the Build job produced — the translation of + `.github/actions/restore-build`. + steps: + # Must run *after* `setup`, not before: `pnpm install` writes into the same + # package directories, and the build output is what should win. Same ordering + # constraint the composite action documents. + - attach_workspace: + at: /tmp/we-build + - run: + name: Unpack build outputs + command: | + tar -xzf /tmp/we-build/build-outputs.tar.gz + echo "Restored $(tar -tzf /tmp/we-build/build-outputs.tar.gz | wc -l) paths." + +# --------------------------------------------------------------------------- +# Jobs — one per GitHub Actions job, same names, same order +# --------------------------------------------------------------------------- + +jobs: + lint: + executor: marvin + steps: + - clean_checkout + - setup + - run: + name: Lint + command: NODE_OPTIONS='--max-old-space-size=8192' pnpm lint + - run: + name: Lint CSS + command: pnpm lint:css + - run: + name: Check formatting + command: pnpm format:check + - run: + name: Check source-shipping consumers + command: pnpm check:source-consumers + + build: + executor: marvin + steps: + - clean_checkout + - setup + - run: + name: Build + command: NODE_OPTIONS='--max-old-space-size=8192' pnpm build + + # Unchanged in intent from `build.yaml`: whole-tree `--porcelain`, no + # exclusions. `clean_checkout` above is what makes it mean the same thing + # here as it does on a hosted runner. + - run: + name: Check generated files are committed + command: | + DRIFT="$(git status --porcelain)" + if [ -n "$DRIFT" ]; then + echo "The build changed tracked files — run \`pnpm build\` and commit the regenerated output." + echo "$DRIFT" + git diff --stat + exit 1 + fi + + # Derived from `git status --ignored` rather than a path list, for the same + # reason the GitHub workflow gives: the outputs are not only `dist/` + # directories, and a hand-listed set would miss the next generator. + - run: + name: Package build outputs + command: | + mkdir -p /tmp/we-build + git status --porcelain --ignored=traditional \ + | awk '$1 == "!!" { print $2 }' \ + | grep -vE '(^|/)node_modules/$' \ + > build-outputs.txt + echo "Packaging $(wc -l < build-outputs.txt) paths:" + cat build-outputs.txt + tar -czf /tmp/we-build/build-outputs.tar.gz -T build-outputs.txt + rm build-outputs.txt + + # `upload-artifact` → workspace. A workspace is the right primitive rather + # than `store_artifacts`: this tarball is read by this pipeline's own + # downstream jobs and by nothing else afterwards, which is exactly what the + # GitHub side expresses as `retention-days: 1`. + - persist_to_workspace: + root: /tmp/we-build + paths: + - build-outputs.tar.gz + + typecheck: + executor: marvin + steps: + - clean_checkout + - setup + - restore_build + - run: + name: Typecheck + command: pnpm typecheck + + test: + executor: marvin + steps: + - clean_checkout + - setup + - restore_build + - run: + name: Test + command: pnpm test + # Reported rather than gated, and non-fatal so a package without the + # reporter cannot fail the run — `continue-on-error: true` upstream. + - run: + name: Coverage + command: pnpm --filter @we/app-shell test -- --coverage.enabled --coverage.reporter=text-summary || true + + rust: + executor: marvin + steps: + # No `setup`: the generator is plain Node with no imports outside the + # standard library, so this job needs neither `pnpm install` nor the + # workspace — which is why it stays independent of Build. + - clean_checkout + - run: + name: Node from .nvmrc + command: | + export NVM_DIR="$HOME/.nvm" + # shellcheck disable=SC1091 + [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" --no-use + nvm install >/dev/null + echo "export PATH=\"$(dirname "$(nvm which "$(cat .nvmrc)")"):\$PATH\"" >> "$BASH_ENV" + + # rustfmt for the toolchain the crate pins, which is not the default one. + # The channel is read out of `rust-toolchain.toml` rather than written here, + # so this cannot drift from the pin it exists to serve. + - run: + name: Install rustfmt for the pinned toolchain + working_directory: apps/we-tauri/src-tauri + command: | + export PATH="$HOME/.cargo/bin:$PATH" + CHANNEL="$(sed -n 's/^ *channel *= *"\(.*\)"/\1/p' rust-toolchain.toml)" + if [ -z "$CHANNEL" ]; then + echo "could not read [toolchain] channel from rust-toolchain.toml" + exit 1 + fi + echo "Pinned Rust channel: $CHANNEL" + rustup toolchain install "$CHANNEL" --component rustfmt --profile minimal + + # `src-tauri/src/generated/` is gitignored and rustfmt follows `mod` + # declarations, so without this it stops at "failed to resolve mod `generated`". + - run: + name: Generate the seed-derived sources + command: node apps/we-tauri/scripts/generate-seed-config.cjs + + # `rustfmt` directly, not `cargo fmt`: `src-tauri/Cargo.toml` is gitignored + # and generated, so `cargo metadata` has nothing to read. Formatting is a + # property of the source text, not of a dependency graph. + - run: + name: Check formatting + command: | + export PATH="$HOME/.cargo/bin:$PATH" + FILES="$(git ls-files -- apps/we-tauri/src-tauri | grep '\.rs$')" + COUNT="$(printf '%s\n' "$FILES" | grep -c . || true)" + if [ "$COUNT" -lt 1 ]; then + echo "no Rust sources found to format-check" + exit 1 + fi + echo "Checking $COUNT files" + printf '%s\n' "$FILES" | xargs rustfmt --check --edition 2021 + + schemas: + executor: marvin + steps: + - clean_checkout + - setup + - restore_build + - run: + name: Validate schemas + command: pnpm validate:schemas + + # ------------------------------------------------------------------------- + # Nightly: is unreleased AD4M about to break WE? + # + # Translation of `ad4m-compat.yaml`. Deliberately not a gate — it compiles + # against a branch, so its result depends on when it runs and on what somebody + # pushed to another repository. + # + # Two things the GitHub version does that this one does not yet: + # + # - **The control.** Upstream resolves "the last commit green on `dev`" from + # the Actions API and tests that, so a failure is attributable to AD4M by + # construction. The CircleCI equivalent needs this project's own run history, + # which does not exist until this config has been running for a while. Until + # then it tests `dev` HEAD and says so, which is upstream's documented + # `controlled=false` fallback. + # - **The tracking issue.** `gh issue create/edit/close` needs a token with + # `issues: write`. That is a project environment variable (`GH_TOKEN`), not + # something this file can provide; without it the job reports into the build + # log only. + # ------------------------------------------------------------------------- + ad4m-compat: + executor: marvin + steps: + - clean_checkout + - run: + name: Node from .nvmrc + command: | + export NVM_DIR="$HOME/.nvm" + # shellcheck disable=SC1091 + [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" --no-use + nvm install >/dev/null + echo "export PATH=\"$(dirname "$(nvm which "$(cat .nvmrc)")"):\$PATH\"" >> "$BASH_ENV" + - run: + name: pnpm from packageManager + command: | + corepack enable + corepack prepare --activate + + - run: + name: Clone AD4M + command: | + BRANCH="${AD4M_BRANCH:-dev}" + if ! git ls-remote --exit-code --heads https://github.com/coasys/ad4m.git "$BRANCH" >/dev/null 2>&1; then + echo "No such AD4M branch: $BRANCH" + exit 1 + fi + rm -rf ad4m + git clone --depth 1 --single-branch --branch "$BRANCH" https://github.com/coasys/ad4m.git ad4m + echo "AD4M $BRANCH at $(cd ad4m && git rev-parse HEAD)" + + # ad4m's monorepo uses object-format `pnpm.overrides`, which pnpm v10 + # rejects — so its install runs via `pnpm@9`, leaving the activated binary + # on the version WE uses. + - run: + name: Install & build AD4M packages + command: | + set -o pipefail + cd ad4m + npx -y pnpm@9.15.0 install --no-frozen-lockfile 2>&1 | tee -a ../compat.log + cd core && npx -y pnpm@9.15.0 exec tsc && npx -y pnpm@9.15.0 run bundle && cd .. + cd connect && npx -y pnpm@9.15.0 run build && cd .. + + # Point the workspace at the source build. This is why the install below + # cannot be frozen — and why this rewrite lives in a job whose verdict gates + # nothing. + - run: + name: Override AD4M packages with local builds + command: | + node -e " + const pkg = require('./package.json'); + pkg.pnpm = pkg.pnpm || {}; + pkg.pnpm.overrides = pkg.pnpm.overrides || {}; + pkg.pnpm.overrides['@coasys/ad4m'] = 'file:./ad4m/core'; + pkg.pnpm.overrides['@coasys/ad4m-connect'] = 'file:./ad4m/connect'; + require('fs').writeFileSync('./package.json', JSON.stringify(pkg, null, 2) + '\n'); + " + - run: + name: Install WE dependencies + command: | + set -o pipefail + pnpm install --no-frozen-lockfile 2>&1 | tee -a compat.log + - run: + name: Clear build caches for linked SDK + command: | + rm -rf node_modules/.cache + find . -name '.vite' -type d -path '*/node_modules/.vite' -not -path './ad4m/*' -exec rm -rf {} + 2>/dev/null || true + + # Build, typecheck and test only. Lint and the generated-file check answer + # questions about WE's own source that the required build already answered. + - run: + name: Build + command: | + set -o pipefail + NODE_OPTIONS='--max-old-space-size=8192' pnpm build 2>&1 | tee -a compat.log + - run: + name: Typecheck + command: | + set -o pipefail + pnpm typecheck 2>&1 | tee -a compat.log + - run: + name: Test + command: | + set -o pipefail + pnpm test 2>&1 | tee -a compat.log + + # The lines a reader wants, out of a log whose first two thousand lines are + # install chatter. + - run: + name: Report + when: on_fail + command: | + echo "### WE does not build against AD4M ${AD4M_BRANCH:-dev}" + echo + if [ -f compat.log ] && grep -qE 'error TS[0-9]+|ERR_PNPM|FAIL |✗ |AssertionError' compat.log; then + grep -E 'error TS[0-9]+|ERR_PNPM|FAIL |✗ |AssertionError' compat.log | head -25 + elif [ -f compat.log ]; then + tail -25 compat.log + else + echo "(no log captured — the failure happened before the build started)" + fi + - store_artifacts: + path: compat.log + destination: compat.log + +# --------------------------------------------------------------------------- +# Workflows +# --------------------------------------------------------------------------- + +workflows: + # Mirrors `build.yaml`: lint and rust need nothing built; typecheck, test and + # schemas take the build's *result* as a workspace rather than rebuilding it. + ci: + jobs: + - lint + - build + - rust + - typecheck: + requires: [build] + - test: + requires: [build] + - schemas: + requires: [build] + + # `on: schedule` at 03:00 UTC. Scheduled *pipelines* (the modern replacement for + # this block) are created through the API against a project that already exists, + # so this stays here until the project has run once. + nightly-ad4m-compat: + triggers: + - schedule: + cron: '0 3 * * *' + filters: + branches: + only: [dev] + jobs: + - ad4m-compat From a1817792a63c98632514e069aca08526bf2bf7e1 Mon Sep 17 00:00:00 2001 From: "Marvin (Paranoid Android)" Date: Wed, 9 Sep 2026 10:06:59 +0200 Subject: [PATCH 2/7] ci: trigger a push now that the CircleCI we pipeline exists From d976d158c90c3789309f67c0ce9b8770e5c3a760 Mon Sep 17 00:00:00 2001 From: "Marvin (Paranoid Android)" Date: Wed, 9 Sep 2026 10:10:19 +0200 Subject: [PATCH 3/7] ci: git clean -xffd, so a nested foreign repo cannot leave the tree dirty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner is shared with AD4M, whose jobs leave .hc-toolchain/ behind — a directory with its own .git. A single -f skips nested repositories ('Skipping repository .hc-toolchain/src'), so the tree stayed dirty and the guard refused to let the drift check run against it, which is what it is for. --- .circleci/config.yml | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 12ff95546..b08fbc41c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -67,10 +67,17 @@ commands: # - Stale `dist/` output from a previous commit makes a build that no # longer produces a file look like it still does. # - # `git clean -xfd` removes untracked *and* ignored files, which is what a + # `git clean -xffd` removes untracked *and* ignored files, which is what a # hosted runner gets for free. It costs a `node_modules` reinstall per job — # paid back by the pnpm store cache below, which survives because it lives # in the runner's home directory rather than the repository. + # + # The second `f` is load-bearing and was learned the hard way. This runner + # is shared with AD4M, whose jobs leave `.hc-toolchain/` behind — and that + # directory contains its own `.git`. A single `-f` *skips nested + # repositories* ("Skipping repository .hc-toolchain/src"), so the tree + # stayed dirty, and the guard below correctly refused to let the drift + # check run against it. - run: name: Reset working directory command: | @@ -79,13 +86,13 @@ commands: rm -rf .git fi if [ -d .git ]; then - git clean -xfd || true + git clean -xffd || true fi - checkout - run: name: Verify the tree is clean before anything runs command: | - git clean -xfd + git clean -xffd DIRT="$(git status --porcelain)" if [ -n "$DIRT" ]; then echo "Working tree is not clean after checkout — the drift check below cannot be trusted." From adc425e609d57f087b344a74dad6d0a48609670d Mon Sep 17 00:00:00 2001 From: "Marvin (Paranoid Android)" Date: Wed, 9 Sep 2026 10:17:33 +0200 Subject: [PATCH 4/7] =?UTF-8?q?ci:=20stop=20using=20CircleCI=20network=20s?= =?UTF-8?q?torage=20=E2=80=94=20it=20is=20billed,=20and=20we=20do=20not=20?= =?UTF-8?q?need=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit save_cache, persist_to_workspace and store_artifacts all upload to CircleCI and are charged even when the compute is free, which makes a self-hosted runner the expensive kind of free. Every job here runs on one host, so all three were paying to move bytes that never had to leave the machine. - pnpm store: no cache steps at all. It lives in ~/.local/share/pnpm/store, outside the repo, so git clean -xffd cannot reach it and it is already warm (6.8G on disk). The GitHub workflow caches it because a hosted runner starts empty; this one does not. - build output: handed between jobs through $CI_ARTIFACT_DIR/we/$CIRCLE_SHA1, the same local-stash mechanism ad4m's config already uses for its binaries, plus the same one-day sweep so it cannot grow forever. - compat.log: kept on the runner, tail printed into the failing build log. Also faster: no upload or download on either side. --- .circleci/config.yml | 95 ++++++++++++++++++++++++++------------------ 1 file changed, 56 insertions(+), 39 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index b08fbc41c..688e5d57c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,10 +24,25 @@ version: 2.1 # — on a hosted runner the tree is pristine by construction, here it is not. See # `clean_checkout` below. # -# **No `actions/*`.** `setup-node`, `pnpm/action-setup`, `actions/cache` and -# `upload/download-artifact` have no equivalents; they are `nvm`, `corepack`, -# `save_cache`/`restore_cache` and `persist_to_workspace`/`attach_workspace` -# respectively. +# **No `actions/*`.** `setup-node` and `pnpm/action-setup` become `nvm` and +# `corepack`. +# +# **Nothing here uses CircleCI network storage.** `save_cache`, +# `persist_to_workspace` and `store_artifacts` all upload to CircleCI and are +# billed even when the compute is free — so a self-hosted runner that used them +# would quietly be the expensive kind of free. Every job in this file runs on the +# same physical host, which makes all three unnecessary: +# +# - the pnpm store already lives in `~/.local/share/pnpm/store`, outside the +# repository, so it survives `git clean` and every job finds it warm. That is +# `actions/cache` done by not needing one. +# - build output moves between jobs through `$CI_ARTIFACT_DIR`, a local +# directory keyed by `$CIRCLE_SHA1` — the same mechanism AD4M's config uses +# to hand its binaries between jobs, for the same reason. +# +# Keep it that way. If a job ever needs to publish something a human downloads +# from the CircleCI UI, that is the one case worth paying for, and it should be +# an explicit decision rather than a habit. # # # ## What has no equivalent and is a project setting instead @@ -133,26 +148,17 @@ commands: corepack prepare --activate echo "pnpm: $(pnpm --version) (packageManager: $(node -p "require('./package.json').packageManager"))" - # Cache the pnpm content-addressable store, not node_modules — the workspace - # nests packages 2–4 levels deep, so a node_modules key misses most of it. - # - # `restore_keys` in the GitHub action becomes the second, less specific key - # here: an exact lockfile match first, then the newest store from any - # previous run, which pnpm tops up rather than rebuilding. - - run: - name: Resolve pnpm store path - command: echo "export PNPM_STORE_PATH=$(pnpm store path --silent)" >> "$BASH_ENV" - - restore_cache: - keys: - - pnpm-store-v1-{{ checksum "pnpm-lock.yaml" }} - - pnpm-store-v1- + # No `restore_cache`/`save_cache`, deliberately. The GitHub workflow caches + # the pnpm content-addressable store because a hosted runner starts empty; + # this one does not. The store lives in the runner user's home directory, + # not the repository, so `git clean -xffd` above cannot reach it and it is + # already warm — uploading it to CircleCI would cost money to replace a + # directory that is sitting on the same disk. - run: name: Install dependencies - command: pnpm install --frozen-lockfile - - save_cache: - key: pnpm-store-v1-{{ checksum "pnpm-lock.yaml" }} - paths: - - /home/marvin/.local/share/pnpm/store + command: | + echo "pnpm store: $(pnpm store path --silent)" + pnpm install --frozen-lockfile restore_build: description: > @@ -162,13 +168,19 @@ commands: # Must run *after* `setup`, not before: `pnpm install` writes into the same # package directories, and the build output is what should win. Same ordering # constraint the composite action documents. - - attach_workspace: - at: /tmp/we-build + # + # Read from local disk rather than a CircleCI workspace: every job runs on + # this one host, so the tarball never needs to leave it. - run: name: Unpack build outputs command: | - tar -xzf /tmp/we-build/build-outputs.tar.gz - echo "Restored $(tar -tzf /tmp/we-build/build-outputs.tar.gz | wc -l) paths." + STASH="${CI_ARTIFACT_DIR:-/var/lib/ci-artifacts}/we/${CIRCLE_SHA1}" + if [ ! -f "$STASH/build-outputs.tar.gz" ]; then + echo "No build output at $STASH — did the build job run on this host?" + exit 1 + fi + tar -xzf "$STASH/build-outputs.tar.gz" + echo "Restored $(tar -tzf "$STASH/build-outputs.tar.gz" | wc -l) paths from $STASH." # --------------------------------------------------------------------------- # Jobs — one per GitHub Actions job, same names, same order @@ -219,27 +231,32 @@ jobs: # Derived from `git status --ignored` rather than a path list, for the same # reason the GitHub workflow gives: the outputs are not only `dist/` # directories, and a hand-listed set would miss the next generator. + # + # `upload-artifact` → a directory on this host. The tarball is read by this + # pipeline's own downstream jobs and by nothing else afterwards — which is + # what the GitHub side expresses as `retention-days: 1`, and what the sweep + # below does without paying CircleCI to hold it. - run: name: Package build outputs command: | - mkdir -p /tmp/we-build + STASH="${CI_ARTIFACT_DIR:-/var/lib/ci-artifacts}/we/${CIRCLE_SHA1}" + mkdir -p "$STASH" git status --porcelain --ignored=traditional \ | awk '$1 == "!!" { print $2 }' \ | grep -vE '(^|/)node_modules/$' \ > build-outputs.txt echo "Packaging $(wc -l < build-outputs.txt) paths:" cat build-outputs.txt - tar -czf /tmp/we-build/build-outputs.tar.gz -T build-outputs.txt + tar -czf "$STASH/build-outputs.tar.gz" -T build-outputs.txt rm build-outputs.txt + echo "Stashed at $STASH" - # `upload-artifact` → workspace. A workspace is the right primitive rather - # than `store_artifacts`: this tarball is read by this pipeline's own - # downstream jobs and by nothing else afterwards, which is exactly what the - # GitHub side expresses as `retention-days: 1`. - - persist_to_workspace: - root: /tmp/we-build - paths: - - build-outputs.tar.gz + # Same one-day sweep AD4M's config runs, so the stash cannot grow forever. + - run: + name: Sweep build stashes older than a day + command: | + ROOT="${CI_ARTIFACT_DIR:-/var/lib/ci-artifacts}/we" + find "$ROOT" -maxdepth 1 -mindepth 1 -mtime +1 -exec rm -rf {} + 2>/dev/null || true typecheck: executor: marvin @@ -450,9 +467,9 @@ jobs: else echo "(no log captured — the failure happened before the build started)" fi - - store_artifacts: - path: compat.log - destination: compat.log + echo + echo "Full log kept on the runner at: ${CI_ARTIFACT_DIR:-/var/lib/ci-artifacts}/we/compat-${CIRCLE_SHA1}.log" + cp compat.log "${CI_ARTIFACT_DIR:-/var/lib/ci-artifacts}/we/compat-${CIRCLE_SHA1}.log" 2>/dev/null || true # --------------------------------------------------------------------------- # Workflows From a32ee7453457677749d29d4573a0d3f58aeba02d Mon Sep 17 00:00:00 2001 From: "Marvin (Paranoid Android)" Date: Wed, 9 Sep 2026 10:32:18 +0200 Subject: [PATCH 5/7] ci: move WE to its own runner pool, coasys/marvin-we MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WE shared coasys/marvin with AD4M for one afternoon, which was enough to show why it should not. Both projects land in the same runner working directory, so WE's clean_checkout wiped AD4M's cached build trees out of all four of them — and AD4M's leftovers had already broken WE's first run. Four dedicated runner instances (marvin-we1..we4), each with its own working directory under /home/marvin/we-ci-workdir-N. Same hardware, separate pools, no shared directory to fight over. Sizing is deliberate rather than symmetric: lint and build each set --max-old-space-size=8192, so four concurrent WE jobs is the honest ceiling on a 59GB box that is also running AD4M's four. Grow it after measuring peak usage, not before. The -ff in git clean stays. The dedicated class removes that particular neighbour, but WE's own tooling is free to write a nested repo into an ignored directory, and this guard should not have to be rediscovered the day it does. --- .circleci/config.yml | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 688e5d57c..5679ff906 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,11 +4,17 @@ version: 2.1 # `ad4m-compat.yaml`), running on Coasys's self-hosted machine runner. # # Why this exists alongside the GitHub Actions workflows: WE's tests are going to -# need a real AD4M executor, and a hosted runner cannot have one. `coasys/marvin` +# need a real AD4M executor, and a hosted runner cannot have one. The runner host # is a physical box that already builds AD4M, so it can hold an executor, a # Holochain conductor and a bootstrap server between jobs. This file is step one — # the same checks GitHub Actions already runs, running there instead. # +# WE has its own resource class, `coasys/marvin-we`, with four runner instances +# and their own working directories. It shared `coasys/marvin` with AD4M for +# exactly one afternoon, which was enough to show why it should not: the two +# projects landed in the same directory, so each one's cleanup destroyed the +# other's cached build tree. Same hardware, separate pools. +# # # ## What a self-hosted machine runner changes # @@ -58,7 +64,7 @@ version: 2.1 executors: marvin: machine: true - resource_class: coasys/marvin + resource_class: coasys/marvin-we # --------------------------------------------------------------------------- # Commands — the composite actions from `.github/actions/` @@ -87,12 +93,17 @@ commands: # paid back by the pnpm store cache below, which survives because it lives # in the runner's home directory rather than the repository. # - # The second `f` is load-bearing and was learned the hard way. This runner - # is shared with AD4M, whose jobs leave `.hc-toolchain/` behind — and that - # directory contains its own `.git`. A single `-f` *skips nested + # The second `f` is load-bearing and was learned the hard way, back when + # this pool was shared with AD4M: its jobs leave `.hc-toolchain/` behind, + # and that directory contains its own `.git`. A single `-f` *skips nested # repositories* ("Skipping repository .hc-toolchain/src"), so the tree - # stayed dirty, and the guard below correctly refused to let the drift - # check run against it. + # stayed dirty and the guard below correctly refused to run the drift check + # against it. + # + # The dedicated resource class removes that particular neighbour, but the + # `-ff` stays: WE's own tooling is free to write a nested repo into an + # ignored directory at any point, and this guard should not have to be + # rediscovered the day it does. - run: name: Reset working directory command: | From d1493e3bf57a4bfb007ce04e61f09e2ff513debb Mon Sep 17 00:00:00 2001 From: "Marvin (Paranoid Android)" Date: Wed, 9 Sep 2026 10:36:57 +0200 Subject: [PATCH 6/7] ci: deactivate the GitHub Actions workflows, CircleCI owns these checks now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build.yaml and ad4m-compat.yaml drop their automatic triggers. The same six jobs and the same nightly now run on CircleCI against the self-hosted coasys/marvin-we pool, and running both would mean every push paying twice for the same answers and two check lists to read on every PR. Deactivated, not deleted, and the distinction is deliberate. Those files carry the reasoning the CircleCI translation was built from — why there are five jobs and not one, why the drift check has no exclusions, why the build outputs are derived from git status --ignored rather than a path list, why the nightly tests the last green commit rather than HEAD. Deleting the files would delete the argument and keep only the result. workflow_dispatch stays on both. If CircleCI or the runner host is unavailable this is the fallback, and a fallback that has never been runnable is not one. It also remains the way to run a WE branch against an AD4M branch carrying an unpublished API. Each file says what to restore to reactivate it. --- .github/workflows/ad4m-compat.yaml | 22 +++++++++++++++++++--- .github/workflows/build.yaml | 22 +++++++++++++++++++--- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ad4m-compat.yaml b/.github/workflows/ad4m-compat.yaml index a947911c0..70a8bbdee 100644 --- a/.github/workflows/ad4m-compat.yaml +++ b/.github/workflows/ad4m-compat.yaml @@ -29,10 +29,26 @@ name: AD4M compatibility # human reading the log and knowing the codebase. It also means a long-red `dev` no longer # blinds this job — it keeps answering about the last thing that worked. +# Deactivated: the nightly now runs on CircleCI (`.circleci/config.yml`, +# workflow `nightly-ad4m-compat`), 03:00 UTC, on the self-hosted +# `coasys/marvin-we` pool. Two of these running would file and close the same +# tracking issue against each other. +# +# Only the schedule is removed. `workflow_dispatch` stays, and everything the +# manual path does still works: this remains the way to run a WE branch against +# an AD4M branch carrying an unpublished API, and it is the fallback if the +# runner host is unavailable. +# +# Note for whoever reactivates this: the CircleCI translation does *not* yet +# reproduce the control below — testing the last commit green on `dev` rather +# than HEAD — because it needs run history that project did not have on day one. +# The reasoning for why that control exists is in the header above, and is worth +# reading before deciding which of the two should own this job long-term. +# +# To reactivate, restore: +# schedule: +# - cron: '0 3 * * *' on: - schedule: - # 03:00 UTC daily — after AD4M's working day, before WE's. - - cron: '0 3 * * *' workflow_dispatch: inputs: ad4m_branch: diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a10b317e4..ebc5c8ba5 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -5,10 +5,26 @@ # history. name: CI +# Deactivated: these checks now run on CircleCI (`.circleci/config.yml`), on the +# self-hosted `coasys/marvin-we` pool. Running both would mean every push paying +# twice for the same six answers, and two check lists to read on every PR. +# +# Deactivated rather than deleted, and the distinction is deliberate: everything +# below — why there are five jobs and not one, why the drift check has no +# exclusions, why the build outputs are derived from `git status --ignored` +# rather than a path list — is the reasoning the CircleCI translation was built +# from. Deleting the file would delete the argument and keep only the result. +# +# `workflow_dispatch` stays so this can be run by hand: if CircleCI or the runner +# host is ever unavailable, this is the fallback, and a fallback that has never +# been runnable is not one. +# +# To reactivate, restore: +# push: +# branches: [dev] +# pull_request: on: - push: - branches: [dev] - pull_request: + workflow_dispatch: # One run per branch/PR — a second push cancels the in-flight run instead of # duplicating the work. From 434cb15bf9c234a037b269b2188e139b70769d7c Mon Sep 17 00:00:00 2001 From: "Marvin (Paranoid Android)" Date: Wed, 16 Sep 2026 13:05:10 +0200 Subject: [PATCH 7/7] ci: keep fork PRs on Actions, keep the nightly, document the settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review items from #193. Forked pull requests. The CircleCI project reports build-fork-prs off (verified via the settings API), so removing `pull_request` from build.yaml would have left fork PRs with no CI at all — CircleCI declines to build them and Actions no longer would either. Nine PRs in this repo came from forks, so it is a live path. Turning the setting on is the wrong fix: it runs a stranger's code on the Coasys host beside ~/.cargo, the pnpm store and $CI_ARTIFACT_DIR. So build.yaml keeps `pull_request` with every job gated on github.event.pull_request.head.repo.fork. Forks get hosted CI with a restricted token, same-repo branches get CircleCI, and no PR shows two check lists. The nightly. ad4m-compat.yaml keeps its schedule. The CircleCI translation cannot replace it yet: no GH_TOKEN, so it cannot open, update or close the tracking issue, and nothing tells a person when it fails. Deactivating it would freeze #176 open and send the nightly answer to a build log nobody reads. It moves in its own PR once the CircleCI side has a channel that reaches a person and resolves its control from commit statuses rather than Actions run history. Settings block now records verified values rather than intentions: auto-cancel off, build-fork-prs off, build-prs-only off. Adds the one behavioural difference with no setting to fix it — CircleCI builds the branch head where Actions built refs/pull/N/merge, so a PR green on its own can still break dev on merge. Also: single-host is documented as a constraint on the pool, since restore_build hands build output over a local directory; the three copies of "Node from .nvmrc" collapse into a node_from_nvmrc command (two of them wrote $BASH_ENV without sourcing it, which only works until someone calls node in the same step); and the compat log line says "for a day" rather than implying an archive the build job's -mtime +1 sweep removes. --- .circleci/config.yml | 119 ++++++++++++++++++++--------- .github/workflows/ad4m-compat.yaml | 16 +++- .github/workflows/build.yaml | 28 ++++++- 3 files changed, 123 insertions(+), 40 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 5679ff906..03649afe8 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -53,14 +53,57 @@ version: 2.1 # # ## What has no equivalent and is a project setting instead # +# Verified against the project settings API on 2026-09-16; the values below are +# what the project actually reports, not what it ought to be. +# # `concurrency: cancel-in-progress` — CircleCI expresses this as the project's # "Auto-cancel redundant workflows" toggle (Project Settings → Advanced), not in -# config. It must be turned on to match the GitHub behaviour. +# config. **Currently off.** It must be turned on to match the GitHub behaviour; +# on a four-instance pool a push storm otherwise occupies the pool with runs +# nobody is waiting for. +# +# **Build forked pull requests — currently off, and should stay off.** This +# repository is public and every job here runs directly on the Coasys host as +# `marvin`, beside `~/.cargo`, `~/.nvm`, the pnpm store and `$CI_ARTIFACT_DIR`, +# on the machine that also builds AD4M. Turning it on would execute an arbitrary +# stranger's pull request there. Nine pull requests in this repository's history +# came from forks, so this is a live path and not a hypothetical one. +# +# Because it is off, forks get no CI from CircleCI at all. That is why +# `build.yaml` keeps its `pull_request` trigger with every job gated on +# `github.event.pull_request.head.repo.fork`: a fork is tested on GitHub's +# hosted runners with a restricted token, a same-repo branch is tested here, and +# no pull request ever shows two check lists. +# +# **Only build pull requests — currently off.** GitHub ran `build.yaml` on +# `pull_request` plus pushes to `dev`; config-level branch filters cannot express +# that shape, so the `ci` workflow below currently runs on every push to every +# branch. Turning this on is the closest equivalent (it also always builds the +# default branch), and it matters on a four-instance pool. +# +# +# ## One genuine behavioural difference, with no setting to fix it +# +# **CircleCI builds the branch head; Actions built the merge commit.** +# `pull_request` on GitHub checks out `refs/pull/N/merge` — the pull request as +# it would be *after* merging into `dev`. CircleCI checks out `CIRCLE_SHA1`, the +# branch as it is. Nothing forces branches up to date, so a pull request that is +# green on its own can still break `dev` on merge, and no check will have said +# so. Worth knowing when reading a green check list; the mitigation is keeping +# branches current, not a config change. # --------------------------------------------------------------------------- # Executor # --------------------------------------------------------------------------- +# All four instances in this pool run on one physical host. That is not a +# nicety: `restore_build` reads the build's output back from a local directory +# rather than a CircleCI workspace, which only works because the job that wrote +# it and the job that reads it share a filesystem. If the pool is ever spread +# across machines, that hand-off breaks first — loudly, since the restore fails +# on a missing path rather than silently rebuilding, but it breaks. Treat +# single-host as a constraint on changes to this pool, not an implementation +# detail of it. executors: marvin: machine: true @@ -71,6 +114,38 @@ executors: # --------------------------------------------------------------------------- commands: + node_from_nvmrc: + description: > + Put the Node version named in `.nvmrc` on PATH, for this step and every + later one. The translation of `actions/setup-node` with + `node-version-file: .nvmrc`. + steps: + # The GitHub action reads `node-version-file: .nvmrc`; nvm reads the same + # file natively, so the version stays declared in exactly one place. + # + # `nvm install` is a no-op when the version is already present, so this + # costs nothing on a warm host and self-heals a cold one. + # + # Three jobs needed this and each carried its own copy. Two of those copies + # wrote `$BASH_ENV` without sourcing it, which works only because CircleCI + # sources that file between steps — so `node` was on PATH for the *next* + # step but not the rest of this one. That is a trap rather than a bug: it + # holds until someone adds a `node` call beside the install and cannot see + # why it is not found. Sourcing here makes the command correct within its + # own step as well as after it. + - run: + name: Node from .nvmrc + command: | + export NVM_DIR="$HOME/.nvm" + # shellcheck disable=SC1091 + [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" --no-use + nvm install >/dev/null + NODE_BIN_DIR="$(dirname "$(nvm which "$(cat .nvmrc)")")" + echo "export PATH=\"$NODE_BIN_DIR:\$PATH\"" >> "$BASH_ENV" + # shellcheck disable=SC1090 + . "$BASH_ENV" + echo "Node: $(node --version) (.nvmrc: $(cat .nvmrc))" + clean_checkout: description: > Check out the commit under test into a tree with no leftovers from the @@ -131,23 +206,7 @@ commands: Node, pnpm, a warm pnpm store and a frozen install — the translation of `.github/actions/setup`. steps: - # The GitHub action reads `node-version-file: .nvmrc`; nvm reads the same - # file natively, so the version stays declared in exactly one place. - # - # `nvm install` is a no-op when the version is already present, so this - # costs nothing on a warm host and self-heals a cold one. - - run: - name: Node from .nvmrc - command: | - export NVM_DIR="$HOME/.nvm" - # shellcheck disable=SC1091 - [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" --no-use - nvm install >/dev/null - NODE_BIN_DIR="$(dirname "$(nvm which "$(cat .nvmrc)")")" - echo "export PATH=\"$NODE_BIN_DIR:\$PATH\"" >> "$BASH_ENV" - # shellcheck disable=SC1090 - . "$BASH_ENV" - echo "Node: $(node --version) (.nvmrc: $(cat .nvmrc))" + - node_from_nvmrc # `pnpm/action-setup` with no `version` reads the repo's `packageManager` # field. corepack does the same thing from the same field, so CI resolves @@ -301,14 +360,7 @@ jobs: # standard library, so this job needs neither `pnpm install` nor the # workspace — which is why it stays independent of Build. - clean_checkout - - run: - name: Node from .nvmrc - command: | - export NVM_DIR="$HOME/.nvm" - # shellcheck disable=SC1091 - [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" --no-use - nvm install >/dev/null - echo "export PATH=\"$(dirname "$(nvm which "$(cat .nvmrc)")"):\$PATH\"" >> "$BASH_ENV" + - node_from_nvmrc # rustfmt for the toolchain the crate pins, which is not the default one. # The channel is read out of `rust-toolchain.toml` rather than written here, @@ -382,14 +434,7 @@ jobs: executor: marvin steps: - clean_checkout - - run: - name: Node from .nvmrc - command: | - export NVM_DIR="$HOME/.nvm" - # shellcheck disable=SC1091 - [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" --no-use - nvm install >/dev/null - echo "export PATH=\"$(dirname "$(nvm which "$(cat .nvmrc)")"):\$PATH\"" >> "$BASH_ENV" + - node_from_nvmrc - run: name: pnpm from packageManager command: | @@ -479,7 +524,11 @@ jobs: echo "(no log captured — the failure happened before the build started)" fi echo - echo "Full log kept on the runner at: ${CI_ARTIFACT_DIR:-/var/lib/ci-artifacts}/we/compat-${CIRCLE_SHA1}.log" + # "for a day", not "kept": the `build` job sweeps this same directory + # with `-mtime +1`, so this file is gone within about 24 hours. Long + # enough to read after a nightly, not an archive — say so rather than + # imply a permanence the sweep removes. + echo "Full log on the runner for a day at: ${CI_ARTIFACT_DIR:-/var/lib/ci-artifacts}/we/compat-${CIRCLE_SHA1}.log" cp compat.log "${CI_ARTIFACT_DIR:-/var/lib/ci-artifacts}/we/compat-${CIRCLE_SHA1}.log" 2>/dev/null || true # --------------------------------------------------------------------------- diff --git a/.github/workflows/ad4m-compat.yaml b/.github/workflows/ad4m-compat.yaml index 70a8bbdee..082f0701a 100644 --- a/.github/workflows/ad4m-compat.yaml +++ b/.github/workflows/ad4m-compat.yaml @@ -45,10 +45,20 @@ name: AD4M compatibility # The reasoning for why that control exists is in the header above, and is worth # reading before deciding which of the two should own this job long-term. # -# To reactivate, restore: -# schedule: -# - cron: '0 3 * * *' +# The `schedule` stays here for now, and deliberately so. The CircleCI +# translation of this job cannot replace it yet: it has no `GH_TOKEN`, so it +# cannot open, update or close the tracking issue, and nothing is configured to +# tell a person when it fails. Deactivating the schedule here would freeze issue +# #176 open — only the scheduled path closes it — and would leave the nightly +# answer going into a build log nobody reads. +# +# A red nightly that files a slightly wrong issue is bad. A red nightly nobody is +# told about is worse. So the nightly moves in its own pull request, once the +# CircleCI side has a channel that reaches a person and the control is resolved +# from commit statuses rather than Actions run history. on: + schedule: + - cron: '0 3 * * *' workflow_dispatch: inputs: ad4m_branch: diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index ebc5c8ba5..baa245514 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -19,12 +19,24 @@ name: CI # host is ever unavailable, this is the fallback, and a fallback that has never # been runnable is not one. # -# To reactivate, restore: +# `pull_request` also stays, but every job is gated on the PR coming from a fork. +# This repository is public and the CircleCI project has **Build forked pull +# requests off** (verified via the project settings API, 2026-09-16). Without +# this gate a fork PR would get no CI at all once these checks moved: CircleCI +# declines to build it and Actions no longer would either. Turning that setting +# on is the wrong fix — it runs a stranger's code directly on the Coasys host, +# beside `~/.cargo`, the pnpm store and `$CI_ARTIFACT_DIR`. +# +# So the split is by trust, not by system: a fork gets hosted CI on GitHub's +# runners with a restricted token, a same-repo branch gets CircleCI on the +# self-hosted pool, and no pull request ever shows two check lists. +# +# To also restore pushes to `dev` here, add: # push: # branches: [dev] -# pull_request: on: workflow_dispatch: + pull_request: # One run per branch/PR — a second push cancels the in-flight run instead of # duplicating the work. @@ -49,6 +61,8 @@ concurrency: jobs: lint: name: Lint + # Forks only — see the `on:` block. Same condition on all six jobs. + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.fork runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -83,6 +97,8 @@ jobs: build: name: Build + # Forks only — see the `on:` block. Same condition on all six jobs. + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.fork runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -154,6 +170,8 @@ jobs: typecheck: name: Typecheck needs: build + # Forks only — see the `on:` block. Same condition on all six jobs. + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.fork runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -166,6 +184,8 @@ jobs: test: name: Test needs: build + # Forks only — see the `on:` block. Same condition on all six jobs. + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.fork runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -222,6 +242,8 @@ jobs: # close: once the crate resolves, add the `cargo test` step below it and drop this note. rust: name: Rust formatting + # Forks only — see the `on:` block. Same condition on all six jobs. + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.fork runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -292,6 +314,8 @@ jobs: schemas: name: Validate schemas needs: build + # Forks only — see the `on:` block. Same condition on all six jobs. + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.fork runs-on: ubuntu-latest steps: - uses: actions/checkout@v7