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