Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
299 changes: 299 additions & 0 deletions .github/workflows/release-prepare.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
---
name: Release Prepare

# Prepares — but never publishes — a release for class-2 repositories
# (decisions/0007-release-publication-flow.md). On every push to the default
# branch it computes the next semantic version from Conventional Commits since
# the last tag, drafts a changelog with GitHub Models (falling back to a
# grouped commit list), and opens or updates a single release-proposal issue.
# The maintainer-pushed annotated tag remains the only publication act.

on:
workflow_call:
inputs:
tag-prefix:
description: Literal prefix of semantic version tags.
required: false
type: string
default: "v"
initial-version:
description: >
Version (without prefix) proposed when the repository has no
semantic tags yet.
required: false
type: string
default: "0.1.0"
model:
description: >
GitHub Models identifier used to draft the changelog. When inference
is unavailable the workflow falls back to a grouped commit list.
required: false
type: string
default: "openai/gpt-4o"
proposal-label:
description: Label applied to the release proposal issue (created when missing).
required: false
type: string
default: "release-proposal"
outputs:
version:
description: >
Proposed semantic version without prefix; empty when no release is
needed.
value: ${{ jobs.propose.outputs.version }}
previous-tag:
description: Latest existing semantic tag; empty when none exists.
value: ${{ jobs.propose.outputs.previous-tag }}
proposed:
description: >
"true" when a release proposal issue was created or updated; empty
otherwise.
value: ${{ jobs.propose.outputs.proposed }}
workflow_dispatch: {}

# Callers own concurrency; use cancel-in-progress: false on the release path.
permissions:
contents: read
issues: write
models: read

jobs:
propose:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.semver.outputs.version }}
previous-tag: ${{ steps.semver.outputs.previous-tag }}
proposed: ${{ steps.issue.outputs.proposed }}
steps:
- name: ⤵️ Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
fetch-tags: true

- name: ⚙️ Compute next semantic version
id: semver
env:
TAG_PREFIX: ${{ inputs.tag-prefix || 'v' }}
INITIAL_VERSION: ${{ inputs.initial-version || '0.1.0' }}
run: |
set -euo pipefail
dir="${RUNNER_TEMP}/release-prepare"
mkdir -p "$dir"

last_tag="$(git tag --list "${TAG_PREFIX}[0-9]*.[0-9]*.[0-9]*" --sort=-v:refname |
grep -E "^${TAG_PREFIX}[0-9]+\.[0-9]+\.[0-9]+$" | head -n 1 || true)"
echo "previous-tag=${last_tag}" >> "$GITHUB_OUTPUT"

range=""
if [ -n "$last_tag" ]; then
range="${last_tag}..HEAD"
fi
subjects="${dir}/subjects.txt"
bodies="${dir}/bodies.txt"
if [ -n "$range" ]; then
git log --no-merges --format='%s' "$range" > "$subjects"
git log --no-merges --format='%B' "$range" > "$bodies"
else
git log --no-merges --format='%s' > "$subjects"
git log --no-merges --format='%B' > "$bodies"
fi

if [ ! -s "$subjects" ]; then
echo "No commits since ${last_tag:-repository start}; nothing to propose."
echo "version=" >> "$GITHUB_OUTPUT"
exit 0
fi

bump=""
if grep -qiE '^[a-z]+(\([^)]*\))?!:' "$subjects" ||
grep -qE '^BREAKING[ -]CHANGE:' "$bodies"; then
bump="major"
elif grep -qE '^feat(\([^)]*\))?:' "$subjects"; then
bump="minor"
elif grep -qE '^(fix|perf)(\([^)]*\))?:' "$subjects"; then
bump="patch"
fi

if [ -z "$last_tag" ]; then
version="$INITIAL_VERSION"
echo "No existing ${TAG_PREFIX}X.Y.Z tag; proposing initial version ${version}."
elif [ -z "$bump" ]; then
echo "No releasable commits (feat/fix/perf/breaking) since ${last_tag}; nothing to propose."
echo "version=" >> "$GITHUB_OUTPUT"
exit 0
else
base="${last_tag#"${TAG_PREFIX}"}"
IFS=. read -r major minor patch <<< "$base"
case "$bump" in
major) version="$((major + 1)).0.0" ;;
minor) version="${major}.$((minor + 1)).0" ;;
patch) version="${major}.${minor}.$((patch + 1))" ;;
esac
echo "Proposing ${bump} bump: ${last_tag} -> ${TAG_PREFIX}${version}."
fi

