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>
281 lines
12 KiB
YAML
281 lines
12 KiB
YAML
# Cut a release: stamp the version across every package.json, commit, tag, push.
|
|
#
|
|
# This does NOT build and does NOT deploy itself. Pushing the `vX.Y.Z` tag is
|
|
# what triggers both build.yml, which publishes the `X.Y.Z`, `X.Y`,
|
|
# `sha-<short>` and `latest` image tags, and deploy-on-tag.yml, which waits for
|
|
# that build to go green and then 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). A tag is the only ref that starts either chain; pushing to
|
|
# master builds images and stops there.
|
|
#
|
|
# So cutting a release DOES reach prod. To cut a version without deploying it,
|
|
# set the repo variable AUTO_DEPLOY_GALACTUS=false first; deploy-on-tag.yml 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
|
|
# only input is the number.
|
|
#
|
|
# Prereqs (once):
|
|
# - Repo secret RELEASE_TOKEN: a Gitea personal access token with
|
|
# write:repository on this repo. The built-in Actions token is deliberately
|
|
# NOT used — whether a push made with it re-triggers build.yml depends on the
|
|
# Gitea version, and a release that silently publishes no images is worse
|
|
# than one that fails. A PAT push is an ordinary push and always triggers.
|
|
# If build.yml somehow does not start, it has workflow_dispatch: run it
|
|
# against the new tag by hand.
|
|
|
|
name: Cut release
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
bump:
|
|
description: "Which part to bump (choose 'explicit' to type the number)"
|
|
type: choice
|
|
required: true
|
|
default: "minor"
|
|
options:
|
|
- patch
|
|
- minor
|
|
- major
|
|
- explicit
|
|
version:
|
|
description: "Exact version when bump=explicit (x.y.z, no leading v)"
|
|
required: false
|
|
default: ""
|
|
|
|
jobs:
|
|
release:
|
|
name: Release
|
|
runs-on: docker
|
|
container:
|
|
image: node:20-alpine
|
|
steps:
|
|
- name: Install tools
|
|
run: apk add --no-cache git
|
|
|
|
- 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. Create a Gitea PAT with"
|
|
echo "::error::write:repository and add it as a repo secret named RELEASE_TOKEN."
|
|
exit 1
|
|
fi
|
|
|
|
# Full history + tags: the duplicate-tag check below is meaningless
|
|
# against a shallow clone, which has none of them.
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
ref: master
|
|
token: ${{ secrets.RELEASE_TOKEN }}
|
|
|
|
- name: Resolve the new version
|
|
id: ver
|
|
env:
|
|
BUMP: ${{ github.event.inputs.bump }}
|
|
EXPLICIT: ${{ github.event.inputs.version }}
|
|
run: |
|
|
set -eu
|
|
CURRENT=$(node -p "require('./package.json').version")
|
|
echo "current: $CURRENT"
|
|
|
|
if [ "$BUMP" = "explicit" ]; then
|
|
NEXT="$EXPLICIT"
|
|
if [ -z "$NEXT" ]; then
|
|
echo "::error::bump=explicit requires the version input."
|
|
exit 1
|
|
fi
|
|
else
|
|
NEXT=$(node -e '
|
|
const [cur, part] = process.argv.slice(1);
|
|
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(cur);
|
|
if (!m) { console.error(`unparseable current version: ${cur}`); process.exit(1); }
|
|
let [maj, min, pat] = m.slice(1).map(Number);
|
|
if (part === "major") { maj += 1; min = 0; pat = 0; }
|
|
else if (part === "minor") { min += 1; pat = 0; }
|
|
else { pat += 1; }
|
|
process.stdout.write(`${maj}.${min}.${pat}`);
|
|
' "$CURRENT" "$BUMP")
|
|
fi
|
|
|
|
# set-version.mjs validates the shape too, but failing here keeps the
|
|
# working tree clean when the input is a typo.
|
|
case "$NEXT" in
|
|
v*) echo "::error::Version must not carry a leading 'v' (got $NEXT)."; exit 1 ;;
|
|
esac
|
|
if ! printf '%s' "$NEXT" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
|
echo "::error::Invalid version: $NEXT (expected x.y.z)."
|
|
exit 1
|
|
fi
|
|
if [ "$NEXT" = "$CURRENT" ]; then
|
|
echo "::error::$NEXT is already the current version."
|
|
exit 1
|
|
fi
|
|
if git rev-parse -q --verify "refs/tags/v$NEXT" >/dev/null; then
|
|
echo "::error::Tag v$NEXT already exists. Releases are immutable — pick a new number."
|
|
exit 1
|
|
fi
|
|
|
|
echo "next: $NEXT"
|
|
echo "version=$NEXT" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Stamp the version across every manifest
|
|
run: node scripts/set-version.mjs "${{ steps.ver.outputs.version }}"
|
|
|
|
# A release whose only content is the version bump means the dispatch was
|
|
# a mistake — set-version.mjs already refused a no-op above, so an empty
|
|
# diff here means the manifests were somehow already at this number.
|
|
- name: Commit, tag, push
|
|
env:
|
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
VERSION: ${{ steps.ver.outputs.version }}
|
|
ACTOR: ${{ github.actor }}
|
|
run: |
|
|
set -eu
|
|
if git diff --quiet; then
|
|
echo "::error::No manifest changed. Nothing to release."
|
|
exit 1
|
|
fi
|
|
|
|
git config user.name "gitea-actions"
|
|
git config user.email "actions@git.mancinas.io"
|
|
|
|
git commit -a \
|
|
-m "chore(release): v${VERSION}" \
|
|
-m "Cut by ${ACTOR} via the \"Cut release\" workflow. Pushing the tag triggers build.yml; deploy separately with tag=${VERSION}."
|
|
git tag -a "v${VERSION}" -m "v${VERSION}"
|
|
|
|
# Re-point at an authenticated remote. The token is a secret, so Gitea
|
|
# masks it in the log; nothing here echoes the URL regardless.
|
|
git remote set-url origin \
|
|
"$(printf '%s' "${GITHUB_SERVER_URL}" | sed "s#://#://x-access-token:${RELEASE_TOKEN}@#")/${GITHUB_REPOSITORY}.git"
|
|
|
|
# One push for both refs: a commit that lands without its tag builds
|
|
# nothing and looks like a successful release.
|
|
#
|
|
# The output is captured because a failing *post-receive* hook does not
|
|
# fail the push: git prints `remote: error: ...`, updates both refs and
|
|
# exits 0. That is how v1.0.3 was cut — the hook 500'd, so Gitea never
|
|
# created the build run, and this step went green anyway.
|
|
if ! git push origin "HEAD:master" "refs/tags/v${VERSION}" 2>push.log; then
|
|
cat push.log
|
|
echo "::error::Push failed. Nothing was released."
|
|
exit 1
|
|
fi
|
|
cat push.log
|
|
|
|
if grep -q '^remote: error' push.log; then
|
|
echo "::warning::The remote's post-receive hook errored. Both refs landed,"
|
|
echo "::warning::but Gitea most likely created no workflow run for them."
|
|
echo "::warning::The next step checks and dispatches build.yml if needed."
|
|
fi
|
|
|
|
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
|
id: push
|
|
|
|
# Gitea creates workflow runs from the post-receive hook, so a hook error
|
|
# silently costs you the build: the tag exists, no image is ever published,
|
|
# and the failure only surfaces later as a 404 when deploy pulls the image.
|
|
# Confirm the run exists; dispatch it if it does not; fail loudly if that
|
|
# does not work either.
|
|
- name: Verify build.yml started
|
|
env:
|
|
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
VERSION: ${{ steps.ver.outputs.version }}
|
|
SHA: ${{ steps.push.outputs.sha }}
|
|
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}` };
|
|
const sha = process.env.SHA;
|
|
const tag = `v${process.env.VERSION}`;
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
|
// The master push and the tag push carry the SAME commit, so a sha
|
|
// match alone is not enough: build.yml skips the master run by
|
|
// design, and that skipped run would satisfy a sha-only check even
|
|
// if the tag run were never created. When the API reports a ref for
|
|
// the run, require it to be the tag; when it reports none, fall back
|
|
// to the sha match rather than failing a release over a field name.
|
|
const isTagRun = (r) => {
|
|
const ref = r.head_branch || r.ref || "";
|
|
return !ref || ref === tag || ref === `refs/tags/${tag}`;
|
|
};
|
|
|
|
const started = async () => {
|
|
const res = await fetch(`${base}/actions/runs?limit=30`, { headers });
|
|
if (!res.ok) throw new Error(`runs query failed: HTTP ${res.status}`);
|
|
const body = await res.json();
|
|
return (body.workflow_runs || []).some(
|
|
(r) =>
|
|
r.head_sha === sha &&
|
|
String(r.path || "").includes("build.yml") &&
|
|
isTagRun(r),
|
|
);
|
|
};
|
|
|
|
// The hook fires synchronously with the push, so a run that is coming
|
|
// is usually already there; the retries cover a busy instance.
|
|
const poll = async (attempts) => {
|
|
for (let i = 0; i < attempts; i++) {
|
|
if (await started()) return true;
|
|
await sleep(10_000);
|
|
}
|
|
return started();
|
|
};
|
|
|
|
(async () => {
|
|
if (await poll(3)) {
|
|
console.log(`build.yml is running for ${sha}.`);
|
|
return;
|
|
}
|
|
|
|
console.log(`No build.yml run for ${sha}. Dispatching against ${tag}.`);
|
|
const res = await fetch(
|
|
`${base}/actions/workflows/build.yml/dispatches`,
|
|
{
|
|
method: "POST",
|
|
headers: { ...headers, "Content-Type": "application/json" },
|
|
// Must be the tag, not master: metadata-action only emits the
|
|
// X.Y.Z and X.Y image tags when the ref is a semver tag. And
|
|
// it must be the fully qualified ref — Gitea 404s on `v1.0.3`.
|
|
body: JSON.stringify({ ref: `refs/tags/${tag}` }),
|
|
},
|
|
);
|
|
if (!res.ok) console.log(`Dispatch returned HTTP ${res.status}.`);
|
|
|
|
if (await poll(3)) {
|
|
console.log(`build.yml is running for ${sha}.`);
|
|
return;
|
|
}
|
|
|
|
console.log(`::error::${tag} is pushed but nothing is building it, and`);
|
|
console.log(`::error::the dispatch did not take. Run "Build and Push Images"`);
|
|
console.log(`::error::by hand with ref=${tag} (the tag, not master), then`);
|
|
console.log(`::error::deploy. Check the Gitea server log for the`);
|
|
console.log(`::error::post-receive error while you are at it.`);
|
|
process.exit(1);
|
|
})();
|
|
'
|
|
|
|
- name: Summary
|
|
env:
|
|
VERSION: ${{ steps.ver.outputs.version }}
|
|
run: |
|
|
set -eu
|
|
echo "Released v${VERSION}."
|
|
echo ""
|
|
echo "build.yml is now building git.mancinas.io/rmancinas/jorgecuadros-{api,web}:${VERSION}."
|
|
echo "deploy-on-tag.yml is watching that build; when it goes green it dispatches"
|
|
echo "'Deploy to galactus' 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."
|