Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
87 changes: 87 additions & 0 deletions .github/workflows/prepare-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
name: Prepare Release

on:
workflow_dispatch:
inputs:
version:
description: "Version to release (e.g. 1.2.3)"
required: true
type: string
base_branch:
description: "Base branch for release (e.g. release/v1.x, hotfix/v1.2.x)"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you explain how this would be used?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added some additional text to try to explain why we'd want a difference base_branch

required: false
default: "main"
type: string

jobs:
prepare-release:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ inputs.base_branch }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "npm"

- name: Validate semver version
run: |
if ! echo "${{ inputs.version }}" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?(\+[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$'; then
echo "Error: '${{ inputs.version }}' is not a valid semver version"
echo "Expected format: MAJOR.MINOR.PATCH (e.g., 1.2.3) or with pre-release/build metadata (e.g., 1.2.3-alpha.1+build.1)"
exit 1
fi
echo "✓ Version '${{ inputs.version }}' is valid semver"

- name: Install dependencies
run: npm install

- name: Create draft release
run: gh release create v${{ inputs.version }} --draft --generate-notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Create release branch
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
git checkout -b "release/prep-release-${{ inputs.version }}"

- name: Set version and run build
run: |
npm run setversion ${{ inputs.version }}

- name: Commit version changes
run: |
git add .
git commit -s -m "Release ${{ inputs.version }}"
git push --set-upstream origin "release/prep-release-${{ inputs.version }}"

- name: Get release notes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this the reason to draft a release? Then I suggest that we create a draft, get the generated release notes, and delete the draft.

Can you add a comment that we should switch to an API call to generate release notes without creating a release? https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#generate-release-notes-content-for-a-release

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated script to use api directly, much easier to understand with less side effects 🚀

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not kick the action off by manually creating a draft release and editing the release notes up front? The prepare-release action could trigger on that event, pick up the release tag, target branch, and release notes, create the release branch and PR, and delete the draft release.

It wouldn't be very different from what you have, but it would make use of the "new release" UI rather than the more esoteric UI to trigger a workflow.

One downside I see is that you could accidentally create a non-draft release in the UI... but this could maybe be handled by permissions or even detected and cleaned up by the prepare-release workflow?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The original idea was to leverage a review process, which is more central to this current version (everything pivots off the PR). Creating a release doesn't really have associated workflows so we'd need to do cleanup. But both would obviously work.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, totally get why you're using a PR for review, and I wasn't suggesting changing that. I was just suggesting using the draft release creation to kick off the flow which would seem marginally more GitHub-idiomatic to me, even though you still wouldn't be publishing the draft release actually created since it would just be tagged from main (or from a branch for a backport).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah i think i now understand what you meant. So instead of triggering via workflow run, we'd trigger on a draft release creation. Yeah, has a little more cleanup but i can see why that might be a little more natural.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@timostamm do you have thoughts on this? Using a draft release as the trigger feels a little more intuitive vs an arbitrary workflow.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can draft releases trigger a workflow?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, not clear from the docs. It says that a draft release doesn't trigger a created/edited/deleted, which implies published but i don't know how you would trigger a published event on a draft release (i guess when it moves from draft to release). So yeah, that would probably be a blocker to using drafts.

id: release_notes
run: |
RELEASE_NOTES=$(gh release view v${{ inputs.version }} --json body | jq -r ".body")
echo "notes<<EOF" >> $GITHUB_OUTPUT
echo "$RELEASE_NOTES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Create pull request
run: |
gh pr create \
--title "Release ${{ inputs.version }}" \
--body "${{ steps.release_notes.outputs.notes }}" \
--base "${{ inputs.base_branch }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
155 changes: 155 additions & 0 deletions .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
name: Publish Release

on:
pull_request:
types: [closed]
branches:
- main
- "release/**"
- "hotfix/**"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I forgot: Why do we need release and hotfix? What's the distinction?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was more of a hypothetical, but the idea is that if we need to release for v1 but we are currently on v2, a hotfix indicates that the release will be based off of the a specific tag. All that said, I actually don't think we need a distinction now, since all we're using this for is to pull out the version to be based on. I think we can simplify to just main and release/**


jobs:
publish-release:
runs-on: ubuntu-latest
# Only run if PR was merged and branch name starts with release/prep-release-
if: github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/prep-release-')
permissions:
contents: write
pull-requests: write
issues: write

steps:
- name: Extract version from branch name
id: extract_version
run: |
BRANCH_NAME="${{ github.event.pull_request.head.ref }}"
VERSION=$(echo "$BRANCH_NAME" | sed 's/release\/prep-release-//')
echo "version=$VERSION" >> $GITHUB_OUTPUT

- name: Checkout base branch
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.ref }}
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
cache: "npm"
registry-url: "https://registry.npmjs.org"

