# Cut a release: stamp the version across every package.json, commit, tag, push. # # This does NOT build and does NOT deploy itself. Pushing the `vX.Y.Z` tag is # what triggers both build.yml, which publishes the `X.Y.Z`, `X.Y`, # `sha-` and `latest` image tags, and deploy-on-tag.yml, which waits for # that build to go green and then dispatches deploy-galactus.yml with # `tag=X.Y.Z scope=app` (no leading v — the git tag carries the `v`, the image # tag does not). A tag is the only ref that starts either chain; pushing to # master builds images and stops there. # # So cutting a release DOES reach prod. To cut a version without deploying it, # set the repo variable AUTO_DEPLOY_GALACTUS=false first; deploy-on-tag.yml then # prints the manual command instead of running it. # # 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)); // The master push and the tag push carry the SAME commit, so a sha // match alone is not enough: build.yml skips the master run by // design, and that skipped run would satisfy a sha-only check even // if the tag run were never created. When the API reports a ref for // the run, require it to be the tag; when it reports none, fall back // to the sha match rather than failing a release over a field name. const isTagRun = (r) => { const ref = r.head_branch || r.ref || ""; return !ref || ref === tag || ref === `refs/tags/${tag}`; }; 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") && isTagRun(r), ); }; // 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 "deploy-on-tag.yml is watching that build; when it goes green it dispatches" echo "'Deploy to galactus' with tag=${VERSION} scope=app bootstrap=false skip_migrate=false." echo "" echo "Watch that run. If it did not start (or AUTO_DEPLOY_GALACTUS=false)," echo "dispatch 'Deploy to galactus' by hand with the same inputs." echo "Rollback = re-dispatch it with an older tag."