ci: add a "Cut release" dispatch workflow
Stamps every package.json, commits chore(release): vX.Y.Z, tags and pushes both refs in one dispatch — patch/minor/major, or an explicit number. Cutting a release from a laptop is how a manifest bump gets forgotten or a tag lands on an unpushed commit; the only input here is the number. Guards: refuses a version that already exists as a tag (releases are immutable), a no-op bump, a leading `v`, and a malformed number. Checkout is full-depth because the duplicate-tag check is meaningless against a shallow clone. Pushes with a RELEASE_TOKEN PAT rather than the built-in Actions token — whether a push made with that token re-triggers build.yml depends on the Gitea version, and a release that quietly publishes no images is worse than one that fails outright. Builds and deploys stay separate: the tag push triggers build.yml, and deploying remains a deliberate dispatch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
# 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.
|
||||
git push origin "HEAD:master" "refs/tags/v${VERSION}"
|
||||
|
||||
- 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"
|
||||
Reference in New Issue
Block a user