From c2fe875c8e4d99396c7c53d6b9a93cdeb1abe133 Mon Sep 17 00:00:00 2001 From: wa0o Date: Wed, 2 Sep 2026 14:16:30 +0000 Subject: [PATCH 1/4] Put the installers on the release page people actually open All 16 releases on this repo have zero assets, and they carry the full "download HamDeckPusher-win-Setup.exe" notes anyway - telling the reader to download a file that is not there. The artifacts for v0.1.29 were on jwussler/hamdeck-releases; every tag before it has no downloadable installer anywhere. Nothing ever failed: gh release create is perfectly happy to publish a release with an empty file list, and the job goes green. - Publish to BOTH repos from the same step, with the same file list, so they cannot drift. hamdeck-releases stays the canonical public download - this repo is private and Velopack's updater cannot read it without a token that would have to be embedded in every copy handed to a friend. But the source repo is where its own author goes to find a build, and for 16 tags what he found was a page of instructions and a Source code (zip) link. - Refuse to publish when dist/ yielded no files, and read the asset count back from the API afterwards and fail if it is short. That is the check that was missing: the failure mode is a release that publishes successfully with nothing attached. - create-or-upload, so re-running a tag tops up an existing release instead of failing on "already exists" and leaving it half filled. - The macOS job uploads to both too, and reads the .dmg back. It publishes the one asset that comes from a different job than the rest, so it can go missing without the publish job's count gate seeing it. - Two tokens rather than one falling back to the other: the fine-grained PAT is scoped to hamdeck-releases and cannot write here, github.token cannot write there, and a single variable holding whichever exists fails on one of them. Gate proven against a stubbed gh: an empty dist/ refuses, a release that reports back 0 assets fails with the count, and a complete one passes. v0.1.29's assets were mirrored onto this repo's release by hand, so the link that prompted this is no longer a dead end. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqubrbjLXwz1GrVgmTcrf9 --- .github/workflows/release.yml | 93 +++++++++++++++++++++++++++++------ 1 file changed, 79 insertions(+), 14 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d28ffb..2e21306 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -719,13 +719,37 @@ jobs: - name: Attach it to the release if: ${{ env.APPLE_CERT_P12 != '' }} env: - GH_TOKEN: ${{ secrets.RELEASES_TOKEN || github.token }} + # ⚠️ TWO TOKENS, NOT ONE FALLING BACK TO THE OTHER. This used to be + # `RELEASES_TOKEN || github.token`, which was fine while there was one + # destination. Now there are two, and they need DIFFERENT credentials: + # the fine-grained PAT is scoped to jwussler/hamdeck-releases and cannot + # write here, while github.token cannot write there. One variable + # holding whichever happened to exist would fail on one of the two. + GH_TOKEN: ${{ github.token }} + RELEASES_TOKEN: ${{ secrets.RELEASES_TOKEN }} run: | set -euo pipefail - if [ -n "${{ secrets.RELEASES_TOKEN }}" ]; then - gh release upload "${GITHUB_REF_NAME}" "$DMG" --repo jwussler/hamdeck-releases --clobber - else - gh release upload "${GITHUB_REF_NAME}" "$DMG" --clobber + # ⚠️ BOTH REPOS, same reason as the publish job: public is the canonical + # download, the source repo is where its own author looks. This job runs + # AFTER publish, so both releases already exist and this is an upload. + # + # ⚠️ The DMG is the ONE asset published by a different job from the rest, + # so it is the one that can go missing without the publish job's count + # gate noticing. Each upload is read back. + has_dmg() { # repo, token + GH_TOKEN="$2" gh release view "${GITHUB_REF_NAME}" --repo "$1" \ + --json assets -q '.assets[].name' | grep -q '\.dmg$' \ + || { echo "no .dmg on the release in $1 after uploading it"; return 1; } + echo " $1: dmg present" + } + + gh release upload "${GITHUB_REF_NAME}" "$DMG" --repo "$GITHUB_REPOSITORY" --clobber + has_dmg "$GITHUB_REPOSITORY" "$GH_TOKEN" + + if [ -n "${RELEASES_TOKEN:-}" ]; then + GH_TOKEN="$RELEASES_TOKEN" gh release upload "${GITHUB_REF_NAME}" "$DMG" \ + --repo jwussler/hamdeck-releases --clobber + has_dmg jwussler/hamdeck-releases "$RELEASES_TOKEN" fi - name: Destroy the keychain @@ -824,15 +848,56 @@ jobs: # it did, because silently publishing somewhere nobody is looking is worse than # an error. FILES="$(find dist -name '*Setup.exe') $(find dist -name '*.dmg') $(find dist -name '*.deb') $(find dist -name '*.nupkg') $(find dist -name 'RELEASES') $(find dist -name 'releases.*.json')" + + # ⚠️ A RELEASE WITH NO FILES IS THE FAILURE THIS STEP KEEPS PRODUCING. + # 16 releases on this repo carried these exact notes - "download + # HamDeckPusher-win-Setup.exe" - with ZERO assets attached, telling the + # reader to download a file that was not there. Nothing failed: `gh + # release create` is perfectly happy to publish a release with an empty + # file list, and the job goes green. So the count is checked, twice: + # here before publishing, and again against what the API actually holds + # afterwards. + EXPECTED=0 + for f in $FILES; do EXPECTED=$((EXPECTED+1)); done + if [ "$EXPECTED" -eq 0 ]; then + echo "no artifacts were found in dist/ - refusing to publish an empty release" + find dist -type f | head -50 + exit 1 + fi + echo "publishing $EXPECTED files" + + # create-or-upload: a re-run of the same tag must top up the existing + # release rather than fail on "already exists" and leave it half filled. + publish() { # repo, token + GH_TOKEN="$2" gh release create "${GITHUB_REF_NAME}" --repo "$1" \ + --title "HamDeck ${GITHUB_REF_NAME}" --notes-file notes.md $FILES \ + || GH_TOKEN="$2" gh release upload "${GITHUB_REF_NAME}" --repo "$1" \ + $FILES --clobber + local n + n="$(GH_TOKEN="$2" gh release view "${GITHUB_REF_NAME}" --repo "$1" \ + --json assets -q '.assets|length')" + echo " $1: $n assets" + [ "$n" -ge "$EXPECTED" ] || { echo " $1 has $n assets, expected $EXPECTED"; return 1; } + } + + # ⚠️ BOTH REPOS, AND THE SOURCE REPO IS NOT OPTIONAL. + # + # jwussler/hamdeck-releases is the PUBLIC, canonical download: this repo + # is private, so a release here is reachable by one person, and + # Velopack's updater cannot read it at all - GithubSource needs a token, + # and embedding one puts a credential in every copy handed to a friend + # where it can be read straight back out. + # + # But the source repo is where its own author goes to find a build, and + # for 16 tags what he found there was a page of instructions and a + # Source code (zip) link. Mirroring costs an upload and removes a dead + # end. Same files, same step, so the two cannot drift. if [ -n "${RELEASES_TOKEN:-}" ]; then - echo "publishing to jwussler/hamdeck-releases (public)" - GH_TOKEN="$RELEASES_TOKEN" gh release create "${GITHUB_REF_NAME}" \ - --repo jwussler/hamdeck-releases \ - --title "HamDeck ${GITHUB_REF_NAME}" \ - --notes-file notes.md $FILES + echo "publishing to jwussler/hamdeck-releases (public, canonical)" + publish jwussler/hamdeck-releases "$RELEASES_TOKEN" + echo "mirroring to jwussler/hamdeck-cpp (private source repo)" + publish "$GITHUB_REPOSITORY" "$GH_TOKEN" else - echo "RELEASES_TOKEN not set - publishing to this PRIVATE repo, which nobody else can reach" - gh release create "${GITHUB_REF_NAME}" \ - --title "HamDeck ${GITHUB_REF_NAME}" \ - --notes-file notes.md $FILES + echo "RELEASES_TOKEN not set - publishing ONLY to this PRIVATE repo, which nobody else can reach" + publish "$GITHUB_REPOSITORY" "$GH_TOKEN" fi From 41c0d2be5e3144c5e55a7c3b55b9920ed843a472 Mon Sep 17 00:00:00 2001 From: wa0o Date: Wed, 2 Sep 2026 14:33:31 +0000 Subject: [PATCH 2/4] Put the client on the page under its own name, and stop the version drifting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the v0.1.29 page got wrong, both from the same habit of naming things in one place and building them from another. The Windows client was on the page all along - hamdeck-qml.exe ships inside HamDeckPusher-win-Setup.exe, signed like everything else - but the download is named after the Pusher, so nothing said the client was in there. The installer is now HamDeck-win-Setup.exe with packTitle "HamDeck", and the notes say what it contains. ⚠️ The packId stays HamDeckPusher. It is Velopack's update identity: changing it orphans every existing install silently - they keep running and never see another update. Only the file name and the display title move, and the file name is safe precisely because the updater never fetches it. RELEASES points at the .nupkg; Setup.exe is only what a human downloads once, which is the whole problem being fixed. The client version was hardcoded in client/CMakeLists.txt, so v0.1.29 shipped hamdeck-client_0.1.15_amd64.deb - built from the right commit, named from a literal nobody had touched in fourteen releases. The pusher took its version from the tag and the client did not. It now comes from the tag on all three platforms, with 0.0.0 as the fallback for a local build. The same number feeds CFBundleShortVersionString, so the Mac app would have told Get Info it was 0.1.15 too. Both gates proven to fail before being wired in: the version check rejects the real v0.1.29 file list and passes the corrected one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqubrbjLXwz1GrVgmTcrf9 --- .github/workflows/release.yml | 73 +++++++++++++++++++++++++++++++---- client/CMakeLists.txt | 17 +++++++- 2 files changed, 82 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2e21306..78bb597 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,8 +46,16 @@ jobs: libasound2-dev libssl-dev - name: Build run: | + # ⚠️ THE VERSION COMES FROM THE TAG. Without this the client falls back + # to 0.0.0 and the .deb / Mac bundle carry a version that has nothing to + # do with the release - which is exactly how v0.1.29 shipped a package + # called hamdeck-client_0.1.15. + VER="${GITHUB_REF_NAME#v}" + case "$VER" in [0-9]*.[0-9]*.[0-9]*) ;; *) VER="0.0.0" ;; esac + echo "building client version $VER" cmake -S client -B client/build -G Ninja \ - -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_INSTALL_PREFIX=/usr + -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_INSTALL_PREFIX=/usr \ + -DHAMDECK_VERSION="$VER" cmake --build client/build - name: Test run: ctest --test-dir client/build --output-on-failure @@ -129,8 +137,14 @@ jobs: # which kills the whole step before a single line is compiled. modules: 'qtwebsockets qtmultimedia' - name: Build + shell: pwsh run: | - cmake -S client -B client/build -DCMAKE_BUILD_TYPE=Release + # ⚠️ THE VERSION COMES FROM THE TAG - see the Linux leg for what the + # hardcoded fallback cost. The tag carries a leading v; CMake must not. + $ver = "${{ github.ref_name }}" -replace '^v','' + if ($ver -notmatch '^\d+\.\d+\.\d+') { $ver = "0.0.0" } + "building client version $ver" + cmake -S client -B client/build -DCMAKE_BUILD_TYPE=Release "-DHAMDECK_VERSION=$ver" cmake --build client/build --config Release - name: Run the binary shell: pwsh @@ -435,13 +449,30 @@ jobs: vpk pack --packId HamDeckPusher --packVersion $ver ` --packDir packaging\dist\hamdeck-pusher ` --mainExe hamdeck-pusher.exe ` - --packTitle "HamDeck Wavelog Pusher" ` + --packTitle "HamDeck" ` --packAuthors "WA0O" ` --icon ..\packaging\icons\hamdeck.ico ` --outputDir packaging\Releases ` --azureTrustedSignFile packaging\azuresign.json Remove-Item packaging\azuresign.json -Force + # ⚠️ THE packId STAYS HamDeckPusher. It is Velopack's UPDATE IDENTITY - + # every installed copy looks for that id, so changing it orphans every + # existing install silently: they keep running and simply never see + # another update. Only the FILE NAME and the display title change here. + # + # ⚠️ And the file name is safe to change precisely because the updater + # never fetches it: RELEASES points at the .nupkg. Setup.exe is only + # what a human downloads once - which is the whole problem being fixed. + # This installer carries the Pusher AND the HamDeck Remote client, and + # calling it HamDeckPusher-win-Setup.exe meant the release page never + # said the client was in there at all. + $packed = Get-ChildItem packaging\Releases\*Setup.exe | Select-Object -First 1 + if (-not $packed) { throw "vpk produced no Setup.exe to rename" } + $renamed = Join-Path $packed.Directory "HamDeck-win-Setup.exe" + Move-Item $packed.FullName $renamed -Force + "renamed $($packed.Name) -> HamDeck-win-Setup.exe" + - name: The installer is actually signed if: ${{ env.AZURE_CLIENT_ID != '' }} working-directory: pusher @@ -547,8 +578,16 @@ jobs: - name: Build run: | + # ⚠️ THE VERSION COMES FROM THE TAG. Without this the client falls back + # to 0.0.0 and the .deb / Mac bundle carry a version that has nothing to + # do with the release - which is exactly how v0.1.29 shipped a package + # called hamdeck-client_0.1.15. + VER="${GITHUB_REF_NAME#v}" + case "$VER" in [0-9]*.[0-9]*.[0-9]*) ;; *) VER="0.0.0" ;; esac + echo "building client version $VER" cmake -S client -B client/build -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" + -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \ + -DHAMDECK_VERSION="$VER" cmake --build client/build - name: Run the binary @@ -793,10 +832,13 @@ jobs: test -n "$(find dist -name '*Setup.exe' -print -quit)" || { echo "no installer among the artifacts - refusing to publish"; exit 1; } cat > notes.md <<'NOTES' - ## HamDeck — download `HamDeckPusher-win-Setup.exe` + ## Windows — download `HamDeck-win-Setup.exe` - **One installer, both applications.** It installs the **Wavelog Pusher** and the - **HamDeck Remote** client together, and updates them together. The `.nupkg`, + **One installer, both applications.** It installs the **HamDeck Remote** client and + the **Wavelog Pusher** together, and updates them together. There is no separate + client download on Windows and none is needed - this is it. + + Mac: `HamDeckRemote-macOS.dmg`. Linux: the `.deb` for your architecture. The `.nupkg`, `RELEASES` and `releases.win.json` beside it are the update manifest — leave them there, they are what future versions install themselves from. @@ -866,6 +908,23 @@ jobs: fi echo "publishing $EXPECTED files" + # ⚠️ EVERY ARTIFACT MUST CARRY THE RELEASE'S VERSION. v0.1.29 shipped + # hamdeck-client_0.1.15_amd64.deb - built from the right commit, named + # from a literal in CMakeLists that nobody had touched in fourteen + # releases. It looked stale and nothing checked. Filenames are the + # cheapest possible place to catch it. + VER="${GITHUB_REF_NAME#v}" + for f in $FILES; do + case "$(basename "$f")" in + *.deb|*.nupkg) + case "$(basename "$f")" in + *"$VER"*) ;; + *) echo "$(basename "$f") does not carry the release version $VER"; exit 1 ;; + esac ;; + esac + done + echo " every versioned artifact carries $VER" + # create-or-upload: a re-run of the same tag must top up the existing # release rather than fail on "already exists" and leave it half filled. publish() { # repo, token diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 72b7c12..866497b 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -1,5 +1,20 @@ cmake_minimum_required(VERSION 3.22) -project(hamdeck-client VERSION 0.1.15 LANGUAGES CXX) +# ⚠️ THE VERSION COMES FROM THE TAG, NOT FROM THIS FILE. +# +# It used to be hardcoded here, and it drifted: v0.1.29 shipped +# hamdeck-client_0.1.15_amd64.deb, fourteen versions behind the release it was +# built from, because the pusher took its version from the tag (vpk +# --packVersion) and this did not. The download looked stale while being current +# - and the same number goes into CFBundleShortVersionString, so the Mac app +# would have told Get Info it was 0.1.15 as well. +# +# The literal below is the fallback for a local build with no tag, and it is the +# only place it should ever appear. CI passes -DHAMDECK_VERSION=. +if(NOT DEFINED HAMDECK_VERSION OR HAMDECK_VERSION STREQUAL "") + set(HAMDECK_VERSION "0.0.0") +endif() +project(hamdeck-client VERSION ${HAMDECK_VERSION} LANGUAGES CXX) +message(STATUS "hamdeck-client version: ${PROJECT_VERSION}") set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) From 00f7ccd8311b09cb40a1253a7ac8b16eed5ba63e Mon Sep 17 00:00:00 2001 From: wa0o Date: Wed, 2 Sep 2026 14:45:07 +0000 Subject: [PATCH 3/4] Check the version inside each artifact, not just on its name Releasing as a group of apps only means something if they can be shown to carry the same number. Filenames cannot show it: the DMG and HamDeck-win-Setup.exe have no version in their names at all, so the publish job's filename check is blind to exactly the two artifacts a person downloads. - The .deb's control Version is read back with dpkg-deb after packaging. The filename is what CPack chose to call it; the control field is what dpkg and apt compare when deciding whether an upgrade is an upgrade. - The Mac bundle's CFBundleShortVersionString is read out of the built Info.plist before it is signed. That is the only place the Mac build states its version, and it is what Get Info shows the operator. Proven against real packages: one built at 0.1.15 and released as 0.1.30 is refused with both numbers named; one built from the tag passes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqubrbjLXwz1GrVgmTcrf9 --- .github/workflows/release.yml | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 78bb597..14b2fa5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,21 @@ jobs: - name: Run the binary run: QT_QPA_PLATFORM=offscreen timeout 60 ./client/build/hamdeck-qml --selftest - name: Package - run: cd client/build && cpack -G DEB + run: | + cd client/build && cpack -G DEB + # ⚠️ READ THE VERSION BACK OUT OF THE PACKAGE, not off the filename. + # The filename is what CPack chose to call it; the control field is + # what dpkg and apt actually compare when deciding whether an upgrade + # is an upgrade. They come from the same variable today - checking the + # one that MATTERS costs nothing and is the one that could silently + # diverge. + VER="${GITHUB_REF_NAME#v}" + case "$VER" in [0-9]*.[0-9]*.[0-9]*) ;; *) VER="0.0.0" ;; esac + for d in *.deb; do + got="$(dpkg-deb -f "$d" Version)" + echo " $d: control Version=$got" + [ "$got" = "$VER" ] || { echo " expected $VER - the release would ship a package that says it is something else"; exit 1; } + done # ⚠️ Verify the ARTIFACT, not the build: install the package and run what # it installed, from the path it installed to. - name: Install the package and run what it installed @@ -720,6 +734,21 @@ jobs: # Velopack does not build DMGs - it produces the .app and a .pkg, and a signed .pkg # would need a second Developer ID INSTALLER certificate against Apple's cap of 5. # So this wraps vpk's already-signed .app rather than replacing it. + # ⚠️ THE DMG AND THE WINDOWS INSTALLER CARRY NO VERSION IN THEIR NAMES, so + # the publish job's filename check is blind to both. The bundle's own + # Info.plist is the only place the Mac build states what version it is - + # and it is what Finder's Get Info shows the operator. + - name: The bundle says it is the version being released + if: ${{ env.APPLE_CERT_P12 != '' }} + run: | + set -euo pipefail + VER="${GITHUB_REF_NAME#v}" + case "$VER" in [0-9]*.[0-9]*.[0-9]*) ;; *) VER="0.0.0" ;; esac + PLIST="$APP/Contents/Info.plist" + got="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$PLIST")" + echo " CFBundleShortVersionString=$got (release $VER)" + [ "$got" = "$VER" ] || { echo " the app would tell Get Info it is $got"; exit 1; } + - name: Build a DMG if: ${{ env.APPLE_CERT_P12 != '' }} run: | From 38d92e39bc69510c168e3bfb510ed3691f5e02df Mon Sep 17 00:00:00 2001 From: wa0o Date: Wed, 2 Sep 2026 15:34:11 +0000 Subject: [PATCH 4/4] Ship an installer for every platform, and refuse to publish without one A zip is not a deliverable. It asks the person downloading it to pick an exe out of 1,369 files and make their own shortcut, and nobody using this project is going to do that. Mac has a .dmg and Linux has .debs; Windows had the client only as a folder inside somebody else's package. - client/packaging/hamdeck-remote.iss builds HamDeckRemote-win-Setup.exe with Inno Setup: per-user, no admin prompt, Start Menu and optional desktop shortcut, uninstall entry. It packages the staged tree AFTER the signing step has run over it, so the files inside are signed, and the wrapper Inno produces is then signed itself - SmartScreen judges the wrapper, not its contents. It refuses to build if platforms\qwindows.dll is absent, because that installs cleanly and then fails to start with no clue which file is missing. - Its own artifact name. Two uploads sharing one name is a race with an arbitrary winner, which would silently drop one of the two Windows installers. - The publish job now refuses to release unless every platform has something to double-click, and rejects any artifact that is neither an installer nor one of the three named Velopack update-feed files. A new stray file is refused rather than quietly joining them. Gate proven against four file lists: today's page (zip, no client installer) fails, a missing client installer fails, a stray zip fails, and what 0.1.30 will publish passes. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqubrbjLXwz1GrVgmTcrf9 --- .github/workflows/release.yml | 96 ++++++++++++++++++++++++++++- client/packaging/hamdeck-remote.iss | 61 ++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 client/packaging/hamdeck-remote.iss diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14b2fa5..2799788 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -487,6 +487,62 @@ jobs: Move-Item $packed.FullName $renamed -Force "renamed $($packed.Name) -> HamDeck-win-Setup.exe" + # ⚠️ EVERY PLATFORM GETS AN INSTALLER. Mac has a .dmg, Linux has .debs, and + # Windows had the client only as a folder inside somebody else's package. + # A zip is not a deliverable: it asks the person downloading it to pick an + # exe out of 1,369 files and make their own shortcut, and nobody does that. + # + # ⚠️ AFTER the Velopack pack, not before. vpk consumes the same staged tree, + # and Inno must package the SIGNED files - which the signing step above has + # already put there, recursively, including client\. + # + # iscc ships on the GitHub Windows runner images; no install step needed. + - name: Build the standalone client installer + working-directory: client/packaging + shell: pwsh + run: | + $ver = "${{ github.ref_name }}" -replace '^v','' + if ($ver -notmatch '^\d+\.\d+\.\d+') { $ver = "0.0.0" } + $src = "${{ github.workspace }}\pusher\packaging\dist\hamdeck-pusher\client" + if (-not (Test-Path "$src\hamdeck-qml.exe")) { throw "no staged client at $src" } + if (-not (Test-Path "$src\platforms\qwindows.dll")) { + throw "the staged client has no platforms\qwindows.dll - it would install and refuse to start" + } + & iscc.exe /Qp "/DAppVersion=$ver" "/DSourceDir=$src" hamdeck-remote.iss + if ($LASTEXITCODE -ne 0) { throw "iscc failed" } + if (-not (Test-Path "HamDeckRemote-win-Setup.exe")) { throw "iscc produced no installer" } + "built HamDeckRemote-win-Setup.exe ($ver)" + + # The Inno output is a NEW binary that nothing has signed yet - the files + # inside it are signed, the wrapper around them is not, and SmartScreen + # judges the wrapper. + - name: Sign the client installer + if: ${{ env.AZURE_CLIENT_ID != '' }} + uses: azure/trusted-signing-action@v0 + with: + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} + endpoint: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ secrets.AZURE_SIGNING_PROFILE }} + files-folder: ${{ github.workspace }}\client\packaging + files-folder-filter: exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: The client installer is actually signed + if: ${{ env.AZURE_CLIENT_ID != '' }} + working-directory: client/packaging + shell: pwsh + run: | + $sig = Get-AuthenticodeSignature "HamDeckRemote-win-Setup.exe" + "Status: $($sig.Status)" + "Signer: $($sig.SignerCertificate.Subject)" + if ($sig.Status -ne 'Valid') { throw "client installer is not validly signed" } + if ($sig.SignerCertificate.Subject -notmatch 'Wussler') { throw "unexpected signer" } + - name: The installer is actually signed if: ${{ env.AZURE_CLIENT_ID != '' }} working-directory: pusher @@ -536,6 +592,12 @@ jobs: - uses: actions/upload-artifact@v4 with: { name: hamdeck-pusher-windows-installer, path: pusher/packaging/Releases/* } + # ⚠️ A SEPARATE ARTIFACT NAME. Two uploads sharing one name is a race with an + # arbitrary winner - the same trap the arch matrix already carries a warning + # about, and it would silently drop one of the two Windows installers. + - uses: actions/upload-artifact@v4 + with: { name: hamdeck-client-windows-installer, path: client/packaging/HamDeckRemote-win-Setup.exe } + macos: # ⚠️ RUNS AFTER publish, NOT BEFORE IT, AND THAT IS DELIBERATE. # @@ -867,7 +929,10 @@ jobs: the **Wavelog Pusher** together, and updates them together. There is no separate client download on Windows and none is needed - this is it. - Mac: `HamDeckRemote-macOS.dmg`. Linux: the `.deb` for your architecture. The `.nupkg`, + Only want the radio panel? **`HamDeckRemote-win-Setup.exe`** installs the client + on its own - no Wavelog pusher, and no auto-update. + + Mac: **`HamDeckRemote-macOS.dmg`**. Linux: the **`.deb`** for your architecture. The `.nupkg`, `RELEASES` and `releases.win.json` beside it are the update manifest — leave them there, they are what future versions install themselves from. @@ -954,6 +1019,35 @@ jobs: done echo " every versioned artifact carries $VER" + # ⚠️ EVERY PLATFORM SHIPS AN INSTALLER. Nobody using this project is going + # to unzip a folder, find an exe among 1,369 files and make their own + # shortcut - so a release that offers one is a release that offers nothing. + # This refuses to publish unless each platform has something to double-click. + need() { # description, glob + for f in $FILES; do + case "$(basename "$f")" in $2) echo " ✓ $1: $(basename "$f")"; return 0;; esac + done + echo " ✗ MISSING $1 - the release page would offer no installer for it" + return 1 + } + need "Windows (client + pusher)" 'HamDeck-win-Setup.exe' + need "Windows (client alone)" 'HamDeckRemote-win-Setup.exe' + need "macOS" '*.dmg' + need "Linux" '*.deb' + + # ⚠️ And nothing ships as a bare archive. The only non-installers allowed + # are the three Velopack update-feed files, which the updater fetches from + # this release - they are named explicitly so a new stray file is refused + # rather than quietly joining them. + for f in $FILES; do + case "$(basename "$f")" in + *.zip|*.tar.gz|*.7z) + echo " ✗ $(basename "$f") is an archive, not an installer"; exit 1 ;; + *Setup.exe|*.dmg|*.deb|*.nupkg|RELEASES|releases.*.json) ;; + *) echo " ✗ $(basename "$f") is neither an installer nor a known update-feed file"; exit 1 ;; + esac + done + # create-or-upload: a re-run of the same tag must top up the existing # release rather than fail on "already exists" and leave it half filled. publish() { # repo, token diff --git a/client/packaging/hamdeck-remote.iss b/client/packaging/hamdeck-remote.iss new file mode 100644 index 0000000..3edec33 --- /dev/null +++ b/client/packaging/hamdeck-remote.iss @@ -0,0 +1,61 @@ +; Standalone installer for the HamDeck Remote client. +; +; ⚠️ THIS EXISTS BECAUSE A ZIP IS NOT A DELIVERABLE. The client had been shipping +; only inside the pusher's package, so the release page never offered a Windows +; client at all - and the stopgap was a zip, which asks the person downloading it +; to find an exe among 1,369 files and make their own shortcut. Nobody does that. +; Every platform gets an installer: .dmg on Mac, .deb on Linux, this on Windows. +; +; ⚠️ IT INSTALLS PER-USER (no admin prompt) into LocalAppData, deliberately. The +; combined HamDeck-win-Setup.exe is a Velopack package that installs to the same +; kind of location; asking for elevation to run a radio panel is friction with +; nothing behind it. +; +; ⚠️ NO AUTO-UPDATE HERE, and that is the honest tradeoff. Velopack's updater +; belongs to the combined package. This installs a fixed version; the notes say so. + +#ifndef AppVersion + #define AppVersion "0.0.0" +#endif +#ifndef SourceDir + #define SourceDir "..\..\pusher\packaging\dist\hamdeck-pusher\client" +#endif + +[Setup] +AppId={{7C4E2E31-9E0E-4E4B-9A8B-2F6C9F5D3A11} +AppName=HamDeck Remote +AppVersion={#AppVersion} +AppVerName=HamDeck Remote {#AppVersion} +AppPublisher=WA0O +AppPublisherURL=https://hamdeck.io +DefaultDirName={localappdata}\HamDeck Remote +DefaultGroupName=HamDeck +DisableProgramGroupPage=yes +DisableDirPage=auto +PrivilegesRequired=lowest +OutputDir=. +OutputBaseFilename=HamDeckRemote-win-Setup +SetupIconFile=..\..\packaging\icons\hamdeck.ico +UninstallDisplayIcon={app}\hamdeck-qml.exe +UninstallDisplayName=HamDeck Remote +Compression=lzma2/max +SolidCompression=yes +WizardStyle=modern +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +[Files] +; ⚠️ The whole staged tree, recursively. The client is useless without its Qt +; runtime - platforms\qwindows.dll in particular, whose absence produces "This +; application failed to start" with no clue which file is missing. +Source: "{#SourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs + +[Icons] +Name: "{group}\HamDeck Remote"; Filename: "{app}\hamdeck-qml.exe" +Name: "{userdesktop}\HamDeck Remote"; Filename: "{app}\hamdeck-qml.exe"; Tasks: desktopicon + +[Tasks] +Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:" + +[Run] +Filename: "{app}\hamdeck-qml.exe"; Description: "Start HamDeck Remote"; Flags: nowait postinstall skipifsilent