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
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ gitlab-mcp-server/
│ ├── audit_install_buttons/ # One-click install payloads decode to one configuration per command
│ ├── audit_binary_vulns/ # Every release binary against the vulnerability database at module grain (make check-binary-vulns)
│ ├── audit_image_vulns/ # The image's Alpine packages against Trivy's vulnerability database before the push (make check-image-vulns)
│ ├── reproduce_release/ # Release binaries rebuilt from the tag and compared byte for byte (make check-reproducible, make check-repeatable)
│ ├── audit_surface_quality/ # MCP surface metadata + output quality (-view; was audit_tools + audit_output)
│ ├── audit_supply_chain/ # Release-configuration invariants (SHA-pinned uses:, Dependabot cooldowns, SECURITY.md, installers)
│ ├── audit_tokens/ # Token overhead; -footprint, --compare-schemas (was audit_meta_schema); cl100k_base tokenizer (tokens.go)
Expand Down
1 change: 1 addition & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ security:
- .github/dependabot.yml
- cmd/audit_supply_chain/**
- cmd/audit_image_vulns/**
- cmd/reproduce_release/**
- cmd/server/authgate*.go
- cmd/server/bearerguard*.go
- internal/oauth/**
Expand Down
69 changes: 68 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#
# Triggers: tag push matching v*, and a manual dispatch that REHEARSES.
# Pipeline: Preflight → E2E (e2e.yml) + Race (race.yml) → Docker → GoReleaser →
# per-publisher jobs → Winget
# per-publisher jobs → Winget, with Reproduce beside the publishers
# If either gate fails, nothing is released.
#
# Rehearsal. Every publisher job below was added after 2.7.5 shipped, so none
Expand Down Expand Up @@ -1410,6 +1410,73 @@ jobs:
NEWEST: ${{ needs.preflight.outputs.newest }}
run: gh release edit "v${RELEASE_TAG#v}" --draft=false "--latest=${NEWEST}"

# Every binary the release published, rebuilt from the commit it was built
# from and compared with the published one byte for byte
# (cmd/reproduce_release, through make check-reproducible). The signature
# over checksums.txt says this workflow published those bytes; a rebuild
# that comes out identical says they are what the tagged source compiles
# to, and it is a check anybody can repeat with the same command, which the
# site's binary page describes. It runs on the other architecture, so
# nothing the release job's host contributed can pass for the source.
#
# It reads what the release job left public, fetched and verified the way
# the publishers fetch it: on a tag push the published release, in a
# rehearsal the rehearsal's own build. It runs beside the publishers rather
# than in front of them, because by the time it can read the release the
# release is public and immutable, and stopping npm, PyPI and NuGet halfway
# would unpublish nothing and leave the channels disagreeing; a failure here
# is a release that does not rebuild, which is an issue to open. It holds no
# credential beyond reading the release, and setup-go caches nothing, as in
# the release job, since a rebuild from a cache another run wrote would
# compare that cache rather than the source.
reproduce:
name: "♻️ Reproduce the binaries"
runs-on: ubuntu-24.04-arm
timeout-minutes: 30
needs: [preflight, release]
env:
RELEASE_TAG: ${{ needs.preflight.outputs.release_tag }}
permissions:
contents: read # download the published release assets
steps:
# The whole history and every tag: the go command stamps a tagged
# commit's version from its tag, and an untagged one's pseudo-version
# from the tag before it, and the release job built with both there.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
persist-credentials: false

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: false

- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: "v3.1.3"

- name: Take the rehearsal's own build
if: env.REHEARSAL == 'true'
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8
with:
name: rehearsal-assets
path: ${{ runner.temp }}/rehearsal

- name: Fetch and verify the published binaries
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REHEARSAL_ARCHIVE: ${{ env.REHEARSAL == 'true' && format('{0}/rehearsal/rehearsal-assets.tar', runner.temp) || '' }}
run: bash scripts/fetch-release-assets.sh "${RELEASE_TAG#v}" "${RUNNER_TEMP}/published"

# HEAD is the tagged commit on a tag push and the dispatched one in a
# rehearsal: the commit the release job built either way. Each binary is
# rebuilt with the toolchain its own build information records, and the
# universal darwin binary is joined from the rebuilt darwin ones in the
# order the published one lists them.
- name: Rebuild each binary and compare it with the published one
run: make check-reproducible TAG="${RELEASE_TAG}" REF=HEAD PUBLISHED="${RUNNER_TEMP}/published"

npm:
name: "📮 Publish to npm"
runs-on: ubuntu-latest
Expand Down
15 changes: 13 additions & 2 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,20 @@ builds:
goarch:
- amd64
- arm64
# The full commit rather than {{.ShortCommit}}: git abbreviates a commit to
# as many characters as the clone's object count needs, so a short commit
# made the bytes depend on the clone that built them (3.3.0 was stamped
# f8813193, which a full clone of the repository abbreviates to nine
# characters today), and a rebuild from any other clone came out
# different. The image stamps the full commit too (release.yml passes
# github.sha). cmd/reproduce_release rebuilds every binary below from the
# tag and compares it with the published one (make check-reproducible, and
# the reproduce job of release.yml).
ldflags:
- &ldflags -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}}
# Reproducible builds: stamp binary mtime from commit timestamp.
- &ldflags -s -w -X main.version={{.Version}} -X main.commit={{.FullCommit}}
# Stamps the file's modification time from the commit, so the binary's
# time on disk is the same in every build too. It does not change the
# bytes, which are what checksums.txt lists.
mod_timestamp: "{{ .CommitTimestamp }}"
# -buildmode=pie makes the Linux binaries dynamically linked, and the
# linker picks their ELF interpreter (PT_INTERP) by stat-ing the build
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
| MCP Icons | 51 icons (50 domain + brand mark), each a 3-entry `[]mcp.Icon`: one SVG (base64 data URI, `Sizes: ["any"]`, `currentColor`) plus light/dark 16×16 lossless WebP fallbacks (`Theme`-tagged, `cmd/gen_icon_webp`) for clients that reject SVG. The brand mark is the generated "fan-out" (`cmd/gen_brand` → `brandmark_gen.go`), original artwork replacing the former tanuki |
| Source files (tools) | 788 non-test Go files under `internal/tools/` |
| Test files (tools) | 488 test files under `internal/tools/` |
| Go packages | 305 in the module (`go list ./...`); 332 directories hold tracked `.go` files, the twenty-seven more being what `go list` leaves out: twenty-six `testdata` fixture packages (the two stand-in programs, the coverage audit's planted trees, the closed-server audit's planted module, the stand-in module the SDK route reader's tests read and the platform module the condition measurement's discovery lists) and `test/e2e/modeleval/internal/provider`, whose every file sits behind a build tag. 182 under `internal/tools/...` (the root package plus 181 sub-packages) |
| Go packages | 306 in the module (`go list ./...`); 333 directories hold tracked `.go` files, the twenty-seven more being what `go list` leaves out: twenty-six `testdata` fixture packages (the two stand-in programs, the coverage audit's planted trees, the closed-server audit's planted module, the stand-in module the SDK route reader's tests read and the platform module the condition measurement's discovery lists) and `test/e2e/modeleval/internal/provider`, whose every file sits behind a build tag. 182 under `internal/tools/...` (the root package plus 181 sub-packages) |

### Orbit live tests

Expand Down Expand Up @@ -83,6 +83,7 @@ gitlab-mcp-server/
│ ├── audit_install_buttons/ # One-click install payloads (make check-install-buttons)
│ ├── audit_binary_vulns/ # Release binaries against the Go vulnerability database (make check-binary-vulns)
│ ├── audit_image_vulns/ # The image's Alpine packages against Trivy's database, before the push (make check-image-vulns)
│ ├── reproduce_release/ # Release binaries rebuilt from the tag and compared byte for byte (make check-reproducible, check-repeatable)
│ ├── godoc_tool/ # Go doc auditor and fixer (make check-godocs)
│ ├── audit_md_escaping/ # GitLab-authored values reaching Markdown unescaped (make check-md-escaping)
│ ├── audit_metrics/ # Tool, resource and prompt metrics
Expand Down
26 changes: 25 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
coverage \
modeleval-ce modeleval-ee modeleval-probe \
lint fmt clean version release release-check checksum \
golangci-lint govulncheck check-binary-vulns image-vulns-images check-image-vulns sonar sonar-status \
golangci-lint govulncheck check-binary-vulns image-vulns-images check-image-vulns check-repeatable check-reproducible sonar sonar-status \
mdlint mdlint-fix audit-docs check-doc-links check-md-tables \
analyze analyze-fix analyze-report install-tools \
audit-output audit-tokens audit-tools audit-surface-quality check-surface-quality check-spec-conditions audit-metrics audit-dynamic-aliases audit-test-names audit-godocs audit-godocs-check check-godocs fix-godocs \
Expand Down Expand Up @@ -959,6 +959,30 @@ govulncheck:
check-binary-vulns:
go run ./cmd/audit_binary_vulns/

## check-repeatable: build every release target .goreleaser.yml names twice from this tree, the second time with an empty build cache, and fail unless the two builds are the same bytes.
## Needs no release and no git, and no network once the module cache holds the modules, so it runs anywhere the tree does, a copy synced without .git included.
check-repeatable:
go run ./cmd/reproduce_release/ -twice

# Where check-reproducible downloads a release's assets when PUBLISHED names
# none, one directory per tag.
REPRODUCE_DIR=dist/reproduce

## check-reproducible: rebuild the binaries of release TAG from the commit it tags (REF overrides) and compare each with the binary the release published, byte for byte.
## Fetches the release's assets and verifies them against its cosign-signed checksums.txt first (scripts/fetch-release-assets.sh, which needs gh and cosign),
## unless PUBLISHED names a directory that already holds them, which is how the reproduce job of release.yml runs it.
## Every release up to 3.3.0 was stamped with the abbreviated commit, which another clone may abbreviate to another length, so it also needs SHORT_COMMIT, the commit `gitlab-mcp-server --version` prints for it.
## A release before 3.2.0 is not covered: it publishes no THIRD_PARTY_NOTICES, which the fetch requires, and names no ELF loader, so its Linux binaries carry whichever one its build host offered.
## make check-reproducible TAG=v3.3.0 SHORT_COMMIT=f8813193
check-reproducible:
@test -n "$(TAG)" || { echo "usage: make check-reproducible TAG=vX.Y.Z [REF=<commit>] [PUBLISHED=<dir>] [SHORT_COMMIT=<hash>]" >&2; exit 2; }
@set -eu; published="$(PUBLISHED)"; \
if [ -z "$$published" ]; then \
published="$(REPRODUCE_DIR)/$(TAG)"; rm -rf "$$published"; \
bash scripts/fetch-release-assets.sh "$(patsubst v%,%,$(TAG))" "$$published"; \
fi; \
go run ./cmd/reproduce_release/ -published "$$published" -tag "$(TAG)" -ref "$(or $(REF),$(TAG))" $(if $(SHORT_COMMIT),-short-commit "$(SHORT_COMMIT)")

# Trivy's own image, pinned by digest, at the release the release workflow
# fetches as a tarball (TRIVY_VERSION and TRIVY_SHA256 in release.yml), so a
# verdict here is reached by the scanner a release reaches its verdict with;
Expand Down
42 changes: 42 additions & 0 deletions cmd/reproduce_release/build.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package main

import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"

"github.com/jmrplens/gitlab-mcp-server/v3/cmd/internal/golist"
)

// goBuild builds one recipe from the module at src into root, at the path
// GoReleaser writes it to, and returns the binary. extra is added to the
// environment last, so what the run decides (the toolchain, the build cache)
// wins over anything before it. root has to be absolute, since the go command
// runs in src.
func goBuild(ctx context.Context, src string, rc recipe, root string, extra ...string) ([]byte, error) {
out := filepath.Join(root, rc.dir, rc.file)
if err := os.MkdirAll(filepath.Dir(out), 0o750); err != nil {
return nil, err
}
// #nosec G204 G702 -- the program is the Go toolchain joined out of GOROOT, and the arguments come from the release configuration of the tree being rebuilt
cmd := exec.CommandContext(ctx, golist.Executable(), rc.arguments(out)...)
cmd.Dir = src
cmd.Env = buildEnv(os.Environ(), rc, extra)
if output, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("building %s for %s: %w\n%s", rc.asset, rc.target, err, output)
}
return os.ReadFile(out) //#nosec G304 -- the binary the go command just wrote under this run's own output directory
}

// buildEnv is the environment a recipe is built in, in GoReleaser's order
// (the process's own, then the build's env, then the target's), so a value
// the build or the target sets wins over one the caller's shell exported.
// GOFLAGS is cleared before the build's env, since the release job sets none
// and every flag a release passes is on its command line, and extra comes
// last.
func buildEnv(base []string, rc recipe, extra []string) []string {
return slices.Concat(base, []string{"GOFLAGS="}, rc.env, rc.target.env(), extra)
}
Loading
Loading