if git rev-parse -q --verify "refs/tags/${TAG_PREFIX}${version}" > /dev/null; then
echo "Computed tag ${TAG_PREFIX}${version} already exists; refusing to propose." >&2
exit 1
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"

- name: ⚙️ Build commit list and fallback changelog
if: steps.semver.outputs.version != ''
env:
PREVIOUS_TAG: ${{ steps.semver.outputs.previous-tag }}
VERSION: ${{ steps.semver.outputs.version }}
TAG_PREFIX: ${{ inputs.tag-prefix || 'v' }}
run: |
set -euo pipefail
dir="${RUNNER_TEMP}/release-prepare"
commits="${dir}/commits.md"
cap=200
revs="HEAD"
if [ -n "$PREVIOUS_TAG" ]; then
revs="${PREVIOUS_TAG}..HEAD"
fi
total="$(git rev-list --no-merges --count "$revs")"
truncated=""
if [ "$total" -gt "$cap" ]; then
truncated="(Note: list truncated to the ${cap} most recent of ${total} commits.)"
fi

# Tag commits whose footer declares a breaking change so they group
# consistently with the major-bump detection in the semver step.
: > "$commits"
git rev-list --no-merges --max-count="$cap" "$revs" | while read -r sha; do
line="$(git log -1 --no-merges --format='- %s (%h)' "$sha")"
if git log -1 --format='%B' "$sha" | grep -qE '^BREAKING[ -]CHANGE:' &&
! printf '%s\n' "$line" | grep -qE '^- [A-Za-z]+(\([^)]*\))?!:'; then
line="${line} [breaking]"
fi
printf '%s\n' "$line" >> "$commits"
done

re_breaking='(^- [A-Za-z]+(\([^)]*\))?!:)|(\[breaking\]$)'
re_feat='^- feat(\([^)]*\))?:'
re_fix='^- (fix|perf)(\([^)]*\))?:'
breaking="$(grep -E "$re_breaking" "$commits" || true)"
nonbreaking="$(grep -vE "$re_breaking" "$commits" || true)"
features="$(printf '%s\n' "$nonbreaking" | grep -E "$re_feat" || true)"
fixes="$(printf '%s\n' "$nonbreaking" | grep -E "$re_fix" || true)"
other="$(printf '%s\n' "$nonbreaking" | grep -vE "($re_feat)|($re_fix)" || true)"
{
[ -n "$truncated" ] && printf '%s\n\n' "$truncated"
[ -n "$breaking" ] && printf '### Breaking changes\n\n%s\n\n' "$breaking"
[ -n "$features" ] && printf '### Features\n\n%s\n\n' "$features"
[ -n "$fixes" ] && printf '### Fixes\n\n%s\n\n' "$fixes"
[ -n "$other" ] && printf '### Other\n\n%s\n' "$other"
true
} > "${dir}/fallback.md"

{
echo "Repository: ${GITHUB_REPOSITORY}"
echo "Proposed version: ${TAG_PREFIX}${VERSION}"
if [ -n "$PREVIOUS_TAG" ]; then
echo "Previous release: ${PREVIOUS_TAG}"
else
echo "Previous release: none (first release)"
fi
echo
echo "Commits suffixed with [breaking] declare a BREAKING CHANGE footer;"
echo "list them under '### Breaking changes'."
if [ -n "$truncated" ]; then
echo "${truncated} Mention in the notes that older commits are omitted."
fi
echo
echo "Conventional commits to summarize:"
echo
cat "$commits"
} > "${dir}/prompt.txt"

- name: 🤖 Draft changelog with GitHub Models
id: ai
if: steps.semver.outputs.version != ''
continue-on-error: true
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
with:
model: ${{ inputs.model || 'openai/gpt-4o' }}
system-prompt: >
You draft changelogs for open-source Zsh ecosystem releases. From
the commit list provided, write concise Markdown release notes
grouped under "### Breaking changes", "### Features", "### Fixes"
and "### Other", omitting empty sections. One bullet per change in
plain language, keeping the short commit hash in parentheses.
Describe only the commits provided — no preamble, no invention.
prompt-file: ${{ runner.temp }}/release-prepare/prompt.txt
max-completion-tokens: "2000"

