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>
279 lines
12 KiB
YAML
279 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 build.yml, which publishes `X.Y.Z`, `X.Y`, `sha-<short>` 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
|
|
# 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 "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."
|