# =============================================================================
# GitLab CI/CD Pipeline for reclaim-mcp-server
# =============================================================================
# Publishes to:
# - GitLab Package Registry (Python)
# - PyPI (Python)
# - TestPyPI (Python)
# - GitLab Container Registry (Docker)
# - DockerHub (Docker)
# - GitLab Releases (with links to all registries)
# =============================================================================
default:
image: python:3.12
retry: 2
interruptible: true
stages:
- lint
- test
- security
- build
- publish
- release
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
SAST_EXCLUDED_PATHS: "spec,test,tests,tmp,.venv"
# Docker configuration
DOCKER_HOST: tcp://docker:2376
DOCKER_TLS_CERTDIR: "/certs"
DOCKER_DRIVER: overlay2
# Image names
GITLAB_IMAGE: $CI_REGISTRY_IMAGE
DOCKERHUB_IMAGE: docker.io/$DOCKERHUB_USERNAME/reclaim-mcp-server
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_TAG
- if: $CI_COMMIT_REF_PROTECTED == "true"
include:
- template: Jobs/SAST.gitlab-ci.yml
- template: Jobs/Secret-Detection.gitlab-ci.yml
cache:
key: ${CI_COMMIT_REF_SLUG}-python
paths:
- .cache/pip
- .venv
# =============================================================================
# TEMPLATES
# =============================================================================
.poetry-setup:
before_script:
- pip install poetry
- poetry config virtualenvs.in-project true
- poetry install
# Note: .docker-setup template removed in v0.8.1
# Docker jobs now define image/services inline for buildx compatibility
.standard-rules:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_REF_PROTECTED == "true"
# Note: .tag-only-rules template removed in v0.9.0 (fully automated pipeline)
# =============================================================================
# LINT STAGE
# =============================================================================
lint:
stage: lint
extends:
- .poetry-setup
- .standard-rules
script:
- poetry run black --check src tests
- poetry run isort --check-only src tests
- poetry run flake8 src tests
- poetry run mypy src
# =============================================================================
# TEST STAGE
# =============================================================================
test:
stage: test
extends:
- .poetry-setup
- .standard-rules
needs: [lint]
script:
- poetry run pytest --cov=src/reclaim_mcp --cov-report=xml --cov-report=term
coverage: '/TOTAL.*\s+(\d+%)/'
artifacts:
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
# =============================================================================
# BUILD STAGE
# =============================================================================
# Build Python package
build-package:
stage: build
extends:
- .poetry-setup
rules:
- if: $CI_COMMIT_TAG
- if: $CI_COMMIT_REF_PROTECTED == "true"
- if: $CI_PIPELINE_SOURCE == "merge_request_event" # For TestPyPI validation
script:
- poetry build
# Note: twine check skipped - Poetry 2.x produces Metadata-Version 2.4
# which twine doesn't support yet (only up to 2.3)
artifacts:
paths:
- dist/
expire_in: 1 week
# Build and push multi-platform Docker image to GitLab Container Registry (only on tags)
build-docker:
stage: build
image: docker:27
services:
- docker:27-dind
needs: [build-package, test]
rules:
- if: $CI_COMMIT_TAG
variables:
PLATFORMS: "linux/amd64,linux/arm64"
# Disable TLS for buildx docker-container driver compatibility
DOCKER_TLS_CERTDIR: ""
DOCKER_HOST: tcp://docker:2375
before_script:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker buildx create --name multiarch --driver docker-container --use
- docker buildx inspect --bootstrap
script:
- |
docker buildx build \
--platform $PLATFORMS \
--push \
--cache-from type=registry,ref=$GITLAB_IMAGE:buildcache \
--cache-to type=registry,ref=$GITLAB_IMAGE:buildcache,mode=max \
-t $GITLAB_IMAGE:$CI_COMMIT_TAG \
-t $GITLAB_IMAGE:latest \
.
# Container vulnerability scanning (scans from registry after build-docker pushes)
container-scan:
stage: build
image:
name: aquasec/trivy:latest
entrypoint: [""] # Override default entrypoint to allow shell scripts
needs: [build-docker]
rules:
- if: $CI_COMMIT_TAG
script:
- trivy image --format table --severity HIGH,CRITICAL $GITLAB_IMAGE:$CI_COMMIT_TAG
- trivy image --exit-code 1 --severity CRITICAL $GITLAB_IMAGE:$CI_COMMIT_TAG
allow_failure: true
# =============================================================================
# PUBLISH STAGE - Python Packages
# =============================================================================
# Publish to GitLab Package Registry
publish-gitlab-package:
stage: publish
image: python:3.12
needs: [build-package, test]
variables:
TWINE_USERNAME: gitlab-ci-token
TWINE_PASSWORD: $CI_JOB_TOKEN
script:
- pip install twine
- python -m twine upload --repository-url ${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/pypi dist/*
rules:
- if: $CI_COMMIT_TAG
# Publish to TestPyPI (MR validation only)
publish-testpypi:
stage: publish
image: python:3.12
needs: [build-package, test]
id_tokens:
PYPI_ID_TOKEN:
aud: testpypi
script:
- pip install twine
- |
echo "Exchanging OIDC token for TestPyPI..."
RESPONSE=$(curl -s -X POST "https://test.pypi.org/_/oidc/mint-token" \
-H "Content-Type: application/json" \
-d "{\"token\": \"${PYPI_ID_TOKEN}\"}")
PYPI_TOKEN=$(echo "${RESPONSE}" | python -c "import sys, json; data=json.load(sys.stdin); print(data.get('token', '')); exit(0 if 'token' in data else 1)")
if [ -z "$PYPI_TOKEN" ]; then
echo "Token exchange failed: $RESPONSE"
exit 1
fi
- twine upload --non-interactive --verbose --skip-existing -u __token__ -p "${PYPI_TOKEN}" --repository-url https://test.pypi.org/legacy/ dist/*
environment:
name: release-test
url: https://test.pypi.org/project/reclaim-mcp-server/
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
allow_failure: true
# Publish to Production PyPI
publish-pypi:
stage: publish
image: python:3.12
needs: [build-package, test]
id_tokens:
PYPI_ID_TOKEN:
aud: pypi
script:
- pip install twine
- |
echo "Exchanging OIDC token for PyPI..."
RESPONSE=$(curl -s -X POST "https://pypi.org/_/oidc/mint-token" \
-H "Content-Type: application/json" \
-d "{\"token\": \"${PYPI_ID_TOKEN}\"}")
PYPI_TOKEN=$(echo "${RESPONSE}" | python -c "import sys, json; data=json.load(sys.stdin); print(data.get('token', '')); exit(0 if 'token' in data else 1)")
if [ -z "$PYPI_TOKEN" ]; then
echo "Token exchange failed: $RESPONSE"
exit 1
fi
- twine upload --non-interactive --verbose --skip-existing -u __token__ -p "${PYPI_TOKEN}" dist/*
environment:
name: release
url: https://pypi.org/project/reclaim-mcp-server/
rules:
- if: $CI_COMMIT_TAG
# =============================================================================
# PUBLISH STAGE - Docker Images
# =============================================================================
# Note: GitLab Container Registry publish merged into build-docker job (v0.8.1)
# Publish multi-platform image to DockerHub
publish-dockerhub:
stage: publish
image: docker:27
services:
- docker:27-dind
needs: [test, build-package]
variables:
PLATFORMS: "linux/amd64,linux/arm64"
# Disable TLS for buildx docker-container driver compatibility
DOCKER_TLS_CERTDIR: ""
DOCKER_HOST: tcp://docker:2375
before_script:
- echo "$DOCKERHUB_TOKEN" | docker login -u "$DOCKERHUB_USERNAME" --password-stdin
- docker buildx create --name multiarch --driver docker-container --use
- docker buildx inspect --bootstrap
script:
- |
docker buildx build \
--platform $PLATFORMS \
--push \
-t $DOCKERHUB_IMAGE:$CI_COMMIT_TAG \
-t $DOCKERHUB_IMAGE:latest \
.
environment:
name: docker
url: https://hub.docker.com/r/$DOCKERHUB_USERNAME/reclaim-mcp-server
rules:
- if: $CI_COMMIT_TAG
# =============================================================================
# RELEASE STAGE - GitLab Release
# =============================================================================
# Verify Docker images are pullable from both registries
verify-docker-pull:
stage: release
image: docker:27
services:
- docker:27-dind
needs:
- job: build-docker
- job: publish-dockerhub
rules:
- if: $CI_COMMIT_TAG
when: on_success
allow_failure: true
script:
- echo "Verifying Docker images are pullable..."
- docker pull $GITLAB_IMAGE:$CI_COMMIT_TAG
- docker pull $DOCKERHUB_IMAGE:$CI_COMMIT_TAG
- echo "Verifying images run correctly..."
- docker run --rm $GITLAB_IMAGE:$CI_COMMIT_TAG python -c "from reclaim_mcp import __version__; print(__version__)"
- docker run --rm $DOCKERHUB_IMAGE:$CI_COMMIT_TAG python -c "from reclaim_mcp import __version__; print(__version__)"
# Create GitLab Release with links to all registries
# Uses native release: keyword (works with CI_JOB_TOKEN, no PAT needed)
create-release:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:latest
needs:
- job: build-package
artifacts: true
- job: publish-gitlab-package
- job: publish-pypi
- job: build-docker
- job: publish-dockerhub
rules:
- if: $CI_COMMIT_TAG
variables:
PACKAGE_VERSION: "${CI_COMMIT_TAG#v}"
before_script:
# Upload artifacts to Generic Package Registry for permanent URLs
- apk add --no-cache curl
- |
for file in dist/*; do
echo "Uploading $(basename $file) to Generic Package Registry..."
curl --header "JOB-TOKEN: $CI_JOB_TOKEN" \
--upload-file "$file" \
"${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/reclaim-mcp-server/${CI_COMMIT_TAG}/$(basename $file)"
done
script:
- echo "Creating GitLab Release for $CI_COMMIT_TAG"
release:
tag_name: $CI_COMMIT_TAG
name: "Release $CI_COMMIT_TAG"
description: "./CHANGELOG.md"
ref: $CI_COMMIT_SHA
assets:
links:
- name: "Python Wheel (.whl)"
url: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/reclaim-mcp-server/${CI_COMMIT_TAG}/reclaim_mcp_server-${PACKAGE_VERSION}-py3-none-any.whl"
link_type: package
- name: "Source Distribution (.tar.gz)"
url: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/reclaim-mcp-server/${CI_COMMIT_TAG}/reclaim_mcp_server-${PACKAGE_VERSION}.tar.gz"
link_type: package
- name: "PyPI"
url: "https://pypi.org/project/reclaim-mcp-server/${PACKAGE_VERSION}/"
link_type: package
- name: "GitLab Package Registry"
url: "${CI_PROJECT_URL}/-/packages"
link_type: package
- name: "DockerHub"
url: "https://hub.docker.com/r/${DOCKERHUB_USERNAME}/reclaim-mcp-server/tags?name=${CI_COMMIT_TAG}"
link_type: image
- name: "GitLab Container Registry"
url: "${CI_PROJECT_URL}/container_registry"
link_type: image
environment:
name: release
url: ${CI_PROJECT_URL}/-/releases/${CI_COMMIT_TAG}