ci: move the galactus deploy chain to a tag-only workflow
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m39s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m17s

The deploy was a job inside build.yml gated by
`if: startsWith(github.ref, 'refs/tags/v')`. Gitea draws every job into the
run graph before it evaluates that `if`, so an ordinary push to master showed
a pending "Deploy to galactus" — indistinguishable from prod being about to be
redeployed off an unreleased commit, and the only safe reaction is to cancel
the run, which takes the images down with it.

The gate itself was never wrong (no deploy-galactus run has ever been created
from a branch ref), but a guarantee you cannot see is not much of a guarantee.
`on: push: tags: ["v*"]` in a workflow of its own makes it structural: the
deploy cannot appear on a master build because the workflow does not exist
there.

It replaces `needs: build` by polling the Actions API for the build.yml run at
this tag and requiring it green, so both images are still known to be in the
registry before anything is pulled. AUTO_DEPLOY_GALACTUS still cuts the chain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 22:28:02 -07:00
co-authored by Claude Opus 5
parent 2620559975
commit b12382b436
3 changed files with 218 additions and 134 deletions
+9 -126
View File
@@ -15,8 +15,15 @@
# 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).
# -> deploy-on-tag.yml waits for this run to go green and then
# dispatches deploy-galactus.yml.
#
# This workflow BUILDS ONLY — it never deploys. The deploy chain used to be a
# job here, gated to tag refs, but Gitea draws every job of a workflow into the
# run graph before it evaluates the job's `if`: a routine master build showed a
# pending "Deploy to galactus" and looked like prod was about to be redeployed
# off an unreleased commit. Keeping the deploy in a `on: push: tags` workflow of
# its own makes that structurally impossible.
name: Build and Push Images
@@ -101,127 +108,3 @@ jobs:
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);
})();
'