diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index b1c14d8..4690100 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -14,7 +14,9 @@ # 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. +# 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 @@ -99,3 +101,127 @@ 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); + })(); + ' diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 2d6a25b..29e6478 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -1,11 +1,15 @@ # Cut a release: stamp the version across every package.json, commit, tag, push. # -# This does NOT build and does NOT deploy. Pushing the `vX.Y.Z` tag is what -# triggers build.yml, which publishes `X.Y.Z`, `X.Y`, `sha-` and `latest` -# image tags. Deploying stays a separate, deliberate act: once the build is -# green, dispatch deploy-galactus.yml with `tag=X.Y.Z` (no leading v — the tag +# This does NOT build and does NOT deploy itself. Pushing the `vX.Y.Z` tag is +# what triggers build.yml, which publishes `X.Y.Z`, `X.Y`, `sha-` and +# `latest` image tags — and then, on a green tag build only, dispatches +# deploy-galactus.yml with `tag=X.Y.Z scope=app` (no leading v — the git tag # carries the `v`, the image tag does not). # +# So cutting a release DOES reach prod. To cut a version without deploying it, +# set the repo variable AUTO_DEPLOY_GALACTUS=false first; build.yml's `deploy` +# job then prints the manual command instead of running it. +# # Why a workflow instead of three local commands: the release commit is the one # thing that must be identical every time, and cutting it from a laptop is how # a manifest bump gets forgotten or a tag lands on an unpushed commit. Here the @@ -266,5 +270,9 @@ jobs: echo "Released v${VERSION}." echo "" echo "build.yml is now building git.mancinas.io/rmancinas/jorgecuadros-{api,web}:${VERSION}." - echo "When it is green, dispatch 'Deploy to galactus' with:" - echo " tag=${VERSION} scope=app bootstrap=false skip_migrate=false" + echo "When both images are pushed, build.yml dispatches 'Deploy to galactus'" + echo "automatically with tag=${VERSION} scope=app bootstrap=false skip_migrate=false." + echo "" + echo "Watch that run. If it did not start (or AUTO_DEPLOY_GALACTUS=false)," + echo "dispatch 'Deploy to galactus' by hand with the same inputs." + echo "Rollback = re-dispatch it with an older tag."