# 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] " 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