|
| 1 | +### Added |
| 2 | + |
| 3 | +**An application Perry compiles can now carry its own update check.** Perry's |
| 4 | +CLI has checked for updates for a long time; an app it *builds* could not, so |
| 5 | +shipping one meant the author wrote a version check by hand or shipped none and |
| 6 | +hoped users noticed. |
| 7 | + |
| 8 | +```json |
| 9 | +{ "perry": { "update": { |
| 10 | + "source": "npm", "package": "myapp", "command": "self-update" |
| 11 | +} } } |
| 12 | +``` |
| 13 | + |
| 14 | +A `perry.update` block — or an `[update]` table in perry.toml, which wins key by |
| 15 | +key — is validated at compile time and baked into the executable as a small |
| 16 | +blob. The runtime reads it at the top of `main`, before any user code. |
| 17 | + |
| 18 | +**With no block configured, nothing is emitted.** Not an empty blob, and not a |
| 19 | +disabled one: a binary that configures no updates is byte-identical to one built |
| 20 | +before this existed. A feature whose off-state still emits code is one you |
| 21 | +cannot prove is off. |
| 22 | + |
| 23 | +<details> |
| 24 | +<summary><b>Validation is a build failure, on purpose</b></summary> |
| 25 | + |
| 26 | +A typo in an update URL is discovered either by the person who typed it, at |
| 27 | +build time, with a message naming the key — or by their users, in production, as |
| 28 | +silence. So these are errors rather than warnings: |
| 29 | + |
| 30 | +- a URL must be `https://`, with loopback allowed for local testing. Plain HTTP |
| 31 | + is refused rather than warned about, because an on-path attacker can suppress |
| 32 | + a legitimate update by answering "you are current", and build-output warnings |
| 33 | + are not where that gets noticed; |
| 34 | +- each source needs the keys it actually reads — `url` for `gh-releases` and |
| 35 | + `custom`, `package` for the npm-shaped ones; |
| 36 | +- a zero check interval is rejected, since it would ask on every run; removing |
| 37 | + the block is how you disable checks; |
| 38 | +- an app with no version has nothing to compare against, so that is caught too. |
| 39 | + |
| 40 | +`enabled = false` keeps the settings on disk and emits nothing, rather than |
| 41 | +embedding a disabled block complete with its URL and startup call. |
| 42 | +</details> |
| 43 | + |
| 44 | +<details> |
| 45 | +<summary><b>What the runtime half does, and what it deliberately does not</b></summary> |
| 46 | + |
| 47 | +It parses the blob and applies every gate that decides whether a check may |
| 48 | +happen: the app's own opt-out variable, `PERRY_NO_UPDATE_CHECK`, the |
| 49 | +ecosystem-wide `NO_UPDATE_NOTIFIER`, `CI` and `CONTINUOUS_INTEGRATION`, a |
| 50 | +non-terminal stderr, and the app's own update command — so `app self-update` |
| 51 | +does not check on its way to updating. |
| 52 | + |
| 53 | +It does **not** yet reach the network or print anything. That split is |
| 54 | +deliberate: the gates are where this feature can go wrong quietly. A check that |
| 55 | +fires in CI, or in a script parsing the app's output, is a bug that surfaces as |
| 56 | +somebody else's flaky pipeline, so they are worth landing and testing ahead of |
| 57 | +the code that would exercise them. |
| 58 | + |
| 59 | +A blob whose schema this build does not recognize is ignored rather than read |
| 60 | +field by field. It is emitted by the same Perry that compiled the binary, so a |
| 61 | +mismatch means something is wrong upstream, and guessing at a moved layout would |
| 62 | +run a network check with settings nobody wrote. |
| 63 | +</details> |
| 64 | + |
| 65 | +<details> |
| 66 | +<summary><b>The cache key, which is where this would have broken silently</b></summary> |
| 67 | + |
| 68 | +The blob is part of the object-cache fingerprint. Without that, adding |
| 69 | +`perry.update` to a project and rebuilding incrementally would serve the cached |
| 70 | +entry object from before — and the binary would ship with no update check while |
| 71 | +the build reported success. Same class as the `dbgloc` and `fmath` entries the |
| 72 | +cache file already documents. |
| 73 | + |
| 74 | +The embed is skipped for a dylib, for the same reason the App Group init is: |
| 75 | +there is no `main` to put a prelude in. |
| 76 | +</details> |
| 77 | + |
| 78 | +<details> |
| 79 | +<summary><b>Tests</b></summary> |
| 80 | + |
| 81 | +22 new. Ten on the compiler side cover the parse and every validation rule, |
| 82 | +including that perry.toml overrides package.json key by key while leaving keys it |
| 83 | +does not set alone, and that the blob stamps its schema and omits unset optionals |
| 84 | +rather than writing nulls. Twelve on the runtime side cover the blob reader and |
| 85 | +every gate, including that a key name appearing inside a *value* is not |
| 86 | +mismatched, and that an app with no config reports "not configured" instead of |
| 87 | +"go ahead" — an inversion the test caught during development. |
| 88 | + |
| 89 | +Verified end to end: a configured project's binary contains the blob, the same |
| 90 | +project without the block produces one that does not, the configured binary runs |
| 91 | +normally, and a plain-HTTP URL fails the build with a message naming the key. |
| 92 | + |
| 93 | +`cargo test -p perry`: 912 passed. `cargo test -p perry-runtime`: 2064 passed. |
| 94 | +Both zero failures. |
| 95 | + |
| 96 | +### Added (second slice) |
| 97 | + |
| 98 | +**The notify half now works from recorded state.** A compiled app with an |
| 99 | +embedded block reads its own per-app state file at startup and prints a |
| 100 | +two-line notice on stderr when a newer version was recorded by a previous run. |
| 101 | +The network refresh that records it is the next slice, so a first run is silent — |
| 102 | +which is the right way round. |
| 103 | + |
| 104 | +State lives in the platform's own cache location, resolved through `dirs` so it |
| 105 | +asks the real APIs (Known Folders on Windows, the Foundation search paths on |
| 106 | +macOS) rather than trusting environment variables a launcher or service manager |
| 107 | +may not have set. Keyed per app, so two Perry-built programs never share a |
| 108 | +throttle: one app's notice silencing another's would be invisible and |
| 109 | +maddening. The app id is sanitized into a single path component — it is a |
| 110 | +manifest value that becomes a path, and `..` is cheaper to make impossible than |
| 111 | +to reason about. |
| 112 | + |
| 113 | +<details> |
| 114 | +<summary><b>Three lessons from the CLI's own review, applied here first</b></summary> |
| 115 | + |
| 116 | +The interval throttles repeats of the **same** release, keyed on the version and |
| 117 | +not only on a timestamp. Keyed on time alone it swallows the next release |
| 118 | +whenever that arrives inside the window, so an interval set to stop nagging |
| 119 | +about one version would also hide the version that fixed it. |
| 120 | + |
| 121 | +The state file is written to a per-write temporary name and renamed over the |
| 122 | +target. Two instances of the same app can run at once, and one shared temporary |
| 123 | +name lets each rename a file the other is still writing. |
| 124 | + |
| 125 | +An unreadable timestamp notifies rather than staying silent, because silence on |
| 126 | +a damaged file would hide updates indefinitely. |
| 127 | + |
| 128 | +Two more that are specific to this half. The notice goes to **stderr**, always: |
| 129 | +an app's stdout belongs to the app. And every value in it arrived in a network |
| 130 | +document, so control characters are stripped — a release name is |
| 131 | +attacker-influenceable terminal input, and a notice must not be able to repaint |
| 132 | +somebody's screen. |
| 133 | + |
| 134 | +An unparseable version never reads as newer. node-smol's equivalent compared |
| 135 | +against a hardcoded `"0.0.0"`, which made every release look newer than the |
| 136 | +running binary; returning "unknown" is what avoids that. |
| 137 | +</details> |
| 138 | + |
| 139 | +<details> |
| 140 | +<summary><b>Tests</b></summary> |
| 141 | + |
| 142 | +16 more, bringing this module to 28. Beyond the units — version comparison, |
| 143 | +the sanitizer against five hostile ids, per-platform directory rules, the |
| 144 | +throttle, control-character stripping, a real file round trip, and that the |
| 145 | +stamp writer and reader agree — there is a **wiring test** that drives the whole |
| 146 | +startup path: blob in, notice out, state advanced, second run quiet. |
| 147 | + |
| 148 | +That one exists because everything else here is a piece tested in isolation, and |
| 149 | +a feature whose pieces all pass while the path between them is broken is exactly |
| 150 | +what ships doing nothing. |
| 151 | + |
| 152 | +`cargo test -p perry-runtime`: 2080 passed. `cargo test -p perry`: 912 passed. |
| 153 | +Both zero failures. |
| 154 | + |
| 155 | +### Documentation |
| 156 | + |
| 157 | +New page `docs/src/cli/app-updates.md`, written for the app author rather than |
| 158 | +for Perry's own maintainers: the smallest configuration that does something |
| 159 | +useful, every key, how to choose a source, the mistakes that fail the build and |
| 160 | +why each is an error rather than a warning, the cases where a user's run will not |
| 161 | +check at all, where the state file lives per platform, and how to give your own |
| 162 | +users an off switch. |
| 163 | + |
| 164 | +It states two things an app author needs to be able to rely on: their app's |
| 165 | +stdout is never touched, and the notify throttle is keyed to the version — so a |
| 166 | +week-long interval set to stop nagging about one release does not also hide the |
| 167 | +patch that fixes it. |
0 commit comments