name: Release
on:
push:
tags:
- "v*"
# Prevent multiple releases from running simultaneously
concurrency:
group: release
cancel-in-progress: false
# Top-level permissions set to read-only for OSSF Scorecard Token-Permissions check
# See: https://github.com/ossf/scorecard/blob/main/docs/checks.md#token-permissions
permissions:
contents: read
packages: read
actions: read
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build_test:
name: Build and Test
runs-on: ubuntu-latest
timeout-minutes: 15 # Prevent runaway test jobs
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.11"
- name: Install deps
run: |
python -m pip install pip==24.0
pip install -e ".[test]"
- name: Tests
run: |
pytest --cov=mcp_ssh --cov-report=xml --cov-report=term
release:
name: Build, Sign, Attest, and Release
runs-on: ubuntu-latest
needs: build_test
if: startsWith(github.ref, 'refs/tags/v')
timeout-minutes: 45 # Prevent runaway release jobs
# Job-level permissions: write access only where needed
# This follows the principle of least privilege for OSSF Scorecard compliance
permissions:
contents: write # create releases + upload assets
packages: write # push to GHCR
id-token: write # OIDC for keyless signing (cosign + attestations)
attestations: write # GitHub Artifact Attestations
actions: read # some actions read workflow run context
steps:
- name: Checkout (tag)
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: ${{ github.ref }}
fetch-depth: 0
# --- GPG: import private key for signing wheels/sdists; export public key for release ---
- name: Import GPG private key
env:
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
set -euo pipefail
install -d -m 700 ~/.gnupg
printf 'pinentry-mode loopback\n' > ~/.gnupg/gpg.conf
printf 'allow-loopback-pinentry\n' > ~/.gnupg/gpg-agent.conf
gpgconf --kill gpg-agent || true
echo "$GPG_PRIVATE_KEY" | gpg --batch --yes --import
KEY_ID=$(gpg --list-secret-keys --with-colons | awk -F: '/^sec/{print $5; exit}')
if [ -z "$KEY_ID" ]; then
echo "Failed to determine GPG key id" >&2
exit 1
fi
echo "GPG_KEY_ID=$KEY_ID" >> "$GITHUB_ENV"
# warm-up to ensure passphrase works non-interactively
echo "warmup" | gpg --batch --yes --pinentry-mode loopback --passphrase "$GPG_PASSPHRASE" --local-user "$KEY_ID" --armor --sign >/dev/null
# export public key for release assets
mkdir -p dist
gpg --armor --export "$KEY_ID" > dist/GPG-PUBLIC-KEY.asc
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.11"
- name: Build Python dists
run: |
python -m pip install pip==24.0 build
python -m build --sdist --wheel
- name: Extract version
id: version
run: |
ref="${GITHUB_REF}"
echo "VERSION=${ref#refs/tags/v}" >> $GITHUB_OUTPUT
- name: GPG-sign Python dists
env:
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: |
set -euo pipefail
shopt -s nullglob
artifacts=(dist/*.whl dist/*.tar.gz)
if [ ${#artifacts[@]} -eq 0 ]; then
echo "No Python artifacts found to sign." >&2
exit 1
fi
for file in "${artifacts[@]}"; do
gpg --batch --yes \
--pinentry-mode loopback \
--passphrase "$GPG_PASSPHRASE" \
--local-user "$GPG_KEY_ID" \
--detach-sign --armor "$file"
done
# --- Container build & push (with provenance from BuildKit) ---
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- name: Set up Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Login to GHCR
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.description=A secure SSH fleet orchestrator for MCP (STDIO transport). Enforces declarative policy and audited access for Claude Desktop, Cursor, and any MCP-aware client.
org.opencontainers.image.licenses=Apache-2.0
- name: Build & push image
id: build_image
uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 # v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
provenance: true # embed buildkit provenance in the image
# Add annotations for multi-arch image manifest (required for description to appear on GHCR)
# Note: Labels are set via metadata action and Dockerfile for individual images
# Outputs annotations are required for multi-arch manifest index description
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},annotation-index.org.opencontainers.image.description=A secure SSH fleet orchestrator for MCP STDIO transport. Enforces declarative policy and audited access for Claude Desktop Cursor and MCP-aware clients,annotation-index.org.opencontainers.image.source=https://github.com/${{ github.repository }},annotation-index.org.opencontainers.image.licenses=Apache-2.0
# --- Install cosign (keyless) ---
- name: Install cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
# --- Keyless sign image (Fulcio/Rekor) ---
- name: Keyless sign image (OIDC)
env:
COSIGN_YES: "true"
run: |
set -euo pipefail
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
DIGEST="${{ steps.build_image.outputs.digest }}"
cosign sign "${IMAGE}@${DIGEST}"
echo "${IMAGE}@${DIGEST}" > "dist/container-image-${{ steps.version.outputs.VERSION }}.digest"
# --- SBOM generation (CycloneDX) for the pushed image ---
# Using Trivy to produce a local file deterministically, then attach + attest it
# Note: GitHub's native dependency graph provides Python package SBOM via UI/API
# Container SBOM is included in releases for OSSF Scorecard compliance
- name: Generate SBOM with Trivy
id: sbom
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
continue-on-error: true # Don't fail on vulnerabilities, just generate SBOM
with:
scan-type: 'image'
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build_image.outputs.digest }}
format: 'cyclonedx'
output: sbom-image-${{ steps.version.outputs.VERSION }}.cdx.json
severity: 'CRITICAL,HIGH' # Only report critical/high, don't fail
ignore-unfixed: true
# --- Move SBOM to dist for release upload ---
- name: Move SBOM to dist for release
id: move_sbom
run: |
set -euo pipefail
SBOM_FILE="sbom-image-${{ steps.version.outputs.VERSION }}.cdx.json"
if [ ! -f "$SBOM_FILE" ]; then
echo "Warning: SBOM file not found: $SBOM_FILE (Trivy may have failed)" >&2
echo "SBOM_GENERATED=false" >> "$GITHUB_OUTPUT"
exit 0
fi
mkdir -p dist
mv "$SBOM_FILE" "dist/"
echo "SBOM_GENERATED=true" >> "$GITHUB_OUTPUT"
# --- SBOM attestation (bind SBOM -> image digest) via GitHub Artifact Attestations ---
- name: Attest SBOM to image
if: steps.move_sbom.outputs.SBOM_GENERATED == 'true'
uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3
with:
subject-digest: ${{ steps.build_image.outputs.digest }}
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
sbom-path: ${{ github.workspace }}/dist/sbom-image-${{ steps.version.outputs.VERSION }}.cdx.json
# Uses GitHub Artifact Attestations (OIDC) under the hood
# --- Build provenance attestation for the CONTAINER image (GitHub Artifact Attestations) ---
- name: Attest build provenance (image)
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8 # v3
with:
subject-digest: ${{ steps.build_image.outputs.digest }}
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# GitHub Artifact Attestations store/verify provenance; downloadable via gh
# --- SLSA provenance (in-toto) for Python artifacts (+ keyless signature to Rekor) ---
- name: Prepare subjects file (sha256 list)
id: subjects
run: |
set -euo pipefail
shopt -s nullglob
artifacts=(dist/*.whl dist/*.tar.gz)
if [ ${#artifacts[@]} -eq 0 ]; then
echo "No Python artifacts available to attest." >&2
exit 1
fi
sha256sum "${artifacts[@]}" | awk '{hash=$1; sub(/^[^ ]+ +/, ""); print hash" "$0}' > dist/subjects.sha256
- name: Generate SLSA provenance (in-toto) for Python artifacts
id: slsa_provenance
env:
VERSION: ${{ steps.version.outputs.VERSION }}
REPOSITORY: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
RUN_NUMBER: ${{ github.run_number }}
RUN_ATTEMPT: ${{ github.run_attempt }}
GITHUB_SHA: ${{ github.sha }}
GITHUB_REF: ${{ github.ref }}
run: |
python <<'PY'
import json, os
from datetime import datetime, timezone
entries = []
with open("dist/subjects.sha256", "r", encoding="utf-8") as fh:
for line in fh:
digest, path = line.strip().split(None, 1)
entries.append((digest, path))
if not entries:
raise SystemExit("No subjects to include in provenance.")
now = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
statement = {
"_type": "https://in-toto.io/Statement/v0.1",
"subject": [{"name": p, "digest": {"sha256": d}} for d, p in entries],
"predicateType": "https://slsa.dev/provenance/v0.2",
"predicate": {
"buildDefinition": {
"buildType": f"https://github.com/{os.environ['REPOSITORY']}/.github/workflows/release.yml@v0",
"externalParameters": {"ref": os.environ["GITHUB_REF"], "version": os.environ["VERSION"]},
"internalParameters": {"run_number": os.environ["RUN_NUMBER"], "run_attempt": os.environ["RUN_ATTEMPT"]},
"resolvedDependencies": [
{"uri": f"git+https://github.com/{os.environ['REPOSITORY']}.git@{os.environ['GITHUB_SHA']}",
"digest": {"sha1": os.environ["GITHUB_SHA"]}}
],
},
"runDetails": {
"builder": {"id": f"https://github.com/{os.environ['REPOSITORY']}/actions/runs/{os.environ['RUN_ID']}"},
"metadata": {"invocationId": f"{os.environ['RUN_ID']}:{os.environ['RUN_ATTEMPT']}",
"startedOn": now, "finishedOn": now}
}
}
}
out = f"dist/slsa-provenance-python-{os.environ['VERSION']}.intoto.jsonl"
with open(out, "w", encoding="utf-8") as f:
f.write(json.dumps(statement) + "\n")
PY
# --- Keyless sign SLSA provenance (upload to Rekor) ---
- name: Keyless sign SLSA provenance
env:
COSIGN_YES: "true"
run: |
set -euo pipefail
PROVENANCE_FILE="dist/slsa-provenance-python-${{ steps.version.outputs.VERSION }}.intoto.jsonl"
BUNDLE_FILE="dist/slsa-provenance-python-${{ steps.version.outputs.VERSION }}.sigstore"
# For keyless signing, cosign v3+ requires --bundle flag to output bundle (signature + certificate)
# Bundle contains both signature and certificate in JSON format
if [ ! -f "$PROVENANCE_FILE" ]; then
echo "Provenance file not found: $PROVENANCE_FILE" >&2
exit 1
fi
cosign sign-blob --yes --bundle "$BUNDLE_FILE" "$PROVENANCE_FILE"
# Verify bundle file was created
if [ ! -s "$BUNDLE_FILE" ]; then
echo "Failed to generate bundle file" >&2
exit 1
fi
# --- Optional: checksums for release artifacts (easy offline verification) ---
- name: Checksums for release artifacts
run: |
set -euo pipefail
# Generate checksums (SHA256SUMS.txt will include itself, which is fine)
cd dist
sha256sum * > SHA256SUMS.txt
# Verify the file was created and has content
if [ ! -s SHA256SUMS.txt ]; then
echo "Failed to generate checksums file" >&2
exit 1
fi
# --- Minimal changelog for the Release body (optional) ---
- name: Generate minimal changelog
id: changelog
run: |
if [ -f CHANGELOG.md ]; then
ver="${{ steps.version.outputs.VERSION }}"
tmp=$(mktemp)
awk -v ver="$ver" '
$0 ~ "^## \\[" ver "\\]" {capture=1; next}
capture && $0 ~ "^## \\[" {capture=0}
capture
' CHANGELOG.md > "$tmp"
if [ -s "$tmp" ]; then
{
echo "CHANGELOG<<EOF"
cat "$tmp"
echo "EOF"
} >> "$GITHUB_OUTPUT"
else
echo "CHANGELOG=Release $ver" >> $GITHUB_OUTPUT
fi
rm -f "$tmp"
else
echo "CHANGELOG=Release ${{ steps.version.outputs.VERSION }}" >> $GITHUB_OUTPUT
fi
# --- Create the GitHub Release with all assets ---
# Note: This workflow meets OSSF Scorecard Signed-Releases requirements (10/10):
# - GPG signatures (*.asc) for Python artifacts
# - SLSA provenance (*.intoto.jsonl) - required for maximum score
# - Cosign bundles (*.sigstore) for provenance signatures
# See: https://github.com/ossf/scorecard/blob/main/docs/checks.md#signed-releases
- name: Create GitHub Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with:
body: ${{ steps.changelog.outputs.CHANGELOG }}
draft: false
prerelease: false
files: |
dist/*
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}