Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ gitlab-mcp-server/
│ ├── bench_resources/ # What the server costs to run, and the charts the docs publish
│ ├── godoc_tool/ # Godoc auditor + fixer (audit/fix; was audit_godocs + add_docs; make check-godocs)
│ ├── format_md_tables/ # Markdown pipe-table normalizer
│ ├── measure_conditions/ # Plan and job summary of the on-demand condition coverage workflow (conditions.yml, dispatched by hand)
│ ├── measure_conditions/ # Plan, measuring loop and job summary of the on-demand condition and branch coverage workflow (conditions.yml, dispatched by hand), and the whole-tree figures (make coverage-conditions-tree)
│ ├── fuzz_targets/ # The fuzz targets the weekly fuzzing workflow (fuzz.yml) runs with go test -fuzz
│ ├── gen_action_catalog_manifest/ # ActionSpec group-builder manifest
│ ├── gen_brand/ # Every vector brand asset from one parametric geometry
│ ├── gen_icon_webp/ # Light/dark WebP icon fallbacks from icons.go
Expand Down
124 changes: 78 additions & 46 deletions .github/workflows/conditions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@
# dispatch names, so every figure is the one the gate would compute there:
# measured on that system, never inferred from another.
#
# It also takes the figure for the whole tree: with tree set, every package
# below cmd/ and internal/ is measured, in shards, and the summary adds up
# each system's total. mode chooses what gobco counts: condition, its
# default, counts every operand of && and || and every comparison; branch is
# gobco's -branch, which counts the whole condition of each if and for and
# each switch case, the branches the OpenSSF Best Practices criterion
# test_branch_coverage80 asks about. docs/development/testing/README.md
# records the figures taken in both, and the command that takes them on any
# machine.
#
# Dispatch only. A push or a pull request never runs it: the answer is wanted
# when somebody asks the question, and a package can take the better part of
# an hour on Windows. Nothing here is a gate. No figure fails it, whatever the
Expand All @@ -20,15 +30,21 @@
# job, and says why.
#
# Shape: plan validates the inputs and, for an empty package list, finds the
# packages carrying a GOOS- or GOARCH-constrained file; measure fans out over
# package x system; summary joins the records the runs uploaded into one table
# in the job summary. The plan and the table are cmd/measure_conditions, whose
# tests hold this file to the record it reads.
# packages carrying a GOOS- or GOARCH-constrained file, or with tree lists
# the tree and deals it into shards; measure fans out over job x system, each
# job measuring its packages one after another; summary joins the records the
# runs uploaded into one table, with the totals, in the job summary. The plan,
# the measuring loop and the table are cmd/measure_conditions, whose tests
# hold this file to the record it reads.
#
# Dispatch with:
#
# gh workflow run conditions.yml -f packages="./cmd/server ./internal/toolutil" \
# -f systems="windows-latest macos-latest ubuntu-latest" -f gate=all
#
# gh workflow run conditions.yml -f tree=true -f systems=ubuntu-latest -f gate=all
#
# gh workflow run conditions.yml -f tree=true -f systems=ubuntu-latest -f gate=all -f mode=branch

name: Condition Coverage

Expand All @@ -50,6 +66,17 @@ on:
description: "Runners to measure on, separated by spaces or commas: windows-latest, macos-latest, ubuntu-latest (the comparison)."
default: "windows-latest macos-latest ubuntu-latest"
type: string
tree:
description: "Measure every package below cmd/ and internal/ instead, in shards, with each system's total. Leave packages empty."
default: false
type: boolean
mode:
description: "What gobco counts. condition, its default, counts every operand of && and || and every comparison; branch (gobco -branch) counts the whole condition of each if and for and each switch case."
default: condition
type: choice
options:
- condition
- branch

