Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '20'
node-version: lts/*

- name: Get Version
id: get_version
Expand Down Expand Up @@ -73,7 +73,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '20'
node-version: lts/*

- name: Install Rust
uses: dtolnay/rust-toolchain@stable
Expand Down Expand Up @@ -300,6 +300,28 @@ jobs:
WINDOWS_AARCH64=$(platform_entry "$WIN_ARM_SIG")
LINUX_X86_64=$(platform_entry "$LINUX_SIG")

# Every key below is a plain {os}-{arch}, and that is load-bearing.
#
# tauri-plugin-updater looks up two keys in order: {os}-{arch}-{installer}
# first, then {os}-{arch}. The first is only tried when the binary knows
# its own bundle type, and ours does not on Windows or Linux --
# `__TAURI_BUNDLE_TYPE` is written into the binary by patching it after
# the build, and that patch fails on both, three times in the v2.7.0 run:
# Warn Failed to add bundler type to the binary: __TAURI_BUNDLE_TYPE
# variable not found in binary. ... Updater plugin may not be able to
# update this package.
# macOS is unaffected: tauri-utils returns BundleType::App there without
# reading the variable.
#
# So Windows and Linux clients ask only for {os}-{arch}, which is what
# this file publishes, and the warning is inert for us.
#
# Adding a per-installer key -- "windows-x86_64-nsis" say -- would end
# that. It would be the key those clients cannot ask for, so they would
# keep resolving the unsuffixed one and silently ignore the new entry;
# remove the unsuffixed one in the same edit and they get
# TargetsNotFound and stop updating. If per-installer keys are ever
# wanted, the missing bundle type has to be fixed first.
jq -n \
--arg version "$VERSION" \
--arg notes "See https://github.com/${REPO}/releases/tag/v${VERSION}" \
Expand Down
30 changes: 28 additions & 2 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ You'll be prompted for a password. **Pick a strong one and store it together wit
- `~/.tauri/markpad-updater.key` — **PRIVATE**. Never commit. Never share. Back up to a password manager.
- `~/.tauri/markpad-updater.key.pub` — **PUBLIC**. Shared with developers; ends up shipped inside Markpad.

### 2. Add Secrets to `alecdotdev/Markpad`
### 2. Add Secrets to `sftwrdotdev/Markpad`

In the GitHub repo settings → Secrets and variables → Actions → New repository secret:

Expand All @@ -42,6 +42,32 @@ If you ever lose the private key:
- A new keypair has to be generated, embedded in a new release, and that release has to be installed manually by every user.
- Communicate this in release notes so users aren't blindsided.

### 5. CRITICAL: `alecdotdev/Markpad` must never exist again

The updater endpoint in `src-tauri/tauri.conf.json` points at **`sftwrdotdev/Markpad`**. (`scripts/releaseWorkflow.test.ts` holds that name and the endpoint together — if the repository ever moves again, this line moves with it.)

Markpad was transferred from `alecdotdev/Markpad` to `sftwrdotdev/Markpad`, and `alecdotdev` is still a live account — a transfer between two accounts, not a rename. Pointing the endpoint at the new location **only helps builds made from here on.** The endpoint is compiled into the binary, so every copy of Markpad up to and including v2.7.0 asks GitHub for:

```
https://github.com/alecdotdev/Markpad/releases/latest/download/latest.json
```

and reaches the current feed only because GitHub answers it with a 301 to the new location. Those installs will use that redirect for as long as they run, however many versions ship after this one.

GitHub voids a transfer redirect if the old location is occupied again. From [Transferring a repository](https://docs.github.com/en/repositories/creating-and-managing-repositories/transferring-a-repository):

> If you create a new repository or fork at the previous repository location, the redirects to the transferred repository will be permanently deleted.

So, permanently, and regardless of what it would contain:

- **Do not create a repository named `Markpad` under `alecdotdev`.**
- **Do not fork `sftwrdotdev/Markpad` to `alecdotdev`** — the doc says a fork at the old location voids the redirect just as a new repository does.
- **Do not transfer Markpad back to `alecdotdev`, then away again.** Each hop leaves another compiled-in endpoint depending on another redirect.

The failure this prevents is quiet. `latest.json` becomes a 404, the updater reports no update available, and users on old versions simply stop being offered new ones — with no error anyone but that user can see. There is no way to fix it from this repository afterwards; the only remedy is asking every affected user to reinstall by hand.

What this is *not* is a code-execution risk. The `pubkey` in `tauri.conf.json` is pinned, and `tauri-plugin-updater` verifies the minisign signature on the downloaded bundle before installing it. Whoever ends up serving that URL cannot ship a Markpad update that installs, because they cannot produce a signature for it. The exposure is broken or stalled updates, not a hijacked one.

## Per-release workflow

The workflow uses `npm ci`, so its installed dependency graph is exactly the committed lockfile. Do not replace it with `npm install` in release jobs. The same applies to [`snapcraft.yaml`](snapcraft.yaml), which builds the snap outside GitHub Actions; `scripts/releaseWorkflow.test.ts` guards both.
Expand All @@ -59,7 +85,7 @@ The workflow uses `npm ci`, so its installed dependency graph is exactly the com
- GitHub UI: Actions → "Build and Release" → Run workflow → master
- Or CLI: `gh workflow run build.yml --ref master`
4. **Wait** ~30 min for matrix builds to finish, plus ~2 min for `generate-update-feed`.
5. **Open the draft release** on the [Releases page](https://github.com/alecdotdev/Markpad/releases). Verify the assets:
5. **Open the draft release** on the [Releases page](https://github.com/sftwrdotdev/Markpad/releases). Verify the assets:
- **macOS**: `*.dmg`, `*.app.tar.gz`, `*.app.tar.gz.sig`
- **Windows x64**: `Markpad_<version>_x64.exe` (portable), `*_x64-setup.exe` (NSIS installer), `*_x64-setup.exe.sig`
- **Windows ARM64**: `Markpad_<version>_arm64.exe` (portable), `*_arm64-setup.exe` (NSIS installer), `*_arm64-setup.exe.sig`
Expand Down
79 changes: 78 additions & 1 deletion scripts/releaseWorkflow.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { readSource } from './sourceTree.js';
import { readSource, sliceBetween } from './sourceTree.js';

const workflow = readSource('.github/workflows/build.yml');
const testWorkflow = readSource('.github/workflows/test.yml');
const testBuildWorkflow = readSource('.github/workflows/test_build.yml');
const releasing = readSource('RELEASING.md');
const snapcraft = readSource('snapcraft.yaml');
const cargoToml = readSource('src-tauri/Cargo.toml');
const packageJson = JSON.parse(readSource('package.json')) as {
scripts: Record<string, string>;
version: string;
};
const tauriConf = JSON.parse(readSource('src-tauri/tauri.conf.json')) as {
plugins: { updater: { endpoints: string[]; pubkey: string } };
};

/** Every `node-version:` value in a workflow, in file order. */
function nodeVersions(source: string): string[] {
return [...source.matchAll(/^\s*node-version:\s*'?([^'\s]+)'?\s*$/gm)].map((m) => m[1]);
}

