M1-74: CodeQL for Go¶
Status: ready | Depends on: M1-05 | Milestone: M1
Goal¶
After this ticket the CodeQL workflow analyzes the Go server module services/server next to the
workflow files and the Python tools, in the public phase only, exactly as ADR 0039 rule 1 allows.
The Go entry builds the shipped module manually, because CodeQL offers no build-free mode for Go
and its autobuilder would first run the repository root Makefile. A test against the real
workflow file keeps the CodeQL matrix in step with the languages the repository ships, so the next
supported language cannot be forgotten silently.
Read first¶
docs/PLAN.md: section "M1 Foundation", ticket row M1-74 (Block H), "Way of working per ticket", "Decisions of this plan" (P-09, P-12)..github/workflows/codeql.yml(current state: matrix[actions, python], every entry withbuild-mode: none; the job condition is the public guard).docs/adr/0039-only-github-hosted-runners-public-from-the-first-push.md, "Decision Outcome", rule 1 "Public jobs".tools/ci/check_runners.py(PUBLIC_GUARD,has_public_guard,load_yaml, the rule list in the module docstring) andtools/ci/tests/test_check_runners.py(test style,REPOconstant).docs/quality/toolchain.md, section "GitHub Actions" (pins ofactions/checkout,actions/setup-go,github/codeql-action) andtools/docs/tests/test_toolchain_doc.py(test_actions_table_matches_workflows: one version per action across all workflows).docs/quality/gates.md: "List" (rowslint,runners-check) and "What the gates do not cover" (no shellcheck; workflows only run short commands).docs/quality/public-switch.md, step 18 "Trial run".docs/spec/23-quality.md: "Component contract" (components are discovered from tracked manifests) and R-QA-05 (CodeQL is part of the general static analysis).Makefile: component discovery (GO_MODS,PY_PROJECTS,TS_PROJECTS), targetslint(actionlint, zizmor),runners-check,typecheck(go build ./...per Go module)..github/workflows/ci.yml: howactions/setup-gois pinned and fed withgo-version-file.services/server/go.modandservices/server/README.md(from M1-05).- External facts (checked 2026-10-10):
- "CodeQL supports build modes
autobuildormanualfor Go code"; Go has nononemode; the Go autobuilder first invokesmake,ninja,./buildor./build.shand then needsgo list ./...to succeed; test files (_test.go) are not analyzed by default. https://docs.github.com/en/code-security/reference/code-scanning/codeql/build-options-for-compiled-languages [verified] - CodeQL CLI terms: analysis is permitted only on an "Open Source Codebase" (released under an OSI-approved license); use "in connection with any codebase that is not an Open Source Codebase (e.g., code in a private repo in GitHub)" is excluded without a paid GitHub Advanced Security license. https://raw.githubusercontent.com/github/codeql-cli-binaries/main/LICENSE.md [verified]
- "CodeQL supports build modes
- This ticket touches no specification chapter, API operation, data table or action id.
Fixed requirements that apply¶
Owner build rules (verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: the new test is red before the workflow changes (record the red output), then green.
- No en or em dashes in any text you write (
uv run --project tools --locked --quiet python tools/hooks/text_rules.py <file>exits 0). White label: no company, person, host or domain names exceptexample.com; no paths of a personal machine. - Configuration values only through named keys from
docs/operations/configuration.md; this ticket introduces none.
Product requirements of this ticket:
- CodeQL is a public-phase job: the job condition stays exactly
PUBLIC_GUARDfromtools/ci/check_runners.py(ADR 0039 rule 1). - Every action is pinned to a full commit SHA with the version comment already listed in
docs/quality/toolchain.md; no new action and no new version (ADR 0039 "Confirmation",test_actions_table_matches_workflows). - Permissions stay minimal: top-level
contents: read; job-levelcontents: readandsecurity-events: writeonly;persist-credentials: falseon checkout (zizmorartipacked). - CodeQL covers every tracked component language that CodeQL supports (plan ticket row M1-74;
R-QA-05). Dart is not supported by CodeQL and is covered by
flutter analyzeand SonarQube Cloud (23 "Gates").
Scope¶
-
.github/workflows/codeql.yml(change):- Header comment: replace the sentence "Languages: add
goandjavascript-typescript..." with: the matrix lists every tracked language CodeQL supports;tools/ci/tests/test_codeql_workflow.pyenforces it;javascript-typescriptis added by the ticket that adds the first TypeScript project; Go usesbuild-mode: manualbecause CodeQL has nononemode for Go and the autobuilder would run the rootMakefilefirst; Dart is not supported by CodeQL. -
The matrix becomes an
includelist, exactly:strategy: fail-fast: false matrix: include: - language: actions build-mode: none - language: go build-mode: manual - language: python build-mode: none -
Steps, in this order:
actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1withpersist-credentials: false(unchanged).-
Go toolchain, only for Go:
- if: matrix.language == 'go' uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: services/server/go.mod cache: falsecache: falsebecause the Go build cache is not checksum-verified (unlike modules, whichgo.sumverifies) and an analysis job must not trust state written by another run; the job runs on free public runners, so a cache saves nothing worth that risk. -
github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3withlanguages: ${{ matrix.language }},build-mode: ${{ matrix.build-mode }},queries: security-extended. -
Build, only for Go:
- name: Build the server module for CodeQL if: matrix.language == 'go' working-directory: services/server env: CGO_ENABLED: "0" run: go build ./...CGO_ENABLED=0because the server ships as a static binary (baseline B2, ADR 0005), so CodeQL sees the build configuration that is shipped. -
github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3withcategory: /language:${{ matrix.language }}(unchanged).- Unchanged: name, triggers (
pull_requesttomain,schedule,workflow_dispatch), top-levelpermissions,concurrency, jobif,runs-on: ubuntu-24.04,timeout-minutes, jobpermissions.
- Unchanged: name, triggers (
tools/ci/tests/test_codeql_workflow.py(new, SPDX header# SPDX-License-Identifier: Apache-2.0, module docstring). Contents:REPO = Path(__file__).resolve().parents[3]; YAML loaded withcheck_runners.load_yaml(keepsona string).LANGUAGE_SOURCES: tracked-manifest patterns to CodeQL languages, mirroring theMakefilediscovery plus the repository's own code:
tracked path (fnmatch) CodeQL language .github/workflows/*.yml,.github/workflows/*.yamlactionsservices/*/go.modgotools/pyproject.toml,sdk/python/pyproject.toml,plugins/*/pyproject.tomlpythonsdk/typescript/package.json,plugins/*/package.jsonjavascript-typescripttools/node/package.jsonis excluded on purpose: it only pins Node tools and has no source. -
ALLOWED_BUILD_MODES = {"actions": {"none"}, "python": {"none"}, "javascript-typescript": {"none"}, "go": {"manual"}}with a comment citing the GitHub page above and the reasonautobuildis not allowed for Go. tracked_files() -> list[str]:git ls-filesinREPO(tracked files only, like theMakefile; subprocess with a fixed argument list, no shell,check=True).required_languages(tracked: Iterable[str]) -> set[str].codeql_findings(doc: dict[str, Any], tracked: Iterable[str]) -> list[str]: one readable finding per violation:- the
analyzejob has nostrategy.matrix.includelist, or an entry lackslanguageorbuild-mode; - a required language is missing from the matrix;
- a build mode is not in
ALLOWED_BUILD_MODESfor its language (an unknown language too); - the job
ifis not the public guard (check_runners.has_public_guard); - for every tracked
services/<m>/go.mod: no stepactions/setup-go@...withif: matrix.language == 'go'andwith.go-version-file: services/<m>/go.mod; norunstepgo build ./...withworking-directory: services/<m>andif: matrix.language == 'go'; or the build step is not after theinitstep and before theanalyzestep; - the
initstep does not passbuild-mode: ${{ matrix.build-mode }}.
- the
- Tests (names fixed):
test_required_languages_mapping(parametrized: each row of the table, plustools/node/package.jsonandapps/client/pubspec.yamlmapping to nothing).test_matrix_without_include_is_rejected(plainlanguage: [go]list).test_go_build_mode_none_is_rejected,test_go_autobuild_is_rejected.test_missing_required_language_is_rejected(synthetic tracked list withsdk/typescript/package.json).test_go_setup_step_must_use_module_go_mod,test_go_build_step_must_run_in_module,test_go_build_step_must_sit_between_init_and_analyze.test_init_must_pass_matrix_build_mode.test_job_must_keep_public_guard(guard removed or weakened).test_compliant_synthetic_workflow_passes(one fixture that satisfies every rule, so the negative tests cannot pass for a wrong reason).test_repository_codeql_workflow_has_no_findings:codeql_findings(<parsed .github/workflows/codeql.yml>, tracked_files()) == []; it first asserts that at least oneservices/*/go.modis tracked, so it cannot pass vacuously.
- Each negative test asserts on the text of the expected finding, not only on a non-empty list.
docs/quality/public-switch.md, step 18 "Trial run": name the checks to look at after the first public pull request: jobsanalyze (actions),analyze (go),analyze (python)green, and code scanning shows results for the category/language:go. English, like the file.CHANGELOG.md(root, section[Unreleased], subsection### Changed): one line, for example "CodeQL analyzes the Go server module (manual build ofservices/server) in the public phase; a test keeps the CodeQL languages in step with the tracked components."
- Header comment: replace the sentence "Languages: add
No other file changes. No ADR: ADR 0039 holds the decision, and the reasons live in the workflow comment.
Out of scope¶
javascript-typescriptin the matrix: added by the ticket that adds the first TypeScript project (sdk/typescript, milestone M2); the new test will force it.- Dart (not supported by CodeQL), custom CodeQL queries, a CodeQL config file, path filters.
- Enabling code scanning, rulesets or
BOTHY_VISIBILITYon GitHub (public-switch runbook, V1). - Any change to
tools/ci/check_runners.py,ci.yml,scorecard.ymlor theMakefile. - Running CodeQL at all before the repository is public (see Boundaries).
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M1-74-codeql-go. - Conventional Commits with DCO sign-off (
git commit -s), scopeci, for exampleci(codeql): analyze Go with a manual build of the server module. - Never push, tag, open pull requests, create remote repositories or trigger workflows on GitHub.
- Do not run the CodeQL CLI, a CodeQL bundle or
codeql-actionlocally: the CodeQL terms exclude analysis of a codebase that is not released open source, and this repository is private until V1. The workflow is proven by lint, policy check and tests now, and by its first run after the public switch (public-switch.md step 18). - Do not modify files outside the scope list.
- Follow the host rules supplied by the launcher. Stop only processes you started, by PID.
Temporary directories only via
mktemp -d, removed at the end. Never read or print secrets. No network calls except package downloads for pinned tools (make bootstrap, Go modules).
Design notes¶
- Why
manualand notautobuildfor Go: the autobuilder first triesmakeat the repository root; the rootMakefilehashelpas its default goal, somakesucceeds without building Go, and the followinggo list ./...runs at a root withoutgo.mod. The outcome would depend on fallback heuristics. A manualgo build ./...inservices/serveris deterministic and is the same commandmake typecheckruns. - Test files stay unanalyzed: CodeQL skips
_test.goby default and test code is not shipped. Do not setCODEQL_EXTRACTOR_GO_OPTION_EXTRACT_TESTS. matrix.build-modeis a valid expression (property names may contain-); actionlint accepts it. Keep everyrunstep a single short command (gates.md: shellcheck is not pinned).make lintruns zizmor offline unlessGH_TOKENis set; report which mode ran. Facts zizmor relies on here: no untrusted context insiderun, no cache (cache: false),persist-credentials: false,security-events: writeonly at job level.- The job condition must stay equal to
PUBLIC_GUARD(whitespace aside);make runners-checkandtest_job_must_keep_public_guardboth check it. test_actions_table_matches_workflowsrequires one version per action across all workflows: copy the SHA and the# v7.0.0comment ofactions/setup-goexactly as inci.yml.- Type hints everywhere in the new test module (mypy strict runs over
tools/);ruffandbanditstay clean.
Tests (write them first)¶
- Write
tools/ci/tests/test_codeql_workflow.pycompletely. Runcd tools && uv run --locked pytest ci/tests/test_codeql_workflow.py -q: every synthetic test passes, andtest_repository_codeql_workflow_has_no_findingsfails with findings that include "go missing from the matrix" and "init does not pass the matrix build mode" (record this red output for the report). - Change
codeql.yml. The same command is green. - Then all of:
cd tools && uv run --locked pytest ci/tests docs/tests/test_toolchain_doc.py -qmake runners-checkmake lintcd services/server && CGO_ENABLED=0 go build ./...(the exact build the workflow runs)make check
Acceptance criteria¶
-
codeql.ymlhas theincludematrix withactions/none,go/manual,python/none;initpassesbuild-mode: ${{ matrix.build-mode }}. - Go entry: pinned
setup-gowithgo-version-file: services/server/go.modandcache: false, theninit, thengo build ./...inservices/serverwithCGO_ENABLED=0, thenanalyze; both Go steps carryif: matrix.language == 'go'. - Job
if, triggers, permissions, concurrency, runner and timeout are unchanged. -
test_codeql_workflow.pywas red before the workflow change and is green after it; every negative test fails for the reason in its name (asserted on the finding text). -
make runners-check, actionlint and zizmor (viamake lint) are green. -
CGO_ENABLED=0 go build ./...succeeds inservices/server. -
docs/quality/public-switch.mdstep 18 names the CodeQL checks; rootCHANGELOG.mdhas the entry; the workflow header comment states the language rule and the reason formanual. - No CodeQL binary was downloaded or run; no workflow was triggered.
-
make checkis green.
Definition of done¶
Code, tests and docs as above: module docstring and doc comments on every helper in the new test
module, workflow comment explaining why, CHANGELOG.md entry under Unreleased, public-switch
runbook updated; no ADR needed (ADR 0039 holds the decision). make check green. Independent
review (make review plus a code review over the diff) and the red team gate
(docs/quality/red-team-gate.md) over the diff with the verdict line recorded in the report;
merge locally into the base branch only if the review is clean, the verdict is JA and
make check is green; then git worktree remove.
Report¶
Write to {{REPORT_PATH}}: first line Verdict: DONE or Verdict: BLOCKED <reason>; then the
commits (SHA and subject), files with line counts, test results with counts (the red run of step 1
and the green runs), the zizmor mode (online or offline), what is not proven (the workflow has not
run on GitHub; CodeQL results appear only after the public switch), and follow-ups that could not
be done in scope with the reason. Then write {{DONE_PATH}} with exit=0 sha=<merge sha> or
exit=1 <reason>.