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>
271 lines
11 KiB
YAML
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"
|