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"
|
||||||
@@ -5,13 +5,20 @@ rollbacks possible.
|
|||||||
|
|
||||||
## The short version
|
## The short version
|
||||||
|
|
||||||
|
Dispatch **Cut release** from the Actions tab and pick `patch`, `minor` or
|
||||||
|
`major` (or `explicit` plus a number). It stamps every `package.json`, commits
|
||||||
|
`chore(release): vX.Y.Z`, tags, and pushes both refs in one go. It refuses a
|
||||||
|
version that already exists as a tag, and refuses a no-op bump.
|
||||||
|
|
||||||
|
The equivalent by hand, if you would rather cut it locally:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm version:set 1.2.0 # stamp every package.json
|
pnpm version:set 1.2.0 # stamp every package.json
|
||||||
git commit -am "chore(release): v1.2.0"
|
git commit -am "chore(release): v1.2.0"
|
||||||
git tag v1.2.0 && git push origin master v1.2.0
|
git tag v1.2.0 && git push origin master v1.2.0
|
||||||
```
|
```
|
||||||
|
|
||||||
That push triggers `.gitea/workflows/build.yml`, which builds **both** images in
|
Either way that push triggers `.gitea/workflows/build.yml`, which builds **both** images in
|
||||||
one matrix run and publishes:
|
one matrix run and publishes:
|
||||||
|
|
||||||
| tag pushed | image tags produced |
|
| tag pushed | image tags produced |
|
||||||
@@ -28,6 +35,13 @@ Then dispatch a deploy from the Actions tab:
|
|||||||
> `{{version}}` strips it. Git tag `v1.2.0`, dispatch `1.2.0`. Dispatching
|
> `{{version}}` strips it. Git tag `v1.2.0`, dispatch `1.2.0`. Dispatching
|
||||||
> `v1.2.0` deploys nothing that exists.
|
> `v1.2.0` deploys nothing that exists.
|
||||||
|
|
||||||
|
**Cut release needs a `RELEASE_TOKEN` secret** — a Gitea PAT with
|
||||||
|
`write:repository`. It does not use the built-in Actions token on purpose:
|
||||||
|
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. If the build somehow does not start, `build.yml` has
|
||||||
|
`workflow_dispatch` — run it against the new tag by hand.
|
||||||
|
|
||||||
Because api and web are built from one matrix run, they cannot drift at build
|
Because api and web are built from one matrix run, they cannot drift at build
|
||||||
time. They *can* drift at deploy time if a stack is applied with only one image
|
time. They *can* drift at deploy time if a stack is applied with only one image
|
||||||
moved — the web footer shows both versions and flags a mismatch, and the deploy
|
moved — the web footer shows both versions and flags a mismatch, and the deploy
|
||||||
|
|||||||
Reference in New Issue
Block a user