Skip to content

Commit 4880393

Browse files
committed
feat: add release automation workflows
- Add release-vote.yml: workflow_dispatch to create vote issue with maintainer checklist and PR changelog - Add release-github.yml: tag-push triggered draft release with categorized notes (Breaking/Features/Fixes/Other) - Update RELEASE_CHECKLIST.md to document new automated flow - PR metadata fallback from commit messages when API lookup fails - First-release edge case handling (no previous tag) Signed-off-by: Akash Singhal <akashsinghal@microsoft.com>
1 parent 6c439ff commit 4880393

File tree

4 files changed

+381
-9
lines changed

4 files changed

+381
-9
lines changed
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# Copyright The ORAS Authors.
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
name: release-github
15+
16+
on:
17+
push:
18+
tags:
19+
- v*
20+
21+
permissions:
22+
contents: write
23+
24+
jobs:
25+
create-release:
26+
runs-on: ubuntu-latest
27+
steps:
28+
- name: Checkout
29+
uses: actions/checkout@v6
30+
with:
31+
fetch-depth: 0
32+
33+
- name: Validate tag name
34+
run: |
35+
if ! echo "${GITHUB_REF_NAME}" \
36+
| grep -qP '^v\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$'; then
37+
echo "::error::Invalid tag '${GITHUB_REF_NAME}'." \
38+
"Must match vMAJOR.MINOR.PATCH[-prerelease]."
39+
exit 1
40+
fi
41+
42+
- name: Get previous release tag
43+
id: prev_tag
44+
run: |
45+
# Get the tag before the current one
46+
PREV_TAG=$(git tag --sort=-v:refname \
47+
| grep -xFv "${GITHUB_REF_NAME}" | head -n 1)
48+
echo "tag=$PREV_TAG" >> "$GITHUB_OUTPUT"
49+
50+
- name: Generate release notes
51+
id: notes
52+
env:
53+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
54+
run: |
55+
TAG_NAME="${GITHUB_REF_NAME}"
56+
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
57+
REPO="${{ github.repository }}"
58+
VERSION="${TAG_NAME#v}"
59+
60+
# Extract PR numbers from commit messages, then look up
61+
# each PR's metadata via the GitHub API. Falls back to
62+
# commit message parsing if the API lookup fails.
63+
if [ -n "$PREV_TAG" ]; then
64+
PR_NUMBERS=$(git log --oneline "$PREV_TAG..$TAG_NAME" \
65+
| grep -oP '#\K\d+' | sort -n -u || true)
66+
else
67+
# First release: include all commits
68+
PR_NUMBERS=$(git log --oneline "$TAG_NAME" \
69+
| grep -oP '#\K\d+' | sort -n -u || true)
70+
fi
71+
72+
# Build a map of PR numbers to commit message data
73+
# for fallback when API lookup fails.
74+
declare -A COMMIT_MAP
75+
while IFS= read -r line; do
76+
if [[ "$line" =~ \#([0-9]+)\) ]]; then
77+
COMMIT_MAP["${BASH_REMATCH[1]}"]="$line"
78+
fi
79+
done < <(if [ -n "$PREV_TAG" ]; then
80+
git log --format="%s%x09%an" "$PREV_TAG..$TAG_NAME"
81+
else
82+
git log --format="%s%x09%an" "$TAG_NAME"
83+
fi)
84+
85+
PRS=""
86+
for num in $PR_NUMBERS; do
87+
PR_DATA=$(gh pr view "$num" --repo "$REPO" \
88+
--json number,title,author \
89+
--jq '"\(.number)\t\(.title)\t\(.author.login)"' \
90+
2>/dev/null || true)
91+
if [ -n "$PR_DATA" ]; then
92+
PRS="${PRS}${PR_DATA}"$'\n'
93+
elif [ -n "${COMMIT_MAP[$num]:-}" ]; then
94+
# Fallback: parse title and author from commit msg.
95+
# Author is plain text (no @) to avoid wrong tags.
96+
RAW="${COMMIT_MAP[$num]}"
97+
TITLE=$(echo "$RAW" \
98+
| sed -E "s/ *\(#${num}\)\t.*$//" )
99+
AUTHOR=$(echo "$RAW" \
100+
| sed -E 's/^.*\t//')
101+
PRS="${PRS}$(printf '%s\t%s\t~%s' \
102+
"$num" "$TITLE" "$AUTHOR")"$'\n'
103+
fi
104+
done
105+
106+
# Categorize PRs by conventional commit prefix
107+
BREAKING=""
108+
FEATURES=""
109+
FIXES=""
110+
OTHER=""
111+
COMMITS=""
112+
113+
while IFS=$'\t' read -r num title author; do
114+
[ -z "$num" ] && continue
115+
# Fallback authors are prefixed with ~ (no @ tag)
116+
if [[ "$author" == ~* ]]; then
117+
entry="* ${title} by ${author#~} in #${num}"
118+
else
119+
entry="* ${title} by @${author} in #${num}"
120+
fi
121+
122+
case "$title" in
123+
*!:*|*"BREAKING CHANGE"*)
124+
BREAKING="${BREAKING}${entry}"$'\n'
125+
;;
126+
feat:*|feat\(*\):*)
127+
FEATURES="${FEATURES}${entry}"$'\n'
128+
;;
129+
fix:*|fix\(*\):*)
130+
FIXES="${FIXES}${entry}"$'\n'
131+
;;
132+
*)
133+
OTHER="${OTHER}${entry}"$'\n'
134+
;;
135+
esac
136+
137+
COMMITS="${COMMITS}${entry}"$'\n'
138+
done <<< "$PRS"
139+
140+
# If no PRs found, list raw commits as fallback
141+
if [ -z "$COMMITS" ]; then
142+
if [ -n "$PREV_TAG" ]; then
143+
RAW_LOG=$(git log --format="* %s (%an)" \
144+
"$PREV_TAG..$TAG_NAME")
145+
else
146+
RAW_LOG=$(git log --format="* %s (%an)" \
147+
"$TAG_NAME")
148+
fi
149+
COMMITS="${RAW_LOG}"$'\n'
150+
fi
151+
152+
# Build release notes
153+
NUGET_URL="https://www.nuget.org/packages/OrasProject.Oras/${VERSION}"
154+
NOTES="The NuGet package is available at [nuget.org](${NUGET_URL})."
155+
NOTES="${NOTES}"$'\n'
156+
157+
if [ -n "$BREAKING" ]; then
158+
NOTES="${NOTES}"$'\n'"## Breaking Changes"$'\n\n'"${BREAKING}"
159+
fi
160+
if [ -n "$FEATURES" ]; then
161+
NOTES="${NOTES}"$'\n'"## New Features"$'\n\n'"${FEATURES}"
162+
fi
163+
if [ -n "$FIXES" ]; then
164+
NOTES="${NOTES}"$'\n'"## Bug Fixes"$'\n\n'"${FIXES}"
165+
fi
166+
if [ -n "$OTHER" ]; then
167+
NOTES="${NOTES}"$'\n'"## Other Changes"$'\n\n'"${OTHER}"
168+
fi
169+
170+
NOTES="${NOTES}"$'\n'"## Detailed Commits"$'\n\n'"${COMMITS}"
171+
if [ -n "$PREV_TAG" ]; then
172+
CHANGELOG="https://github.com/${REPO}/compare/${PREV_TAG}...${TAG_NAME}"
173+
NOTES="${NOTES}"$'\n'"**Full Changelog**: [${PREV_TAG}...${TAG_NAME}](${CHANGELOG})"
174+
fi
175+
176+
# Write notes to file for gh release
177+
echo "$NOTES" > release-notes.md
178+
179+
- name: Create GitHub Release
180+
env:
181+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
182+
run: |
183+
TAG_NAME="${GITHUB_REF_NAME}"
184+
185+
# Determine if this is a pre-release
186+
PRERELEASE_FLAG=""
187+
if echo "$TAG_NAME" | grep -qE '[-](alpha|beta|rc|preview)'; then
188+
PRERELEASE_FLAG="--prerelease"
189+
fi
190+
191+
gh release create "$TAG_NAME" \
192+
--repo "${{ github.repository }}" \
193+
--title "$TAG_NAME" \
194+
--notes-file release-notes.md \
195+
--draft \
196+
$PRERELEASE_FLAG

