diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6920ad..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: @@ -65,6 +105,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 @@ -85,8 +142,9 @@ 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)" + $tag = '${{ needs.decide.outputs.tag }}' + $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." @@ -97,10 +155,31 @@ jobs: } $manifest | ConvertTo-Json -Depth 5 | Out-File latest.json -Encoding utf8 - - uses: softprops/action-gh-release@v2 + # `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: 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 + + - name: Keep what the rehearsal produced + if: needs.decide.outputs.publish != 'true' + 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: 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. 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