# Read only. Nothing here writes to the repository, comments, or mints an
# identity: the default token reads the checkout and the artifacts of this run.
Expand Down Expand Up @@ -79,25 +106,33 @@ jobs:
# the expression syntax inside the script, so a value is data whatever
# it holds. The command refuses a package, a runner or a gate it would
# not hand on, and for an empty package list lists the module under
# every release target to find the packages whose files differ.
# every release target to find the packages whose files differ. With
# tree it lists the packages below cmd/ and internal/ instead, and
# refuses a package list beside it. The mode is refused here too when
# the script would refuse it, before any runner starts.
- name: Plan the runs
id: plan
shell: bash
env:
PACKAGES: ${{ inputs.packages }}
SYSTEMS: ${{ inputs.systems }}
GATE: ${{ inputs.gate }}
run: go run ./cmd/measure_conditions plan -packages "$PACKAGES" -systems "$SYSTEMS" -gate "$GATE" >> "$GITHUB_OUTPUT"
MODE: ${{ inputs.mode }}
TREE: ${{ inputs.tree }}
run: go run ./cmd/measure_conditions plan -packages "$PACKAGES" -systems "$SYSTEMS" -gate "$GATE" -mode "$MODE" -tree="$TREE" >> "$GITHUB_OUTPUT"

