From c907fad6b22623bebe87102c3a71a0145a60ab30 Mon Sep 17 00:00:00 2001 From: thorsten Date: Mon, 31 Aug 2026 16:54:51 +0200 Subject: [PATCH 1/3] Find out whether the key can sign before building anything with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier check only proves a key is present, which is why the run got all the way to the bundler again — this time to be told the password was wrong. Signing a throwaway file settles both halves in a second. The signature it produces is public and the key stays in the environment, so nothing here can leak into a log. --- .github/workflows/release.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6920ad..9ff99bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,6 +65,23 @@ jobs: # naming a dependency that was added after it was last written. # `npm run licenses` shells out to `cargo license`, which no runner image carries. Without it # the notices cannot be regenerated, and they are what the licences require to ship. + # The check above cannot tell a wrong password from a right one -- only that a key is there. + # Signing a throwaway file can, and it costs a second against six minutes of build before the + # bundler reaches the same conclusion. The signature is public; the key never leaves the env. + - name: Check the key and its password sign something + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + Set-Content -Path signing-probe.txt -Value 'probe' + npx tauri signer sign signing-probe.txt + if ($LASTEXITCODE -ne 0) { + Write-Host '::error::The key and TAURI_SIGNING_PRIVATE_KEY_PASSWORD do not match. Set the password the key was generated with, or generate a new pair and update plugins.updater.pubkey with its public half.' + exit 1 + } + Remove-Item signing-probe.txt, signing-probe.txt.sig -ErrorAction SilentlyContinue + Write-Host 'Key and password agree.' + - name: Install cargo-license run: cargo install cargo-license --locked From 22d9831fe8926a06e4fea7c377c57df69ad9de33 Mon Sep 17 00:00:00 2001 From: thorsten Date: Mon, 31 Aug 2026 17:08:00 +0200 Subject: [PATCH 2/3] Let a manual run be a rehearsal instead of a failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release belongs to a tag. Started by hand there is none, so the last step refused — after the build, the signing and everything else had already succeeded, which is the most expensive place to learn it. A run without a tag now attaches the installer, its signature and latest.json to itself and says plainly that it published nothing. The version in latest.json comes from the tag when there is one and from tauri.conf.json when there is not, rather than from a branch name that would have written "main" into it. --- .github/workflows/release.yml | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ff99bc..c5c2147 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -102,8 +102,13 @@ jobs: run: | $setup = Get-ChildItem target/release/bundle/nsis/*-setup.exe | Select-Object -First 1 $signature = Get-Content "$($setup.FullName).sig" -Raw - $version = "${{ github.ref_name }}".TrimStart('v') - $url = "https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/$($setup.Name)" + # A tag names the version of a real release. A manual run has no tag, so the version the + # app was actually built with is the only honest answer. + $tag = if ('${{ github.ref_type }}' -eq 'tag') { '${{ github.ref_name }}' } else { + 'v' + (Get-Content src-tauri/tauri.conf.json -Raw | ConvertFrom-Json).version + } + $version = $tag.TrimStart('v') + $url = "https://github.com/${{ github.repository }}/releases/download/$tag/$($setup.Name)" $manifest = [ordered]@{ version = $version notes = "See the release notes for $version." @@ -114,10 +119,31 @@ jobs: } $manifest | ConvertTo-Json -Depth 5 | Out-File latest.json -Encoding utf8 - - uses: softprops/action-gh-release@v2 + - name: Publish the release + if: github.ref_type == 'tag' + uses: softprops/action-gh-release@v2 with: files: | target/release/bundle/nsis/*-setup.exe target/release/bundle/nsis/*-setup.exe.sig latest.json fail_on_unmatched_files: true + + # A manual run is a rehearsal: it builds, signs and checks everything and publishes nothing, + # because a release belongs to a tag and a manual run has none. Failing at the last step to + # say so wasted the whole build. + - name: Keep what the rehearsal produced + if: github.ref_type != 'tag' + uses: actions/upload-artifact@v4 + with: + name: release-rehearsal + path: | + target/release/bundle/nsis/*-setup.exe + target/release/bundle/nsis/*-setup.exe.sig + latest.json + if-no-files-found: error + + - name: Say that nothing was published + if: github.ref_type != 'tag' + run: | + Write-Host '::notice::Rehearsal only. The installer, its signature and latest.json are attached to this run as an artifact. To publish them, push a tag: git tag v0.1.0 && git push origin v0.1.0' From a019b116741a0a80331f482a6df2ed74db642a9f Mon Sep 17 00:00:00 2001 From: thorsten Date: Mon, 31 Aug 2026 17:14:58 +0200 Subject: [PATCH 3/3] Release when the version changes, not when someone remembers to tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumping the number is the whole ceremony now. A push to main whose version has no tag yet builds, signs, tags at that commit and publishes; a push whose version is already out stops in under a minute on a Linux runner, before anything is compiled. Running it by hand stays a rehearsal. The version had been written in four places and read from a fifth that was a string literal in two views — which an automatic release would have turned into a lie on the first bump. package.json holds it, tauri.conf.json points at it, and the interface reads it through __APP_VERSION__. --- .github/workflows/release.yml | 62 +++++++++++++++++++++++------- AGENTS.md | 14 ++++++- src-tauri/tauri.conf.json | 2 +- src/lib/views/info-view.svelte | 2 +- src/lib/views/settings-view.svelte | 2 +- src/vite-env.d.ts | 3 ++ vite.config.ts | 6 +++ 7 files changed, 73 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c5c2147..39d8abe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,14 +2,54 @@ name: Release on: push: - tags: ['v*'] + branches: [main] workflow_dispatch: permissions: contents: write jobs: + # A minute of Linux in front of six minutes of Windows. Most pushes to main are not releases, and + # this is what tells them apart before anything is compiled. + decide: + runs-on: ubuntu-latest + outputs: + publish: ${{ steps.check.outputs.publish }} + tag: ${{ steps.check.outputs.tag }} + steps: + - uses: actions/checkout@v4 + + - name: Is this version already released? + id: check + env: + GH_TOKEN: ${{ github.token }} + run: | + version=$(jq -r .version package.json) + tag="v$version" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + # A run started by hand is a rehearsal whatever the version says: it builds and signs and + # publishes nothing, which is what makes it safe to press. + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "::notice::Rehearsal of $tag. Nothing will be published." + exit 0 + fi + + if gh api "repos/${{ github.repository }}/git/ref/tags/$tag" >/dev/null 2>&1; then + echo "publish=false" >> "$GITHUB_OUTPUT" + echo "::notice::$tag is already released. Bump version in src-tauri/tauri.conf.json to cut a new one." + exit 0 + fi + + echo "publish=true" >> "$GITHUB_OUTPUT" + echo "::notice::$tag has no release yet. Building one." + release: + needs: decide + # A push whose version is already out has nothing to do; a rehearsal still builds, because + # finding out that a release *would* work is the whole point of pressing the button. + if: needs.decide.outputs.publish == 'true' || github.event_name == 'workflow_dispatch' runs-on: windows-latest defaults: run: @@ -102,11 +142,7 @@ jobs: run: | $setup = Get-ChildItem target/release/bundle/nsis/*-setup.exe | Select-Object -First 1 $signature = Get-Content "$($setup.FullName).sig" -Raw - # A tag names the version of a real release. A manual run has no tag, so the version the - # app was actually built with is the only honest answer. - $tag = if ('${{ github.ref_type }}' -eq 'tag') { '${{ github.ref_name }}' } else { - 'v' + (Get-Content src-tauri/tauri.conf.json -Raw | ConvertFrom-Json).version - } + $tag = '${{ needs.decide.outputs.tag }}' $version = $tag.TrimStart('v') $url = "https://github.com/${{ github.repository }}/releases/download/$tag/$($setup.Name)" $manifest = [ordered]@{ @@ -119,21 +155,21 @@ jobs: } $manifest | ConvertTo-Json -Depth 5 | Out-File latest.json -Encoding utf8 + # `tag_name` on a tag that does not exist yet creates it at this commit, so nobody has to + # remember to tag by hand — bumping the version in tauri.conf.json is the whole ceremony. - name: Publish the release - if: github.ref_type == 'tag' + if: needs.decide.outputs.publish == 'true' uses: softprops/action-gh-release@v2 with: + tag_name: ${{ needs.decide.outputs.tag }} files: | target/release/bundle/nsis/*-setup.exe target/release/bundle/nsis/*-setup.exe.sig latest.json fail_on_unmatched_files: true - # A manual run is a rehearsal: it builds, signs and checks everything and publishes nothing, - # because a release belongs to a tag and a manual run has none. Failing at the last step to - # say so wasted the whole build. - name: Keep what the rehearsal produced - if: github.ref_type != 'tag' + if: needs.decide.outputs.publish != 'true' uses: actions/upload-artifact@v4 with: name: release-rehearsal @@ -144,6 +180,6 @@ jobs: if-no-files-found: error - name: Say that nothing was published - if: github.ref_type != 'tag' + if: needs.decide.outputs.publish != 'true' run: | - Write-Host '::notice::Rehearsal only. The installer, its signature and latest.json are attached to this run as an artifact. To publish them, push a tag: git tag v0.1.0 && git push origin v0.1.0' + Write-Host '::notice::Rehearsal only. The installer, its signature and latest.json are attached to this run as an artifact. A release happens when a push to main carries a version that has no tag yet.' diff --git a/AGENTS.md b/AGENTS.md index 1c88da0..731e855 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -74,8 +74,18 @@ npx tauri signer generate -w ~/.tauri/openeventviewer.key The public half goes into `plugins.updater.pubkey` in `src-tauri/tauri.conf.json`; the private half and its password are the GitHub secrets `TAURI_SIGNING_PRIVATE_KEY` and -`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` and never enter the repository. `release.yml` builds on a `v*` -tag and uploads the installer, its `.sig` and a `latest.json` written from that signature. +`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` and never enter the repository. + +**Cutting a release is bumping a number.** `package.json` holds the version; `tauri.conf.json` +points at it and the interface reads it through `__APP_VERSION__`, so it is written once. A push to +`main` whose version has no tag yet builds, signs, tags at that commit and publishes the installer, +its `.sig` and a `latest.json` written from that signature. A push whose version is already out +stops in under a minute. Running the workflow by hand is a rehearsal: it builds and signs and +attaches the result to the run, and publishes nothing. + +```bash +npm version patch # or minor, or major +``` ## Commands diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 134e771..75302f0 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenEventViewer", - "version": "0.1.0", + "version": "../package.json", "identifier": "com.thorstenalpers.openeventviewer", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/lib/views/info-view.svelte b/src/lib/views/info-view.svelte index 53872ef..90ab4d7 100644 --- a/src/lib/views/info-view.svelte +++ b/src/lib/views/info-view.svelte @@ -16,7 +16,7 @@ const t = $derived(i18n.t); let filter = $state(''); - const APP_VERSION = '0.1.0'; + const APP_VERSION = __APP_VERSION__; let notices = $state(null); let noticesError = $state(null); diff --git a/src/lib/views/settings-view.svelte b/src/lib/views/settings-view.svelte index f350b26..81121fe 100644 --- a/src/lib/views/settings-view.svelte +++ b/src/lib/views/settings-view.svelte @@ -8,7 +8,7 @@ import { settings, MAX_ROW_CHOICES } from '$lib/stores/settings.svelte'; import { updater } from '$lib/stores/updater.svelte'; - const APP_VERSION = '0.1.0'; + const APP_VERSION = __APP_VERSION__; const t = $derived(i18n.t); diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..9f7772a 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,4 @@ /// + +/** The version in package.json, substituted at build time — see `define` in vite.config.ts. */ +declare const __APP_VERSION__: string; diff --git a/vite.config.ts b/vite.config.ts index 1436d4e..5089924 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,8 +1,14 @@ +import { readFileSync } from 'node:fs'; import { defineConfig } from 'vite'; import { sveltekit } from '@sveltejs/kit/vite'; import tailwindcss from '@tailwindcss/vite'; +// One version, in package.json, which tauri.conf.json also points at. A number typed a second time +// into a view is a number that goes stale the first time anyone bumps the first one. +const { version } = JSON.parse(readFileSync('package.json', 'utf8')) as { version: string }; + export default defineConfig({ + define: { __APP_VERSION__: JSON.stringify(version) }, plugins: [tailwindcss(), sveltekit()], server: { // Pinned because tauri.conf.json waits for exactly this URL: on a taken port Vite would