name: Publish Docker Images run-name: Publish Docker Images ${{ inputs.tag }} # Triggered by create-release.yml after staging images pass tests. # Builds multi-arch images (amd64+arm64) from source and publishes to Docker Hub and GHCR. on: workflow_dispatch: inputs: tag: description: 'Release tag (e.g., v4.34.0)' required: true type: string concurrency: group: docker-publish-${{ inputs.tag }} cancel-in-progress: false permissions: contents: read jobs: publish: runs-on: ubuntu-24.04 timeout-minutes: 60 # Increased for multi-arch builds with QEMU permissions: contents: read packages: write id-token: write attestations: write steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ inputs.tag }} fetch-depth: 0 fetch-tags: true - name: Extract version from release tag id: version run: | TAG="${{ inputs.tag }}" VERSION="${TAG#v}" # Detect if this is a prerelease (RC, alpha, beta) IS_PRERELEASE="false" if [[ "$VERSION" =~ -rc\.[0-9]+$ ]] || [[ "$VERSION" =~ -alpha\.[0-9]+$ ]] || [[ "$VERSION" =~ -beta\.[0-9]+$ ]]; then IS_PRERELEASE="true" echo "Detected prerelease version - will NOT update :latest tag" fi echo "tag=${TAG}" >> $GITHUB_OUTPUT echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "is_prerelease=${IS_PRERELEASE}" >> $GITHUB_OUTPUT echo "Publishing Docker images for ${TAG} (prerelease: ${IS_PRERELEASE})" - name: Validate release line policy env: TAG: ${{ steps.version.outputs.tag }} IS_PRERELEASE: ${{ steps.version.outputs.is_prerelease }} run: | set -euo pipefail VERSION="${TAG#v}" REQUIRED_BRANCH="$(python3 scripts/release_control/control_plane.py --branch-for-version "${VERSION}")" if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then git fetch --prune --unshallow origin fi git fetch --prune origin "${REQUIRED_BRANCH}" --tags if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then echo "::error::Tag ${TAG} does not exist in repository tags." exit 1 fi TAG_COMMIT="$(git rev-list -n1 "refs/tags/${TAG}")" if ! git merge-base --is-ancestor "$TAG_COMMIT" "origin/${REQUIRED_BRANCH}"; then echo "::error::Tag ${TAG} is not reachable from origin/${REQUIRED_BRANCH}. Refusing cross-line Docker publish." exit 1 fi if [ "$IS_PRERELEASE" != "true" ]; then BASE_VERSION="${TAG#v}" BASE_VERSION="${BASE_VERSION%%-*}" mapfile -t RC_TAGS < <(git tag -l "v${BASE_VERSION}-rc.*" --sort=-version:refname) if [ "${#RC_TAGS[@]}" -eq 0 ]; then echo "::error::Stable tag ${TAG} has no matching prerelease tags for base version ${BASE_VERSION}." exit 1 fi MATCHED_RC="" for rc_tag in "${RC_TAGS[@]}"; do rc_commit="$(git rev-list -n1 "refs/tags/${rc_tag}")" if git merge-base --is-ancestor "$rc_commit" "$TAG_COMMIT"; then MATCHED_RC="$rc_tag" break fi done if [ -z "$MATCHED_RC" ]; then echo "::error::Stable tag ${TAG} does not descend from any matching prerelease tag for base version ${BASE_VERSION}." exit 1 fi echo "[OK] ${TAG} descends from prerelease ${MATCHED_RC}" fi echo "[OK] ${TAG} validated against release line ${REQUIRED_BRANCH}" - name: Set up QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Log in to Docker Hub uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Log in to GHCR uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Derive license public key Docker cache key id: license_key_cache env: PULSE_LICENSE_PUBLIC_KEY: ${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} run: | set -euo pipefail decoded_len="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | wc -c | tr -d ' ')" if [ "${decoded_len}" != "32" ]; then echo "PULSE_LICENSE_PUBLIC_KEY must decode to 32 bytes." >&2 exit 1 fi key_sha256="$(printf '%s' "${PULSE_LICENSE_PUBLIC_KEY}" | base64 -d | sha256sum | awk '{print $1}')" echo "sha256=${key_sha256}" >> "${GITHUB_OUTPUT}" - name: Build and push Pulse server image (multi-arch) id: build_server_image uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . target: runtime platforms: linux/amd64,linux/arm64 push: true provenance: mode=max sbom: true cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/pulse:buildcache build-args: | VERSION=${{ steps.version.outputs.tag }} PULSE_LICENSE_PUBLIC_KEY_SHA256=${{ steps.license_key_cache.outputs.sha256 }} PULSE_UPDATE_SIGNING_PUBLIC_KEY=${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} secrets: | pulse_license_public_key=${{ secrets.PULSE_LICENSE_PUBLIC_KEY }} pulse_update_signing_key=${{ secrets.PULSE_UPDATE_SIGNING_KEY }} tags: | rcourtman/pulse:${{ steps.version.outputs.tag }} rcourtman/pulse:${{ steps.version.outputs.version }} ${{ steps.version.outputs.is_prerelease != 'true' && 'rcourtman/pulse:latest' || '' }} ghcr.io/${{ github.repository_owner }}/pulse:${{ steps.version.outputs.tag }} ghcr.io/${{ github.repository_owner }}/pulse:${{ steps.version.outputs.version }} ${{ steps.version.outputs.is_prerelease != 'true' && format('ghcr.io/{0}/pulse:latest', github.repository_owner) || '' }} - name: Attest Pulse server image on Docker Hub uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4 with: subject-name: docker.io/rcourtman/pulse subject-digest: ${{ steps.build_server_image.outputs.digest }} push-to-registry: true create-storage-record: false - name: Attest Pulse server image on GHCR uses: actions/attest@59d89421af93a897026c735860bf21b6eb4f7b26 # v4 with: subject-name: ghcr.io/${{ github.repository_owner }}/pulse subject-digest: ${{ steps.build_server_image.outputs.digest }} push-to-registry: true create-storage-record: false - name: Output image information run: | IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}" echo "✅ Docker images published successfully!" echo "" echo "Server images (linux/amd64, linux/arm64):" echo " - rcourtman/pulse:${{ steps.version.outputs.tag }}" echo " - rcourtman/pulse:${{ steps.version.outputs.version }}" if [ "$IS_PRERELEASE" != "true" ]; then echo " - rcourtman/pulse:latest" fi echo "" echo "Pulse Agent binaries ship as release assets, not as a Docker image." echo "See the GitHub release page for pulse-agent-{darwin,freebsd,linux,windows}-{amd64,arm64,...}." echo "" if [ "$IS_PRERELEASE" = "true" ]; then echo "Note: :latest tags were NOT updated (this is a prerelease)" fi