measure:
name: "🔬 ${{ matrix.package }} on ${{ matrix.system }}"
name: "🔬 ${{ matrix.name }} on ${{ matrix.system }}"
needs: plan
runs-on: ${{ matrix.system }}
# gobco's go test is bounded at 30 minutes below, the bound ci.yml gives
# cmd/server on Windows; the job bound leaves room for the toolchain and
# the staged copy around it, and ends a hang on a runner billed at a
# multiple of the Linux rate.
timeout-minutes: 60
# cmd/server on Windows. A job of named packages measures one, and keeps
# the hour it had before the tree could be measured; a shard of the tree
# measures fifteen or so one after another, and is given two. Each bound
# leaves room for the toolchain and the staged copies around the
# measuring, and ends a hang on a runner billed at a multiple of the Linux
# rate.
timeout-minutes: ${{ inputs.tree && 120 || 60 }}
strategy:
# Every run reports. Stopping at the first that could not measure would
# hide every figure the others took.
Expand All @@ -112,20 +147,26 @@ jobs:
go-version-file: go.mod
- run: go mod download
# The script, through bash, exactly as ci.yml's legs run it and as
# `make coverage-conditions` runs it, with the gate the dispatch chose.
# Both streams go to the log and to the raw output file.
# `make coverage-conditions` runs it, with the gate and the mode the
# dispatch chose, over each package of the job one after another. The
# measure subcommand runs it and writes each package's record: both
# streams of the script go to the log and to the package's raw output
# file, beside a run.env naming the package, the runner, the platform
# and the script's status, in a directory of its own named after the
# package.
#
# The record is written outside the checkout: the script copies the
# The records are written outside the checkout: the script copies the
# module into a staged directory for a package with a platform file,
# and a file this step is still writing inside the tree would be copied
# mid-write. RUNNER_TEMP is a Windows path on Windows, written with
# slashes here, which Git Bash and go both read, as the script does with
# go list's directories.
# and a file written inside the tree would be copied with it.
# RUNNER_TEMP is a Windows path on Windows, written with slashes here,
# which Git Bash and go both read, as the script does with go list's
# directories.
#
# The exit status is read for one thing: 0 and 3 are a measurement (3 is
# the gate holding a condition, which the script uses for nothing else),
# and anything else means nothing was measured. That fails the job with
# the reason; a figure never does.
# The script's status is read for one thing: 0 and 3 are a measurement
# (3 is the gate holding a condition, which the script uses for nothing
# else), and anything else means nothing was measured. A package not
# measured fails the job with the reason once the job's other packages
# are measured; a figure never does.
#
# setup-go registers a problem matcher, owner go, that files every line
# shaped like file.go:line:col: text as an error annotation, and every
Expand All @@ -137,32 +178,21 @@ jobs:
- name: Measure
shell: bash
env:
PACKAGE: ${{ matrix.package }}
PACKAGES: ${{ matrix.packages }}
SYSTEM: ${{ matrix.system }}
ARTIFACT: ${{ matrix.artifact }}
GOBCO_GATE: ${{ inputs.gate }}
GOBCO_MODE: ${{ inputs.mode }}
GOBCO_TEST_FLAGS: -timeout=30m
run: |
echo "::remove-matcher owner=go::"
record="${RUNNER_TEMP//\\//}/$ARTIFACT"
mkdir -p "$record"
set +e
bash scripts/coverage-conditions.sh "$PACKAGE" 2>&1 | tee "$record/gobco.txt"
status=${PIPESTATUS[0]}
set -e
{
echo "package=$PACKAGE"
echo "system=$SYSTEM"
echo "platform=$(go env GOOS)/$(go env GOARCH)"
echo "status=$status"
} >"$record/run.env"
case "$status" in
0|3) echo "Measured. The figure is in the summary job's table." ;;
*)
echo "::error title=Not measured::scripts/coverage-conditions.sh exited $status for $PACKAGE on $SYSTEM, so nothing was measured; the gobco: lines above say why, and the raw output is the artifact $ARTIFACT"
exit "$status"
;;
esac
status=0
go run ./cmd/measure_conditions measure -packages "$PACKAGES" -system "$SYSTEM" -records "${RUNNER_TEMP//\\//}/$ARTIFACT" || status=$?
if [ "$status" -ne 0 ]; then
echo "::error title=Not measured::a package of this job was not measured on $SYSTEM (measure_conditions exited $status); the measure_conditions: and gobco: lines above say which and why, and the raw output is in the artifact $ARTIFACT"
exit "$status"
fi
echo "Measured. The figures are in the summary job's table."
# Uploaded whatever became of the measurement, since a run that could
# not measure is the one whose output somebody will want to read. A run
# that never reached the step above has nothing to upload, which the
Expand Down Expand Up @@ -202,13 +232,15 @@ jobs:
with:
pattern: gobco-*
path: ${{ runner.temp }}/records
# The table goes to the job summary and to the log. The command exits
# non-zero only when a record cannot be read or disagrees with itself,
# never on a figure.
# The table goes to the job summary and to the log, headed by the mode
# the runs measured in, whose figure it reads each record's in. The
# command exits non-zero only when a record cannot be read or disagrees
# with itself, never on a figure.
- name: Write the job summary
shell: bash
env:
MATRIX: ${{ needs.plan.outputs.matrix }}
GATE: ${{ inputs.gate }}
MODE: ${{ inputs.mode }}
COMMIT: ${{ github.sha }}
run: go run ./cmd/measure_conditions summarize -plan "$MATRIX" -records "$RUNNER_TEMP/records" -gate "$GATE" -commit "$COMMIT" | tee -a "$GITHUB_STEP_SUMMARY"
run: go run ./cmd/measure_conditions summarize -plan "$MATRIX" -records "$RUNNER_TEMP/records" -gate "$GATE" -mode "$MODE" -commit "$COMMIT" | tee -a "$GITHUB_STEP_SUMMARY"
116 changes: 116 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Fuzzing for gitlab-mcp-server
#
# The module carries fuzz targets (FuzzMeterFor, FuzzEvaluate_IsSoundAgainstGitLab,
# FuzzRefuseUnreadable and the rest), and go test runs each one's seed corpus
# as one more test: it varies no input unless it is given -fuzz, and until this
# workflow nothing here gave it. This runs every target with go test -fuzz for
# a fixed time, so the inputs the targets exist to vary are varied.
#
# Where it runs, and where it does not:
#
# * once a week on main, on Wednesdays, which no other weekly workflow
# runs on;
# * when dispatched, from the Actions tab or with `gh workflow run fuzz.yml`;
# * on a pull request that changes this file or cmd/fuzz_targets, and on no
# other, with one minute per target rather than ten. GitHub offers a
# workflow for dispatch only once its file is on the default branch, so
# the pull request that adds this file can run it through this trigger
# alone; a later change could be dispatched from its branch, and this
# runs it without anybody having to.
#
# It is a workflow of its own for the reason race.yml is: ci.yml converges on
# one job, "CI", the single check branch protection requires, and a job added
# there would gate every pull request. A failure here is a defect a fuzzer
# found, to be fixed, and is not wired into the release.
#
# Shape: plan lists the targets with cmd/fuzz_targets, which reads the test
# files the go command builds and prints one matrix cell per target, so a
# target added to any package is fuzzed from the next run on without an edit
# here, and a plan that found none fails rather than passing with nothing
# fuzzed. fuzz runs each cell. A failing input is what the go command writes
# under the package's testdata/fuzz/<target>; it is uploaded as the cell's
# artifact, and copying it into that directory of a checkout makes it a seed
# that `go test` replays from then on (docs/development/static-analysis.md,
# Fuzzing). The cmd/fuzz_targets tests hold this file to the matrix it prints.

