name: CI/CD Pipeline
on:
push:
branches: [main, master]
tags: ['v*']
pull_request:
branches: [main, master]
workflow_dispatch:
# Prevent concurrent runs on the same branch
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# Principle of least privilege
permissions:
contents: read
packages: write
id-token: write
attestations: write
security-events: write
jobs:
ci:
name: CI & Version
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
security-events: write
actions: read
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Cache Node.js modules
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}-${{ github.run_id }}
restore-keys: |
${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}-
${{ runner.os }}-node-
- name: Get package version
id: version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Package version: $VERSION"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'npm'
- name: Ensure npm supports OIDC
run: sudo npm install -g npm@^11.5.1
- name: Install dependencies
run: npm ci
- name: Dependency review (PR only)
if: github.event_name == 'pull_request'
uses: actions/dependency-review-action@v4
- name: Run CodeQL analysis
uses: github/codeql-action/init@v3
with:
languages: javascript
- name: Perform CodeQL analysis
uses: github/codeql-action/analyze@v3
- name: Build TypeScript
run: npm run build
- name: Run tests
run: npm test
- name: Upload test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: |
test-results.xml
tests/results/**/*
junit.xml
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: |
coverage/**/*
.nyc_output/**/*
# Determine what to do based on changes
analyze-changes:
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
has-code-changes: ${{ steps.changes.outputs.has-code-changes }}
has-readme-changes: ${{ steps.changes.outputs.has-readme-changes }}
package-json-changed: ${{ steps.changes.outputs.package-json-changed }}
should-publish: ${{ steps.decide.outputs.should-publish }}
should-release: ${{ steps.decide.outputs.should-release }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check what changed
id: changes
run: |
# Skip change analysis for pull requests - we don't publish/release from PRs
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "has-code-changes=false" >> $GITHUB_OUTPUT
echo "has-readme-changes=false" >> $GITHUB_OUTPUT
echo "package-json-changed=false" >> $GITHUB_OUTPUT
exit 0
fi
# For tag pushes, assume everything changed to trigger full publish/release
if [[ "${{ github.ref }}" == refs/tags/* ]]; then
echo "has-code-changes=true" >> $GITHUB_OUTPUT
echo "has-readme-changes=true" >> $GITHUB_OUTPUT
echo "package-json-changed=true" >> $GITHUB_OUTPUT
exit 0
fi
# Check commit message for docs-only commits using conventional commit format
COMMIT_MSG=$(git log -1 --pretty=%s)
echo "Commit message: $COMMIT_MSG"
if [[ "$COMMIT_MSG" =~ ^docs(\(.+\))?: ]]; then
echo "Documentation-only commit detected, skipping build/release"
echo "has-code-changes=false" >> $GITHUB_OUTPUT
echo "has-readme-changes=true" >> $GITHUB_OUTPUT
echo "package-json-changed=false" >> $GITHUB_OUTPUT
exit 0
fi
# Analyze changed files to determine what types of changes occurred
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD || echo "")
echo "Changed files: $CHANGED_FILES"
HAS_CODE_CHANGES=false
HAS_README_CHANGES=false
PACKAGE_JSON_CHANGED=false
# Categorize each changed file
for file in $CHANGED_FILES; do
if [[ "$file" =~ README\.dockerhub\.md$ || "$file" =~ README\.md$ ]]; then
HAS_README_CHANGES=true
fi
if [[ "$file" == "package.json" ]]; then
PACKAGE_JSON_CHANGED=true
fi
# Consider it a code change if it's not documentation, images, or config files
if [[ ! "$file" =~ \.(md|txt|png|jpg|jpeg|gif)$ ]] && \
[[ "$file" != "LICENSE" ]] && \
[[ ! "$file" =~ ^docs/ ]] && \
[[ ! "$file" =~ ^screenshots/ ]] && \
[[ ! "$file" =~ \.gitignore$ ]]; then
HAS_CODE_CHANGES=true
fi
done
echo "has-code-changes=$HAS_CODE_CHANGES" >> $GITHUB_OUTPUT
echo "has-readme-changes=$HAS_README_CHANGES" >> $GITHUB_OUTPUT
echo "package-json-changed=$PACKAGE_JSON_CHANGED" >> $GITHUB_OUTPUT
- name: Decide what to do
id: decide
run: |
SHOULD_PUBLISH=false
SHOULD_RELEASE=false
# Check if this is a docs-only commit
COMMIT_MSG=$(git log -1 --pretty=%s)
if [[ "$COMMIT_MSG" =~ ^docs(\(.+\))?: ]]; then
echo "Documentation-only commit - skipping publish and release"
echo "should-publish=false" >> $GITHUB_OUTPUT
echo "should-release=false" >> $GITHUB_OUTPUT
exit 0
fi
# Only publish and release if we have actual code changes OR package.json version changed
if [[ "${{ steps.changes.outputs.has-code-changes }}" == "true" ]]; then
SHOULD_PUBLISH=true
SHOULD_RELEASE=true
echo "Code changes detected - will publish and release"
elif [[ "${{ steps.changes.outputs.package-json-changed }}" == "true" ]]; then
# For package.json changes, only proceed if it's likely a version bump
# We'll let Docker and NPM checks handle whether to actually publish
SHOULD_PUBLISH=true
SHOULD_RELEASE=true
echo "Package.json changed (likely version bump) - will check if publish needed"
fi
echo "should-publish=$SHOULD_PUBLISH" >> $GITHUB_OUTPUT
echo "should-release=$SHOULD_RELEASE" >> $GITHUB_OUTPUT
echo "Will check for publish: $SHOULD_PUBLISH"
echo "Will check for release: $SHOULD_RELEASE"
# Create git tag when needed (before publish/release)
create-tag:
needs: [ci, analyze-changes]
if: always() && !cancelled() && !failure() && needs.analyze-changes.outputs.should-publish == 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Create git tag
run: |
VERSION="${{ needs.ci.outputs.version }}"
TAG="v$VERSION"
# Fetch all tags from remote to check if tag exists
git fetch --tags
# Check if tag already exists locally or remotely
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists locally"
elif git ls-remote --tags origin | grep -q "refs/tags/$TAG$"; then
echo "Tag $TAG already exists on remote"
else
echo "Creating tag $TAG"
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git tag "$TAG"
git push origin "$TAG"
echo "Tag $TAG created successfully"
fi
# Publish only when needed
publish:
needs: [ci, analyze-changes, create-tag]
if: always() && !cancelled() && !failure() && needs.analyze-changes.outputs.should-publish == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: write
id-token: write
attestations: write
security-events: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build TypeScript
run: npm run build
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: build-artifact
path: build/
- name: Set up Node.js for publish (enable registry & OIDC)
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
- name: Ensure npm supports OIDC
run: sudo npm install -g npm@^11.5.1
- name: Install dependencies
run: npm ci
- name: Check if NPM version exists
id: npm-check
run: |
VERSION="${{ needs.ci.outputs.version }}"
# Check if this version already exists on NPM
if npm view it-tools-mcp@$VERSION version >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Version $VERSION already exists on NPM"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Version $VERSION does not exist on NPM"
fi
# Ensure OIDC-ready npm and force use of OIDC by clearing any tokens
- name: Set up Node.js for publish (enable registry & OIDC)
if: steps.npm-check.outputs.exists == 'false'
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
registry-url: 'https://registry.npmjs.org'
- name: Ensure npm supports OIDC
if: steps.npm-check.outputs.exists == 'false'
run: sudo npm install -g npm@^11.5.1
- name: Ensure no token auth present and force OIDC
if: steps.npm-check.outputs.exists == 'false'
run: |
set -euo pipefail
echo "Checking npm version: $(npm --version)"
# Unset common env vars
unset NODE_AUTH_TOKEN || true
unset NPM_TOKEN || true
unset NPM_CONFIG_USERCONFIG || true
# Remove global and temp npmrc files if present
rm -f ~/.npmrc || true
rm -f "$RUNNER_TEMP/.npmrc" || true
# Clean npm config entries
npm config delete //registry.npmjs.org/:_authToken || true
npm config delete //registry.npmjs.org/:_auth || true
npm config delete //registry.npmjs.org/:_password || true
# Check auth state
if npm whoami >/dev/null 2>&1; then
echo "ERROR: runner is still authenticated to npm via token. Remove publish tokens from repo/org secrets and retry or configure Trusted Publisher on npmjs.com." >&2
npm whoami || true
exit 1
else
echo "No npm auth detected; proceeding to OIDC publish flow"
fi
# Diagnostic steps removed from main CI pipeline to avoid running OIDC checks during normal publish flow.
# Use the manual 'diagnose-oidc.yml' workflow to run a focused OIDC exchange diagnostic on demand.
- name: Publish to NPM
if: steps.npm-check.outputs.exists == 'false'
run: npm publish --access public
- name: Check if Docker image exists
id: docker-check
run: |
VERSION="${{ needs.ci.outputs.version }}"
# Check if this version tag already exists on Docker Hub
if docker manifest inspect wrenchpilot/it-tools-mcp:v$VERSION >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Docker image v$VERSION already exists"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Docker image v$VERSION does not exist"
fi
- name: Set up Docker Buildx
if: steps.docker-check.outputs.exists == 'false'
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: steps.docker-check.outputs.exists == 'false'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata
if: steps.docker-check.outputs.exists == 'false'
id: meta
uses: docker/metadata-action@v5
with:
images: wrenchpilot/it-tools-mcp
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=v${{ needs.ci.outputs.version }}
labels: |
org.opencontainers.image.title=IT Tools MCP Server
org.opencontainers.image.description=MCP server providing access to various IT tools and utilities
org.opencontainers.image.vendor=wrenchpilot
org.opencontainers.image.version=${{ needs.ci.outputs.version }}
- name: Build and push Docker image
if: steps.docker-check.outputs.exists == 'false'
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: true
sbom: true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan Docker image for vulnerabilities
if: steps.docker-check.outputs.exists == 'false'
uses: aquasecurity/trivy-action@master
with:
image-ref: wrenchpilot/it-tools-mcp:v${{ needs.ci.outputs.version }}
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy scan results
if: steps.docker-check.outputs.exists == 'false'
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
# Create release when needed
release:
needs: [ci, analyze-changes, create-tag]
if: always() && !cancelled() && !failure() && needs.analyze-changes.outputs.should-release == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check if release exists
id: check-release
run: |
VERSION="${{ needs.ci.outputs.version }}"
TAG="v$VERSION"
# Check if GitHub release already exists
if gh release view "$TAG" >/dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Release $TAG already exists"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "Release $TAG does not exist"
fi
env:
GH_TOKEN: ${{ github.token }}
- name: Generate release notes
if: steps.check-release.outputs.exists == 'false'
run: |
VERSION="${{ needs.ci.outputs.version }}"
TAG="v$VERSION"
# Extract release notes from CHANGELOG.md
if [[ -f "CHANGELOG.md" ]]; then
echo "Extracting release notes from CHANGELOG.md for version $VERSION"
# Find the section for this version and extract until next version or EOF
awk "
/^## $VERSION / { found=1; next }
found && /^## [0-9]/ { exit }
found { print }
" CHANGELOG.md > release_notes.md
# Remove leading/trailing whitespace
sed -i '/^$/d' release_notes.md
# If no content found in changelog, fall back to git log
if [[ ! -s release_notes.md ]]; then
echo "No changelog entry found for $VERSION, using git log"
LAST_TAG=$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo "")
if [[ -n "$LAST_TAG" ]]; then
echo "## Changes since $LAST_TAG" > release_notes.md
echo "" >> release_notes.md
git log --pretty=format:"- %s (%h)" "$LAST_TAG"..HEAD >> release_notes.md
else
echo "## Initial Release" > release_notes.md
echo "" >> release_notes.md
echo "First release of IT Tools MCP Server v$VERSION" >> release_notes.md
fi
fi
else
echo "No CHANGELOG.md found, using git log"
LAST_TAG=$(git describe --tags --abbrev=0 HEAD~1 2>/dev/null || echo "")
if [[ -n "$LAST_TAG" ]]; then
echo "## Changes since $LAST_TAG" > release_notes.md
echo "" >> release_notes.md
git log --pretty=format:"- %s (%h)" "$LAST_TAG"..HEAD >> release_notes.md
else
echo "## Initial Release" > release_notes.md
echo "" >> release_notes.md
echo "First release of IT Tools MCP Server v$VERSION" >> release_notes.md
fi
fi
echo "" >> release_notes.md
echo "## Installation & Setup" >> release_notes.md
echo "" >> release_notes.md
echo "### Quick Install with VS Code" >> release_notes.md
echo "" >> release_notes.md
echo "[](https://vscode.dev/redirect?url=vscode:mcp/install?%7B%22name%22%3A%22it-tools%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22it-tools-mcp%22%5D%7D) [](https://insiders.vscode.dev/redirect?url=vscode-insiders:mcp/install?%7B%22name%22%3A%22it-tools%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22it-tools-mcp%22%5D%7D)" >> release_notes.md
echo "" >> release_notes.md
echo "### Manual Installation" >> release_notes.md
echo "" >> release_notes.md
echo "Add to your VS Code \`settings.json\`:" >> release_notes.md
echo "" >> release_notes.md
echo "#### Node" >> release_notes.md
echo "" >> release_notes.md
echo "\`\`\`json" >> release_notes.md
echo "{" >> release_notes.md
echo " \"mcp\": {" >> release_notes.md
echo " \"servers\": {" >> release_notes.md
echo " \"it-tools\": {" >> release_notes.md
echo " \"command\": \"npx\"," >> release_notes.md
echo " \"args\": [" >> release_notes.md
echo " \"it-tools-mcp\"" >> release_notes.md
echo " ]," >> release_notes.md
echo " \"env\": {}" >> release_notes.md
echo " }" >> release_notes.md
echo " }" >> release_notes.md
echo " }" >> release_notes.md
echo "}" >> release_notes.md
echo "\`\`\`" >> release_notes.md
echo "" >> release_notes.md
echo "#### Docker" >> release_notes.md
echo "" >> release_notes.md
echo "\`\`\`json" >> release_notes.md
echo "{" >> release_notes.md
echo " \"mcp\": {" >> release_notes.md
echo " \"servers\": {" >> release_notes.md
echo " \"it-tools\": {" >> release_notes.md
echo " \"command\": \"docker\"," >> release_notes.md
echo " \"args\": [" >> release_notes.md
echo " \"run\"," >> release_notes.md
echo " \"-i\"," >> release_notes.md
echo " \"--rm\"," >> release_notes.md
echo " \"--init\"," >> release_notes.md
echo " \"--security-opt\", \"no-new-privileges:true\"," >> release_notes.md
echo " \"--cap-drop\", \"ALL\"," >> release_notes.md
echo " \"--read-only\"," >> release_notes.md
echo " \"--user\", \"1001:1001\"," >> release_notes.md
echo " \"--memory=256m\"," >> release_notes.md
echo " \"--cpus=0.5\"," >> release_notes.md
echo " \"--name\", \"it-tools-mcp\"," >> release_notes.md
echo " \"wrenchpilot/it-tools-mcp:v$VERSION\"" >> release_notes.md
echo " ]" >> release_notes.md
echo " }" >> release_notes.md
echo " }" >> release_notes.md
echo " }" >> release_notes.md
echo "}" >> release_notes.md
echo "\`\`\`" >> release_notes.md
- name: Create Release
if: steps.check-release.outputs.exists == 'false'
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ needs.ci.outputs.version }}
name: IT Tools MCP Server v${{ needs.ci.outputs.version }}
body_path: release_notes.md
draft: false
prerelease: false
# Update Docker README when README files change
update-docker-readme:
needs: [analyze-changes]
if: always() && !cancelled() && !failure() && needs.analyze-changes.outputs.has-readme-changes == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Update Docker Hub README
uses: peter-evans/dockerhub-description@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: wrenchpilot/it-tools-mcp
readme-filepath: ./README.dockerhub.md