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:

  1. The Node.js runtime satisfies the Duo CLI's own >=20.17 guard, checked before touching the CLI so a failure here is never misattributed to a CLI bug.

  2. The pinned @gitlab/duo-cli version installs, and duo --version reports exactly that pin (catches a floating/global install shadowing it).

  3. duo run --help matches 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-type was removed from duo run in v8.84.0 (a MINOR bump, under a "BREAKING CHANGES" heading), reintroduced the very next day in v8.86.0 as a bug fix, and is present-but-deprecated ((DEPRECATED) ... ignored) at the 9.10.0 pin 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.

  4. --output-format <format> is present in that help output with text/ json choices -- the flag the JSON-mode contract below depends on.

  5. Where GITLAB_DUO_TOKEN is available, two live contracts, asserted separately and labeled:

    • Today's contract -- duo run invoked with exactly the analyzer's flag shape (no --output-format) must emit a [RunController] marker on stdout followed by a brace-countable JSON object, mirroring parse_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 json must 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.

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.

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 real process.argv index 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's before_script installing ca-certificates, all four static assertions pass and the live assertions skip, exit 0; and with no ca-certificates at 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

Merge request reports

Loading