Files
jorgecuadros-platform/.gitea/workflows/release.yml
T
rmancinasandClaude Opus 5 e589bda28b
Build and Push Images / Build jorgecuadros-api (push) Canceled after 1m10s
Build and Push Images / Build jorgecuadros-web (push) Canceled after 1m8s
ci(build): skip the redundant master build when a release is cut
release.yml pushes the release commit and its tag in a single `git push`,
so Gitea created two build.yml runs for the same commit. Only the tag run
matters: it emits the X.Y.Z and X.Y image tags, and since it is the same
commit it publishes `latest` and `sha-<short>` as well. The master run was
pure duplicate work that had to be waited out or cancelled by hand.

Guard the build job with an `if` that skips a branch push whose head commit
message starts with `chore(release):`. Ordinary pushes to master are
unaffected, and tag pushes and manual dispatches always build.

The skipped master run keeps the release commit's sha, which would have let
release.yml's "Verify build.yml started" check go green on it alone even if
the tag run were never created — the exact failure that check exists to
catch. It now also requires the run's ref to be the tag, falling back to the
sha match only when the API reports no ref.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 02:28:09 -07:00

271 lines
11 KiB
YAML

# 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-<short>` 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
# carries the `v`, the image tag does not).
#
# 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 it is green, dispatch 'Deploy to galactus' with:"
echo " tag=${VERSION} scope=app bootstrap=false skip_migrate=false"