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
103 changes: 103 additions & 0 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
name: Automatic release

on:
push:
branches: ["main"]
workflow_dispatch:
inputs:
commit_message:
description: Conventional commit subject to evaluate during a dry run
required: false
type: string
default: "fix: manual automatic-release dry run"

permissions:
contents: read

jobs:
auto-release:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
fetch-tags: true
persist-credentials: false

- uses: actions/setup-node@v5
with:
node-version: 22

- name: Check release contract
run: node scripts/check-release.mjs

- id: version
name: Resolve release line
shell: bash
run: |
set -euo pipefail
version="$(node -p "require('./apps/desktop/src-tauri/tauri.conf.json').version")"
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Tauri version must be strict MAJOR.MINOR.PATCH (got $version)"
exit 1
fi
echo "version=${version%.*}" >> "$GITHUB_OUTPUT"

- id: gate
uses: open-cli-collective/.github/actions/auto-release@74d24fcd862d7b9cbe8f6fdda31db6a833e3d706
with:
release-paths: apps/**,crates/**,Cargo.toml,Cargo.lock,packaging/**,scripts/**,.github/workflows/release.yml,.github/workflows/auto-release.yml
version-file: apps/desktop/src-tauri/tauri.conf.json
tag-prefix: v
version: ${{ steps.version.outputs.version }}
run-number: ${{ github.run_number }}
before-sha: ${{ github.event.before }}
after-sha: ${{ github.sha }}
commit-message: ${{ github.event.head_commit.message || inputs.commit_message }}

- name: Report dry-run tag
if: github.event_name == 'workflow_dispatch' && steps.gate.outputs.should-release == 'true'
shell: bash
env:
TAG: ${{ steps.gate.outputs.tag }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
echo "::notice::dry-run: would create tag $TAG at $SHA"

- name: Tag
if: github.event_name == 'push' && steps.gate.outputs.should-release == 'true'
shell: bash
env:
TAG: ${{ steps.gate.outputs.tag }}
TAG_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
# Same-tag/same-SHA reruns are safe; a different SHA is a collision.
existing="$(git rev-parse -q --verify "refs/tags/$TAG" 2>/dev/null || true)"
if [ -n "$existing" ]; then
target="$(git rev-parse -q --verify "refs/tags/$TAG^{}" 2>/dev/null || true)"
[ -n "$target" ] || target="$existing"
if [ "$target" = "$SHA" ]; then
echo "::notice::tag $TAG already exists at $SHA — nothing to do"
exit 0
fi
echo "::error::tag $TAG already exists at $target, not $SHA (collision)"
exit 1
fi
if [ -z "$TAG_TOKEN" ]; then
echo "::error::TAP_GITHUB_TOKEN is required for a non-dry-run release"
exit 1
fi
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$TAG" "$SHA"
# Use the dedicated token so this tag retriggers Release; never use GITHUB_TOKEN.
git remote set-url origin "https://github.com/${REPO}.git"
# shellcheck disable=SC2016
git -c credential.helper= \
-c 'credential.helper=!f() { echo username=x-access-token; echo "password=${TAG_TOKEN}"; }; f' \
push origin "refs/tags/$TAG"
echo "::notice::pushed tag $TAG"
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ on:
pull_request:

jobs:
pr-title:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: open-cli-collective/.github/actions/pr-title@74d24fcd862d7b9cbe8f6fdda31db6a833e3d706
with:
title: ${{ github.event.pull_request.title }}

rust:
runs-on: macos-latest
steps:
Expand Down
52 changes: 40 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,25 @@ jobs:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
version="$(node -p "require('./apps/desktop/src-tauri/tauri.conf.json').version")"
if [ "$EVENT_NAME" = push ] && [ "${TAG#v}" != "$version" ]; then
echo "::error::tag $TAG does not match tauri.conf.json version $version"
exit 1
fi
if [ "$EVENT_NAME" = push ] && ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error::release tags must point to a commit on main"
exit 1
baseline="$(node -p "require('./apps/desktop/src-tauri/tauri.conf.json').version")"
release_line="${baseline%.*}"
if [ "$EVENT_NAME" = push ]; then
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::release tag must be strict vMAJOR.MINOR.PATCH (got $TAG)"
exit 1
fi
version="${TAG#v}"
tag_line="${version%.*}"
if [ "$tag_line" != "$release_line" ]; then
echo "::error::tag $TAG is outside configured release line v$release_line"
exit 1
fi
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "::error::release tags must point to a commit on main"
exit 1
fi
else
version="$baseline"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"

Expand Down Expand Up @@ -125,15 +136,22 @@ jobs:
cargo test -p retune-audio --release
cargo test -p retune-desktop --release local_files_remain_playable_without_playback_authorization

- name: Require Last.fm credentials
env:
RETUNE_LASTFM_API_KEY: ${{ vars.LASTFM_API_KEY }}
RETUNE_LASTFM_SHARED_SECRET: ${{ secrets.LASTFM_API_SECRET }}
run: node --input-type=module -e "for (const name of ['RETUNE_LASTFM_API_KEY', 'RETUNE_LASTFM_SHARED_SECRET']) if (!process.env[name]?.trim()) throw new Error(name + ' is required for release packaging')"

- name: Build native bundle
working-directory: apps/desktop
env:
RETUNE_LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }}
RETUNE_LASTFM_API_KEY: ${{ vars.LASTFM_API_KEY }}
RETUNE_LASTFM_SHARED_SECRET: ${{ secrets.LASTFM_API_SECRET }}
RETUNE_SUPPORT_EMAIL: ${{ vars.RETUNE_SUPPORT_EMAIL }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
node --input-type=module -e "for (const name of ['RETUNE_LASTFM_API_KEY', 'RETUNE_LASTFM_SHARED_SECRET']) if (!process.env[name]?.trim()) throw new Error(name + ' is required for release packaging')"
npx tauri build --bundles ${{ matrix.bundle }}
node -e "require('node:fs').writeFileSync('src-tauri/tauri.release.conf.json', JSON.stringify({ version: process.env.VERSION }))"
npx tauri build --config src-tauri/tauri.release.conf.json --bundles ${{ matrix.bundle }}

- name: Configure stable macOS signing
if: matrix.os == 'macos-15'
Expand Down Expand Up @@ -220,7 +238,12 @@ jobs:
working-directory: apps/desktop
env:
VERSION: ${{ needs.prepare.outputs.version }}
run: tar -czf "Retune-${VERSION}-aarch64.tar.gz" -C ../../target/release/bundle/macos Retune.app
run: |
set -euo pipefail
app="../../target/release/bundle/macos/Retune.app"
actual="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$app/Contents/Info.plist")"
[ "$actual" = "$VERSION" ] || { echo "::error::macOS bundle version $actual does not match $VERSION"; exit 1; }
tar -czf "Retune-${VERSION}-aarch64.tar.gz" -C ../../target/release/bundle/macos Retune.app

- name: Rename Windows NSIS installer
if: startsWith(matrix.os, 'windows-')
Expand All @@ -233,6 +256,9 @@ jobs:
$bundle = Resolve-Path '../../target/release/bundle/nsis'
$files = @(Get-ChildItem -LiteralPath $bundle -Filter '*-setup.exe' -File)
if ($files.Count -ne 1) { throw "expected one NSIS setup.exe, found $($files.Count)" }
if ($files[0].BaseName -notmatch [regex]::Escape($env:VERSION)) { throw "NSIS source filename does not contain version $env:VERSION: $($files[0].Name)" }
$productVersion = (Get-Item -LiteralPath $files[0].FullName).VersionInfo.ProductVersion
if ($productVersion -ne $env:VERSION) { throw "NSIS ProductVersion $productVersion does not match $env:VERSION" }
$destination = Join-Path $bundle "Retune-$env:VERSION-windows-$env:ARCH-setup.exe"
Move-Item -LiteralPath $files[0].FullName -Destination $destination

Expand All @@ -247,6 +273,8 @@ jobs:
bundle=../../target/release/bundle/deb
files=("$bundle"/*.deb)
[ "${#files[@]}" -eq 1 ] || { echo "::error::expected one Debian package, found ${#files[@]}"; exit 1; }
package_version="$(dpkg-deb -f "${files[0]}" Version)"
[ "$package_version" = "$VERSION" ] || { echo "::error::Debian package version $package_version does not match $VERSION"; exit 1; }
mv "${files[0]}" "$bundle/retune_${VERSION}_${ARCH}.deb"

- name: Upload macOS artifact
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "retune-desktop"
version = "0.2.1"
version = "0.3.0"
description = "Retune desktop shell — iTunes-style Spotify wrapper with local metadata overlay"
license.workspace = true
edition = "2021"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Retune",
"version": "0.2.1",
"version": "0.3.0",
"identifier": "com.rianjs.retune",
"build": {
"frontendDist": "../dist",
Expand Down
39 changes: 32 additions & 7 deletions docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ npm exec tauri dev

If the file is absent or either value is empty, Retune remains usable and shows
Last.fm as unavailable. Release builds receive the credentials only on the
trusted native bundle step and fail there if either value is missing.
trusted native bundle step and fail there if either value is missing. Hosted
release builds map the repository variable `LASTFM_API_KEY` and repository
secret `LASTFM_API_SECRET` to those backend-only values; CI receives neither.

Native CI builds the Tauri app bundle on macOS arm64, Windows x64/ARM64, and
Ubuntu 22.04 amd64/arm64. The Windows and Linux jobs run release Rust tests,
Expand All @@ -57,22 +59,45 @@ native credential stores.

## Release automation

Pushing a tag such as `v0.2.1` runs the native release workflow. It builds and
publishes exactly these assets: `Retune-<version>-aarch64.tar.gz`,
Merging a pull request to `main` releases automatically only when the squash
commit's title passes the pinned conventional-commit check, has the `feat` or
`fix` conventional type (including scoped forms such as `feat(scope):`), and
changes a release-worthy path (`apps/**`, `crates/**`, root Cargo files,
`packaging/**`, `scripts/**`, or a release workflow). Other conventional types
and unrelated paths skip the release gate. The automatic
workflow derives `MAJOR.MINOR` from `apps/desktop/src-tauri/tauri.conf.json`
and creates `v<major>.<minor>.<run-number>`; with the current `0.3.0` baseline,
the first live tag is expected to be `v0.3.1`.

Pushing a strict tag such as `v0.3.1` runs the native release workflow. The tag
must match the configured release line and point to a commit reachable from
`main`. It builds and publishes exactly these assets: `Retune-<version>-aarch64.tar.gz`,
`Retune-<version>-windows-x64-setup.exe`,
`Retune-<version>-windows-arm64-setup.exe`,
`retune_<version>_amd64.deb`, `retune_<version>_arm64.deb`, and
`checksums.txt`. `workflow_dispatch` is a dry run: it builds, signs, verifies,
and aggregates the same artifacts without creating a release or dispatching
package channels.
`checksums.txt`. The tag version is passed to Tauri through its `--config`
override so package metadata matches the release tag. The automatic
workflow's `workflow_dispatch` only evaluates the release gate and reports the
computed tag; it never builds or pushes. The Release workflow's
`workflow_dispatch` builds, signs, verifies, and aggregates the same artifacts
against the selected ref without creating a release or dispatching package
channels. Before merging, dispatch the existing Release workflow against the
feature branch for packaging validation. Once this automatic workflow exists
on `main`, its manual dispatch can validate the gate and computed tag.

To start a new release line, update the checked-in Tauri version, desktop Cargo
version, and matching `Cargo.lock` package entry together (for example,
`0.4.0`); do not add a `version.txt` file. The automatic workflow then uses
that line for subsequent tags.

Run the local release contract check with:

```sh
node scripts/check-release.mjs
```

Tag releases require these repository secrets: `MACOS_CERT_P12`,
Tag releases require the repository variable `LASTFM_API_KEY` and repository
secret `LASTFM_API_SECRET`, plus these repository secrets: `MACOS_CERT_P12`,
`MACOS_CERT_PASSWORD`, `MACOS_CERT_CN`, `MACOS_CERT_LEAF_SHA` (exactly
`42e1afd02aae8666c09c15f171e1639550f301c2`), `TAP_GITHUB_TOKEN`,
and `WINGET_GITHUB_TOKEN`. `LINUX_PACKAGES_DISPATCH_TOKEN` is optional; when
Expand Down
31 changes: 16 additions & 15 deletions docs/INSTALL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Install and set up Retune

Retune v0.2.1 supports these native packages:
Retune supports these native packages:

| Platform | Supported architecture |
| --- | --- |
Expand All @@ -14,37 +14,38 @@ published package metadata.

## Direct downloads

The [v0.2.1 release](https://github.com/open-cli-collective/Retune/releases/tag/v0.2.1)
provides these fallback downloads:
The [latest Retune release](https://github.com/open-cli-collective/Retune/releases/latest)
provides these fallback downloads. Use the version shown on that release page
in each artifact name:

| Platform | Architecture | Asset |
| --- | --- | --- |
| macOS | Apple Silicon | [`Retune-0.2.1-aarch64.tar.gz`](https://github.com/open-cli-collective/Retune/releases/download/v0.2.1/Retune-0.2.1-aarch64.tar.gz) |
| Windows | x64 | [`Retune-0.2.1-windows-x64-setup.exe`](https://github.com/open-cli-collective/Retune/releases/download/v0.2.1/Retune-0.2.1-windows-x64-setup.exe) |
| Windows | ARM64 | [`Retune-0.2.1-windows-arm64-setup.exe`](https://github.com/open-cli-collective/Retune/releases/download/v0.2.1/Retune-0.2.1-windows-arm64-setup.exe) |
| Debian/Ubuntu | amd64 | [`retune_0.2.1_amd64.deb`](https://github.com/open-cli-collective/Retune/releases/download/v0.2.1/retune_0.2.1_amd64.deb) |
| Debian/Ubuntu | arm64 | [`retune_0.2.1_arm64.deb`](https://github.com/open-cli-collective/Retune/releases/download/v0.2.1/retune_0.2.1_arm64.deb) |

Download [`checksums.txt`](https://github.com/open-cli-collective/Retune/releases/download/v0.2.1/checksums.txt)
and verify the matching asset before installing. On macOS, verify the tarball
| macOS | Apple Silicon | `Retune-<version>-aarch64.tar.gz` |
| Windows | x64 | `Retune-<version>-windows-x64-setup.exe` |
| Windows | ARM64 | `Retune-<version>-windows-arm64-setup.exe` |
| Debian/Ubuntu | amd64 | `retune_<version>_amd64.deb` |
| Debian/Ubuntu | arm64 | `retune_<version>_arm64.deb` |

Download `checksums.txt` from that same latest release and verify the matching asset before installing. On macOS, verify the tarball
before clearing quarantine or moving `Retune.app` into `/Applications`. On
Windows, verify the installer before accepting any unsigned-publisher warning.
These files support only the targets listed above.

After verifying the download, install the matching package:

```sh
# macOS
tar -xzf Retune-0.2.1-aarch64.tar.gz
# macOS (replace VERSION with the latest release version)
VERSION=latest-version
tar -xzf "Retune-${VERSION}-aarch64.tar.gz"
xattr -dr com.apple.quarantine Retune.app
sudo mv Retune.app /Applications/

# Debian/Ubuntu amd64 (use the arm64 filename on ARM64)
sudo apt install ./retune_0.2.1_amd64.deb
sudo apt install "./retune_${VERSION}_amd64.deb"
```

On Windows, run the downloaded `.exe` installer from File Explorer or
PowerShell, for example `./Retune-0.2.1-windows-x64-setup.exe`.
PowerShell, for example `./Retune-<version>-windows-x64-setup.exe`.

## macOS with Homebrew

Expand Down
13 changes: 9 additions & 4 deletions scripts/check-docs.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ for (const file of ["ARCHITECTURE.md", ...markdown.filter((file) => file.startsW

const install = fs.readFileSync(path.join(root, "docs/INSTALL.md"), "utf8");
const normalizedInstall = install.replace(/\s+/g, " ");
const version = JSON.parse(fs.readFileSync(path.join(root, "apps/desktop/src-tauri/tauri.conf.json"), "utf8")).version;
for (const match of install.matchAll(/(?:Retune v|Retune-|retune_|\/(?:tag|download)\/v)(\d+\.\d+\.\d+)/g)) {
if (match[1] !== version) errors.push(`docs/INSTALL.md: stale version ${match[1]} (expected ${version})`);
}
for (const value of [
"https://github.com/open-cli-collective/Retune/releases/latest",
"Retune-<version>-aarch64.tar.gz",
"Retune-<version>-windows-x64-setup.exe",
"Retune-<version>-windows-arm64-setup.exe",
"retune_<version>_amd64.deb",
"retune_<version>_arm64.deb",
"brew install --cask open-cli-collective/tap/retune",
"winget install --exact --id OpenCLICollective.Retune",
"sudo apt install retune",
Expand All @@ -70,6 +72,9 @@ for (const value of [
]) {
if (!install.includes(value)) errors.push(`docs/INSTALL.md: missing contract ${value}`);
}
if (/Retune v\d+\.\d+\.\d+|Retune-\d+\.\d+\.\d+|retune_\d+\.\d+\.\d+/.test(install)) {
errors.push("docs/INSTALL.md: release instructions must use latest-release and artifact patterns");
}
for (const value of [
"Do **not** register `http://127.0.0.1:8898/login`; `/login` is Retune's separate internal built-in-playback callback.",
"Retune uses Authorization Code with PKCE, so it does not need or store the client secret.",
Expand Down
Loading
Loading