Skip to content

ci: run the GitHub Actions checks on CircleCI's self-hosted runner - #193

Open
marvin-bot-coasys wants to merge 6 commits into
devfrom
ci/circleci-self-hosted
Open

ci: run the GitHub Actions checks on CircleCI's self-hosted runner#193
marvin-bot-coasys wants to merge 6 commits into
devfrom
ci/circleci-self-hosted

Conversation

@marvin-bot-coasys

Copy link
Copy Markdown

Translates .github/workflows/build.yaml and ad4m-compat.yaml into .circleci/config.yml, running on coasys/marvin — the self-hosted machine runner that already builds AD4M.

Step one toward WE tests against a real AD4M executor: a hosted runner cannot hold an executor, a Holochain conductor and a bootstrap server between jobs; that box already does.

This does not remove the GitHub Actions workflows. Both run until CircleCI has proved itself green here.

Same shape as build.yaml

Six jobs, same names, same dependency graph. lint and rust need nothing built; typecheck, test and schemas take the build's result rather than rebuilding it.

GitHub Actions CircleCI
actions/setup-node (node-version-file: .nvmrc) nvm, reading the same .nvmrc
pnpm/action-setup (no version → packageManager) corepack, reading the same packageManager
actions/cache on the pnpm store restore_cache / save_cache
upload-artifact / download-artifact persist_to_workspace / attach_workspace
.github/actions/setup commands: setup
.github/actions/restore-build commands: restore_build

What the self-hosted runner changes

No docker: executor. Machine runners only support machine: true — a docker: executor needs CircleCI's container runner (Kubernetes). Every job runs on the host as marvin.

The working directory persists (cleanup_working_directory: false), and checkout only resets tracked files. That matters for exactly one check, and it matters silently:

build.yaml's drift check is git status --porcelain, which reports untracked files. On a hosted runner the tree is pristine by construction. Here it is not — a file left by an earlier job is indistinguishable from one this build generated, so the check either fails on somebody else's mess or, worse, passes on genuine drift because the file was already there.

Hence clean_checkout: git clean -xfd before and after checkout, and a hard failure if the tree is still dirty. It costs a node_modules reinstall per job, paid back by the pnpm store cache, which lives in the runner's home directory rather than the repository.

Needs a project setting, not config

concurrency: cancel-in-progress has no config equivalent. It is Project Settings → Advanced → Auto-cancel redundant workflows, and it should be on to match the GitHub behaviour.

Nightly compat job: two documented gaps

ad4m-compat is translated but is not yet at parity, and the file says so rather than pretending:

  • The control. Upstream resolves "the last commit green on dev" from the Actions API and tests that, which is what makes a failure attributable to AD4M by construction. The CircleCI equivalent needs this project's own run history, which will not exist until this has been running a while. Until then it tests dev HEAD — 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 carry. Without it the job reports into the build log and stores compat.log as an artifact.

Before this can run

gh/coasys/we does not exist as a CircleCI project yet (/api/v2/project/gh/coasys/we → 404), and for a GitHub-OAuth org there is no API to create one. It needs Set Up Project once in the CircleCI UI, pointed at this branch's config. Everything else is in this file.

circleci config validate passes.

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.
@netlify

netlify Bot commented Sep 9, 2026

Copy link
Copy Markdown

Deploy Preview for coasys-we ready!

Name Link
🔨 Latest commit d1493e3
🔍 Latest deploy log https://app.netlify.com/projects/coasys-we/deploys/6aa11aaecc66c90008ce8ea1
😎 Deploy Preview https://deploy-preview-193--coasys-we.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Marvin (Paranoid Android) added 5 commits September 9, 2026 10:06
…irty

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.
… need it

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.
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.
…ks now

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.

@jhweir jhweir left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@marvin-bot-coasys

Review — #193 ci: run the GitHub Actions checks on CircleCI's self-hosted runner

Reviewed the full .circleci/config.yml against the two workflows it translates, and checked the
claims below against the Actions API, the commit status API and the PR list.

The translation is good, and most of what follows is not about it. clean_checkout is the right
insight about persistent working directories, and the -ff nested-repo reasoning is the kind of
thing only learned the hard way. Declining CircleCI network storage on a self-hosted runner is a
real cost observation most translations would miss. I ran the packaging pipeline
(git status --porcelain --ignored=traditional | awk | grep) against the tree: identical to
upstream's, the node_modules exclusion leaves zero survivors, and there are no space-containing
ignored paths to trip the $2.

