release.yml: fix gh CLI misuse in the post-retry size re-check #21
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 | |
| - name: Publish release with all platform assets | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| gh release create "${{ github.ref_name }}" dist/* \ | |
| --repo "${{ github.repository }}" \ | |
| --title "${{ github.ref_name }}" \ | |
| --generate-notes \ | |
| --draft=false \ | |
| || gh release upload "${{ github.ref_name }}" dist/* \ | |
| --repo "${{ github.repository }}" \ | |
| --clobber | |
| # `gh release create dist/*` uploads every asset concurrently and can | |
| # exit 0 even when one of them silently failed to attach (observed on | |
| # v1.1.1: the Windows .exe — by far the largest asset at ~450MB — never | |
| # made it onto the release while gh reported success, and every asset | |
| # smaller than it uploaded fine). The "Validate..." step above only | |
| # confirms the files exist locally in dist/ *before* this upload; | |
| # this checks what the release *actually* ended up with afterward, and | |
| # retries anything gh's own upload silently dropped. | |
| # | |
| # Comparing by NAME alone isn't enough (learned the hard way on the | |
| # v1.1.1 rebuild): 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 check treats as "already there" and never retries. Byte | |
| # size is compared against the local file instead — cheap, and the one | |
| # signal that actually distinguishes a real upload from a placeholder. | |
| - name: Verify every local asset actually landed on the release, retry any that didn't | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set -euo pipefail | |
| published_json=$(gh release view "${{ github.ref_name }}" --repo "${{ github.repository }}" --json assets --jq '.assets') | |
| missing=0 | |
| for f in dist/*; do | |
| name=$(basename "$f") | |
| local_size=$(stat -c%s "$f") | |
| remote_size=$(jq -r --arg name "$name" '.[] | select(.name == $name) | .size' <<< "$published_json") | |
| if [ -z "$remote_size" ] || [ "$remote_size" != "$local_size" ]; then | |
| echo "::warning::Asset '$name' is missing or wrong size on the release (local: ${local_size} bytes, remote: ${remote_size:-none}) — deleting any partial copy and re-uploading." | |
| gh release delete-asset "${{ github.ref_name }}" "$name" --repo "${{ github.repository }}" --yes 2>/dev/null || true | |
| if ! gh release upload "${{ github.ref_name }}" "$f" --repo "${{ github.repository }}" --clobber; then | |
| echo "::error::Retry upload failed for '$name'." | |
| missing=1 | |
| continue | |
| fi | |
| # Re-check size after the retry rather than trusting gh's exit | |
| # code alone — that's the exact assumption that let the | |
| # original silent failure through. gh's own --jq flag takes a | |
| # single filter-string argument, not jq's --arg/--jq combo (an | |
| # earlier version of this line mixed the two, which made gh | |
| # treat the leftover tokens — including the two halves of an | |
| # asset name containing a space, "Modelforge Setup 1.1.1.exe" | |
| # — as extra positional args to `release view`, which only | |
| # accepts one: the tag). Re-fetching the full JSON and reusing | |
| # the same plain-jq --arg pattern as above avoids that entirely. | |
| published_json=$(gh release view "${{ github.ref_name }}" --repo "${{ github.repository }}" --json assets --jq '.assets') | |
| new_size=$(jq -r --arg name "$name" '.[] | select(.name == $name) | .size' <<< "$published_json") | |
| if [ "$new_size" != "$local_size" ]; then | |
| echo "::error::Asset '$name' still wrong size after retry (local: ${local_size}, remote: ${new_size:-none})." | |
| missing=1 | |
| fi | |
| fi | |
| done | |
| if [ "$missing" -ne 0 ]; then | |
| echo "::error::One or more release assets could not be uploaded even after a retry — this release is incomplete." | |
| exit 1 | |
| fi |