.github/workflows/release-nuget.yml

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
name: release-nuget
1515

1616
on:
17-
push:
18-
tags:
19-
- v*
17+
release:
18+
types: [published]
19+
20+
permissions:
21+
contents: write
2022

2123
jobs:
2224
build:
@@ -30,8 +32,25 @@ jobs:
3032
dotnet-version: '8.0.x'
3133
- name: Extract Version
3234
id: version
33-
run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
35+
run: |
36+
TAG_NAME="${{ github.event.release.tag_name }}"
37+
if ! echo "$TAG_NAME" \
38+
| grep -qP '^v\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$'; then
39+
echo "::error::Invalid tag '${TAG_NAME}'." \
40+
"Must match vMAJOR.MINOR.PATCH[-prerelease]."
41+
exit 1
42+
fi
43+
echo "version=${TAG_NAME#v}" >> $GITHUB_OUTPUT
3444
- name: Build nuget package
3545
run: dotnet build ./src/OrasProject.Oras --configuration Release /p:PackageVersion=${{ steps.version.outputs.version }}
46+
- name: Upload nupkg and checksum to release
47+
env:
48+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49+
NUPKG: ./src/OrasProject.Oras/bin/Release/OrasProject.Oras.${{ steps.version.outputs.version }}.nupkg
50+
run: |
51+
sha256sum "$NUPKG" | awk '{print $1}' > "${NUPKG}.sha256"
52+
gh release upload "${{ github.event.release.tag_name }}" \
53+
"$NUPKG" \
54+
"${NUPKG}.sha256"
3655
- name: Publish nuget package
3756
run: dotnet nuget push ./src/OrasProject.Oras/bin/Release/OrasProject.Oras.${{ steps.version.outputs.version }}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }}

