Skip to content

fix(release): sign and attest the image outside the job that builds it #2275

fix(release): sign and attest the image outside the job that builds it

fix(release): sign and attest the image outside the job that builds it #2275

Workflow file for this run

# CI pipeline for gitlab-mcp-server
#
# Runs on: pull requests to main, pushes to main
#
# Shape. A job depends on another only when it consumes something the other
# produced, and everything converges on one final job, "CI", which is the one
# check branch protection requires. That is what makes the graph read as a
# flow rather than as a box of unconnected names, and it is also what lets the
# job list change without touching a repository setting: a required check
# named after a job is a setting that has to move with the job.
#
# compile ─┬─ typecheck (windows/amd64 · darwin/arm64 · linux/arm64)
# ├─ e2e-http
# └─ e2e-stdio
# coverage ─── sonarcloud
# docker (×2) ─── docker-build
# cross_platform (×3) ─── cross_platform_summary
# generated · golangci-lint · govulncheck · markdown · site-lint · hadolint
# server-json-schema · supply-chain · docs-audit ─── checks
# └──► ci
#
# Every edge joins neighbouring columns, and that is deliberate. GitHub draws
# a job with no parent and no child at the end of the first column, in one box
# with every other such job, and draws that box's single edge straight to
# whatever needs it. With "ci" two columns away, that edge crossed every node
# of the second column on its way there, and so did the direct edges from
# compile and coverage. So the standalone jobs fan into "checks" first, and
# "ci" needs only second-column nodes; compile and coverage are still decided
# there, because when either fails its children are skipped, and a skip is a
# failure to the verdict script.
#
# Speed. The critical path used to be Test then Build in series, about twenty
# minutes, and Build consumed nothing Test produced. Now the longest path is
# the coverage run followed by the SonarCloud scan, around eleven, level with
# the macOS leg of the platform matrix. Everything that runs a command by name
# runs a pinned version, and nothing resolves a package from a registry at run
# time except the audit whose job is to ask the registry.
name: CI
on:
# No branches filter on pull_request: a stacked PR whose base is another
# feature branch must run the same suite as one targeting main, or a
# green check mark means "CI did not run here" instead of "CI passed".
pull_request:
push:
branches: [main]
# One in-flight run per ref: with pull_request no longer filtered by base
# branch, a stacked PR's pushes would otherwise pile up obsolete runs.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
# The Go version is not stated here: every setup-go step reads it from go.mod,
# which is the one place a Go bump has to touch. The same goes for Node
# (site/.node-version) and pnpm (the packageManager field in site/package.json).
env:
COVERAGE_MIN: "90"
# Freshness gates compare a committed, generated artifact (README stats,
# llms*.txt, the tool snapshots, the token footprint, the testing reference,
# the request inventory, the manifests) with what the tree would generate
# now. In a stack every layer below the top would fail on drift the top
# overwrites, and refreshing each layer costs a suite run per layer for
# artifacts nobody keeps. So they are deferred there and run where the
# artifacts land: at the top of a stack, on a pull request to main that is
# not in a stack, and on every push to main. The unit suite reads the same
# answer through GITLAB_MCP_TEST_SNAPSHOT_PARITY.
#
# A pull request in a GitHub stack is tested as the whole stack up to it
# merged into the stack's base, and github.base_ref is that base (main) for
# every layer, so the layer is told from its position in the stack rather
# than from its own base branch, which stays in the event payload. A pull
# request outside a stack whose base is another branch is deferred as before.
FRESHNESS: ${{ github.event_name == 'pull_request' && ((github.event.pull_request.stack != null && github.event.pull_request.stack.position != github.event.pull_request.stack.size) || (github.event.pull_request.stack == null && github.event.pull_request.base.ref != 'main')) && 'deferred' || 'checked' }}
jobs:
# The unit suite with coverage, and nothing else: it is the longest single
# command in the pipeline at close to seven minutes, so anything that shares
# a job with it waits on it for no reason, and the only consumer of its
# output is the SonarCloud scan below.
coverage:
name: "🧪 Coverage"
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
- run: go mod download
- name: Run tests with coverage
# The suite also records every request it issues, which is what the
# inventory gate below merges. Recording is a shard file per test
# process and costs the run nothing measurable, so it rides along here
# rather than paying for a second seven-minute suite of its own.
env:
GITLAB_MCP_TEST_INVENTORY_DIR: ${{ github.workspace }}/dist/request-inventory
GITLAB_MCP_TEST_SNAPSHOT_PARITY: ${{ env.FRESHNESS }}
run: |
go test -count=1 -coverpkg=./cmd/...,./internal/... -coverprofile=coverage.out ./cmd/... ./internal/...
go tool cover -func=coverage.out
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | tr -d '%')
echo "Total coverage: ${COVERAGE}%"
if awk "BEGIN {exit !(${COVERAGE} + 0 < ${COVERAGE_MIN} + 0)}"; then
echo "FAIL: coverage ${COVERAGE}% is below minimum ${COVERAGE_MIN}%"
exit 1
fi
echo "PASS: coverage ${COVERAGE}% meets minimum ${COVERAGE_MIN}%"
- name: Check the recorded request inventory
if: env.FRESHNESS == 'checked'
run: go run ./cmd/gen_request_inventory/ -check
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: coverage
path: coverage.out
retention-days: 1
# Every gate that compares a committed artifact with what the source tree
# would generate now. They used to sit in front of the coverage run in one
# job, which meant a stale README table delayed the coverage result by two
# minutes and a failing one hid it entirely. None of them needs the suite.
generated:
name: "🔎 Generated artifacts"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
- run: go mod download
- name: Check generated llms files
if: env.FRESHNESS == 'checked'
run: make check-llms
- name: Check LobeHub manifest
if: env.FRESHNESS == 'checked'
run: make check-lhm-manifest
- name: Check documented tool names exist
run: make check-doc-tool-names
- name: Check no read-only action reaches a GraphQL mutation
run: make check-readonly-graphql
- name: Check the pinned GraphQL schema parses
run: make check-graphql-schema
# The REST twin of the schema pin, and the oracle that is evaluated
# rather than parsed: what a booted GitLab says its own API is. It
# replaced two records that were readings of text, the OpenAPI document
# GitLab generates and a scan of the Grape source beside it, and answers
# both of their questions from one reading of one instance. The gate
# reads the committed record and needs no Docker, which is the whole
# reason the boot lives in a generator.
- name: Check the pinned live GitLab record
run: make check-api-live
# A meta-tool's description is checked against the parameters its routes
# take, which is the third party the golden snapshot never was (issue 574).
- name: Check every meta-tool description against its routes
run: make check-meta-descriptions
# The test transport judges every document a test sends. This judges the
# ones no test reaches, which is why it is a gate of its own.
- name: Check every GraphQL document against the pinned schema
run: make check-graphql-documents
# The other half of the same exchange: the struct each document is
# decoded into, judged against the selection set. No test can see a
# decoder disagreeing with GitLab, because the fixture is written to
# match the decoder.
- name: Check every GraphQL decoder against its document
run: make check-graphql-shapes
# R-PATH, the dimension that reads the request rather than the surface.
# It reads the committed inventory the coverage job regenerates, so it
# needs no suite run and no network, and it fails on a package the suite
# never drives as loudly as on a document GitLab would refuse.
- name: Check the request every action issues
run: make audit-1to1-paths
- name: Check the one-click install buttons
run: make check-install-buttons
# The manifest validator is the registry's publisher, a module whose 77
# direct dependencies are not this project's, so it is not a go.mod tool.
# It is installed once per pinned version into a directory named after
# that version and cached by that version, so a push resolves nothing
# over the network and a stale binary can never validate with another
# release.
- name: Read the pinned publisher version
id: publisher
run: echo "version=$(make -s mcp-publisher-version)" >> "$GITHUB_OUTPUT"
- name: Cache the MCP Registry publisher
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/gitlab-mcp-server/mcp-publisher/${{ steps.publisher.outputs.version }}
key: mcp-publisher-${{ steps.publisher.outputs.version }}-${{ runner.os }}
- name: Check MCP Registry manifest
run: make check-server-json
# Downloads every declared artifact, so it runs on main only. A pull
# request does not move server.json — the release stamp does — and paying
# a 40 MB download plus a registry round-trip on every push would trade a
# real gate for a flaky one.
- name: Check MCP Registry packages are published
if: github.event_name == 'push'
run: make check-server-json-packages
- name: Check plugin manifests (Agent Plugins + legacy Open Plugins)
run: make check-openplugin
- name: Check README repository statistics
if: env.FRESHNESS == 'checked'
run: make check-stats
- name: Check test-goroutine aborts
run: make check-test-goroutines
- name: Check case loops run as subtests
run: make check-test-subtests
- name: Check Markdown formatters escape what they interpolate
run: make check-md-escaping
- name: Check gateway-safe served characters
run: make check-gateway-chars
- name: Check test-file naming convention
run: make check-test-file-names
- name: Check token footprint
if: env.FRESHNESS == 'checked'
run: make check-footprint
- name: Check site stats
if: env.FRESHNESS == 'checked'
run: make check-site-stats
- name: Check brand assets
run: make brand-check
# check-icon-webp is deliberately NOT run here: it is a byte-level
# comparison whose output depends on the librsvg/cwebp versions doing
# the rendering — CI's Ubuntu toolchain produces different (equally
# valid) bytes than the maintainer machine for a handful of icons, so
# the gate only holds on the machine that committed the assets. It
# stays maintainer tooling, run with gen-icon-webp after icon edits.
#
# The benchmark pages take their numbers from the committed measurement
# record, so nothing is measured here and the step costs seconds. It is a
# freshness gate all the same, because the drawing around those numbers
# comes from the source tree: the palette from site/src/styles/theme.css
# and the chart and table strings from the command itself, either of
# which makes the committed SVGs and page blocks stale. update-all
# redraws them, so a stack refreshes them once at its top like the rest.
- name: Check the committed benchmark charts and tables
if: env.FRESHNESS == 'checked'
run: make check-bench-resources
- name: Check action catalog manifest
if: env.FRESHNESS == 'checked'
run: make check-action-catalog-manifest
# Everything in the testing reference that the source tree determines:
# test counts, naming breakdown, per-layer tables, and which packages
# appear in the coverage tables, which is what caught one missing from
# them. Not the coverage percentages: those depend on the machine that
# measured them (tests asserting permission refusals skip for uid 0,
# cmd/gen_icon_webp needs rsvg-convert and cwebp, cmd/server has a
# load-dependent branch), so a gate on them would go red over tenths of a
# percent nobody can fix. Carrying them forward instead also means no
# coverage pass here at all, so this costs seconds on a job SonarCloud
# already waits on. `make gen-testing-docs` refreshes the numbers.
- name: Check the generated testing reference
if: env.FRESHNESS == 'checked'
run: make check-testing-docs
sonarcloud:
name: "📊 SonarCloud"
runs-on: ubuntu-latest
# The only job here with a bound, because a hung scanner would otherwise
# sit on a runner for the six-hour default. It has to leave room for the
# scan to grow with the repository: at 5 minutes it started failing every
# attempt once the analysis reached 250s on top of ~51s of setup — 302s
# against a 300s ceiling. The scan itself had finished; what was cut off
# was the upload of the 8 MB report, so the run was killed after doing
# all the work and before recording any of it. 15 keeps the guard against
# a genuine hang while giving the scan roughly three times its current
# runtime, so ordinary growth does not re-break it.
timeout-minutes: 15
needs: [coverage]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: coverage
- name: SonarCloud Scan
if: env.SONAR_TOKEN != ''
uses: SonarSource/sonarqube-scan-action@22918119ff8e1ca75a623e15c8296b6ea4fbe28f # v8
continue-on-error: true
env:
SONAR_HOST_URL: https://sonarcloud.io
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Split out of the SonarCloud job, where it was the step before the scan and
# the only one that could fail it. The scan itself is continue-on-error, so a
# required check named after SonarCloud could not fail because of SonarCloud
# and could fail because npmjs.org did not answer, which happened on three
# pull requests in one afternoon: `pnpm audit` retried twice and timed out,
# the scan was skipped entirely, and the merge was blocked by something that
# is neither code quality nor under our control.
#
# Separated, the audit reports its own verdict under its own name and a
# registry outage stops blocking merges, while the analysis still runs and is
# still published. This job is deliberately not in the required-check list:
# what it reports is the state of somebody else's advisory database, which
# can turn red without a commit.
docs-audit:
name: "📦 Site dependency audit"
runs-on: ubuntu-latest
# The whole job is one call to somebody else's registry, and that call has
# already stalled here: pnpm retried twice over two minutes and gave up.
# Without a bound, a registry that accepts the connection and never answers
# holds a runner for the six-hour default.
timeout-minutes: 15
steps:
# This job runs third-party code: `pnpm install` may execute a
# dependency's lifecycle scripts. Without this the checkout token stays
# in .git/config while they run, so a compromised dependency in the
# documentation site's tree could read it. Nothing here talks to git
# after the checkout.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
# pnpm/action-setup installs pnpm itself from the npm registry on every
# run, into ~/setup-pnpm, and that download took 54 seconds here and 164
# in the Pages build on a slow registry day, against 8 seconds for the
# dependency install that follows it from cache. So the installed pnpm is
# cached too, keyed on the exact packageManager string in package.json,
# and on a hit the action is skipped and its bin directory put on PATH.
- name: Read the pinned pnpm version
id: pnpm
run: echo "spec=$(jq -r .packageManager site/package.json)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: pnpm-cache
with:
path: ~/setup-pnpm
key: pnpm-${{ runner.os }}-${{ steps.pnpm.outputs.spec }}
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
if: steps.pnpm-cache.outputs.cache-hit != 'true'
with:
package_json_file: site/package.json
- name: Put the cached pnpm on PATH
if: steps.pnpm-cache.outputs.cache-hit == 'true'
run: echo "$HOME/setup-pnpm/node_modules/.bin" >> "$GITHUB_PATH"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: site/.node-version
cache: pnpm
cache-dependency-path: site/pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
working-directory: site
# Advisories already judged live in site/pnpm-workspace.yaml, under
# auditConfig.ignoreGhsas, each with the reason beside it. That is the
# only place pnpm reads them from: from v10 the settings moved out of
# package.json, and a `pnpm` field there is now ignored with a warning
# rather than an error, so an exception added to it looks applied and is
# not.
#
# A registry that does not answer produces no verdict, and reporting one
# anyway is the mistake this job was split out of the SonarCloud job to
# stop making: a red check that means "npmjs.org timed out" is
# indistinguishable from one that means "a dependency has a high
# advisory", so the second stops being read. pnpm exits non-zero for
# both, so the two are told apart by what it printed, and an unanswered
# run is annotated rather than failed.
- name: Audit docs dependencies
shell: bash
working-directory: site
run: |
set +e
output=$(pnpm audit --audit-level=high 2>&1)
status=$?
printf '%s\n' "$output"
if [ "$status" -ne 0 ] && printf '%s' "$output" |
grep -qE 'TimeoutError|operation was aborted|ENOTFOUND|EAI_AGAIN|ECONNRESET|ECONNREFUSED|socket hang up'; then
echo "::warning title=Dependency audit did not run::the npm advisories endpoint did not answer, so this run says nothing about the dependencies"
exit 0
fi
exit "$status"
# The one thing every job below this one has in common: the server and the
# e2e suite compile. It is a minute, and it is the parent of the type-check
# matrix and the two transport modules so that the graph shows them as what
# they are, three questions asked of one binary. The children do not receive
# it as an artifact, since each transport harness builds ./cmd/server itself
# in its TestMain; waiting on this job costs them a minute and saves nothing
# when it passes, but it is a minute well below the pipeline's longest path,
# so the picture is free.
compile:
name: "🔨 Compile"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
- run: go mod download
- run: go build -o /dev/null ./cmd/server
- name: Verify E2E tests compile
run: go test -tags e2e -c -o /dev/null ./test/e2e/suite/
# A second compile rather than a second tag on the line above: the
# suite's *_ce_test.go and *_ee_test.go files exclude each other, so one
# compile sees one half and never the other. Between the day the first
# _ee_test.go was written and issue 570, nothing compiled the 41 EE files
# except a person running make test-e2e-docker-enterprise by hand.
- name: Verify the Enterprise E2E tests compile
run: go test -tags "e2e enterprise" -c -o /dev/null ./test/e2e/suite/
# Type-checks every build-constrained file including the tests, for each
# platform this project ships, on a Linux runner. The cross-platform matrix
# does this too, on real runners, but that matrix is expensive enough that
# somebody may reasonably narrow it later; this job means narrowing it can
# never take the compile of cmd/server/socket_mode_windows.go with it, which
# before either existed was first compiled by GoReleaser during a release.
#
# One job per target rather than a loop in one job: the three vets took four
# minutes in series and take under two in parallel, and a failure names the
# platform in the job list instead of in a log.
typecheck:
name: "🧭 Type-check (${{ matrix.target }})"
runs-on: ubuntu-latest
needs: [compile]
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
target: [windows/amd64, darwin/arm64, linux/arm64]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
- run: go mod download
- name: Vet for ${{ matrix.target }}
env:
TARGET: ${{ matrix.target }}
run: GOOS="${TARGET%%/*}" GOARCH="${TARGET##*/}" go vet ./...
# The HTTP transport module needs no GitLab and no credentials, so it runs
# here rather than only on demand. It is the gate for the handler chain in
# package main — cross-origin, preflight, auth modes, rate limiting, and every
# flag that restricts something — which unit tests cannot reach and which has
# shipped broken more than once. The nginx cases skip when Docker is
# unavailable.
e2e-http:
name: "🌐 HTTP transport e2e"
runs-on: ubuntu-latest
needs: [compile]
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
- run: go mod download
- name: HTTP transport end-to-end
run: go test -tags httpe2e -count=1 -timeout 900s ./test/e2e/http/
# stdio is the primary transport and nothing drove it until this module
# existed: the e2e suite uses an in-memory transport in the same process, so
# no pipes, no process, no separation of stdout from stderr, and none of the
# environment-variable configuration stdio mode actually uses. Two defects
# that shipped were reachable here and invisible everywhere else, one of them
# held in place by a unit test asserting the opposite.
e2e-stdio:
name: "🔌 stdio transport e2e"
runs-on: ubuntu-latest
needs: [compile]
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
- run: go mod download
- name: stdio transport end-to-end
run: go test -tags stdioe2e -count=1 -timeout 900s ./test/e2e/stdio/
# The linter used to be `go install …@latest`: fifty seconds compiling it
# from source on every run, and whichever version the proxy served that
# morning, so two runs of the same commit could disagree. The official action
# downloads a release binary for an exact version and caches it. It only
# installs; the Makefile target is still what runs, so CI and a developer's
# `make golangci-lint` are the same three commands.
golangci-lint:
name: "🧹 golangci-lint"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
- run: go mod download
# The version lives in the Makefile beside the other tool pins, so it is
# stated once; the reason it is not a go.mod tool directive is recorded
# there.
- name: Read the pinned linter version
id: lint
run: echo "version=$(sed -n 's/^GOLANGCI_LINT_VERSION := //p' Makefile)" >> "$GITHUB_OUTPUT"
- uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0
with:
version: ${{ steps.lint.outputs.version }}
install-only: true
# golangci-lint keeps a per-package analysis cache and skips what has
# not changed, which is most of the tree on most pull requests. The key
# names the linter config and go.sum, so a change to either starts
# fresh; restore-keys lets a run reuse the newest cache for the same
# config even when go.sum moved, since a dependency bump does not
# invalidate the analysis of packages that do not import it.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/golangci-lint
key: golangci-lint-${{ hashFiles('.golangci.yml', 'go.sum') }}-${{ github.sha }}
restore-keys: |
golangci-lint-${{ hashFiles('.golangci.yml', 'go.sum') }}-
golangci-lint-
- run: make golangci-lint
govulncheck:
name: "🛡️ govulncheck"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
- run: go mod download
# No version here on purpose: govulncheck is a `tool` directive in go.mod,
# so `go install` without @version installs the version go.mod names and
# Dependabot bumps it with the other modules. A scanner that changed under
# us would change what "no known vulnerability" meant last week.
- run: go install golang.org/x/vuln/cmd/govulncheck
- run: make govulncheck
# This job took six and a half minutes on a day the npm registry was slow, of
# which the lint itself was eight seconds: `npx --yes` resolved and installed
# markdownlint-cli2 from the registry on every run, and then did the same for
# the mcpb CLI. The linter now comes bundled in its own action, which touches
# no registry, and the CLI's npx cache is kept between runs keyed on the
# version the Makefile pins, so the registry is asked once per version.
analyze-md:
name: "📝 Markdown"
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0
with:
globs: |
**/*.{md,mdx}
#plan
#node_modules
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: site/.node-version
- name: Read the pinned mcpb CLI version
id: mcpb
run: echo "version=$(sed -n 's/^MCPB_CLI_VERSION := //p' Makefile)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.npm/_npx
key: npx-mcpb-${{ steps.mcpb.outputs.version }}
# Node-based freshness gates live here rather than beside the Go gates,
# which have no Node toolchain. check-doc-links resolves every relative
# link in tracked Markdown, so it needs the working tree, not just the Go
# module.
- name: Check documentation local links
run: make check-doc-links
- name: Check Claude Desktop extension manifest
run: make check-mcpb
site-lint:
name: "🎨 Site lint"
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# Same reasoning as docs-audit: this is the other job that installs the
# documentation site's dependencies, so it is the other one that runs
# code this repository did not write.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
# Same arrangement as docs-audit, for the same 54-second reason. This job
# used to ask for "version: 11" here while package.json pins pnpm to an
# exact version with a hash; reading that field is what makes the two
# jobs, Pages and a developer's machine install the same pnpm.
- name: Read the pinned pnpm version
id: pnpm
run: echo "spec=$(jq -r .packageManager site/package.json)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: pnpm-cache
with:
path: ~/setup-pnpm
key: pnpm-${{ runner.os }}-${{ steps.pnpm.outputs.spec }}
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
if: steps.pnpm-cache.outputs.cache-hit != 'true'
with:
package_json_file: site/package.json
- name: Put the cached pnpm on PATH
if: steps.pnpm-cache.outputs.cache-hit == 'true'
run: echo "$HOME/setup-pnpm/node_modules/.bin" >> "$GITHUB_PATH"
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: site/.node-version
cache: pnpm
cache-dependency-path: site/pnpm-lock.yaml
# The build-independent half of `pnpm run lint`: type check, palette
# contrast, chip parity, i18n parity, llms index coverage, eslint,
# prettier. The dist-dependent gates (html-validate, htmlhint, pa11y)
# stay in the Pages workflow, which is the one that builds the site.
- name: Install site dependencies
run: pnpm install --frozen-lockfile
working-directory: site
- name: Static site gates
run: |
pnpm run check
pnpm run contrast:check
pnpm run chips:check
pnpm run facts:check
pnpm run i18n:check
# The site's own /llms.txt is generated from the content collection
# against a section table that has to be edited by hand. A page added
# to the collection and not listed there would simply be missing from
# the index, which nothing else would notice.
pnpm run llms:check
# The Person entity is fetched from its canonical source at build
# time, with this snapshot as the offline fallback. A stale snapshot
# is invisible while the fetch works and ships a Person missing
# several properties the moment it does not, announced by nothing
# louder than a console.warn. The guard existed and nothing ran it.
pnpm run identity:check
pnpm run eslint
pnpm run format:check
working-directory: site
hadolint:
name: "📐 hadolint"
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0
with:
dockerfile: Dockerfile
failure-threshold: warning
ignore: DL3008,DL3018
server-json-schema:
name: "📄 server.json schema"
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.x"
- name: Install jsonschema
run: pip install --quiet jsonschema
- name: Validate server.json against MCP Registry schema (2025-12-11)
run: |
set -euo pipefail
SCHEMA_URL=$(jq -r '."$schema"' server.json)
echo "Schema referenced by server.json: $SCHEMA_URL"
# SSRF guard: only allow the official MCP Registry schema host.
# Prevents an attacker from changing $schema in a PR to probe
# arbitrary URLs from the runner.
ALLOWED_PREFIX="https://static.modelcontextprotocol.io/"
case "$SCHEMA_URL" in
"$ALLOWED_PREFIX"*) ;;
*)
echo "::error::Schema URL must start with $ALLOWED_PREFIX (got: $SCHEMA_URL)"
exit 1
;;
esac
curl -fsSL --proto '=https' --tlsv1.2 "$SCHEMA_URL" -o /tmp/mcp-schema.json
python3 -c "import json,jsonschema; \
schema=json.load(open('/tmp/mcp-schema.json')); \
inst=json.load(open('server.json')); \
jsonschema.validate(inst, schema); \
print('server.json: VALID against', schema.get('\$id'))"
# Both published platforms, each one built AND started. This job used to
# build only the runner's native architecture and never run it, so the
# linux/arm64 image that could not exec at all — the cross-compiled PIE
# binary asked for the glibc loader the Alpine runtime does not ship —
# passed CI and every release gate on its way to the registry.
docker:
name: "🐳 Docker (${{ matrix.platform }})"
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
platform: [linux/amd64, linux/arm64]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- name: Check for Docker changes
id: filter
run: |
CHANGED=$(git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.sha }} -- Dockerfile docker-compose*.yml .dockerignore)
if [ -n "$CHANGED" ]; then
echo "docker=true" >> "$GITHUB_OUTPUT"
else
echo "docker=false" >> "$GITHUB_OUTPUT"
echo "No Docker-related files changed — skipping build"
fi
- name: Compute build date
if: steps.filter.outputs.docker == 'true'
id: build-date
run: echo "date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
# QEMU so the non-native variant can be executed here, not only built.
- name: Set up QEMU
if: steps.filter.outputs.docker == 'true'
uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4
- name: Set up Docker Buildx
if: steps.filter.outputs.docker == 'true'
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4
- name: Build Docker image
if: steps.filter.outputs.docker == 'true'
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
push: false
load: true
platforms: ${{ matrix.platform }}
tags: gitlab-mcp-server:ci
build-args: |
VERSION=ci
COMMIT=${{ github.sha }}
BUILD_DATE=${{ steps.build-date.outputs.date }}
- name: Smoke test the image
if: steps.filter.outputs.docker == 'true'
run: bash scripts/smoke-test-image.sh ci "gitlab-mcp-server:ci=${{ matrix.platform }}"
# One node for the matrix above. It began life because branch protection
# required a check named exactly "Docker Build" and a matrix job reports
# "Docker Build (linux/amd64)" instead, which left the required check
# permanently "expected" and every pull request unmergeable. The single
# required check is "CI" now, so that reason is gone; this stays because the
# final job wants one answer for "did the images build" rather than one per
# platform, and because the graph reads better with the fan-in drawn.
docker-build:
name: "🐳 Docker images"
needs: docker
# always(), so a failing matrix is reported rather than skipped along with
# it. The event guard matches the matrix's own, so pushes to main see this
# check no more than they see the job it summarizes.
if: always() && github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Report the platform builds
env:
RESULT: ${{ needs.docker.result }}
run: |
echo "Docker matrix result: $RESULT"
# skipped is a pass: the matrix skips its own steps when no Docker
# file changed, and a skipped job must not hold a pull request.
case "$RESULT" in
success|skipped) ;;
*) echo "::error::the Docker platform builds did not succeed ($RESULT)"; exit 1 ;;
esac
# Configuration invariants nothing else in this pipeline can see: pinned
# actions, release jobs that run no code resolved at run time, stated
# Dependabot cooldowns, a security policy that names the shipping major, and
# installers that verify the release signature.
supply-chain:
name: "🔗 Supply chain"
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
# Still needed by the installer and packaging tests below, which are
# Python because what they test is. The auditor no longer is, so nothing
# here installs a package at run time any more.
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
with:
python-version: "3.x"
- run: go mod download
- name: Audit the supply chain configuration
run: make check-supply-chain
- name: Test the auditor itself
run: go test ./cmd/audit_supply_chain/ -count=1
- name: Test the installer and packaging scripts
run: python3 -m unittest discover -s scripts -p "*_test.py" -v
# One node for the jobs that depend on nothing. They are fanned in here
# rather than straight into "ci" for the graph's sake, explained at the top
# of the file: this node lands below the matrix summaries, because its
# parents are drawn last, and every edge stays between neighbouring columns.
#
# The site dependency audit reaches here and is printed, but does not
# decide: it reports the state of somebody else's advisory database.
checks:
name: "🧰 Checks"
runs-on: ubuntu-latest
timeout-minutes: 5
if: always()
# Everything that can fail a change is listed, the test jobs included: the
# verdict used to take only the analysis jobs, so a failing unit suite, a
# compile error on another platform, or a red Windows leg could sit under
# a green required check. The cross-platform summary already tells the
# known Go runtime crash on AMX hosts apart from a failing test (issue
# 467), so listing it fails the verdict on real failures only.
needs:
- generated
- golangci-lint
- govulncheck
- analyze-md
- site-lint
- hadolint
- server-json-schema
- supply-chain
- docs-audit
- coverage
- compile
- typecheck
- cross_platform_summary
- docker-build
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Decide
env:
RESULTS: ${{ toJSON(needs) }}
INFORMATIONAL: docs-audit
# The image build only runs for a pull request; on a push it is
# skipped by design rather than failed.
MAY_SKIP: docker-build
run: bash .github/scripts/needs-verdict.sh
# Every job above runs on Linux, and until this one existed that was the only
# platform any test in this repository had ever seen, while the project ships
# binaries for three. cmd/server/socket_mode_windows.go was first compiled by
# GoReleaser during a release, where a compile error breaks the release
# instead of a pull request.
#
# This job asks only the platform question, and deliberately runs none of the
# generated-artifact gates or the coverage floor: their answers cannot depend
# on the operating system, so running them three times would triple the flake
# surface and add no signal. What does depend on the platform is here, namely
# the build constraints, every path the code touches, and the two transport
# modules that spawn the real binary.
#
# Linux is in the matrix even though Test already runs the suite there,
# because it is the control: without it, a macOS-only failure cannot be told
# apart from a command that would have failed anywhere.
cross_platform:
name: "🖥️ Cross-platform (${{ matrix.os }})"
runs-on: ${{ matrix.os }}
# A hung test must not sit for the six-hour default on a runner billing ten
# times the Linux rate (macOS) or twice it (Windows). The Linux unit suite
# takes about twenty minutes, so this allows being substantially slower
# without allowing a hang.
timeout-minutes: 60
strategy:
# Every platform reports. Stopping at the first failure would hide a
# second, different one, and telling them apart is the entire point.
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
- run: go mod download
# Build constraints resolve against the host, so these two steps are the
# first thing in the pipeline that compiles the windows-only source at
# all, and the first that type-checks the unix-only source anywhere other
# than Linux.
- name: Build every package
run: go build ./...
- name: Vet every package
run: go vet ./...
# The whole tree, tooling included. Everything under cmd/ other than
# the server is maintainer tooling that runs on Linux from a Makefile
# target, and its tests used to assume "/" and Linux error text, so the
# leg ran the shipped code alone. The fifteen tests that failed on a
# real Windows were made portable (issue 439): reported paths are
# spelled with slashes on every platform, not-found is asserted with
# errors.Is rather than by its text, and the one case a platform cannot
# produce is skipped there and says why. Running them here is what keeps
# that true.
#
# The bound is raised from the 10 minute default because cmd/server
# exceeded it on Windows, where the suite runs roughly three times slower
# than on Linux.
#
# Through the crash-aware runner rather than a bare go test: the Windows
# leg dies inside the Go runtime now and then, for a reason recorded in
# the script and in issue 467, and the runner tells that crash apart
# from a failing test, reruns only the crashed package, and puts the
# outcome in the job summary. A failing test fails the leg as before.
# Flags carry their value in the same word, which is how the runner
# tells them from packages.
- name: Install gotestsum
run: go install gotest.tools/gotestsum
- name: Unit suite
shell: bash
# The same freshness answer the coverage job passes. This job asks the
# platform question and none of the generated-artifact ones, but the
# suite it runs carries a handful of tests that compare a committed
# artifact with what the tree generates now, and with the variable
# unset they compared on all three platforms even on a layer the
# policy exempts, three times over (issue 644).
env:
GITLAB_MCP_TEST_SNAPSHOT_PARITY: ${{ env.FRESHNESS }}
run: bash .github/scripts/go-test-crash-aware.sh test-results/unit-suite -count=1 -timeout=30m ./cmd/... ./internal/...
# The two transport modules need neither GitLab nor a credential, and
# they are why this is not a build-only check: they start the real binary
# and drive it the way a client does, over pipes and sockets, which is
# where a platform difference actually surfaces.
#
# macOS and Windows, not Linux. Linux has them as jobs of their own,
# e2e-http and e2e-stdio, on the same runner image with the same
# commands, so running them here as well was six minutes of runner time
# answering a question already answered in the same run. The unit suite
# above does stay on Linux, as the control that tells a failure of one
# platform from one that would fail anywhere.
#
# The harnesses terminate the server the way each platform does: SIGTERM
# where it exists, and on Windows a CTRL_BREAK event to the server's own
# console process group, which the Go runtime delivers as the
# os.Interrupt the server stops on (terminate_*_test.go in each module).
# The unix-socket cases assert only what Windows' AF_UNIX support shares
# with the others.
- name: HTTP transport end-to-end
if: matrix.os != 'ubuntu-latest'
run: go test -tags httpe2e -count=1 -timeout 900s ./test/e2e/http/
- name: stdio transport end-to-end
if: matrix.os != 'ubuntu-latest'
run: go test -tags stdioe2e -count=1 -timeout 900s ./test/e2e/stdio/
# One node for the matrix above, for the same reason the Docker images job
# exists: the final job wants one answer for the platform question, and the
# graph draws the fan-in.
cross_platform_summary:
name: "🖥️ Cross-platform"
needs: cross_platform
# always(), so a failing platform is reported rather than skipped along
# with the job it belongs to.
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Report the platform suites
env:
# Underscored job ids on purpose: a hyphen in a job id has to be
# reached as needs['cross-platform'], because the expression parser
# reads needs.cross-platform as a subtraction.
RESULT: ${{ needs.cross_platform.result }}
run: |
echo "Cross-platform matrix result: $RESULT"
case "$RESULT" in
success) ;;
*) echo "::error::the cross-platform suites did not succeed ($RESULT)"; exit 1 ;;
esac
# The one check branch protection requires. Everything converges here, so
# the job list above can change shape without a repository setting having to
# follow it, and the graph has an end.
#
# It needs only second-column nodes, for the graph's sake (see the top of
# the file), and loses nothing by it: compile and coverage are decided
# through their children, which are skipped when the parent fails, and the
# standalone jobs through "checks". One result is read but does not decide,
# for a reason recorded where the job is defined: the platform matrix has a
# Windows leg that crashes inside the Go runtime now and then, tracked in
# https://github.com/jmrplens/gitlab-mcp-server/issues/467. Its unit suite
# runs through .github/scripts/go-test-crash-aware.sh, which reruns a
# crashed package alone and records the crash in the job summary, so a red
# leg now means a failing test or a package that crashed twice. It is
# printed so a red one is seen; it becomes a deciding result once a run of
# weeks shows it red for those reasons only.
#
# "skipped" passes only for the Docker summary, which skips itself on pushes
# to main along with the matrix it summarizes. For every other job a skip
# means an upstream failure took it down, which is a failure.
ci:
name: "✅ CI"
runs-on: ubuntu-latest
timeout-minutes: 5
if: always()
needs:
- typecheck
- e2e-http
- e2e-stdio
- sonarcloud
- docker-build
- cross_platform_summary
- checks
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Decide
env:
RESULTS: ${{ toJSON(needs) }}
INFORMATIONAL: cross_platform_summary
MAY_SKIP: docker-build
run: bash .github/scripts/needs-verdict.sh