name: Release # ------------------------------------------------------------------------------ # Workflow Settings # ------------------------------------------------------------------------------ on: push: tags: - "v*" workflow_dispatch: inputs: release_tag: description: Existing immutable release tag to publish required: true type: string concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false permissions: {} env: CARGO_TERM_COLOR: always NIGHTLY_RUSTFMT: nightly-2026-03-28 OFFICIAL_AI_IMAGE_NAME: ghcr.io/praxis-proxy/ai OFFICIAL_AI_IMAGE_TAG: 0.7.0 OFFICIAL_AI_IMAGE_DIGEST: sha256:550661eea7e392a9f4415b836e0b2393299f26856bc02cbbc8d3301d49b759f3 OFFICIAL_AI_SOURCE_SHA: 6f058ec9d4037441b42530da4d9e54a05e5a6d34 ENROLLMENT_IMAGE_NAME: ghcr.io/praxis-proxy/grid-enrollment OPERATOR_IMAGE_NAME: ghcr.io/praxis-proxy/grid-operator GATEWAY_IMAGE_NAME: ghcr.io/praxis-proxy/grid-gateway MOCK_PROVIDERS_IMAGE_NAME: ghcr.io/praxis-proxy/grid-mock-providers OVERLAY_SYNC_IMAGE_NAME: ghcr.io/praxis-proxy/grid-overlay-sync FLEET_DASHBOARD_IMAGE_NAME: ghcr.io/praxis-proxy/grid-fleet-dashboard jobs: # ---------------------------------------------------------------------------- # Validate release identity # ---------------------------------------------------------------------------- validate: runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: contents: read outputs: tag: ${{ steps.version.outputs.tag }} version: ${{ steps.version.outputs.version }} source_sha: ${{ steps.version.outputs.source_sha }} short_sha: ${{ steps.version.outputs.short_sha }} build_date: ${{ steps.version.outputs.build_date }} prerelease: ${{ steps.version.outputs.prerelease }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} persist-credentials: false - name: Validate tag and workspace version id: version env: TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_tag || github.ref_name }} run: | # A pre-release segment is allowed, so a release candidate can be tagged. Build # metadata is not: an OCI tag cannot carry a plus. if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then echo "::error::Release tag must use the form vMAJOR.MINOR.PATCH or vMAJOR.MINOR.PATCH-PRERELEASE" exit 1 fi PRERELEASE=false if [[ "${TAG}" == *-* ]]; then PRERELEASE=true fi TAG_VERSION="${TAG#v}" CARGO_VERSION="$( perl -ne 'print $1 and exit if /^version\s*=\s*"(.+)"/' \ Cargo.toml )" if [ -z "${CARGO_VERSION}" ]; then echo "::error::Unable to read workspace version from Cargo.toml" exit 1 fi if [ "${TAG_VERSION}" != "${CARGO_VERSION}" ]; then echo "::error::Tag version (${TAG_VERSION}) does not match Cargo.toml (${CARGO_VERSION})" exit 1 fi # The gateway workspace declares its version twice because Hermeto cannot # inherit one for a workspace root package, so check both against the tag. GATEWAY_MANIFEST=crates/gateway/Cargo.toml GATEWAY_VERSIONS="$( perl -ne 'print "$1\n" if /^version\s*=\s*"(.+)"/' \ "${GATEWAY_MANIFEST}" )" if [ "$(printf '%s' "${GATEWAY_VERSIONS}" | grep -c '')" -ne 2 ]; then echo "::error::Expected two version declarations in ${GATEWAY_MANIFEST}" exit 1 fi while read -r GATEWAY_VERSION; do if [ "${TAG_VERSION}" != "${GATEWAY_VERSION}" ]; then echo "::error::Tag version (${TAG_VERSION}) does not match ${GATEWAY_MANIFEST} (${GATEWAY_VERSION})" exit 1 fi done <<< "${GATEWAY_VERSIONS}" SOURCE_SHA="$(git rev-parse HEAD)" { echo "tag=${TAG}" echo "version=${TAG_VERSION}" echo "source_sha=${SOURCE_SHA}" echo "short_sha=${SOURCE_SHA::7}" echo "build_date=$(date -u +%Y%m%d)" echo "prerelease=${PRERELEASE}" } >> "${GITHUB_OUTPUT}" # ---------------------------------------------------------------------------- # Re-run release gates from the tagged source # ---------------------------------------------------------------------------- test: needs: [validate] runs-on: ubuntu-24.04 timeout-minutes: 45 permissions: contents: read steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.validate.outputs.tag }} persist-credentials: false - name: Install nightly rustfmt run: rustup toolchain install "$NIGHTLY_RUSTFMT" --profile minimal --component rustfmt - name: Install Rust uses: dtolnay/rust-toolchain@6190aa5fb88a88ee71c12769924bbe63a9ab152e # 1.96.0 with: components: clippy - name: Install cargo-machete run: cargo install cargo-machete --locked - name: Lint run: make lint - name: Test run: make test - name: Documentation run: make doc # ---------------------------------------------------------------------------- # Publish immutable Grid images # ---------------------------------------------------------------------------- container: needs: [validate, test] runs-on: ubuntu-24.04 timeout-minutes: 45 permissions: contents: read packages: write outputs: operator_digest: ${{ steps.operator.outputs.digest }} gateway_digest: ${{ steps.gateway.outputs.digest }} mock_providers_digest: ${{ steps.mock_providers.outputs.digest }} enrollment_digest: ${{ steps.enrollment.outputs.digest }} overlay_sync_digest: ${{ steps.overlay_sync.outputs.digest }} fleet_dashboard_digest: ${{ steps.fleet_dashboard.outputs.digest }} official_ai_digest: ${{ steps.official_ai.outputs.digest }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.validate.outputs.tag }} persist-credentials: false - name: Set up Docker Buildx uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Log in to GHCR uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} - name: Verify official Praxis AI image id: official_ai run: | set -euo pipefail IMAGE_REF="${OFFICIAL_AI_IMAGE_NAME}:${OFFICIAL_AI_IMAGE_TAG}" DIGEST=$(docker buildx imagetools inspect "${IMAGE_REF}" | awk '$1 == "Digest:" { print $2; exit }') if [[ ! "${DIGEST}" =~ ^sha256:[0-9a-f]{64}$ ]]; then echo "::error::Could not resolve official AI image digest" exit 1 fi if [[ "${DIGEST}" != "${OFFICIAL_AI_IMAGE_DIGEST}" ]]; then echo "::error::Official AI tag no longer resolves to the pinned digest" exit 1 fi IMMUTABLE_REF="${OFFICIAL_AI_IMAGE_NAME}@${DIGEST}" docker pull "${IMMUTABLE_REF}" SOURCE=$(docker image inspect "${IMMUTABLE_REF}" --format '{{index .Config.Labels "org.opencontainers.image.source"}}') REVISION=$(docker image inspect "${IMMUTABLE_REF}" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}') VERSION=$(docker image inspect "${IMMUTABLE_REF}" --format '{{index .Config.Labels "org.opencontainers.image.version"}}') if [[ "${SOURCE}" != "https://github.com/praxis-proxy/ai" || \ "${REVISION}" != "${OFFICIAL_AI_SOURCE_SHA}" || \ "${VERSION}" != "${OFFICIAL_AI_IMAGE_TAG}" ]]; then echo "::error::Official AI image provenance does not match the pinned release" exit 1 fi echo "digest=${DIGEST}" >> "${GITHUB_OUTPUT}" echo "Verified ${IMAGE_REF}@${DIGEST} (${REVISION})" - name: Build and push operator id: operator uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: deploy/operator/Containerfile push: true tags: | ${{ env.OPERATOR_IMAGE_NAME }}:${{ needs.validate.outputs.tag }} ${{ env.OPERATOR_IMAGE_NAME }}:sha-${{ needs.validate.outputs.short_sha }} build-args: | GRID_GIT_COMMIT=${{ needs.validate.outputs.source_sha }} GRID_GIT_VERSION=${{ needs.validate.outputs.version }} GRID_GIT_TREE_STATE=clean GRID_BUILD_DATE=${{ needs.validate.outputs.build_date }} labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.revision=${{ needs.validate.outputs.source_sha }} org.opencontainers.image.version=${{ needs.validate.outputs.version }} cache-from: type=gha,scope=release-operator cache-to: type=gha,mode=max,scope=release-operator provenance: mode=max sbom: true - name: Build and push gateway id: gateway uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: deploy/gateway/Containerfile push: true tags: | ${{ env.GATEWAY_IMAGE_NAME }}:${{ needs.validate.outputs.tag }} ${{ env.GATEWAY_IMAGE_NAME }}:sha-${{ needs.validate.outputs.short_sha }} build-args: | GRID_GIT_COMMIT=${{ needs.validate.outputs.source_sha }} GRID_GIT_VERSION=${{ needs.validate.outputs.version }} GRID_GIT_TREE_STATE=clean GRID_BUILD_DATE=${{ needs.validate.outputs.build_date }} labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.revision=${{ needs.validate.outputs.source_sha }} org.opencontainers.image.version=${{ needs.validate.outputs.version }} cache-from: type=gha,scope=release-gateway cache-to: type=gha,mode=max,scope=release-gateway provenance: mode=max sbom: true - name: Build and push enrollment id: enrollment uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: deploy/enrollment/Containerfile push: true tags: | ${{ env.ENROLLMENT_IMAGE_NAME }}:${{ needs.validate.outputs.tag }} ${{ env.ENROLLMENT_IMAGE_NAME }}:sha-${{ needs.validate.outputs.short_sha }} build-args: | GRID_GIT_COMMIT=${{ needs.validate.outputs.source_sha }} GRID_GIT_VERSION=${{ needs.validate.outputs.version }} GRID_GIT_TREE_STATE=clean GRID_BUILD_DATE=${{ needs.validate.outputs.build_date }} labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.revision=${{ needs.validate.outputs.source_sha }} org.opencontainers.image.version=${{ needs.validate.outputs.version }} cache-from: type=gha,scope=release-enrollment cache-to: type=gha,mode=max,scope=release-enrollment provenance: mode=max sbom: true - name: Build and push mock providers id: mock_providers uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: crates/mock-providers/Containerfile push: true tags: | ${{ env.MOCK_PROVIDERS_IMAGE_NAME }}:${{ needs.validate.outputs.tag }} ${{ env.MOCK_PROVIDERS_IMAGE_NAME }}:sha-${{ needs.validate.outputs.short_sha }} build-args: | GRID_GIT_COMMIT=${{ needs.validate.outputs.source_sha }} GRID_GIT_VERSION=${{ needs.validate.outputs.version }} GRID_GIT_TREE_STATE=clean GRID_BUILD_DATE=${{ needs.validate.outputs.build_date }} labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.revision=${{ needs.validate.outputs.source_sha }} org.opencontainers.image.version=${{ needs.validate.outputs.version }} cache-from: type=gha,scope=release-mock-providers cache-to: type=gha,mode=max,scope=release-mock-providers provenance: mode=max sbom: true - name: Build and push overlay-sync id: overlay_sync uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: crates/overlay-sync/Containerfile push: true tags: | ${{ env.OVERLAY_SYNC_IMAGE_NAME }}:${{ needs.validate.outputs.tag }} ${{ env.OVERLAY_SYNC_IMAGE_NAME }}:sha-${{ needs.validate.outputs.short_sha }} build-args: | GRID_GIT_COMMIT=${{ needs.validate.outputs.source_sha }} GRID_GIT_VERSION=${{ needs.validate.outputs.version }} GRID_GIT_TREE_STATE=clean GRID_BUILD_DATE=${{ needs.validate.outputs.build_date }} labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.revision=${{ needs.validate.outputs.source_sha }} org.opencontainers.image.version=${{ needs.validate.outputs.version }} cache-from: type=gha,scope=release-overlay-sync cache-to: type=gha,mode=max,scope=release-overlay-sync provenance: mode=max sbom: true - name: Build and push fleet dashboard id: fleet_dashboard uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: crates/fleet-dashboard/Containerfile push: true tags: | ${{ env.FLEET_DASHBOARD_IMAGE_NAME }}:${{ needs.validate.outputs.tag }} ${{ env.FLEET_DASHBOARD_IMAGE_NAME }}:sha-${{ needs.validate.outputs.short_sha }} build-args: | GRID_GIT_COMMIT=${{ needs.validate.outputs.source_sha }} GRID_GIT_VERSION=${{ needs.validate.outputs.version }} GRID_GIT_TREE_STATE=clean GRID_BUILD_DATE=${{ needs.validate.outputs.build_date }} labels: | org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} org.opencontainers.image.revision=${{ needs.validate.outputs.source_sha }} org.opencontainers.image.version=${{ needs.validate.outputs.version }} cache-from: type=gha,scope=release-fleet-dashboard cache-to: type=gha,mode=max,scope=release-fleet-dashboard provenance: mode=max sbom: true # ---------------------------------------------------------------------------- # Package and publish Helm chart to GHCR OCI registry # ---------------------------------------------------------------------------- chart: needs: [validate, test] runs-on: ubuntu-24.04 timeout-minutes: 20 permissions: contents: read packages: write outputs: chart_digest: ${{ steps.push.outputs.digest }} env: # Release chart directories. The directory basename is the chart name. CHART_DIRS: >- charts/grid-operator charts/praxis-gateway charts/grid-site charts/grid-mock-providers charts/grid-enrollment crates/fleet-dashboard/charts/grid-fleet-dashboard steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.validate.outputs.tag }} persist-credentials: false - name: Install Helm uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 with: version: v3.17.3 - name: Validate chart versions match tag env: TAG: ${{ needs.validate.outputs.tag }} VERSION: ${{ needs.validate.outputs.version }} run: | for dir in ${CHART_DIRS}; do chart=$(basename "${dir}") CHART_VERSION=$(grep '^version:' "${dir}/Chart.yaml" | awk '{print $2}') CHART_APP_VERSION=$(grep '^appVersion:' "${dir}/Chart.yaml" | awk '{print $2}' | tr -d '"') if [ "$CHART_VERSION" != "$VERSION" ]; then echo "::error::${chart} version ($CHART_VERSION) != tag version ($VERSION)" exit 1 fi EXPECTED_APP_VERSION="$TAG" if [ "$chart" = praxis-gateway ]; then EXPECTED_APP_VERSION="$OFFICIAL_AI_IMAGE_TAG" fi if [ "$CHART_APP_VERSION" != "$EXPECTED_APP_VERSION" ]; then echo "::error::${chart} appVersion ($CHART_APP_VERSION) != expected application version ($EXPECTED_APP_VERSION)" exit 1 fi done - name: Lint charts run: | for chart in grid-operator praxis-gateway grid-mock-providers grid-enrollment; do helm lint "charts/${chart}" --strict done helm lint charts/grid-site --strict \ --set gridNetwork.name=release-lint \ --set gridSite.name=release-lint helm lint crates/fleet-dashboard/charts/grid-fleet-dashboard --strict - name: Render fleet dashboard chart run: crates/fleet-dashboard/hack/helm-template-test.sh - name: Package charts run: | for dir in ${CHART_DIRS}; do helm package "${dir}" done - name: Log in to GHCR run: echo "${{ github.token }}" | helm registry login ghcr.io --username "${{ github.actor }}" --password-stdin - name: Push charts to OCI registry id: push run: | for dir in ${CHART_DIRS}; do chart=$(basename "${dir}") OUTPUT=$(helm push "${chart}-${{ needs.validate.outputs.version }}.tgz" oci://ghcr.io/praxis-proxy/charts 2>&1) echo "$OUTPUT" if [ "$chart" = grid-operator ]; then DIGEST=$(echo "$OUTPUT" | grep -oP 'sha256:[0-9a-f]{64}' | head -1) echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" fi done - name: Upload chart artifact for release job uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: grid-charts path: "*.tgz" if-no-files-found: error retention-days: 1 # ---------------------------------------------------------------------------- # Publish release notes with immutable image references # ---------------------------------------------------------------------------- github-release: needs: [validate, container, chart] runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: contents: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ needs.validate.outputs.tag }} persist-credentials: false - name: Download chart artifact uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: grid-charts - name: Write image manifest env: OPERATOR_DIGEST: ${{ needs.container.outputs.operator_digest }} GATEWAY_DIGEST: ${{ needs.container.outputs.gateway_digest }} MOCK_PROVIDERS_DIGEST: ${{ needs.container.outputs.mock_providers_digest }} ENROLLMENT_DIGEST: ${{ needs.container.outputs.enrollment_digest }} OVERLAY_SYNC_DIGEST: ${{ needs.container.outputs.overlay_sync_digest }} FLEET_DASHBOARD_DIGEST: ${{ needs.container.outputs.fleet_dashboard_digest }} OFFICIAL_AI_DIGEST: ${{ needs.container.outputs.official_ai_digest }} CHART_DIGEST: ${{ needs.chart.outputs.chart_digest }} SOURCE_SHA: ${{ needs.validate.outputs.source_sha }} VERSION: ${{ needs.validate.outputs.version }} run: | cat > release-images.md <- gh release create "$TAG" ./*.tgz --verify-tag --title "Grid $TAG" --generate-notes --notes-file release-images.md ${{ needs.validate.outputs.prerelease == 'true' && '--prerelease' || '' }}