Skip to content

Add an installer and a tag-driven release pipeline - #4

Closed
sethdtwigg wants to merge 4 commits into
feat/installer-prerequisitesfrom
feat/installer-and-release
Closed

Add an installer and a tag-driven release pipeline#4
sethdtwigg wants to merge 4 commits into
feat/installer-prerequisitesfrom
feat/installer-and-release

Conversation

@sethdtwigg

@sethdtwigg sethdtwigg commented Sep 6, 2026

Copy link
Copy Markdown
Owner

Stacked on #3 — base is feat/installer-prerequisites. GitHub will retarget this to master once #3 merges.

Inno Setup installer plus a Release workflow that builds and publishes it.

Update behaviour

The installer updates in place and keeps the old version. It does not uninstall or delete anything.

Where it goes You choose. The directory page is always shown, and elevation can be picked from the first dialog, so C:\VerseLink or Program Files work as well as the per-user default. The app doesn't need a writable install dir — settings live in %APPDATA%.
Old version Moved to {app}\previous-versions\VerseLinkWindows-<version>.exe before the new exe is copied in. Rolling back is copying that file back.
Settings Never touched. They live in %APPDATA%\VerseLink, which the installer neither writes nor removes, and no config.json is shipped.
Running app Closed before the update, restarted after — so an update doesn't leave you without your hotkey.

Two fixes this required

AppMutex had to go. It only blocks: it tells the user to close the app and aborts if they don't. Under /SILENT that fails outright — Defaulting to Cancel for suppressed message box... Got EAbort exception — and it blocked any install while VerseLink was running anywhere, not just an update of the copy being replaced. PrepareToInstall now closes the app itself and waits for its mutex to clear, with a clear message if it can't within 15s.

WM_CLOSE was broken in the app. It fell through to DefWindowProc, which destroyed the hidden window but left the message loop and worker thread running — the process stayed alive with no window and no tray icon, and Restart Manager had nothing to work with. It now runs the same shutdown path as the tray's Quit, which also fixes sign-out behaviour.

Release pipeline

  • A v* tag builds, self-tests, packages and publishes. workflow_dispatch runs the same pipeline without publishing.
  • The tag must match Version.h, checked before anything is built, so a release can never advertise a version its binary doesn't report.
  • Packaging is gated on --selftest passing.
  • A scoped pull_request trigger builds packaging on PRs that touch it — that's what caught the AppMutex failure here.

Verified end to end

Against the installer this PR's own CI produced, on a real machine:

1. install to a chosen (non-default) directory        exit 0
2. start it                                           pid 16832
3. install again over the running copy                exit 0

Asking the running VerseLink to close
Running VerseLink closed after 250 ms
Kept the previous executable as ...\previous-versions\VerseLinkWindows-1.1.0.0.exe
  • old version kept, byte-identical to the exe it replaced
  • new exe in place
  • restarted automatically (pid 16832 → 22560)
  • config untouched
  • uninstall afterwards left no install dir, no shortcuts, no Add/Remove entry, and kept settings

Honest limitation

A copy that was never installed (an unzipped folder) is invisible to this installer — nothing recorded it, so it's neither updated nor removed. Settings still migrate on first run, but the old folder and any Startup shortcut have to go manually. Called out in the release notes and DISTRIBUTION-NOTES.txt.

To cut a release once merged

git tag v1.1.0 && git push origin v1.1.0

Inno Setup installer, built and published by a Release workflow.

Upgrades and removing the old version: the .iss carries a fixed AppId, which is
the identity Windows and Inno use to recognise an existing installation. A newer
build therefore upgrades the existing one in place - old files are replaced, the
Add/Remove Programs entry is updated rather than duplicated, and the previous
uninstaller is superseded. AppMutex names the single-instance mutex the app now
holds, so Setup detects a running VerseLink and asks the user to close it rather
than failing part way through on a locked exe.

Per-user install to %LOCALAPPDATA%\Programs\VerseLink: no UAC prompt, and the
install directory stays writable, which matters because the app runs AsInvoker.

