diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d28ffb..2799788 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,15 +46,37 @@ 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 - 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 @@ -129,8 +151,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 +463,86 @@ 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" + + # ⚠️ 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 @@ -491,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. # @@ -547,8 +654,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 @@ -681,6 +796,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: | @@ -719,13 +849,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 @@ -769,10 +923,16 @@ 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. + + 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. @@ -824,15 +984,102 @@ 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" + + # ⚠️ 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" + + # ⚠️ 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 + 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 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) 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