-
Notifications
You must be signed in to change notification settings - Fork 2
246 lines (225 loc) · 11.5 KB
/
Copy pathrelease.yml
File metadata and controls
246 lines (225 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
name: Auto Release & Satis Rebuild
on:
push:
branches: [master]
permissions:
contents: write
jobs:
tests:
uses: ./.github/workflows/tests.yml
release:
needs: tests
runs-on: ubuntu-latest
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Ensure full tag history is available
# Belt and braces on top of fetch-depth: 0. A shallow or stale view of
# tags here would make every step below compute a wrong bump silently
# (under- or over-releasing) - too costly to leave to one safeguard.
run: git fetch --tags --force
- name: Get latest tag
# Single source of truth for "the last tag", reused by both the bump-range
# scan below and the version arithmetic. Previously this used
# `git tag --sort=-v:refname | head -1` (the highest semver string ANYWHERE
# in the tag namespace) while the bump step used `git describe` (the
# nearest ANCESTOR tag) - two different algorithms that silently diverge
# the moment a stray tag exists that isn't reachable from HEAD (this repo
# has had two such stray tags today). A version computed from one history
# and a bump computed from another is a release disconnected from reality.
# `tag` is empty when no ancestor tag exists at all (fresh repo); `base`
# is the same value with the documented v0.0.0 starting point as fallback,
# for the arithmetic below (which needs actual numbers to increment).
id: latest_tag
run: |
TAG=$(git describe --tags --abbrev=0 2>/dev/null || true)
if [ -z "$TAG" ]; then
echo "tag=" >> "$GITHUB_OUTPUT"
echo "base=v0.0.0" >> "$GITHUB_OUTPUT"
echo "No ancestor tag found on this branch"
else
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "base=$TAG" >> "$GITHUB_OUTPUT"
echo "Latest ancestor tag: $TAG"
fi
- name: Check if HEAD is already tagged
id: head_tag
run: |
TAG=$(git tag --points-at HEAD | sort -V | tail -1)
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
if [ -n "$TAG" ]; then
echo "HEAD is already tagged ($TAG) — a release for this commit already happened"
else
echo "HEAD is not tagged yet"
fi
- name: Determine version bump from commit message
# Scans every commit since the last ancestor tag, not just the tip, so a
# fix:/feat: whose release gets blocked by the gate can't be buried by a
# later docs:/ci:/chore: commit becoming the new tip. Bump = max severity
# found in the range (major > minor > patch > none).
#
# Subject and body are read and matched SEPARATELY, on purpose, after two
# incidents from blurring them: the type prefix and the !-bang form are
# Conventional Commits subject-line syntax and are matched only against
# the first physical line; BREAKING CHANGE:/BREAKING-CHANGE: is a footer
# and is matched only against everything after that line, anchored at
# line start with the colon required. No line in a commit body can
# trigger a bump except a genuine footer - a commit merely documenting
# the convention (which these commits do, routinely) must never be read
# as declaring one.
#
# %s/%b are NOT used for the split: git treats %s as the first
# PARAGRAPH, so a footer with no blank line before it (a malformed but
# real-world commit shape) folds into %s and %b comes back empty - the
# opposite failure direction from the first two incidents (a genuine
# breaking change silently downgraded to patch instead of a false
# major). Reading the raw message (%B) and splitting on the first
# physical line ourselves gives a true first-line subject regardless of
# blank lines.
#
# Two further edge cases found by review: a leading blank physical line
# (reachable via --cleanup=verbatim, commit-tree, or the GitHub web UI/
# Action commits) would make `head -n1` grab the blank line as the
# "subject" and hide a real feat:/fix: in the body, where the prefix
# regex never looks - so the subject is the first NON-empty physical
# line, not simply the first one. And leading whitespace on that line
# (git's default --cleanup=strip only trims trailing whitespace) would
# otherwise dodge every subject anchor - so the extracted subject is
# trimmed of leading whitespace before matching. The body is
# deliberately NOT trimmed: a BREAKING CHANGE: footer must sit at column
# zero per the spec, and an indented one must keep failing to match.
#
# This step reuses the single ancestor tag resolved in "Get latest tag"
# (steps.latest_tag.outputs.tag) instead of calling `git describe` again,
# so the range scanned here and the version incremented below can never
# disagree about what "the last tag" is.
#
# CRITICAL, cost a release on this exact repo: `--pretty=format:%H` does
# NOT terminate the final entry with a newline, and a `while read` loop
# does not run its body for a trailing line with no terminator - so the
# LAST commit of every range was silently dropped. On a multi-commit
# range some other commit usually still carried the max severity, which
# is why this stayed hidden; on a single-commit range (one fix:/feat:
# pushed alone) it always produced none. Using `tformat:%H` instead
# (which does terminate every entry, including the last) fixes the root
# cause; `read -r HASH || [ -n "$HASH" ]` is kept too, belt-and-braces,
# so a trailing unterminated line would still be processed even if some
# future edit reintroduces a non-terminating format.
id: bump
run: |
TAG="${{ steps.latest_tag.outputs.tag }}"
if [ -n "$TAG" ]; then
RANGE="$TAG..HEAD"
echo "Scanning commits since $TAG ($RANGE)"
else
RANGE="HEAD"
echo "No ancestor tag found; scanning full history"
fi
SEVERITY=0
while IFS= read -r HASH || [ -n "$HASH" ]; do
FULL=$(git log -1 --pretty=format:%B "$HASH")
SUBJECT_LINE=$(printf '%s\n' "$FULL" | awk '/[^[:space:]]/{print NR; exit}')
if [ -z "$SUBJECT_LINE" ]; then
SUBJECT_LINE=1
fi
SUBJECT=$(printf '%s\n' "$FULL" | sed -n "${SUBJECT_LINE}p" | sed 's/^[[:space:]]*//')
BODY=$(printf '%s\n' "$FULL" | tail -n +$((SUBJECT_LINE + 1)))
if echo "$SUBJECT" | grep -qE '^(feat|fix|refactor|perf|style|docs|test|chore|ci|build)(\(.+\))?!:' || echo "$BODY" | grep -qE '^BREAKING[ -]CHANGE:'; then
[ "$SEVERITY" -lt 3 ] && SEVERITY=3
elif echo "$SUBJECT" | grep -qE '^feat(\(.+\))?:'; then
[ "$SEVERITY" -lt 2 ] && SEVERITY=2
elif echo "$SUBJECT" | grep -qE '^(fix|perf|refactor)(\(.+\))?:'; then
[ "$SEVERITY" -lt 1 ] && SEVERITY=1
fi
done < <(git log $RANGE --pretty=tformat:%H 2>/dev/null)
case "$SEVERITY" in
3) echo "type=major" >> "$GITHUB_OUTPUT" ;;
2) echo "type=minor" >> "$GITHUB_OUTPUT" ;;
1) echo "type=patch" >> "$GITHUB_OUTPUT" ;;
# docs/test/chore/ci/build/style-only range, no shipped code -> no release
*) echo "type=none" >> "$GITHUB_OUTPUT" ;;
esac
- name: Calculate new version
id: version
# Tagging must be idempotent: only compute/create a tag when this commit
# doesn't have one yet. Re-runs and double-fires must never mint a second tag.
if: steps.bump.outputs.type != 'none' && steps.head_tag.outputs.tag == ''
run: |
TAG="${{ steps.latest_tag.outputs.base }}"
VERSION="${TAG#v}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
BUMP="${{ steps.bump.outputs.type }}"
case "$BUMP" in
major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
patch) PATCH=$((PATCH + 1)) ;;
esac
NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}"
echo "new_tag=$NEW_TAG" >> "$GITHUB_OUTPUT"
echo "New version: $NEW_TAG ($BUMP bump)"
- name: Create and push tag
if: steps.bump.outputs.type != 'none' && steps.head_tag.outputs.tag == ''
run: |
git tag ${{ steps.version.outputs.new_tag }}
git push origin ${{ steps.version.outputs.new_tag }}
- name: Publish GitHub Release
# The tag alone is invisible: 63 versions had shipped to Packagist while
# the Releases page stayed empty, so the repo read as dormant to anyone
# landing on it — and GitHub's release feed, which people actually
# subscribe to, had nothing to carry.
#
# Gated exactly like the Satis trigger (not like the tag step) so it is
# retryable: a run whose release call failed can be re-run against a tag
# that already exists. `gh release view` makes it idempotent — creating a
# release that is already there is an error, and must not fail the job.
#
# Notes are built from the commit subjects in the released range rather
# than --generate-notes, which summarises pull requests: this repo
# commits straight to master, so auto-generated notes come back empty.
if: steps.bump.outputs.type != 'none'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${{ steps.head_tag.outputs.tag }}"
if [ -z "$TAG" ]; then
TAG="${{ steps.version.outputs.new_tag }}"
fi
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists, nothing to do"
exit 0
fi
PREV="${{ steps.latest_tag.outputs.tag }}"
if [ -n "$PREV" ]; then
RANGE="$PREV..HEAD"
else
RANGE="HEAD"
fi
{
git log $RANGE --no-merges --pretty=tformat:'- %s' | grep -E '^- (feat|fix|perf|refactor)(\(.+\))?!?:' || echo '- Maintenance release'
echo ""
echo "Install: \`composer require bluestarsystem/aura-ui:$TAG\`"
echo ""
echo "Full component catalogue: https://aura-ui.com/components"
} > /tmp/notes.md
gh release create "$TAG" --title "$TAG" --notes-file /tmp/notes.md
echo "Published release $TAG"
- name: Trigger Satis rebuild
# Publishing must be retryable, unlike tagging: this runs whenever the
# commit is release-worthy, whether the tag was just created above or
# already existed from a prior run whose Satis trigger failed. Firing
# this twice is harmless; skipping it once leaves a tag with no build.
if: steps.bump.outputs.type != 'none'
run: |
TAG="${{ steps.head_tag.outputs.tag }}"
if [ -z "$TAG" ]; then
TAG="${{ steps.version.outputs.new_tag }}"
fi
curl -s -X POST https://packages.elju.it/_webhook \
-H "X-Satis-Api-Key: ${{ secrets.SATIS_API_KEY }}" \
-H "Content-Type: application/json" \
-d "{\"package\": \"${{ github.repository }}\", \"version\": \"$TAG\"}"
echo "Satis rebuild triggered for $TAG"