Notes for agents working on this repo. User-facing docs live in README.md;
this file is the working knowledge that is easy to get wrong.
Two front ends over one core, for putting a link to a CircleCI artifact into a GitHub commit status:
| File | Role |
|---|---|
src/core.js |
all the logic; runtime-neutral (global fetch only, nothing from node:) |
src/config.js |
option names, defaults and the config-file parser, shared by both |
index.js |
GitHub Action entry point (@actions/core, @actions/github) |
worker/index.js |
GitHub App entry point: a Cloudflare Worker handling status webhooks |
dist/index.js |
the bundle the action actually runs; committed, built by ncc |
tools/cf-usage.py |
Cloudflare usage report for the deployed Worker (stdlib only) |
tools/diagnose-pr.py |
why a fork PR got no CircleCI status; public APIs, stdlib only |
Keep logic in src/. Anything added to only one front end will drift; that is
the whole reason the split exists.
Shared logic does not mean shared behaviour, and advice that is right for one
is routinely wrong for the other. Establish which front end a repo uses
before saying anything about what it will post — a repo is on the App if
.github/circle(ci)?[-_]artifacts.ya?ml exists and the on: status workflow
is gone (see "Where things stand" for who is where).
| Action | App | |
|---|---|---|
| Pending "Waiting for CircleCI ..." status | posted, unless post-pending: 'false' |
never, unconditionally: handle returns on state: pending and forces postPending: false |
| Options come from | with: in the workflow |
.github/circleci-artifacts.yml on the default branch |
url output |
yes | no — no step to consume it |
Private CircleCI projects (api-token) |
yes | no |
| Duplicate CircleCI deliveries | posted twice | deduped for 5 min |
| Where a run shows up | a workflow run per status event |
nowhere; the App's Recent Deliveries tab |
The pending row is the one that bites: any reasoning of the form "a status
already posted earlier will be left behind" is action-only, and every caveat
about post-pending is too.
npm test # eslint + node --test
npm run coverage # the same, with a hard 100% line/branch/function floor
npx ncc build index.js -o dist # after ANY change to index.js or src/
pre-commit run --all-files # yamllint + eslint, as CI runs them
npx wrangler deploy # manual deploy; normally CI does this, see below
tools/cf-usage.py [days] # deployed Worker usage vs the free-tier limits
tools/diagnose-pr.py <org>/<repo> <PR#> # why a fork PR has no CircleCI statusCI enforces 100% coverage. New code needs tests, or /* node:coverage disable */ with a reason (see the entry-point guard in index.js).
- No semicolons, single quotes,
eqeqeqwith{null: 'ignore'}— enforced byeslint.config.mjs, all autofixable withnpx eslint . --fix. - Rebuild
dist/in the same commit as anyindex.js/src/change, or the action ships stale code. autofix.ci also does this on PRs, and thecheck-distjob intests.yamlverifies it — on amasterpush a failure opens an issue, because nobody watches post-merge CI. - Bot PRs that touch
dist/are never auto-merged.dist/is what every consumer of the action executes, so a dependency bump that autofix.ci re-bundles must get human review — otherwise a compromised npm release would flow to consumers' CI with no human in the loop.automerge.ymlenforces it (including disabling auto-merge when autofix pushes a rebuiltdist/to a PR it already approved), and dependabot has a 14-daycooldownon npm so a poisoned release is likely yanked before we ever see it. .pre-commit-config.yamlpins ESLint separately frompackage.jsonand dependabot only updates the latter. Bump both together.- Style-only commits go in
.git-blame-ignore-revs. - Node version comes from
.nvmrc(CircleCI orb andsetup-nodeboth read it).
node:test, no framework. The action is tested by setting real INPUT_* env
vars and injecting fetchFn/getOctokit; the Worker by building a real
Request and injecting fetchFn. Both use the real @actions/core and real
Web Crypto — the JWT test signs with a generated key and verifies with
node:crypto, so it would be accepted by GitHub.
Mutation-test any fix: revert it alone and confirm the new test fails. This caught a test that passed with and without the fix (an HTTP-error test that only asserted "the job failed", when the old code also failed, just with a useless message).
Things that cost real debugging time. Do not undo these.
- Never send
Circle-Tokenunless a token was supplied. CircleCI answers401to a bogus token even on public projects, while no header at all is200. Sending the literal string"null"broke every tokenless public repo (gh-119). - The status reports the link, not the build (gh-57): green when artifacts
exist, red when they do not, regardless of whether CircleCI passed.
no-artifact-state(failuredefault /success/skip) overrides the "red when they do not" half, for repos where a build that uploads nothing is expected (scipy's[skip circle]). There is deliberately no grey: a commit status has no neutral state, andpendingwould be a yellow one that never resolves. Doing it properly would mean posting check runs instead, i.e. achecks: writepermission for action users and an App permission change that every installation has to re-approve. - Do not add exact
artifact-pathmatching. It was proposed and declined: CircleCI lists only files, so anyone whose path is a directory (0/dev/, relying on an index redirect) would go permanently red. A broken link is the lesser evil. Revisiting it would also neednext_page_tokenpaging. - The app must read config from the default branch. Reading it from the
event's ref would let a forked PR point
domain:at a host it controls and have us post a trusted-looking link to it. Verified live with a fork PR whose branch config saidSHOULD NOT APPEAR. on: statuscannot be filtered — notypes, no branches, and the workflow must exist on the default branch. Job-levelifskips the work but the run entry is still created, which is gh-27. Every status the action posts is itself astatusevent, so it triggers its own workflow again; that is whypost-pendingexists.- A fork that is itself a followed CircleCI project suppresses upstream
builds. CircleCI builds it in the fork's project and never creates a
pull/Npipeline in the parent, so the upstream PR shows no status while every setting looks correct.tools/diagnose-pr.py <org>/<repo> <PR#>decides it:/api/v2/project/gh/<parent>/pipeline?branch=pull/<N>empty while…/gh/<fork-owner>/<repo>/pipeline?branch=<head ref>is not. Both are public and unauthenticated;/api/v2/project/gh/<owner>/<repo>is not a discriminator (it answers 200 for any fork of a known project), and/api/v1.1/…/settingsneeds a token and answers 403 without one. Only the contributor can fix it, by unfollowing their fork — no upstream setting helps, because there is no event upstream to react to. Very often the fork build also errors at config-parse time (an org-settings orb restriction, say), so the contributor sees nothing anywhere and there is nothing to forward even in principle. - Never suggest installing the CircleCI GitHub App as a fix for forked PRs — App pipelines are never built on forks, so it makes this strictly worse. The OAuth integration is the one that supports them.
Deploy: normally .github/workflows/deploy.yml, on a published release or a
manual workflow_dispatch; npx wrangler deploy still works for emergencies.
Worker secrets: APP_ID, PRIVATE_KEY, WEBHOOK_SECRET via
wrangler secret put — these live on the Worker and survive a deploy, so
CI never needs them and must never be given them.
PRIVATE_KEYmust be PKCS#8 (openssl pkcs8 -topk8 -nocrypt …); Web Crypto cannot import the PKCS#1 file GitHub gives you.- Upload
WEBHOOK_SECRETwithprintf '%s', never< file— a trailing newline makes every delivery401. The same trap caughtCLOUDFLARE_API_TOKENas a repo secret, where a pasted newline became an invalidAuthorizationheader. Every credential in this project has to be stored with no trailing newline, and nothing in any UI shows you one; the deploy workflow now rejects whitespace up front for the two it can see. - Token (50 min), config (10 min) and posted-status dedupe (5 min) are cached in
an isolate-level
Map. All best-effort: a cold isolate just refetches, and a duplicate can slip through. Nothing is correctness-critical. - Repos with no config file are inert, so a stale installation posts nothing.
- A failed delivery is lost — accepted. GitHub never retries a webhook delivery on its own, so when the Worker 500s on a transient CircleCI/GitHub blip, that one status is never posted. A build emits several status events, so the exposure is small; the "fix" (a cron job calling the App's redeliver API) would hand App credentials to CI, which we deliberately never do. Manual redelivery from Recent Deliveries covers the rare case that matters.
- The config file is found by listing
.github/and matchingCONFIG_NAME(circle(ci)?[-_]artifacts.ya?ml) rather than fetching one fixed path: people migrate bygit mv-ing their workflow, which is calledcircle_artifacts.ymlin SciPy and MNE-Python. Costs one extra API call when a config exists, cached for 10 minutes. - Responses are the diagnostic surface: the App's Advanced → Recent Deliveries tab shows exactly which stage a delivery reached.
.github/workflows/deploy.yml runs wrangler deploy on a published release or
a manual workflow_dispatch. Two repo secrets:
| Secret | What |
|---|---|
CLOUDFLARE_API_TOKEN |
Scientific Python account → Manage Account → Account API Tokens (circleci-artifacts-redirector-deploy), Edit Cloudflare Workers template |
CLOUDFLARE_ACCOUNT_ID |
the Scientific Python account; an identifier rather than a credential, and also used by the weekly usage check |
Both Cloudflare tokens are account-owned (Account API Tokens, not My
Profile → API Tokens), so they survive any member — including their creator —
leaving the account. Account-owned tokens verify against
/accounts/<id>/tokens/verify, not /user/tokens/verify, which rejects them
as "Invalid API Token".
For local read-only work, mint a second token rather than reusing the deploy
one — it can write to production. tools/cf-usage.py needs exactly Account
Analytics: Read, provided CLOUDFLARE_ACCOUNT_ID is exported too; without it
the script falls back to listing accounts, which additionally needs Account
Settings: Read. Add Workers Tail: Read to that token if you also want
wrangler tail to stream live deliveries.
Not on every push to master: the Worker is production for every repo with the
App installed, so deploying is a decision rather than a consequence of merging.
workflow_dispatch exists because Worker-only fixes should not have to wait for
an action release — action releases run at a few per year, and a Worker fix
(gh-126 was one) can be urgent.
The token from wrangler login is a different credential, expires about an
hour after issue, and this repo's tooling cannot refresh it — hence the API
token above, which does not expire.
Roll back first, revert git after. They are not alternatives:
- Run the Deploy workflow with a
rollback_toversion ID. Cloudflare re-serves a version it already has, so this needs no build, no tests and no working tree — it is the one thing that still works whenmasteris broken. Version IDs are printed at the end of every run of the workflow. - Then
git revertand merge. Cloudflare is now ahead ofmaster, and the next deploy silently re-ships whatever you rolled back. This step is what reconciles them, and skipping it is how the same outage happens twice.
git revert + merge + workflow_dispatch on its own is a full PR and CI cycle
to fix an outage, and it cannot help at all if the problem is that master does
not build. Use it as step 2, never as step 1.
Rollback is a no-op risk here: the restriction in Cloudflare's docs is about
Durable Objects, R2, KV and queues changing between versions, and this Worker
has no bindings at all (wrangler deploy --dry-run reports "No bindings
found"). Secrets are not touched. Only recent versions are retained — the docs
say the last 100, wrangler versions list shows the last 10.
The free plan allows 100,000 requests/day but only 10 ms of CPU per invocation (I/O does not count, so awaiting GitHub and CircleCI is free). Requests are a non-issue — measured traffic is well under 1% of the daily cap, with ~200x headroom — while the observed CPU p99 already sits near 10 ms. When a Worker consistently exceeds it, Cloudflare terminates the invocation with error 1102; occasional overruns are tolerated.
What actually costs CPU here, measured on a scikit-learn-sized listing:
| Operation | Cost |
|---|---|
JSON.stringify of the artifacts listing (~1 MB) |
~2.8 ms |
JSON.parse of that same listing |
~2.2 ms |
| RSA import + JWT sign (only on a token cache miss) | ~1.0 ms |
| streaming scan for the first artifact URL | ~0.08 ms |
| HMAC import + webhook signature verify | ~0.08 ms |
A large docs build lists thousands of files (scikit-learn: 3,904 artifacts, 982 KB; MNE-Python: 3,290, 755 KB), so anything that touches the whole artifacts payload is the most expensive thing the Worker does — more than the crypto, by a lot. Both halves of that have now been removed:
- Never serialize it.
src/core.jspasses a thunk tologfor messages that are expensive to build, and the Worker'slogis a no-op that never calls it. Do not "simplify" a thunk back into a template string — a no-op logger still evaluates its argument, which is exactly the bug it fixes.index.jsresolves the thunk only whencore.isDebug(). Nothing passes a thunk today (the message that did is gone, below), but the contract holds and the next expensive message must use it. - Never parse it. Only two facts about the listing matter: whether the job
uploaded anything, and the URL of one artifact — they all share the job
segment the redirect needs.
firstArtifactUrlscans the response bytes as they arrive, stops at the first"url", and cancels the rest of the download, turning ~2.2 ms into ~0.08 ms and skipping most of the transfer as well. The regex cannot be fooled by a path containing the text"url":, because an unescaped"only appears as JSON syntax, never inside a string value. If the scan finds nothing before the stream ends it parses the (then tiny, or unexpected) body properly, so a malformed response still throws instead of quietly reading as "no artifacts". - Answer pending statuses before the API. The app never posts one, so
handlereturns as soon as it seesstate: pending— before the token mint and the config read. CircleCI sends a pending as each watched job starts, so roughly half of watched-job deliveries cost only signature-verify plus parse, and on a cold isolate the skipped work is an RSA sign and two GitHub API calls. A test pins the path to zero fetches. Relatedly, the webhook HMAC key and the App RSA key are imported into Web Crypto once per isolate and reused, so the recurring crypto cost is the verify/sign itself.
The cost of that second one is debuggability: there is no longer a full
Artifacts JSON: … dump in the action's debug log, because the payload is never
assembled. First artifact: <url> replaces it.
Four tests pin this down (index.test.js, worker/index.test.js). The fake
artifacts payload carries a toJSON hook that counts serializations and the
tests assert it is never called; a second fixture serves the listing as a real
chunked ReadableStream and asserts the scan pulls at most 2 of ~15 chunks.
Both are deterministic tripwires for the specific wasteful operation, where
asserting on elapsed milliseconds would be flaky. Add the same guard for any new
code that could walk the payload.
Check real usage with tools/cf-usage.py ($CLOUDFLARE_API_TOKEN if exported,
else the token wrangler login stored). It reports CPU quantiles and invocation
outcomes, and flags a p99 over the limit. --check exits non-zero on real
problems — non-success invocation outcomes (where exceededCpu kills land), a
peak day past half the request quota, or no traffic at all — while a p99 over
the CPU limit alone is only reported, since Cloudflare tolerates occasional
overruns and failing on it meant a weekly issue for a standing condition
(gh-132). .github/workflows/usage.yml runs the check weekly and opens (or
comments on) an issue on failure, so it does not depend on anyone remembering
to run it. It shares the CLOUDFLARE_ACCOUNT_ID repo secret with
deploy but needs its own CLOUDFLARE_ANALYTICS_TOKEN — a repo secret with
Account Analytics: Read only, never the deploy token, which can write to
production.
The Worker runs in the Scientific Python Cloudflare account (migrated
2026-08-26 from larsoner's personal account), serving at
https://circleci-artifacts-redirector-app.scientific-python.workers.dev —
the GitHub App's webhook URL. No custom domain: the scientific-python.dev
zone lives in stefanv's personal Cloudflare account, and a Worker's custom
domain must share the Worker's account (see the comment in wrangler.toml
for what to enable if the zone ever moves). The App registration is still
personally owned by larsoner; transferring it to the scientific-python org is
the remaining handover step, preserves installations, and touches nothing on
Cloudflare.
Who is on which front end — this is the first thing to establish before answering anything about behaviour, per "The two front ends do not behave the same":
| Repo | Front end | Evidence |
|---|---|---|
LABSN/expyfun |
App | .github/circleci-artifacts.yml, workflow removed |
scipy/scipy |
App | .github/circle-artifacts.yml, workflow removed |
scikit-image/scikit-image |
App | .github/circleci-artifacts.yaml, workflow removed |
mne-tools/mne-python |
App | .github/circle-artifacts.yml, workflow removed |
braindecode/braindecode |
App | .github/circleci-artifacts.yaml; its push-artifact-to-circleci.yml is unrelated (it triggers a CircleCI pipeline) |
scikit-learn/scikit-learn |
Action | .github/workflows/artifact-redirector.yml, no config file |
Verify from the repo rather than trusting this table, which goes stale: config
file present + on: status workflow gone means the App. Installation state
itself is only visible from the App's own settings.
Next steps, roughly in order:
- Transfer the GitHub App registration to the scientific-python org (Settings → Developer settings → the App → Advanced → Transfer ownership; an org owner — stefanv or Jarrod Millman — accepts). Installations, App ID and keys all survive; nothing on Cloudflare changes.
- Decommission the old Worker on larsoner's personal account once the new
one has soaked:
wrangler deletethere, then delete the App's old private key (the new Worker signs with a second key generated 2026-08-26) and larsoner's now-unused user-owned Cloudflare tokens. - Measured load for scikit-learn + MNE-Python + SciPy combined: ~8,200 deliveries/week, about 1.2% of the Workers free tier.
Not supported by the App, by design: private CircleCI projects (would need
server-side token storage) and the url output (no workflow step to consume
it). The action remains the answer for both, and is not going away.