CI/CD Guide¶
One-stop reference for the four workflow files that govern every code change, release, and docs update in this repo. Read this when you're debugging a failed run, adding a new job, or preparing a release.
πΊοΈ At a glance¶
Every push / PR v* tag push
β β
βΌ βΌ
βββββββββββββββββββββ ββββββββββββββββββββββββ
β βοΈ ci.yml β β π release.yml β
β (merge gate) β β (publish pipeline) β
βββββββββββ¬ββββββββββ ββββββββββββ¬ββββββββββββ
β β
change detector (code / packaging flags)
ββββββββββββββββββββ¬βββββββββββββββββββββββ¬βββββββββββββββββββββββββ
β packaging-only β docs/human (no code) β code change β
β ββββββββββββββ β ββββββββββββββββ β ββββββββββββββββββ β
β π¦ validate-pkg β π docs-drift β π§Ή lint β
β β π python-drift β π§ͺ test (race+coverage)β
β β π§ͺ test-python β π¬ e2e (STDIO+HTTP+OAuthβ
β β π¦ validate-pkg β π³ docker-smoke β
β β β ποΈ build (5 platforms) β
β β β π‘οΈ security (vuln+gosecβ
ββββββββββββββββββββ΄βββββββββββββββββββββββ΄βββββββββββββββββββββββββ
ποΈ GoReleaser Β· Build & Publish
β
ββββββββββββββββΌβββββββββββββββββββββ
βΌ βΌ βΌ
π³ docker-sign π mcp-registry π¨ smithery
π pypi π¦ packaging (all parallel)
There is also a fourth file, codeql.yml, that runs GitHub's CodeQL deep static analysis separately on push-to-main and a weekly schedule.
π Workflow files¶
| File | Trigger | Purpose |
|---|---|---|
.github/workflows/ci.yml |
push to main, any PR, workflow_dispatch |
Gate: prevents broken code from merging |
.github/workflows/release.yml |
push of a v* tag, workflow_dispatch (manual re-run) |
Publish: builds, signs, and ships a release |
.github/workflows/docs.yml |
push to main (docs paths only) |
Deploy: builds the mkdocs site to GitHub Pages |
.github/workflows/codeql.yml |
push to main, weekly |
Security: deep static analysis via GitHub CodeQL |
βοΈ ci.yml β The merge gate¶
π‘ Triggers¶
- Every pull request targeting
main - Every push to
main - Manual:
Actions β βοΈ CI β Run workflow(addrun_python_live=trueto run live SDK tests)
π Change detector (changes job)¶
The first job classifies every PR. It reads the list of changed files and outputs two flags:
code=trueβ at least one file outside the harmless/packaging allowlist changed β full CI runspackaging=trueβ every changed file is underpackaging/**β packaging-only mode (onlyvalidate-packagingruns)
Harmless files (skip heavy CI, not packaging-only):
- *.md, docs/**, decks/**, assets/**
- LICENSE, .gitignore, mkdocs.yml, overrides/**
- .github/**_TEMPLATE*, .github/ISSUE_TEMPLATE/**
Anything else outside packaging/** β code=true β full CI runs.
Why? Branch protection marks skipped required checks as passing. A docs-only PR goes green in seconds without getting stuck on checks that legitimately have nothing to test. A machine-generated packaging PR only runs
validate-packagingβ the one check that actually matters.
π Always-run jobs (every PR except packaging-only)¶
These run on every PR β except packaging-only ones, which can't cause the drift they detect:
| Job | What it catches | Skipped on packaging-only? |
|---|---|---|
| π§Ή lint | gofmt -s + golangci-lint β formatting and static analysis |
No (gated on code=true) |
| π docs-drift | docs/TOOLS.md β registry drift; tool annotation coverage |
Yes |
| π python-drift | Python client (models.py/client.py) not regenerated after Go schema change |
Yes |
| π§ͺ test-python | Python SDK unit + integration tests (mock HTTP, no binary needed) | Yes |
| π¦ validate-packaging | PKGBUILD / .SRCINFO / flake.nix version + hash consistency |
No β this is the only job that runs on packaging-only PRs |
Rule: If you change a Go tool schema, run
make gen-python-clientbefore pushing. If you changedocs/TOOLS.md, the matching tool definition must change in the same commit (or vice versa). These jobs enforce both.
β‘ Fast-fail ordering in the code path¶
π changes ββΊ π§Ή lint ββΊ π§ͺ test ββΊ π¬ e2e
ββββββββββββββββΊ π³ docker-smoke
ββββββββΊ π‘οΈ security
π changes ββΊ ποΈ build (parallel, independent of test)
lintgates bothtestandsecurityβ a formatting error surfaces before expensive compute starts.e2eanddocker-smokewait fortestβ a unit test failure blocks the heavier suites.buildruns in parallel with everything else β cross-compilation failures surface alongside test feedback without waiting.
π§ͺ Code-only jobs (skipped on docs/packaging PRs)¶
| Job | What it runs |
|---|---|
| π§ͺ test | go test -race β unit + integration tests with race detector |
| π¬ e2e | Security + lifecycle E2E suite (STDIO, HTTP, OAuth) β network-free |
| π³ docker-smoke | Builds the Docker image and drives MCP over HTTP end-to-end |
| ποΈ build | Cross-compile for Linux/Darwin/Windows Γ amd64/arm64 |
| π‘οΈ security | govulncheck + gosec β vulnerability and security scanning |
π Manual-dispatch only¶
| Job | How to trigger |
|---|---|
| π python-live-e2e | Actions β βοΈ CI β Run workflow with run_python_live=true |
Hits real external APIs. Not part of the required gate β too flaky on rate limits.
π release.yml β The publish pipeline¶
π‘ Trigger¶
Any tag matching v* pushed to the repo.
git tag v1.38.0
git push origin v1.38.0
π Required secrets and variables¶
| Name | Kind | Purpose |
|---|---|---|
GITHUB_TOKEN |
Auto | Release assets, Docker GHCR, PR creation |
DOCKERHUB_USERNAME / DOCKERHUB_TOKEN |
Secret | Docker Hub push |
HOMEBREW_TAP_GITHUB_TOKEN |
Secret | Homebrew tap PR |
SCOOP_BUCKET_GITHUB_TOKEN |
Secret | Scoop bucket PR |
WINGET_PKGS_GITHUB_TOKEN |
Secret | WinGet PR |
CHOCOLATEY_API_KEY |
Secret | Chocolatey push (optional β degrades gracefully) |
MACOS_SIGN_P12 / MACOS_SIGN_PASSWORD |
Secret | macOS Developer ID signing |
MACOS_NOTARY_ISSUER_ID / MACOS_NOTARY_KEY_ID / MACOS_NOTARY_KEY |
Secret | Notarization |
AZURE_TENANT_ID / AZURE_CLIENT_ID / AZURE_CLIENT_SECRET |
Secret | Windows Authenticode (jsign) |
SMITHERY_API_KEY |
Secret | Smithery publish |
AZURE_SIGNING_ENABLED |
Var | "true" to enable Windows signing |
SMITHERY_ENABLED |
Var | "true" to enable Smithery job |
PYPI_PUBLISH_ENABLED |
Var | "true" to enable PyPI wheels |
AUR_SSH_KEY |
Secret | SSH private key for AUR push (optional β skips gracefully if absent) |
PACKAGING_UPDATE_ENABLED |
Var | "true" to enable AUR/Nix manifest generation + upload |
NIXPKGS_FORK_GITHUB_TOKEN |
Secret | PAT (repo scope) used to push branch to the NixOS/nixpkgs fork and open the PR |
NIXPKGS_ENABLED |
Var | "true" to enable the nixpkgs submission job |
π¦ Job dependency graph¶
[ποΈ GoReleaser Β· Build & Publish]
β
ββββΊ [π³ Sign Docker Images]
β
ββββΊ [π Publish β MCP Registry] (independent of docker-sign)
β
ββββΊ [π¨ Publish β Smithery] (if SMITHERY_ENABLED)
β
ββββΊ [π Publish β PyPI] (if PYPI_PUBLISH_ENABLED)
β
ββββΊ [π¦ Update Packaging Manifests] (if PACKAGING_UPDATE_ENABLED)
β ββ attaches PKGBUILD/.SRCINFO/flake.nix/package.nix to release
β ββ pushes to AUR via SSH (if AUR_SSH_KEY set)
β
ββββΊ [π§ Submit β nixpkgs proper] (if NIXPKGS_ENABLED)
All downstream jobs run in parallel after the core release job completes. A failure in any one job does not block the others.
β‘ Fast-fail ordering inside the core release job¶
Steps run in cheapest-first order so an early misconfiguration surfaces before slow Docker builds start:
0. Resolve tag ref (override for manual dispatch) β resolves RELEASE_TAG for workflow_dispatch runs
1. Set up Go
2. Set up Python + verify Python client drift β fast fail
3. Verify macOS signing chain β fast fail
4. Set up Chocolatey CLI (only if key set) β fast fail
5. Log in to Docker Hub + GHCR
6. Set up Docker Buildx + QEMU
7. Install cosign + Syft
7a. Clear existing release assets (idempotent re-run) β deletes existing assets so GoReleaser can re-upload on workflow_dispatch retrigger
8. Run GoReleaser β the slow step (cross-compile, sign, push)
9. Build + upload .mcpb bundles
10. Generate + attach SBOM
11. Sign checksums.txt with cosign (keyless OIDC)
12. Upload dist binaries artifact (for PyPI)
π¬ Core release steps in detail¶
| Step | What it does |
|---|---|
| Verify Python client drift | Fails fast if make gen-python-client was not run before tagging |
| Verify macOS signing chain | Checks p12 contains leaf + intermediate + Apple Root CA (prevents AMFI SIGKILL on launch) |
| Set up Chocolatey CLI | Installs choco under mono; push subcommand is non-fatal (Chocolatey moderation can 403) |
| Run GoReleaser | Cross-compiles 5 platforms; signs macOS (quill+notarize); signs Windows (jsign/Azure); pushes Docker multi-arch; updates Homebrew/Scoop/WinGet; publishes GitHub Release |
| Build .mcpb bundles | Assembles MCP bundle archives for Smithery + MCPB registries |
| Generate SBOM | Syft produces a full SPDX JSON; attached to the release |
| Sign checksums.txt | Cosign keyless OIDC signature; .sig + .pem attached to the release |
| Upload dist binaries | Artifact for the PyPI job to wrap into wheels without re-running GoReleaser |
π³ [docker-sign] β Sign Docker images¶
Fetches the image digest from GHCR and signs it with cosign using GitHub's OIDC identity. Creates a publicly verifiable Sigstore signature.
π [publish-mcp-registry] β MCP Registry¶
Depends on release directly (not docker-sign) β the registry only needs the GitHub Release to exist, not the Docker signature. Re-syncs the version string and publishes via mcp-publisher + GitHub OIDC authentication.
π¨ [publish-smithery] β Smithery¶
Downloads the .mcpb bundle that was already built and uploaded by the release job (no re-build from source). Publishes to Smithery. Gated on vars.SMITHERY_ENABLED == 'true'.
π [publish-pypi] β PyPI platform wheels¶
Downloads the cross-compiled binaries from the release job artifact, wraps them into platform wheels, smoke-tests the manylinux wheel (import check), then publishes via Trusted Publishing (OIDC β no token secret). Gated on vars.PYPI_PUBLISH_ENABLED == 'true'.
π¦ [update-packaging] β AUR + Nix publishing¶
Fully automated β no manual steps needed.
1. Run scripts/update-packaging.sh <VERSION>
ββ Fetches checksums.txt from the new release
ββ Generates packaging/aur/PKGBUILD (version + hex SHA256)
ββ Generates packaging/aur/.SRCINFO (version + hex SHA256)
ββ Generates packaging/nix/flake.nix (version + SRI hashes, 4 platforms)
ββ Updates packaging/nixpkgs/package.nix (version string only β hashes via nix)
2. Attach PKGBUILD, .SRCINFO, flake.nix, package.nix to the GitHub Release as assets
3. Push PKGBUILD + .SRCINFO to AUR via SSH (gated on AUR_SSH_KEY secret)
Gated on vars.PACKAGING_UPDATE_ENABLED == 'true'. The AUR push step is additionally gated on the AUR_SSH_KEY secret β absent key means the step skips cleanly, manifests are still attached to the release.
No PR to this repo. Checksums only exist after GoReleaser builds the binaries, so the manifest generation must happen post-release. The generated files are published directly β as release artifacts and to AUR β without needing to land in
main. Nix flake users pin to a release tag (github:zoharbabin/web-researcher-mcp/vX.Y.Z) and get the correctflake.nixfrom that tag's checkout automatically.
π§ [submit-nixpkgs-pr] β nixpkgs proper¶
Initial submission is automated; future bumps are handled by the nixpkgs-update bot.
This is what gets nix profile install nixpkgs#web-researcher-mcp working for users β no flake, no pinning to this repo. Two modes, chosen automatically per run:
- Package not yet in nixpkgs β installs nix, computes
srchash andvendorHash(usinglib.fakeHashtrick β build with empty hash, extract real hash from the error, repeat for vendor), forksNixOS/nixpkgs, pushes the derivation to a stable branch, opens or updates the one PR againstNixOS/nixpkgsmaster. - Package already merged β no-op; the
passthru.updateScript = nix-update-script {}in the derivation signals thenixpkgs-updatebot to open version-bump PRs automatically on every new release tag β zero maintenance.
The branch name (nixpkgs-web-researcher-mcp-init) is version-independent by design: every pre-merge release pushes to the same branch and reuses the same open PR (checked via gh pr list --head) instead of opening a new PR per release. The bot-authored commit carries an Assisted-by: Claude Code (claude-sonnet-5) trailer and the PR body ticks the automation/AI policy checklist item, per CONTRIBUTING.md#automationai-policy in nixpkgs.
1. Check if pkgs/by-name/we/web-researcher-mcp/package.nix exists in NixOS/nixpkgs
ββ If yes β exit 0 (nixpkgs-update handles future bumps)
2. Install nix via DeterminateSystems/nix-installer-action
3. Compute real src hash (nix-build with lib.fakeHash β parse error output)
4. Compute real vendorHash (same trick with src hash known)
5. Patch packaging/nixpkgs/package.nix with both hashes
6. Fork NixOS/nixpkgs (gh repo fork β idempotent)
7. Clone fork, force-push branch nixpkgs-web-researcher-mcp-init, commit (with Assisted-by trailer)
8. Look for an existing open PR from that branch β update its title/body if found,
else open a new PR: zoharbabin:nixpkgs-web-researcher-mcp-init β NixOS/nixpkgs master
Gated on vars.NIXPKGS_ENABLED == 'true' + secrets.NIXPKGS_FORK_GITHUB_TOKEN.
π docs.yml β Docs deploy¶
π‘ Trigger¶
Push to main touching any of:
- README.md, ARCHITECTURE.md, CONTRIBUTING.md
- docs/**, decks/**, overrides/**
- assets/logo-final-transparent.svg, assets/favicon.ico, assets/demo.webm, assets/demo.mp4
- mkdocs.yml, .github/workflows/docs.yml
Steps¶
- Checkout
- Install
mkdocs-material(pinned version) - Assemble
site_src/from root +docs/files (with cross-link rewriting) - Generate
robots.txt - Deploy to GitHub Pages via
mkdocs gh-deploy --force --remote-branch gh-pages
All
docs/*.mdlinks are rewritten from](docs/FOO.mdβ](foo.mdso the published site URLs are clean. Root policy files (SECURITY.md,CODE_OF_CONDUCT.md) point to GitHub because they are not published to the site.
π codeql.yml β Deep static analysis¶
π‘ Triggers¶
- Push to
main(excluding docs/assets) - Any PR targeting
main(same exclusion) - Weekly schedule: every Monday at 06:00 UTC
Runs GitHub's CodeQL engine with security-extended,security-and-quality query suites. Results appear in the Security β Code scanning tab. Findings must be addressed or dismissed before they accumulate.
π οΈ Adding or changing a workflow¶
Adding a job to ci.yml¶
- Runs on every non-packaging PR (e.g., a new drift gate): add
needs: changesandif: needs.changes.outputs.packaging != 'true'. - Only relevant when Go code changes: gate it on
needs.changes.outputs.code == 'true'and addneeds: [changes, lint](so formatting failures fast-fail first). - Must run even on packaging PRs: omit the
packagingguard and addneeds: changesonly if you need the outputs. Onlyvalidate-packagingfalls into this category.
Adding a post-release distribution channel¶
- Add a new job to
release.ymlwithneeds: release. - Gate it on a repo var (e.g.,
vars.MY_CHANNEL_ENABLED == 'true') so an unconfigured fork is a clean no-op. - Use
|| echo "::warning::..."for non-fatal publish failures so a channel hiccup doesn't block the overall release. - Document the required secret/var in the table above.
Cutting a release¶
# 1. Merge your feature branch to main via PR
# 2. Bump version
echo "1.38.0" > VERSION
bash scripts/sync-version.sh
make sync-lenses
make gen-python-client
git add VERSION server.json .claude-plugin/plugin.json python/ internal/search/lenses_embed/
git commit -m "chore: bump VERSION to 1.38.0"
git push origin HEAD # via PR
# 3. Tag + push
git tag v1.38.0
git push origin v1.38.0
# β release.yml starts automatically
# β update-packaging job generates PKGBUILD/.SRCINFO/flake.nix and attaches them to the release
π Debugging a failed run¶
| Symptom | Where to look |
|---|---|
gofmt failure |
Run make fmt locally, push again |
golangci-lint failure |
Run go tool golangci-lint run locally |
docs-drift failure |
docs/TOOLS.md section out of sync with the Go tool registry β update the matching ## Tool N: \name`section indocs/TOOLS.md` (or update the tool definition to match the doc) |
python-drift failure |
Run make gen-python-client, stage + commit the regenerated files |
validate-packaging failure |
Version mismatch across PKGBUILD / .SRCINFO / flake.nix β run scripts/update-packaging.sh <version> |
| GoReleaser failure | Check secrets are set; check .goreleaser.yml template syntax |
| Python drift fails during release | Tag pushed before make gen-python-client β rebuild on a new patch tag |
| macOS chain verify fails | Re-export the signing p12 with full chain (leaf + intermediate + Apple Root CA) |
update-packaging assets not attached |
Check vars.PACKAGING_UPDATE_ENABLED == 'true'; check job logs |
| AUR push skipped | Expected when AUR_SSH_KEY secret is absent β add the secret to enable |
| AUR push fails | Verify SSH key is registered on aur.archlinux.org; check package name matches |
| PyPI publish failure | Check pypi environment is configured with Trusted Publishing OIDC |
| Docker sign failure | Check GHCR image exists; check cosign OIDC token permissions |
| MCP Registry warning | Non-fatal; check mcp-publisher OIDC credentials, may already be published |
| Smithery warning | Non-fatal; check SMITHERY_API_KEY secret, may already be published |
| Release workflow didn't fire on tag push | GitHub infrastructure glitch β use Actions β π Release β Run workflow and supply the tag (e.g. v1.37.5) to retrigger manually |
π See also¶
CONTRIBUTING.mdβ commit format, branch naming, PR processdocs/DEPLOYMENT.mdβ Docker, Kubernetes, env varsdocs/SECURITY_AND_COMPLIANCE.mdβ security rules that apply to CI changes.goreleaser.ymlβ GoReleaser pipeline config (build matrix, signing, publishers)scripts/update-packaging.shβ packaging update script invoked by the CI job