# 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) # - the branch that was pushed (e.g. master, feat-foo) # - sha- 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-` 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); })(); '