mirror of
https://github.com/Sea-Haven-Industries/.github.git
synced 2026-09-30 17:33:11 +00:00
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.
286 lines
11 KiB
YAML
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."
|