# Contributing

## Getting set up

```bash
git clone https://github.com/bastienallain/headless-seo-check
cd headless-seo-check
cargo test
```

There is no setup step. `rust-toolchain.toml` pins the compiler and rustup installs it on the first
`cargo` command, `rustfmt` and `clippy` included.

```bash
cargo test                             # the whole suite, no network needed
cargo fmt --all                        # what CI checks with --check
cargo clippy --all-targets -- -D warnings
cargo run -- https://example.com       # the tool itself
```

CI runs exactly those four things, on Linux, macOS and Windows. If they pass locally they pass
there.

## Adding a diagnostic

The tool answers four questions today. A fifth is welcome if it meets the same bar: **a real failure
that is invisible where people normally look** — not in the page source, not in the browser, not at
build time, not in a classic SEO crawler. "A crawler already reports this" is a reason to say no.

The shape of the work:

1. **Emit a `Finding`, never text.** Checks return `Finding { kind, severity, message, detail }`.
   Rendering belongs to the report module alone; that is what stops the human output and `--json`
   from ever disagreeing about what is blocking.
2. **Pick the severity deliberately.** `Severity::Error` fails the run and therefore fails someone's
   CI. Our own uncertainty is never an error — an undetermined rendering mode is a warning, because
   it is our limitation and not the site's fault.
3. **Add a fixture route that reproduces the bug**, under `tests/`. A check with no fixture does not
   go in: the fixture is the difference between a diagnostic and a guess, and it is what lets the
   suite run offline.
4. **Assert on both outputs.** Text and `--json` come from the same verdict, and a test should hold
   them to it.
5. **Document it in the README** — the check, and the failure it comes from.

Two standing constraints. Concurrency is capped at 6 in code because this audits a site rather than
load-testing it; do not raise it. And a new dependency needs a reason in the pull request: the
dependency list is short on purpose, and the binary being small and quick to start is part of what
the tool is.

## Pull requests

- One behaviour per pull request.
- [Conventional Commits](https://www.conventionalcommits.org): `feat:`, `fix:`, `docs:`, `chore:`,
  `refactor:`, `test:`, `perf:`, `ci:`.
- Tests that fail before the change and pass after it.
- If the output changes, paste the before and after.

## The npm gateway

`npm/` is the package that keeps `npx headless-seo-check` working. It ships no binary: on install it
downloads the one built for your platform from the matching GitHub release and verifies it against a
SHA-256 that travelled inside the npm tarball rather than over the same connection as the download.

It has no dependencies and must keep having none.

Testing it without a published release:

```bash
# against a local directory of archives, over any static file server
HEADLESS_SEO_CHECK_DOWNLOAD_BASE=http://127.0.0.1:8000 node npm/install.js

# or skip downloading altogether and point it at a binary you built
HEADLESS_SEO_CHECK_BINARY=$PWD/target/release/headless-seo-check \
  node npm/bin/headless-seo-check.js https://example.com
```

The list of targets in `npm/lib/binary.js` and the build matrix in `.github/workflows/release.yml`
have to stay identical. A target in one and not the other is either a download URL that 404s or a
binary nobody can reach.

## Releasing

**One version number.** The crate, the npm package and the tag are the same string. CI fails a pull
request where `Cargo.toml` and `npm/package.json` disagree, and the release workflow refuses a tag
that does not match both. Two numbers would mean answering "which npm version has the fix that
landed in crate 1.4.2", forever.

```bash
# 1. bump both files to the same version
#      Cargo.toml        version = "X.Y.Z"
#      npm/package.json  "version": "X.Y.Z"
# 2. cargo build          (refreshes Cargo.lock — commit it)
# 3. commit, then
git tag vX.Y.Z && git push origin vX.Y.Z
```

The tag runs `.github/workflows/release.yml`, which builds all seven targets, writes `SHA256SUMS`,
creates the GitHub release, bakes those checksums into the npm package, installs and runs that
package against the release it has just created, and only then publishes to crates.io and npm.

### Secrets

Set these in **Settings → Secrets and variables → Actions**. Neither appears anywhere in the
repository, and a release with either one missing prints a warning and skips that publish rather
than failing.

| Secret | Used by | How to get it |
| --- | --- | --- |
| `CARGO_REGISTRY_TOKEN` | `cargo publish` | crates.io → Account Settings → API Tokens, scoped to `publish-update` on this crate |
| `NPM_TOKEN` | `npm publish` | npmjs.com → Access Tokens → Granular, read-write on `headless-seo-check` |

`GITHUB_TOKEN` is provided by Actions; nothing needs to be created for it.

Both registries now support trusted publishing over OIDC, which removes these two long-lived tokens
entirely. That is the upgrade to make once the release pipeline has run a few times unchanged.

### While the repository is private

The release workflow runs, but two things do not:

- **`npx headless-seo-check` cannot work.** Release assets on a private repository are not publicly
  downloadable, so the gateway's download 404s for everyone. The end-to-end check in the release
  workflow will fail on exactly this, which is the correct behaviour: it stops a broken package from
  reaching npm.
- **`--provenance` is dropped** on npm publishes, because npm requires a public repository to attest
  one.

Free arm64 Linux runners are also a public-repository benefit; on a private repository the two
`ubuntu-22.04-arm` rows are billed, and unavailable on some plans.