.github/workflows/release-vote.yml

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
# Copyright The ORAS Authors.
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
14+
name: release-vote
15+
16+
on:
17+
workflow_dispatch:
18+
inputs:
19+
tag_name:
20+
description: >
21+
The tag name for the new release (e.g., v1.0.0).
22+
Must be a valid SemVer2 version prefixed with 'v'.
23+
required: true
24+
type: string
25+
commit_sha:
26+
description: >
27+
The commit SHA to tag for this release.
28+
Defaults to the latest commit on the default branch.
29+
required: false
30+
type: string
31+
32+
permissions:
33+
issues: write
34+
35+
jobs:
36+
create-vote-issue:
37+
runs-on: ubuntu-latest
38+
steps:
39+
- name: Checkout
40+
uses: actions/checkout@v6
41+
with:
42+
fetch-depth: 0
43+
44+
- name: Validate tag name
45+
run: |
46+
TAG_NAME="${{ inputs.tag_name }}"
47+
if ! echo "$TAG_NAME" \
48+
| grep -qP '^v\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$'; then
49+
echo "::error::Invalid tag name '${TAG_NAME}'." \
50+
"Must match vMAJOR.MINOR.PATCH[-prerelease]."
51+
exit 1
52+
fi
53+
54+
- name: Resolve commit SHA
55+
id: resolve
56+
run: |
57+
if [ -n "${{ inputs.commit_sha }}" ]; then
58+
SHA="${{ inputs.commit_sha }}"
59+
if ! git rev-parse --verify "${SHA}^{commit}" >/dev/null 2>&1; then
60+
echo "::error::Invalid commit SHA: ${SHA}"
61+
exit 1
62+
fi
63+
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
64+
else
65+
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
66+
fi
67+
68+
- name: Get previous release tag
69+
id: prev_tag
70+
run: |
71+
PREV_TAG=$(git tag --sort=-v:refname | head -n 1)
72+
echo "tag=$PREV_TAG" >> "$GITHUB_OUTPUT"
73+
74+
- name: Parse maintainers
75+
id: maintainers
76+
run: |
77+
# Parse MAINTAINERS.md into checkbox list
78+
# Format: "- Name (@handle)" -> "- [ ] Name (@handle)"
79+
{
80+
echo 'list<<EOF'
81+
grep -E '^\s*-\s+.+\(@.+\)' MAINTAINERS.md \
82+
| sed 's/^\s*-\s*/- [ ] /'
83+
echo 'EOF'
84+
} >> "$GITHUB_OUTPUT"
85+
86+
- name: Generate PR list
87+
id: changelog
88+
env:
89+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
90+
run: |
91+
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
92+
COMMIT_SHA="${{ steps.resolve.outputs.sha }}"
93+
94+
if [ -n "$PREV_TAG" ]; then
95+
PR_NUMBERS=$(git log --oneline \
96+
"$PREV_TAG..$COMMIT_SHA" \
97+
| grep -oP '#\K\d+' | sort -n -u || true)
98+
else
99+
# First release: include all commits
100+
PR_NUMBERS=$(git log --oneline "$COMMIT_SHA" \
101+
| grep -oP '#\K\d+' | sort -n -u || true)
102+
fi
103+
104+
# Format as markdown list with linked PR references.
105+
{
106+
echo 'pr_list<<EOF'
107+
if [ -z "$PR_NUMBERS" ]; then
108+
echo "_No PRs found — direct commits only._"
109+
else
110+
echo "$PR_NUMBERS" | while read -r num; do
111+
if [ -n "$num" ]; then
112+
echo "- #${num}"
113+
fi
114+
done
115+
fi
116+
echo 'EOF'
117+
} >> "$GITHUB_OUTPUT"
118+
119+
- name: Create vote issue
120+
env:
121+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
122+
run: |
123+
TAG_NAME="${{ inputs.tag_name }}"
124+
COMMIT_SHA="${{ steps.resolve.outputs.sha }}"
125+
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
126+
MAINTAINERS="${{ steps.maintainers.outputs.list }}"
127+
PR_LIST="${{ steps.changelog.outputs.pr_list }}"
128+
REPO="${{ github.repository }}"
129+
COUNT=$(echo "$MAINTAINERS" | grep -c '\- \[ \]')
130+
131+
# Build changelog context
132+
if [ -n "$PREV_TAG" ]; then
133+
CHANGES_HEADER="The changes compared to \`${PREV_TAG}\` include:"
134+
CHANGELOG_LINK="See [the full changelog](https://github.com/${REPO}/compare/${PREV_TAG}...${COMMIT_SHA}) for more details."
135+
else
136+
CHANGES_HEADER="The changes in this release include:"
137+
CHANGELOG_LINK=""
138+
fi
139+
140+
BODY="At least 2 approvals are needed from the ${COUNT} maintainers for tagging ${COMMIT_SHA} as \`${TAG_NAME}\`."
141+
BODY="${BODY}"$'\n\n'"${MAINTAINERS}"
142+
BODY="${BODY}"$'\n\n'"${CHANGES_HEADER}"
143+
BODY="${BODY}"$'\n\n'"${PR_LIST}"
144+
if [ -n "$CHANGELOG_LINK" ]; then
145+
BODY="${BODY}"$'\n\n'"${CHANGELOG_LINK}"
146+
fi
147+
BODY="${BODY}"$'\n'"Please respond LGTM or REJECT (with reasoning)."
148+
149+
gh issue create \
150+
--repo "$REPO" \
151+
--title "Vote for release of \`${TAG_NAME}\`" \
152+
--body "$BODY"

0 commit comments

Comments
 (0)