Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ repos:
rev: 'v10.8.0'
hooks:
- id: eslint
files: ^index(\.test)?\.js$
files: ^(index|src/.*|worker/.*)(\.test)?\.js$
additional_dependencies:
- globals@17.8.0
- eslint@10.8.0
Expand Down
134 changes: 134 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# CLAUDE.md

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.

## What this is

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` |

Keep logic in `src/`. Anything added to only one front end will drift; that is
the whole reason the split exists.

## Commands

```bash
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 # after ANY change to worker/ or src/
```

CI enforces 100% coverage. New code needs tests, or `/* node:coverage
disable */` with a reason (see the entry-point guard in `index.js`).

## Conventions

- **No semicolons, single quotes**, `eqeqeq` with `{null: 'ignore'}` — enforced
by `eslint.config.mjs`, all autofixable with `npx eslint . --fix`.
- **Rebuild `dist/`** in the same commit as any `index.js`/`src/` change, or
the action ships stale code. autofix.ci also does this on PRs.
- **`.pre-commit-config.yaml` pins ESLint separately from `package.json`** and
dependabot only updates the latter. Bump both together.
- Style-only commits go in `.git-blame-ignore-revs`.
- Node version comes from `.nvmrc` (CircleCI orb and `setup-node` both read it).

## Testing style

`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).

## Hard-won gotchas

Things that cost real debugging time. Do not undo these.

- **Never send `Circle-Token` unless a token was supplied.** CircleCI answers
`401` to a bogus token even on public projects, while no header at all is
`200`. 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.
- **Do not add exact `artifact-path` matching.** 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 need `next_page_token` paging.
- **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 said `SHOULD NOT APPEAR`.
- **`on: status` cannot be filtered** — no `types`, no branches, and the
workflow must exist on the default branch. Job-level `if` skips the work but
the run entry is still created, which is gh-27. Every status the action posts
is itself a `status` event, so it triggers its own workflow again; that is why
`post-pending` exists.
- **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/N` pipeline in the parent, so the upstream PR shows no status while
every setting looks correct. Check
`/api/v1.1/project/github/<org>/<repo>/settings` for `build-fork-prs`.
- **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.

## The GitHub App

Deploy: `npx wrangler deploy`. Secrets: `APP_ID`, `PRIVATE_KEY`,
`WEBHOOK_SECRET` via `wrangler secret put`.

- `PRIVATE_KEY` must be **PKCS#8** (`openssl pkcs8 -topk8 -nocrypt …`); Web
Crypto cannot import the PKCS#1 file GitHub gives you.
- Upload `WEBHOOK_SECRET` with `printf '%s'`, never `< file` — a trailing
newline makes every delivery `401`.
- 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.
- The config file is found by **listing `.github/` and matching
`CONFIG_NAME`** (`circle(ci)?[-_]artifacts.ya?ml`) rather than fetching one
fixed path: people migrate by `git mv`-ing their workflow, which is called
`circle_artifacts.yml` in 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.

## Where things stand (2026-07-28)

The App prototype is merged/being merged from `app-prototype`. It is **running
in production for `LABSN/expyfun`**, which removed its workflow — but on a
*personal* Cloudflare account and a personally-owned App registration, not
scientific-python infrastructure.

Next steps, roughly in order:

1. More repos: `scikit-image/scikit-image` and `braindecode/braindecode`
already have the App installed (since 2019) and only need a config file.
Then MNE-Python and SciPy.
2. Hand over to scientific-python: App ownership transfers preserve
installations, and the Worker is stateless, so it is `wrangler deploy` +
three secrets + one webhook URL change. Stefan van der Walt (stefanv) runs
the org's existing Cloudflare Worker (`scientific-python/circleci-proxy`);
he and Jarrod Millman are the org owners.
3. 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.
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ jobs:
status for that). So a job that fails after uploading its artifacts still
gets a green link, and a job that passes without uploading anything gets a
red one (see [#57](https://github.com/scientific-python/circleci-artifacts-redirector-action/issues/57)).
- Set `post-pending: 'false'` to skip the "Waiting for CircleCI ..." status
that is posted while the job is still running. That halves the statuses this
action creates, and since every status is itself a `status` event, it halves
the workflow runs they trigger too (see
[#27](https://github.com/scientific-python/circleci-artifacts-redirector-action/issues/27)).
It defaults to `'true'`, so existing setups are unchanged.
- The action has an output `url` that you can use in downstream steps, but
this URL will only point to a valid artifact once the job is complete, i.e.,
`github.event.status` is either `'success'`, `'fail'`, or (maybe) `'error'`,
Expand All @@ -82,6 +88,49 @@ jobs:
> (rather than app) API and that this is always tied to the `master`/default
> branch of a given repository.

## GitHub App (prototype, not yet deployed)

`worker/index.js` is a Cloudflare Worker that does the same job as the action,
but as a GitHub App reacting to `status` webhooks server-side. The point is
[#27](https://github.com/scientific-python/circleci-artifacts-redirector-action/issues/27):
with the App there is no workflow, so there are **no workflow runs at all** —
instead of one run per status event, most of which do nothing.

Instead of a workflow file, a repo using the App has
`.github/circleci-artifacts.yml`, which is the `with:` block of the old
workflow with the indentation and `repo-token` removed:

```yaml
artifact-path: 0/doc/index.html
circleci-jobs: build_docs
job-title: Check the rendered docs here!
```

Since migrating usually means `git mv`-ing the old workflow, the underscore
and `circle` spellings are accepted too — `circle-artifacts.yml`,
`circle_artifacts.yml`, `circleci_artifacts.yml`, and the `.yaml` versions of
each all work.

The config is always read from the **default branch**, so a pull request
(including one from a fork) cannot change where the link points.

Both front ends share `src/core.js` and `src/config.js`, so the two cannot
drift apart: the same resolution logic and the same option defaults serve both.

Differences from the action, by design:

- No `url` output, because there is no workflow step to consume it.
- No "Waiting for CircleCI ..." status: the app always behaves as though
`post-pending` were `false`, since each status it posts is itself a `status`
event, and the final status says everything the pending one did.
- Public CircleCI projects only: a private project needs an `api-token`, which
would mean storing each repo's CircleCI token server-side.
- Duplicate deliveries are dropped: CircleCI sometimes reports the same job
status twice, and posting an identical status twice is invisible in the UI
but doubles the events it generates.

The action is not going away; the App is a second way to run the same code.

## Limitations

Currently has (known) limitations:
Expand Down
8 changes: 8 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ inputs:
which addresses some routing issues.
required: false
default: 'output.circle-artifacts.com'
post-pending:
description: |
Whether to post a "Waiting for CircleCI ..." status while the CircleCI
job is still running. Set to 'false' to halve the number of statuses
this action creates (and therefore the number of workflow runs the
`on: status` trigger produces).
required: false
default: 'true'
outputs:
url:
description: 'The full redirect URL'
Expand Down
Loading
Loading