name: Build and Publish Docker Image

on:
  push:
    tags:
      - 'v*'
  workflow_dispatch:

concurrency:
  group: docker-publish
  cancel-in-progress: true

jobs:
  push_to_registry:
    name: Push Docker image to Docker Hub and GHCR
    runs-on: ubuntu-latest
    timeout-minutes: 30
    # DOCKERHUB_USERNAME and DOCKERHUB_TOKEN live in the `production` environment,
    # not repo-wide secrets. Any future workflow that tries to push to Docker Hub
    # without declaring this environment will fail to resolve the credentials,
    # which is exactly the blast-radius reduction we want. Adding a required
    # reviewer to the environment in repo settings also turns every release into
    # a manual-approval gate without any workflow change.
    environment: production
    permissions:
      contents: write
      # Required for cosign keyless signing via GitHub OIDC and for uploading
      # SBOM/VEX files to GitHub Releases via softprops/action-gh-release.
      id-token: write
      # Required to push the multi-arch image to ghcr.io/studio-saelix/sencho
      # using the auto-provisioned GITHUB_TOKEN. Docker Hub credentials still
      # come from the production environment above.
      packages: write
    steps:
      - name: Check out the repo
        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6

      - name: Set up QEMU
        uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4
        with:
          platforms: arm64

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4

      # Computed once per job run. Feeds Dockerfile's APK_CACHE_BUST arg so
      # the `apk upgrade` layer rebuilds at least once per calendar day, even
      # when every other input to the layer is cached. See Dockerfile:115-122
      # for the rationale. Both the pre-publish scan build and the multi-arch
      # push build below consume this so Trivy scans a fresh layer.
      - name: Compute daily apk cache bust value
        id: apk-bust
        run: echo "date=$(date -u +%Y-%m-%d)" >> "$GITHUB_OUTPUT"

      - name: Log in to Docker Hub
        uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4
        with:
          registry: ghcr.io
          # repository_owner resolves to a fixed value (studio-saelix) on every
          # event type. github.actor varies (a maintainer on workflow_dispatch,
          # github-actions[bot] on the release tag push) and is just a label;
          # GITHUB_TOKEN is what actually authenticates.
          username: ${{ github.repository_owner }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Install cosign
        uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2

      - name: Extract metadata (tags, labels) for Docker
        id: meta
        uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
        with:
          # Publish the same manifest under both registries so users on either
          # platform can pull. Docker Hub remains primary for discoverability;
          # GHCR mirrors at ghcr.io/studio-saelix/sencho with identical tags.
          images: |
            saelix/sencho
            ghcr.io/studio-saelix/sencho
          # On a v-tag push we publish:
          #   latest           always points at the newest release
          #   X.Y.Z            the immutable semver tag
          #   X.Y              moving minor tag for users who want latest patch
          # The pre-1.0 constraint hides the {{major}}-only tag until we ship 1.0,
          # since on 0.x every minor is potentially breaking.
          tags: |
            type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
            type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
            type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}

      # Build an amd64-only variant into the local daemon first so Trivy can
      # scan the exact release artifact before it is tagged and pushed. This
      # keeps vulnerable releases out of the `latest` and semver tags that
      # users actually pull. Because the push-build step that follows runs in
      # the same job against the same buildkit daemon, it reuses this build's
      # layers from the daemon's in-memory cache (observed wall-time: the
      # push-build typically finishes faster than the scan-build despite
      # producing multi-arch output). The `cache-from` pull is just a
      # cold-start fallback for the first-ever run or when the buildkit daemon
      # is fresh; we intentionally do NOT write `cache-to` here because the
      # push-build below writes a strictly better (multi-arch, mode=max)
      # cache entry moments later. The tag lives in the `localhost/` namespace
      # so a future `push: false` -> `push: true` mistake cannot publish it.
      # Scanning only amd64 is a defensible proxy for the multi-arch release:
      # distro package CVEs are arch-agnostic at the manifest level, and
      # arch-specific container-relevant CVEs are extraordinarily rare.
      - name: Build release image for pre-publish scan (amd64, loaded)
        uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
        with:
          context: .
          push: false
          load: true
          platforms: linux/amd64
          tags: localhost/sencho:release-scan
          cache-from: type=registry,ref=saelix/sencho:buildcache
          build-args: |
            APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}

      - name: Re-scan release image for vulnerabilities (Trivy)
        # Gates the release on the same HIGH/CRITICAL policy as the PR scan.
        # CVEs suppressed via the OpenVEX document (trivy.yaml -> security/vex/
        # sencho.openvex.json) are the single source of truth across PR CI and
        # release CI.
        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
        with:
          image-ref: localhost/sencho:release-scan
          exit-code: '1'
          severity: 'CRITICAL,HIGH'
          format: 'table'
          trivy-version: 'latest'
          trivy-config: trivy.yaml

      # Start the scanned image headless on the runner and poll /api/health
      # until it returns 200. Catches entrypoint regressions, native module
      # ABI breakage, and first-boot crashes on the exact artifact that the
      # multi-arch push below will republish moments later. Intentionally
      # placed BEFORE the publish step so a smoke failure does not move
      # `latest` or the semver tags on Docker Hub. The health endpoint is
      # public and returns before any DB or Docker-socket work, so the
      # container only needs a free port, no env vars, and no volume mounts.
      - name: Smoke test release image (pre-publish)
        run: |
          set -euo pipefail
          # Verify source-built Docker CLI and Compose binaries are present and functional.
          # Both checks run in one container to avoid double start-up overhead.
          docker run --rm --entrypoint sh localhost/sencho:release-scan -c \
            'docker --version && docker compose version'

          # Not using --rm so that a crashed container sticks around long
          # enough for `docker logs` in the trap to surface the stack trace.
          # The trap force-removes it explicitly whether it is still running
          # or already exited.
          docker run -d --name sencho-smoke -p 1852:1852 localhost/sencho:release-scan
          trap 'docker logs sencho-smoke 2>&1 || true; docker rm -f sencho-smoke >/dev/null 2>&1 || true' EXIT
          for i in $(seq 1 30); do
            if curl -fsS http://localhost:1852/api/health >/dev/null 2>&1; then
              echo "Container healthy after ${i}s"
              curl -s http://localhost:1852/api/health
              exit 0
            fi
            sleep 1
          done
          echo "FAILED: /api/health did not become ready within 30s"
          exit 1

      - name: Build and push Docker image
        id: build
        uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7
        with:
          context: .
          push: true
          platforms: linux/amd64,linux/arm64
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=registry,ref=saelix/sencho:buildcache
          cache-to: type=registry,ref=saelix/sencho:buildcache,mode=max
          build-args: |
            APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }}
          # SBOM + provenance attestations are embedded as OCI referrers on the
          # published image. Inspect with: docker buildx imagetools inspect <img>
          sbom: true
          provenance: mode=max

      - name: Sign published image with cosign (keyless)
        # Signs every tag produced by metadata-action using the ambient GitHub
        # OIDC token. No private keys, no secrets. Users verify with:
        #   cosign verify saelix/sencho:<tag> \
        #     --certificate-identity-regexp "https://github.com/studio-saelix/sencho/.*" \
        #     --certificate-oidc-issuer https://token.actions.githubusercontent.com
        # Each ref is pinned to the immutable digest so signatures are bound to
        # the exact manifest, not the mutable tag pointer.
        env:
          TAGS: ${{ steps.meta.outputs.tags }}
          DIGEST: ${{ steps.build.outputs.digest }}
        run: |
          # Build a single cosign invocation with every tag pinned to the digest.
          # All tags resolve to the same manifest, so one call signs them all and
          # saves N-1 Rekor/Fulcio round trips. On workflow_dispatch $TAGS is empty
          # and the refs array stays empty, so we no-op cleanly.
          refs=()
          while IFS= read -r tag; do
            [ -n "$tag" ] || continue
            refs+=("${tag}@${DIGEST}")
          done <<< "$TAGS"
          if [ ${#refs[@]} -gt 0 ]; then
            cosign sign --yes "${refs[@]}"
          else
            echo "No tags to sign (likely a workflow_dispatch run on a non-tag ref)."
          fi

      - name: Generate CycloneDX SBOM
        # syft scans the published manifest by digest for richer package
        # extraction than the BuildKit-native SBOM. Also installs syft into
        # PATH so the SPDX step below can reuse the OCI layer cache.
        uses: anchore/sbom-action@e22c389904149dbc22b58101806040fa8d37a610 # v0.20.2
        with:
          image: saelix/sencho@${{ steps.build.outputs.digest }}
          format: cyclonedx-json
          output-file: sbom.cdx.json
          upload-artifact: false

      - name: Generate SPDX SBOM
        # Reuses the syft binary and OCI layer cache from the prior step.
        run: |
          syft saelix/sencho@${{ steps.build.outputs.digest }} \
            -o spdx-json=sbom.spdx.json

      - name: Attest SBOMs and VEX with cosign (keyless)
        # Attaches CycloneDX SBOM, SPDX SBOM, and OpenVEX document as signed
        # OCI referrer attestations on the published digest, separately to each
        # registry path. Attestations live next to the image manifest in the
        # registry, so a verifier pulling from GHCR cannot resolve attestations
        # written only to Docker Hub. Verification commands are documented in
        # docs/operations/verifying-images.mdx.
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
        run: |
          for IMAGE_REF in \
            "saelix/sencho@${DIGEST}" \
            "ghcr.io/studio-saelix/sencho@${DIGEST}"
          do
            cosign attest --yes --predicate sbom.cdx.json  --type cyclonedx "${IMAGE_REF}"
            cosign attest --yes --predicate sbom.spdx.json --type spdxjson  "${IMAGE_REF}"
            cosign attest --yes --predicate security/vex/sencho.openvex.json --type openvex "${IMAGE_REF}"
          done

      - name: Upload SBOM and VEX to GitHub Release
        # Fallback for consumers who do not use cosign; files are also
        # available as signed OCI attestations via the attest step above.
        if: startsWith(github.ref, 'refs/tags/v')
        uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0.3.3.0.0
        with:
          files: |
            sbom.cdx.json
            sbom.spdx.json
            security/vex/sencho.openvex.json
