Files
git.stella-ops.org/.gitea/workflows/service-release.yml

491 lines
18 KiB
YAML

# Service Release Pipeline
# Sprint: CI/CD Enhancement - Per-Service Auto-Versioning
#
# Purpose: Automated per-service release pipeline with semantic versioning
# and Docker tag format: {semver}+{YYYYMMDDHHmmss}
#
# Triggers:
# - Tag: service-{name}-v{semver} (e.g., service-scanner-v1.2.3)
# - Manual dispatch with service selection and bump type
name: Service Release
on:
push:
tags:
- 'service-*-v*'
workflow_dispatch:
inputs:
service:
description: 'Service to release'
required: true
type: choice
options:
- authority
- attestor
- concelier
- scanner
- policy
- signer
- excititor
- gateway
- scheduler
- cli
- orchestrator
- notify
- sbomservice
- vexhub
- evidencelocker
bump_type:
description: 'Version bump type'
required: true
type: choice
options:
- patch
- minor
- major
default: 'patch'
dry_run:
description: 'Dry run (no actual release)'
required: false
type: boolean
default: false
skip_tests:
description: 'Skip tests (use with caution)'
required: false
type: boolean
default: false
env:
DOTNET_VERSION: '10.0.100'
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
REGISTRY: git.stella-ops.org/stella-ops.org
SYFT_VERSION: '1.21.0'
jobs:
# ===========================================================================
# Parse tag or manual inputs to determine service and version
# ===========================================================================
resolve:
name: Resolve Release Parameters
runs-on: ubuntu-latest
outputs:
service: ${{ steps.resolve.outputs.service }}
bump_type: ${{ steps.resolve.outputs.bump_type }}
current_version: ${{ steps.resolve.outputs.current_version }}
new_version: ${{ steps.resolve.outputs.new_version }}
docker_tag: ${{ steps.resolve.outputs.docker_tag }}
is_dry_run: ${{ steps.resolve.outputs.is_dry_run }}
skip_tests: ${{ steps.resolve.outputs.skip_tests }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Resolve parameters
id: resolve
run: |
if [[ "${{ github.event_name }}" == "push" ]]; then
# Parse tag: service-{name}-v{version}
TAG="${GITHUB_REF#refs/tags/}"
echo "Processing tag: $TAG"
if [[ "$TAG" =~ ^service-([a-z]+)-v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
SERVICE="${BASH_REMATCH[1]}"
VERSION="${BASH_REMATCH[2]}"
BUMP_TYPE="explicit"
else
echo "::error::Invalid tag format: $TAG (expected: service-{name}-v{semver})"
exit 1
fi
IS_DRY_RUN="false"
SKIP_TESTS="false"
else
# Manual dispatch
SERVICE="${{ github.event.inputs.service }}"
BUMP_TYPE="${{ github.event.inputs.bump_type }}"
VERSION="" # Will be calculated
IS_DRY_RUN="${{ github.event.inputs.dry_run }}"
SKIP_TESTS="${{ github.event.inputs.skip_tests }}"
fi
# Read current version
CURRENT_VERSION=$(.gitea/scripts/release/read-service-version.sh "$SERVICE")
echo "Current version: $CURRENT_VERSION"
# Calculate new version
if [[ -n "$VERSION" ]]; then
NEW_VERSION="$VERSION"
else
NEW_VERSION=$(python3 .gitea/scripts/release/bump-service-version.py "$SERVICE" "$BUMP_TYPE" --output-version)
fi
echo "New version: $NEW_VERSION"
# Generate Docker tag
DOCKER_TAG=$(.gitea/scripts/release/generate-docker-tag.sh --version "$NEW_VERSION")
echo "Docker tag: $DOCKER_TAG"
# Set outputs
echo "service=$SERVICE" >> $GITHUB_OUTPUT
echo "bump_type=$BUMP_TYPE" >> $GITHUB_OUTPUT
echo "current_version=$CURRENT_VERSION" >> $GITHUB_OUTPUT
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "docker_tag=$DOCKER_TAG" >> $GITHUB_OUTPUT
echo "is_dry_run=$IS_DRY_RUN" >> $GITHUB_OUTPUT
echo "skip_tests=$SKIP_TESTS" >> $GITHUB_OUTPUT
- name: Summary
run: |
echo "## Release Parameters" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Parameter | Value |" >> $GITHUB_STEP_SUMMARY
echo "|-----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Service | ${{ steps.resolve.outputs.service }} |" >> $GITHUB_STEP_SUMMARY
echo "| Current Version | ${{ steps.resolve.outputs.current_version }} |" >> $GITHUB_STEP_SUMMARY
echo "| New Version | ${{ steps.resolve.outputs.new_version }} |" >> $GITHUB_STEP_SUMMARY
echo "| Docker Tag | ${{ steps.resolve.outputs.docker_tag }} |" >> $GITHUB_STEP_SUMMARY
echo "| Dry Run | ${{ steps.resolve.outputs.is_dry_run }} |" >> $GITHUB_STEP_SUMMARY
# ===========================================================================
# Update version in source files
# ===========================================================================
update-version:
name: Update Version
runs-on: ubuntu-latest
needs: [resolve]
if: needs.resolve.outputs.is_dry_run != 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITEA_TOKEN }}
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Update version
run: |
python3 .gitea/scripts/release/bump-service-version.py \
"${{ needs.resolve.outputs.service }}" \
"${{ needs.resolve.outputs.new_version }}" \
--docker-tag "${{ needs.resolve.outputs.docker_tag }}" \
--git-sha "${{ github.sha }}"
- name: Commit version update
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add src/Directory.Versions.props devops/releases/service-versions.json
if git diff --cached --quiet; then
echo "No version changes to commit"
else
git commit -m "chore(${{ needs.resolve.outputs.service }}): release v${{ needs.resolve.outputs.new_version }}
Docker tag: ${{ needs.resolve.outputs.docker_tag }}
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
git push
fi
# ===========================================================================
# Build and test the service
# ===========================================================================
build-test:
name: Build and Test
runs-on: ubuntu-latest
needs: [resolve, update-version]
if: always() && (needs.update-version.result == 'success' || needs.update-version.result == 'skipped')
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.ref }}
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
include-prerelease: true
- name: Restore dependencies
run: dotnet restore src/StellaOps.sln
- name: Build solution
run: |
dotnet build src/StellaOps.sln \
--configuration Release \
--no-restore \
-p:StellaOpsServiceVersion=${{ needs.resolve.outputs.new_version }}
- name: Run tests
if: needs.resolve.outputs.skip_tests != 'true'
run: |
SERVICE="${{ needs.resolve.outputs.service }}"
SERVICE_PASCAL=$(echo "$SERVICE" | sed -r 's/(^|-)(\w)/\U\2/g')
# Find and run tests for this service
TEST_PROJECTS=$(find src -path "*/${SERVICE_PASCAL}/*" -name "*.Tests.csproj" -o -path "*/${SERVICE_PASCAL}*Tests*" -name "*.csproj" | head -20)
if [[ -n "$TEST_PROJECTS" ]]; then
echo "Running tests for: $TEST_PROJECTS"
echo "$TEST_PROJECTS" | xargs -I{} dotnet test {} --configuration Release --no-build --verbosity normal
else
echo "::warning::No test projects found for service: $SERVICE"
fi
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-${{ needs.resolve.outputs.service }}
path: |
src/**/bin/Release/**/*.dll
src/**/bin/Release/**/*.exe
src/**/bin/Release/**/*.pdb
retention-days: 7
# ===========================================================================
# Build and publish Docker image
# ===========================================================================
publish-container:
name: Publish Container
runs-on: ubuntu-latest
needs: [resolve, build-test]
if: needs.resolve.outputs.is_dry_run != 'true'
outputs:
image_digest: ${{ steps.push.outputs.digest }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Determine Dockerfile path
id: dockerfile
run: |
SERVICE="${{ needs.resolve.outputs.service }}"
SERVICE_PASCAL=$(echo "$SERVICE" | sed -r 's/(^|-)(\w)/\U\2/g')
# Look for service-specific Dockerfile
DOCKERFILE_PATHS=(
"devops/docker/${SERVICE}/Dockerfile"
"devops/docker/${SERVICE_PASCAL}/Dockerfile"
"src/${SERVICE_PASCAL}/Dockerfile"
"src/${SERVICE_PASCAL}/StellaOps.${SERVICE_PASCAL}.WebService/Dockerfile"
"devops/docker/platform/Dockerfile"
)
for path in "${DOCKERFILE_PATHS[@]}"; do
if [[ -f "$path" ]]; then
echo "dockerfile=$path" >> $GITHUB_OUTPUT
echo "Found Dockerfile: $path"
exit 0
fi
done
echo "::error::No Dockerfile found for service: $SERVICE"
exit 1
- name: Build and push image
id: push
uses: docker/build-push-action@v5
with:
context: .
file: ${{ steps.dockerfile.outputs.dockerfile }}
push: true
tags: |
${{ env.REGISTRY }}/${{ needs.resolve.outputs.service }}:${{ needs.resolve.outputs.docker_tag }}
${{ env.REGISTRY }}/${{ needs.resolve.outputs.service }}:${{ needs.resolve.outputs.new_version }}
${{ env.REGISTRY }}/${{ needs.resolve.outputs.service }}:latest
labels: |
org.opencontainers.image.title=${{ needs.resolve.outputs.service }}
org.opencontainers.image.version=${{ needs.resolve.outputs.new_version }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
com.stellaops.service.name=${{ needs.resolve.outputs.service }}
com.stellaops.service.version=${{ needs.resolve.outputs.new_version }}
com.stellaops.docker.tag=${{ needs.resolve.outputs.docker_tag }}
build-args: |
VERSION=${{ needs.resolve.outputs.new_version }}
GIT_SHA=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Image summary
run: |
echo "## Container Image" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Image | \`${{ env.REGISTRY }}/${{ needs.resolve.outputs.service }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Tag | \`${{ needs.resolve.outputs.docker_tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Digest | \`${{ steps.push.outputs.digest }}\` |" >> $GITHUB_STEP_SUMMARY
# ===========================================================================
# Generate SBOM
# ===========================================================================
generate-sbom:
name: Generate SBOM
runs-on: ubuntu-latest
needs: [resolve, publish-container]
if: needs.resolve.outputs.is_dry_run != 'true'
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Syft
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | \
sh -s -- -b /usr/local/bin v${{ env.SYFT_VERSION }}
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Generate SBOM
run: |
IMAGE="${{ env.REGISTRY }}/${{ needs.resolve.outputs.service }}:${{ needs.resolve.outputs.docker_tag }}"
syft "$IMAGE" \
--output cyclonedx-json=sbom.cyclonedx.json \
--output spdx-json=sbom.spdx.json
echo "Generated SBOMs for: $IMAGE"
- name: Upload SBOM artifacts
uses: actions/upload-artifact@v4
with:
name: sbom-${{ needs.resolve.outputs.service }}-${{ needs.resolve.outputs.new_version }}
path: |
sbom.cyclonedx.json
sbom.spdx.json
retention-days: 90
# ===========================================================================
# Sign artifacts with Cosign
# ===========================================================================
sign-artifacts:
name: Sign Artifacts
runs-on: ubuntu-latest
needs: [resolve, publish-container, generate-sbom]
if: needs.resolve.outputs.is_dry_run != 'true'
steps:
- name: Install Cosign
uses: sigstore/cosign-installer@v3
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Sign container image
if: env.COSIGN_PRIVATE_KEY_B64 != ''
env:
COSIGN_PRIVATE_KEY_B64: ${{ secrets.COSIGN_PRIVATE_KEY_B64 }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$COSIGN_PRIVATE_KEY_B64" | base64 -d > cosign.key
IMAGE="${{ env.REGISTRY }}/${{ needs.resolve.outputs.service }}@${{ needs.publish-container.outputs.image_digest }}"
cosign sign --key cosign.key \
-a "service=${{ needs.resolve.outputs.service }}" \
-a "version=${{ needs.resolve.outputs.new_version }}" \
-a "docker-tag=${{ needs.resolve.outputs.docker_tag }}" \
"$IMAGE"
rm -f cosign.key
echo "Signed: $IMAGE"
- name: Download SBOM
uses: actions/download-artifact@v4
with:
name: sbom-${{ needs.resolve.outputs.service }}-${{ needs.resolve.outputs.new_version }}
path: sbom/
- name: Attach SBOM to image
if: env.COSIGN_PRIVATE_KEY_B64 != ''
env:
COSIGN_PRIVATE_KEY_B64: ${{ secrets.COSIGN_PRIVATE_KEY_B64 }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$COSIGN_PRIVATE_KEY_B64" | base64 -d > cosign.key
IMAGE="${{ env.REGISTRY }}/${{ needs.resolve.outputs.service }}@${{ needs.publish-container.outputs.image_digest }}"
cosign attach sbom --sbom sbom/sbom.cyclonedx.json "$IMAGE"
cosign sign --key cosign.key --attachment sbom "$IMAGE"
rm -f cosign.key
# ===========================================================================
# Release summary
# ===========================================================================
summary:
name: Release Summary
runs-on: ubuntu-latest
needs: [resolve, build-test, publish-container, generate-sbom, sign-artifacts]
if: always()
steps:
- name: Generate summary
run: |
echo "# Service Release: ${{ needs.resolve.outputs.service }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Release Details" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Service | ${{ needs.resolve.outputs.service }} |" >> $GITHUB_STEP_SUMMARY
echo "| Version | ${{ needs.resolve.outputs.new_version }} |" >> $GITHUB_STEP_SUMMARY
echo "| Previous | ${{ needs.resolve.outputs.current_version }} |" >> $GITHUB_STEP_SUMMARY
echo "| Docker Tag | \`${{ needs.resolve.outputs.docker_tag }}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Git SHA | \`${{ github.sha }}\` |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Job Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Build & Test | ${{ needs.build-test.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Publish Container | ${{ needs.publish-container.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Generate SBOM | ${{ needs.generate-sbom.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Sign Artifacts | ${{ needs.sign-artifacts.result }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ needs.resolve.outputs.is_dry_run }}" == "true" ]]; then
echo "⚠️ **This was a dry run. No artifacts were published.**" >> $GITHUB_STEP_SUMMARY
else
echo "## Pull Image" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ env.REGISTRY }}/${{ needs.resolve.outputs.service }}:${{ needs.resolve.outputs.docker_tag }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
fi