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.
121 lines
5.4 KiB
YAML
121 lines
5.4 KiB
YAML
name: Release
|
|
|
|
on:
|
|
push:
|
|
tags:
|
|
- 'v*'
|
|
|
|
jobs:
|
|
# 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
|
|
# 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: Install Docker CLI and ssh client
|
|
run: |
|
|
apt-get update
|
|
apt-get install -y --no-install-recommends docker.io openssh-client
|
|
rm -rf /var/lib/apt/lists/*
|
|
|
|
- name: Determine image tags
|
|
id: image
|
|
# Gitea Actions' runner is protocol-compatible with GitHub Actions,
|
|
# so step commands use the same $GITHUB_OUTPUT / $GITHUB_ENV
|
|
# mechanism (the `gitea.*` vs `github.*` expression contexts are
|
|
# just aliases of each other for `${{ }}` templating).
|
|
#
|
|
# The tag name (github.ref_name / $GITHUB_REF_NAME) is
|
|
# attacker-influenced: anyone with tag-push rights controls it, and
|
|
# git allows shell metacharacters in tag names. It's read here via
|
|
# the automatic $GITHUB_REF_NAME env var rather than `${{ github.ref_name }}`
|
|
# so its value reaches bash as data (argv/envp), never spliced into
|
|
# the script text — `${{ }}` expressions are substituted into the
|
|
# YAML before bash ever sees the script, which is what makes
|
|
# interpolating attacker-controlled values that way a script-injection
|
|
# vector (arbitrary code execution in a job that holds the registry
|
|
# and production deploy credentials).
|
|
run: |
|
|
echo "registry=${REGISTRY:-git.maro.run}" >> "$GITHUB_OUTPUT"
|
|
echo "repo=${REGISTRY_REPO:-maro/anouma}" >> "$GITHUB_OUTPUT"
|
|
echo "version=$GITHUB_REF_NAME" >> "$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: 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 tag "$IMAGE:$GITHUB_SHA" "$IMAGE:$VERSION"
|
|
docker tag "$IMAGE:$GITHUB_SHA" "$IMAGE:latest"
|
|
|
|
- name: Push promoted tags
|
|
env:
|
|
IMAGE: ${{ steps.image.outputs.registry }}/${{ steps.image.outputs.repo }}
|
|
VERSION: ${{ steps.image.outputs.version }}
|
|
run: |
|
|
docker push "$IMAGE:$VERSION"
|
|
docker push "$IMAGE:latest"
|
|
|
|
- name: Deploy to production
|
|
env:
|
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
|
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
|
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
|
# Optional: pin the exact host key instead of trusting it on
|
|
# first connect (see DEPLOYMENT.md). Safe to leave unset.
|
|
DEPLOY_HOST_KEY: ${{ secrets.DEPLOY_HOST_KEY }}
|
|
VERSION: ${{ steps.image.outputs.version }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
umask 077
|
|
key_file="$(mktemp)"
|
|
known_hosts_file="$(mktemp)"
|
|
trap 'rm -f "$key_file" "$known_hosts_file"' EXIT
|
|
|
|
printf '%s\n' "$DEPLOY_SSH_KEY" > "$key_file"
|
|
chmod 600 "$key_file"
|
|
|
|
if [ -n "${DEPLOY_HOST_KEY:-}" ]; then
|
|
printf '%s\n' "$DEPLOY_HOST_KEY" > "$known_hosts_file"
|
|
else
|
|
ssh-keyscan -p "${DEPLOY_PORT:-22}" "$DEPLOY_HOST" > "$known_hosts_file" 2>/dev/null
|
|
fi
|
|
|
|
# The forced command in the server's authorized_keys (see
|
|
# setup.sh) ignores this and always runs deploy.sh itself — the
|
|
# string below only ever reaches it via $SSH_ORIGINAL_COMMAND.
|
|
ssh -i "$key_file" -o UserKnownHostsFile="$known_hosts_file" -p "${DEPLOY_PORT:-22}" \
|
|
"${DEPLOY_USER}@${DEPLOY_HOST}" "deploy ${VERSION}"
|