diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 000000000..0ece94ca4 --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -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" diff --git a/runbooks/release.md b/runbooks/release.md index 1b084eea3..608ceb1ee 100644 --- a/runbooks/release.md +++ b/runbooks/release.md @@ -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: