From 7ffa5431a5ca70996bce0c0ab932a47107bb71b3 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:47:25 -0400 Subject: [PATCH 1/2] Automate safe release selection Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/publish-release.sh | 74 +++++++++++ .github/scripts/release-policy.cjs | 135 +++++++++++++++++++++ .github/scripts/release-version.sh | 38 ++++++ .github/scripts/update-draft-release.sh | 61 ++++++++++ .github/workflows/automatic-release.yml | 89 ++++++++++++++ .github/workflows/release.yml | 68 +++++------ .github/workflows/update-draft-release.yml | 68 +++++++++++ __tests__/release-policy.test.ts | 71 +++++++++++ 8 files changed, 570 insertions(+), 34 deletions(-) create mode 100755 .github/scripts/publish-release.sh create mode 100644 .github/scripts/release-policy.cjs create mode 100755 .github/scripts/release-version.sh create mode 100755 .github/scripts/update-draft-release.sh create mode 100644 .github/workflows/automatic-release.yml create mode 100644 .github/workflows/update-draft-release.yml create mode 100644 __tests__/release-policy.test.ts diff --git a/.github/scripts/publish-release.sh b/.github/scripts/publish-release.sh new file mode 100755 index 0000000..71e6c76 --- /dev/null +++ b/.github/scripts/publish-release.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +NEXT=$1 +MAJOR_TAG=$2 +NOTES_FILE=$3 +AUTOMATIC=$4 +RELEASES_FILE="$RUNNER_TEMP/releases.json" + +gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/releases?per_page=100" > "$RELEASES_FILE" + +MATCH_COUNT=$( + jq --arg tag "$NEXT" '[.[][] | select(.tag_name == $tag)] | length' \ + "$RELEASES_FILE" +) +if [ "$MATCH_COUNT" -gt 1 ]; then + echo "::error::Multiple releases already use $NEXT" + exit 1 +fi + +MATCH_ID=$( + jq -r --arg tag "$NEXT" \ + '[.[][] | select(.tag_name == $tag)][0].id // empty' \ + "$RELEASES_FILE" +) +MATCH_DRAFT=$( + jq -r --arg tag "$NEXT" \ + '[.[][] | select(.tag_name == $tag)][0].draft // empty' \ + "$RELEASES_FILE" +) + +if [ "$AUTOMATIC" = "true" ] && [ "$MATCH_DRAFT" = "true" ]; then + echo "::error::Automatic patch release refuses to publish existing draft $NEXT" + exit 1 +fi + +if [ -n "$MATCH_ID" ]; then + if [ "$MATCH_DRAFT" != "true" ]; then + echo "::error::$NEXT is already published" + exit 1 + fi + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$MATCH_ID" \ + -f tag_name="$NEXT" \ + -f name="$NEXT" \ + -F body=@"$NOTES_FILE" \ + -F draft=false \ + -f target_commitish="$GITHUB_SHA" +else + gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ + -f tag_name="$NEXT" \ + -f name="$NEXT" \ + -F body=@"$NOTES_FILE" \ + -F draft=false \ + -f target_commitish="$GITHUB_SHA" +fi + +git fetch --force --tags origin +HIGHEST=$( + gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/releases?per_page=100" | + jq -r '.[][] | select((.draft | not) and (.prerelease | not)) | .tag_name' | + awk -v major="${MAJOR_TAG#v}" \ + '$0 ~ ("^v" major "\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$")' | + sort -V | + tail -n 1 +) +if [ -z "$HIGHEST" ]; then + echo "::error::No stable release found for $MAJOR_TAG" + exit 1 +fi + +git tag -f "$MAJOR_TAG" "${HIGHEST}^{commit}" +git push origin "refs/tags/$MAJOR_TAG" --force diff --git a/.github/scripts/release-policy.cjs b/.github/scripts/release-policy.cjs new file mode 100644 index 0000000..e8ebc35 --- /dev/null +++ b/.github/scripts/release-policy.cjs @@ -0,0 +1,135 @@ +const {execFileSync} = require('node:child_process') +const fs = require('node:fs') + +const RUNTIME_MANIFEST_KEYS = new Set([ + 'dependencies', + 'optionalDependencies', + 'overrides', + 'peerDependencies' +]) + +function equal(left, right) { + return JSON.stringify(left) === JSON.stringify(right) +} + +function withoutRuntimeDependencies(manifest) { + return Object.fromEntries( + Object.entries(manifest).filter(([key]) => !RUNTIME_MANIFEST_KEYS.has(key)) + ) +} + +function runtimeDependenciesChanged(baseManifest, headManifest) { + return [...RUNTIME_MANIFEST_KEYS].some( + key => !equal(baseManifest[key], headManifest[key]) + ) +} + +function isAccompanyingFile(path) { + return ( + path.startsWith('.github/') || + path.startsWith('__tests__/') || + path.startsWith('test/') || + path.startsWith('tests/') || + path.startsWith('docs/') || + path.endsWith('.md') + ) +} + +function classifyRelease(changedFiles, baseManifest, headManifest) { + const files = [...new Set(changedFiles)] + const hasSource = files.some(path => path.startsWith('src/')) + const hasDist = files.some(path => path.startsWith('dist/')) + const hasAction = files.includes('action.yml') + const hasPackage = files.includes('package.json') + const hasLock = files.includes('package-lock.json') + const hasShippedChange = hasSource || hasDist || hasAction + + if (!hasShippedChange) { + return {kind: 'none', reason: 'No shipped files changed'} + } + + if (hasSource && !hasDist) { + return { + kind: 'stale-bundle', + reason: 'src/** changed without a rebuilt dist/** bundle' + } + } + + const knownFiles = files.every( + path => + path.startsWith('src/') || + path.startsWith('dist/') || + path === 'action.yml' || + path === 'package.json' || + path === 'package-lock.json' || + isAccompanyingFile(path) + ) + const dependencyOnlyManifest = + hasPackage && + hasLock && + runtimeDependenciesChanged(baseManifest, headManifest) && + equal( + withoutRuntimeDependencies(baseManifest), + withoutRuntimeDependencies(headManifest) + ) + + if ( + hasDist && + !hasSource && + !hasAction && + knownFiles && + dependencyOnlyManifest + ) { + return { + kind: 'patch', + reason: 'Only bundled runtime dependencies changed' + } + } + + return { + kind: 'minor', + reason: knownFiles + ? 'Potentially behavioral shipped files changed' + : 'Unknown files accompany shipped changes' + } +} + +function readManifest(ref) { + return JSON.parse( + execFileSync('git', ['show', `${ref}:package.json`], {encoding: 'utf8'}) + ) +} + +function changedFiles(base, head) { + return execFileSync('git', ['diff', '--name-only', '-z', base, head]) + .toString() + .split('\0') + .filter(Boolean) +} + +function writeOutput(result) { + const output = process.env.GITHUB_OUTPUT + if (!output) return + + fs.appendFileSync( + output, + `classification=${result.kind}\nreason=${result.reason}\n` + ) +} + +if (require.main === module) { + const [base, head = 'HEAD'] = process.argv.slice(2) + if (!base) { + throw new Error('Usage: release-policy.cjs [head-ref]') + } + + const result = classifyRelease( + changedFiles(base, head), + readManifest(base), + readManifest(head) + ) + writeOutput(result) + console.log(`${result.kind}: ${result.reason}`) +} + +module.exports = {classifyRelease} diff --git a/.github/scripts/release-version.sh b/.github/scripts/release-version.sh new file mode 100755 index 0000000..a9b5391 --- /dev/null +++ b/.github/scripts/release-version.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +BUMP=$1 +EXPECTED_BASE=${2:-} + +LATEST=$( + git tag --list | + awk '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/' | + sort -V | + tail -n 1 +) +if [ -z "$LATEST" ]; then + LATEST="v0.0.0" +fi +if [ -n "$EXPECTED_BASE" ] && [ "$EXPECTED_BASE" != "$LATEST" ]; then + echo "::error::Latest stable release advanced from $EXPECTED_BASE to $LATEST; retry automation" + exit 1 +fi + +case "$BUMP" in + major|minor|patch) ;; + *) + echo "::error::Invalid version bump: $BUMP" + exit 1 + ;; +esac + +IFS='.' read -r MAJOR MINOR PATCH <<< "${LATEST#v}" +case "$BUMP" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; +esac + +echo "previous=$LATEST" >> "$GITHUB_OUTPUT" +echo "next=v${MAJOR}.${MINOR}.${PATCH}" >> "$GITHUB_OUTPUT" +echo "major=v${MAJOR}" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/update-draft-release.sh b/.github/scripts/update-draft-release.sh new file mode 100755 index 0000000..ad3adb6 --- /dev/null +++ b/.github/scripts/update-draft-release.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +NEXT=$1 +NOTES_FILE=$2 +RELEASES_FILE="$RUNNER_TEMP/releases.json" + +gh api --paginate --slurp \ + "repos/$GITHUB_REPOSITORY/releases?per_page=100" > "$RELEASES_FILE" + +MATCH_COUNT=$( + jq --arg tag "$NEXT" '[.[][] | select(.tag_name == $tag)] | length' \ + "$RELEASES_FILE" +) +if [ "$MATCH_COUNT" -gt 1 ]; then + echo "::error::Multiple releases already use $NEXT" + exit 1 +fi + +MATCH_ID=$( + jq -r --arg tag "$NEXT" \ + '[.[][] | select(.tag_name == $tag)][0].id // empty' \ + "$RELEASES_FILE" +) +MATCH_DRAFT=$( + jq -r --arg tag "$NEXT" \ + '[.[][] | select(.tag_name == $tag)][0].draft // empty' \ + "$RELEASES_FILE" +) + +if [ -n "$MATCH_ID" ] && [ "$MATCH_DRAFT" != "true" ]; then + echo "::error::$NEXT already exists as a published release" + exit 1 +fi + +OTHER_DRAFTS=$( + jq -r '.[][] | select(.draft) | .tag_name' "$RELEASES_FILE" | + awk '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/' | + awk -v next="$NEXT" '$0 != next' | + sort -V +) +if [ -n "$OTHER_DRAFTS" ]; then + echo "::error::Conflicting or stale draft release(s) must be resolved: ${OTHER_DRAFTS//$'\n'/, }" + exit 1 +fi + +if [ -n "$MATCH_ID" ]; then + gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$MATCH_ID" \ + -f tag_name="$NEXT" \ + -f name="$NEXT" \ + -F body=@"$NOTES_FILE" \ + -F draft=true \ + -f target_commitish="$GITHUB_SHA" +else + gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ + -f tag_name="$NEXT" \ + -f name="$NEXT" \ + -F body=@"$NOTES_FILE" \ + -F draft=true \ + -f target_commitish="$GITHUB_SHA" +fi diff --git a/.github/workflows/automatic-release.yml b/.github/workflows/automatic-release.yml new file mode 100644 index 0000000..5da4d0a --- /dev/null +++ b/.github/workflows/automatic-release.yml @@ -0,0 +1,89 @@ +name: Automatic release + +on: + schedule: + - cron: '17 13 * * *' + workflow_dispatch: + +# Releases made with GITHUB_TOKEN do not recursively trigger push/release workflows. +# The daily schedule invokes the reusable release workflow directly. +permissions: + copilot-requests: write + contents: write + pull-requests: read + +jobs: + classify: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + base-ref: ${{ steps.release.outputs.base-ref }} + bump: ${{ steps.release.outputs.bump }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Classify changes + id: policy + run: | + set -euo pipefail + LATEST=$( + git tag --list | + awk '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/' | + sort -V | + tail -n 1 + ) + if [ -z "$LATEST" ]; then + echo "::error::Automatic releases require an existing stable semver tag" + exit 1 + fi + echo "base-ref=$LATEST" >> "$GITHUB_OUTPUT" + node .github/scripts/release-policy.cjs "$LATEST" "$GITHUB_SHA" + + - name: Select release behavior + id: release + env: + BASE_REF: ${{ steps.policy.outputs.base-ref }} + CLASSIFICATION: ${{ steps.policy.outputs.classification }} + REASON: ${{ steps.policy.outputs.reason }} + run: | + set -euo pipefail + echo "$CLASSIFICATION: $REASON" + echo "base-ref=$BASE_REF" >> "$GITHUB_OUTPUT" + case "$CLASSIFICATION" in + none) + ;; + patch) + echo "bump=patch" >> "$GITHUB_OUTPUT" + ;; + minor) + echo "bump=minor" >> "$GITHUB_OUTPUT" + ;; + stale-bundle) + echo "::error::$REASON" + exit 1 + ;; + *) + echo "::error::Unexpected release classification: $CLASSIFICATION" + exit 1 + ;; + esac + + release: + needs: classify + if: needs.classify.result == 'success' && needs.classify.outputs.bump == 'patch' + uses: ./.github/workflows/release.yml + with: + automatic: true + base-ref: ${{ needs.classify.outputs.base-ref }} + bump: ${{ needs.classify.outputs.bump }} + + update-draft: + needs: classify + if: needs.classify.result == 'success' && needs.classify.outputs.bump == 'minor' + uses: ./.github/workflows/update-draft-release.yml + with: + base-ref: ${{ needs.classify.outputs.base-ref }} + bump: ${{ needs.classify.outputs.bump }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5572fbe..e6a1db4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,21 @@ name: Release on: + workflow_call: + inputs: + automatic: + description: 'Whether this release was selected by the automatic policy' + required: false + default: false + type: boolean + base-ref: + description: 'Expected latest stable tag for an automatic release' + required: false + type: string + bump: + description: 'Version bump type' + required: true + type: string workflow_dispatch: inputs: bump: @@ -12,11 +27,10 @@ on: - patch - minor - major - draft: - description: 'Create the release as a draft' - required: true - default: false - type: boolean + +concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false jobs: release: @@ -33,24 +47,11 @@ jobs: - name: Determine versions id: version + env: + BASE_REF: ${{ inputs.base-ref }} + BUMP: ${{ inputs.bump }} run: | - LATEST=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n 1) - if [ -z "$LATEST" ]; then - LATEST="v0.0.0" - fi - echo "previous=$LATEST" >> "$GITHUB_OUTPUT" - - # Strip 'v' prefix, split, bump, reassemble - IFS='.' read -r MAJOR MINOR PATCH <<< "${LATEST#v}" - case "${{ inputs.bump }}" in - major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; - minor) MINOR=$((MINOR + 1)); PATCH=0 ;; - patch) PATCH=$((PATCH + 1)) ;; - esac - NEXT="v${MAJOR}.${MINOR}.${PATCH}" - echo "next=$NEXT" >> "$GITHUB_OUTPUT" - echo "major=v${MAJOR}" >> "$GITHUB_OUTPUT" - echo "Releasing $NEXT (previous: $LATEST)" + .github/scripts/release-version.sh "$BUMP" "$BASE_REF" - name: Generate release notes id: notes @@ -61,16 +62,15 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.version.outputs.next }} - name: ${{ steps.version.outputs.next }} - body: ${{ steps.notes.outputs.release-notes }} - draft: ${{ inputs.draft }} - target_commitish: ${{ github.sha }} - - - name: Update major version tag + - name: Publish release + env: + AUTOMATIC: ${{ inputs.automatic || false }} + GH_TOKEN: ${{ github.token }} + NOTES: ${{ steps.notes.outputs.release-notes }} run: | - git tag -f "${{ steps.version.outputs.major }}" "${{ github.sha }}" - git push origin "refs/tags/${{ steps.version.outputs.major }}" --force + printf '%s\n' "$NOTES" > "$RUNNER_TEMP/release-notes.md" + .github/scripts/publish-release.sh \ + "${{ steps.version.outputs.next }}" \ + "${{ steps.version.outputs.major }}" \ + "$RUNNER_TEMP/release-notes.md" \ + "$AUTOMATIC" diff --git a/.github/workflows/update-draft-release.yml b/.github/workflows/update-draft-release.yml new file mode 100644 index 0000000..1ed17b9 --- /dev/null +++ b/.github/workflows/update-draft-release.yml @@ -0,0 +1,68 @@ +name: Update draft release + +on: + workflow_call: + inputs: + base-ref: + description: 'Expected latest stable tag for an automatic release' + required: false + type: string + bump: + description: 'Version bump type' + required: true + type: string + workflow_dispatch: + inputs: + bump: + description: 'Version bump type' + required: true + default: 'minor' + type: choice + options: + - patch + - minor + - major + +concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false + +jobs: + update: + runs-on: ubuntu-latest + permissions: + copilot-requests: write + contents: write + pull-requests: read + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine versions + id: version + env: + BASE_REF: ${{ inputs.base-ref }} + BUMP: ${{ inputs.bump }} + run: | + .github/scripts/release-version.sh "$BUMP" "$BASE_REF" + + - name: Generate release notes + id: notes + uses: github/copilot-release-notes@v1 + with: + base-ref: ${{ steps.version.outputs.previous }} + head-ref: ${{ github.sha }} + env: + GITHUB_TOKEN: ${{ github.token }} + + - name: Create or update draft release + env: + GH_TOKEN: ${{ github.token }} + NOTES: ${{ steps.notes.outputs.release-notes }} + run: | + printf '%s\n' "$NOTES" > "$RUNNER_TEMP/release-notes.md" + .github/scripts/update-draft-release.sh \ + "${{ steps.version.outputs.next }}" \ + "$RUNNER_TEMP/release-notes.md" diff --git a/__tests__/release-policy.test.ts b/__tests__/release-policy.test.ts new file mode 100644 index 0000000..534c9bf --- /dev/null +++ b/__tests__/release-policy.test.ts @@ -0,0 +1,71 @@ +const {classifyRelease} = require('../.github/scripts/release-policy.cjs') + +const baseManifest = { + name: 'action', + version: '1.0.0', + dependencies: {runtime: '1.0.0'}, + devDependencies: {test: '1.0.0'} +} + +function classify( + files: string[], + headManifest: Record = baseManifest +) { + return classifyRelease(files, baseManifest, headManifest) +} + +describe('release policy', () => { + it('does nothing when only accompanying files change', () => { + expect( + classify(['README.md', '__tests__/release-policy.test.ts', '.github/a.yml']) + ).toEqual({kind: 'none', reason: 'No shipped files changed'}) + }) + + it('publishes a patch for dependency-only bundle updates', () => { + expect( + classify(['package.json', 'package-lock.json', 'dist/index.js'], { + ...baseManifest, + dependencies: {runtime: '1.1.0'} + }) + ).toEqual({ + kind: 'patch', + reason: 'Only bundled runtime dependencies changed' + }) + }) + + it('allows tests, docs, and workflows alongside a dependency patch', () => { + expect( + classify( + [ + 'package.json', + 'package-lock.json', + 'dist/index.js', + '__tests__/runtime.test.ts', + 'docs/dependencies.md', + '.github/dependabot.yml' + ], + {...baseManifest, dependencies: {runtime: '1.1.0'}} + ).kind + ).toBe('patch') + }) + + it.each([ + [['src/index.ts', 'dist/index.js'], baseManifest], + [['action.yml'], baseManifest], + [['dist/index.js'], baseManifest], + [ + ['package.json', 'package-lock.json', 'dist/index.js'], + {...baseManifest, version: '1.0.1', dependencies: {runtime: '1.1.0'}} + ], + [ + ['package.json', 'package-lock.json', 'dist/index.js', 'CODEOWNERS'], + {...baseManifest, dependencies: {runtime: '1.1.0'}} + ] + ])('creates a draft minor for behavioral changes: %j', (files, manifest) => { + expect(classify(files as string[], manifest).kind).toBe('minor') + }) + + it('fails closed when source changes without a rebuilt bundle', () => { + expect(classify(['src/index.ts']).kind).toBe('stale-bundle') + }) +}) From a5a6bb1cc2f0eb47a2d73a57356cf6b3312bb047 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:08:25 -0400 Subject: [PATCH 2/2] Simplify release automation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/scripts/publish-release.sh | 74 ----------------- .github/scripts/release-version.sh | 38 --------- .github/scripts/update-draft-release.sh | 61 -------------- .github/workflows/automatic-release.yml | 14 ++-- .github/workflows/release.yml | 92 ++++++++++++++++++---- .github/workflows/update-draft-release.yml | 68 ---------------- 6 files changed, 80 insertions(+), 267 deletions(-) delete mode 100755 .github/scripts/publish-release.sh delete mode 100755 .github/scripts/release-version.sh delete mode 100755 .github/scripts/update-draft-release.sh delete mode 100644 .github/workflows/update-draft-release.yml diff --git a/.github/scripts/publish-release.sh b/.github/scripts/publish-release.sh deleted file mode 100755 index 71e6c76..0000000 --- a/.github/scripts/publish-release.sh +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -NEXT=$1 -MAJOR_TAG=$2 -NOTES_FILE=$3 -AUTOMATIC=$4 -RELEASES_FILE="$RUNNER_TEMP/releases.json" - -gh api --paginate --slurp \ - "repos/$GITHUB_REPOSITORY/releases?per_page=100" > "$RELEASES_FILE" - -MATCH_COUNT=$( - jq --arg tag "$NEXT" '[.[][] | select(.tag_name == $tag)] | length' \ - "$RELEASES_FILE" -) -if [ "$MATCH_COUNT" -gt 1 ]; then - echo "::error::Multiple releases already use $NEXT" - exit 1 -fi - -MATCH_ID=$( - jq -r --arg tag "$NEXT" \ - '[.[][] | select(.tag_name == $tag)][0].id // empty' \ - "$RELEASES_FILE" -) -MATCH_DRAFT=$( - jq -r --arg tag "$NEXT" \ - '[.[][] | select(.tag_name == $tag)][0].draft // empty' \ - "$RELEASES_FILE" -) - -if [ "$AUTOMATIC" = "true" ] && [ "$MATCH_DRAFT" = "true" ]; then - echo "::error::Automatic patch release refuses to publish existing draft $NEXT" - exit 1 -fi - -if [ -n "$MATCH_ID" ]; then - if [ "$MATCH_DRAFT" != "true" ]; then - echo "::error::$NEXT is already published" - exit 1 - fi - gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$MATCH_ID" \ - -f tag_name="$NEXT" \ - -f name="$NEXT" \ - -F body=@"$NOTES_FILE" \ - -F draft=false \ - -f target_commitish="$GITHUB_SHA" -else - gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ - -f tag_name="$NEXT" \ - -f name="$NEXT" \ - -F body=@"$NOTES_FILE" \ - -F draft=false \ - -f target_commitish="$GITHUB_SHA" -fi - -git fetch --force --tags origin -HIGHEST=$( - gh api --paginate --slurp \ - "repos/$GITHUB_REPOSITORY/releases?per_page=100" | - jq -r '.[][] | select((.draft | not) and (.prerelease | not)) | .tag_name' | - awk -v major="${MAJOR_TAG#v}" \ - '$0 ~ ("^v" major "\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$")' | - sort -V | - tail -n 1 -) -if [ -z "$HIGHEST" ]; then - echo "::error::No stable release found for $MAJOR_TAG" - exit 1 -fi - -git tag -f "$MAJOR_TAG" "${HIGHEST}^{commit}" -git push origin "refs/tags/$MAJOR_TAG" --force diff --git a/.github/scripts/release-version.sh b/.github/scripts/release-version.sh deleted file mode 100755 index a9b5391..0000000 --- a/.github/scripts/release-version.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -BUMP=$1 -EXPECTED_BASE=${2:-} - -LATEST=$( - git tag --list | - awk '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/' | - sort -V | - tail -n 1 -) -if [ -z "$LATEST" ]; then - LATEST="v0.0.0" -fi -if [ -n "$EXPECTED_BASE" ] && [ "$EXPECTED_BASE" != "$LATEST" ]; then - echo "::error::Latest stable release advanced from $EXPECTED_BASE to $LATEST; retry automation" - exit 1 -fi - -case "$BUMP" in - major|minor|patch) ;; - *) - echo "::error::Invalid version bump: $BUMP" - exit 1 - ;; -esac - -IFS='.' read -r MAJOR MINOR PATCH <<< "${LATEST#v}" -case "$BUMP" in - major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; - minor) MINOR=$((MINOR + 1)); PATCH=0 ;; - patch) PATCH=$((PATCH + 1)) ;; -esac - -echo "previous=$LATEST" >> "$GITHUB_OUTPUT" -echo "next=v${MAJOR}.${MINOR}.${PATCH}" >> "$GITHUB_OUTPUT" -echo "major=v${MAJOR}" >> "$GITHUB_OUTPUT" diff --git a/.github/scripts/update-draft-release.sh b/.github/scripts/update-draft-release.sh deleted file mode 100755 index ad3adb6..0000000 --- a/.github/scripts/update-draft-release.sh +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -NEXT=$1 -NOTES_FILE=$2 -RELEASES_FILE="$RUNNER_TEMP/releases.json" - -gh api --paginate --slurp \ - "repos/$GITHUB_REPOSITORY/releases?per_page=100" > "$RELEASES_FILE" - -MATCH_COUNT=$( - jq --arg tag "$NEXT" '[.[][] | select(.tag_name == $tag)] | length' \ - "$RELEASES_FILE" -) -if [ "$MATCH_COUNT" -gt 1 ]; then - echo "::error::Multiple releases already use $NEXT" - exit 1 -fi - -MATCH_ID=$( - jq -r --arg tag "$NEXT" \ - '[.[][] | select(.tag_name == $tag)][0].id // empty' \ - "$RELEASES_FILE" -) -MATCH_DRAFT=$( - jq -r --arg tag "$NEXT" \ - '[.[][] | select(.tag_name == $tag)][0].draft // empty' \ - "$RELEASES_FILE" -) - -if [ -n "$MATCH_ID" ] && [ "$MATCH_DRAFT" != "true" ]; then - echo "::error::$NEXT already exists as a published release" - exit 1 -fi - -OTHER_DRAFTS=$( - jq -r '.[][] | select(.draft) | .tag_name' "$RELEASES_FILE" | - awk '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/' | - awk -v next="$NEXT" '$0 != next' | - sort -V -) -if [ -n "$OTHER_DRAFTS" ]; then - echo "::error::Conflicting or stale draft release(s) must be resolved: ${OTHER_DRAFTS//$'\n'/, }" - exit 1 -fi - -if [ -n "$MATCH_ID" ]; then - gh api --method PATCH "repos/$GITHUB_REPOSITORY/releases/$MATCH_ID" \ - -f tag_name="$NEXT" \ - -f name="$NEXT" \ - -F body=@"$NOTES_FILE" \ - -F draft=true \ - -f target_commitish="$GITHUB_SHA" -else - gh api --method POST "repos/$GITHUB_REPOSITORY/releases" \ - -f tag_name="$NEXT" \ - -f name="$NEXT" \ - -F body=@"$NOTES_FILE" \ - -F draft=true \ - -f target_commitish="$GITHUB_SHA" -fi diff --git a/.github/workflows/automatic-release.yml b/.github/workflows/automatic-release.yml index 5da4d0a..8f5e03b 100644 --- a/.github/workflows/automatic-release.yml +++ b/.github/workflows/automatic-release.yml @@ -20,6 +20,7 @@ jobs: outputs: base-ref: ${{ steps.release.outputs.base-ref }} bump: ${{ steps.release.outputs.bump }} + draft: ${{ steps.release.outputs.draft }} steps: - uses: actions/checkout@v4 with: @@ -57,9 +58,11 @@ jobs: ;; patch) echo "bump=patch" >> "$GITHUB_OUTPUT" + echo "draft=false" >> "$GITHUB_OUTPUT" ;; minor) echo "bump=minor" >> "$GITHUB_OUTPUT" + echo "draft=true" >> "$GITHUB_OUTPUT" ;; stale-bundle) echo "::error::$REASON" @@ -73,17 +76,10 @@ jobs: release: needs: classify - if: needs.classify.result == 'success' && needs.classify.outputs.bump == 'patch' + if: needs.classify.result == 'success' && needs.classify.outputs.bump != '' uses: ./.github/workflows/release.yml with: automatic: true base-ref: ${{ needs.classify.outputs.base-ref }} bump: ${{ needs.classify.outputs.bump }} - - update-draft: - needs: classify - if: needs.classify.result == 'success' && needs.classify.outputs.bump == 'minor' - uses: ./.github/workflows/update-draft-release.yml - with: - base-ref: ${{ needs.classify.outputs.base-ref }} - bump: ${{ needs.classify.outputs.bump }} + draft: ${{ fromJSON(needs.classify.outputs.draft) }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e6a1db4..b46264a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,18 +4,18 @@ on: workflow_call: inputs: automatic: - description: 'Whether this release was selected by the automatic policy' required: false default: false type: boolean base-ref: - description: 'Expected latest stable tag for an automatic release' required: false type: string bump: - description: 'Version bump type' required: true type: string + draft: + required: true + type: boolean workflow_dispatch: inputs: bump: @@ -23,10 +23,12 @@ on: required: true default: 'patch' type: choice - options: - - patch - - minor - - major + options: [patch, minor, major] + draft: + description: 'Create the release as a draft' + required: true + default: false + type: boolean concurrency: group: release-${{ github.repository }} @@ -39,7 +41,6 @@ jobs: copilot-requests: write contents: write pull-requests: read - steps: - uses: actions/checkout@v4 with: @@ -48,10 +49,29 @@ jobs: - name: Determine versions id: version env: - BASE_REF: ${{ inputs.base-ref }} BUMP: ${{ inputs.bump }} + EXPECTED_BASE: ${{ inputs.base-ref }} run: | - .github/scripts/release-version.sh "$BUMP" "$BASE_REF" + set -euo pipefail + LATEST=$(git tag --list | awk '/^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/' | sort -V | tail -1) + LATEST=${LATEST:-v0.0.0} + if [ -n "$EXPECTED_BASE" ] && [ "$LATEST" != "$EXPECTED_BASE" ]; then + echo "::error::Latest stable release advanced to $LATEST; retry automation" + exit 1 + fi + case "$BUMP" in + major|minor|patch) ;; + *) echo "::error::Invalid version bump: $BUMP"; exit 1 ;; + esac + IFS=. read -r MAJOR MINOR PATCH <<< "${LATEST#v}" + case "$BUMP" in + major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;; + minor) MINOR=$((MINOR + 1)); PATCH=0 ;; + patch) PATCH=$((PATCH + 1)) ;; + esac + echo "previous=$LATEST" >> "$GITHUB_OUTPUT" + echo "next=v$MAJOR.$MINOR.$PATCH" >> "$GITHUB_OUTPUT" + echo "major=v$MAJOR" >> "$GITHUB_OUTPUT" - name: Generate release notes id: notes @@ -62,15 +82,53 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} - - name: Publish release + - name: Create or update release env: AUTOMATIC: ${{ inputs.automatic || false }} + DRAFT: ${{ inputs.draft }} GH_TOKEN: ${{ github.token }} + MAJOR: ${{ steps.version.outputs.major }} + NEXT: ${{ steps.version.outputs.next }} NOTES: ${{ steps.notes.outputs.release-notes }} run: | - printf '%s\n' "$NOTES" > "$RUNNER_TEMP/release-notes.md" - .github/scripts/publish-release.sh \ - "${{ steps.version.outputs.next }}" \ - "${{ steps.version.outputs.major }}" \ - "$RUNNER_TEMP/release-notes.md" \ - "$AUTOMATIC" + set -euo pipefail + NOTES_FILE="$RUNNER_TEMP/release-notes.md" + printf '%s\n' "$NOTES" > "$NOTES_FILE" + gh release list --limit 100 --json tagName,isDraft,isPrerelease > "$RUNNER_TEMP/releases.json" + MATCH_DRAFT=$(jq -r --arg tag "$NEXT" '.[] | select(.tagName == $tag) | .isDraft' "$RUNNER_TEMP/releases.json") + + if [ "$DRAFT" = "true" ]; then + OTHER_DRAFTS=$(jq -r --arg tag "$NEXT" \ + '.[] | select(.isDraft and .tagName != $tag and (.tagName | test("^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"))) | .tagName' \ + "$RUNNER_TEMP/releases.json") + if [ -n "$OTHER_DRAFTS" ]; then + echo "::error::Resolve conflicting draft release(s): ${OTHER_DRAFTS//$'\n'/, }" + exit 1 + fi + elif [ "$AUTOMATIC" = "true" ] && [ "$MATCH_DRAFT" = "true" ]; then + echo "::error::Automatic patch release refuses to publish existing draft $NEXT" + exit 1 + fi + + if [ "$MATCH_DRAFT" = "true" ]; then + gh release edit "$NEXT" --title "$NEXT" --notes-file "$NOTES_FILE" \ + --target "$GITHUB_SHA" --draft="$DRAFT" + elif [ -z "$MATCH_DRAFT" ]; then + DRAFT_FLAG=() + [ "$DRAFT" = "true" ] && DRAFT_FLAG=(--draft) + gh release create "$NEXT" --title "$NEXT" --notes-file "$NOTES_FILE" \ + --target "$GITHUB_SHA" "${DRAFT_FLAG[@]}" + else + echo "$NEXT is already published; reconciling its major tag" + fi + + if [ "$DRAFT" != "true" ]; then + git fetch --force --tags origin + HIGHEST=$(gh release list --limit 100 --exclude-drafts --exclude-pre-releases \ + --json tagName --jq '.[].tagName' | + awk -v major="${MAJOR#v}" '$0 ~ ("^v" major "\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$")' | + sort -V | tail -1) + [ -n "$HIGHEST" ] || { echo "::error::No stable release found for $MAJOR"; exit 1; } + git tag -f "$MAJOR" "${HIGHEST}^{commit}" + git push origin "refs/tags/$MAJOR" --force + fi diff --git a/.github/workflows/update-draft-release.yml b/.github/workflows/update-draft-release.yml deleted file mode 100644 index 1ed17b9..0000000 --- a/.github/workflows/update-draft-release.yml +++ /dev/null @@ -1,68 +0,0 @@ -name: Update draft release - -on: - workflow_call: - inputs: - base-ref: - description: 'Expected latest stable tag for an automatic release' - required: false - type: string - bump: - description: 'Version bump type' - required: true - type: string - workflow_dispatch: - inputs: - bump: - description: 'Version bump type' - required: true - default: 'minor' - type: choice - options: - - patch - - minor - - major - -concurrency: - group: release-${{ github.repository }} - cancel-in-progress: false - -jobs: - update: - runs-on: ubuntu-latest - permissions: - copilot-requests: write - contents: write - pull-requests: read - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Determine versions - id: version - env: - BASE_REF: ${{ inputs.base-ref }} - BUMP: ${{ inputs.bump }} - run: | - .github/scripts/release-version.sh "$BUMP" "$BASE_REF" - - - name: Generate release notes - id: notes - uses: github/copilot-release-notes@v1 - with: - base-ref: ${{ steps.version.outputs.previous }} - head-ref: ${{ github.sha }} - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Create or update draft release - env: - GH_TOKEN: ${{ github.token }} - NOTES: ${{ steps.notes.outputs.release-notes }} - run: | - printf '%s\n' "$NOTES" > "$RUNNER_TEMP/release-notes.md" - .github/scripts/update-draft-release.sh \ - "${{ steps.version.outputs.next }}" \ - "$RUNNER_TEMP/release-notes.md"