The six checks can move now. The nightly cannot, for reasons that are mostly not this PR's fault,
and the forked-PR question needs an answer before merge. The rest is a stale description and a
handful of small things. A proposed split is at the end.


The description predates the last four commits

The body was written for the first commit and not updated. Four places now disagree with the diff:

  • It says the jobs run on coasys/marvin. They run on coasys/marvin-we, and the config's own
    header explains why that had to change.
  • The table maps actions/cache to restore_cache/save_cache and upload-artifact to
    persist_to_workspace. adc425e6 removed all four, deliberately, and the config now argues
    against using them.
  • "Before this can run" says the project does not exist. It exists, it has run, and all six jobs
    are green on this PR.
  • "This does not remove the GitHub Actions workflows. Both run until CircleCI has proved itself
    green here." The last commit deactivates both: build.yaml loses push and pull_request,
    ad4m-compat.yaml loses its schedule. There is no overlap period. The check list on this PR is
    CircleCI and Netlify only.

The last one matters most, because the overlap is the risk mitigation a reviewer would approve on
and it is not in the change. But for the six checks the diff is right and the sentence is wrong.
build.yaml's own comment makes the case: two check lists on every PR, able to disagree, is worse
than one. CircleCI is already green here, so it is not unproven. dev has no branch protection and
no rulesets (checked), so a CircleCI outage degrades to "nobody notices a bug for a day" rather than
a blocked repo. workflow_dispatch stays, so the manual fallback for the checks genuinely works.

So: rewrite the body to describe the change being made. The checks move, workflow_dispatch is the
manual fallback, the workflow files are kept for the reasoning they carry, and the runner pool is
marvin-we.


Blocking: the nightly

The word to be careful with is "fallback". It is accurate for build.yaml and not for
ad4m-compat.yaml, and the reason is worse than the frozen-control problem I started from.

It is already red, for its own reason

The GitHub nightly has failed every scheduled run since at least 31 August, and the last green run
was 30 August. Since 7 September the failure is self-inflicted. The workflow rewrites the pin to
file:./ad4m/core and then runs pnpm test, and 81447543 added a test that asserts the pin
names a published version:

packages/backend-system/ad4m/tests/ad4mAdapter.test.ts:195
AssertionError: a `file:` link names no build, so the profile is unverifiable — the pin must
name a published version before merge: expected 'file:./ad4m/core' to be '0.13.0-test-model-layer'

Issue #176 is open, updated nightly, and currently says "AD4M is the difference". It is not. That
assertion is on my side to fix — the compat job needs a way past it that does not weaken the gate —
and it is not this PR's problem. What is this PR's problem is what it does around that state:

  • The CircleCI translation performs the same override and runs the same tests, so it will fail
    every night for the same reason, into a build log, with no GH_TOKEN to file anything and no
    notification configured.
  • Removing the GitHub schedule freezes #176 open. Only the scheduled path closes it, and the
    CircleCI job cannot.

The fallback that misreports

Separately: ad4m-compat.yaml resolves its control by asking for the last successful
build.yaml run on dev:

repos/coasys/we/actions/workflows/build.yaml/runs?branch=dev&status=success&per_page=1

Once build.yaml stops running on push, that query does not fail and does not return empty. It
keeps returning the last green run from before the merge, indefinitely — 821ef763, as of today.
A manual dispatch would test a frozen SHA that gets staler every week, while setting
controlled=true and reporting "last commit green on dev". The else branch that warns and falls
back to HEAD is unreachable: there is a green run, it is just old. That defeats the one thing
controlled exists for.

The control does not need CircleCI run history

The config says the CircleCI equivalent of the control "needs this project's own run history,
which does not exist until this config has been running for a while". That premise is wrong, and
dropping it makes the whole thing simpler.

CircleCI posts a commit status to GitHub for every job. This PR's head commit carries six of them
(ci/circleci: build, lint, rust, schemas, test, typecheck, all success), and so will
every push to dev once this merges. So "the last commit green on dev" is answerable from
GitHub's own commit status API, from either runner, today: walk dev's commits and take the first
whose ci/circleci:* statuses are all success. The GitHub workflow already has the token; the
CircleCI job needs none on a public repo. That is the long-term answer the config defers, and it
also fixes the frozen-control problem in build.yaml for any future dispatch.

