Use in CI
internetnl was built for pipelines from the start: --json for
machine-readable output, --fail-on-scored for a gate, and stable exit
codes. This page covers running it from someone else's CI — a GitHub
Actions workflow via the bundled composite action, or any other CI
system by installing the CLI directly.
a) GitHub Actions
The repository root ships action.yml, a composite
action. Minimal use:
- uses: MWest2020/internetnl-cli@v1
with:
hosts: example.org example.com
endpoint: https://api.westerweel.work
credential: ${{ secrets.INTERNETNL_CREDENTIAL }}
credential is a single username:password string, split on the
first colon: a password may contain one, but per RFC 7617 a Basic
userid never can — a username containing a colon could never
authenticate over HTTP Basic in the first place (a compliant server,
the netnl facade included, parses everything from the first colon
onward as the password), so this split is unambiguous for every
credential that could ever actually work. It is the preferred way to
pass a credential to the action: one secret instead of two.
username/password still work as separate inputs if you already
have the credential split that way:
- uses: MWest2020/internetnl-cli@v1
with:
hosts: example.org example.com
endpoint: https://api.westerweel.work
username: ${{ secrets.INTERNETNL_USERNAME }}
password: ${{ secrets.INTERNETNL_PASSWORD }}
Provide exactly one of the two forms — credential, or both username
and password — never a mix; the action's "Validate inputs" step fails
closed otherwise.
Pin @main to a tag or commit SHA once one exists, the same way this
repo pins its own third-party actions (see .github/workflows/ci.yml).
Never put the password (or the combined credential) directly in the
workflow file — always a secret.
Note the trade-off while there is no tag/SHA to pin yet: the action
resolves its own install source from github.action_repository/
github.action_ref, which are only populated when consumed as uses:
owner/repo@ref from another repo. A local uses: ./ (dogfooding this
action from its own repo, e.g. in the smoke workflow) falls back to
installing from this repo's main branch, so that path always tracks
main regardless of what you pin uses: to elsewhere. Pinning to a
tag or SHA fixes what code the step definition runs, but does not
change this fallback's behaviour when it triggers.
Inputs:
| Input | Required | Default | Meaning |
|---|---|---|---|
hosts |
one of hosts/file |
— | space-separated hostnames |
file |
one of hosts/file |
— | path to a file, one hostname per line |
type |
no | web |
web or mail |
fail-on-scored |
no | true |
gate on scored subtest failures (exit 3); must be exactly true or false, anything else fails the step |
name |
no | — | free-form label for the request |
allowlist |
no | — | workspace-relative path to an allowlist file (see (c) below); requires actions/checkout to have run first |
endpoint |
yes | — | INTERNETNL_ENDPOINT |
credential |
one of credential/(username+password) |
— | INTERNETNL_CREDENTIAL, username:password (split on the first :), from a secret; preferred |
username |
one of credential/(username+password) |
— | INTERNETNL_USERNAME; alternative to credential, pair with password |
password |
one of credential/(username+password) |
— | INTERNETNL_PASSWORD, from a secret; alternative to credential, pair with username |
Output: results-path, the path to the raw --json results file
written during the run — attach it as a build artifact if you want it
kept:
- uses: actions/upload-artifact@v4
if: always() && steps.<step-id>.outputs.results-path != ''
with:
name: internetnl-results
path: ${{ steps.<step-id>.outputs.results-path }}
Give the action step an id: to reference its outputs, and use
if: always() on the upload step so a result still gets uploaded when
the gate trips the job (that is the point of the gate: it is supposed
to fail the job, not hide the evidence). The &&
steps.<step-id>.outputs.results-path != '' guard is explained below —
it is not optional decoration.
Three things to know about that artifact recipe.
results-path can point at a 0-byte file on exit codes 1, 2, or 4
— the CLI redirects its own stdout straight to that path, so a run
that fails before producing JSON (a config error, a transport/API
error, or the poll timeout) still creates the file, just empty; do not
assume a present file means usable JSON.
The path: given to upload-artifact above is workspace-relative
like everything else actions/upload-artifact handles — ${{
steps.<step-id>.outputs.results-path }} itself resolves to an
absolute path under $RUNNER_TEMP (set by the action, not the
workspace), so this works regardless of whether actions/checkout
ran. actions/checkout is still required, though: the allowlist and
file inputs above are both workspace-relative paths, so either one
needs the workspace populated by actions/checkout first to resolve.
And results-path is only ever set by the action's "Run internetnl
submit" step, so it stays unset (empty string) if an earlier step
— "Validate inputs" (e.g. a hosts/file conflict or an invalid
fail-on-scored) or "Install uv"/the install step — fails first. A
bare if: always() upload step given that empty path: errors ("no
files were found"/invalid path) instead of silently skipping, which is
why the recipe above guards on steps.<step-id>.outputs.results-path
!= '' as well.
b) Any other CI (plain CLI)
The action is a thin wrapper; anything that can run uv (or install a
Python package and set environment variables) can do the same thing
directly. GitLab CI example:
scan:
image: python:3.12-slim
variables:
INTERNETNL_ENDPOINT: https://api.westerweel.work
INTERNETNL_USERNAME: $INTERNETNL_USERNAME # CI/CD variable, masked
INTERNETNL_PASSWORD: $INTERNETNL_PASSWORD # CI/CD variable, masked+protected
script:
- pip install "git+https://github.com/MWest2020/internetnl-cli@v1.0.0"
- internetnl submit --file hosts.txt --fail-on-scored --json > results.json
artifacts:
when: always
paths: [results.json]
Credentials are still environment variables only (INTERNETNL_ENDPOINT
/ INTERNETNL_USERNAME / INTERNETNL_PASSWORD, or the single
INTERNETNL_CREDENTIAL in username:password form — never both at
once) — never command-line arguments. Mark the username/password (or
the combined credential) as masked (and protected, if your CI supports
it) CI/CD variables, the same way you would for any other credential.
c) The gate: exit codes and what "scored" means
internetnl submit/poll/results return one of these exit codes
(the full table is in
openspec/changes/archive/2026-09-08-add-internetnl-cli/design.md):
| Code | Meaning |
|---|---|
| 0 | Success (including results on a run that has not finished yet) |
| 1 | Configuration error (missing endpoint, unreadable file, bad allowlist) |
| 2 | Usage error, transport failure, or an API error |
| 3 | --fail-on-scored gate tripped |
| 4 | INTERNETNL_POLL_MAX exceeded while the run was still unfinished |
Exit code 2's message includes the endpoint's own error body verbatim
in the CI log, so against a third-party batch API you do not control
(unlike the netnl facade, which is built not to do this) that body
could itself echo your username unmasked.
Exit code 4 is the one most likely to bite a large run: the default
INTERNETNL_POLL_MAX is 3600 seconds (one hour), and a batch of many
hosts can legitimately take longer than that to finish scoring. The
composite action's job-level (or workflow-level) env: block passes
through unchanged to its run: steps — GitHub Actions does not
sandbox a composite action's environment from the calling job's — so
setting INTERNETNL_POLL_MAX (raise the timeout), INTERNETNL_POLL_INTERVAL
(poll less often), or INTERNETNL_BATCH_SIZE (cap how many hosts one
request may contain, forcing you to split a large host list across
multiple submit calls/jobs) on the job that calls uses:
MWest2020/internetnl-cli@v1 reaches the action's own internetnl
invocation:
jobs:
scan:
runs-on: ubuntu-latest
env:
INTERNETNL_POLL_MAX: "7200"
steps:
- uses: MWest2020/internetnl-cli@v1
with: { ... }
"Scored" means a subtest whose Internet.nl API status is failed —
Internet.nl reserves failed for problems that count toward the
published score; purely informational findings surface as warning or
info and are shown in the output but never change the exit code.
An accepted, tracked exception is an allowlist file, not a
disabled gate: --allowlist path/to/file takes host testname pairs
(whitespace-separated, # comments allowed). A listed failure still
shows up in the output as "accepted" — it just does not trip exit code
3. This is for a specific, named, tracked exception (e.g. "we know
example.org fails web_tls_... until the certificate is renewed
next week"), not a way to silence a whole class of findings.
A subtest the API omitted for a host renders as unknown, never as passing — a gap in the response is not silent success.
d) The rule
Measure your own hosts freely; measure someone else's only where the measurement is one their operator already invites (see "The rule" in the README). In CI the second half barely applies and the first half matters more than anywhere else: a workflow that runs on every pull request from outside contributors, or that lets a PR body or branch name influence which hosts get submitted, can turn "scan my own site" into "scan whatever a stranger asks for." The tool does not enforce this — you do, by keeping the host list under your own control (a file committed to the repo, not something derived from untrusted input).
e) Getting a credential
Every route needs INTERNETNL_ENDPOINT and a credential — either
INTERNETNL_USERNAME / INTERNETNL_PASSWORD, or the single
INTERNETNL_CREDENTIAL (username:password, split on the first :).
Options, cheapest first:
- An account on the hosted
batch.internet.nlAPI (ask upstream). - A credential on the
netnlfacade athttps://api.westerweel.work: see Running the netnl private beta for how the beta works today, and Supporter keys for the lifetime-key-for-a-donation route. - Self-hosting your own batch instance, if your volume or requirements warrant it.