Skip to content

Images

Images #1

Workflow file for this run

name: Images
# Build once, deploy many. Builds the compose images on a GitHub-hosted runner,
# scans them for secrets, and pushes them to GHCR as
# ghcr.io/<owner>/textstack-<service>:<full commit sha>
# and the web frontend's dist/ as ghcr.io/<owner>/textstack-web:<full commit sha>
# (job `web`). deploy.yml calls this next to its CI job, and the server pulls those
# exact digests instead of building (it still builds itself if a pull fails).
#
# It replaced ci-cache-warm.yml, which built the same images on main only to warm
# the layer cache and threw them away. Same overlay and cache scopes, so pull
# requests still start warm.
#
# The packages are meant to be PUBLIC (free). That is only safe if nothing secret
# is baked in, so the scan below gates the push: every .env value is a canary, and
# a canary, a private key or a token pattern anywhere in an image fails the job
# before anything leaves the runner.
on:
workflow_call:
inputs:
ref:
description: Commit to build. Empty = the commit that triggered the run.
type: string
default: ''
outputs:
digests:
description: >-
`<service>@sha256:<digest>` per image, space-separated. deploy.yml pulls
these, not the tag: a tag can be re-pushed between build and pull, a
digest cannot. Empty when nothing was published (old commit, failed run).
value: ${{ jobs.publish.outputs.digests }}
web_digest:
description: >-
`sha256:<digest>` of textstack-web (the web `dist/`), pulled the same way.
Empty when nothing was published — the server then builds the web itself.
value: ${{ jobs.web.outputs.digest }}
workflow_dispatch:
inputs:
ref:
description: Commit to build (empty = the selected branch head)
required: false
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
jobs:
publish:
runs-on: ubuntu-latest
# ~12m cold, a few minutes warm, plus the push and scan.
timeout-minutes: 40
permissions:
contents: read
packages: write
outputs:
digests: ${{ steps.digests.outputs.digests }}
env:
IMAGE_REGISTRY: ghcr.io/${{ github.repository_owner }}/textstack
# Every service with a `build:` in docker-compose.yml. deploy.yml pulls the same list.
SERVICES: migrator api worker admin ssg-worker mcp-server
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.sha }}
- name: Resolve tag
id: tag
run: |
echo "IMAGE_TAG=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
# Same value the server stamps on a fallback build — one Sentry release per commit.
echo "GIT_SHA=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_ENV"
# A rollback to a commit from before this workflow existed: its compose file has
# no `image:` entries, so the server builds it, as it always did.
if grep -q 'IMAGE_TAG' docker-compose.yml; then
echo "publish=true" >> "$GITHUB_OUTPUT"
else
echo "::notice::$(git rev-parse --short HEAD) predates published images — the server will build it"
echo "publish=false" >> "$GITHUB_OUTPUT"
fi
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
if: steps.tag.outputs.publish == 'true'
- name: Log in to GHCR
if: steps.tag.outputs.publish == 'true'
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
# A rollback to a commit that was deployed before is already in the registry.
- name: Already published?
id: exists
if: steps.tag.outputs.publish == 'true'
run: |
for s in $SERVICES; do
if ! docker buildx imagetools inspect "$IMAGE_REGISTRY-$s:$IMAGE_TAG" >/dev/null 2>&1; then
echo "all=false" >> "$GITHUB_OUTPUT"; exit 0
fi
done
echo "All images for $IMAGE_TAG already published"
echo "all=true" >> "$GITHUB_OUTPUT"
# Still needed while the worker/admin/ssg-worker images weigh 3-5 GB each.
# Delete it once the slim Dockerfiles land and a run shows the space is there.
- name: Free disk space
if: steps.exists.outputs.all == 'false'
run: |
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache
docker system prune -af
- uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0
if: steps.exists.outputs.all == 'false'
# Every value a canary. Compose interpolates .env into build args, so if anyone
# ever forwards a real variable into a build (`args: KEY: ${OPENAI_API_KEY}`),
# the canary lands in the image and the scan fails.
#
# Names come from .env.example AND every `${VAR}` the compose files reference,
# so a variable added to compose but not to .env.example is still a canary
# (a hand list here missed MOBILE_MIN_SUPPORTED_BUILD). The three this job
# sets itself are left out; the shell env would override them anyway.
- name: Create canary .env
if: steps.exists.outputs.all == 'false'
run: |
{ sed -n 's/^\([A-Z_][A-Z0-9_]*\)=.*/\1/p' .env.example
grep -ohE '\$\{[A-Z_][A-Z0-9_]*' docker-compose.yml .github/docker-compose.ci.yml | cut -c3-
} | sort -u | grep -vxE 'IMAGE_REGISTRY|IMAGE_TAG|GIT_SHA' \
| awk '{ print $0 "=CANARY_SECRET_" $0 }' > .env
cat .env
- name: Build
if: steps.exists.outputs.all == 'false'
run: |
# shellcheck disable=SC2086
docker compose --profile mcp -f docker-compose.yml -f .github/docker-compose.ci.yml build $SERVICES
- name: Secret scan (gates the push)
if: steps.exists.outputs.all == 'false'
run: |
# shellcheck disable=SC2046
scripts/scan-image-secrets.sh $(for s in $SERVICES; do echo "$IMAGE_REGISTRY-$s:$IMAGE_TAG"; done)
- name: Push
id: push
if: steps.exists.outputs.all == 'false'
run: |
set -o pipefail
# The digest `docker push` reports is the one for the bytes this job built
# and scanned. Asking the registry afterwards would trust whatever the tag
# points at by then.
d=""
for s in $SERVICES; do
digest=$(docker push "$IMAGE_REGISTRY-$s:$IMAGE_TAG" | tee /dev/stderr \
| sed -n 's/.*digest: \(sha256:[0-9a-f]\{64\}\).*/\1/p' | tail -1)
[ -n "$digest" ] || { echo "::error::no digest in the push output for $s"; exit 1; }
d="$d $s@$digest"
done
echo "digests=${d# }" >> "$GITHUB_OUTPUT"
- name: Digests
id: digests
if: steps.tag.outputs.publish == 'true'
env:
PUSHED: ${{ steps.push.outputs.digests }}
run: |
set -o pipefail
d=$PUSHED
if [ -z "$d" ]; then
# Already published by an earlier run (a rollback): the registry is the only record.
for s in $SERVICES; do
digest=$(docker buildx imagetools inspect "$IMAGE_REGISTRY-$s:$IMAGE_TAG" --format '{{json .Manifest}}' | jq -r .digest)
d="$d $s@$digest"
done
fi
for x in $d; do
[[ $x =~ ^[a-z-]+@sha256:[0-9a-f]{64}$ ]] || { echo "::error::bad digest entry: $x"; exit 1; }
done
echo "digests=${d# }" | tee -a "$GITHUB_OUTPUT"
# The web frontend (apps/web/dist), built and scanned HERE instead of on the
# production host, so that (a) a secret in the bundle stops the deploy before
# anything is live, and (b) npm/pnpm install, its postinstalls and the vite build
# never run on the server. Shipped as a FROM-scratch image holding only /dist —
# never run, only copied out (`docker create` + `docker cp`) — so it rides the
# same pull-by-digest, rollback-by-SHA and retention path as the others.
# Its own job: it runs beside `publish`, not after it, and needs none of its
# disk cleanup.
web:
runs-on: ubuntu-latest
# ~3 min.
timeout-minutes: 20
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.digest.outputs.digest }}
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/textstack-web
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref || github.sha }}
persist-credentials: false
- name: Resolve tag
id: tag
run: |
echo "IMAGE_TAG=$(git rev-parse HEAD)" >> "$GITHUB_ENV"
# A rollback to a commit from before the web image: no production.env to
# build it with, so the server builds it, as it always did.
if [ -f apps/web/production.env ]; then
echo "publish=true" >> "$GITHUB_OUTPUT"
else
echo "::notice::$(git rev-parse --short HEAD) predates the web image — the server will build it"
echo "publish=false" >> "$GITHUB_OUTPUT"
fi
- name: Log in to GHCR
if: steps.tag.outputs.publish == 'true'
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
# A rollback to a commit that was deployed before is already in the registry.
- name: Already published?
id: exists
if: steps.tag.outputs.publish == 'true'
run: |
if docker buildx imagetools inspect "$IMAGE:$IMAGE_TAG" >/dev/null 2>&1; then
echo "$IMAGE:$IMAGE_TAG already published"
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
fi
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
if: steps.exists.outputs.found == 'false'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
if: steps.exists.outputs.found == 'false'
with:
node-version-file: .nvmrc
# The whole workspace, as the server did: same lockfile resolution, same bundle.
# No Chrome: puppeteer is the ssg-worker's, not vite's.
- name: Install workspace
if: steps.exists.outputs.found == 'false'
env:
PUPPETEER_SKIP_DOWNLOAD: 'true'
run: pnpm install --frozen-lockfile
# Every name the server's .env can hold is set to a canary first (the same list
# as the image canaries), so a vite config or plugin that ever inlines a
# non-VITE_ variable puts CANARY_SECRET_<name> into the bundle and the scan
# fails. Then the real public values, from the one file that holds them.
- name: Build web
if: steps.exists.outputs.found == 'false'
run: |
for n in $( { sed -n 's/^\([A-Z_][A-Z0-9_]*\)=.*/\1/p' .env.example
grep -ohE '\$\{[A-Z_][A-Z0-9_]*' docker-compose.yml .github/docker-compose.ci.yml | cut -c3-
} | sort -u | grep -vxE 'IMAGE_REGISTRY|IMAGE_TAG|GIT_SHA'); do
export "$n=CANARY_SECRET_$n"
done
set -a; . apps/web/production.env; set +a
pnpm --filter textstack-web build
du -sh apps/web/dist
# The whole dist (fresh: no SSG here): token shapes, private keys, secret-named
# files and the canaries. Gates the push.
- name: Secret scan (gates the push)
if: steps.exists.outputs.found == 'false'
run: scripts/scan-image-secrets.sh --dir apps/web/dist
- name: Push
id: push
if: steps.exists.outputs.found == 'false'
run: |
set -o pipefail
# No base image: nothing from Docker Hub, nothing but the scanned files.
printf 'FROM scratch\nCOPY . /dist/\n' | docker build -f - -t "$IMAGE:$IMAGE_TAG" apps/web/dist
digest=$(docker push "$IMAGE:$IMAGE_TAG" | tee /dev/stderr \
| sed -n 's/.*digest: \(sha256:[0-9a-f]\{64\}\).*/\1/p' | tail -1)
[ -n "$digest" ] || { echo "::error::no digest in the push output"; exit 1; }
echo "digest=$digest" >> "$GITHUB_OUTPUT"
- name: Digest
id: digest
if: steps.tag.outputs.publish == 'true'
env:
PUSHED: ${{ steps.push.outputs.digest }}
run: |
set -o pipefail
d=$PUSHED
# Already published by an earlier run (a rollback): the registry is the only record.
[ -n "$d" ] || d=$(docker buildx imagetools inspect "$IMAGE:$IMAGE_TAG" --format '{{json .Manifest}}' | jq -r .digest)
[[ $d =~ ^sha256:[0-9a-f]{64}$ ]] || { echo "::error::bad digest: $d"; exit 1; }
echo "digest=$d" | tee -a "$GITHUB_OUTPUT"