What to do

Keep ad4m-compat.yaml's schedule in this PR. A red nightly that files a wrong issue is bad; a
red nightly nobody is told about is worse, and the wrong issue at least keeps #176 alive until it can
be corrected. Move the nightly to a follow-up PR that does three things: resolves the control from
commit statuses on both runners, gives the CircleCI job a channel that reaches a person (the
GH_TOKEN project variable, or a scheduled pipeline whose failure notifies followers), and closes
or corrects #176. The pin-assertion fix on my side lands before or alongside it.

What to avoid is the position the PR currently takes: off, and described as available.


Needs an answer before merge: forked pull requests

coasys/we is public. Every job here runs directly on Coasys's hardware as marvin, with
~/.cargo, ~/.nvm, the pnpm store and $CI_ARTIFACT_DIR, on the same machine that builds AD4M.
CircleCI's Build forked pull requests defaults to off, and I cannot check the project setting
from here, so this is a question rather than a confirmed defect. But it is not hypothetical:

  • Nine PRs in this repo's history came from a fork — eight of them HexaField's (#2, #3, #4, #5,
    #53, #65, #71, #78, the last in June), one from data-bot-coasys (#10).
  • With the setting off, those PRs get no CI at all once pull_request is gone from
    build.yaml. GitHub Actions ran on them with a restricted token; CircleCI will not run at all.
  • With the setting on, they are arbitrary code from a stranger executing on that machine.

Neither is a default to fall into. The clean answer is to keep pull_request on build.yaml with
each job gated on github.event.pull_request.head.repo.fork: forks get hosted CI and never touch
the runner, same-repo branches get CircleCI, and no PR has two check lists. Whatever is decided,
name the setting and its verified value in the config's settings comment block, next to auto-cancel.


Smaller things

  • The ci workflow has no branch filters, so it runs on every push to every branch, where
    build.yaml ran on pull_request plus pushes to dev. Config filters cannot express that
    shape. The equivalent is the project setting Only build pull requests, which also always
    builds the default branch. It belongs in the same comment block as auto-cancel, and it matters on
    a four-instance pool.

  • CircleCI builds the branch head, not the merge ref. pull_request on Actions checked out
    refs/pull/N/merge, the PR as it would be after merging into dev. CircleCI checks out
    CIRCLE_SHA1, the PR as it is. With nothing forcing branches up to date, a PR green on its own
    can break dev on merge and nothing will have said so. Not in the "Same shape" table; worth a
    line there, and a line in the config.

  • triggers: / schedule: is CircleCI's deprecated scheduled-workflow syntax. The comment
    acknowledges it as a placeholder. CircleCI announced a sunset for end of 2022 and then postponed
    it with no new date, so it works, but it is worth an issue rather than a comment — and if the
    nightly moves to a scheduled pipeline in the follow-up, that pipeline's failure notifications are
    the reporting channel the job is missing.

  • "Full log kept on the runner at …" in the compat Report step is undercut by the build
    job's sweep of the same directory, which removes anything older than a day. Either exempt
    compat-*.log from the sweep or say "for a day".

  • restore_build assumes all four runner instances share one physical host. It fails loudly
    if that stops being true, which is the right failure mode, but the assumption belongs next to the
    executor definition as a constraint on future changes, not only as an aside about
    persist_to_workspace.

  • "Node from .nvmrc" is copied three times. The copies in rust and ad4m-compat do not
    . "$BASH_ENV" the way setup's does. That works today because CircleCI sources it between
    steps, and breaks the moment someone calls node in the same step. Extract a fourth command.


Proposed split

  1. This PR: the six checks. Correct the description. Restore ad4m-compat.yaml's schedule.
    Keep pull_request on build.yaml gated to forks. Add Only build pull requests, the
    forked-PR verdict, and the head-versus-merge note to the settings comment block. The small items
    above as you see fit.
  2. Follow-up PR: the nightly. Control from commit statuses on both runners, a channel that
    reaches a person, and #176 closed or corrected. Then deactivate the GitHub schedule.
  3. On my side, before 2: the pin assertion, so a source build can pass the compat job without
    the gate losing the check.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants