diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml
index c8089846..e67b9009 100644
--- a/.github/workflows/publish-packages.yml
+++ b/.github/workflows/publish-packages.yml
@@ -49,7 +49,32 @@ jobs:
with:
ref: ${{ github.event.release.tag_name || inputs.tag }}
+ # Chocolatey accepts a version exactly once, so pushing 2.7.2 again is an
+ # error. That is correct on the `release: published` path -- a second
+ # publish of the same version is a real problem -- and wrong on the
+ # `workflow_dispatch` path, whose only possible use is a tag that is
+ # already published: recovering after the snap job failed, or exercising
+ # this workflow without cutting a release. Both would report a Chocolatey
+ # failure that means nothing.
+ #
+ # So it is asked rather than assumed, and asked of the feed rather than
+ # inferred from the text of a push error. A check that cannot answer --
+ # network, rate limit -- deliberately falls through to the push, which is
+ # the real gate; the point is to drop a known non-event, not to make
+ # publishing conditional on a lookup succeeding.
+ - name: Is this version already on Chocolatey?
+ id: already
+ shell: bash
+ run: |
+ VERSION="${TAG#v}"
+ feed="https://community.chocolatey.org/api/v2/Packages()?\$filter=Id%20eq%20'markpad-app'%20and%20Version%20eq%20'${VERSION}'"
+ if curl -fsS --max-time 30 "$feed" | grep -q "${VERSION}"; then
+ echo "::notice::markpad-app ${VERSION} is already on Chocolatey. Nothing to push."
+ echo "published=true" >> "$GITHUB_OUTPUT"
+ fi
+
- name: Take the executable from the release
+ if: steps.already.outputs.published != 'true'
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -69,6 +94,7 @@ jobs:
cp LICENSE packaging/choco/tools/LICENSE.txt
- name: Record what the package contains
+ if: steps.already.outputs.published != 'true'
shell: bash
run: |
set -euo pipefail
@@ -85,6 +111,7 @@ jobs:
cat packaging/choco/tools/VERIFICATION.txt
- name: Pack and publish
+ if: steps.already.outputs.published != 'true'
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/README.md b/README.md
index 4100eb9f..868ef53c 100644
--- a/README.md
+++ b/README.md
@@ -73,7 +73,9 @@ Every file below is on the [latest release](https://github.com/sftwrdotdev/Markp
The two Windows files differ only in that one installs and one does not — `-setup.exe` puts Markpad in Program Files and the Start menu; the plain `.exe` runs from wherever you put it.
-> After a direct `.dmg` (macOS), `*-setup.exe` (Windows NSIS) or `.AppImage` (Linux) install, Markpad self-updates from GitHub releases via the in-app *Check for Updates…* entry (macOS app menu, or Settings elsewhere). Snap, Chocolatey, `.deb` and `.rpm` users continue to update through their distribution channels.
+> After a direct `.dmg` (macOS), `*-setup.exe` (Windows NSIS) or `.AppImage` (Linux) install, Markpad self-updates from GitHub releases via the in-app *Check for Updates…* entry (macOS app menu, or Settings elsewhere). Chocolatey and Snap keep Markpad current through those package managers instead.
+>
+> **`.deb` and `.rpm` are one-time installs.** `tauri-plugin-updater` cannot replace a package-managed install, and there is no apt or dnf repository to update from — upgrading means downloading a newer package from the releases page. *Check for Updates…* will report that a newer version exists but cannot install it ([#570](https://github.com/sftwrdotdev/Markpad/issues/570)).
## What Markpad renders
diff --git a/RELEASING.md b/RELEASING.md
index da9284e5..4fa10893 100644
--- a/RELEASING.md
+++ b/RELEASING.md
@@ -106,7 +106,7 @@ Mention this clearly in the release notes for the first auto-update-capable vers
- **macOS** uses one universal binary (`darwin-aarch64` + `darwin-x86_64` share the same `.app.tar.gz` and signature).
- **Windows** uses NSIS — the auto-updater downloads `*-setup.exe` (verified by `*-setup.exe.sig`) and runs it in `passive` install mode. The existing raw portable `.exe` distribution path is preserved alongside, so users who download the portable `.exe` directly continue to work; only the auto-updater path uses the NSIS installer.
-- **Linux**: only `AppImage` users get auto-updates — `tauri-plugin-updater` doesn't support `.deb` or `.rpm`. `apt`/`rpm` users keep getting updates via their distro package manager (or by downloading a fresh package).
+- **Linux**: only `AppImage` users get auto-updates — `tauri-plugin-updater` doesn't support `.deb` or `.rpm`, and there is no apt or dnf repository either. `.deb` and `.rpm` are therefore one-time installs: those users upgrade by downloading a newer package. *Check for Updates…* still offers them the update and then fails to install it, because `__TAURI_BUNDLE_TYPE` is not patched into the Linux binary and every Linux package therefore takes the AppImage install path — see #570. This is stated in the README as well; if a repository is ever published, both change together.
- **Snap / Chocolatey**: independent distribution channels, published by `publish-packages.yml` after the release is published. Their update mechanisms are unaffected. The Chocolatey package wraps the release's own `Markpad__x64.exe` rather than a second build, which is what `packaging/choco/tools/VERIFICATION.txt` promises.
## Troubleshooting
diff --git a/scripts/releaseWorkflow.test.ts b/scripts/releaseWorkflow.test.ts
index ed3afb83..b746ea13 100644
--- a/scripts/releaseWorkflow.test.ts
+++ b/scripts/releaseWorkflow.test.ts
@@ -10,6 +10,7 @@ const testBuildWorkflow = readSource('.github/workflows/test_build.yml');
const releasing = readSource('RELEASING.md');
const snapcraft = readSource('snapcraft.yaml');
const stripAppImage = readSource('scripts/strip-appimage.sh');
+const readme = readSource('README.md');
const cargoToml = readSource('src-tauri/Cargo.toml');
const packageJson = JSON.parse(readSource('package.json')) as {
scripts: Record;
@@ -210,3 +211,50 @@ test('signing uses the environment variables the pinned CLI reads', () => {
assert.doesNotMatch(workflow, /TAURI_PRIVATE_KEY/);
assert.match(workflow, /TAURI_SIGNING_PRIVATE_KEY_PASSWORD/);
});
+
+test('the package managers we recommend are the ones we publish to', () => {
+ // Three places tell people a package manager can install Markpad: the README,
+ // the download table written into every release body, and the workflow that
+ // actually pushes. They drifted once already and nobody noticed for three
+ // months — the snap build was dead from v2.6.11 while both documents went on
+ // recommending `sudo snap install markpad` and the Snap Store served 2.6.11.
+ //
+ // That drift is about to be possible again in the other direction: #562 asks
+ // whether to retire the snap, and retiring it means deleting the job below.
+ // Doing that without touching the two documents leaves the same failure with
+ // the arrow reversed — a recommendation for a channel that no longer gets
+ // anything. This holds the three together so the deletion cannot be partial.
+ const releaseBody = sliceBetween(workflow, "echo '## Download'", 'RELEASE_BODY');
+ for (const [name, install, push] of [
+ ['Chocolatey', /choco install markpad-app/, /choco push/],
+ ['Snap', /snap install markpad/, /snapcraft push/],
+ ] as const) {
+ const publishes = push.test(publishWorkflow);
+ assert.equal(
+ install.test(readme),
+ publishes,
+ `README ${install.test(readme) ? 'recommends' : 'does not recommend'} ${name}, but the pipeline ` +
+ `${publishes ? 'does' : 'does not'} publish to it`,
+ );
+ assert.equal(
+ install.test(releaseBody),
+ publishes,
+ `the release body ${install.test(releaseBody) ? 'recommends' : 'does not recommend'} ${name}, but the ` +
+ `pipeline ${publishes ? 'does' : 'does not'} publish to it`,
+ );
+ }
+});
+
+test('the Chocolatey job skips a version that is already on the feed', () => {
+ // Chocolatey accepts a version once. On the `release: published` path a
+ // duplicate is a real problem; on the `workflow_dispatch` path it is the
+ // normal case, because the only possible use of that path is a tag that is
+ // already published — recovering after the snap job failed, or exercising
+ // this workflow without cutting a release. Without the skip, that escape
+ // hatch always reports a Chocolatey failure that means nothing.
+ //
+ // The guard must gate the push, not merely exist.
+ assert.match(publishWorkflow, /id: already/);
+ const pack = sliceFrom(publishWorkflow, '- name: Pack and publish');
+ assert.match(pack, /if: steps\.already\.outputs\.published != 'true'/);
+});