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
27 changes: 27 additions & 0 deletions .github/workflows/release-please-bump-lockfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,33 @@ jobs:
return;
}

// Bind the artifact to the run that built it: the PR it
// names must be one GitHub attached to that run, at the same
// branch and commit. Otherwise a run built for one PR could
// hand over a lockfile claiming to be another's. Release PRs
// come from this repository, so the list is never empty for
// them; an empty list fails like a mismatch.
const run = context.payload.workflow_run;
const linked = (run.pull_requests || []).find(
(p) => p.number === prNumber,
);
if (!linked) {
core.setFailed(
`artifact names PR #${prNumber}, which is not a PR of run ${run.id}`,
);
return;
}
if (
linked.head.ref !== branch ||
linked.head.sha !== artifactHeadSha ||
run.head_sha !== artifactHeadSha
) {
core.setFailed(
`run ${run.id} built ${linked.head.ref}@${linked.head.sha}, artifact claims ${branch}@${artifactHeadSha}`,
);
return;
}

// Verify the PR is still open, still authored by the
// release-please bot, and its head ref still matches the
// artifact's claim.
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/release-please-lockfile-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ name: Build Cargo.lock for release-please PR

"on":
pull_request:
branches: [main]
# Maintenance branches get release PRs too (see release-please.yml).
branches: [main, "release/**"]
types: [opened, synchronize, reopened]

permissions:
Expand Down
40 changes: 30 additions & 10 deletions .github/workflows/release-please.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,15 @@ name: Release Please
# workflow keeps gating correctly on `startsWith(head_ref,
# 'release-please--')`.**

# Also on maintenance branches (`release/1.9.x`, …): a patch release of a
# shipped line is cut from there, so the features already on main stay
# out of it. Each run targets the branch that was pushed
# (`--target-branch`), and reads that branch's config and manifest.
# A push to a branch runs that branch's copy of this file, so a
# maintenance branch needs this version of it too.
"on":
push:
branches: [main]
branches: [main, "release/**"]

permissions:
contents: write
Expand All @@ -47,27 +53,41 @@ jobs:
with:
node-version: 20

# Phase A — open / update the release PR. Idempotent: re-runs
# on every push, only mutates GitHub when something changed.
- name: Update release PR
# release-please is pinned to an exact version: `@17` resolved to
# whatever 17.x npm served at each run, handed the write token.
# Bump it by hand, on both steps.
#
# Phase A — when the head commit IS the release PR's merge
# commit, cut the GitHub release + tag. No-op on every other
# push, so we can run both phases unconditionally.
#
# **First, not second.** Right after a release PR merges, the
# update below looks for the release of the version the manifest
# now names; when that release does not exist yet it walks back up
# to `commit-search-depth` (1500) commits, one API call each, and
# the job's timeout cut it before this step ever ran — no tag, and
# a manual rerun on every release (1.8.1, 1.9.0, 1.9.1). Tagged
# first, the update finds its starting point at once.
- name: Publish GitHub release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx --yes release-please@17 release-pr \
npx --yes release-please@17.11.2 github-release \
--token="${GITHUB_TOKEN}" \
--repo-url="${GITHUB_REPOSITORY}" \
--target-branch="${GITHUB_REF_NAME}" \
--config-file=release-please-config.json \
--manifest-file=.release-please-manifest.json

# Phase B — when the head commit IS the release PR's merge
# commit, cut the GitHub release + tag. No-op on every other
# push, so we can run both phases unconditionally.
- name: Publish GitHub release
# Phase B — open / update the release PR. Idempotent: re-runs
# on every push, only mutates GitHub when something changed.
- name: Update release PR
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npx --yes release-please@17 github-release \
npx --yes release-please@17.11.2 release-pr \
--token="${GITHUB_TOKEN}" \
--repo-url="${GITHUB_REPOSITORY}" \
--target-branch="${GITHUB_REF_NAME}" \
--config-file=release-please-config.json \
--manifest-file=.release-please-manifest.json
68 changes: 60 additions & 8 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,18 @@ gh secret set SIGNTOOL_PFX_PASSWORD --body "<passphrase>"

### 1. Cut the release via release-please

