Cutting a release then had one manual step left: watch build.yml and dispatch "Deploy to galactus" by hand with the version. Chain it. build.yml gains a `deploy` job, `needs: build` and gated on refs/tags/v*, that dispatches deploy-galactus.yml against the tag with tag=<version> scope=app bootstrap=false skip_migrate=false. `needs` waits for both matrix legs, so api and web are both in the registry before prod pulls either — deploy-galactus.yml only pulls, and a half-pushed pair leaves prod running one new image and one old one. A dispatch rather than a `workflow_run:` trigger (which Gitea has supported since 1.24) because deploy-galactus.yml reads github.event.inputs.* in ten places; under workflow_run all of them are empty strings, so the deploy would run with no tag. The dispatch keeps that workflow's contract intact and keeps it hand-runnable, which is how rollbacks work. The dispatch is confirmed the same way release.yml confirms the build started: snapshot the existing deploy-galactus run ids first, then require a new one to appear. An accepted dispatch that creates no run is the failure mode that cost v1.0.3 its images, and a plain "is there a deploy run" check would be satisfied by the previous release. Kill switch: repo variable AUTO_DEPLOY_GALACTUS=false prints the manual command instead of deploying. Needs the existing RELEASE_TOKEN secret; preflight fails loudly and names the manual command if it is unset. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
228 lines
9.2 KiB
YAML
228 lines
9.2 KiB
YAML
# Build + push the API and web container images to the git.mancinas.io registry.
|
|
#
|
|
# Two images from this one repo:
|
|
# git.mancinas.io/rmancinas/jorgecuadros-api
|
|
# git.mancinas.io/rmancinas/jorgecuadros-web
|
|
#
|
|
# Comprehensive versioning (docker/metadata-action). Every build pushes a set
|
|
# of tags so an image is addressable at several granularities:
|
|
# - vX.Y.Z / vX.Y when the trigger is a git tag vX.Y.Z (releases)
|
|
# - <branch> the branch that was pushed (e.g. master, feat-foo)
|
|
# - sha-<short> immutable per-commit id, always present
|
|
# - latest only on the default branch (master)
|
|
# The same version string + commit + build date are baked into the image as
|
|
# ARG/ENV (APP_VERSION / GIT_SHA / BUILD_DATE) and as OCI labels, so a running
|
|
# container can report exactly what is deployed.
|
|
#
|
|
# Release flow: git tag v1.2.0 && git push origin v1.2.0 -> versioned images
|
|
# -> deploy-galactus.yml is dispatched automatically (see the
|
|
# `deploy` job at the bottom).
|
|
|
|
name: Build and Push Images
|
|
|
|
on:
|
|
push:
|
|
branches: [master]
|
|
tags: ["v*"]
|
|
paths:
|
|
- "apps/**"
|
|
- "packages/**"
|
|
- "docker/**"
|
|
- "package.json"
|
|
- "pnpm-lock.yaml"
|
|
- ".gitea/workflows/build.yml"
|
|
workflow_dispatch:
|
|
|
|
env:
|
|
REGISTRY: git.mancinas.io
|
|
|
|
jobs:
|
|
build:
|
|
name: Build ${{ matrix.image }}
|
|
# release.yml pushes the release commit and its tag in a single `git push`,
|
|
# so Gitea creates two runs for the same commit: one for master, one for the
|
|
# tag. Only the tag run matters — it is the one that emits the X.Y.Z / X.Y
|
|
# image tags, and it publishes `latest` and `sha-<short>` too, since it is
|
|
# the same commit. Skip the branch run rather than racing or cancelling it.
|
|
# Ordinary pushes to master (any message but `chore(release):`) still build.
|
|
if: >-
|
|
github.event_name != 'push' ||
|
|
startsWith(github.ref, 'refs/tags/') ||
|
|
!startsWith(github.event.head_commit.message, 'chore(release):')
|
|
runs-on: docker
|
|
container:
|
|
image: docker:27-dind
|
|
options: --privileged
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- image: jorgecuadros-api
|
|
dockerfile: docker/api.Dockerfile
|
|
- image: jorgecuadros-web
|
|
dockerfile: docker/web.Dockerfile
|
|
steps:
|
|
- name: Install Node.js for actions
|
|
run: apk add --no-cache nodejs npm
|
|
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: docker/setup-buildx-action@v3
|
|
|
|
- uses: docker/login-action@v3
|
|
with:
|
|
registry: ${{ env.REGISTRY }}
|
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
|
|
|
- id: meta
|
|
uses: docker/metadata-action@v5
|
|
with:
|
|
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}
|
|
tags: |
|
|
type=semver,pattern={{version}}
|
|
type=semver,pattern={{major}}.{{minor}}
|
|
type=ref,event=branch
|
|
type=sha,format=short,prefix=sha-
|
|
type=raw,value=latest,enable={{is_default_branch}}
|
|
|
|
- uses: docker/build-push-action@v5
|
|
with:
|
|
context: .
|
|
file: ${{ matrix.dockerfile }}
|
|
push: true
|
|
tags: ${{ steps.meta.outputs.tags }}
|
|
labels: ${{ steps.meta.outputs.labels }}
|
|
platforms: linux/amd64
|
|
build-args: |
|
|
APP_VERSION=${{ steps.meta.outputs.version }}
|
|
GIT_SHA=${{ github.sha }}
|
|
BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}
|
|
|
|
# Chain the PROD deploy onto a green tag build.
|
|
#
|
|
# `needs: build` waits for BOTH matrix legs, so api and web at this tag are
|
|
# both in the registry before anything is deployed — deploy-galactus.yml does
|
|
# not build, it only pulls, and a half-pushed pair is exactly the state that
|
|
# leaves prod running one new image and one old one.
|
|
#
|
|
# Why a dispatch and not a `workflow_run:` trigger, which Gitea does support
|
|
# as of 1.24: deploy-galactus.yml reads `github.event.inputs.*` in ten places
|
|
# (tag, scope, bootstrap, skip_migrate). Under workflow_run every one of them
|
|
# is the empty string, so the deploy would silently run with no tag and
|
|
# scope != 'full'. A dispatch keeps that workflow's contract intact and keeps
|
|
# it hand-runnable for rollbacks, which is the whole point of it.
|
|
#
|
|
# Kill switch: set the repo variable AUTO_DEPLOY_GALACTUS to `false` to cut
|
|
# the chain and go back to dispatching the deploy by hand. Anything else
|
|
# (including unset) deploys.
|
|
deploy:
|
|
name: Deploy to galactus
|
|
needs: build
|
|
if: startsWith(github.ref, 'refs/tags/v')
|
|
runs-on: docker
|
|
container:
|
|
image: node:20-alpine
|
|
steps:
|
|
- name: Preflight — RELEASE_TOKEN
|
|
env:
|
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
run: |
|
|
set -eu
|
|
if [ -z "${RELEASE_TOKEN:-}" ]; then
|
|
echo "::error::Secret RELEASE_TOKEN is not set, so this build cannot"
|
|
echo "::error::dispatch the deploy. The images ARE published — run"
|
|
V=${GITHUB_REF#refs/tags/}
|
|
echo "::error::'Deploy to galactus' by hand with tag=${V#v}."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Dispatch deploy-galactus.yml
|
|
env:
|
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
AUTO_DEPLOY: ${{ vars.AUTO_DEPLOY_GALACTUS }}
|
|
TAG_REF: ${{ github.ref }}
|
|
run: |
|
|
node -e '
|
|
const base = `${process.env.GITHUB_SERVER_URL}/api/v1/repos/${process.env.GITHUB_REPOSITORY}`;
|
|
const headers = { Authorization: `token ${process.env.RELEASE_TOKEN}` };
|
|
// refs/tags/v1.2.3 — derived from github.ref rather than ref_name so
|
|
// it does not depend on how Gitea populates GITHUB_REF_NAME.
|
|
const tagRef = process.env.TAG_REF;
|
|
// The git tag carries the leading v; the image tag does not.
|
|
const version = tagRef.replace(/^refs\/tags\/v?/, "");
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
// Every deploy-galactus run id visible right now. A dispatch is
|
|
// only confirmed by an id that is NOT in here — a plain "is there a
|
|
// deploy run" check is satisfied by the previous release run, and
|
|
// would report success for a dispatch that never took.
|
|
const deployRunIds = async () => {
|
|
const r = await fetch(`${base}/actions/runs?limit=50`, { headers });
|
|
if (!r.ok) throw new Error(`runs query failed: HTTP ${r.status}`);
|
|
const body = await r.json();
|
|
return new Set(
|
|
(body.workflow_runs || [])
|
|
.filter((run) => String(run.path || "").includes("deploy-galactus.yml"))
|
|
.map((run) => run.id),
|
|
);
|
|
};
|
|
|
|
(async () => {
|
|
if (process.env.AUTO_DEPLOY === "false") {
|
|
console.log("AUTO_DEPLOY_GALACTUS=false — not deploying.");
|
|
console.log(`Deploy by hand with tag=${version} when ready.`);
|
|
return;
|
|
}
|
|
|
|
const before = await deployRunIds();
|
|
|
|
// Dispatch against the TAG, not master: the deploy applies the
|
|
// compose files under deploy/galactus/ from whatever ref it runs
|
|
// on, and those must be the ones this release was cut with.
|
|
const res = await fetch(
|
|
`${base}/actions/workflows/deploy-galactus.yml/dispatches`,
|
|
{
|
|
method: "POST",
|
|
headers: { ...headers, "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
ref: tagRef,
|
|
inputs: {
|
|
tag: version,
|
|
scope: "app",
|
|
bootstrap: "false",
|
|
skip_migrate: "false",
|
|
},
|
|
}),
|
|
},
|
|
);
|
|
if (!res.ok) {
|
|
console.log(`::error::Dispatch returned HTTP ${res.status}: ${await res.text()}`);
|
|
console.log(`::error::Images for ${version} are published. Run`);
|
|
console.log(`::error::"Deploy to galactus" by hand with tag=${version}.`);
|
|
process.exit(1);
|
|
}
|
|
|
|
// A 204 only means Gitea accepted the request. Confirm a NEW run
|
|
// exists, the same way release.yml confirms this build started —
|
|
// an accepted call that creates no run is the failure mode that
|
|
// cost v1.0.3 its images.
|
|
for (let i = 0; i < 3; i++) {
|
|
await sleep(5_000);
|
|
const now = await deployRunIds();
|
|
const fresh = [...now].filter((id) => !before.has(id));
|
|
if (fresh.length) {
|
|
console.log(`Deploy of ${version} to galactus is running (run ${fresh[0]}).`);
|
|
return;
|
|
}
|
|
}
|
|
|
|
console.log(`::error::Dispatch was accepted but no deploy run appeared.`);
|
|
console.log(`::error::Run "Deploy to galactus" by hand with tag=${version}.`);
|
|
process.exit(1);
|
|
})();
|
|
'
|