Repository navigation
Images #1
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |