From 64574a457a4999a53bddb829d7398f61b92c6556 Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 30 Jun 2026 00:09:01 -0400 Subject: [PATCH] ci: add Docker Hub PR preview image workflow (#1527) Publish saelix/sencho:pr-N and preview-sha from open PRs. Uses Trivy and smoke gates matching the integration build. --- .github/workflows/docker-preview.yml | 232 +++++++++++++++++++++++++++ docs/operations/verifying-images.mdx | 19 ++- 2 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/docker-preview.yml diff --git a/.github/workflows/docker-preview.yml b/.github/workflows/docker-preview.yml new file mode 100644 index 00000000..886716c1 --- /dev/null +++ b/.github/workflows/docker-preview.yml @@ -0,0 +1,232 @@ +name: Publish PR Preview Image + +# --------------------------------------------------------------------------- +# Preview build vs integration (dev) vs release +# --------------------------------------------------------------------------- +# Maintainer-triggered Docker Hub images for external validation BEFORE a PR +# merges. Testers pull saelix/sencho:pr- without GHCR credentials. +# +# Preview (this file) Integration (docker-dev.yml) +# --------------------- --------------------------- +# trigger: workflow_dispatch trigger: push to main +# registry: Docker Hub only registry: GHCR only +# image: saelix/sencho image: ghcr.io/.../sencho-dev +# tags: :pr-, :preview- tags: :dev, :dev- +# signing / SBOM / Release: no signing / SBOM / Release: no +# approval gate: preview environment approval gate: none +# +# Release tags (latest, semver), cosign, SBOM/VEX, and GitHub Releases are +# RELEASE-ONLY in docker-publish.yml. Preview tags never use latest or semver. +# --------------------------------------------------------------------------- + +on: + workflow_dispatch: + inputs: + pr_number: + description: 'PR number to build and publish (tags pr-N and preview-sha)' + required: true + type: number + +concurrency: + group: docker-preview-pr-${{ inputs.pr_number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + push_preview_image: + name: Build and push preview image to Docker Hub + runs-on: ubuntu-latest + timeout-minutes: 30 + # DOCKERHUB_USERNAME and DOCKERHUB_TOKEN live in the `preview` environment + # (same Hub repo as production, but no release approval gate). Copy both + # secrets from the production environment when bootstrapping preview. + environment: preview + steps: + - name: Validate PR number + env: + PR_NUMBER: ${{ inputs.pr_number }} + run: | + set -euo pipefail + if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || [ "$PR_NUMBER" -lt 1 ]; then + echo "pr_number must be a positive integer" + exit 1 + fi + + - name: Resolve pull request + id: pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + set -euo pipefail + pr_json=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}") + state=$(echo "$pr_json" | jq -r '.state') + head_sha=$(echo "$pr_json" | jq -r '.head.sha') + if [ "$state" != "open" ]; then + echo "PR #${PR_NUMBER} is not open (state=${state})" + exit 1 + fi + echo "head_sha=${head_sha}" >> "$GITHUB_OUTPUT" + echo "Building PR #${PR_NUMBER} at ${head_sha}" + + - name: Check out the repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: refs/pull/${{ inputs.pr_number }}/head + + - name: Set up QEMU + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4 + with: + platforms: arm64 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 + + - 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@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6 + with: + images: saelix/sencho + tags: | + type=raw,value=pr-${{ inputs.pr_number }} + type=sha,prefix=preview-,format=short + + - name: Validate preview tags before publish + env: + TAGS: ${{ steps.meta.outputs.tags }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + set -euo pipefail + if [ -z "${TAGS// }" ]; then + echo "No tags generated for publish" + exit 1 + fi + while IFS= read -r full_ref; do + [ -n "$full_ref" ] || continue + tag="${full_ref##*:}" + if [ "$tag" = "latest" ]; then + echo "Refusing to publish reserved tag: latest" + exit 1 + fi + if [[ "$tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || [[ "$tag" =~ ^[0-9]+\.[0-9]+$ ]]; then + echo "Refusing to publish semver-like tag: ${tag}" + exit 1 + fi + if [ "$tag" = "dev" ] || [[ "$tag" == dev-* ]]; then + echo "Refusing to publish dev integration tag: ${tag}" + exit 1 + fi + if [[ "$tag" == "pr-${PR_NUMBER}" ]]; then + continue + fi + if [[ "$tag" =~ ^preview-[0-9a-f]+$ ]]; then + continue + fi + echo "Refusing to publish unexpected tag: ${tag}" + exit 1 + done <<< "$TAGS" + + - name: Build preview image for pre-publish scan (amd64, loaded) + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + with: + context: . + push: false + load: true + platforms: linux/amd64 + tags: localhost/sencho:preview-scan + cache-from: type=gha + build-args: | + APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }} + + - name: Scan preview image for vulnerabilities (Trivy) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: localhost/sencho:preview-scan + exit-code: '1' + severity: 'CRITICAL,HIGH' + format: 'table' + trivy-config: trivy.yaml + + - name: Smoke test preview image (pre-publish) + run: | + set -euo pipefail + docker run --rm --entrypoint sh localhost/sencho:preview-scan -c \ + 'docker --version && docker compose version' + + docker run -d --name sencho-preview-smoke -p 1852:1852 localhost/sencho:preview-scan + trap 'docker logs sencho-preview-smoke 2>&1 || true; docker rm -f sencho-preview-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 preview image + id: build + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + APK_CACHE_BUST=${{ steps.apk-bust.outputs.date }} + + - name: Comment on pull request with pull instructions + continue-on-error: true + uses: actions/github-script@ed597411d8f9240738b6aec6e6bc2f24db7bbd28 # v8.0.0 + env: + PR_NUMBER: ${{ inputs.pr_number }} + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + DIGEST: ${{ steps.build.outputs.digest }} + with: + script: | + const prNumber = Number(process.env.PR_NUMBER); + const shortSha = process.env.HEAD_SHA.slice(0, 7); + const digest = process.env.DIGEST; + const body = [ + '## Preview image published', + '', + 'Docker Hub preview tags for this PR:', + '', + '```bash', + `docker pull saelix/sencho:pr-${prNumber}`, + '```', + '', + 'Pinned build:', + '', + '```bash', + `docker pull saelix/sencho:preview-${shortSha}`, + '```', + '', + `Manifest digest: \`${digest}\``, + '', + 'These tags are for pre-merge validation only. They are not cosign-signed and are not suitable as a production pin.', + ].join('\n'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); diff --git a/docs/operations/verifying-images.mdx b/docs/operations/verifying-images.mdx index 21d6e12f..79d5244f 100644 --- a/docs/operations/verifying-images.mdx +++ b/docs/operations/verifying-images.mdx @@ -3,7 +3,11 @@ title: Verifying Published Images description: How to verify signatures, provenance, SBOMs, and CVE triage for Sencho Docker images. --- -Every Sencho release image is published to two registries with identical content: Docker Hub (`saelix/sencho`) and GitHub Container Registry (`ghcr.io/studio-saelix/sencho`). Every image is signed and carries verifiable supply-chain artifacts on both registries. The `cosign verify` examples below use the Docker Hub path; substitute `ghcr.io/studio-saelix/sencho:` to verify the GHCR copy. +Every Sencho **release** image is published to two registries with identical content: Docker Hub (`saelix/sencho`) and GitHub Container Registry (`ghcr.io/studio-saelix/sencho`). Release tags are signed and carry verifiable supply-chain artifacts on both registries. The `cosign verify` examples below use the Docker Hub path; substitute `ghcr.io/studio-saelix/sencho:` to verify the GHCR copy. + + +Pre-merge preview tags (`pr-*`, `preview-*` on Docker Hub) are maintainer-published tester builds. They are **not** cosign-signed and have no SBOM or VEX attestations. Use them only for feature validation before merge; pin production to a release semver tag and verify signatures as below. + ## Prerequisites @@ -25,7 +29,7 @@ brew install trivy ## Verify the image signature -Every published tag is signed with cosign keyless signing via GitHub Actions OIDC. No private key is stored anywhere; the signing identity is bound to the GitHub Actions workflow that published the image. +Every **release** tag is signed with cosign keyless signing via GitHub Actions OIDC. No private key is stored anywhere; the signing identity is bound to the GitHub Actions workflow that published the image. Preview tags (`pr-*`, `preview-*`) are not signed. ```bash cosign verify saelix/sencho: \ @@ -114,6 +118,8 @@ Trivy 0.36.0 or later is required for `--vex` flag support. ## Available tags +### Release tags + Each release publishes two moving tags plus one immutable tag, so you can choose how aggressively you want to track updates: | Tag | Example | Updates on | @@ -123,3 +129,12 @@ Each release publishes two moving tags plus one immutable tag, so you can choose | `X.Y.Z` | `saelix/sencho:0.42.7` | Never (immutable) | For production, pin to `X.Y.Z` or `X.Y` and verify the signature on every pull. For staging or development, `latest` is fine. + +### Preview tags (pre-merge only) + +Maintainers publish these from open PRs for external validation. They are unsigned and not for production. + +| Tag | Example | Updates on | +|---|---|---| +| `pr-` | `saelix/sencho:pr-1526` | Each re-run of the preview workflow for that PR | +| `preview-` | `saelix/sencho:preview-abc1234` | Never (immutable per build) |