# Publishing Whereabouts This is the release flow for `joplin-plugin-whereabouts`. It **mirrors the pipeline of [joplin-plugin-ridgeline](https://github.com/pmslava/joplin-plugin-ridgeline)** — same two-workflow shape, same npm trusted-publishing (OIDC) setup, same "E2E is excluded from the publish gate" decision. Whereabouts ships to the Joplin plugin catalogue the same way every Joplin plugin does: it is published to **npm** as a package that carries the built `.jpl`, and the Joplin plugin repository harvests npm for packages keyed with the `joplin-plugin` keyword. There is no separate submission step. The one-time bootstrap is **done** (see [First release (bootstrap)](#first-release-bootstrap--done-once-for-v020)), so the whole release is now driven by a GitHub Release: publishing the release fires `publish.yml`, which publishes to npm via **trusted publishing (OIDC)**. > **Before the next release, read [the "Allow npm publish" > checkbox](#the-allow-npm-publish-checkbox-must-stay-checked).** npm added staged publishing to the > trusted-publisher form in 2026; if that box is ever cleared, every release silently starts > requiring a manual promotion step. ## The short version 1. Bump the version in **all four places** (see [Version bump](#version-bump)) and commit. 2. Prove the gates in order: **local fast gate green → local E2E green → push `main` → the GitHub Tests workflow green on that exact pushed SHA**. Do not cut the release until that SHA is green. 3. `npm run dist` (after the final commit) and cut the release: `gh release create vX.Y.Z publish/io.github.pmslava.whereabouts.jpl --repo pmslava/joplin-plugin-whereabouts --target main --title vX.Y.Z --notes "…"`. 4. The release event triggers `publish.yml`, which publishes to npm via OIDC trusted publishing. Verify with `npm view joplin-plugin-whereabouts version`. 5. The Joplin catalogue refreshes on its own schedule (hours to about a day). A stale plugin page is not a failed release. ## Version bump The version lives in **four** places, and a CI check fails the build unless all four agree: - `package.json` `"version"` — the npm package version. - `src/manifest.json` `"version"` — the version baked into the built `.jpl`. - `package-lock.json` top-level `.version`. - `package-lock.json` `.packages[""].version` (the root-package entry). > **`npm run updateVersion` is an incrementer, not a syncer, and it knows nothing about the > lockfile.** It adds one to the LAST component of `package.json`'s version and, separately, to the > last component of `src/manifest.json`'s version, then warns if the two results disagree — see > `increaseVersion` / `updateVersion` in `webpack.config.js`. So it is only useful for a patch bump > from an already-matching pair; for a minor or major bump, or to repair a mismatch, it makes things > worse. For a patch bump from a matching pair: ``` npm run updateVersion ``` Otherwise set `package.json` and `src/manifest.json` to the intended version by hand. (0.2.1 → 0.3.0 was done that way: `updateVersion` produced 0.3.1 and 0.2.2 and reported them out of sync.) **Either way, finish by refreshing the lockfile**, which nothing above touches, so all four places stay equal: ``` npm install --package-lock-only # rewrites .version + .packages[""].version ``` The CI check will fail the build if you forget. On an actual release, `publish.yml` also derives the version from the git tag and rewrites `package.json` + `src/manifest.json` in CI, so the tag is the source of truth for those two — but keep the committed files (all four) in step so a plain `main` build already reflects the intended version and the check passes. `src/manifest.json` carries a `screenshots` array. `npm run dist` runs webpack's `validateScreenshots`, which rejects any screenshot that is not a `png`/`jpg`/`jpeg`/`gif`/`webp` or is larger than **1024 KB** — and it refuses to build at all if a file named there is missing. The four placement screenshots are regenerated by `e2e/placement.spec.ts` on every E2E run and are ~15 KB each, so this only bites if you delete one or check in something enormous. ## The gates, in order Prove these **in this order** before cutting a release. Do not skip ahead — the point is that the exact commit you release has already passed every gate. 1. **Local fast gate — green.** Whereabouts has no unit-level harness: every claim it makes is about the real Joplin DOM, so it is all asserted by the real-app E2E suite rather than by mocks that could agree with a wrong assumption. The fast gate is therefore the type-check and the build: ``` npm ci npx tsc --noEmit npm run dist # must emit publish/io.github.pmslava.whereabouts.jpl ``` The four-place version check runs automatically in CI's fast gate; `npm ci` locally reproduces the same install if you want to check before pushing. 2. **Local E2E — green.** Run the full real-app Playwright suite locally (see [DEVELOPMENT.md](DEVELOPMENT.md)). Use the AppImage you already have rather than downloading one: ``` JOPLIN_E2E_APPIMAGE=~/.joplin/Joplin.AppImage npm run setup:e2e npm run test:e2e ``` 3. **Push `main`.** ``` git push origin main ``` 4. **The GitHub Tests workflow — green on that exact pushed SHA.** Watch `tests.yml` in the Actions tab and confirm it is green **on the commit you just pushed** — both the fast gate **and** the real-app E2E job. Only once that specific SHA is green do you cut the release. ## Releasing Once the pushed SHA is green, build the artifact **after the final commit** and cut the release: ``` # Build the artifact from the exact commit you are releasing. npm run dist # Cut the release. This is what triggers publish.yml. gh release create vX.Y.Z publish/io.github.pmslava.whereabouts.jpl \ --repo pmslava/joplin-plugin-whereabouts \ --target main \ --title vX.Y.Z \ --notes "…" ``` - Always pass **`--repo pmslava/joplin-plugin-whereabouts`** so you never accidentally cut the release against the wrong repository. - **`--target main`** pins the tag to the tip of `main` (the SHA you just proved green). - Attach the freshly built **`publish/io.github.pmslava.whereabouts.jpl`** so it is downloadable from the releases page immediately. (The publish workflow's `attach-jpl` job also uploads it automatically, with `--clobber`, as a safety net.) - **Verify any number you cite in `--notes`** against reality before publishing the release — the notes are the public changelog. ## The publish is automatic (never `npm publish` by hand) Publishing the release fires `publish.yml`, whose **publish** job resolves the version from the tag, writes it into `package.json` + `src/manifest.json`, runs the fast gate (`tsc --noEmit` + `npm run dist` + the `.jpl` check), and then publishes to npm via **OIDC trusted publishing**. **Never run `npm publish` manually** — with exactly one exception, the first-release bootstrap below. There are **no npm tokens anywhere** — not in the repo, not in GitHub secrets, not on any laptop — and it must stay that way. A manual publish would bypass provenance and the whole trusted-publishing model; if a release fails, fix the workflow rather than reaching for a token. Watch `publish.yml` in the Actions tab; the **"Show the OIDC claims npm will present"** step prints (never the token) the claims npm sends to the registry — if the publish ever fails with `ENEEDAUTH`, compare those claims against the trusted-publisher configuration below. **Verify the publish succeeded:** ``` npm view joplin-plugin-whereabouts version ``` ## npm trusted publishing Trusted publishing means there is **no `NPM_TOKEN` secret**. npm is told to trust this repository and this workflow, and the job exchanges a short-lived GitHub OIDC token for publish rights. ### First release (bootstrap) — done once, for v0.2.0 **The very first publish of this package was done by hand,** on **2026-09-03 at 09:51 UTC**. That was the **one sanctioned exception** to the [never-`npm publish`-by-hand rule](#the-publish-is-automatic-never-npm-publish-by-hand), and it existed for a concrete npm limitation, not for convenience: > A trusted publisher can only be configured on a package that **already exists** on npm. npm has > **no pending / pre-registration** — there is no way to declare a trusted publisher for a name > before that name has been published. (See > [docs.npmjs.com/trusted-publishers](https://docs.npmjs.com/trusted-publishers).) That is a chicken-and-egg at birth: the release workflow can only publish via OIDC once the trusted publisher is configured, but the trusted publisher can only be configured once the package exists. The bootstrap broke the loop by publishing the first version manually, configuring trust on the now-existing package, then removing the local token so the repository returned to its steady **no-tokens-anywhere** state. **This is a record, not a step to repeat.** What was done, in order, for `v0.2.0`: 1. Proved the gates in the normal order — local fast gate → local E2E → pushed `main` → `tests.yml` green on that exact SHA (`92cc33a`), both the fast gate **and** the real-app E2E job. 2. `npm login` — interactive, authenticating with the **YubiKey (WebAuthn)**. This wrote a **temporary local token into `~/.npmrc`**, the only moment a token existed anywhere. 3. `npm run dist` — built the `.jpl` from the commit proved green. 4. `npm publish` — the manual publish that created the package on npm for the first and only time. > **Do not pass `--otp`.** The npm account is protected with a **YubiKey (WebAuthn)**, and > WebAuthn is not a TOTP code — there is no six-digit number to supply. `npm publish` prints an > **authentication URL** to open in a browser, where you touch the key to approve; the CLI then > continues on its own. Passing `--otp` with anything makes npm try the TOTP path and fail. > (Learned the hard way publishing `joplin-plugin-copy-note-id`.) 5. `npm view joplin-plugin-whereabouts version` — confirmed `0.2.0` had landed. 6. Configured the **trusted publisher** on the now-existing package (npmjs.com → the `joplin-plugin-whereabouts` package → **Settings → Trusted publisher**, with [the values below](#the-trusted-publisher-configuration)) — only possible once step 4 made it exist. 7. `npm logout` — invalidated and removed the temporary token, restoring the **no-tokens-anywhere** state. 8. Cut the GitHub Release `v0.2.0` with the built `.jpl` attached. `publish.yml` fired for a version **already on npm**, and its **idempotency guard** detected that and skipped the publish step, finishing the job GREEN (`0.2.0 already on npm — skipping publish (bootstrap or re-run)`). That guard is still in place, so re-running the workflow on an already-published version is safe. > In that run, the **"Show the OIDC claims npm will present"** step reported **`package not > found`** from the token exchange. That was expected and harmless: the exchange ran at a moment > when the trusted publisher was not yet in effect for the package, and nothing depended on it — > the guard had already skipped the publish. It is **not** evidence that the configuration is > wrong. To actually confirm the configuration, use a dry run (below). **Every release from here is fully automatic** — the trusted publisher is configured, the token is gone, and `publish.yml` publishes via OIDC exactly as [The publish is automatic](#the-publish-is-automatic-never-npm-publish-by-hand) describes. The bootstrap was the only time `npm publish` was ever run by hand. ### Verifying the trusted publisher without releasing anything Run **`publish.yml` manually** (Actions → *Publish to npm* → **Run workflow**). This is safe: the version in `package.json` is already on npm, so the **idempotency guard skips the publish step** and nothing is written to the registry. What it does prove is the OIDC path — the **"Show the OIDC claims npm will present"** step should print: ``` token exchange for joplin-plugin-whereabouts: HTTP 201 registry says: (exchange succeeded) ``` If it instead says `package not found`, or the claims do not match the trusted publisher, the configuration is wrong — fix it **before** cutting a release rather than discovering it mid-release. ### The trusted-publisher configuration On **npmjs.com → the `joplin-plugin-whereabouts` package → Settings → Trusted publisher**: | Field | Value | | --- | --- | | Provider | GitHub Actions | | Owner | `pmslava` | | Repository | `joplin-plugin-whereabouts` | | Workflow | `publish.yml` | | Environment | `npm` | | Allowed actions | **Allow npm publish — checked** (see below) | #### The "Allow npm publish" checkbox must stay CHECKED npm's trusted-publisher form now carries an **Allowed actions** section with an **"Allow npm publish"** checkbox. This is npm's **staged publishing** control, added in **2026** — it did **not exist** when the trusted publishers for `joplin-plugin-ridgeline` and `joplin-plugin-cockpit` were set up, so those playbooks say nothing about it and neither did this one until v0.2.0. **It must stay checked.** With it cleared, the trusted publisher is only allowed to run `npm stage publish`: the workflow's plain `npm publish` is rejected, and the version lands in a staging area that a maintainer then has to **promote manually on npmjs.com or via the CLI, with 2FA**. That would quietly turn every release from "cut the Release and walk away" into "cut the Release, then go and touch the YubiKey" — the exact hands-off property the whole trusted-publishing setup exists to buy. See [docs.npmjs.com/trusted-publishers](https://docs.npmjs.com/trusted-publishers). If a release ever fails with an authorisation error despite the claims matching, check this box first. The **Environment** name must match on both sides. GitHub only adds an `environment` claim to the OIDC token when the job declares `environment:` — `publish.yml` declares `environment: npm`, so npm must be told to expect `npm`. If they disagree, npm falls back to token auth and the publish fails with `ENEEDAUTH`. With this configured, releases publish with no secrets, and npm generates provenance attestations automatically (that is why `--provenance` is not passed). ## What the two workflows do, and why E2E is not in the publish gate - **`.github/workflows/tests.yml`** runs on every push and pull request. Two jobs: - **build** — the *fast gate*: `npm ci`, the **four-place version check**, `tsc --noEmit`, `npm run dist`, and a check that the `.jpl` was actually produced. - **e2e** — the *real-app suite*: launches the actual Joplin desktop (Electron) under Xvfb with the plugin loaded and drives it with Playwright. It caches the Joplin AppImage keyed on `JOPLIN_E2E_VERSION` (3.7.14) and uploads its report/traces on failure. - **`.github/workflows/publish.yml`** runs on a published GitHub Release (or manually via `workflow_dispatch`). Its **publish** job resolves the version from the tag, writes it into both `package.json` and `src/manifest.json`, runs the **fast gate only**, and then publishes to npm. On a real Release it also runs a second **attach-jpl** job that rebuilds the plugin and uploads `publish/io.github.pmslava.whereabouts.jpl` to the release as a downloadable asset. That job carries its own minimal `contents: write` scope, keeping the npm-publish job at `contents: read`. **Why E2E is deliberately excluded from the publish gate:** the E2E job downloads a ~200 MB Joplin build and launches it ten times — minutes of work that would make every release slow and flaky for no added safety. `tests.yml` already runs the real-app suite on every push, and the gate order above requires that exact SHA to be green there before you cut the release, so the released code has been through it. The publish gate only needs to prove the artifact type-checks and builds. ## The Joplin plugin catalogue pickup The Joplin plugin repository scans npm for packages carrying the **`joplin-plugin`** keyword (present in `package.json` `keywords`) and installs the `.jpl` from the published tarball. After a successful npm publish, the new version does **not** appear in Joplin's in-app plugin search instantly — **the catalogue refreshes on its own schedule**, so expect **a few hours to about a day** before "Whereabouts" shows up in **Tools → Options → Plugins**. **A stale plugin page is not a failed release** — if `npm view joplin-plugin-whereabouts version` shows the new version, the release worked. ## Checklist - [ ] All **four** version places match the intended `vX.Y.Z`: `package.json`, `src/manifest.json`, `package-lock.json .version`, `package-lock.json .packages[""].version` (refresh the lockfile after `npm run updateVersion`). - [ ] `keywords` in `package.json` still include `joplin-plugin`. - [ ] Screenshots referenced by `src/manifest.json` exist under `docs/images/` and are each ≤ 1024 KB. - [ ] `npm pack --dry-run --ignore-scripts` lists the `.jpl` and `.json` and nothing private. - [ ] Gates passed **in order**: local fast gate → local E2E → pushed `main` → `tests.yml` green on that exact SHA (fast gate **and** real-app E2E). - [ ] `npm run dist` run after the final commit; release cut with `gh release create … --repo pmslava/joplin-plugin-whereabouts --target main` and the `.jpl` attached. - [ ] Trusted publisher still configured, with **"Allow npm publish" checked** — a `workflow_dispatch` run of `publish.yml` prints `(exchange succeeded)` and skips the publish. - [ ] `publish.yml` succeeded; **no** manual `npm publish` (the v0.2.0 bootstrap was the only one). - [ ] `npm view joplin-plugin-whereabouts version` shows the new version. - [ ] Catalogue shows the plugin (allow up to ~a day; a stale page is not a failed release).