.github/.github/workflows/release.yaml
Adam Moussa 7c0c6ab984
docs(release): point the reusable at this repo's own caller
release.yaml documented a generic caller example but not the caller that
actually exists in this repo. Notes that release-on-merge.yaml computes the
version, calls this workflow by local path, and is the only caller that does
so without a version comment.
2026-07-28 17:13:44 -04:00

286 lines
11 KiB
YAML

name: Release — Tag and GitHub Release
# Reusable release workflow: creates an annotated git tag at a commit and
# publishes a GitHub Release pointing at it.
#
# Version derivation is an INPUT, not read from a manifest and not computed.
# That follows what the org's repos actually contain:
# - 3 of 23 non-archived repos carry any tag at all; every existing tag is
# `vMAJOR.MINOR.PATCH`, which is why `tag-prefix` defaults to "v".
# - `package.json` "version" is not maintained as a release marker where it
# exists: seahaven-door-unlock-api sits at "1.0.0" while its tags reach
# v3.0.0, and payments-dashboard sits at "1.0.0" with no tags at all.
# - No repo has a VERSION file, and the repos that tag are Python/SAM,
# TypeScript/CDK and Python-desktop, so there is no one manifest to read.
# - The single repo that derives a version from a file
# (afterhours-shift-manager, from CHANGELOG.md) does it with two
# repo-local parser scripts under `scripts/`, which a reusable workflow
# cannot assume exist.
# An explicit input is therefore the only derivation that works unchanged for
# every repo here. A repo that does maintain a machine-readable version can
# still pass it: `version: ${{ needs.x.outputs.version }}`.
#
# Re-running on a version that is already released is a no-op, not a failure:
# the tag and the Release are both checked first, and either one being present
# skips every mutating step. This mirrors afterhours-shift-manager's release
# job, which likewise no-ops when the Release already exists.
#
# Safe in a repo with no releases yet: the "previous tag" lookup tolerates zero
# tags, and `gh release create --generate-notes` falls back to the full commit
# history when there is no earlier release to diff against.
#
# Caller example:
# jobs:
# release:
# uses: Sea-Haven-Industries/.github/.github/workflows/release.yaml@<full-commit-sha> # main
# with:
# version: ${{ inputs.version }}
#
# This repo's own caller is .github/workflows/release-on-merge.yaml, which
# computes the version and calls this workflow by local path rather than by
# SHA. It runs on merges to main that touch a workflow carrying `on:
# workflow_call`, so a change to a reusable produces a tag other repos can pin
# to. It is also the one caller that reaches this workflow without a version
# comment, because a repo cannot usefully pin to a commit of itself.
on:
workflow_call:
inputs:
version:
description: 'Version to release, e.g. "1.4.0". A leading "v" is accepted and stripped.'
type: string
required: true
tag-prefix:
description: "Prefix placed in front of the version to form the tag name"
type: string
default: "v"
target:
description: "Commit-ish to tag. Empty means the commit the workflow was triggered on."
type: string
default: ""
notes-file:
description: "Path to a file whose contents become the release notes. Empty means use generate-notes."
type: string
default: ""
generate-notes:
description: "Let GitHub generate release notes from commits when notes-file is empty"
type: boolean
default: true
title:
description: "Release title. Empty means use the tag name."
type: string
default: ""
draft:
description: "Publish the Release as a draft"
type: boolean
default: false
prerelease:
description: "Mark the Release as a prerelease"
type: boolean
default: false
timeout-minutes:
description: "Job timeout in minutes"
type: number
default: 10
outputs:
tag:
description: "The tag name that was created, or that already existed"
value: ${{ jobs.release.outputs.tag }}
version:
description: "The normalised version, without the tag prefix"
value: ${{ jobs.release.outputs.version }}
released:
description: '"true" when this run created the tag and Release, "false" when it skipped'
value: ${{ jobs.release.outputs.released }}
url:
description: "URL of the Release this run created. Empty when the run skipped."
value: ${{ jobs.release.outputs.url }}
# Only `contents: write` — needed to push the tag and publish the Release.
# Nothing here mints an OIDC token, so `id-token` is deliberately not granted.
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: ${{ inputs.timeout-minutes }}
# Serialise per tag so two runs cannot race to create the same release.
# version is required and tag-prefix always defaults, so the group is never
# empty. cancel-in-progress is FALSE on purpose: unlike CI, aborting midway
# can leave a pushed tag with no Release attached to it.
concurrency:
group: release-${{ github.repository }}-${{ inputs.tag-prefix }}${{ inputs.version }}
cancel-in-progress: false
outputs:
tag: ${{ steps.resolve.outputs.tag }}
version: ${{ steps.resolve.outputs.version }}
released: ${{ steps.publish.outputs.released || 'false' }}
url: ${{ steps.publish.outputs.url }}
steps:
- uses: actions/checkout@v7
with:
# Full history + tags: the existing-tag guard reads local refs.
fetch-depth: 0
fetch-tags: true
ref: ${{ inputs.target }}
- name: Resolve and validate version
id: resolve
# Inputs are passed through env, never interpolated into the script
# body, so a caller cannot inject shell into this step.
env:
RAW_VERSION: ${{ inputs.version }}
TAG_PREFIX: ${{ inputs.tag-prefix }}
run: |
set -euo pipefail
version="${RAW_VERSION#v}"
if ! printf '%s' "${version}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'; then
echo "::error::version '${RAW_VERSION}' is not MAJOR.MINOR.PATCH with an optional -prerelease/+build suffix."
exit 1
fi
tag="${TAG_PREFIX}${version}"
{
echo "version=${version}"
echo "tag=${tag}"
} >> "${GITHUB_OUTPUT}"
echo "Resolved ${RAW_VERSION} to tag ${tag}."
- name: Check whether the tag or Release already exists
id: guard
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.resolve.outputs.tag }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
skip=false
reason=""
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
skip=true
reason="tag ${TAG} already exists locally"
elif [ -n "$(git ls-remote --tags origin "refs/tags/${TAG}")" ]; then
skip=true
reason="tag ${TAG} already exists on the remote"
elif gh release view "${TAG}" --repo "${REPO}" >/dev/null 2>&1; then
skip=true
reason="a Release for ${TAG} is already published"
fi
echo "skip=${skip}" >> "${GITHUB_OUTPUT}"
if [ "${skip}" = "true" ]; then
echo "::notice::Skipping — ${reason}."
else
echo "No existing tag or Release for ${TAG}."
fi
- name: Report the previous tag
if: ${{ steps.guard.outputs.skip == 'false' }}
run: |
set -euo pipefail
previous="$(git tag -l --sort=-v:refname | head -n 1)"
if [ -z "${previous}" ]; then
echo "No previous tag in this repository — this is the first release."
else
echo "Previous tag: ${previous}"
fi
- name: Resolve release notes
id: notes
if: ${{ steps.guard.outputs.skip == 'false' }}
env:
NOTES_FILE: ${{ inputs.notes-file }}
GENERATE_NOTES: ${{ inputs.generate-notes }}
run: |
set -euo pipefail
if [ -n "${NOTES_FILE}" ]; then
if [ ! -f "${NOTES_FILE}" ]; then
echo "::error::notes-file '${NOTES_FILE}' does not exist."
exit 1
fi
echo "mode=file" >> "${GITHUB_OUTPUT}"
echo "Using release notes from ${NOTES_FILE}."
elif [ "${GENERATE_NOTES}" = "true" ]; then
echo "mode=generate" >> "${GITHUB_OUTPUT}"
echo "Release notes will be generated from commit history."
else
echo "mode=empty" >> "${GITHUB_OUTPUT}"
echo "Publishing with empty release notes."
fi
- name: Create and push the annotated tag
if: ${{ steps.guard.outputs.skip == 'false' }}
env:
TAG: ${{ steps.resolve.outputs.tag }}
run: |
set -euo pipefail
# -a makes this an annotated tag: it is a real tag object carrying a
# tagger, a date and a message, unlike a lightweight ref.
git -c user.name='github-actions[bot]' \
-c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
tag -a "${TAG}" -m "${TAG}" "${GITHUB_SHA}"
git push origin "refs/tags/${TAG}"
echo "Pushed annotated tag ${TAG} at ${GITHUB_SHA}."
- name: Publish the GitHub Release
id: publish
if: ${{ steps.guard.outputs.skip == 'false' }}
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ steps.resolve.outputs.tag }}
TITLE: ${{ inputs.title }}
NOTES_MODE: ${{ steps.notes.outputs.mode }}
NOTES_FILE: ${{ inputs.notes-file }}
DRAFT: ${{ inputs.draft }}
PRERELEASE: ${{ inputs.prerelease }}
run: |
set -euo pipefail
args=(release create "${TAG}" --repo "${REPO}" --verify-tag)
args+=(--title "${TITLE:-${TAG}}")
case "${NOTES_MODE}" in
file) args+=(--notes-file "${NOTES_FILE}") ;;
generate) args+=(--generate-notes) ;;
*) args+=(--notes "") ;;
esac
if [ "${DRAFT}" = "true" ]; then
args+=(--draft)
fi
if [ "${PRERELEASE}" = "true" ]; then
args+=(--prerelease)
fi
gh "${args[@]}"
url="$(gh release view "${TAG}" --repo "${REPO}" --json url --jq .url)"
{
echo "released=true"
echo "url=${url}"
} >> "${GITHUB_OUTPUT}"
echo "Published ${url}"
- name: Report a skipped run
if: ${{ steps.guard.outputs.skip == 'true' }}
env:
TAG: ${{ steps.resolve.outputs.tag }}
run: echo "${TAG} was already released. Nothing to do."