forked from OpenCoven/coven-code
-
Notifications
You must be signed in to change notification settings - Fork 0
409 lines (366 loc) · 15.6 KB
/
Copy pathrelease.yml
File metadata and controls
409 lines (366 loc) · 15.6 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
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
name: Release
on:
workflow_dispatch:
inputs:
version:
description: 'Version tag (e.g., v0.1.0)'
required: true
type: string
publish_npm:
description: 'Also publish @opencoven/coven-code to npm. The package is deprecated (engine ships as a GitHub Release binary) — leave false unless intentionally re-publishing.'
required: false
type: boolean
default: false
# Only one release at a time — prevent races on the same tag.
concurrency:
group: release-${{ inputs.version }}
cancel-in-progress: false
permissions:
contents: write
# `actions: write` is needed for the final step that dispatches
# npm-publish.yml via `gh workflow run`. Without it, `gh workflow run`
# gets a 403 from the GitHub API.
actions: write
env:
CARGO_TERM_COLOR: always
RELEASE_VERSION: ${{ inputs.version }}
jobs:
# ── Preflight: validate branch, tag, and version match ──────────────
preflight:
runs-on: ubuntu-latest
name: Preflight checks
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
- name: Validate version input
run: |
if [[ ! "$RELEASE_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Version must be a v-prefixed SemVer tag, for example v0.1.0."
exit 1
fi
- name: Must be on main branch
run: |
if [[ "${{ github.ref }}" != "refs/heads/main" ]]; then
echo "::error::Releases must be triggered from the main branch (got ${{ github.ref }})"
exit 1
fi
- name: Tag must not already exist
run: |
if git ls-remote --exit-code --tags origin "refs/tags/$RELEASE_VERSION" >/dev/null 2>&1; then
echo "::error::Tag $RELEASE_VERSION already exists"
exit 1
fi
- name: Version tag must match Cargo.toml
run: |
CARGO_VERSION=$(grep '^version' src-rust/Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/')
TAG_VERSION="$RELEASE_VERSION"
TAG_VERSION="${TAG_VERSION#v}" # strip leading v
if [[ "$CARGO_VERSION" != "$TAG_VERSION" ]]; then
echo "::error::Tag version ($TAG_VERSION) does not match Cargo.toml ($CARGO_VERSION). Update Cargo.toml first."
exit 1
fi
echo "Version verified: $CARGO_VERSION"
# ── Build matrix ────────────────────────────────────────────────────
build:
needs: preflight
strategy:
fail-fast: true
matrix:
include:
- target: x86_64-pc-windows-msvc
os: windows-latest
artifact: coven-code-windows-x86_64
ext: .exe
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
artifact: coven-code-linux-x86_64
ext: ""
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
artifact: coven-code-linux-aarch64
ext: ""
cross: true
- target: x86_64-apple-darwin
os: macos-latest
artifact: coven-code-macos-x86_64
ext: ""
- target: aarch64-apple-darwin
os: macos-latest
artifact: coven-code-macos-aarch64
ext: ""
runs-on: ${{ matrix.os }}
name: Build ${{ matrix.artifact }}
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
# Linux native: install system libraries for ALSA (voice feature).
- name: Install Linux system dependencies
if: runner.os == 'Linux' && !matrix.cross
run: |
sudo apt-get update
sudo apt-get install -y libasound2-dev pkg-config
# Linux ARM64: use cross for reliable cross-compilation (handles
# sysroot, pkg-config, ALSA automatically via Docker).
- name: Install cross (aarch64-linux)
if: matrix.cross
run: cargo install cross --git https://github.com/cross-rs/cross
- name: Cache cargo registry & build
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
src-rust/target
key: ${{ matrix.target }}-cargo-${{ hashFiles('src-rust/Cargo.lock') }}
restore-keys: ${{ matrix.target }}-cargo-
- name: Build release binary (native)
if: ${{ !matrix.cross }}
working-directory: src-rust
run: cargo build --release --locked --package claurst --target ${{ matrix.target }}
- name: Create Cross.toml for cross-compilation
if: matrix.cross
working-directory: src-rust
run: |
cat > Cross.toml << 'EOF'
[target.aarch64-unknown-linux-gnu]
pre-build = [
"dpkg --add-architecture $CROSS_DEB_ARCH",
"apt-get update",
"apt-get install -y pkg-config libasound2-dev:$CROSS_DEB_ARCH"
]
EOF
- name: Build release binary (cross)
if: matrix.cross
working-directory: src-rust
run: cross build --release --locked --package claurst --target ${{ matrix.target }}
- name: Verify binary exists
shell: bash
run: |
BINARY="src-rust/target/${{ matrix.target }}/release/coven-code${{ matrix.ext }}"
if [[ ! -f "$BINARY" ]]; then
echo "::error::Binary not found at $BINARY"
exit 1
fi
ls -lh "$BINARY"
# Stage the binary into a per-archive directory using the canonical
# name "coven-code" (or "coven-code.exe"). The install scripts assume this
# name inside the archive — do not change without updating install.sh
# and install.ps1.
- name: Stage binary for packaging
shell: bash
run: |
mkdir -p "stage/${{ matrix.artifact }}"
cp "src-rust/target/${{ matrix.target }}/release/coven-code${{ matrix.ext }}" \
"stage/${{ matrix.artifact }}/coven-code${{ matrix.ext }}"
- name: Upload artifact
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact }}
path: stage/${{ matrix.artifact }}/coven-code${{ matrix.ext }}
# ── Create GitHub release ───────────────────────────────────────────
release:
needs: build
runs-on: ubuntu-latest
name: Create Release
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
- name: Verify all expected assets exist
run: |
EXPECTED=(
coven-code-windows-x86_64
coven-code-linux-x86_64
coven-code-linux-aarch64
coven-code-macos-x86_64
coven-code-macos-aarch64
)
MISSING=()
for name in "${EXPECTED[@]}"; do
if [[ ! -d "artifacts/$name" ]]; then
MISSING+=("$name")
fi
done
if [[ ${#MISSING[@]} -gt 0 ]]; then
echo "::error::Missing artifacts: ${MISSING[*]}"
exit 1
fi
echo "All 5 artifacts present."
- name: Prepare release archives
run: |
mkdir -p release
for dir in artifacts/*/; do
name=$(basename "$dir")
binary=$(find "$dir" -maxdepth 1 -type f | head -1)
if [[ -z "$binary" ]]; then
echo "::error::No file in $dir"
exit 1
fi
if [[ "$binary" == *.exe ]]; then
(cd "$dir" && zip "../../release/${name}.zip" "$(basename "$binary")")
else
chmod +x "$binary"
tar -czf "release/${name}.tar.gz" -C "$dir" "$(basename "$binary")"
fi
done
# Stage install scripts as release assets so users can run:
# curl -fsSL https://github.com/<repo>/releases/latest/download/install.sh | bash
# irm https://github.com/<repo>/releases/latest/download/install.ps1 | iex
if [[ -f install.sh ]]; then
cp install.sh release/install.sh
fi
if [[ -f install.ps1 ]]; then
cp install.ps1 release/install.ps1
fi
echo "Release assets:"
ls -lh release/
# Build release notes in two passes:
# 1. Pull GitHub's auto-generated notes via the API — this gives us
# the "What's Changed" PR list with @author mentions, the
# "New Contributors" section, the "Full Changelog" link, and the
# label-driven categorisation defined in .github/release.yml.
# 2. Append a "Direct commits" section listing non-merge commits
# that did NOT land via a squash-merged PR (those already appear
# in the API output). This captures direct pushes to main, which
# `generate_release_notes` ignores.
#
# We don't pass `generate_release_notes: true` on the release action
# because it conflicts with `body_path`. The API call below is the
# exact same endpoint the action would have hit.
- name: Generate release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
CURRENT_TAG="$RELEASE_VERSION"
PREVIOUS_TAG="$(git tag --sort=-v:refname | grep -vxF "$CURRENT_TAG" | head -n 1 || true)"
# ── Pass 1: GitHub-generated PR notes ──────────────────────────
# Same endpoint the gh-release action's `generate_release_notes`
# toggle hits. Honours .github/release.yml categories and
# surfaces "New Contributors" + "Full Changelog" footer.
if [[ -n "$PREVIOUS_TAG" ]]; then
gh api \
-X POST \
"/repos/${{ github.repository }}/releases/generate-notes" \
-f tag_name="$CURRENT_TAG" \
-f previous_tag_name="$PREVIOUS_TAG" \
-f target_commitish="${{ github.sha }}" \
--jq '.body' \
> pr-notes.md
else
echo "" > pr-notes.md
fi
# ── Pass 2: direct commits not associated with a merged PR ─────
# Heuristic: squash-merged PRs land with their PR number appended
# to the subject as "(#NN)" — strip those, they're already in the
# PR section. Merge commits are excluded outright.
if [[ -n "$PREVIOUS_TAG" ]]; then
RANGE="${PREVIOUS_TAG}..${{ github.sha }}"
else
RANGE="${{ github.sha }}"
fi
DIRECT_COMMITS="$(
git log --no-merges --pretty=format:'%H%x09%s' "$RANGE" \
| grep -Ev '\(#[0-9]+\)$' \
| awk -F'\t' -v repo="${{ github.repository }}" '
{
short=substr($1,1,7)
printf("- %s ([`%s`](https://github.com/%s/commit/%s))\n", $2, short, repo, $1)
}
' \
|| true
)"
# ── Pass 3: split pr-notes.md into its three logical sections ──
# GitHub's generate-notes returns one blob containing, in order:
# 1. Category headings (✨ Features, 🐛 Fixes, …) + Other Changes
# 2. "## New Contributors" list
# 3. "**Full Changelog**: …compare link"
# We want to re-order the final body as:
# Direct commits → Other changes → New Contributors → Full Changelog
# so split on the two well-known anchors. awk keeps this portable
# and avoids shelling out to a heredoc-python in the workflow.
awk '
BEGIN { section = "categories" }
/^## New Contributors[[:space:]]*$/ { section = "contributors"; next }
/^\*\*Full Changelog\*\*:/ { section = "changelog" }
{ print > (section ".md") }
' pr-notes.md
# Ensure all three files exist even when empty so the composition
# block below doesn't fail under `set -e`.
touch categories.md contributors.md changelog.md
# ── Compose final body in the requested order ──────────────────
# Direct commits first, then everything carried over from the PR
# categorisation, then New Contributors, then the Full Changelog
# footer link. Blank-line spacers are emitted only between
# sections that have content, so a release with (say) no direct
# commits doesn't get a leading blank line.
: > release-notes.md
first=1
append_section() {
local body="$1"
[[ -z "$body" ]] && return 0
if [[ $first -eq 0 ]]; then
{ echo; echo; } >> release-notes.md
fi
printf '%s\n' "$body" >> release-notes.md
first=0
}
if [[ -n "$DIRECT_COMMITS" ]]; then
append_section "$(printf '## 🛠 Direct commits\n\nWork pushed to `main` outside of pull requests in this release:\n\n%s' "$DIRECT_COMMITS")"
fi
# `categories.md` already carries its own per-section `##` headings
# (e.g. `## ✨ Features`) emitted by GitHub's generator, so we pipe
# it through verbatim — no extra wrapper heading needed.
CATEGORIES="$(sed -e :a -e '/^[[:space:]]*$/{$d;N;ba' -e '}' categories.md)"
append_section "$CATEGORIES"
CONTRIBUTORS="$(sed -e :a -e '/^[[:space:]]*$/{$d;N;ba' -e '}' contributors.md)"
if [[ -n "$CONTRIBUTORS" ]]; then
append_section "$(printf '## New Contributors\n\n%s' "$CONTRIBUTORS")"
fi
CHANGELOG="$(sed -e :a -e '/^[[:space:]]*$/{$d;N;ba' -e '}' changelog.md)"
append_section "$CHANGELOG"
echo "── Final release notes ──────────────────────────────"
cat release-notes.md
- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ env.RELEASE_VERSION }}
target_commitish: ${{ github.sha }}
name: Coven Code ${{ env.RELEASE_VERSION }}
draft: false
prerelease: false
body_path: release-notes.md
files: release/*
# Explicit hand-off to npm-publish.yml.
#
# We can't rely solely on the `workflow_run` trigger in npm-publish.yml
# because this Release workflow is itself triggered by a workflow
# dispatch from auto-release.yml's GITHUB_TOKEN, and GitHub's
# automatic-token rules don't list `workflow_run` as an exception to
# the "events from GITHUB_TOKEN don't create new workflow runs" rule.
# In practice that means the workflow_run chain silently no-ops on
# auto-cut releases. Dispatching directly here makes the publish step
# deterministic — the workflow_run trigger stays as a manual-release
# fallback.
- name: Dispatch npm-publish.yml
if: ${{ inputs.publish_npm }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh workflow run npm-publish.yml \
--repo "${{ github.repository }}" \
--ref main \
-f version="$RELEASE_VERSION"
echo "Dispatched npm-publish.yml for $RELEASE_VERSION."