name: Build and Push Docker Image # Builds each platform on its own native runner, then stitches the two into one # manifest list. # # This was a single amd64 job emulating arm64 through QEMU and took ~31 minutes: # QEMU translates every Arm instruction into x86, so the arm64 half ran several # times slower than the amd64 half it shared a runner with. GitHub's free # `ubuntu-24.04-arm` runners (public repos) remove the emulation entirely, and # the two halves now run concurrently on their own hardware. # # The cost is a second job. A native single-platform build cannot produce a # multi-platform manifest by itself, so each build pushes an untagged, # digest-addressed image and `merge` composes the tag list from those digests # with `imagetools create`. Tagging, signing, and scanning all belong to `merge`, # because the digest consumers actually resolve is the manifest list's, not # either platform's. # # workflow_dispatch is deliberate: it makes this runnable from a branch before # merge. On a non-default branch the is_default_branch guard below disables the # `latest` tag, so a dispatch run publishes only the immutable commit-sha tag and # cannot move what production pulls. on: push: branches: [main] tags: ['v*.*.*'] workflow_dispatch: {} env: REGISTRY: ghcr.io IMAGE_NAME: erp-mafia/gnubok jobs: build: name: Build ${{ matrix.platform }} runs-on: ${{ matrix.runner }} strategy: # Publishing one architecture and silently dropping the other is worse # than publishing neither: the merge below needs both digests. fail-fast: true matrix: include: - platform: linux/amd64 arch: amd64 runner: ubuntu-latest - platform: linux/arm64 arch: arm64 runner: ubuntu-24.04-arm permissions: contents: read packages: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: # The build pushes to GHCR via docker/login-action, never over git, so # the git credential is dead weight inside a Docker build context. persist-credentials: false - name: Log in to GHCR uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} # Labels only. Tags are applied once, to the manifest list, in `merge`. - name: Extract metadata id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 # No setup-qemu-action. That was the whole point: each runner now builds # its own architecture natively. - name: Build and push by digest id: build uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . platforms: ${{ matrix.platform }} labels: ${{ steps.meta.outputs.labels }} build-args: | EXTENSIONS_PRESET=self-hosted # SBOM (software bill of materials) + SLSA provenance are attached as # OCI attestations, queryable via `docker buildx imagetools inspect`. provenance: mode=max sbom: true # push-by-digest publishes without a tag; `merge` collects the digests # and builds the tag list from them. name-canonical records the # repository name in the config so the merged manifest resolves. outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true # Layer cache lives in GHCR, not the GitHub Actions cache. Two # platforms x mode=max is ~7 GB, which alone ate 70% of the repo's # 10 GB Actions cache quota and, because buildx refreshes every blob's # access time on each run, never aged out under LRU: it starved the # other workflows' caches instead. GHCR storage is free for public # repos and off that quota. mode=max is kept deliberately: mode=min # would drop the deps/builder stages and make every push redo # `npm ci` + `next build`. # # The cache tag is now per-architecture. Each runner builds a single # platform, so a shared tag would leave the two jobs racing to # overwrite a cache manifest describing layers the other cannot use. # # A stable cache tag is safe here: this workflow runs only on main, # on v*.*.* tags (cut from main), and on manual dispatch, so there is # no untrusted ref that could poison the layers. # # image-manifest=true,oci-mediatypes=true is required by GHCR, which # rejects buildkit's default cache manifest media type. cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }} cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache-${{ matrix.arch }},mode=max,image-manifest=true,oci-mediatypes=true - name: Verify NEXT_PUBLIC_* sentinels survived the build # The image is generic: it is built with sentinel values # (__NEXT_PUBLIC_SELF_HOSTED__) that docker-entrypoint.sh seds into # .next at container start. That only works if the sentinel is still IN # the build output. Writing `process.env.NEXT_PUBLIC_X === 'true'` in # source lets the minifier fold the comparison and delete the branch, # erasing the sentinel: the flag is then permanently false and no # operator setting can change it. That shipped once and left every # Docker self-host running with the entitlement paywall live, invisibly, # because dev and the Vercel build both have real env values and never # reproduce it. # # This runs in the per-platform build, not in `merge`, for two reasons. # It is the only place each architecture is actually checked: `docker # run` against the manifest list resolves the runner's own platform, so # a merge-job check would silently exempt arm64. And it lands BEFORE any # tag exists, so a folded sentinel fails the matrix (fail-fast) and # `merge` never runs: `latest` cannot move onto a build whose flags can # no longer be configured. The digest image pushed above stays untagged # and unreferenced. # # check:guards catches the source pattern on every PR; this is the # end-to-end proof against the built artifact, which is the only place # the failure is observable. env: DIGEST: ${{ steps.build.outputs.digest }} run: | set -euo pipefail IMAGE="${REGISTRY}/${IMAGE_NAME}@${DIGEST}" MISSING="" for VAR in NEXT_PUBLIC_SELF_HOSTED NEXT_PUBLIC_REQUIRE_MFA; do if docker run --rm --entrypoint sh "$IMAGE" -c \ "grep -rq '__${VAR}__' /opt/gnubok-template/.next"; then echo "ok: __${VAR}__ present in build output" else MISSING="${MISSING} ${VAR}" fi done if [ -n "$MISSING" ]; then echo "::error::Sentinel(s) missing from the build output:${MISSING}." echo "::error::Read these flags via lib/env/public-flags (flagEnabled/isSelfHosted)." echo "::error::An in-place comparison is constant-folded away, leaving the flag stuck off." exit 1 fi # Digests reach `merge` as artifact filenames: the file content is # irrelevant, only the name carries information. - name: Export digest env: DIGEST: ${{ steps.build.outputs.digest }} run: | set -euo pipefail mkdir -p /tmp/digests touch "/tmp/digests/${DIGEST#sha256:}" - name: Upload digest uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: digests-${{ matrix.arch }} path: /tmp/digests/* # A silently empty artifact would surface later as an imagetools # invocation with no source digests, which is a much worse error. if-no-files-found: error retention-days: 1 merge: name: Merge, sign and scan runs-on: ubuntu-latest needs: build permissions: contents: read packages: write # OIDC token used by cosign keyless signing. id-token: write # SARIF upload to the repo's "Security" tab from the Trivy scan. security-events: write steps: - name: Download digests uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: path: /tmp/digests pattern: digests-* merge-multiple: true - name: Log in to GHCR uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4 - name: Extract metadata id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} # On main: push `latest` + commit-sha tags. # On v*.*.* tags: push semver tags (1.2.3, 1.2, 1) for production pinning. # A workflow_dispatch run from a branch gets the sha tag only, because # is_default_branch gates `latest`. tags: | type=raw,value=latest,enable={{is_default_branch}} type=sha,prefix= type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} - name: Create and push manifest list working-directory: /tmp/digests run: | set -euo pipefail docker buildx imagetools create \ $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ $(printf "${REGISTRY}/${IMAGE_NAME}@sha256:%s " *) # Cosign and Trivy must address the manifest list, not either platform's # image: the list digest is what `docker pull` resolves for consumers, so # it is the thing worth signing and the thing worth scanning. - name: Resolve manifest list digest id: manifest env: VERSION: ${{ steps.meta.outputs.version }} run: | set -euo pipefail DIGEST=$(docker buildx imagetools inspect \ "${REGISTRY}/${IMAGE_NAME}:${VERSION}" \ --format '{{json .Manifest}}' | jq -r '.digest') echo "Resolved ${REGISTRY}/${IMAGE_NAME}:${VERSION} -> ${DIGEST}" echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" - name: Verify both platforms are present # A merge that quietly produced a single-arch manifest would ship a # broken image to every arm64 self-hoster, and nothing downstream checks # architecture. Assert it here while the digest is still in hand. env: DIGEST: ${{ steps.manifest.outputs.digest }} run: | set -euo pipefail PLATFORMS=$(docker buildx imagetools inspect \ "${REGISTRY}/${IMAGE_NAME}@${DIGEST}" --raw \ | jq -r '[.manifests[] | select(.platform != null) | select(.platform.os != "unknown") | "\(.platform.os)/\(.platform.architecture)"] | sort | unique | join(",")') echo "Manifest platforms: ${PLATFORMS}" [ "$PLATFORMS" = "linux/amd64,linux/arm64" ] || { echo "::error::Expected linux/amd64,linux/arm64 but got ${PLATFORMS}" exit 1 } - name: Install cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Sign the image (keyless OIDC) env: DIGEST: ${{ steps.manifest.outputs.digest }} run: | set -euo pipefail cosign sign --yes "${REGISTRY}/${IMAGE_NAME}@${DIGEST}" - name: Scan image with Trivy (report-only) id: trivy # Decoupled from the publish gate on purpose: this pipeline must stay # green so builds are deterministic. The image is already pushed and # signed above, so failing here would only redden the run: it would not # unship a vulnerable image. exit-code:0 + continue-on-error keep CVEs # (and even a Trivy/DB outage) from failing the build; findings still # flow to the Security tab below. The real blocking gate is # docker-image-scan.yml, which re-scans the published image and fails # (notifying admins) on a fixable CRITICAL/HIGH CVE. It runs on a # workflow_run trigger the moment THIS workflow completes (so the gap # between publish and the blocking scan is the scan's own duration # (minutes), not a 24h cron window) plus a daily cron as a safety net. # Accepted residual risk: an image is live for that short scan window # before the gate fires; see docs/SELF-HOSTING.md / the risk register. continue-on-error: true uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.manifest.outputs.digest }} severity: CRITICAL,HIGH exit-code: '0' ignore-unfixed: true format: sarif output: trivy-results.sarif - name: Upload Trivy results to GitHub Security tab # if: always(), evidence must reach the Security tab regardless of the # scan step's exit status. With the previous `outcome == 'success'` guard, # a Trivy/DB outage that errored the scan would silently drop findings. # Kept non-fatal (continue-on-error) so a missing SARIF or a Security-tab # hiccup can't redden an otherwise-good publish. if: always() continue-on-error: true uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: sarif_file: trivy-results.sarif category: trivy