CI/CD: build the Docker image once on main, promote it on release tags
Previously release.yml fully rebuilt and re-ran lint/typecheck/test/build from source on every tag push, duplicating what ci.yml had already just verified on main. Now ci.yml builds and pushes the image (tagged by commit SHA) once, right after its checks pass, but only for pushes to main. release.yml no longer builds anything: it pulls that exact image, retags it as the release version + latest, and deploys it. This guarantees the deployed image is byte-for-byte what CI verified, and turns "tag a commit that was never pushed to main or hasn't finished CI" into a loud, immediate failure (no image to promote) instead of a rebuild that silently skips the checks.
This commit is contained in:
@@ -49,3 +49,70 @@ jobs:
|
||||
env:
|
||||
NODE_ENV: production
|
||||
run: npm run build
|
||||
|
||||
# Builds once per commit that lands on main and has passed the checks
|
||||
# above, and pushes it to the registry tagged by commit SHA. release.yml
|
||||
# (triggered by a version tag) never rebuilds — it only promotes this
|
||||
# exact, already-tested image to the release tag + `latest` and deploys
|
||||
# it. This guarantees byte-for-byte that what gets deployed is what CI
|
||||
# verified, and makes tagging a commit that never passed CI on main fail
|
||||
# loudly (no image to promote) instead of silently rebuilding from
|
||||
# source and skipping the checks.
|
||||
build-image:
|
||||
needs: test
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
# The anouma-runner runs as a Docker container itself (gitea/runner:3),
|
||||
# with the host's docker.sock bind-mounted in but no `docker` CLI binary
|
||||
# of its own — a job with no `container:` here runs directly inside that
|
||||
# minimal runner image and gets "docker: command not found".
|
||||
#
|
||||
# A plain `docker:27-cli` image (Alpine, no Node.js) doesn't work either:
|
||||
# actions/checkout@v4 is a JS action and needs a Node runtime in the job
|
||||
# container, which that image doesn't have — it fails at Checkout with
|
||||
# exit 127. Using a Node image as the base guarantees Node is already
|
||||
# there (same reason the `test` job's node:22-alpine works), and the
|
||||
# Docker CLI is added via apt. The runner auto-mounts the same host
|
||||
# docker.sock into this container (sibling containers — see
|
||||
# container.docker_host in the runner's config.yaml), so `docker
|
||||
# build`/`push` operate on the host's real Docker daemon. release.yml's
|
||||
# promote-and-deploy job uses the same setup, for the same reasons.
|
||||
container:
|
||||
image: node:22-bookworm-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Docker CLI
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends docker.io
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
- name: Determine image name
|
||||
id: image
|
||||
run: |
|
||||
echo "registry=${REGISTRY:-git.maro.run}" >> "$GITHUB_OUTPUT"
|
||||
echo "repo=${REGISTRY_REPO:-maro/anouma}" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
REGISTRY: git.maro.run
|
||||
REGISTRY_REPO: maro/anouma
|
||||
|
||||
- name: Log in to registry
|
||||
env:
|
||||
REGISTRY_IMAGE: ${{ steps.image.outputs.registry }}
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_IMAGE" -u "$REGISTRY_USERNAME" --password-stdin
|
||||
|
||||
- name: Build image
|
||||
env:
|
||||
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
|
||||
# Tagged by commit SHA only — `latest` is reserved for whatever was
|
||||
# last actually released (see release.yml), not the newest main build.
|
||||
run: docker build -t "$IMAGE:$GITHUB_SHA" .
|
||||
|
||||
- name: Push image
|
||||
env:
|
||||
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
|
||||
run: docker push "$IMAGE:$GITHUB_SHA"
|
||||
|
||||
@@ -6,56 +6,23 @@ on:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
test:
|
||||
# Deliberately does NOT rebuild or re-run lint/typecheck/test/build: the
|
||||
# image for this exact commit was already built and pushed by ci.yml's
|
||||
# build-image job when it landed on main (which itself only runs after
|
||||
# the `test` job passes). This job just promotes that already-verified
|
||||
# image to the release tag + `latest` and deploys it — if the tagged
|
||||
# commit was never pushed to main (or CI hasn't finished yet), the
|
||||
# "Pull the tested image" step below fails loudly instead of silently
|
||||
# rebuilding from source and skipping the checks.
|
||||
promote-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: node:22-alpine
|
||||
# NODE_ENV is deliberately NOT set at job level: `npm ci` treats a
|
||||
# production NODE_ENV as `--omit=dev`, which would skip eslint/
|
||||
# typescript/etc. and break Lint/Typecheck/Test. It's scoped to the
|
||||
# Build step below instead.
|
||||
env:
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
DATABASE_URI: postgresql://ci:ci@localhost:5432/ci
|
||||
PAYLOAD_SECRET: ci-only-not-a-real-secret
|
||||
MEETING_SESSION_SECRET: ci-only-not-a-real-secret
|
||||
ANALYTICS_SALT_SECRET: ci-only-not-a-real-secret
|
||||
CRON_SECRET: ci-only-not-a-real-secret
|
||||
NEXT_PUBLIC_SERVER_URL: http://localhost:3000
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: apk add --no-cache libc6-compat
|
||||
- run: npm ci
|
||||
- run: npm run lint
|
||||
- run: npx next typegen && npx tsc --noEmit
|
||||
- run: npm test
|
||||
- run: npm run build
|
||||
env:
|
||||
NODE_ENV: production
|
||||
|
||||
build-and-deploy:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
# The anouma-runner runs as a Docker container itself (gitea/runner:3),
|
||||
# with the host's docker.sock bind-mounted in but no `docker` CLI binary
|
||||
# of its own — a job with no `container:` here runs directly inside that
|
||||
# minimal runner image and gets "docker: command not found".
|
||||
#
|
||||
# A plain `docker:27-cli` image (Alpine, no Node.js) doesn't work either:
|
||||
# actions/checkout@v4 is a JS action and needs a Node runtime in the job
|
||||
# container, which that image doesn't have — it failed at Checkout with
|
||||
# exit 127. Using a Node image as the base guarantees Node is already
|
||||
# there (same reason the `test` job's node:22-alpine works), and the
|
||||
# Docker CLI + ssh client are added via apt. The runner auto-mounts the
|
||||
# same host docker.sock into this container (sibling containers — see
|
||||
# container.docker_host in the runner's config.yaml), so `docker
|
||||
# build`/`push` operate on the host's real Docker daemon.
|
||||
# Node-based image (not the bare runner, not a Docker-only image) for
|
||||
# the same reasons as ci.yml's build-image job: the runner needs a
|
||||
# Docker CLI added via apt (it has none of its own), and actions/checkout
|
||||
# needs a Node runtime that a plain docker:*-cli image doesn't have.
|
||||
container:
|
||||
image: node:22-bookworm-slim
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Docker CLI and ssh client
|
||||
run: |
|
||||
apt-get update
|
||||
@@ -94,13 +61,24 @@ jobs:
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
run: echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_IMAGE" -u "$REGISTRY_USERNAME" --password-stdin
|
||||
|
||||
- name: Build image
|
||||
- name: Pull the tested image for this commit
|
||||
env:
|
||||
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
|
||||
run: |
|
||||
docker pull "$IMAGE:$GITHUB_SHA" || {
|
||||
echo "::error::No image found for commit $GITHUB_SHA. This tag must point at a commit that was already pushed to main and built by ci.yml's build-image job — push to main first (and wait for CI to finish) before tagging a release." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Promote image to the release tag and latest
|
||||
env:
|
||||
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
|
||||
VERSION: ${{ steps.image.outputs.version }}
|
||||
run: docker build -t "$IMAGE:$VERSION" -t "$IMAGE:latest" .
|
||||
run: |
|
||||
docker tag "$IMAGE:$GITHUB_SHA" "$IMAGE:$VERSION"
|
||||
docker tag "$IMAGE:$GITHUB_SHA" "$IMAGE:latest"
|
||||
|
||||
- name: Push image
|
||||
- name: Push promoted tags
|
||||
env:
|
||||
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
|
||||
VERSION: ${{ steps.image.outputs.version }}
|
||||
|
||||
Reference in New Issue
Block a user