-
Notifications
You must be signed in to change notification settings - Fork 3
255 lines (236 loc) · 11.5 KB
/
Copy pathrelease.yml
File metadata and controls
255 lines (236 loc) · 11.5 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
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
name: Release
# A release is a version string, because the version is the cache key both
# Claude Code and Codex compare against an install: merging to main ships
# nothing until the number moves. This runs in two halves, because the "Protect
# main" ruleset requires a pull request and no actor bypasses it.
#
# 1. Dispatch this workflow with a version. It runs the gates, bumps every
# manifest with scripts/bump-version.sh, and opens a release PR.
# 2. Merging that PR changes .claude-plugin/plugin.json on main, which fires
# the second half: it tags super-prototyping--v<version> and cuts the
# GitHub Release from the matching RELEASE-NOTES.md section.
#
# Nothing tags a commit that is not on main, and the second half only acts when
# the version actually changed in that push: the tag ruleset forbids moving a
# tag, and an edit to plugin.json that is not a release must not cut one.
#
# Two settings this depends on, neither of them a file in the repo: "Allow
# GitHub Actions to create and approve pull requests" must be on, or the first
# job pushes its branch and then fails to open the PR; and a PR opened by
# GITHUB_TOKEN does not itself trigger `pull_request` workflows, so the release
# PR shows no Validate run until a person pushes to it, which step 4 of the
# release asks for anyway. Either way it is covered: `gates` ran on the commit
# being released, the bump touches only version strings, and the tag job
# re-checks every manifest before `claude plugin tag` runs.
on:
workflow_dispatch:
inputs:
version:
description: 'Release version, e.g. 1.1.0'
required: true
type: string
push:
branches: [main]
paths:
- '.claude-plugin/plugin.json'
permissions:
contents: read
jobs:
gates:
name: Gates
# A release is cut from main, so dispatching from a branch would bump a tree
# nobody reviewed and open a PR carrying every commit on it.
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main'
uses: ./.github/workflows/validate.yml
prepare:
name: Prepare the release PR
# A group per half. Sharing one lets a push queue behind a dispatch and then be
# cancelled by the next push, which would drop a version on the floor untagged.
concurrency:
group: release-prepare
cancel-in-progress: false
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main'
needs: gates
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v7
- name: Bump every manifest
env:
VERSION: ${{ inputs.version }}
run: |
scripts/bump-version.sh "$VERSION"
scripts/bump-version.sh --check
- name: Open the release PR
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
if git diff --quiet; then
echo "::error::every manifest is already at $VERSION, so there is nothing to release"
exit 1
fi
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
# A re-dispatch of the same version must not discard the release notes
# someone has already written on the branch, so the bump is replayed on
# top of what is there. A lease cannot protect that: actions/checkout
# maps only the branch it checked out, so --force-with-lease finds no
# remote-tracking ref for release/<version>, reads the lease as "must
# not exist", and rejects the push whenever the branch is already up.
if git ls-remote --exit-code --heads origin "release/$VERSION" > /dev/null 2>&1; then
git fetch --depth 1 origin "release/$VERSION"
git checkout -f -B "release/$VERSION" FETCH_HEAD
scripts/bump-version.sh "$VERSION"
scripts/bump-version.sh --check
if git diff --quiet; then
echo "release/$VERSION is already at $VERSION; leaving the branch as it is"
else
git commit -am "release $VERSION"
git push origin "release/$VERSION"
fi
else
git switch -c "release/$VERSION"
git commit -am "release $VERSION"
git push --set-upstream origin "release/$VERSION"
fi
if [ -n "$(gh pr list --head "release/$VERSION" --state open --json number --jq '.[].number')" ]; then
echo "a PR for release/$VERSION is already open; pushed the new bump to it"
exit 0
fi
# The tag and the notes come after the merge; this PR is only the bump.
gh pr create --base main --title "release $VERSION" --body "$(cat <<BODY
Version bump only: \`scripts/bump-version.sh $VERSION\`.
Merging this tags \`super-prototyping--v$VERSION\` and cuts the GitHub
Release from the \`## v$VERSION\` section of \`RELEASE-NOTES.md\`, so write
that section before merging if it is not in yet.
Users are on the old version until this merges: the version string is the
cache key \`/plugin update\` and \`codex plugin marketplace upgrade\` compare.
BODY
)" || {
echo "::error::could not open the PR. The branch release/$VERSION is pushed, so nothing is lost. Open it by hand at https://github.com/${{ github.repository }}/compare/main...release/$VERSION, and check that Settings → Actions → General → 'Allow GitHub Actions to create and approve pull requests' is on."
exit 1
}
tag:
name: Tag and release
concurrency:
group: release-tag
cancel-in-progress: false
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
# The trigger only says plugin.json was touched, which any edit does. A
# release is the narrower thing: the version *changed* in this push, and no
# tag names it yet. Renaming a field or adding a keyword must not cut a
# release of whatever version happens to be sitting in the file.
- name: Read the version, and stop unless this push released it
id: v
env:
BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
read_version() { python3 -c 'import json,sys;print(json.load(sys.stdin)["version"])'; }
# Semver precedence, which `sort -V` does not implement: it ranks 1.1.0-rc.1
# above 1.1.0, so the ordinary rc-to-final release would read as a downgrade
# and a real downgrade would read as a release. A version neither side can
# parse exits 2 here, which counts as "did not move forward" and is not tagged.
newer() {
python3 -c 'import re, sys
def key(v):
m = re.match(r"(\d+)\.(\d+)\.(\d+)(?:-(.+))?$", v)
if not m:
sys.exit(2)
pre = m.group(4)
rel = tuple(int(x) for x in m.group(1, 2, 3))
return rel + ((0, tuple(int(n) for n in re.findall(r"\d+", pre))) if pre else (1, ()))
sys.exit(0 if key(sys.argv[2]) > key(sys.argv[1]) else 1)' "$1" "$2"
}
version=$(read_version < .claude-plugin/plugin.json)
fresh=true
if ! git cat-file -e "$BEFORE:.claude-plugin/plugin.json" 2> /dev/null; then
echo "no plugin.json at $BEFORE, so there is nothing to compare against and nothing to tag"
fresh=false
elif before=$(git show "$BEFORE:.claude-plugin/plugin.json" | read_version); [ "$before" = "$version" ]; then
echo "plugin.json changed but the version did not ($version), so this is not a release"
fresh=false
elif ! newer "$before" "$version"; then
# Reverting the release PR restores the old number on a tree that still
# holds the new code, and the tag ruleset forbids moving a tag once it is
# cut. So a version that does not move forward is a revert, not a release,
# and tagging it would be unfixable.
echo "the version did not move forward ($before to $version), so this is not a release"
fresh=false
elif git rev-parse -q --verify "refs/tags/super-prototyping--v$version" > /dev/null; then
echo "super-prototyping--v$version already exists, so there is nothing to do"
fresh=false
else
echo "this push released $version"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "fresh=$fresh" >> "$GITHUB_OUTPUT"
- uses: actions/setup-node@v7
if: steps.v.outputs.fresh == 'true'
with:
node-version: "22"
- name: Install Claude Code
if: steps.v.outputs.fresh == 'true'
# Unpinned, so the version that tagged a release is worth having in the log:
# the tag format and what --strict rejects are both its to change.
run: |
npm install -g @anthropic-ai/claude-code
claude --version
# `claude plugin tag` reads .claude-plugin/plugin.json and the marketplace entry
# and nothing else, so without this the root, Codex, CodeBuddy and pyproject
# versions are unchecked at the moment of tagging: exactly the half-versioned
# release scripts/bump-version.sh exists to prevent.
- name: Every manifest still agrees
if: steps.v.outputs.fresh == 'true'
run: scripts/bump-version.sh --check
# `claude plugin tag` is the tool's own spelling of this repo's tag: it
# validates the plugin, checks plugin.json and the marketplace entry agree on
# the version, and refuses a dirty tree.
- name: Tag
if: steps.v.outputs.fresh == 'true'
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
claude plugin tag . --push -m 'super-prototyping %s'
- name: Cut the GitHub Release
if: steps.v.outputs.fresh == 'true'
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ steps.v.outputs.version }}
run: |
set -euo pipefail
# The section for this version, up to the next heading of any kind. The
# heading must match exactly: a prefix match would hand `1.1.0` the notes
# written under `## v1.1.0-rc.1`.
awk -v want="## v$VERSION" '
{ sub(/\r$/, ""); sub(/[ \t]+$/, "") }
$0 == want { on = 1; next }
on && /^## / { exit }
on { print }
' RELEASE-NOTES.md > notes.md
# A version with a suffix is a prerelease; GitHub should not offer it as
# the latest release.
case "$VERSION" in
*-*) prerelease=--prerelease ;;
*) prerelease= ;;
esac
# -s would pass a section that is nothing but blank lines.
if grep -q '[^[:space:]]' notes.md; then
gh release create "super-prototyping--v$VERSION" $prerelease \
--title "super-prototyping $VERSION" --notes-file notes.md
else
echo "::warning::RELEASE-NOTES.md has no '## v$VERSION' section; falling back to generated notes"
gh release create "super-prototyping--v$VERSION" $prerelease \
--title "super-prototyping $VERSION" --generate-notes
fi