diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 000000000..03649afe8 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,564 @@ +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. 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 +# +# **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` 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 +# +# 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. **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 + resource_class: coasys/marvin-we + +# --------------------------------------------------------------------------- +# Commands — the composite actions from `.github/actions/` +# --------------------------------------------------------------------------- + +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 + 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 -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, 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 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: | + 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 -xffd || true + fi + - checkout + - run: + name: Verify the tree is clean before anything runs + command: | + 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." + echo "$DIRT" + exit 1 + fi + + setup: + description: > + Node, pnpm, a warm pnpm store and a frozen install — the translation of + `.github/actions/setup`. + steps: + - 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 + # 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"))" + + # 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: | + echo "pnpm store: $(pnpm store path --silent)" + pnpm install --frozen-lockfile + + 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. + # + # 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: | + 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 +# --------------------------------------------------------------------------- + +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. + # + # `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: | + 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 "$STASH/build-outputs.tar.gz" -T build-outputs.txt + rm build-outputs.txt + echo "Stashed at $STASH" + + # 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 + 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 + - 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, + # 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 + - node_from_nvmrc + - 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 + echo + # "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 + +# --------------------------------------------------------------------------- +# 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 diff --git a/.github/workflows/ad4m-compat.yaml b/.github/workflows/ad4m-compat.yaml index a947911c0..082f0701a 100644 --- a/.github/workflows/ad4m-compat.yaml +++ b/.github/workflows/ad4m-compat.yaml @@ -29,9 +29,35 @@ 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. +# +# 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: - # 03:00 UTC daily — after AD4M's working day, before WE's. - cron: '0 3 * * *' workflow_dispatch: inputs: diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a10b317e4..baa245514 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -5,9 +5,37 @@ # 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. +# +# `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] on: - push: - branches: [dev] + workflow_dispatch: pull_request: # One run per branch/PR — a second push cancels the in-flight run instead of @@ -33,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 @@ -67,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 @@ -138,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 @@ -150,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 @@ -206,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 @@ -276,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