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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
button that opens Settings there. The notes come from the version that is running, so they
appear from the update after this one.
([#535](https://github.com/devswha/herdr-web-ui/pull/535))
- An update also tells what it brought once it is installed. After the reload, the line under
the header says which version now runs, with **What's new** and **Dismiss**, for a week or
until it is closed on that device, and **Settings → Updates** keeps the notes under
**What the last update brought**. These come from the new version itself, so they appear
with the first update to a version that has them.
([#560](https://github.com/devswha/herdr-web-ui/pull/560))
- A release is told the way a game's patch notes are, before and after its install: a short
list under **New features**, **Improvements** and **Bug fixes**, in the app's language
(English, Korean, Japanese or Chinese). The changelog entries are folded under
**Show every change**. A release that wrote no such list shows its entries as before.
([#560](https://github.com/devswha/herdr-web-ui/pull/560))
- The one-line installers can give the GitHub star they mention. On a first install, when the gh
CLI is signed in and that account has not starred the repository, `install.sh` and `install.ps1`
ask once at the terminal and star only on `y`. An account that already starred hears nothing.
Expand Down
6 changes: 3 additions & 3 deletions docs/app-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ Chatmux's systemd-specific launcher and release archive pipeline were not adopte

- `server/managed.ts` is the launcher: the process `bun run start` and the plugin start and record. It runs `server/supervisor.ts` from the active release (`current.json`, only while it matches the source checkout's HEAD), else its own copy. `server/supervisor.ts` owns the bridge process and health-checked rollback. `server/updater.ts` owns bounded Git/build commands, source guards, release selection and persistence. Only the supervisor runs discovery timers.
- Supervisor handover: after an install passes the bridge health check and is committed, the supervisor exits with 75 before reporting `idle`; the launcher then starts the new release's supervisor, which restarts the bridge from the same release (a second brief reconnect). A new supervisor that exits before it reports ready is replaced by the previous one (then the checkout's own), which runs the new release and reports the failure in the update status until the next update. Handover only happens under the launcher.
- `server/update-api.ts` relays requests/status over IPC. `/api/updates*` uses the existing token gate; POSTs require a custom header and reject cross-site browser requests. The browser cannot choose an executable, directory, remote or revision. `GET /api/updates/notes` answers what the offered release brings: the supervisor reads the `CHANGELOG.md` of the release's own commit (`git show <revision>:CHANGELOG.md`, fetched with its tag, no other network call) and `server/release-notes.ts` keeps the sections after the running version, up to the release, newest first and bounded in size. The notes travel beside the status over IPC and stay out of `GET /api/updates`, which is polled. A release without a readable changelog section is offered without notes.
- `shared/update.ts` defines the status contract. `src/lib/updates.ts` polls it after authentication. `UpdateControls.tsx` displays Settings controls, the release's notes (fetched once per offer and kept only when read from the offered commit, `src/lib/updateNotes.ts`) and the header notice, whose **Update** button installs the release from there; both show the install's `step` (download, dependencies, typecheck, build, restart) as a step count and a bar. The compiled frontend revision identifies when a reload is needed; reloading remains explicit to preserve drafts.
- `server/update-api.ts` relays requests/status over IPC. `/api/updates*` uses the existing token gate; POSTs require a custom header and reject cross-site browser requests. The browser cannot choose an executable, directory, remote or revision. `GET /api/updates/notes` answers what the offered release brings: the supervisor reads the `CHANGELOG.md` of the release's own commit (`git show <revision>:CHANGELOG.md`, fetched with its tag, no other network call) and `server/release-notes.ts` keeps the sections after the running version, up to the release, newest first and bounded in size. The notes travel beside the status over IPC and stay out of `GET /api/updates`, which is polled. A release without a readable changelog section is offered without notes. A release also tells itself as patch notes in `release-summaries.json`: short lines per UI language under what is new, improved and fixed (`{ "X.Y.Z": { "en": { "new": [], "improved": [], "fixed": [] }, "ko": …, "ja": …, "zh": … } }`), read from the same commit and sent with its section as `summary`; a release that wrote none is told by its section alone. `GET /api/updates/installed` answers what the last update brought once the new release runs: the supervisor reads the running release's own `CHANGELOG.md` and `release-summaries.json` from disk, from the version it replaced. The installing supervisor writes that version and the time into `current.json` (`previous_version`, `installed_at`). A supervisor older than that record leaves none, and then the version is the newest older build still present (an install keeps the release it replaces, and the source checkout stays) and the time is the record's own. A source checkout that no update installed answers, for its commit, that there is none; an answer that names no commit is a supervisor that has not told yet, and the client asks again a few times.
- `shared/update.ts` defines the status contract. `src/lib/updates.ts` polls it after authentication. `UpdateControls.tsx` displays Settings controls, the release's notes (fetched once per offer and kept only when read from the offered commit, `src/lib/updateNotes.ts`) and the header notice, whose **Update** button installs the release from there. After the reload the notice tells of the update for a week, until the device closes it or opens its notes (`announcesUpdate`, a version kept in `localStorage`), and Settings keeps what the last update brought. A release with a summary shows its lines in the app's language (English when that language is missing) under **New features**, **Improvements** and **Bug fixes**, with its changelog section folded under them. While a newer release is known, the notice is that release's, and Settings shows both sets of notes. Both both show the install's `step` (download, dependencies, typecheck, build, restart) as a step count and a bar. The compiled frontend revision identifies when a reload is needed; reloading remains explicit to preserve drafts.
- `bun run start` and plugin `start` use the supervisor; `bun run server` remains unmanaged. Restart an older running server once to adopt this entrypoint. Updates replace the supervisor through the handover above; only the launcher (`server/managed.ts`, kept small and dependency-light) stays loaded from the source checkout, so a launcher change needs a restart from an updated checkout (plugin reinstall or `git pull`).
- Update data is separate from push keys/subscriptions. State and socket paths are resolved before launching a candidate, so changing the candidate working directory cannot redirect either. The source checkout and the two most recent successful isolated builds remain available.
- Discovery/build failures keep the serving bridge alive. A failed candidate boot restores the previous build. If both candidate and previous build cannot start (for example herdr is unavailable), the failure remains in supervisor logs and needs normal service recovery.
Expand All @@ -31,5 +31,5 @@ Chatmux's systemd-specific launcher and release archive pipeline were not adopte
- `bun test server/updater.test.ts`: real temporary Git remotes, pinned builds, unchanged source, local-change/branch/divergence guards, build failure cleanup, IPC installation with the real bridge and live herdr, failed-boot rollback, saved-release restart, automatic install, duplicate requests, bounded subprocess cancellation, and persistent failed-revision retry suppression.
- `server/api.contract.test.ts`: update status cache policy, token protection, cross-site/form rejection and unmanaged installation rejection.
- `bun run typecheck` and `bun run build` passed.
- `bun scripts/update-browser-qa.ts`: actual source copy, private Git remote, candidate dependency installation/typecheck/build, the release's notes in Settings and from the header line's **What's new**, desktop check/install, new frontend reload, retained unsent draft and owned herdr pane, deliberately failed server boot/rollback, mobile update-section bounds, and no browser JavaScript errors. The temporary server, browser and workspace are cleaned up.
- `bun scripts/update-browser-qa.ts`: actual source copy, private Git remote, candidate dependency installation/typecheck/build, the release's summary and folded notes in Settings and from the header line's **What's new**, desktop check/install, new frontend reload, the line that tells of the update and what it brought, retained unsent draft and owned herdr pane, deliberately failed server boot/rollback, mobile update-section bounds, and no browser JavaScript errors. The temporary server, browser and workspace are cleaned up.
- Screenshots and the supervisor log are generated under gitignored `evidence/updates/`. They are QA artifacts, not README/demo screenshots.
9 changes: 8 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,14 @@ after changing the staged session. Files, images, push and remote PCs are not pa
## Releasing

1. Open a release PR that bumps `version` in `package.json` and `herdr-plugin.toml`,
and moves the `Unreleased` notes in [CHANGELOG.md](../CHANGELOG.md) under the new version.
moves the `Unreleased` notes in [CHANGELOG.md](../CHANGELOG.md) under the new version,
and tells the release in [release-summaries.json](../release-summaries.json) the way a
game's patch notes do: under the version, for each of `en`, `ko`, `ja` and `zh`, the lists
`new`, `improved` and `fixed` (a list with nothing to say is left out), each a few lines of
plain text, 90 characters at most, the same number of lines in every language. It is what
an install shows before and after the update, with the changelog section folded under it,
so a line names what changes for the people who use the app and leaves out PR numbers and
internals. The unit suite and the release workflow fail without all four languages.
2. Merge it after CI passes.
3. Run **Actions → Release → Run workflow**, select `main`, and enter `X.Y.Z` without `v`.
The CLI equivalent is `gh workflow run release.yml --ref main -f version=X.Y.Z`.
Expand Down
2 changes: 1 addition & 1 deletion docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@ Observe connections cannot take a pane, and a displaced bridge never takes it ba

## Updates

`bun run start` and the plugin look for a newer **release** 10 seconds after start and then every 5 minutes. A release is a `vX.Y.Z` tag ([changelog](../CHANGELOG.md)); commits between releases never reach installs. When a new version is out, a line under the header names it, and its **Update** button installs it and shows the install's steps; **Settings → Updates** has the same controls and the full error when an install fails. The release's notes are there too, under **What's new**: the changelog of every release the update installs, newest first, and the line's **What's new** button opens Settings on them. To install releases without asking, set `HERDR_WEB_AUTO_UPDATE=1`.
`bun run start` and the plugin look for a newer **release** 10 seconds after start and then every 5 minutes. A release is a `vX.Y.Z` tag ([changelog](../CHANGELOG.md)); commits between releases never reach installs. When a new version is out, a line under the header names it, and its **Update** button installs it and shows the install's steps; **Settings → Updates** has the same controls and the full error when an install fails. The release's notes are there too, under **What's new**: every release the update installs, newest first, as a short list of what is new, improved and fixed in the app's language, with **Show every change** opening that release's changelog, and the line's **What's new** button opens Settings on them. After the update and the reload, the line says which version now runs, with the same **What's new** button, until you close it; **Settings → Updates** keeps the notes under **What the last update brought**. To install releases without asking, set `HERDR_WEB_AUTO_UPDATE=1`.

An update is built and typechecked in a private checkout while the current server keeps serving. The new server must pass a health check, or the previous build comes back. herdr and your agents keep running, and a **Reload app** notice lets you save drafts before the new frontend loads.

Expand Down
1 change: 1 addition & 0 deletions release-summaries.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{}
19 changes: 14 additions & 5 deletions scripts/release-notes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,27 @@ import { releaseNotes } from "./release-notes.ts";

const manifest = 'name = "herdr-web-ui"\nversion = "1.2.3"\n';
const changelog = '# Changelog\n\n## [Unreleased]\nFuture\n\n## [1.2.3] — today\n\n- Fixed input.\n\n## [1.2.2]\nOld\n\n[1.2.3]: https://example.com\n';
const told = { en: { fixed: ["Input."] }, ko: { fixed: ["입력."] }, ja: { fixed: ["入力。"] }, zh: { fixed: ["输入。"] } };
const summaries = JSON.stringify({ "1.2.3": told, "1.2.2": { en: { fixed: ["Old."] } } });
test("release notes belong only to the exact requested version", () => {
expect(releaseNotes("1.2.3", "1.2.3", manifest, changelog)).toBe("- Fixed input.");
expect(releaseNotes("1.2.3", "1.2.3", manifest, changelog, summaries)).toBe("- Fixed input.");
});
test("invalid versions and mismatched metadata cannot be released", () => {
for (const version of ["v1.2.3", "1.2.3-rc.1", "01.2.3", "1.2.3\n", "1.2.3; echo bad", ""]) {
expect(() => releaseNotes(version, version, manifest, changelog)).toThrow();
expect(() => releaseNotes(version, version, manifest, changelog, summaries)).toThrow();
}
expect(() => releaseNotes("1.2.3", "1.2.4", manifest, changelog)).toThrow("package.json");
expect(() => releaseNotes("1.2.3", "1.2.3", 'version = "1.2.4"', changelog)).toThrow("herdr-plugin.toml");
expect(() => releaseNotes("1.2.3", "1.2.4", manifest, changelog, summaries)).toThrow("package.json");
expect(() => releaseNotes("1.2.3", "1.2.3", 'version = "1.2.4"', changelog, summaries)).toThrow("herdr-plugin.toml");
});
test("missing, prefix-matched, duplicate and empty notes cannot be released", () => {
for (const text of ["## [1.2.30]\nWrong", "## [1.2.3]extra\nWrong", "## [1.2.3]\n\n## [1.2.2]\nOld", "## [1.2.3]\nA\n## [1.2.3]\nB"]) {
expect(() => releaseNotes("1.2.3", "1.2.3", manifest, text)).toThrow();
expect(() => releaseNotes("1.2.3", "1.2.3", manifest, text, summaries)).toThrow();
}
});
test("a release that is not told in every language of the app cannot be released", () => {
const { zh: _zh, ...three } = told;
for (const text of ["", "{}", JSON.stringify({ "1.2.2": told }), JSON.stringify({ "1.2.3": three }), JSON.stringify({ "1.2.3": { ...told, ja: { fixed: [" "] } } }), JSON.stringify({ "1.2.3": { ...told, ja: "入力。" } })]) {
expect(() => releaseNotes("1.2.3", "1.2.3", manifest, changelog, text)).toThrow("release-summaries.json");
}
expect(() => releaseNotes("1.2.3", "1.2.3", manifest, changelog, JSON.stringify({ "1.2.3": three }))).toThrow("zh");
});
15 changes: 12 additions & 3 deletions scripts/release-notes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { readFileSync } from "node:fs";
import { releaseSummaries, SUMMARIES_FILE } from "../server/release-notes.ts";
import { SUMMARY_LANGUAGES } from "../shared/update.ts";

/** Fail before publishing a tag: all three version sources and nonempty notes must agree. */
export function releaseNotes(version: string, packageVersion: string, manifest: string, changelog: string): string {
/**
* Fail before publishing a tag: all three version sources and nonempty notes must agree, and the
* release must be told as patch notes in every language of the app (release-summaries.json),
* which is what an install shows of it.
*/
export function releaseNotes(version: string, packageVersion: string, manifest: string, changelog: string, summaries: string): string {
if (version !== version.trim() || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) throw new Error("Expected version X.Y.Z without v");
if (packageVersion !== version) throw new Error("package.json version does not match");
if (!manifest.split(/\r?\n/).some((line) => line === `version = "${version}"`)) throw new Error("herdr-plugin.toml version does not match");
Expand All @@ -10,10 +16,13 @@ export function releaseNotes(version: string, packageVersion: string, manifest:
if (matching.length !== 1) throw new Error("CHANGELOG.md must contain exactly one heading for this version");
const notes = matching[0]!.split("\n").slice(1).filter((line) => !/^\[[^\]]+\]: /.test(line)).join("\n").trim();
if (!notes) throw new Error("Release notes are empty");
const summary = releaseSummaries(summaries).get(version);
const untold = SUMMARY_LANGUAGES.filter((language) => !summary?.[language]);
if (untold.length > 0) throw new Error(`${SUMMARIES_FILE} must tell this version in: ${untold.join(", ")}`);
return notes;
}

if (import.meta.main) {
console.log(releaseNotes(process.argv[2] ?? "", JSON.parse(readFileSync("package.json", "utf8")).version,
readFileSync("herdr-plugin.toml", "utf8"), readFileSync("CHANGELOG.md", "utf8")));
readFileSync("herdr-plugin.toml", "utf8"), readFileSync("CHANGELOG.md", "utf8"), readFileSync(SUMMARIES_FILE, "utf8")));
}
Loading
Loading