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 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: read # Required for cosign keyless signing via GitHub OIDC. id-token: write steps: - name: Check out the repo uses: actions/checkout@v6 - name: Set up QEMU uses: docker/setup-qemu-action@v4 with: platforms: arm64 - name: Set up Docker Buildx uses: docker/setup-buildx-action@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@v4 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Install cosign uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v6 with: images: 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@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. # Entries in .trivyignore at the repo root are honored so the acknowledged # CVE list is the single source of truth across PR CI and release CI. uses: aquasecurity/trivy-action@57a97c7e7821a5776cebc9bb87c984fa69cba8f1 # v0.35.0 with: image-ref: localhost/sencho:release-scan exit-code: '1' severity: 'CRITICAL,HIGH' format: 'table' # 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 # 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 3000:3000 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:3000/api/health >/dev/null 2>&1; then echo "Container healthy after ${i}s" curl -s http://localhost:3000/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@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 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: \ # --certificate-identity-regexp "https://github.com/AnsoCode/Sencho/.*" \ # --certificate-oidc-issuer https://token.actions.githubusercontent.com 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