You do **not** hand-bump versions anymore. Every push to `main`
runs [`.github/workflows/release-please.yml`](../.github/workflows/release-please.yml),
which:
You do **not** hand-bump versions anymore. Every push to `main` — or to
a maintenance branch, see
[Patch releases from a maintenance branch](#patch-releases-from-a-maintenance-branch) —
runs [`.github/workflows/release-please.yml`](../.github/workflows/release-please.yml)
against that branch, which:

1. Parses the Conventional Commits since the last tag.
2. Computes the next semver (`feat:` → minor, `fix:` → patch,
`feat!:` / `BREAKING CHANGE:` → major, no relevant commits → no PR).
3. Opens or refreshes a **chore(main): release X.Y.Z** PR that
3. Opens or refreshes a release PR — **chore(main): release X.Y.Z** on
`main`, **chore(release/X.Y.x): release X.Y.Z+1** on a maintenance
branch — that
bumps every version manifest in lockstep:
- [`package.json`](../package.json) (canonical, owned by release-please)
- [`src-tauri/crates/app/tauri.conf.json`](../src-tauri/crates/app/tauri.conf.json) (`$.version`)
Expand All @@ -152,17 +156,30 @@ which:
To ship, **review and merge the release PR** (squash or merge,
release-please tolerates both). The merge causes release-please to:

- create the `vX.Y.Z` tag on `main`,
- create the `vX.Y.Z` tag on the branch it targets (`main`, or the
maintenance branch),
- create the matching GitHub Release with the auto-generated notes,
- update [`.release-please-manifest.json`](../.release-please-manifest.json)
to record the new version.

The new tag triggers [`release.yml`](../.github/workflows/release.yml)
exactly as before — no change needed downstream.
The tag is created with the workflow's `GITHUB_TOKEN`, and a tag pushed
that way **does not trigger** [`release.yml`](../.github/workflows/release.yml)
(GitHub does not start workflows from events the token itself caused).
Start it by hand once the tag exists:

```bash
gh workflow run release.yml -f tag_name=vX.Y.Z
```

The workflow tags first and updates the release PR second: in the other
order, the update right after a release PR merged went looking for a
release that did not exist yet, walked up to 1,500 commits one API call at
a time, and the job's timeout cut it before the tag was ever made.

### 2. Watch the workflow

Pushing the tag triggers `.github/workflows/release.yml`:
`.github/workflows/release.yml`, started for the tag as above (a tag
pushed by hand would start it on its own; release-please's does not):

- Builds Linux on `ubuntu-latest` — produces an `.AppImage`
(universal, also the updater payload), a `.deb` (Debian/Ubuntu),
Expand Down Expand Up @@ -215,6 +232,41 @@ updater errors. Common causes: pubkey mismatch (private/public
keys regenerated), endpoint 404, malformed `latest.json`,
signature corrupted on upload.

## Patch releases from a maintenance branch

Once `main` carries features for the next minor or major, a fix for the
version users have cannot be cut from it: release-please would bump to
the next minor and ship those features with it. A patch is cut from a
maintenance branch instead, `release/X.Y.x`, branched from the last tag
of that line:

1. **Create the branch** from the tag: `git push origin vX.Y.Z:refs/heads/release/X.Y.x`.
2. **Bring the workflows** to it. A push runs the pushed branch's copy of
[`release-please.yml`](../.github/workflows/release-please.yml), and
a pull request runs its base's copy of
[`release-please-lockfile-build.yml`](../.github/workflows/release-please-lockfile-build.yml);
a tag older than their `release/**` support has neither. Cherry-pick
the commit that added it.
3. **Cherry-pick each fix** from `main` (signed: `git cherry-pick -S`),
through a pull request into `release/X.Y.x` so CI runs on it. The
**Maintenance branches** ruleset protects `release/**` the way `Main`
protects `main` — no deletion or force push, signed commits, a pull
request with one approval — without the CodeQL gate, since CodeQL only
analyses pull requests into `main`. A push to these branches runs their
own copy of the workflow with a write token, which is why they must not
take direct pushes.
4. release-please, run on the branch with `--target-branch`, opens
`chore(release/X.Y.x): release X.Y.Z+1` against it; the Cargo.lock
pipeline fills it in as on `main`. Merge it, then start `release.yml`
for the new tag as above. Downstream (AUR, winget, COPR, apt) and the
updater manifest work the same, since everything starts from the tag.

`main` keeps its own manifest, so its next release still counts from its
last stable version; its changelog will list the cherry-picked fixes a
second time, under the version that ships them on `main`. For a major,
set `release-as` in [`release-please-config.json`](../release-please-config.json)
before merging the release PR.

## Beta channel

WaveFlow ships an **opt-in beta channel** so testers can run
Expand Down
2 changes: 1 addition & 1 deletion docs/features/library.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Tag edits go through the same funnel: [`edit.rs`](../../src-tauri/crates/app/src
- **Artist page** — its track list comes from `list_tracks` with an `artist_id`, matched **by row id**: every track that credits the artist through `track_artist`, in any position, plus every track of an album whose album artist they are. It used to load the whole library and keep the rows whose display string, split on `", "`, contained the name — so "Earth, Wind & Fire" had no tracks, the multi-artist rule broken on the one page that needed it. [`get_artist_detail`](../../src-tauri/crates/app/src/commands/browse.rs) counts and lists albums the same way, so an artist who is only ever an album artist ("Various Artists", reached from a compilation's page) gets a page instead of "artist not found".
- **Single-click play** — with `ui.single_click_play` on, the first click of a row plays it; the second click of a double-click (`detail > 1`) and the row's `dblclick` stand down, so a double-click out of habit is one play instead of three.
- **A-Z navigator** — letter rail on the artists tab, NFD-normalised so accents (É → E, Ñ → N) bucket correctly.
- **Multi-select** — ctrl/shift across rows with a floating action bar (Play / Add to queue / Add to playlist / Remove) anchored to the bottom of the viewport. **Ctrl+A** (Cmd+A) selects every local row of the list on screen — the tracks tab, an opened folder, an inventory category, an album, a playlist, once that list has loaded (right after navigating, the previous one is still in state) — ([`useSelectAllShortcut`](../../src/hooks/useSelectAllShortcut.ts)) — and leaves the combo to a focused field or an open dialog. The selection bar shows on the tracks tab, in an opened folder and in an inventory category, and the selection clears when the tab, the folder or the inventory category changes. Each row's number gives way to a **checkbox** ([`RowSelectCheckbox`](../../src/components/common/RowSelectCheckbox.tsx)) on hover, on keyboard focus (it is hidden by opacity, so it stays in the tab order), and on every row once a selection has been _asked for_ — a checkbox, a Ctrl or Shift click, or Ctrl+A (`useMultiSelect().checkboxMode`). A plain click only highlights its row, which keeps its number: switching the whole list to boxes on the way to a double-click play is what [#859](https://github.com/InstaZDLL/WaveFlow/issues/859) reported. With single-click play on, hovering no longer reveals the box, which sits where the number is and caught clicks meant to play the track; a selection there starts with Ctrl, Shift or Ctrl+A, and the box stays in the tab order, so Space or Enter on it still starts one from the keyboard. Each box is named after its row for screen readers, so a selection can be built without a modifier; its click and Space never reach the row, which would play it or reset the selection. Escape clears the selection and, when the mouse picked the row, drops that row's focus too, or the key would paint a focus ring on it; focus anywhere else is left alone ([`inputModality`](../../src/lib/inputModality.ts)). Server rows have no rowid to select and keep their number.
- **Multi-select** — ctrl/shift across rows with a floating action bar (Play / Add to queue / Add to playlist / Remove) anchored to the bottom of the viewport. **Ctrl+A** (Cmd+A) selects every local row of the list on screen — the tracks tab, an opened folder, an inventory category, an album, a playlist, once that list has loaded (right after navigating, the previous one is still in state) — ([`useSelectAllShortcut`](../../src/hooks/useSelectAllShortcut.ts)) — and leaves the combo to a focused field or an open dialog. The selection bar shows on the tracks tab, in an opened folder and in an inventory category, and the selection clears when the tab, the folder or the inventory category changes. Each row's number gives way to a **checkbox** ([`RowSelectCheckbox`](../../src/components/common/RowSelectCheckbox.tsx)) on keyboard focus (it is hidden by opacity, so it stays in the tab order) and on every row once a selection has been _asked for_ — a Ctrl or Shift click, or Ctrl+A (`useMultiSelect().checkboxMode`). A plain click only highlights its row, which keeps its number: switching the whole list to boxes on the way to a double-click play is what [#859](https://github.com/InstaZDLL/WaveFlow/issues/859) reported. The box no longer shows on hover, in either click mode ([#867](https://github.com/InstaZDLL/WaveFlow/issues/867)): it sits where the number is, so it caught clicks meant for the row, and it put a checkbox under every pass of the mouse. Each box is named after its row for screen readers; its click and Space never reach the row, which would play it or reset the selection. A row clicked with the mouse never shows a focus ring, whatever key is pressed next — Escape, a digit, or a desktop shortcut such as Super+Q under Hyprland ([#866](https://github.com/InstaZDLL/WaveFlow/issues/866)): [`inputModality`](../../src/lib/inputModality.ts) marks focus that arrives from a pointer, and `app.css` keeps a marked element ringless; focus reached from the keyboard keeps its ring. Server rows have no rowid to select and keep their number.
- **Track Properties dialog** — foobar2000-style modal with the full tag set, audio specs, analysis results, file path and a Show in Explorer button. The read-only view shows every tag the form edits (track and disc number on two rows — the old "1 / 37" read as track 1 of 37 — and the genre), and **Edit** sits in the header next to Close rather than in the footer a scroll away. In the form, artist, album and genre are [`TagCombobox`](../../src/components/common/TagCombobox.tsx) fields: click or type and a list opens with what the library already holds. Genres are few and loaded whole, then filtered as you type (accent- and case-insensitive); artists and albums can run to thousands, so they come from the backend's `search_artists` / `search_albums` (bounded, prefix-first, the top bar's search) after a 150 ms pause, cached per query for the life of the dialog and shown as returned, since that search matches a normalised form the list would not. For the genre, the list adds the common genres of [`genrePresets.ts`](../../src/lib/genrePresets.ts) (the ID3v1 / Winamp list tag editors propose, trimmed, plus a few recent genres) — anything typed is still taken as is. A pick replaces the genre rather than adding one: the save stores one genre, so completing a `;`-separated list would create a genre named after the whole list. The list is portalled (the metadata section clips with `overflow: hidden`) and focus never leaves the input, so Escape closes the list first and the dialog second. **What a save sends, and what it changes.** Title, numbers and year always; artist, album and genre only when the field differs from what the track holds. The artist field is filled from `get_track_artists`, the credits joined with `"; "` — the only separator `split_artist_name` splits on — not from the row's display `artist_name`, which is joined with `", "`: echoing that back merged a multi-artist track's credits into one artist named "A, B", in the database and in the file, on any save, a title-only one included. Artists, albums and genres are found by a canonical key that ignores case, punctuation and spacing, so "The Wall" typed over "the wall" lands on the same row; the save then **respells** that row (`respell` in `edit.rs`), since nothing else ever renamed it and the fix showed in the file alone. Only on an explicit edit: the scanner leaves names as they were first seen, or two files spelling an artist differently would rename it back and forth on every scan. The save also recomputes the year of the album the track left and the one it joined (`refresh_album_years`, shared with the folder scan), which the header otherwise kept until the next scan of that folder.
- **POPM ratings** — 5-star with half-steps, round-tripped to the file's tag. Edit surfaces: inline `StarRating` in the library track list, integer-star submenu in the right-click `TrackContextMenu` (any view), full half-star widget in the `TrackPropertiesModal`. The backend command `set_track_rating` writes the POPM frame back to the file (binary `<email>\0<rating><counter>` for ID3v2, text `RATING=0-100` for Vorbis / MP4 / APE), updates `track.rating` in the DB, then emits `track:updated` so every open view re-fetches without polling. The write goes through `edit::patch_file` like every other tag edit (#644), so a `.dsf` gets its POPM frame through the DSF writer rather than stopping at the database. **ID3v2 ratings are written as the POPM frame itself**, on the concrete tag, and that is not a stylistic choice: lofty 0.25 changed what a POPM looks like on its generic tag — it arrives as `<provider>|<stars>|<counter>` through a whole-star `StarRating` rather than as the raw frame body its own documentation still describes — so a 0-255 value in half-star steps put into the generic tag is dropped on the way back to the file, silently, and a plain number or a raw body produces no frame at all (measured). The scanner reads both shapes for the same reason: reading only the old one is why an MP3's rating came back as nothing after that bump. `.dff` is still refused, with the reason. WaveFlow does not read a rating back out of a DSD file — the DSD extractor lifts title / artist / album / year and no more — so the database stays the copy this app trusts; what the write buys is the rating travelling with the file. Smart playlists expose this as the `rating_min` rule — see [smart-playlists.md](smart-playlists.md#custom-smart-playlists-recursive-boolean-rule-tree).
- **Lightbox** — double-click any cover or artist photo to view full-size with keyboard navigation.
Expand Down
Loading
Loading