Gitea's workflow dispatch API 404s on a bare `v1.0.3` and accepts only
`refs/tags/v1.0.3`, so the fallback added in fdbe9fd would have failed
the release instead of rescuing it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
257 lines
11 KiB
YAML
257 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));
|
|
|
|
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"),
|
|
);
|
|
};
|
|
|
|
// 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"
|