Skip to content

Commit 073f541

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 073f541

File tree

4 files changed

+386
-9
lines changed

4 files changed

+386
-9
lines changed
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
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 most recent tag reachable from this tag,
46+
# excluding the current tag itself.
47+
PREV_TAG=$(git tag --merged "${GITHUB_REF_NAME}" \
48+
--sort=-v:refname \
49+
| grep -xFv "${GITHUB_REF_NAME}" | head -n 1)
50+
echo "tag=$PREV_TAG" >> "$GITHUB_OUTPUT"
51+
52+
- name: Generate release notes
53+
id: notes
54+
env:
55+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
56+
run: |
57+
TAG_NAME="${GITHUB_REF_NAME}"
58+
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
59+
REPO="${{ github.repository }}"
60+
VERSION="${TAG_NAME#v}"
61+
62+
# Extract PR numbers from commit messages, then look up
63+
# each PR's metadata via the GitHub API. Falls back to
64+
# commit message parsing if the API lookup fails.
65+
if [ -n "$PREV_TAG" ]; then
66+
PR_NUMBERS=$(git log --oneline "$PREV_TAG..$TAG_NAME" \
67+
| grep -oP '#\K\d+' | sort -n -u || true)
68+
else
69+
# First release: include all commits
70+
PR_NUMBERS=$(git log --oneline "$TAG_NAME" \
71+
| grep -oP '#\K\d+' | sort -n -u || true)
72+
fi
73+
74+
# Build a map of PR numbers to commit message data
75+
# for fallback when API lookup fails.
76+
declare -A COMMIT_MAP
77+
while IFS= read -r line; do
78+
if [[ "$line" =~ \#([0-9]+)\) ]]; then
79+
COMMIT_MAP["${BASH_REMATCH[1]}"]="$line"
80+
fi
81+
done < <(if [ -n "$PREV_TAG" ]; then
82+
git log --format="%s%x09%an" "$PREV_TAG..$TAG_NAME"
83+
else
84+
git log --format="%s%x09%an" "$TAG_NAME"
85+
fi)
86+
87+
PRS=""
88+
for num in $PR_NUMBERS; do
89+
PR_DATA=$(gh pr view "$num" --repo "$REPO" \
90+
--json number,title,author \
91+
--jq '"\(.number)\t\(.title)\t\(.author.login)"' \
92+
2>/dev/null || true)
93+
if [ -n "$PR_DATA" ]; then
94+
PRS="${PRS}${PR_DATA}"$'\n'
95+
elif [ -n "${COMMIT_MAP[$num]:-}" ]; then
96+
# Fallback: parse title and author from commit msg.
97+
# Author is plain text (no @) to avoid wrong tags.
98+
RAW="${COMMIT_MAP[$num]}"
99+
TITLE=$(echo "$RAW" \
100+
| sed -E "s/ *\(#${num}\)\t.*$//" )
101+
AUTHOR=$(echo "$RAW" \
102+
| sed -E 's/^.*\t//')
103+
PRS="${PRS}$(printf '%s\t%s\t~%s' \
104+
"$num" "$TITLE" "$AUTHOR")"$'\n'
105+
fi
106+
done
107+
108+
# Categorize PRs by conventional commit prefix
109+
BREAKING=""
110+
FEATURES=""
111+
FIXES=""
112+
OTHER=""
113+
COMMITS=""
114+
115+
while IFS=$'\t' read -r num title author; do
116+
[ -z "$num" ] && continue
117+
# Fallback authors are prefixed with ~ (no @ tag)
118+
if [[ "$author" == ~* ]]; then
119+
entry="* ${title} by ${author#~} in #${num}"
120+
else
121+
entry="* ${title} by @${author} in #${num}"
122+
fi
123+
124+
case "$title" in
125+
*!:*|*"BREAKING CHANGE"*)
126+
BREAKING="${BREAKING}${entry}"$'\n'
127+
;;
128+
feat:*|feat\(*\):*)
129+
FEATURES="${FEATURES}${entry}"$'\n'
130+
;;
131+
fix:*|fix\(*\):*)
132+
FIXES="${FIXES}${entry}"$'\n'
133+
;;
134+
*)
135+
OTHER="${OTHER}${entry}"$'\n'
136+
;;
137+
esac
138+
139+
COMMITS="${COMMITS}${entry}"$'\n'
140+
done <<< "$PRS"
141+
142+
# If no PRs found, list raw commits as fallback
143+
if [ -z "$COMMITS" ]; then
144+
if [ -n "$PREV_TAG" ]; then
145+
RAW_LOG=$(git log --format="* %s (%an)" \
146+
"$PREV_TAG..$TAG_NAME")
147+
else
148+
RAW_LOG=$(git log --format="* %s (%an)" \
149+
"$TAG_NAME")
150+
fi
151+
COMMITS="${RAW_LOG}"$'\n'
152+
fi
153+
154+
# Build release notes
155+
NUGET_URL="https://www.nuget.org/packages/OrasProject.Oras/${VERSION}"
156+
NOTES="The NuGet package is available at [nuget.org](${NUGET_URL})."
157+
NOTES="${NOTES}"$'\n'
158+
159+
if [ -n "$BREAKING" ]; then
160+
NOTES="${NOTES}"$'\n'"## Breaking Changes"$'\n\n'"${BREAKING}"
161+
fi
162+
if [ -n "$FEATURES" ]; then
163+
NOTES="${NOTES}"$'\n'"## New Features"$'\n\n'"${FEATURES}"
164+
fi
165+
if [ -n "$FIXES" ]; then
166+
NOTES="${NOTES}"$'\n'"## Bug Fixes"$'\n\n'"${FIXES}"
167+
fi
168+
if [ -n "$OTHER" ]; then
169+
NOTES="${NOTES}"$'\n'"## Other Changes"$'\n\n'"${OTHER}"
170+
fi
171+
172+
NOTES="${NOTES}"$'\n'"## Detailed Commits"$'\n\n'"${COMMITS}"
173+
if [ -n "$PREV_TAG" ]; then
174+
CHANGELOG="https://github.com/${REPO}/compare/${PREV_TAG}...${TAG_NAME}"
175+
NOTES="${NOTES}"$'\n'"**Full Changelog**: [${PREV_TAG}...${TAG_NAME}](${CHANGELOG})"
176+
fi
177+
178+
# Write notes to file for gh release
179+
echo "$NOTES" > release-notes.md
180+
181+
- name: Create GitHub Release
182+
env:
183+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
184+
run: |
185+
TAG_NAME="${GITHUB_REF_NAME}"
186+
187+
# Determine if this is a pre-release
188+
PRERELEASE_FLAG=""
189+
if echo "$TAG_NAME" | grep -qE '[-](alpha|beta|rc|preview)'; then
190+
PRERELEASE_FLAG="--prerelease"
191+
fi
192+
193+
gh release create "$TAG_NAME" \
194+
--repo "${{ github.repository }}" \
195+
--title "$TAG_NAME" \
196+
--notes-file release-notes.md \
197+
--draft \
198+
$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: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
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+
contents: read
34+
issues: write
35+
36+
jobs:
37+
create-vote-issue:
38+
runs-on: ubuntu-latest
39+
steps:
40+
- name: Checkout
41+
uses: actions/checkout@v6
42+
with:
43+
fetch-depth: 0
44+
45+
- name: Validate tag name
46+
run: |
47+
TAG_NAME="${{ inputs.tag_name }}"
48+
if ! echo "$TAG_NAME" \
49+
| grep -qP '^v\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$'; then
50+
echo "::error::Invalid tag name '${TAG_NAME}'." \
51+
"Must match vMAJOR.MINOR.PATCH[-prerelease]."
52+
exit 1
53+
fi
54+
55+
- name: Resolve commit SHA
56+
id: resolve
57+
run: |
58+
if [ -n "${{ inputs.commit_sha }}" ]; then
59+
SHA="${{ inputs.commit_sha }}"
60+
if ! git rev-parse --verify "${SHA}^{commit}" >/dev/null 2>&1; then
61+
echo "::error::Invalid commit SHA: ${SHA}"
62+
exit 1
63+
fi
64+
echo "sha=${SHA}" >> "$GITHUB_OUTPUT"
65+
else
66+
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
67+
fi
68+
69+
- name: Get previous release tag
70+
id: prev_tag
71+
run: |
72+
COMMIT_SHA="${{ steps.resolve.outputs.sha }}"
73+
PREV_TAG=$(git tag --merged "$COMMIT_SHA" \
74+
--sort=-v:refname | head -n 1)
75+
echo "tag=$PREV_TAG" >> "$GITHUB_OUTPUT"
76+
77+
- name: Parse maintainers
78+
id: maintainers
79+
run: |
80+
# Parse MAINTAINERS.md into checkbox list
81+
# Format: "- Name (@handle)" -> "- [ ] Name (@handle)"
82+
{
83+
echo 'list<<EOF'
84+
grep -E '^\s*-\s+.+\(@.+\)' MAINTAINERS.md \
85+
| sed 's/^\s*-\s*/- [ ] /'
86+
echo 'EOF'
87+
} >> "$GITHUB_OUTPUT"
88+
89+
- name: Generate PR list
90+
id: changelog
91+
env:
92+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
93+
run: |
94+
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
95+
COMMIT_SHA="${{ steps.resolve.outputs.sha }}"
96+
97+
if [ -n "$PREV_TAG" ]; then
98+
PR_NUMBERS=$(git log --oneline \
99+
"$PREV_TAG..$COMMIT_SHA" \
100+
| grep -oP '#\K\d+' | sort -n -u || true)
101+
else
102+
# First release: include all commits
103+
PR_NUMBERS=$(git log --oneline "$COMMIT_SHA" \
104+
| grep -oP '#\K\d+' | sort -n -u || true)
105+
fi
106+
107+
# Format as markdown list with linked PR references.
108+
{
109+
echo 'pr_list<<EOF'
110+
if [ -z "$PR_NUMBERS" ]; then
111+
echo "_No PRs found — direct commits only._"
112+
else
113+
echo "$PR_NUMBERS" | while read -r num; do
114+
if [ -n "$num" ]; then
115+
echo "- #${num}"
116+
fi
117+
done
118+
fi
119+
echo 'EOF'
120+
} >> "$GITHUB_OUTPUT"
121+
122+
- name: Create vote issue
123+
env:
124+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
125+
run: |
126+
TAG_NAME="${{ inputs.tag_name }}"
127+
COMMIT_SHA="${{ steps.resolve.outputs.sha }}"
128+
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
129+
MAINTAINERS="${{ steps.maintainers.outputs.list }}"
130+
PR_LIST="${{ steps.changelog.outputs.pr_list }}"
131+
REPO="${{ github.repository }}"
132+
COUNT=$(echo "$MAINTAINERS" | grep -c '\- \[ \]')
133+
134+
# Build changelog context
135+
if [ -n "$PREV_TAG" ]; then
136+
CHANGES_HEADER="The changes compared to \`${PREV_TAG}\` include:"
137+
CHANGELOG_LINK="See [the full changelog](https://github.com/${REPO}/compare/${PREV_TAG}...${COMMIT_SHA}) for more details."
138+
else
139+
CHANGES_HEADER="The changes in this release include:"
140+
CHANGELOG_LINK=""
141+
fi
142+
143+
BODY="At least 2 approvals are needed from the ${COUNT} maintainers for tagging ${COMMIT_SHA} as \`${TAG_NAME}\`."
144+
BODY="${BODY}"$'\n\n'"${MAINTAINERS}"
145+
BODY="${BODY}"$'\n\n'"${CHANGES_HEADER}"
146+
BODY="${BODY}"$'\n\n'"${PR_LIST}"
147+
if [ -n "$CHANGELOG_LINK" ]; then
148+
BODY="${BODY}"$'\n\n'"${CHANGELOG_LINK}"
149+
fi
150+
BODY="${BODY}"$'\n'"Please respond LGTM or REJECT (with reasoning)."
151+
152+
gh issue create \
153+
--repo "$REPO" \
154+
--title "Vote for release of \`${TAG_NAME}\`" \
155+
--body "$BODY"

0 commit comments

Comments
 (0)