# 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"