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/workflows/automatic-release.yml b/.github/workflows/automatic-release.yml new file mode 100644 index 0000000..8f5e03b --- /dev/null +++ b/.github/workflows/automatic-release.yml @@ -0,0 +1,85 @@ +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 }} + draft: ${{ steps.release.outputs.draft }} + 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" + echo "draft=false" >> "$GITHUB_OUTPUT" + ;; + minor) + echo "bump=minor" >> "$GITHUB_OUTPUT" + echo "draft=true" >> "$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 != '' + uses: ./.github/workflows/release.yml + with: + automatic: true + 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 5572fbe..b46264a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,21 @@ name: Release on: + workflow_call: + inputs: + automatic: + required: false + default: false + type: boolean + base-ref: + required: false + type: string + bump: + required: true + type: string + draft: + required: true + type: boolean workflow_dispatch: inputs: bump: @@ -8,16 +23,17 @@ 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 }} + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest @@ -25,7 +41,6 @@ jobs: copilot-requests: write contents: write pull-requests: read - steps: - uses: actions/checkout@v4 with: @@ -33,24 +48,30 @@ jobs: - name: Determine versions id: version + env: + BUMP: ${{ inputs.bump }} + EXPECTED_BASE: ${{ inputs.base-ref }} run: | - LATEST=$(git tag --list 'v*.*.*' --sort=-v:refname | head -n 1) - if [ -z "$LATEST" ]; then - LATEST="v0.0.0" + 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 - echo "previous=$LATEST" >> "$GITHUB_OUTPUT" - - # Strip 'v' prefix, split, bump, reassemble - IFS='.' read -r MAJOR MINOR PATCH <<< "${LATEST#v}" - case "${{ inputs.bump }}" in + 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 - NEXT="v${MAJOR}.${MINOR}.${PATCH}" - echo "next=$NEXT" >> "$GITHUB_OUTPUT" - echo "major=v${MAJOR}" >> "$GITHUB_OUTPUT" - echo "Releasing $NEXT (previous: $LATEST)" + 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 @@ -61,16 +82,53 @@ 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: 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: | - git tag -f "${{ steps.version.outputs.major }}" "${{ github.sha }}" - git push origin "refs/tags/${{ steps.version.outputs.major }}" --force + 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/__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') + }) +})