fix(release): publish real notes, and stop shipping old releases' installers
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 15m1s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 42s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 10s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m6s
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 15m1s
🏗️ Build and Test JellyTau / Supply Chain (pull_request) Successful in 42s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 10s
🏗️ Build and Test JellyTau / Android Compile Check (pull_request) Successful in 4m6s
Two defects found while preparing v0.9.2, both of which had been shipping for months without anything to notice them by. **Every release note was the same 1,050 bytes.** All 35 releases from v0.0.1 to v0.9.1 published identical generic install instructions whose "What's New" section read "See CHANGELOG.md" -- a link that does not resolve from a release page. A reader learned nothing about what changed in any release the project has ever made. The body now comes from the `## <version>` section of CHANGELOG.md, and a missing section fails the release: notes that say nothing are worse than a build that waits for a maintainer to write two sentences. The 35 published bodies have been backfilled from the changelog via the tea CLI. This also corrects something introduced two commits ago. That change generated the body from `bun run release:notes`, which CLAUDE.md is explicit about -- its output is "a reviewed draft, not a final changelog". Publishing it unreviewed proved the point immediately: the v0.9.1..HEAD range contains a repo-wide prettier sweep, so every file in src/ counted as changed, their TRACES resolved to nearly the whole matrix, and the draft claimed the release had added the entire application. The script now skips cosmetic commits (chore(format), chore(deps), style) and reports how many rather than silently returning a smaller set, but it stays a local drafting tool. **Every release from v0.1.0 to v0.8.2 shipped every Windows installer ever built.** src-tauri/target/*/release/bundle/ is not versioned, cargo never cleans it, and the runner reuses the target directory -- so the copy step's bundle/**/*-setup.exe glob collected the lot. v0.8.2 carried sixteen installers, thirteen of them stale; v0.5.0 offered users a download list going back to 0.1.0. Eight months, and nothing to notice it by: the upload loop reported success, the files were real, and the page looked busy rather than wrong. It stopped only because an unrelated cargo cache change wiped the runner's target dir, so it was dormant, not fixed. Both desktop builds now remove the bundle directory before building, so a stale file cannot exist to be copied. Filtering the copy by version would have hidden it instead. The Linux job gets the same treatment: it was never hit only because Linux packaging is newer, and the glob is identical. scripts/check-release-artifacts.sh is the backstop for whatever reintroduces one by a route nobody predicted. It runs before the SBOM, the checksums and the upload -- all of which describe the file set, so a stale artifact has to be caught before it is hashed and published as part of the release. Verified against a reconstruction of the real v0.8.2 accumulation. DR-219, DR-220, UT-210.
This commit is contained in:
@@ -146,6 +146,15 @@ jobs:
|
|||||||
# If TAURI_SIGNING_PRIVATE_KEY is ever absent the build fails loudly rather
|
# If TAURI_SIGNING_PRIVATE_KEY is ever absent the build fails loudly rather
|
||||||
# than quietly shipping an unsigned release that no client will accept --
|
# than quietly shipping an unsigned release that no client will accept --
|
||||||
# which is the behaviour we want.
|
# which is the behaviour we want.
|
||||||
|
# Same hazard as the Windows job: the bundle directory is never cleaned by
|
||||||
|
# cargo and the runner reuses src-tauri/target, while the copy step below
|
||||||
|
# globs bundle/deb/*.deb and friends. Windows is where this actually bit
|
||||||
|
# (v0.8.2 shipped thirteen stale installers), but only because Linux
|
||||||
|
# packaging is newer -- the glob is identical. Remove the directory so a
|
||||||
|
# stale artifact cannot exist to be copied.
|
||||||
|
- name: Clear previous bundle output
|
||||||
|
run: rm -rf src-tauri/target/release/bundle
|
||||||
|
|
||||||
- name: Build for Linux
|
- name: Build for Linux
|
||||||
run: bun run tauri build
|
run: bun run tauri build
|
||||||
env:
|
env:
|
||||||
@@ -413,6 +422,19 @@ jobs:
|
|||||||
name: jellytau-android
|
name: jellytau-android
|
||||||
path: artifacts/android/
|
path: artifacts/android/
|
||||||
|
|
||||||
|
# Runs before the SBOM, the checksums and the upload -- everything
|
||||||
|
# downstream describes this set of files, so a stale artifact must be
|
||||||
|
# caught before it gets hashed into SHA256SUMS and published as though it
|
||||||
|
# belonged to this release.
|
||||||
|
#
|
||||||
|
# See the script for the eight months of releases that shipped their
|
||||||
|
# predecessors' Windows installers.
|
||||||
|
- name: Verify artifacts belong to this release
|
||||||
|
run: |
|
||||||
|
./scripts/check-release-artifacts.sh \
|
||||||
|
"${{ steps.tag_name.outputs.VERSION }}" \
|
||||||
|
artifacts/linux artifacts/windows artifacts/android
|
||||||
|
|
||||||
# Software Bill of Materials, one per half of the app. Without it there is
|
# Software Bill of Materials, one per half of the app. Without it there is
|
||||||
# no answer to "does this release contain <vulnerable crate>?" other than
|
# no answer to "does this release contain <vulnerable crate>?" other than
|
||||||
# rebuilding the tag and re-resolving it. cargo-cyclonedx is in the builder
|
# rebuilding the tag and re-resolving it. cargo-cyclonedx is in the builder
|
||||||
@@ -482,9 +504,11 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Release notes for the update prompt come from the traceability graph,
|
# What the in-app update prompt shows. Same reviewed source as the
|
||||||
# same source as the release body.
|
# release body -- the CHANGELOG section for this version, not the
|
||||||
NOTES="$(bun run release:notes 2>/dev/null | head -c 4000 || echo "See the release page for details.")"
|
# traceability draft.
|
||||||
|
NOTES="$(awk -v ver="## $VERSION" '$0==ver{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md | head -c 4000)"
|
||||||
|
[ -n "$NOTES" ] || NOTES="See the release page for details."
|
||||||
|
|
||||||
jq -n \
|
jq -n \
|
||||||
--arg version "$PLAIN" \
|
--arg version "$PLAIN" \
|
||||||
@@ -549,24 +573,37 @@ jobs:
|
|||||||
# release rather than shipping and failing for users.
|
# release rather than shipping and failing for users.
|
||||||
sha256sum -c SHA256SUMS
|
sha256sum -c SHA256SUMS
|
||||||
|
|
||||||
# Release notes come from the traceability graph, not from a hardcoded
|
# The published body is the hand-written CHANGELOG.md section for this
|
||||||
# heredoc. scripts/release-notes.ts resolves the commit range's changed
|
# version. `bun run release:notes` is printed into the job log as a
|
||||||
# files to their TRACES ids and then to requirement descriptions, grouping
|
# drafting aid, but is NOT published: CLAUDE.md is explicit that its
|
||||||
# UR into Features and DR/IR into Improvements -- which is what CLAUDE.md
|
# output is "a reviewed draft, not a final changelog", and publishing it
|
||||||
# has asked for all along, while this workflow pasted a fixed block of
|
# unreviewed proved the point -- a range containing a repo-wide prettier
|
||||||
# install instructions and a line saying "see CHANGELOG.md for detailed
|
# sweep resolved to nearly the whole requirement matrix and produced notes
|
||||||
# changes". It also linked "GitHub Issues" on a Gitea-hosted project.
|
# claiming one release had added the entire application.
|
||||||
|
#
|
||||||
|
# A missing CHANGELOG section fails the release. A release whose notes say
|
||||||
|
# nothing is worse than one that waits for a maintainer to write two
|
||||||
|
# sentences, and the checklist already requires that entry.
|
||||||
- name: Prepare release notes
|
- name: Prepare release notes
|
||||||
id: release_notes
|
id: release_notes
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
VERSION="${{ steps.tag_name.outputs.VERSION }}"
|
||||||
|
|
||||||
|
echo "📋 Traceability draft (for reference; not published):"
|
||||||
|
bun run release:notes 2>/dev/null || echo "(could not derive a draft)"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# The section between this version's heading and the next one.
|
||||||
|
CHANGES=$(awk -v ver="## $VERSION" '$0==ver{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md)
|
||||||
|
if [ -z "$(echo "$CHANGES" | tr -d '[:space:]')" ]; then
|
||||||
|
echo "::error::CHANGELOG.md has no '## $VERSION' section."
|
||||||
|
echo "::error::Add the entry for this version and re-tag; see docs/release-checklist.md."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "## JellyTau $VERSION"
|
echo "$CHANGES"
|
||||||
echo ""
|
|
||||||
# A generated summary of what actually changed; falls back to a
|
|
||||||
# pointer rather than failing the release if the range is odd.
|
|
||||||
bun run release:notes 2>/dev/null || echo "See the commit log for changes in this release."
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "### Downloads"
|
echo "### Downloads"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -578,8 +615,8 @@ jobs:
|
|||||||
echo "| Windows | \`*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run. |"
|
echo "| Windows | \`*-setup.exe\` (NSIS). Unsigned — SmartScreen may warn on first run. |"
|
||||||
echo "| Android | \`*.apk\` sideload, or \`*.aab\` for Play Console |"
|
echo "| Android | \`*.apk\` sideload, or \`*.aab\` for Play Console |"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Desktop builds update themselves from here on: JellyTau checks this"
|
echo "Desktop builds check for updates from here and can install a new"
|
||||||
echo "release feed and can install a new version in place."
|
echo "version in place, verifying its signature first."
|
||||||
echo ""
|
echo ""
|
||||||
echo "### Verifying your download"
|
echo "### Verifying your download"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -599,6 +636,7 @@ jobs:
|
|||||||
echo "---"
|
echo "---"
|
||||||
echo "Report a problem: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/issues"
|
echo "Report a problem: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/issues"
|
||||||
} > release_notes.md
|
} > release_notes.md
|
||||||
|
|
||||||
echo "📝 Release notes:"
|
echo "📝 Release notes:"
|
||||||
cat release_notes.md
|
cat release_notes.md
|
||||||
|
|
||||||
|
|||||||
@@ -410,6 +410,8 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-216 | Dependencies are gated on known vulnerabilities and on licence compatibility, and the build graph is pinned to what is actually shipped. The project had no scanning of any kind: nothing checked the ~500-crate Rust graph or the JS packages against an advisory feed, and nothing checked that everything redistributed inside an MIT-licensed bundle permits it. The first run found eight vulnerabilities and one unsoundness — `bytes`, four in `rustls-webpki`, `time`, two in `quick-xml`, `rand` — every one closed by a `cargo update` nobody had reason to run. `cargo deny` (src-tauri/deny.toml) now runs in CI over advisories, licences, bans and sources. Two structural fixes matter as much as the gate: the graph is scoped to the targets actually shipped, so an advisory against an Apple-only path is correctly absent rather than ignored by ID; and the one git dependency (`libmpv`) is pinned by revision instead of by branch, since a branch means any `cargo update` silently substitutes new upstream code in the one dependency that is unsigned and links a C library into the player. Licence findings are recorded rather than waved through — `libmpv`/`libmpv-sys` are LGPL-2.1, which the app satisfies by dynamic linking, and that carries obligations (keep the linkage dynamic; ship libmpv's licence text with any bundle carrying the .so) | Tooling | - | Done |
|
| DR-216 | Dependencies are gated on known vulnerabilities and on licence compatibility, and the build graph is pinned to what is actually shipped. The project had no scanning of any kind: nothing checked the ~500-crate Rust graph or the JS packages against an advisory feed, and nothing checked that everything redistributed inside an MIT-licensed bundle permits it. The first run found eight vulnerabilities and one unsoundness — `bytes`, four in `rustls-webpki`, `time`, two in `quick-xml`, `rand` — every one closed by a `cargo update` nobody had reason to run. `cargo deny` (src-tauri/deny.toml) now runs in CI over advisories, licences, bans and sources. Two structural fixes matter as much as the gate: the graph is scoped to the targets actually shipped, so an advisory against an Apple-only path is correctly absent rather than ignored by ID; and the one git dependency (`libmpv`) is pinned by revision instead of by branch, since a branch means any `cargo update` silently substitutes new upstream code in the one dependency that is unsigned and links a C library into the player. Licence findings are recorded rather than waved through — `libmpv`/`libmpv-sys` are LGPL-2.1, which the app satisfies by dynamic linking, and that carries obligations (keep the linkage dynamic; ship libmpv's licence text with any bundle carrying the .so) | Tooling | - | Done |
|
||||||
| DR-217 | In-app update, desktop only, over a manifest we control. `tauri-plugin-updater` and `tauri-plugin-process` are compiled for everything except Android/iOS — spelled as a target-triple cfg rather than `cfg(desktop)`, which Cargo does not evaluate in a `[target.'cfg(…)']` table and which therefore drops the dependency silently, surfacing much later as "Permission updater:default not found". The release workflow signs updater artifacts with a minisign key held in Gitea secrets and publishes `latest.json` to a dedicated `updater` branch, read over Gitea's raw-file URL: this instance serves `/releases/download/<tag>/<asset>` but returns 404 for `/releases/latest/download/<asset>`, so there is no stable latest-release URL to point at, and the docs branch is force-pushed by publish-docs.yml so it cannot host the manifest either. Bundle targets gain `appimage`, which the release notes had been advertising for months while `tauri.conf.json` never built it — the artifact step globbed for `*.AppImage`, found nothing, and said nothing | Tooling | UR-077 | Done |
|
| DR-217 | In-app update, desktop only, over a manifest we control. `tauri-plugin-updater` and `tauri-plugin-process` are compiled for everything except Android/iOS — spelled as a target-triple cfg rather than `cfg(desktop)`, which Cargo does not evaluate in a `[target.'cfg(…)']` table and which therefore drops the dependency silently, surfacing much later as "Permission updater:default not found". The release workflow signs updater artifacts with a minisign key held in Gitea secrets and publishes `latest.json` to a dedicated `updater` branch, read over Gitea's raw-file URL: this instance serves `/releases/download/<tag>/<asset>` but returns 404 for `/releases/latest/download/<asset>`, so there is no stable latest-release URL to point at, and the docs branch is force-pushed by publish-docs.yml so it cannot host the manifest either. Bundle targets gain `appimage`, which the release notes had been advertising for months while `tauri.conf.json` never built it — the artifact step globbed for `*.AppImage`, found nothing, and said nothing | Tooling | UR-077 | Done |
|
||||||
| DR-218 | Persistent, redacted logging and a diagnostics export. `tauri-plugin-log` replaces the `env_logger` stdout-only init, giving a rotating 5 MB file, a webview target in dev, and — the single largest gain — logcat on Android, where `env_logger`'s stdout went nowhere. **Redaction runs in the log formatter, not at export**: a credential in a file on the device is already a disclosure, so stripping it on the way out would be too late; the exporter redacts a second time to cover files written by older builds. `api_key`/`X-Emby-Token`/`Authorization`/`"AccessToken"`/`Token="…"` all reduce to `[REDACTED]` while host, item ids and filenames are deliberately kept — a bundle scrubbed of those is one nobody can debug from. The server URL is reduced to scheme and host, dropping any embedded `user:pass@`. The panic hook chains to the previous hook rather than replacing it, because `utils/lock.rs` installs a silencing hook around tests that provoke poisoned locks on purpose. The chosen level persists to disk and is re-applied at startup, since reproducing a bug usually means restarting into it. The frontend facade keeps its untouched `console.*` pass-through (DR-204) and additionally forwards a stringified copy at info and above, so one file holds both halves of the app in order — which is what makes a race between them legible after the fact | Tooling | UR-078 | Done |
|
| DR-218 | Persistent, redacted logging and a diagnostics export. `tauri-plugin-log` replaces the `env_logger` stdout-only init, giving a rotating 5 MB file, a webview target in dev, and — the single largest gain — logcat on Android, where `env_logger`'s stdout went nowhere. **Redaction runs in the log formatter, not at export**: a credential in a file on the device is already a disclosure, so stripping it on the way out would be too late; the exporter redacts a second time to cover files written by older builds. `api_key`/`X-Emby-Token`/`Authorization`/`"AccessToken"`/`Token="…"` all reduce to `[REDACTED]` while host, item ids and filenames are deliberately kept — a bundle scrubbed of those is one nobody can debug from. The server URL is reduced to scheme and host, dropping any embedded `user:pass@`. The panic hook chains to the previous hook rather than replacing it, because `utils/lock.rs` installs a silencing hook around tests that provoke poisoned locks on purpose. The chosen level persists to disk and is re-applied at startup, since reproducing a bug usually means restarting into it. The frontend facade keeps its untouched `console.*` pass-through (DR-204) and additionally forwards a stringified copy at info and above, so one file holds both halves of the app in order — which is what makes a race between them legible after the fact | Tooling | UR-078 | Done |
|
||||||
|
| DR-219 | Release notes are the reviewed CHANGELOG entry, not a generated draft. Every release from v0.0.1 to v0.9.1 published the same ~1,050 bytes of generic install instructions whose "What's New" section said "See CHANGELOG.md" — a link that does not resolve from a release page. Thirty-five releases, byte-identical, telling a reader nothing about what changed. The workflow now publishes the `## <version>` section of CHANGELOG.md and fails the release if that section is absent, since notes that say nothing are worse than a build that waits for two sentences. `release:notes` is printed into the job log as a drafting aid but is deliberately *not* published: CLAUDE.md calls its output "a reviewed draft, not a final changelog", and publishing it unreviewed proved why — a range containing a repo-wide formatting sweep resolved to nearly the entire requirement matrix and produced notes claiming one release had added the whole application. The script now skips cosmetic commits (`chore(format)`, `chore(deps)`, `style`) when deriving a range's files, and says how many it skipped rather than silently reporting a smaller set | Tooling | - | Done |
|
||||||
|
| DR-220 | A release ships only its own artifacts. `src-tauri/target/*/release/bundle/` is not versioned, cargo never cleans it, and the CI runner reuses the target directory — so the copy step's `bundle/**/*-setup.exe` glob collected every installer ever built there. Every release from v0.1.0 to v0.8.2 shipped its predecessors': sixteen Windows installers on v0.8.2, thirteen of them stale, and a download list on v0.5.0 reaching back to 0.1.0. It went unnoticed for eight months because there was nothing to notice — the upload loop reported success, the files were real, and the page looked busy rather than wrong. It stopped only when an unrelated cache change wiped the runner's target dir, leaving the defect dormant rather than fixed. Both desktop builds now clear the bundle directory first, so a stale file cannot exist to be copied — filtering the copy by version would have hidden it instead. `scripts/check-release-artifacts.sh` is the backstop for the next route nobody predicts: it runs before the SBOM, the checksums and the upload, and refuses to publish when any artifact's embedded version disagrees with the tag | Tooling | - | Done |
|
||||||
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
| DR-198 | The webview runs under a real Content-Security-Policy, and the asset protocol is scoped to the one directory it still serves. `csp` was `null`, which disables CSP entirely: any script that reached the web layer — through a future `{@html}`, a dependency, or a devtools paste — would have inherited the whole IPC surface, and with it the user's session. `script-src 'self'` (Tauri injects a nonce for SvelteKit's inline bootstrap script at build time, so no `'unsafe-inline'` is needed) plus `object-src`/`frame-src 'none'` and `base-uri 'self'` is the part that is genuinely restrictive. `img-src`/`media-src`/`connect-src` cannot be: the Jellyfin origin is typed in by the user at run time and is commonly plain `http` on a LAN, so they allow `http:`/`https:` — a wide grant for *data*, but one that still bars `file:`, `filesystem:` and scripting schemes, and leaves `script-src` untouched. `style-src` keeps `'unsafe-inline'` because Svelte compiles `style="…"` attributes (including `app.html`'s `display: contents` wrapper) into markup; this is safe only while no `<style>` element survives into `index.html`, since a nonce there would make Tauri's injection outrank — and therefore void — `'unsafe-inline'`. `worker-src blob:` and `media-src blob:` are hls.js: it demuxes in a worker built from a blob and attaches MSE through `URL.createObjectURL`. `asset:` and `http://asset.localhost` are the same protocol under the two naming schemes `convertFileSrc` emits (custom scheme on Linux/macOS, `http` host on Windows/Android); `ipc:`/`http://ipc.localhost` is the invoke transport, which would otherwise be blocked by `connect-src`. A run-time CSP naming the server origin exactly was rejected: Tauri computes the header from immutable config when it serves the HTML, so it would mean rebuilding config and reloading the webview on every server change, for a policy the user can already point anywhere. The asset-protocol scope narrows from `$APPDATA/**` to `$APPDATA/thumbnails/**` — since DR-137 moved downloaded media to the loopback server, `imageCache` is the only `convertFileSrc` caller left, so the database and the encrypted-token fallback file no longer sit inside the grant | Security | UR-012, UR-071 | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -709,6 +711,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-207 | The hero banner's rotation timer restarts from the moment of a manual change: a swipe 5.5s into a 6s interval waits a further 6s instead of firing the leftover 500ms, repeated restarts never stack timers, and `stop()` ends rotation | DR-038 | Done |
|
| UT-207 | The hero banner's rotation timer restarts from the moment of a manual change: a swipe 5.5s into a 6s interval waits a further 6s instead of firing the leftover 500ms, repeated restarts never stack timers, and `stop()` ends rotation | DR-038 | Done |
|
||||||
| UT-208 | The update decision: each numeric version field is compared in order, the installed version is not offered to itself, a leading `v` is tolerated because that is how the tags are written, a pre-release sorts below the release of the same number so 0.9.2-rc1 is not offered to somebody on 0.9.2, a missing patch field reads as zero rather than NaN, mobile reports link-only while desktop reports install, and absent release notes normalise to null rather than undefined | DR-217 | Done |
|
| UT-208 | The update decision: each numeric version field is compared in order, the installed version is not offered to itself, a leading `v` is tolerated because that is how the tags are written, a pre-release sorts below the release of the same number so 0.9.2-rc1 is not offered to somebody on 0.9.2, a missing patch field reads as zero rather than NaN, mobile reports link-only while desktop reports install, and absent release notes normalise to null rather than undefined | DR-217 | Done |
|
||||||
| UT-209 | Redaction and forwarding. Rust: every credential shape reduces to `[REDACTED]` while the host, username and neighbouring parameters survive; redaction is idempotent, leaves ordinary lines alone, does not fire on the word "token" in prose, and does not panic on multi-byte input; a server URL keeps only scheme and host and drops an embedded `user:pass@`; an unparseable level falls back to info rather than failing at startup. Frontend: info and above forward while debug does not, a message the level filter suppressed is not forwarded, a throwing forwarder neither propagates nor prevents the console write, and an `Error` renders as name and message rather than the `{}` that `JSON.stringify` produces | DR-218 | Done |
|
| UT-209 | Redaction and forwarding. Rust: every credential shape reduces to `[REDACTED]` while the host, username and neighbouring parameters survive; redaction is idempotent, leaves ordinary lines alone, does not fire on the word "token" in prose, and does not panic on multi-byte input; a server URL keeps only scheme and host and drops an embedded `user:pass@`; an unparseable level falls back to info rather than failing at startup. Frontend: info and above forward while debug does not, a message the level filter suppressed is not forwarded, a throwing forwarder neither propagates nor prevents the console write, and an `Error` renders as name and message rather than the `{}` that `JSON.stringify` produces | DR-218 | Done |
|
||||||
|
| UT-210 | Cosmetic-commit detection for release notes: a `chore(format)`, `chore(deps)` or `style` subject is skipped when deriving a range's changed files, while `fix`, `feat`, `ci`, `docs`, a bare `chore:` and `chore(release):` are kept; and the word "format" appearing later in a subject ("fix(duration): format times over 24 hours") does not make a real fix look cosmetic | DR-219 | Done |
|
||||||
|
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ know how something *works*, read
|
|||||||
|
|
||||||
**Next free requirement ids** (always re-check
|
**Next free requirement ids** (always re-check
|
||||||
[requirements.md](../requirements.md) before allocating): **UR-079**,
|
[requirements.md](../requirements.md) before allocating): **UR-079**,
|
||||||
**IR-033**, **DR-219**. Three specs below suggested ids that have since been
|
**IR-033**, **DR-221**. Three specs below suggested ids that have since been
|
||||||
taken by other work; each carries a ⚠️ note at the top.
|
taken by other work; each carries a ⚠️ note at the top.
|
||||||
|
|
||||||
## Partially implemented
|
## Partially implemented
|
||||||
|
|||||||
@@ -46,6 +46,26 @@ bun run build
|
|||||||
# from tauri.conf.json (bundle.targets includes "nsis"), which is not subject to
|
# from tauri.conf.json (bundle.targets includes "nsis"), which is not subject to
|
||||||
# that CLI validation — the bundler then picks nsis once it knows the target is
|
# that CLI validation — the bundler then picks nsis once it knows the target is
|
||||||
# Windows.
|
# Windows.
|
||||||
|
# 🔴 Clear the bundle output before building.
|
||||||
|
#
|
||||||
|
# The bundle directory is not versioned and is never cleaned by cargo, and the
|
||||||
|
# CI runner reuses src-tauri/target between builds. The copy step below globs
|
||||||
|
# `bundle/**/*-setup.exe`, so every stale installer left there was picked up and
|
||||||
|
# attached to the release: v0.8.2 shipped sixteen Windows installers, thirteen
|
||||||
|
# of them from earlier versions, and v0.5.0 offered users a download list going
|
||||||
|
# back to 0.1.0. Every release from v0.1.0 to v0.8.2 did this. It stopped only
|
||||||
|
# because an unrelated change wiped the runner's target dir, so it is dormant
|
||||||
|
# rather than fixed.
|
||||||
|
#
|
||||||
|
# Filtering the copy by version would hide it; removing the directory means a
|
||||||
|
# stale file cannot exist to be copied. scripts/check-release-artifacts.sh is
|
||||||
|
# the backstop if some other path reintroduces one.
|
||||||
|
BUNDLE_DIR="src-tauri/target/$TARGET/release/bundle"
|
||||||
|
if [[ -d "$BUNDLE_DIR" ]]; then
|
||||||
|
echo "🧹 Clearing previous bundle output at $BUNDLE_DIR"
|
||||||
|
rm -rf "$BUNDLE_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "$WIN_BUNDLES" == "none" ]]; then
|
if [[ "$WIN_BUNDLES" == "none" ]]; then
|
||||||
bun run tauri build --runner cargo-xwin --target "$TARGET" --no-bundle
|
bun run tauri build --runner cargo-xwin --target "$TARGET" --no-bundle
|
||||||
else
|
else
|
||||||
|
|||||||
Executable
+103
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Refuse to publish a release whose artifacts are not all from this release.
|
||||||
|
#
|
||||||
|
# TRACES: | DR-220
|
||||||
|
#
|
||||||
|
# ./scripts/check-release-artifacts.sh <version> <dir> [<dir>...]
|
||||||
|
#
|
||||||
|
# e.g.
|
||||||
|
# ./scripts/check-release-artifacts.sh v0.9.2 artifacts/linux artifacts/windows
|
||||||
|
#
|
||||||
|
# ## The defect this exists for
|
||||||
|
#
|
||||||
|
# Every JellyTau release from v0.1.0 to v0.8.2 shipped every Windows installer
|
||||||
|
# ever built. `src-tauri/target/*/release/bundle/` is not versioned, cargo never
|
||||||
|
# cleans it, and the CI runner reuses the target directory between builds — so
|
||||||
|
# the copy step's `bundle/**/*-setup.exe` glob collected the whole history. By
|
||||||
|
# v0.8.2 that was sixteen installers, thirteen of them stale. v0.5.0 offered
|
||||||
|
# users a download list going back to 0.1.0.
|
||||||
|
#
|
||||||
|
# Nobody noticed for eight months. There was nothing to notice with: the upload
|
||||||
|
# loop reported success, the assets were real files, and the release page looked
|
||||||
|
# busy rather than wrong.
|
||||||
|
#
|
||||||
|
# The builds now clear the bundle directory first, which removes the cause. This
|
||||||
|
# is the backstop for the next thing that reintroduces a stale file by a route
|
||||||
|
# nobody predicted — a cached directory, a restored artifact, a hand-copied fix.
|
||||||
|
#
|
||||||
|
# ## What it checks
|
||||||
|
#
|
||||||
|
# Every file whose name embeds a semantic version must embed *this* version.
|
||||||
|
# Files with no version in the name (jellytau-release.apk, jellytau.exe,
|
||||||
|
# SHA256SUMS, latest.json) are accepted: they are produced fresh each build and
|
||||||
|
# have no version to disagree with.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$#" -lt 2 ]; then
|
||||||
|
echo "usage: $0 <version> <dir> [<dir>...]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION_RAW="$1"
|
||||||
|
shift
|
||||||
|
# Accept the tag form (v0.9.2) or the bare form (0.9.2).
|
||||||
|
VERSION="${VERSION_RAW#v}"
|
||||||
|
|
||||||
|
echo "🔎 Checking release artifacts are all version ${VERSION}…"
|
||||||
|
|
||||||
|
FOUND=0
|
||||||
|
STALE=0
|
||||||
|
UNVERSIONED=0
|
||||||
|
|
||||||
|
for dir in "$@"; do
|
||||||
|
if [ ! -d "$dir" ]; then
|
||||||
|
echo " (no $dir — skipping)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -print0/read -d '' so a filename with a space cannot split into two.
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
name="$(basename "$file")"
|
||||||
|
FOUND=$((FOUND + 1))
|
||||||
|
|
||||||
|
# First x.y.z in the filename, if any.
|
||||||
|
embedded="$(printf '%s' "$name" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true)"
|
||||||
|
|
||||||
|
if [ -z "$embedded" ]; then
|
||||||
|
UNVERSIONED=$((UNVERSIONED + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$embedded" != "$VERSION" ]; then
|
||||||
|
echo " ❌ $name carries version $embedded"
|
||||||
|
STALE=$((STALE + 1))
|
||||||
|
fi
|
||||||
|
done < <(find "$dir" -type f -print0)
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " $FOUND file(s) checked; $UNVERSIONED carry no version in the name."
|
||||||
|
|
||||||
|
if [ "$FOUND" -eq 0 ]; then
|
||||||
|
echo "❌ No artifacts found at all. A release with no files is a failed build," >&2
|
||||||
|
echo " not an empty one." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$STALE" -gt 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ $STALE artifact(s) belong to a different version than ${VERSION}." >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo " This is how every release from v0.1.0 to v0.8.2 came to ship its" >&2
|
||||||
|
echo " predecessors' Windows installers: src-tauri/target/*/release/bundle/" >&2
|
||||||
|
echo " is never cleaned and the runner reuses it, so a glob picks up" >&2
|
||||||
|
echo " whatever was left behind." >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo " The builds clear that directory first, so seeing this means a stale" >&2
|
||||||
|
echo " file arrived by some other route. Find it before publishing — do not" >&2
|
||||||
|
echo " delete the file and re-run." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Every versioned artifact is ${VERSION}."
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Tests for release-note derivation.
|
||||||
|
*
|
||||||
|
* TRACES: | DR-219 | UT-210
|
||||||
|
*
|
||||||
|
* The bug these were written against: `bun run release:notes v0.9.1..HEAD`
|
||||||
|
* listed *every user requirement in the project* as a feature of the release.
|
||||||
|
* The range contained a repo-wide `prettier --write` sweep, so `git diff
|
||||||
|
* --name-only` reported 199 files, their TRACES comments resolved to nearly the
|
||||||
|
* whole matrix, and the result claimed one release had added the entire
|
||||||
|
* application.
|
||||||
|
*
|
||||||
|
* That mattered more than it looked: build-release.yml now generates the
|
||||||
|
* published release body from this script, so the noise would have shipped.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { isCosmeticCommit } from "./release-notes";
|
||||||
|
|
||||||
|
describe("isCosmeticCommit", () => {
|
||||||
|
it("treats a formatting sweep as cosmetic", () => {
|
||||||
|
// The actual commit that triggered this.
|
||||||
|
expect(isCosmeticCommit("chore(format): run prettier over src/ and scripts/")).toBe(true);
|
||||||
|
expect(isCosmeticCommit("style: reindent the player module")).toBe(true);
|
||||||
|
expect(isCosmeticCommit("style(player): reindent")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a lockfile-only dependency bump as cosmetic", () => {
|
||||||
|
// Touches package.json/bun.lock, which carry no TRACES, but a `chore(deps)`
|
||||||
|
// that also edits source would still be caught by that source file.
|
||||||
|
expect(isCosmeticCommit("chore(deps): bump vitest to 4.1.11")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT treat ordinary work as cosmetic", () => {
|
||||||
|
expect(isCosmeticCommit("fix(player): restart the hero banner timer")).toBe(false);
|
||||||
|
expect(isCosmeticCommit("feat(updater): in-app update on desktop")).toBe(false);
|
||||||
|
expect(isCosmeticCommit("ci: make the frontend gates real")).toBe(false);
|
||||||
|
expect(isCosmeticCommit("docs: add SECURITY.md")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not mistake a chore that is not formatting for a formatting one", () => {
|
||||||
|
// `chore(release)` bumps versions and must still be attributable; a bare
|
||||||
|
// `chore:` could be anything, so it is NOT skipped by default.
|
||||||
|
expect(isCosmeticCommit("chore(release): v0.9.2")).toBe(false);
|
||||||
|
expect(isCosmeticCommit("chore: tidy up the queue helper")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not fooled by the word format appearing later in a subject", () => {
|
||||||
|
// A real fix to formatting *code* is not a cosmetic commit.
|
||||||
|
expect(isCosmeticCommit("fix(duration): format times over 24 hours correctly")).toBe(false);
|
||||||
|
expect(isCosmeticCommit("feat: add a format picker to settings")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles an empty or malformed subject without throwing", () => {
|
||||||
|
expect(isCosmeticCommit("")).toBe(false);
|
||||||
|
expect(isCosmeticCommit(" ")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -54,11 +54,86 @@ function loadRequirementDescriptions(): Map<string, string> {
|
|||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit subjects whose changes carry no requirement meaning.
|
||||||
|
*
|
||||||
|
* `chore(format)` / `style` rewrite files without changing behaviour;
|
||||||
|
* `chore(deps)` moves lockfiles. Anything else — including a bare `chore:` and
|
||||||
|
* `chore(release):` — is assumed to mean something and is kept.
|
||||||
|
*
|
||||||
|
* Anchored at the start of the subject on purpose: "fix(duration): format times
|
||||||
|
* over 24 hours" is a real fix to formatting *code*, not a formatting commit.
|
||||||
|
*/
|
||||||
|
const COSMETIC_SUBJECT = /^(chore\(format\)|chore\(deps\)|style)(\([^)]*\))?\s*:/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does this commit subject describe a change with no requirement meaning?
|
||||||
|
*
|
||||||
|
* Exported for scripts/release-notes.test.ts.
|
||||||
|
*
|
||||||
|
* TRACES: | DR-219
|
||||||
|
*/
|
||||||
|
export function isCosmeticCommit(subject: string): boolean {
|
||||||
|
return COSMETIC_SUBJECT.test(subject.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files the range changed, excluding those touched only by cosmetic commits.
|
||||||
|
*
|
||||||
|
* Why not a plain `git diff --name-only <range>`: that is what this did, and a
|
||||||
|
* single repo-wide `prettier --write` inside the range made it report 199 files
|
||||||
|
* whose TRACES comments resolved to nearly the entire requirement matrix. The
|
||||||
|
* generated notes for v0.9.2 claimed the release had added the whole
|
||||||
|
* application — and build-release.yml publishes this output, so the noise would
|
||||||
|
* have shipped.
|
||||||
|
*
|
||||||
|
* Walking commit by commit and skipping the cosmetic ones keeps a file that a
|
||||||
|
* sweep *and* a real change both touched: it is still listed by the real
|
||||||
|
* commit. Only files touched exclusively by cosmetic commits drop out, which is
|
||||||
|
* exactly the intent.
|
||||||
|
*
|
||||||
|
* Merge commits produce no output from `git diff-tree` without `-m`, and are
|
||||||
|
* skipped deliberately: everything they merge is already in the range as its
|
||||||
|
* own commit, so including them would double-count.
|
||||||
|
*/
|
||||||
function changedFiles(range: string): string[] {
|
function changedFiles(range: string): string[] {
|
||||||
const cmd = range ? `git diff --name-only ${range}` : "git ls-files"; // untagged repo: describe everything currently traced
|
// Untagged repo: describe everything currently traced.
|
||||||
return sh(cmd)
|
if (!range) {
|
||||||
.split("\n")
|
return sh("git ls-files")
|
||||||
.filter((f) => f && existsSync(f));
|
.split("\n")
|
||||||
|
.filter((f) => f && existsSync(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
// NUL between hash and subject so a subject containing anything at all is safe.
|
||||||
|
const log = sh(`git log --no-merges --format=%H%x00%s ${range}`);
|
||||||
|
if (!log) return [];
|
||||||
|
|
||||||
|
const files = new Set<string>();
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const line of log.split("\n")) {
|
||||||
|
const [sha, ...subjectParts] = line.split("\u0000");
|
||||||
|
const subject = subjectParts.join("\u0000");
|
||||||
|
if (!sha) continue;
|
||||||
|
|
||||||
|
if (isCosmeticCommit(subject)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const f of sh(`git diff-tree --no-commit-id --name-only -r ${sha}`).split("\n")) {
|
||||||
|
if (f && existsSync(f)) files.add(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipped > 0) {
|
||||||
|
// Say what was dropped rather than silently reporting a smaller set.
|
||||||
|
console.error(
|
||||||
|
`ℹ️ Skipped ${skipped} cosmetic commit(s) (formatting/deps) when deriving notes.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...files];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Collect requirement IDs referenced by TRACES comments in the given files. */
|
/** Collect requirement IDs referenced by TRACES comments in the given files. */
|
||||||
@@ -132,4 +207,7 @@ function main() {
|
|||||||
console.log(out.join("\n"));
|
console.log(out.join("\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
// Guarded so this module stays importable from release-notes.test.ts.
|
||||||
|
if (import.meta.main) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user