-
Notifications
You must be signed in to change notification settings - Fork 1
212 lines (195 loc) · 9.34 KB
/
Copy pathrelease.yml
File metadata and controls
212 lines (195 loc) · 9.34 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
name: Release
on:
push:
tags:
- "v*.*.*"
permissions:
contents: write
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [windows-latest, macos-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Install app dependencies
working-directory: app
run: npm ci
- name: Run tests
run: |
npm --prefix frontend test
npm --prefix app test
# Build only — no publish here. Three parallel jobs each trying to
# create/find the same GitHub release is a known electron-builder race:
# only one reliably wins, and the others can exit 0 while silently
# skipping their own upload. Publishing happens once, sequentially,
# in the job below instead.
- name: Build
working-directory: app
run: npm run build:all && npx electron-builder --publish never
# electron-builder can exit 0 while having silently failed to produce
# the actual installer for this platform (a code-signing step that
# warns instead of erroring, a packager crash it swallows, etc.) — the
# old `if-no-files-found: ignore` on the upload step below meant that
# kind of failure would only surface later, as a missing asset in the
# published GitHub release, with no clear signal of which platform or
# step was responsible. Each matrix leg's own required installer type
# is checked explicitly here so a silent packaging failure fails this
# job, on this platform, right where it happened.
- name: Verify the expected installer artifact was produced
working-directory: app/release
shell: bash
run: |
set -euo pipefail
case "${{ matrix.os }}" in
windows-latest) pattern='*.exe' ;;
macos-latest) pattern='*.dmg' ;;
ubuntu-latest) pattern='*.AppImage' ;;
*) echo "::error::Unhandled matrix os '${{ matrix.os }}' — add its expected installer pattern here." && exit 1 ;;
esac
# shellcheck disable=SC2086 (intentional glob expansion)
matches=$(ls -1 $pattern 2>/dev/null | wc -l)
if [ "$matches" -eq 0 ]; then
echo "::error::No installer matching '$pattern' was found in app/release for ${{ matrix.os }} — electron-builder must have failed to produce it."
ls -la .
exit 1
fi
echo "Found $matches artifact(s) matching '$pattern'."
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist-${{ matrix.os }}
path: |
app/release/*.exe
app/release/*.exe.blockmap
app/release/*.dmg
app/release/*.dmg.blockmap
app/release/*.zip
app/release/*.AppImage
app/release/latest*.yml
# The list above intentionally spans all three platforms' output
# patterns (electron-builder's own upload step further down needs
# them merged into one flat `dist/` for a single release publish),
# so on any given matrix leg most of these globs legitimately match
# nothing — that's expected, not a failure, which is exactly why
# the step above checks for *this platform's* required installer
# explicitly instead of relying on this list.
if-no-files-found: warn
publish-release:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: List downloaded assets
run: ls -la dist
# Each build matrix leg already fails on its own platform if its
# installer didn't get produced (see "Verify the expected installer
# artifact was produced" above) — this is the second, independent
# check right before publishing: it protects against a leg being
# skipped entirely (fail-fast: false lets the others continue) or an
# artifact getting lost/renamed between upload and this download,
# either of which would otherwise still let a release go out missing
# a platform.
- name: Validate every platform's installer is present before publishing
working-directory: dist
run: |
set -euo pipefail
missing=0
for pattern in '*.exe' '*.dmg' '*.AppImage'; do
if ! ls -1 $pattern >/dev/null 2>&1; then
echo "::error::No file matching '$pattern' in the downloaded release assets — that platform's installer is missing."
missing=1
fi
done
if [ "$missing" -ne 0 ]; then
exit 1
fi
# Create the release shell with no assets attached yet. Asset upload is
# handled entirely by the next step, one file at a time — see that
# step's comment for why: bundling every asset (including the ~450MB
# Windows .exe) into one `gh release create/upload dist/*` call uploads
# them all concurrently, and a transient network hiccup on the largest
# asset's stream can be swallowed by gh while every smaller asset
# uploaded alongside it succeeds — this is exactly what happened on the
# v1.1.1 release (twice, even with a single delete+retry cycle).
- name: Create the release (no assets yet)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view "${{ github.ref_name }}" --repo "${{ github.repository }}" >/dev/null 2>&1 \
|| gh release create "${{ github.ref_name }}" \
--repo "${{ github.repository }}" \
--title "${{ github.ref_name }}" \
--generate-notes \
--draft=false
# Uploads (and independently verifies) each asset ONE AT A TIME instead
# of concurrently — the actual fix for the silent-drop failure, not
# just a retry loop around the same concurrent bulk upload. Each file
# gets up to 4 attempts with backoff (10s/30s/60s) before the release
# is declared incomplete. Byte size (not just presence/exit code) is
# what's checked after every attempt: a failed/partial upload can still
# register a ghost asset entry under the right name with the wrong
# size (or 0), which a name-only or exit-code-only check would treat as
# "already there" and never retry.
- name: Upload each release asset individually, with retry + size verification
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
remote_size_of() {
gh release view "${{ github.ref_name }}" --repo "${{ github.repository }}" --json assets --jq \
'.assets[] | select(.name == "'"$1"'") | .size' 2>/dev/null || true
}
overall_missing=0
# Largest assets first — they're the ones actually at risk, so if
# the runner/network is having a bad day this surfaces (and starts
# retrying) as early as possible rather than after every small file
# has already gone through.
while IFS= read -r -d '' f; do
name=$(basename "$f")
# GitHub normalizes spaces in uploaded asset names to periods.
# Query and delete by the server-side name while continuing to
# upload the original local path.
remote_name=${name// /.}
local_size=$(stat -c%s "$f")
uploaded=0
for attempt in 1 2 3 4; do
remote_size=$(remote_size_of "$remote_name")
if [ -n "$remote_size" ] && [ "$remote_size" = "$local_size" ]; then
echo "Asset '$name' already present with the correct size ($local_size bytes)."
uploaded=1
break
fi
if [ "$attempt" -gt 1 ]; then
backoff=$((5 * attempt * (attempt - 1)))
echo "::warning::Asset '$name' missing or wrong size (local: ${local_size}, remote: ${remote_size:-none}) — attempt $attempt/4 after ${backoff}s."
sleep "$backoff"
# Only clean up a ghost/partial entry once we know one exists —
# never delete-then-fail-to-replace on the very first attempt.
if [ -n "$remote_size" ]; then
gh release delete-asset "${{ github.ref_name }}" "$remote_name" --repo "${{ github.repository }}" --yes 2>/dev/null || true
fi
fi
gh release upload "${{ github.ref_name }}" "$f" --repo "${{ github.repository }}" --clobber || true
done
remote_size=$(remote_size_of "$remote_name")
if [ -z "$remote_size" ] || [ "$remote_size" != "$local_size" ]; then
echo "::error::Asset '$name' still missing or wrong size after 4 attempts (local: ${local_size}, remote: ${remote_size:-none})."
overall_missing=1
fi
done < <(find dist -maxdepth 1 -type f -printf '%s\t%p\0' | sort -z -nr | cut -z -f2-)
if [ "$overall_missing" -ne 0 ]; then
echo "::error::One or more release assets could not be uploaded even after retrying — this release is incomplete."
exit 1
fi