Release notes: a version a reader can check, and a label that survives encoding
Three problems in the /releases page, all of which shipped as "working". 🔴 **The change labels rendered as nothing.** `Change.label` was a computed property, and Swift does not encode computed properties — so Leaf, which renders the ENCODED release, received no `label` at all and every note lost its "New" / "Fixed" / "Changed" prefix in public. Every unit test passed, because each one read `.label` in Swift, where it worked perfectly. It is now a stored property derived on decode, and the new test asserts on the encoded JSON, which is the only thing the template can see. 🔴 **A release named a version that belonged to nothing.** One release is several artifacts with numbers of their own: this repository called a release "0.9" while the Android build in it was 0.10 and iOS had not shipped at all. A reader checks the number in their app against this page, so a number that matches no artifact is worse than no number. `platforms` now carries a version per platform — "android 0.10", or bare "relay", which has no version a user ever sees — and the top-level `version` is gone. 🔴 **Omitting `selfHosting` emptied the whole page.** It has a default and is documented as optional, but Swift's synthesized decoding ignores defaults, so a release without operator notes threw — and `all(in:)` fails open, which turns one missing section into a blank page. Most releases have no operator notes, so the common case was the broken one. The shipped file is also now truthful. It announced Android and iOS changes that nobody can install: published Android is 0.8, the repository is at 0.10 and unbuilt, and iOS is still waiting on Apple. It is a relay release, and it says so. Scripts/covers-android.py answers one question for the Android publish flow — does the changelog name this APK's version — and lives here because this is where the schema is. "Cannot tell" exits 2, never 0: the relay fails open on a bad changelog because that costs a page, but a publish gate that failed open would wave through exactly the build whose notes nobody wrote. 653 tests pass. The page was rendered from a running relay to confirm the labels and tags actually appear, since that is precisely what the unit tests could not see.
This commit is contained in:
@@ -17,9 +17,9 @@
|
||||
#else:
|
||||
#for(release in releases):
|
||||
<section class="release">
|
||||
<h2>#(release.version) <small>— #(release.date)</small></h2>
|
||||
<h2>#(release.date)</h2>
|
||||
<p class="release-platforms">
|
||||
#for(platform in release.platforms):<span class="tag">#(platform)</span> #endfor
|
||||
#for(platform in release.platforms):<span class="tag">#(platform.label)</span> #endfor
|
||||
</p>
|
||||
#if(release.headline):
|
||||
<p class="lede">#(release.headline)</p>
|
||||
|
||||
+16
-11
@@ -1,22 +1,27 @@
|
||||
[
|
||||
{
|
||||
"version": "0.9",
|
||||
"date": "2026-09-05",
|
||||
"platforms": ["relay", "android", "ios"],
|
||||
"headline": "The server can no longer hold a readable message, and a call now tells you when it was refused.",
|
||||
"platforms": [
|
||||
{
|
||||
"name": "relay"
|
||||
}
|
||||
],
|
||||
"headline": "The server can no longer hold a readable message.",
|
||||
"changes": [
|
||||
{ "kind": "changed", "text": "The relay has no route that accepts a readable message, and no table for one to land in. The key it used to hold over those messages is gone with them — so there is nothing on the server for anyone to decrypt, including whoever runs it." },
|
||||
{ "kind": "fixed", "text": "A call that failed because your contact's security code changed said nothing at all — it just sat there. It now tells you what happened and where to review it. Their code changes when they reinstall or switch phones, and until you accept it nothing will send, which is the point." },
|
||||
{ "kind": "fixed", "text": "A group of any size was quietly limited to about five messages an hour. The limit was counting one unit per recipient device rather than per message, so the bigger your group the sooner you were cut off. One-to-one chats were never affected." },
|
||||
{ "kind": "changed", "text": "\"Unsend\" now says \"Delete for me\", on both phones, because that is what it does. It removes the message from your device; the copy on theirs stays. Nothing in the app can reach that yet, and the button no longer implies otherwise." },
|
||||
{ "kind": "changed", "text": "Files no longer asks you to invent a second passphrase. Use your Flamenet one — the same as before if you already set a different one, which still works." },
|
||||
{ "kind": "fixed", "text": "A file inside a folder could not be sent from a conversation, and a folder tree deep enough could return the same file thousands of times." }
|
||||
{
|
||||
"kind": "changed",
|
||||
"text": "The relay has no route that accepts a readable message, and no table for one to land in. The key it used to hold over those messages is gone with them — so there is nothing on the server for anyone to decrypt, including whoever runs it."
|
||||
},
|
||||
{
|
||||
"kind": "fixed",
|
||||
"text": "A group of any size was quietly limited to about five messages an hour. The limit counted one unit per recipient device rather than one per message, so the bigger your group the sooner you were cut off. One-to-one chats were never affected."
|
||||
}
|
||||
],
|
||||
"selfHosting": [
|
||||
"FN_AT_REST_SECRET, FN_MESSAGE_RETENTION_DAYS and FN_READ_MESSAGE_RETENTION_DAYS are no longer read. Leaving them set is harmless; delete them when convenient.",
|
||||
"A migration drops the `messages` table. It was empty on this deployment, and the migration does not preserve rows — check yours before upgrading if you ever enabled the plaintext path.",
|
||||
"A migration drops the messages table. It was empty on this deployment, and the migration does not preserve rows — check yours before upgrading if you ever enabled the plaintext path.",
|
||||
"The send rate limit now counts one unit per message rather than one per recipient device. If you lowered FN_SEND_RATE to compensate for group fan-out, you can raise it back.",
|
||||
"CI now pins the flamenet-e2e commit it checks against, in `.e2e-ref`, so a merge in that repository can no longer turn this one red. Two advisory jobs still run against its HEAD and are allowed to fail."
|
||||
"CI now pins the flamenet-e2e commit it checks against, in a file called .e2e-ref, so a merge in that repository can no longer turn this one red. Two advisory jobs still run against its HEAD and are allowed to fail."
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Does the changelog say anything about this Android build?
|
||||
|
||||
Answers one question for the Android publish flow: is there a release in
|
||||
`Resources/releases.json` that names this APK's versionName?
|
||||
|
||||
🔴 **This is the only place that knows the schema.** The Android repository
|
||||
calls it rather than parsing the JSON itself, so a change to the release format
|
||||
updates the checker in the same commit instead of silently turning a gate in
|
||||
another repository into a rubber stamp.
|
||||
|
||||
⚠️ **"Cannot tell" is not "yes".** A missing or malformed file exits 2, not 0.
|
||||
The relay itself fails open on a bad changelog — a broken file costs a page, not
|
||||
the site — but a publish gate that failed open would wave through exactly the
|
||||
build whose notes nobody wrote.
|
||||
|
||||
covers-android.py <versionName> [releases.json]
|
||||
|
||||
0 a release names this version
|
||||
1 no release names it
|
||||
2 the question could not be answered
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main(argv):
|
||||
if len(argv) < 2:
|
||||
print(__doc__.strip(), file=sys.stderr)
|
||||
return 2
|
||||
wanted = argv[1].strip()
|
||||
if not wanted:
|
||||
print("no version given", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
path = argv[2] if len(argv) > 2 else os.path.join(here, "Resources", "releases.json")
|
||||
|
||||
try:
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
releases = json.load(handle)
|
||||
except FileNotFoundError:
|
||||
print(f"no changelog at {path}", file=sys.stderr)
|
||||
return 2
|
||||
except (json.JSONDecodeError, OSError) as err:
|
||||
print(f"changelog at {path} could not be read: {err}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if not isinstance(releases, list):
|
||||
print(f"changelog at {path} is not a list of releases", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
for release in releases:
|
||||
if not isinstance(release, dict):
|
||||
continue
|
||||
for platform in release.get("platforms") or []:
|
||||
# ⚠️ The old schema had `platforms: ["android"]` with no version.
|
||||
# A bare string cannot name a build, so it never counts as cover —
|
||||
# otherwise an un-migrated file would silently pass every APK.
|
||||
if not isinstance(platform, dict):
|
||||
continue
|
||||
if str(platform.get("name", "")).lower() != "android":
|
||||
continue
|
||||
if str(platform.get("version", "")).strip() == wanted:
|
||||
date = release.get("date", "?")
|
||||
changes = len(release.get("changes") or [])
|
||||
print(f"Android {wanted} is covered by the release dated {date} "
|
||||
f"({changes} note{'' if changes == 1 else 's'}).", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
print(f"No release in {path} names Android {wanted}.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -13,14 +13,18 @@ import Vapor
|
||||
/// the publish script, and a script cannot safely edit HTML. It can append to
|
||||
/// a JSON array.
|
||||
struct Release: Codable, Content {
|
||||
/// What a person sees in the app — "0.9". Not a build number.
|
||||
let version: String
|
||||
/// ISO date, `2026-09-05`. Rendered as written; the page states no timezone
|
||||
/// because a release does not happen at an instant anyone can name.
|
||||
let date: String
|
||||
/// `android`, `ios`, `relay`. Several, because one change usually lands in
|
||||
/// more than one place and a reader should not have to guess which.
|
||||
let platforms: [String]
|
||||
/// Which build of which app this release is.
|
||||
///
|
||||
/// 🔴 **Per-platform versions, because one release is several artifacts
|
||||
/// with numbers of their own.** A single `version` on the release was wrong
|
||||
/// the day it was written: this repository called a release "0.9" while the
|
||||
/// Android build in it was 0.10. A reader checking whether they have a fix
|
||||
/// compares the number in their app against this page, so a number that
|
||||
/// belongs to no artifact is worse than none.
|
||||
let platforms: [Platform]
|
||||
/// One sentence, in the user's language. Optional: not every release has a
|
||||
/// headline, and inventing one is how changelogs start lying.
|
||||
var headline: String?
|
||||
@@ -30,17 +34,110 @@ struct Release: Codable, Content {
|
||||
/// release teaches people to skip the ones that matter.
|
||||
var selfHosting: [String] = []
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case date, platforms, headline, changes, selfHosting
|
||||
}
|
||||
|
||||
init(date: String, platforms: [Platform], headline: String? = nil,
|
||||
changes: [Change], selfHosting: [String] = []) {
|
||||
self.date = date
|
||||
self.platforms = platforms
|
||||
self.headline = headline
|
||||
self.changes = changes
|
||||
self.selfHosting = selfHosting
|
||||
}
|
||||
|
||||
/// 🔴 **Written out because Swift's synthesized decoding ignores default
|
||||
/// values.** `selfHosting` reads as optional and is documented as optional,
|
||||
/// but with the synthesized initialiser a release that omitted it threw —
|
||||
/// and `all(in:)` fails open, so ONE release without operator notes emptied
|
||||
/// the entire page rather than losing one section. Most releases have no
|
||||
/// operator notes, so the common case was the broken one.
|
||||
init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
date = try c.decode(String.self, forKey: .date)
|
||||
platforms = try c.decode([Platform].self, forKey: .platforms)
|
||||
headline = try c.decodeIfPresent(String.self, forKey: .headline)
|
||||
changes = try c.decode([Change].self, forKey: .changes)
|
||||
selfHosting = try c.decodeIfPresent([String].self, forKey: .selfHosting) ?? []
|
||||
}
|
||||
|
||||
/// The version of the APK this release corresponds to, if it touched
|
||||
/// Android. What the publish gate matches against.
|
||||
var androidVersion: String? {
|
||||
platforms.first { $0.name.lowercased() == "android" }?.version
|
||||
}
|
||||
|
||||
struct Platform: Codable, Content {
|
||||
/// `android`, `ios`, `relay`.
|
||||
let name: String
|
||||
/// The number a person can check against their own app — "0.10".
|
||||
/// Absent for the relay, which has no version a user ever sees.
|
||||
var version: String?
|
||||
|
||||
/// 🔴 **Stored, not computed.** See `Change.label`.
|
||||
var label: String = ""
|
||||
|
||||
enum CodingKeys: String, CodingKey { case name, version, label }
|
||||
|
||||
init(name: String, version: String? = nil) {
|
||||
self.name = name
|
||||
self.version = version
|
||||
self.label = Self.label(name: name, version: version)
|
||||
}
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
name = try c.decode(String.self, forKey: .name)
|
||||
version = try c.decodeIfPresent(String.self, forKey: .version)
|
||||
label = Self.label(name: name, version: version)
|
||||
}
|
||||
|
||||
static func label(name: String, version: String?) -> String {
|
||||
guard let version, !version.isEmpty else { return name }
|
||||
return "\(name) \(version)"
|
||||
}
|
||||
}
|
||||
|
||||
struct Change: Codable, Content {
|
||||
/// `new`, `changed`, `fixed`. Lowercase on the wire, capitalised by the
|
||||
/// template — Leaf has no string functions.
|
||||
/// `new`, `changed`, `fixed`. Lowercase on the wire, capitalised for
|
||||
/// display — Leaf has no string functions.
|
||||
let kind: String
|
||||
let text: String
|
||||
|
||||
/// What the page prints before the note.
|
||||
///
|
||||
/// 🔴 **A STORED property, derived on decode, and that is not a style
|
||||
/// choice.** Leaf renders the ENCODED object, and Swift does not encode
|
||||
/// computed properties — so as a `var label: String { ... }` this
|
||||
/// rendered as nothing at all, silently stripping "New" / "Fixed" /
|
||||
/// "Changed" from every note in public. Unit tests that read `.label`
|
||||
/// in Swift all passed, because in Swift it worked.
|
||||
///
|
||||
/// ⚠️ Never decoded from the file. The file says `kind`; the label is
|
||||
/// derived from it, so the two cannot disagree.
|
||||
var label: String = ""
|
||||
|
||||
enum CodingKeys: String, CodingKey { case kind, text, label }
|
||||
|
||||
init(kind: String, text: String) {
|
||||
self.kind = kind
|
||||
self.text = text
|
||||
self.label = Self.label(for: kind)
|
||||
}
|
||||
|
||||
init(from decoder: any Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
kind = try c.decode(String.self, forKey: .kind)
|
||||
text = try c.decode(String.self, forKey: .text)
|
||||
label = Self.label(for: kind)
|
||||
}
|
||||
|
||||
/// ⚠️ Anything unrecognised renders as "Changed" rather than being
|
||||
/// dropped. A release note that vanishes because somebody typed
|
||||
/// `fix` instead of `fixed` is worse than one filed under the wrong
|
||||
/// heading.
|
||||
var label: String {
|
||||
static func label(for kind: String) -> String {
|
||||
switch kind.lowercased() {
|
||||
case "new": return "New"
|
||||
case "fixed": return "Fixed"
|
||||
|
||||
@@ -15,8 +15,9 @@ struct ReleaseNotesTests {
|
||||
|
||||
private func releases(_ n: Int) -> [Release] {
|
||||
(0..<n).map {
|
||||
Release(version: "0.\($0)", date: "2026-01-0\($0 % 9 + 1)",
|
||||
platforms: ["android"], headline: nil,
|
||||
Release(date: "2026-01-0\($0 % 9 + 1)",
|
||||
platforms: [.init(name: "android", version: "0.\($0)")],
|
||||
headline: nil,
|
||||
changes: [.init(kind: "fixed", text: "thing \($0)")])
|
||||
}
|
||||
}
|
||||
@@ -51,7 +52,7 @@ struct ReleaseNotesTests {
|
||||
let first = ReleaseNotes.page(1, of: all)
|
||||
#expect(first.items.count == ReleaseNotes.perPage)
|
||||
#expect(first.pages == 3)
|
||||
#expect(first.items.first?.version == all.first?.version,
|
||||
#expect(first.items.first?.changes.first?.text == all.first?.changes.first?.text,
|
||||
"the page reordered the releases — the file's order IS the order")
|
||||
}
|
||||
|
||||
@@ -110,9 +111,83 @@ struct ReleaseNotesTests {
|
||||
in: DirectoryConfiguration(workingDirectory: root.path + "/"))
|
||||
#expect(!parsed.isEmpty, "Resources/releases.json is missing or does not parse")
|
||||
for release in parsed {
|
||||
#expect(!release.version.isEmpty)
|
||||
#expect(!release.date.isEmpty)
|
||||
#expect(!release.platforms.isEmpty,
|
||||
"release \(release.date) names no platform, so nobody knows what it is a release OF")
|
||||
#expect(!release.changes.isEmpty,
|
||||
"release \(release.version) lists no changes, so it says nothing")
|
||||
"release \(release.date) lists no changes, so it says nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - What the template actually receives
|
||||
|
||||
/// 🔴 **The page renders the ENCODED release, not the Swift value.** Swift
|
||||
/// does not encode computed properties, so `label` as a `var { ... }`
|
||||
/// reached Leaf as nothing at all and every note lost its "New" / "Fixed" /
|
||||
/// "Changed" prefix in public — while every unit test above still passed,
|
||||
/// because in Swift the property worked perfectly.
|
||||
///
|
||||
/// ⚠️ Assert on the JSON, which is the only thing Leaf can see.
|
||||
@Test("the label the page prints survives encoding")
|
||||
func labelSurvivesEncoding() throws {
|
||||
let release = Release(
|
||||
date: "2026-09-05",
|
||||
platforms: [.init(name: "android", version: "0.10"), .init(name: "relay")],
|
||||
headline: nil,
|
||||
changes: [.init(kind: "fixed", text: "x")])
|
||||
let json = try JSONSerialization.jsonObject(
|
||||
with: try JSONEncoder().encode(release)) as? [String: Any]
|
||||
|
||||
let changes = json?["changes"] as? [[String: Any]]
|
||||
#expect(changes?.first?["label"] as? String == "Fixed",
|
||||
"the change label is missing from the encoded release, so the page prints nothing")
|
||||
|
||||
let platforms = json?["platforms"] as? [[String: Any]]
|
||||
#expect(platforms?.first?["label"] as? String == "android 0.10",
|
||||
"the platform tag is missing its version in the encoded release")
|
||||
#expect(platforms?.last?["label"] as? String == "relay",
|
||||
"a platform with no version should be its bare name, not a dangling one")
|
||||
}
|
||||
|
||||
/// The label is derived from `kind`, never read from the file — so a file
|
||||
/// that supplies a contradictory one cannot make the page disagree with
|
||||
/// itself.
|
||||
@Test("a label written into the file is ignored, not trusted")
|
||||
func labelIsNotTakenFromTheFile() throws {
|
||||
let data = Data("""
|
||||
[{"date":"2026-01-01","platforms":[{"name":"relay"}],
|
||||
"changes":[{"kind":"fixed","text":"x","label":"Totally New"}]}]
|
||||
""".utf8)
|
||||
let parsed = try JSONDecoder().decode([Release].self, from: data)
|
||||
#expect(parsed.first?.changes.first?.label == "Fixed")
|
||||
}
|
||||
|
||||
/// 🔴 **Most releases have no operator notes, so this IS the common case.**
|
||||
/// `selfHosting` has a default and is documented as optional, but Swift's
|
||||
/// synthesized decoding ignores defaults — so a release that omitted it
|
||||
/// threw, and because `all(in:)` fails open the whole page went blank
|
||||
/// rather than one section going missing.
|
||||
@Test("a release with no operator notes still decodes")
|
||||
func selfHostingIsOptional() throws {
|
||||
let data = Data("""
|
||||
[{"date":"2026-01-01","platforms":[{"name":"ios","version":"0.1"}],
|
||||
"changes":[{"kind":"new","text":"x"}]}]
|
||||
""".utf8)
|
||||
let parsed = try JSONDecoder().decode([Release].self, from: data)
|
||||
#expect(parsed.count == 1, "a release without operator notes emptied the page")
|
||||
#expect(parsed.first?.selfHosting.isEmpty == true)
|
||||
}
|
||||
|
||||
/// 🔴 The gate on the Android publish flow reads this. A release that
|
||||
/// touched Android without naming the build it shipped in cannot be checked
|
||||
/// against an APK, and the gate would wave every build through.
|
||||
@Test("an android release reports the build it belongs to")
|
||||
func androidVersionIsReadable() {
|
||||
let r = Release(date: "d", platforms: [.init(name: "android", version: "0.10")],
|
||||
headline: nil, changes: [.init(kind: "new", text: "x")])
|
||||
#expect(r.androidVersion == "0.10")
|
||||
#expect(Release(date: "d", platforms: [.init(name: "relay")],
|
||||
headline: nil,
|
||||
changes: [.init(kind: "new", text: "x")]).androidVersion == nil)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user