ci: add Duo CLI output-contract canary
What does this MR do?
Adds a continuous integration (CI) job, duo_cli_canary, that asserts the
GitLab Duo command-line interface (CLI)'s non-interactive output contract at
a pinned version, so a CLI release that changes that contract fails loudly
in CI instead of silently degrading analysis results.
The problem: stig_ai_analyzer.py's call_gitlab_duo() invokes duo run in the CLI's default text mode (no --output-format) and hands the
raw stdout to parse_gitlab_duo_output(), a brace-counting parse chain
that requires a literal [RunController] marker. On any mismatch it
degrades silently to a Not_Reviewed verdict for that rule -- there is no
signal that the CLI's output shape changed underneath the analyzer, only an
unexplained wave of Not_Reviewed findings sometime later.
What the canary asserts
scripts/duo_cli_canary.sh, run by the new duo_cli_canary job:
-
The Node.js runtime satisfies the Duo CLI's own
>=20.17guard, checked before touching the CLI so a failure here is never misattributed to a CLI bug. -
The pinned
@gitlab/duo-cliversion installs, andduo --versionreports exactly that pin (catches a floating/global install shadowing it). -
duo run --helpmatches a committed golden snapshot (tests/fixtures/duo_run_help_9.10.0.txt) -- a tripwire against a silent flag change. Upstream precedent is stronger than a single removal:--connection-typewas removed fromduo runinv8.84.0(a MINOR bump, under a "BREAKING CHANGES" heading), reintroduced the very next day inv8.86.0as a bug fix, and is present-but-deprecated ((DEPRECATED) ... ignored) at the9.10.0pin committed here. A real breaking change that shipped, was noticed, and was walked back within 24 hours -- entirely inside routine minor/patch churn -- is the argument for a snapshot tripwire: a major-version pin would have caught none of it. -
--output-format <format>is present in that help output withtext/jsonchoices -- the flag the JSON-mode contract below depends on. -
Where
GITLAB_DUO_TOKENis available, two live contracts, asserted separately and labeled:- Today's contract --
duo runinvoked with exactly the analyzer's flag shape (no--output-format) must emit a[RunController]marker on stdout followed by a brace-countable JSON object, mirroringparse_gitlab_duo_output()'s assumptions exactly. This is the contract the analyzer actually depends on right now. - The JSON-mode contract (migration target) -- stdout under
--output-format jsonmust parse as a single clean JSON document with zero brace-counting. The analyzer does not consume this mode yet; this assertion exists so the parser's planned migration target is proven live before anything is built on it.
Both tolerate arbitrary stderr log noise.
- Today's contract --
Every failure prints the next action: re-pin @gitlab/duo-cli, or update
the parsing contract in stig_ai_analyzer.py.
Auth and merge-request-pipeline gating
GITLAB_DUO_TOKEN is Protected + Masked in this project, and only master
is a protected branch. A merge request (MR) pipeline is only ever given a
protected variable when: both the source and target branch are protected,
the user triggering the pipeline has push/merge access to the target
branch, and both branches belong to the same project -- and, on GitLab
18.1+, only if the project has also opted in via the "Allow merge request
pipelines to access protected variables and runners" setting under
Settings > CI/CD > Variables. Since only master is protected here, no MR pipeline can
satisfy the branch-protection half of that regardless of how the 18.1+
setting is configured. Gating on the token's presence -- rather than
re-deriving all of the above from branch name or pipeline source -- is what
keeps this job correct no matter how those settings change later; that's
the point of the design, not branch-name matching. Assertions 1-4 need no
auth and always run; assertion 5 skips (not fails) with an explanatory
message when the token is absent. It runs for real on pushes to master,
and -- once a pipeline schedule exists targeting master -- on those runs
too (none is configured today; see Related issues).
Container fix: stdout-only snapshot capture + system CA certificates
node:22-slim ships no /etc/ssl/certs, and the CLI's version-guard.cjs
passes Node's --use-system-ca flag on this image's Node version, so every
duo invocation -- including a bare --help -- prints an OpenSSL
cert-directory warning to stderr. The help-snapshot capture is stdout-only
(2>/dev/null) so that cosmetic warning can never corrupt the snapshot
compare; the fixture was already generated stdout-only, so this also fixes
a self-inconsistency between how the fixture was made and how it was
checked. The job's before_script installs ca-certificates so the live
assertions' TLS calls to gitlab.com have a trust store to use.
Version-pin coordination and its limit
The job declares and installs its own pin (DUO_CLI_CANARY_PIN: "9.10.0")
and runs with needs: [], independent of whatever stig_tools/Dockerfile's
npm install --global @gitlab/duo-cli line bakes into the analyzer image.
A companion change is expected to pin that Dockerfile line to the same
version; this canary does not depend on that change landing first or in
any particular order. Limitation: until that companion pin lands, a
green canary here proves the CLI's contract at 9.10.0 -- it does not prove
the shipped analyzer image is running that version; the image may still
float to whatever was latest when it was last built. Keep the two pins in
sync going forward.
Why scripts/, not stig_tools/
stig_tools/verify_duo_creds.sh and its siblings run inside the built
analyzer image (copied and chmod +x in stig_tools/Dockerfile). This
canary intentionally runs in a separate, minimal node:22-slim job with no
dependency on that image or its build stage, so it lives under a new
top-level scripts/ directory instead.
Relationship to existing coverage
test_ai_connectivity already exercises the real text-mode parse path
end-to-end with a live model call, but only under *full_pipeline_rules
(gated on stig_tools/**/* changes for cost). This canary's distinct value
is the cheap flag-surface and contract check that runs on every pipeline,
whether or not this repo changed anything -- including the token-absent case
where test_ai_connectivity doesn't run at all.
Related issues
None filed yet -- follow-up: nightly pipeline schedule on master so the canary catches upstream drift between pushes.
Documentation updates
CHANGELOG.md updated under [Unreleased] ("Added - GitLab Duo CLI
output-contract canary"), matching the file's existing entry convention. No
README.md change: this is an internal CI job in the component project's own
pipeline, not a new input exposed to consumers of the CI/CD Catalog
component.
CHANGELOG.md updated
Yes.
Test plan
-
duo --version,duo run --help, and the flag/choice grep all pass locally against the actual pinned@gitlab/duo-cli@9.10.0 - The four non-auth assertions pass end-to-end with no token set (the expected merge-request-pipeline condition), piping stdout through a non-interactive, non-TTY pipe, and the live assertions correctly SKIP with the documented explanation -- script exits 0
- Each failure path (stale pin, shadowed install, corrupted snapshot) was independently triggered locally and produced the correct prescriptive remediation text
- The embedded
[RunController]-marker + brace-extraction checker was unit-tested standalone against a realistic good fixture and four broken variants (no marker, no brace block, unbalanced braces, malformed JSON) -- each produces the correct pass/fail outcome; this also caught and fixed a realprocess.argvindex bug in that checker before it reached CI -
shellcheck -s sh scripts/duo_cli_canary.sh-- no findings - Confirmed in-container on
node:22-slim(Debian 12, Node v22.23.2): with the job'sbefore_scriptinstallingca-certificates, all four static assertions pass and the live assertions skip, exit 0; and with noca-certificatesat all, the OpenSSL stderr warning appears in the log but never reaches the snapshot compare -- still exit 0, proving the stdout-only capture is immune by construction
Checklist
- README.md updated (if applicable) -- N/A, no consumer-facing input added
- CHANGELOG.md updated
- Security documentation updated (if applicable) -- N/A
- Usage examples added (for new features) -- N/A, internal CI job
- Pipeline passes -- non-auth assertions run on this MR's pipeline; the
live assertions exercise for real once merged to
master