f101bde6a8
The image is built once with sentinel values
(ENV NEXT_PUBLIC_SELF_HOSTED=__NEXT_PUBLIC_SELF_HOSTED__) that
docker-entrypoint.sh seds into .next at container start. Comparing a flag in
place defeats that: the bundler inlines the sentinel, the minifier folds
"__NEXT_PUBLIC_SELF_HOSTED__" === 'true' to false and eliminates the branch, so
both the variable name and the sentinel disappear and sed has nothing left to
replace. The flag is then permanently false whatever the operator configures.
Diagnosed against a running self-hosted instance: the compiled gate read
function r(){return"true"!==process.env.FORCE_PAYWALL
&&"true"===process.env.DISABLE_PAYWALL}
with the isSelfHosted() branch gone. The un-prefixed FORCE_PAYWALL /
DISABLE_PAYWALL survived precisely because they are never inlined, and
NODE_ENV === 'development' was folded away by the same mechanism. The one
place the flag still worked, getSessionTimeoutConfig(env = process.env), reads
it off a parameter the bundler cannot fold.
Consequence: every Docker self-host ran with the entitlement paywall live, so
ai, bank_sync, skatteverket and email_send went dark 30 days after company
creation when the seeded trial grants expired. Nothing surfaced it, because
dev and the Vercel build both have real env values and never reproduce it.
Analytics, forced MFA, BankID and the hosted upload ceiling read the same flag
and were wrong in the same direction.
Flags are now read as values through lib/env/public-flags, which keeps the
sentinel in the output as a live string literal and defers the comparison to
runtime. flagEnabled uses a Set lookup rather than ===, which a minifier could
fold if it ever inlined the helper.
Guarded twice, because the source fix alone would not have caught this:
- check:guards folded-public-flag fails any in-place NEXT_PUBLIC_* comparison
(AST, no baseline, verified to fire on a probe file);
- docker-publish asserts the sentinels survive the built image, which is the
only artifact where the failure is observable.
npm test 14999 passed, npm run lint 0 errors, npm run check:guards clean.
Signed-off-by: Bjorn Bergenheim <29535152+bjornbergenheim@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
321 lines
14 KiB
YAML
321 lines
14 KiB
YAML
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
|