test('release builds install the locked dependency graph', () => {
assert.match(workflow, /name: Install Frontend Dependencies\s+run: npm ci/);
Expand Down Expand Up @@ -40,3 +51,69 @@ test('portable executables are not mislabeled as installers', () => {
test('the test bundle command uses an isolated builder', () => {
assert.equal(packageJson.scripts['build:test-bundle'], 'node scripts/build-test-bundle.mjs');
});

test('the shipped app is built on the Node the tests ran on', () => {
// build.yml pinned '20' in both of its jobs while test.yml and
// test_build.yml used lts/*, so the binary users install was produced by a
// runtime nothing had exercised. Node 20 went end-of-life in April 2026 and
// left the runner toolcache in May: the v2.7.0 run downloaded 20.20.2 fresh
// in all five jobs ("Attempting to download 20... Acquiring 20.20.2"), while
// lts/* is a cache hit.
//
// The three files are one fact, so they are asserted against each other
// rather than against a literal — pinning a specific version here would
// re-create the drift one level up.
const expected = [...new Set([...nodeVersions(testWorkflow), ...nodeVersions(testBuildWorkflow)])];
assert.deepEqual(expected.length, 1, `the test workflows disagree on Node: ${expected.join(', ')}`);
assert.deepEqual(
[...new Set(nodeVersions(workflow))],
expected,
'build.yml must request the same Node as the workflows that test the code it ships',
);
});

test('the updater feed publishes the keys an installed Markpad can ask for', () => {
// tauri-plugin-updater tries `{os}-{arch}-{installer}` and then `{os}-{arch}`,
// and only tries the first when the binary knows its own bundle type. Ours
// does not on Windows or Linux — the `__TAURI_BUNDLE_TYPE` patch fails there,
// three times in the v2.7.0 build. Those clients therefore look up the plain
// key and nothing else, so a per-installer key added here would be invisible
// to them, and one added *instead* would strand them on TargetsNotFound.
const platforms = sliceBetween(workflow, 'platforms: ({', '} | with_entries');
const keys = [...platforms.matchAll(/"([a-z0-9_-]+)":/g)].map((m) => m[1]);
assert.ok(keys.length >= 5, `expected the five platform keys, found ${keys.length}`);
for (const key of keys) {
assert.match(
key,
/^(darwin|windows|linux)-[a-z0-9_]+$/,
`latest.json key "${key}" carries an installer suffix; Windows and Linux clients ` +
'cannot ask for one until the missing bundle type is fixed',
);
}
});

test('the updater endpoint names the repository RELEASING.md documents', () => {
// The endpoint is compiled into every installed copy, so getting it wrong is
// only discoverable by a user who stops being offered updates. It was left
// on `alecdotdev/Markpad` after the transfer and survived on a GitHub
// redirect, which RELEASING.md now explains must never be broken. Config and
// runbook are one fact; if the repository moves again, both move.
//
// Matched against the one sentence that states it, not against the document.
// Measured: "RELEASING.md mentions this repo somewhere" passed with the
// endpoint reverted to `alecdotdev/Markpad`, because the section warning
// against recreating that repository necessarily names it.
const endpoints = tauriConf.plugins.updater.endpoints;
assert.equal(endpoints.length, 1, 'expected exactly one updater endpoint');
const repo = /^https:\/\/github\.com\/([\w.-]+\/[\w.-]+)\/releases\//.exec(endpoints[0])?.[1];
assert.ok(repo, `updater endpoint is not a GitHub releases URL: ${endpoints[0]}`);
const documented = /The updater endpoint in `src-tauri\/tauri\.conf\.json` points at \*\*`([\w.-]+\/[\w.-]+)`\*\*/.exec(
releasing,
);
assert.ok(documented, 'RELEASING.md must state which repository the updater endpoint names');
assert.equal(
repo,
documented[1],
`tauri.conf.json points the updater at ${repo} while RELEASING.md documents ${documented[1]}`,
);
});
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@
"plugins": {
"updater": {
"endpoints": [
"https://github.com/alecdotdev/Markpad/releases/latest/download/latest.json"
"https://github.com/sftwrdotdev/Markpad/releases/latest/download/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IEIzMENBRkQ5NjFGRkM4NDcKUldSSHlQOWgyYThNczhINFc4VzVmVEcrS2JGRlRKRWFTVHVTVU9saC9WdFJQZjc0RjZzNmdQRGcK",
"windows": { "installMode": "passive" }
Expand Down