mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-21 22:22:24 -05:00
feat: let a release update without notifying users
Versions listed in release-notes/quiet-releases.json, e.g. a hotfix straight after a release, update without a notification. A quiet version still counts as the one that ran, so a restart doesn't announce it, while the version the next announcement's notes start from stays on the last announced one, so they cover the quiet release too. An unreadable list means not quiet.
This commit is contained in:
1 parent
60ee06ba43
commit
aab83df886
4 files changed
+111
-21
No files matched your search
@@ -9,9 +9,12 @@ const CACHE_TTL_MS = 30 * 60 * 1000;
|
||||
// Someone skipping many releases gets the most recent ones, not a wall of every fix since.
|
||||
const MAX_VERSIONS = 5;
|
||||
const LAST_RUN_VERSION_KEY = "last_run_version";
|
||||
// Behind the last run after a quiet release, so the next announcement's notes still cover it.
|
||||
const LAST_ANNOUNCED_VERSION_KEY = "last_announced_version";
|
||||
const SEMVER = /^\d+\.\d+\.\d+$/;
|
||||
|
||||
export const CURATED_NOTES_PATH = "release-notes/latest-release-notes.json";
|
||||
export const QUIET_RELEASES_PATH = "release-notes/quiet-releases.json";
|
||||
|
||||
export type ReleaseNoteKind = "breaking" | "feature" | "improvement" | "fix";
|
||||
const KIND_ORDER: ReleaseNoteKind[] = ["breaking", "feature", "improvement", "fix"];
|
||||
@@ -47,28 +50,61 @@ export function whatsNewPath(version: string, since: string | null): string {
|
||||
return `?${params}`;
|
||||
}
|
||||
|
||||
/** Versions listed in release-notes/quiet-releases.json, e.g. a hotfix straight after a release, update without telling
|
||||
* anyone. Unreadable means not quiet: an update is announced rather than missed. */
|
||||
export async function isQuietRelease(version: string, baseUrl = RELEASE_NOTES_URL): Promise<boolean> {
|
||||
if (!baseUrl) return false;
|
||||
const text = await fetchText(`${baseUrl}/${QUIET_RELEASES_PATH}`);
|
||||
try {
|
||||
const versions = (JSON.parse(text ?? "null") as { versions?: unknown } | null)?.versions;
|
||||
return Array.isArray(versions) && versions.some((v) => typeof v === "string" && v.trim() === version);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function readVersionSetting(key: string): Promise<string | null> {
|
||||
const row = await prisma.setting.findUnique({ where: { key } });
|
||||
return typeof row?.value === "string" && SEMVER.test(row.value) ? row.value : null;
|
||||
}
|
||||
|
||||
function writeVersionSetting(key: string, value: string) {
|
||||
return prisma.setting.upsert({ where: { key }, create: { key, value }, update: { value } });
|
||||
}
|
||||
|
||||
/** At startup: when this version is newer than the one that ran last, every user gets a notification that opens
|
||||
* What's new. A fresh install has no users yet, so only an instance that ran before this check is told. */
|
||||
export async function announceVersionUpdate(version = getBackendVersion(), now = new Date()): Promise<boolean> {
|
||||
* What's new, unless it's a quiet release. A fresh install has no users yet, so only an instance that ran before this
|
||||
* check is told. True when users were notified. */
|
||||
export async function announceVersionUpdate(
|
||||
version = getBackendVersion(),
|
||||
now = new Date(),
|
||||
isQuiet: (version: string) => Promise<boolean> = isQuietRelease,
|
||||
): Promise<boolean> {
|
||||
if (!version || !SEMVER.test(version)) return false;
|
||||
const row = await prisma.setting.findUnique({ where: { key: LAST_RUN_VERSION_KEY } });
|
||||
const previous = typeof row?.value === "string" && SEMVER.test(row.value) ? row.value : null;
|
||||
const previous = await readVersionSetting(LAST_RUN_VERSION_KEY);
|
||||
if (previous === version) return false;
|
||||
const updated = previous ? compareVersions(version, previous) > 0 : (await prisma.user.count()) > 0;
|
||||
const since = (await readVersionSetting(LAST_ANNOUNCED_VERSION_KEY)) ?? previous;
|
||||
|
||||
if (updated && (await isQuiet(version))) {
|
||||
await prisma.$transaction([
|
||||
writeVersionSetting(LAST_RUN_VERSION_KEY, version),
|
||||
...(since ? [writeVersionSetting(LAST_ANNOUNCED_VERSION_KEY, since)] : []),
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
const users = updated ? await prisma.user.findMany({ select: { id: true } }) : [];
|
||||
await prisma.$transaction([
|
||||
prisma.setting.upsert({
|
||||
where: { key: LAST_RUN_VERSION_KEY },
|
||||
create: { key: LAST_RUN_VERSION_KEY, value: version },
|
||||
update: { value: version },
|
||||
}),
|
||||
writeVersionSetting(LAST_RUN_VERSION_KEY, version),
|
||||
writeVersionSetting(LAST_ANNOUNCED_VERSION_KEY, version),
|
||||
prisma.notification.createMany({
|
||||
data: users.map((user) => ({
|
||||
userId: user.id,
|
||||
type: "system",
|
||||
title: `Thanks for updating to Thingport ${version}`,
|
||||
body: "This release brings a lot of new things. Click here to see what's new.",
|
||||
internalPath: whatsNewPath(version, previous),
|
||||
internalPath: whatsNewPath(version, since),
|
||||
createdAt: now,
|
||||
})),
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
announceVersionUpdate,
|
||||
clearReleaseNotesCache,
|
||||
getReleaseNotes,
|
||||
isQuietRelease,
|
||||
notesFromChangelogs,
|
||||
parseChangelog,
|
||||
parseCuratedNotes,
|
||||
@@ -189,6 +190,16 @@ describe("version update notification", () => {
|
||||
const run = Date.now();
|
||||
let user: { id: string; token: string };
|
||||
const updates = () => prisma.notification.findMany({ where: { userId: user.id, type: "system" } });
|
||||
const VERSION_KEYS = ["last_run_version", "last_announced_version"];
|
||||
const ranBefore = (version: string) =>
|
||||
prisma.$transaction(
|
||||
VERSION_KEYS.map((key) =>
|
||||
prisma.setting.upsert({ where: { key }, create: { key, value: version }, update: { value: version } }),
|
||||
),
|
||||
);
|
||||
// Never reaches the network: the quiet list is given.
|
||||
const announce = (version: string, quiet: string[] = []) =>
|
||||
announceVersionUpdate(version, new Date(), async (v) => quiet.includes(v));
|
||||
|
||||
beforeAll(async () => {
|
||||
const email = `release-notes-${run}@example.com`;
|
||||
@@ -203,33 +214,62 @@ describe("version update notification", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await prisma.notification.deleteMany({ where: { userId: user.id } });
|
||||
await prisma.setting.deleteMany({ where: { key: { in: VERSION_KEYS } } });
|
||||
});
|
||||
|
||||
afterEach(clearAnnouncements);
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.setting.deleteMany({ where: { key: "last_run_version" } });
|
||||
await prisma.setting.deleteMany({ where: { key: { in: VERSION_KEYS } } });
|
||||
await prisma.user.deleteMany({ where: { id: user.id } });
|
||||
});
|
||||
|
||||
it("tells users once when a newer version starts, but not on a restart or a downgrade", async () => {
|
||||
await prisma.setting.upsert({
|
||||
where: { key: "last_run_version" },
|
||||
create: { key: "last_run_version", value: "1.1.0" },
|
||||
update: { value: "1.1.0" },
|
||||
});
|
||||
expect(await announceVersionUpdate("1.2.0")).toBe(true);
|
||||
await ranBefore("1.1.0");
|
||||
expect(await announce("1.2.0")).toBe(true);
|
||||
const [notification] = await updates();
|
||||
expect(notification.internalPath).toBe("?whats-new=1.2.0&since=1.1.0");
|
||||
|
||||
expect(await announceVersionUpdate("1.2.0")).toBe(false);
|
||||
expect(await announceVersionUpdate("1.1.5")).toBe(false);
|
||||
expect(await announce("1.2.0")).toBe(false);
|
||||
expect(await announce("1.1.5")).toBe(false);
|
||||
expect(await updates()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("tells existing users when the version that ran before isn't known", async () => {
|
||||
await prisma.setting.deleteMany({ where: { key: "last_run_version" } });
|
||||
expect(await announceVersionUpdate("1.2.0")).toBe(true);
|
||||
expect(await announce("1.2.0")).toBe(true);
|
||||
expect((await updates())[0].internalPath).toBe("?whats-new=1.2.0");
|
||||
});
|
||||
|
||||
it("skips a quiet release, and covers it in the next announcement's notes", async () => {
|
||||
await ranBefore("1.2.0");
|
||||
expect(await announce("1.2.1", ["1.2.1"])).toBe(false);
|
||||
// A restart doesn't announce it even when the quiet list can't be read.
|
||||
expect(await announce("1.2.1")).toBe(false);
|
||||
expect(await updates()).toHaveLength(0);
|
||||
|
||||
expect(await announce("1.3.0")).toBe(true);
|
||||
expect((await updates())[0].internalPath).toBe("?whats-new=1.3.0&since=1.2.0");
|
||||
});
|
||||
|
||||
it("reads quiet releases from the repo file, and treats an unreadable one as not quiet", async () => {
|
||||
let body: string | null = JSON.stringify({ versions: ["1.2.1", 7] });
|
||||
const fetchMock = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockImplementation(async () => new Response(body ?? "", { status: body === null ? 404 : 200 }));
|
||||
try {
|
||||
clearReleaseNotesCache();
|
||||
expect(await isQuietRelease("1.2.1", "https://notes.example")).toBe(true);
|
||||
expect(await isQuietRelease("1.3.0", "https://notes.example")).toBe(false);
|
||||
body = "not json";
|
||||
clearReleaseNotesCache();
|
||||
expect(await isQuietRelease("1.2.1", "https://notes.example")).toBe(false);
|
||||
body = null;
|
||||
clearReleaseNotesCache();
|
||||
expect(await isQuietRelease("1.2.1", "https://notes.example")).toBe(false);
|
||||
expect(await isQuietRelease("1.2.1", "")).toBe(false);
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
clearReleaseNotesCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -300,6 +300,17 @@ To write the notes for a release, replace the file's contents:
|
||||
- A file that doesn't parse, or names another version, falls back to the changelog.
|
||||
- `RELEASE_NOTES_URL` points a backend at another copy of the repo files; an empty value turns fetching off.
|
||||
|
||||
To update without telling anyone, e.g. a hotfix straight after a release, add its version to
|
||||
[`release-notes/quiet-releases.json`](../release-notes/quiet-releases.json) in the same release PR, so it's on `main`
|
||||
before instances update:
|
||||
|
||||
```json
|
||||
{ "versions": ["1.2.1"] }
|
||||
```
|
||||
|
||||
No notification is sent for it, and the next release's What's new also covers what the quiet one changed. If the file
|
||||
can't be read when an instance starts, the update is announced as usual.
|
||||
|
||||
## Related documents
|
||||
|
||||
- [CONTRIBUTING.md](../CONTRIBUTING.md) -- branch, commit and pull request workflow
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"versions": []
|
||||
}
|
||||
Reference in new issue
Block a user