Skip to content

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 (add run_python_live=true to 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 runs
  • packaging=true β€” every changed file is under packaging/** β†’ packaging-only mode (only validate-packaging runs)

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-client before pushing. If you change docs/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)
  • lint gates both test and security β€” a formatting error surfaces before expensive compute starts.
  • e2e and docker-smoke wait for test β€” a unit test failure blocks the heavier suites.
  • build runs 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 correct flake.nix from 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 src hash and vendorHash (using lib.fakeHash trick β€” build with empty hash, extract real hash from the error, repeat for vendor), forks NixOS/nixpkgs, pushes the derivation to a stable branch, opens or updates the one PR against NixOS/nixpkgs master.
  • Package already merged β†’ no-op; the passthru.updateScript = nix-update-script {} in the derivation signals the nixpkgs-update bot 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

  1. Checkout
  2. Install mkdocs-material (pinned version)
  3. Assemble site_src/ from root + docs/ files (with cross-link rewriting)
  4. Generate robots.txt
  5. Deploy to GitHub Pages via mkdocs gh-deploy --force --remote-branch gh-pages

All docs/*.md links are rewritten from ](docs/FOO.md β†’ ](foo.md so 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

  1. Runs on every non-packaging PR (e.g., a new drift gate): add needs: changes and if: needs.changes.outputs.packaging != 'true'.
  2. Only relevant when Go code changes: gate it on needs.changes.outputs.code == 'true' and add needs: [changes, lint] (so formatting failures fast-fail first).
  3. Must run even on packaging PRs: omit the packaging guard and add needs: changes only if you need the outputs. Only validate-packaging falls into this category.

Adding a post-release distribution channel

  1. Add a new job to release.yml with needs: release.
  2. Gate it on a repo var (e.g., vars.MY_CHANNEL_ENABLED == 'true') so an unconfigured fork is a clean no-op.
  3. Use || echo "::warning::..." for non-fatal publish failures so a channel hiccup doesn't block the overall release.
  4. 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