Skip to content
Closed
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
135 changes: 135 additions & 0 deletions .github/scripts/release-policy.cjs
Original file line number Diff line number Diff line change
@@ -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 <base-ref> [head-ref]')
}

const result = classifyRelease(
changedFiles(base, head),
readManifest(base),
readManifest(head)
)
writeOutput(result)
console.log(`${result.kind}: ${result.reason}`)
}

module.exports = {classifyRelease}
85 changes: 85 additions & 0 deletions .github/workflows/automatic-release.yml
Original file line number Diff line number Diff line change
@@ -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) }}
116 changes: 87 additions & 29 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,56 +1,77 @@
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:
description: 'Version bump type'
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
permissions:
copilot-requests: write
contents: write
pull-requests: read

steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- 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
Expand All @@ -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
Loading
Loading