- name: Install dependencies
run: npm install

- name: Get updated release notes from PR
id: pr_notes
run: |
RELEASE_NOTES=$(gh pr view ${{ github.event.pull_request.number }} --json body | jq -r ".body")
echo "notes<<EOF" >> $GITHUB_OUTPUT
echo "$RELEASE_NOTES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Publish to npm
run: npm run release
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

- name: Publish GitHub release
run: |
gh release edit v${{ steps.extract_version.outputs.version }} \
--notes "${{ steps.pr_notes.outputs.notes }}" \
--draft=false
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Get release info for issue comments
id: release_info
run: |
# Get last two releases
RELEASES=$(gh api repos/${{ github.repository }}/releases --jq ".[0:2].[].name")
LATEST_RELEASE=$(echo "${RELEASES}" | head -1)
PREV_RELEASE=$(echo "${RELEASES}" | tail -1)

echo "latest=$LATEST_RELEASE" >> $GITHUB_OUTPUT
echo "previous=$PREV_RELEASE" >> $GITHUB_OUTPUT

RELEASE_URL=$(gh release view v${{ steps.extract_version.outputs.version }} --json url | jq -r ".url")
echo "release_url=$RELEASE_URL" >> $GITHUB_OUTPUT
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Comment on fixed issues
run: |
# Get PRs included in this release
START=$(gh release view ${{ steps.release_info.outputs.previous }} --json publishedAt | jq -r ".publishedAt")
END=$(gh release view ${{ steps.release_info.outputs.latest }} --json publishedAt | jq -r ".publishedAt")
PRS=$(gh pr list --search="merged:$START..$END" --json="number" | jq -r ".[].number")

# For each PR, get the issues it fixes and comment on them
echo "$PRS" | while IFS= read -r pr; do
if [[ -z "$pr" ]]; then
continue
fi

ISSUES=$(gh api graphql -F owner='${{ github.repository_owner }}' -F repo='${{ github.event.repository.name }}' -F pr=$pr -f query='
query ($owner: String!, $repo: String!, $pr: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr) {
closingIssuesReferences(first: 100) {
nodes {
number
}
}
}
}
}' | jq -r '.data.repository.pullRequest.closingIssuesReferences.nodes[].number')

echo "$ISSUES" | while IFS= read -r issue; do
issue=$(echo $issue | tr -d '\n')
if [[ -z "$issue" ]]; then
continue
fi
echo "Adding comment to issue $issue"
gh issue comment $issue -b "Released in [${{ steps.extract_version.outputs.version }}](${{ steps.release_info.outputs.release_url }}) 🚀"
done
done
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
cleanup-canceled-release:
runs-on: ubuntu-latest
# Only run if PR was closed without merge and branch name starts with release/prep-release-
if: github.event.pull_request.merged == false && startsWith(github.event.pull_request.head.ref, 'release/prep-release-')
permissions:
contents: write
pull-requests: write

steps:
- name: Extract version from branch name
id: extract_version
run: |
BRANCH_NAME="${{ github.event.pull_request.head.ref }}"
VERSION=$(echo "$BRANCH_NAME" | sed 's/release\/prep-release-//')
echo "version=$VERSION" >> $GITHUB_OUTPUT

- name: Delete draft release
run: |
if gh release view v${{ steps.extract_version.outputs.version }} --json isDraft | jq -r ".isDraft" | grep -q "true"; then
echo "Deleting draft release v${{ steps.extract_version.outputs.version }}"
gh release delete v${{ steps.extract_version.outputs.version }} --yes
else
echo "Release v${{ steps.extract_version.outputs.version }} is not a draft, skipping deletion"
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Delete release branch
continue-on-error: true
run: |
echo "Deleting release branch ${{ github.event.pull_request.head.ref }}"
git push origin --delete ${{ github.event.pull_request.head.ref }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}