name: Fuzz

on:
schedule:
# Wednesdays, 05:43 UTC: no other weekly workflow runs on that day (the
# one every day is gitlab-mirror.yml, every six hours at 17 past), and off
# the hour for the reason recorded in e2e.yml, since GitHub delays runs
# that pile up on round times.
- cron: "43 5 * * 3"
workflow_dispatch:
pull_request:
paths:
- .github/workflows/fuzz.yml
- cmd/fuzz_targets/**

# Read only. Nothing here writes to the repository, comments, or mints an
# identity: the default token reads the checkout and this run's artifacts.
permissions:
contents: read

jobs:
plan:
name: "📋 Plan"
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
matrix: ${{ steps.plan.outputs.matrix }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
- name: List the fuzz targets
id: plan
run: go run ./cmd/fuzz_targets >> "$GITHUB_OUTPUT"

fuzz:
name: "🎲 ${{ matrix.target }} (${{ matrix.package }})"
needs: plan
runs-on: ubuntu-latest
# Ten minutes of fuzzing, and the build of a test binary instrumented for
# it around them, which for cmd/server and internal/tools/search is a few
# minutes cold. The bound ends a hang; it is not how long a run should take.
timeout-minutes: 30
strategy:
# Every target reports. Stopping at the first failure would hide a
# second, different one.
fail-fast: false
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
- run: go mod download
# One target of one package, anchored so it matches no other, and no
# test beside it: the target's seeds run first as part of fuzzing. The
# inputs reach go test through the environment, so a value is data
# whatever it holds. On a failure go test prints the input, writes it
# under testdata/fuzz/<target> and exits non-zero, which fails the job.
- name: Fuzz
env:
PACKAGE: ${{ matrix.package }}
TARGET: ${{ matrix.target }}
FUZZTIME: ${{ github.event_name == 'pull_request' && '1m' || '10m' }}
run: go test -run '^$' -fuzz "^${TARGET}\$" -fuzztime "$FUZZTIME" "$PACKAGE"
# The directory holds the target's committed seeds as well when it has
# any, which a reader tells apart by name: the go command names a
# failing input after its hash, and the log of the step above names it.
- name: Upload the failing input
if: failure()
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: ${{ matrix.artifact }}
path: ${{ matrix.package }}/testdata/fuzz/${{ matrix.target }}
retention-days: 30
if-no-files-found: ignore
13 changes: 7 additions & 6 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ run:
go: "1.27"
timeout: 15m
tests: true
# golangci-lint defaults to one worker per logical CPU. Across 226 packages
# and 55 linters that working set does not fit comfortably in memory on a
# many-threaded machine: measured on a cold cache, the default (16 workers on
# a 16-thread laptop) peaked at 4.4GB and had not finished after 18 minutes,
# golangci-lint defaults to one worker per logical CPU, and that working set
# does not fit comfortably in memory on a many-threaded machine. Measured in
# July 2026 on a cold cache, when the module had 226 packages and this file
# enabled 52 linters and 3 formatters, the default (16 workers on a
# 16-thread laptop) peaked at 4.4GB and had not finished after 18 minutes,
# while the same run capped at 4 workers peaked at 3.6GB and took 3m01s.
# CI runners have 4 vCPUs, so this matches what CI already does implicitly and
# only changes behaviour on bigger machines — where it is 6x faster.
# CI runners have 4 vCPUs, so this matches what CI already does implicitly
# and only changes behaviour on bigger machines, where it was 6x faster.
concurrency: 4
# Every e2e build tag, so that the invocation the header documents sees the
# same files the Makefile's GO_ANALYSIS_TAGS does. A tagged file is invisible
Expand Down
Loading
Loading