Deliberately not shipped: config.json. Settings live in %APPDATA%\VerseLink and
are created with defaults on first run. A config.json next to the exe would be
picked up by the legacy-config migration and could overwrite what an upgrading
user actually had. The same reasoning applies to the portable zip.

Uninstall keeps settings unless the user says otherwise, and removes the log.

Release pipeline:
- A v* tag builds, self-tests, packages and publishes. workflow_dispatch runs
  the same pipeline without publishing, so packaging can be exercised without
  creating a release that then has to be deleted.
- The tag must match Version.h, checked before anything is built, so a release
  cannot advertise a version its binary does not report. build-installer.ps1
  enforces the same thing for local runs, and additionally checks the built
  exe's own VERSIONINFO.
- Packaging is gated on --selftest passing; a build that cannot pass its own
  checks is never packaged.
- Publishing uses gh rather than a third-party action, consistent with the
  Node 20 cleanup.

Also: the zip is named by version rather than by date so it pairs with the
installer, and both now share packaging/DISTRIBUTION-NOTES.txt instead of the
script generating its own copy.
workflow_dispatch only works from the default branch, so a packaging change
could not be exercised before merging. A scoped pull_request trigger builds the
installer and zip whenever packaging, the release workflow or Version.h change.
Publishing stays gated on a tag, so these runs only produce artifacts.
Reworked to match how this should actually behave:

- The install location is chosen by the user. The directory page is always
  shown, and elevation can be picked from the first dialog, so C:\VerseLink or
  Program Files work as well as the per-user default. The app does not need a
  writable install directory - its settings live in %APPDATA%.

- An existing version is no longer removed. Before the new executable is
  copied in, the old one is moved to
  {app}\previous-versions\VerseLinkWindows-<version>.exe, named by the version
  it reports so successive updates do not collide. Rolling back is copying that
  file back over VerseLinkWindows.exe.

- Settings are untouched, as before: they live in %APPDATA%\VerseLink, which
  the installer neither writes nor removes, and no config.json is shipped.

- A running VerseLink is closed before the update and relaunched afterwards, so
  an update does not leave the user without their hotkey. Whether it was running
  is recorded before Setup prompts, and the post-install "Launch VerseLink"
  checkbox is suppressed in that case so it cannot start a second copy.

For the close to be clean, the app now handles WM_CLOSE, WM_QUERYENDSESSION and
WM_ENDSESSION. WM_CLOSE previously fell through to DefWindowProc, which
destroyed the hidden window but left the message loop and worker thread running
- the process stayed alive with no window and no tray icon, and Restart Manager
had nothing to work with. Shutdown now runs the same path as the tray's Quit,
which also makes sign-out and any external close request behave.

Verified: taskkill without /F (a plain WM_CLOSE) now produces
"Shutdown requested by the system or an installer" followed by a full clean
shutdown, where it previously left the process alive.
AppMutex only blocks. It tells the user to close the app and aborts if they do
not, which under /SILENT fails outright:

    Defaulting to Cancel for suppressed message box (OK/Cancel):
    Please close all instances of it now, then click OK to continue...
    Got EAbort exception.

That is the opposite of the intended behaviour, and it blocked any install
while VerseLink was running anywhere - not just an update of the copy being
replaced.

PrepareToInstall now closes it: WM_CLOSE is posted to the app's hidden window,
which the app handles as a clean shutdown, and Setup waits for the
single-instance mutex to clear so the exe is genuinely unlocked before being
replaced. If it cannot be closed within 15 seconds the user gets a clear
message naming the tray icon, rather than a mid-install failure.

CloseApplications stays on as a Restart Manager backstop for any other locked
file. The window class name is now noted in both the .iss and the source, since
the two have to agree.
@sethdtwigg
sethdtwigg deleted the branch feat/installer-prerequisites September 6, 2026 17:33
@sethdtwigg sethdtwigg closed this Sep 6, 2026
@sethdtwigg
sethdtwigg deleted the feat/installer-and-release branch September 6, 2026 17:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant