From a8afd87c3f91cac69a8a5ff622c86470e9bc1d7e Mon Sep 17 00:00:00 2001 From: Ricardo Mancinas Date: Sat, 1 Aug 2026 01:21:13 -0700 Subject: [PATCH] ci: add a "Cut release" dispatch workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitea/workflows/release.yml | 166 ++++++++++++++++++++++++++++++++++ docs/DEPLOY_AND_MIGRATIONS.md | 16 +++- 2 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 .gitea/workflows/release.yml diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..1072c02 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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-` 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" diff --git a/docs/DEPLOY_AND_MIGRATIONS.md b/docs/DEPLOY_AND_MIGRATIONS.md index 0e008b2..8298f65 100644 --- a/docs/DEPLOY_AND_MIGRATIONS.md +++ b/docs/DEPLOY_AND_MIGRATIONS.md @@ -5,13 +5,20 @@ rollbacks possible. ## 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 pnpm version:set 1.2.0 # stamp every package.json git commit -am "chore(release): 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: | 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 > `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 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