-
Notifications
You must be signed in to change notification settings - Fork 2.7k
174 lines (151 loc) · 6.97 KB
/
Copy pathtag-on-merge.yml
File metadata and controls
174 lines (151 loc) · 6.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
name: Tag and release from CHANGELOG
# Runs after each push to main. If CHANGELOG.md gained a new ## vX.Y.Z heading
# anywhere in this push's commit range (compared to the push's `before` SHA):
# 1. gate the release — the version in .claude-plugin/marketplace.json must
# match the new heading, and the validator + test suite must pass
# (the suite also asserts every plugin.json carries the same version), then
# 2. create a lightweight tag with that version name at the pushed commit, and
# 3. publish a GitHub Release for that tag with the matching CHANGELOG
# section as the notes.
#
# CHANGELOG is the source of truth; the tag and the Release are deterministic
# projections of it. Adapted from phuryn/claude-usage's tag-on-merge workflow,
# minus the .vsix build. Added headings whose tag already exists are treated as
# backfilled history and skipped, so importing old releases is safe.
#
# No action when CHANGELOG wasn't touched, when an existing version heading was
# edited (not added), or when the tag/release already exists. Safe to re-run on
# force-pushes and amends.
on:
push:
branches: [main]
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
# Need enough history to diff the whole push range (`before..after`),
# not just the tip commit. Small repo; a full clone is cheap.
fetch-depth: 0
- name: Detect new version heading in CHANGELOG
id: detect
env:
BEFORE: ${{ github.event.before }}
AFTER: ${{ github.sha }}
run: |
set -euo pipefail
# On a brand-new branch (first push), before is all zeros.
zeros="0000000000000000000000000000000000000000"
if [ "$BEFORE" = "$zeros" ] || [ -z "$BEFORE" ]; then
echo "version=" >> "$GITHUB_OUTPUT"
echo "Brand-new branch push; nothing to compare."
exit 0
fi
# Lines added to CHANGELOG.md across the entire pushed range that
# look like a version heading. Format: `## vX.Y.Z` (semver triplet
# required). The trailing-boundary group prevents `## v2.1.0a` from
# matching `v2.1.0`.
added_versions=$(git diff "$BEFORE..$AFTER" -- CHANGELOG.md \
| grep -E '^\+## v[0-9]+\.[0-9]+\.[0-9]+([[:space:]]|$)' \
| sed -E 's/^\+## (v[0-9]+\.[0-9]+\.[0-9]+)([[:space:]]|$).*/\1/' \
|| true)
if [ -z "$added_versions" ]; then
echo "version=" >> "$GITHUB_OUTPUT"
echo "No new ## vX.Y.Z heading added to CHANGELOG; nothing to tag."
exit 0
fi
# Headings whose tag already exists on origin are backfilled history,
# not new releases — skip them.
new_versions=""
for v in $added_versions; do
if git ls-remote --tags origin "refs/tags/$v" | grep -q .; then
echo "$v is already tagged; treating as backfill."
else
new_versions="${new_versions}${v}"$'\n'
fi
done
new_versions=$(printf '%s' "$new_versions" | sed '/^$/d')
if [ -z "$new_versions" ]; then
echo "version=" >> "$GITHUB_OUTPUT"
echo "All added headings are already tagged (backfill); nothing to do."
exit 0
fi
# If multiple new untagged headings were added in one push, fail
# loudly — ambiguous which one to tag, and shipping two releases in
# one merge is almost certainly not intended.
count=$(echo "$new_versions" | wc -l)
if [ "$count" -gt 1 ]; then
echo "::error::Multiple new untagged version headings detected; refusing to auto-tag. Versions: $new_versions"
exit 1
fi
version=$(echo "$new_versions" | head -1)
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Detected new release: $version"
# ── Release gates ────────────────────────────────────────────────────
# Everything below is gated on a new version being detected, so ordinary
# pushes to main (docs, typo fixes) incur no setup or test cost.
- name: "Gate: marketplace.json version matches the CHANGELOG"
if: steps.detect.outputs.version != ''
env:
VERSION: ${{ steps.detect.outputs.version }}
run: |
set -euo pipefail
want="${VERSION#v}"
have=$(python3 -c "import json; print(json.load(open('.claude-plugin/marketplace.json'))['version'])")
if [ "$have" != "$want" ]; then
echo "::error::marketplace.json is $have but CHANGELOG released $VERSION. Bump the manifests before the release push."
exit 1
fi
echo "marketplace.json at $have."
- name: "Gate: validator + test suite"
if: steps.detect.outputs.version != ''
run: |
set -euo pipefail
python3 validate_plugins.py
python3 -m unittest discover -s tests -v
- name: Create and push tag if it doesn't already exist
if: steps.detect.outputs.version != ''
env:
VERSION: ${{ steps.detect.outputs.version }}
run: |
set -euo pipefail
# Tag may already exist if someone tagged manually before the
# workflow caught up, or on a re-push of the same commit. Idempotent.
if git ls-remote --tags origin "refs/tags/$VERSION" | grep -q .; then
echo "Tag $VERSION already exists on origin; nothing to do."
exit 0
fi
git tag "$VERSION"
git push origin "$VERSION"
echo "Tagged $VERSION at $(git rev-parse HEAD)."
- name: Create GitHub Release with the CHANGELOG section as notes
if: steps.detect.outputs.version != ''
env:
VERSION: ${{ steps.detect.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Idempotent: a re-push of the same release commit shouldn't error.
if gh release view "$VERSION" >/dev/null 2>&1; then
echo "Release $VERSION already exists; nothing to do."
exit 0
fi
# Extract this version's CHANGELOG section (heading through the line
# before the next `## vX` heading) as the release notes. $2 is the
# version token: `## v2.1.0 — 2026-07-03` → $2 == "v2.1.0".
notes="$(mktemp)"
awk -v ver="$VERSION" '
/^## v[0-9]/ { if (started) exit; if ($2 == ver) started=1 }
started { print }
' CHANGELOG.md > "$notes"
if [ ! -s "$notes" ]; then
echo "::error::No '## $VERSION' section found in CHANGELOG.md."
exit 1
fi
gh release create "$VERSION" \
--title "$VERSION" \
--notes-file "$notes"
echo "Released $VERSION."