release: v1.3.0 — ModelForge Medical, a clinical decision-support wor… #26
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |