# =============================================================================
# WORKFLOW: Multi-Channel Package Publishing
# PURPOSE: Distribute releases to NPM, GitHub Packages, and Docker Hub
# TRIGGERS: GitHub release publication or manual dispatch
# OUTPUTS: Published packages to configured registries
# =============================================================================
name: Publish
on:
release:
types: [published] # Triggered when a GitHub release is published
workflow_dispatch: # Manual trigger for re-publishing or testing
inputs:
tag:
description: 'Release tag to publish (e.g., v1.2.3)'
required: true
type: string
# Allow only one publish workflow per branch
# cancel-in-progress: false to allow multiple releases to proceed
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
# Global environment variables for consistency
env:
PNPM_VERSION: 10.17.0 # Pinned: Must match packageManager in package.json
NODE_VERSION: 22 # Pinned: Must match engines.node in package.json
# SECURITY: Minimal required permissions
# contents: read - Checkout code at release tag
# packages: write - Publish to GitHub Packages
# id-token: write - Generate provenance for npm
permissions:
contents: read
packages: write
id-token: write
jobs:
# =============================================================================
# NPM PUBLISHING
# Publishes package to npm registry with provenance
# =============================================================================
npm:
name: Publish to NPM
runs-on: ubuntu-latest
# Only runs if ENABLE_NPM_RELEASE variable is set to 'true'
# Configure in Settings > Secrets and variables > Variables
if: vars.ENABLE_NPM_RELEASE == 'true'
permissions:
contents: read
id-token: write # Required for npm provenance
actions: read # Required to download artifacts
steps:
- name: Determine version
id: version
# Extract version from release tag or manual input
# Strips 'v' prefix to get semver (v1.2.3 -> 1.2.3)
run: |
VERSION="${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag }}"
VERSION="${VERSION#v}" # Remove 'v' prefix
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
echo "π¦ Publishing to NPM: $VERSION"
- name: Checkout code
uses: actions/checkout@v4
with:
# IMPORTANT: Checkout the exact release tag, not latest main
# This ensures we publish exactly what was released
ref: ${{ steps.version.outputs.tag }}
- name: Setup Node.js
# Node.js is required for npm publish command
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
# Configure npm registry for authentication
registry-url: 'https://registry.npmjs.org'
- name: Determine artifact source
id: artifact
# Use shared script to find the correct NPM package artifact from the release build
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
chmod +x .github/scripts/determine-artifact.sh
.github/scripts/determine-artifact.sh \
--tag "${{ steps.version.outputs.tag }}" \
--repo "${{ github.repository }}" \
--version "${{ steps.version.outputs.version }}" \
--prefix "npm-package" \
--output "$GITHUB_OUTPUT"
- name: Download pre-built NPM package
id: download
# Download the pre-built, pre-scanned NPM package from main workflow
# This ensures we publish exactly what was tested
uses: actions/download-artifact@v4
with:
name: ${{ steps.artifact.outputs.artifact_name }}
path: ./npm-artifact
run-id: ${{ steps.artifact.outputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract pre-built package
run: |
# Check if any .tgz files exist
TARBALL=$(find ./npm-artifact -name "*.tgz" -type f | head -1)
if [ -z "$TARBALL" ]; then
echo "β No .tgz file found in artifact!"
echo "Contents of ./npm-artifact:"
ls -la ./npm-artifact/
exit 1
fi
echo "β
Using pre-built NPM package from main workflow"
echo "π¦ Extracting: $TARBALL"
tar -xzf "$TARBALL"
# The package extracts to a 'package' directory
# We need to move its contents to the current directory
if [ -d package ]; then
cp -r package/* .
rm -rf package
fi
echo "π Verified package contents from manifest"
if [ -f ./npm-artifact/npm-package-manifest.txt ]; then
echo "Package contains $(wc -l < ./npm-artifact/npm-package-manifest.txt) files"
fi
- name: Check NPM token
id: check-npm
# Gracefully handle missing NPM_TOKEN
# Allows workflow to succeed even without npm publishing
run: |
if [ -n "${{ secrets.NPM_TOKEN }}" ]; then
echo "has_token=true" >> $GITHUB_OUTPUT
echo "β
NPM_TOKEN is configured"
else
echo "has_token=false" >> $GITHUB_OUTPUT
echo "β οΈ NPM_TOKEN is not configured, skipping publish"
# To fix: Add NPM_TOKEN secret in Settings > Secrets
fi
- name: Publish to NPM
if: steps.check-npm.outputs.has_token == 'true'
run: |
# Remove private flag and prepare script (which runs husky)
# The prepare script runs even with --ignore-scripts, so we must remove it
jq 'del(.private) | del(.scripts.prepare)' package.json > tmp.json && mv tmp.json package.json
# Publish with provenance for supply chain security
# --provenance creates a signed attestation of the build
npm publish --provenance --access public
env:
# SECURITY: NPM_TOKEN required for authentication
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# =============================================================================
# GITHUB PACKAGES PUBLISHING
# Publishes package to GitHub's npm registry
# =============================================================================
github-packages:
name: Publish to GitHub Packages
runs-on: ubuntu-latest
# Only runs if ENABLE_GITHUB_PACKAGES variable is set
# Useful for private packages within organization
if: vars.ENABLE_GITHUB_PACKAGES == 'true'
permissions:
contents: read
packages: write # Required to publish to GitHub Packages
id-token: write # Required for provenance
actions: read # Required to download artifacts
steps:
- name: Determine version
id: version
run: |
VERSION="${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag }}"
VERSION="${VERSION#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
echo "π¦ Publishing to GitHub Packages: $VERSION"
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ steps.version.outputs.tag }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
# GitHub Packages npm registry URL
registry-url: 'https://npm.pkg.github.com'
- name: Determine artifact source
id: artifact
# Use shared script to find the correct NPM package artifact (same as npm job)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
chmod +x .github/scripts/determine-artifact.sh
.github/scripts/determine-artifact.sh \
--tag "${{ steps.version.outputs.tag }}" \
--repo "${{ github.repository }}" \
--version "${{ steps.version.outputs.version }}" \
--prefix "npm-package" \
--output "$GITHUB_OUTPUT"
- name: Download pre-built NPM package
id: download
uses: actions/download-artifact@v4
with:
name: ${{ steps.artifact.outputs.artifact_name }}
path: ./npm-artifact
run-id: ${{ steps.artifact.outputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Extract pre-built package
run: |
# Check if any .tgz files exist
TARBALL=$(find ./npm-artifact -name "*.tgz" -type f | head -1)
if [ -z "$TARBALL" ]; then
echo "β No .tgz file found in artifact!"
echo "Contents of ./npm-artifact:"
ls -la ./npm-artifact/
exit 1
fi
echo "β
Using pre-built NPM package from main workflow"
echo "π¦ Extracting: $TARBALL"
tar -xzf "$TARBALL"
# The package extracts to a 'package' directory
if [ -d package ]; then
cp -r package/* .
rm -rf package
fi
echo "π Verified package contents"
- name: Publish to GitHub Packages
run: |
# Scope package name to organization and remove private flag and prepare script
# The prepare script runs even with --ignore-scripts, so we must remove it
jq '.name = "@${{ github.repository_owner }}/" + .name | del(.private) | del(.scripts.prepare)' package.json > tmp.json && mv tmp.json package.json
npm publish --access public
env:
# SECURITY: Uses GITHUB_TOKEN for authentication
# Automatically available, no configuration needed
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# =============================================================================
# DOCKER HUB PUBLISHING
# Copies pre-built multi-platform image from GHCR to Docker Hub
# =============================================================================
docker:
name: Publish to Docker Hub
runs-on: ubuntu-latest
# Only runs if ENABLE_DOCKER_RELEASE variable is set
# Requires DOCKERHUB_USERNAME and DOCKERHUB_TOKEN secrets
if: vars.ENABLE_DOCKER_RELEASE == 'true'
permissions:
contents: read
packages: read # Read from GitHub Container Registry
steps:
- name: Determine version
id: version
run: |
VERSION="${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag }}"
VERSION="${VERSION#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=v$VERSION" >> $GITHUB_OUTPUT
echo "π³ Publishing Docker image: $VERSION"
- name: Check Docker credentials
id: check-docker
# Validate Docker Hub credentials exist
# Allows workflow to succeed without Docker publishing
run: |
if [ -n "${{ secrets.DOCKERHUB_USERNAME }}" ] && [ -n "${{ secrets.DOCKERHUB_TOKEN }}" ]; then
echo "has_credentials=true" >> $GITHUB_OUTPUT
echo "β
Docker Hub credentials are configured"
else
echo "has_credentials=false" >> $GITHUB_OUTPUT
echo "β οΈ Docker Hub credentials are not configured, skipping publish"
# To fix: Add DOCKERHUB_USERNAME and DOCKERHUB_TOKEN in Settings > Secrets
exit 0
fi
- name: Set up Docker Buildx
# Required for imagetools commands
if: steps.check-docker.outputs.has_credentials == 'true'
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
# Login to GHCR to pull the pre-built image
if: steps.check-docker.outputs.has_credentials == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
# SECURITY: Authenticate with Docker Hub for pushing
if: steps.check-docker.outputs.has_credentials == 'true'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Copy image from GHCR to Docker Hub
# Use buildx imagetools to copy multi-platform image between registries
# This properly handles multi-platform manifest lists
if: steps.check-docker.outputs.has_credentials == 'true'
run: |
SOURCE_IMAGE="ghcr.io/${{ github.repository_owner }}/deepsource-mcp-server"
TARGET_REPO="${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}"
VERSION="${{ steps.version.outputs.version }}"
echo "π€ Copying multi-platform image from GHCR to Docker Hub..."
echo "Source: $SOURCE_IMAGE:$VERSION"
echo "Target: $TARGET_REPO:$VERSION"
# Copy image with version tag
docker buildx imagetools create \
--tag $TARGET_REPO:$VERSION \
$SOURCE_IMAGE:$VERSION
echo "π·οΈ Creating additional tags..."
# Create alias tags for latest, major, and major.minor versions
MAJOR=$(echo "$VERSION" | cut -d. -f1)
MINOR=$(echo "$VERSION" | cut -d. -f2)
docker buildx imagetools create --tag $TARGET_REPO:latest $TARGET_REPO:$VERSION
docker buildx imagetools create --tag $TARGET_REPO:$MAJOR $TARGET_REPO:$VERSION
docker buildx imagetools create --tag $TARGET_REPO:$MAJOR.$MINOR $TARGET_REPO:$VERSION
echo "β
Docker image published successfully to Docker Hub"
echo "π Published tags: $VERSION, latest, $MAJOR, $MAJOR.$MINOR"
# =============================================================================
# NOTIFICATION
# Send status updates to team communication channels
# =============================================================================
notify:
name: Notify
if: always() # Run even if publishing jobs fail
needs: [npm, docker, github-packages]
runs-on: ubuntu-latest
steps:
- name: Check Slack webhook
id: check-slack
# Gracefully handle missing Slack configuration
run: |
if [ -n "${{ secrets.SLACK_WEBHOOK }}" ]; then
echo "has_webhook=true" >> $GITHUB_OUTPUT
else
echo "has_webhook=false" >> $GITHUB_OUTPUT
# Optional: Configure SLACK_WEBHOOK in Settings > Secrets
fi
- name: Send Slack notification
# Send release status to Slack channel
# Shows success/skip/failure for each distribution channel
if: steps.check-slack.outputs.has_webhook == 'true'
uses: slackapi/slack-github-action@v2
with:
payload: |
{
"text": "π Release ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag }}",
"blocks": [
{
"type": "section",
"fields": [
{"type": "mrkdwn", "text": "*Repo:*\n${{ github.repository }}"},
{"type": "mrkdwn", "text": "*NPM:*\n${{ needs.npm.result == 'success' && 'β
' || needs.npm.result == 'skipped' && 'βοΈ' || 'β' }}"},
{"type": "mrkdwn", "text": "*Docker:*\n${{ needs.docker.result == 'success' && 'β
' || needs.docker.result == 'skipped' && 'βοΈ' || 'β' }}"},
{"type": "mrkdwn", "text": "*GitHub:*\n${{ needs.github-packages.result == 'success' && 'β
' || needs.github-packages.result == 'skipped' && 'βοΈ' || 'β' }}"}
]
}
]
}
env:
# SECURITY: Webhook URL for Slack integration
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}