- name: 📝 Open or update release proposal issue
id: issue
if: steps.semver.outputs.version != ''
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.semver.outputs.version }}
PREVIOUS_TAG: ${{ steps.semver.outputs.previous-tag }}
TAG_PREFIX: ${{ inputs.tag-prefix || 'v' }}
PROPOSAL_LABEL: ${{ inputs.proposal-label || 'release-proposal' }}
AI_RESPONSE_FILE: ${{ steps.ai.outputs.response-file }}
run: |
set -euo pipefail
dir="${RUNNER_TEMP}/release-prepare"
tag="${TAG_PREFIX}${VERSION}"

changelog="${dir}/fallback.md"
if [ -n "${AI_RESPONSE_FILE}" ] && [ -s "${AI_RESPONSE_FILE}" ]; then
changelog="${AI_RESPONSE_FILE}"
fi

body="${dir}/body.md"
{
echo "Automated release proposal from the reusable [Release Prepare](https://github.com/z-shell/.github/blob/main/.github/workflows/release-prepare.yml) workflow."
echo
echo "- **Proposed tag:** \`${tag}\`"
if [ -n "$PREVIOUS_TAG" ]; then
echo "- **Previous tag:** \`${PREVIOUS_TAG}\`"
echo "- **Diff:** ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/compare/${PREVIOUS_TAG}...${GITHUB_SHA}"
else
echo "- **Previous tag:** none (first release)"
fi
echo "- **Proposed from:** \`${GITHUB_SHA}\`"
echo
echo "## Draft changelog"
echo
cat "$changelog"
echo
echo "## To publish"
echo
echo "Per [ADR 0007](https://github.com/z-shell/.github/blob/main/decisions/0007-release-publication-flow.md) the annotated tag is the publication boundary — review, adjust the version if needed, then:"
echo
echo '```sh'
echo "git switch ${GITHUB_REF_NAME} && git pull --ff-only"
echo "# bump any in-repo version metadata first, if this repository has it"
echo "git tag -a ${tag} -m ${tag}"
echo "git push origin ${tag}"
echo '```'
echo
echo "_Updated automatically on every push to the default branch; closing this issue without tagging skips the release._"
} > "$body"

gh label create "$PROPOSAL_LABEL" \
--repo "$GITHUB_REPOSITORY" \
--description "Automated release proposal awaiting a maintainer tag." \
--color D93F0B 2> /dev/null || true

existing="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--label "$PROPOSAL_LABEL" --json number --jq '.[0].number // empty')"
title="Release proposal: ${tag}"
if [ -n "$existing" ]; then
gh issue edit "$existing" --repo "$GITHUB_REPOSITORY" \
--title "$title" --body-file "$body"
echo "Updated release proposal issue #${existing}."
else
gh issue create --repo "$GITHUB_REPOSITORY" \
--title "$title" --body-file "$body" --label "$PROPOSAL_LABEL" ||
gh issue create --repo "$GITHUB_REPOSITORY" \
--title "$title" --body-file "$body"
echo "Created release proposal issue."
fi
echo "proposed=true" >> "$GITHUB_OUTPUT"
51 changes: 51 additions & 0 deletions runbooks/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,57 @@ Repositories that should stay out of the first pilot:
- `.github`
- `zi`

## Release preparation automation (class 2)

The reusable workflow
[`release-prepare.yml`](../.github/workflows/release-prepare.yml) automates the
_preparation_ half of the class-2 flow without moving the publication boundary.
On every push to the default branch it:

1. computes the next semantic version from Conventional Commits since the last
`vX.Y.Z` tag (`feat` → minor, `fix`/`perf` → patch, `!`/`BREAKING CHANGE` →
major; no releasable commits → clean no-op)
2. drafts a changelog with GitHub Models (`actions/ai-inference`), degrading to
a grouped commit list when inference is unavailable
3. opens or updates a single `release-proposal` issue containing the draft
notes and the exact annotated-tag commands

The maintainer-pushed annotated tag remains the only publication act, and the
repository's tag-driven `release.yml` (zunit pattern) still does the
publishing, so ADR 0007 is unchanged.

Caller snippet for a class-2 repository:

```yaml
---
name: Release Prepare

on:
push:
branches: [main]

permissions:
contents: read
issues: write
models: read

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false

jobs:
propose:
uses: z-shell/.github/.github/workflows/release-prepare.yml@main
```

Notes:

- Callers own `concurrency`; the reusable workflow does not set it.
- `models: read` enables the GitHub Models changelog draft; without it the
workflow still opens the proposal with the fallback commit list.
- Do **not** add this to class-1, class-3, or class-4 repositories — they have
no tag boundary to prepare for.

## Release-automation decision checklist

Before proposing `release-please` for a repository, confirm:
Expand Down
Loading