ci(release): split channel pipelines and automate version transitions (#345)

* ci(release): split channel pipelines and automate version transitions

Introduce dedicated next/release orchestration workflows backed by a reusable publish pipeline with RC/final package version updates and post-release main dev bump PRs. Add a strict release process SOP with RC freeze, hotfix override, and mandatory backport rules.

* ci(release): gate main-to-next promotion on green CI

Add a dedicated workflow that validates main branch checks/statuses and block the promote-main-to-next flow unless main is green. Update the release SOP to reflect the enforced pre-promotion validation.

* ci(release): fix channel promotion edge cases and policy gaps

Switch channel promotions to reset-based snapshots with force-with-lease pushes, stop auto-merging channel nix-refresh PRs, and guard main dev bump against version downgrades. Add RC changelog fallback, require AUR release updates in SOP, and extend npmDepsHash sync to main/next/release pushes.

* docs(release): clarify backport and nix refresh survivability rules

Require RC fix backports to reach main before the next main-to-next promotion, and document that channel-local nix refresh PRs are advisory unless equivalent changes are merged into main.
This commit is contained in:
cucadmuh
2026-04-28 23:20:01 +03:00
committed by GitHub
parent 8141c5213f
commit bf38288feb
8 changed files with 868 additions and 314 deletions
+18
View File
@@ -0,0 +1,18 @@
name: Next Channel
on:
push:
branches: [next]
workflow_dispatch:
jobs:
publish-next:
uses: ./.github/workflows/reusable-channel-publish.yml
with:
channel: next
source_ref: ${{ github.ref_name }}
target_branch: next
prerelease: true
draft_release: false
verify_nix: true
secrets: inherit
+2 -2
View File
@@ -2,12 +2,12 @@
# Runs in CI with Nix — contributors do not need Nix locally. # Runs in CI with Nix — contributors do not need Nix locally.
# #
# Skips fork PRs (cannot push to the contributor branch); after merge to main, # Skips fork PRs (cannot push to the contributor branch); after merge to main,
# the push workflow updates the hash on main if needed. # next, or release, the push workflow updates the hash on that branch if needed.
name: Sync Nix npmDepsHash name: Sync Nix npmDepsHash
on: on:
push: push:
branches: [main] branches: [main, next, release]
paths: paths:
- package-lock.json - package-lock.json
- package.json - package.json
@@ -0,0 +1,63 @@
name: Promote main to next
on:
workflow_dispatch:
jobs:
validate-main:
uses: ./.github/workflows/validate-main-green-ci.yml
with:
branch: main
promote:
needs: validate-main
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: next
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: fast-forward next to main
run: |
set -euo pipefail
git fetch origin main next
# Reset channel branch to main snapshot, then create a fresh RC bump commit.
git reset --hard origin/main
- name: bump package version to next RC
run: |
set -euo pipefail
git fetch --tags origin
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
BASE_VERSION="$(node -p 'const v=require("./package.json").version; const m=v.match(/^(\d+\.\d+\.\d+)/); if(!m){throw new Error("Invalid version: "+v)}; m[1]')"
MAX_RC="$(git tag -l "app-v${BASE_VERSION}-rc.*" | sed -E 's/.*-rc\.([0-9]+)$/\1/' | sort -n | tail -n1)"
if [[ -z "$MAX_RC" ]]; then
NEXT_RC=1
else
NEXT_RC=$((MAX_RC + 1))
fi
TARGET_VERSION="${BASE_VERSION}-rc.${NEXT_RC}"
if [[ "$CURRENT_VERSION" == "$TARGET_VERSION" ]]; then
echo "package.json already uses $TARGET_VERSION"
exit 0
fi
npm version --no-git-tag-version "$TARGET_VERSION"
- name: commit RC version bump
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json
if git diff --cached --quiet; then
echo "No version bump changes to commit."
exit 0
fi
NEW_VERSION="$(node -p 'require("./package.json").version')"
git commit -m "chore(release): bump next channel to ${NEW_VERSION}"
- name: push next
run: git push --force-with-lease origin HEAD:next
@@ -0,0 +1,49 @@
name: Promote next to release
on:
workflow_dispatch:
jobs:
promote:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: release
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: fast-forward release to next
run: |
set -euo pipefail
git fetch origin next release
# Reset stable channel to next snapshot, then finalize version on top.
git reset --hard origin/next
- name: finalize package version for release
run: |
set -euo pipefail
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
FINAL_VERSION="$(node -p 'const v=require("./package.json").version; const m=v.match(/^(\d+\.\d+\.\d+)/); if(!m){throw new Error("Invalid version: "+v)}; m[1]')"
if [[ "$CURRENT_VERSION" == "$FINAL_VERSION" ]]; then
echo "package.json is already final: $FINAL_VERSION"
exit 0
fi
npm version --no-git-tag-version "$FINAL_VERSION"
- name: commit final version bump
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json
if git diff --cached --quiet; then
echo "No finalization changes to commit."
exit 0
fi
NEW_VERSION="$(node -p 'require("./package.json").version')"
git commit -m "chore(release): finalize release version ${NEW_VERSION}"
- name: push release
run: git push --force-with-lease origin HEAD:release
+11 -310
View File
@@ -1,317 +1,18 @@
name: Release name: Release Channel
on: on:
push: push:
tags: branches: [release]
- 'v*'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
create-release: publish-release:
permissions: uses: ./.github/workflows/reusable-channel-publish.yml
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.create-release.outputs.result }}
package_version: ${{ steps.get-version.outputs.version }}
steps:
- uses: actions/checkout@v5
- name: setup node
uses: actions/setup-node@v5
with: with:
node-version: lts/* channel: release
cache: 'npm' source_ref: ${{ github.ref_name }}
- name: get version target_branch: release
id: get-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
- name: extract changelog
id: changelog
run: |
VERSION="${{ steps.get-version.outputs.version }}"
# Extract the block between ## [VERSION] and the next ## heading
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
# Store multiline output
EOF=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "body<<$EOF" >> $GITHUB_OUTPUT
echo "$BODY" >> $GITHUB_OUTPUT
echo "$EOF" >> $GITHUB_OUTPUT
- name: create release
id: create-release
uses: actions/github-script@v7
env:
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
CHANGELOG_BODY: ${{ steps.changelog.outputs.body }}
with:
script: |
const tag = `app-v${process.env.PACKAGE_VERSION}`;
const body = process.env.CHANGELOG_BODY || 'See the assets to download this version and install.';
try {
const { data } = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag,
});
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: data.id,
body,
});
return data.id;
} catch (e) {
if (e.status !== 404) throw e;
}
const { data } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: tag,
name: `Psysonic v${process.env.PACKAGE_VERSION}`,
body,
draft: true,
prerelease: false prerelease: false
}); draft_release: true
return data.id; verify_nix: true
secrets: inherit
build-macos-windows:
needs: create-release
permissions:
contents: write
strategy:
fail-fast: false
matrix:
settings:
- platform: 'macos-latest'
args: '--target aarch64-apple-darwin'
- platform: 'macos-latest'
args: '--target x86_64-apple-darwin'
- platform: 'windows-latest'
args: '--bundles nsis'
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v5
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: 'npm'
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: cache cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: install npm dependencies
run: npm install
- name: write Apple API key (macOS only)
if: runner.os == 'macOS'
run: |
mkdir -p ~/private_keys
echo "${{ secrets.APPLE_API_KEY_B64 }}" | base64 --decode > ~/private_keys/AuthKey.p8
echo "APPLE_API_KEY_PATH=$HOME/private_keys/AuthKey.p8" >> $GITHUB_ENV
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
# Apple signing + notarization (macOS runner only — ignored on Windows)
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
# APPLE_API_KEY_PATH comes from the previous step via $GITHUB_ENV
# Tauri Updater signing — produces .sig files alongside the update bundles
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.settings.args }}
- name: re-sign updater bundle + upload .sig (macOS only)
# tauri-action re-packs the .app into .app.tar.gz after tauri CLI is
# done, which invalidates the .sig tauri CLI created (different hash).
# We can't stop the repack (it's tied to includeUpdaterJson), so we
# sign the final repacked .tar.gz ourselves and upload the fresh .sig.
if: runner.os == 'macOS'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -e
VERSION=${{ needs.create-release.outputs.package_version }}
TARGET_ARG='${{ matrix.settings.args }}'
if echo "$TARGET_ARG" | grep -q 'aarch64'; then
TARGET="aarch64-apple-darwin"
ARCH="aarch64"
else
TARGET="x86_64-apple-darwin"
ARCH="x64"
fi
TARBALL="src-tauri/target/${TARGET}/release/bundle/macos/Psysonic.app.tar.gz"
if [ ! -f "$TARBALL" ]; then
echo "::error::Expected tarball missing: $TARBALL"
ls -la "$(dirname "$TARBALL")" || true
exit 1
fi
npx @tauri-apps/cli signer sign "$TARBALL"
cp "${TARBALL}.sig" "Psysonic_${ARCH}.app.tar.gz.sig"
gh release upload "app-v${VERSION}" \
"Psysonic_${ARCH}.app.tar.gz.sig" \
--clobber
generate-manifest:
needs: [create-release, build-macos-windows]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v5
- name: generate latest.json
env:
VERSION: ${{ needs.create-release.outputs.package_version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/generate-update-manifest.js
- name: upload latest.json to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
gh release upload "app-v${VERSION}" latest.json --clobber
build-linux:
needs: create-release
permissions:
contents: write
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
libasound2-dev squashfs-tools cmake
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: 'npm'
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: cache cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: install npm dependencies
run: npm install
- name: build
env:
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
APPIMAGE_EXTRACT_AND_RUN: 1
run: npm run tauri:build -- --bundles deb,rpm,appimage
- name: upload Linux artifacts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION=${{ needs.create-release.outputs.package_version }}
find src-tauri/target/release/bundle \
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
| xargs gh release upload "app-v${VERSION}" --clobber
# Verifies that `nix build .#psysonic` still works against the current source,
# refreshes `nix/upstream-sources.json` (npmDepsHash) + `flake.lock`
# (nixpkgs pin), and pushes the resulting store paths to the public Cachix
# binary cache so end users can `nix profile install github:Psychotoxical/psysonic`
# without having to compile locally.
#
# The refreshed lock/hash files are committed back to `main` when they change.
verify-nix:
needs: create-release
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v5
with:
# Full history so we can push the auto-commit back to the default branch.
fetch-depth: 0
# Checkout main, not the tag — we want to push lock/hash refreshes to
# the moving branch, not the immutable tag ref.
ref: main
- name: install Nix
uses: DeterminateSystems/nix-installer-action@v15
# cachix-action with no signingKey = Cachix-managed signing (Cachix signs
# server-side). The action watches the nix store during subsequent build
# steps and uploads new paths automatically.
- name: configure Cachix (managed signing)
uses: cachix/cachix-action@v15
with:
name: psysonic
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: compute npmDepsHash from package-lock.json
id: npm-hash
run: |
set -euo pipefail
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
echo "Computed npmDepsHash: $HASH"
- name: write npmDepsHash into nix/upstream-sources.json
run: |
set -euo pipefail
HASH='${{ steps.npm-hash.outputs.hash }}'
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > nix/upstream-sources.json.new
mv nix/upstream-sources.json.new nix/upstream-sources.json
cat nix/upstream-sources.json
- name: refresh flake.lock (nixpkgs pin)
run: nix flake update --accept-flake-config
- name: verify nix build + push to Cachix
run: |
set -euo pipefail
nix build .#psysonic --accept-flake-config --no-link --print-build-logs
# The cachix-action daemon writes a post-build-hook into the user
# nix.conf, but the Determinate Nix daemon that runs the builds reads
# the system nix.conf — so the hook never fires and only a couple of
# early prep paths get uploaded. Force an explicit closure push here;
# cachix dedupes against anything already in the cache.
nix path-info --recursive .#psysonic | cachix push psysonic
- name: open + auto-merge PR with refreshed lock and hash (if changed)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add flake.lock nix/upstream-sources.json
if git diff --cached --quiet; then
echo "flake.lock / nix/upstream-sources.json unchanged — nothing to commit."
exit 0
fi
VERSION="${{ needs.create-release.outputs.package_version }}"
BRANCH="chore/nix-lock-refresh-v${VERSION}"
git checkout -b "$BRANCH"
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
git push origin "$BRANCH"
gh pr create \
--base main \
--head "$BRANCH" \
--title "chore(nix): refresh lock + npmDepsHash for v${VERSION}" \
--body "Auto-generated after the v${VERSION} release: refreshes \`flake.lock\` and \`nix/upstream-sources.json\` so the Cachix substituter resolves the latest pin."
gh pr merge "$BRANCH" --squash --delete-branch
@@ -0,0 +1,409 @@
name: Reusable Channel Publish
on:
workflow_call:
inputs:
channel:
description: "Delivery channel name (release/next)"
required: true
type: string
source_ref:
description: "Git ref to build from"
required: true
type: string
target_branch:
description: "Branch that receives nix refresh PRs"
required: true
type: string
prerelease:
description: "Mark GitHub release as prerelease"
required: true
type: boolean
draft_release:
description: "Create release as draft"
required: true
type: boolean
verify_nix:
description: "Run verify-nix job"
required: false
default: true
type: boolean
jobs:
create-release:
permissions:
contents: write
runs-on: ubuntu-latest
outputs:
release_id: ${{ steps.create-release.outputs.result }}
package_version: ${{ steps.get-version.outputs.version }}
release_tag: ${{ steps.tag.outputs.value }}
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref }}
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: "npm"
- name: get version
id: get-version
run: echo "version=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"
- name: compute release tag
id: tag
env:
VERSION: ${{ steps.get-version.outputs.version }}
run: |
set -euo pipefail
TAG="app-v${VERSION}"
echo "value=$TAG" >> "$GITHUB_OUTPUT"
- name: extract changelog
id: changelog
run: |
VERSION="${{ steps.get-version.outputs.version }}"
BODY=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
if [ -z "$BODY" ]; then
BASE_VERSION="$(node -e 'const v=process.argv[1]; const m=v.match(/^(\d+\.\d+\.\d+)/); if(m){process.stdout.write(m[1]);}' "$VERSION")"
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "$VERSION" ]; then
BODY=$(awk "/^## \[$BASE_VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
fi
fi
EOF_MARKER=$(dd if=/dev/urandom bs=15 count=1 status=none | base64)
echo "body<<$EOF_MARKER" >> "$GITHUB_OUTPUT"
echo "$BODY" >> "$GITHUB_OUTPUT"
echo "$EOF_MARKER" >> "$GITHUB_OUTPUT"
- name: create or update release
id: create-release
uses: actions/github-script@v7
env:
PACKAGE_VERSION: ${{ steps.get-version.outputs.version }}
RELEASE_TAG: ${{ steps.tag.outputs.value }}
CHANGELOG_BODY: ${{ steps.changelog.outputs.body }}
IS_PRERELEASE: ${{ inputs.prerelease }}
IS_DRAFT: ${{ inputs.draft_release }}
CHANNEL: ${{ inputs.channel }}
with:
script: |
const tag = process.env.RELEASE_TAG;
const body = process.env.CHANGELOG_BODY || "See the assets to download this version and install.";
const prerelease = process.env.IS_PRERELEASE === "true";
const draft = process.env.IS_DRAFT === "true";
const version = process.env.PACKAGE_VERSION;
const channel = process.env.CHANNEL;
const titleSuffix = prerelease ? ` RC (${channel})` : "";
const name = `Psysonic v${version}${titleSuffix}`;
try {
const { data } = await github.rest.repos.getReleaseByTag({
owner: context.repo.owner,
repo: context.repo.repo,
tag,
});
await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: data.id,
body,
name,
draft,
prerelease,
});
return data.id;
} catch (e) {
if (e.status !== 404) throw e;
}
const { data } = await github.rest.repos.createRelease({
owner: context.repo.owner,
repo: context.repo.repo,
tag_name: tag,
name,
body,
draft,
prerelease,
});
return data.id;
build-macos-windows:
needs: create-release
permissions:
contents: write
strategy:
fail-fast: false
matrix:
settings:
- platform: "macos-latest"
args: "--target aarch64-apple-darwin"
- platform: "macos-latest"
args: "--target x86_64-apple-darwin"
- platform: "windows-latest"
args: "--bundles nsis"
runs-on: ${{ matrix.settings.platform }}
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref }}
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: "npm"
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.settings.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
- name: cache cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: install npm dependencies
run: npm install
- name: write Apple API key (macOS only)
if: runner.os == 'macOS'
run: |
mkdir -p ~/private_keys
echo "${{ secrets.APPLE_API_KEY_B64 }}" | base64 --decode > ~/private_keys/AuthKey.p8
echo "APPLE_API_KEY_PATH=$HOME/private_keys/AuthKey.p8" >> "$GITHUB_ENV"
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
args: ${{ matrix.settings.args }}
- name: re-sign updater bundle + upload .sig (macOS only)
if: runner.os == 'macOS'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -e
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
TARGET_ARG='${{ matrix.settings.args }}'
if echo "$TARGET_ARG" | grep -q 'aarch64'; then
TARGET="aarch64-apple-darwin"
ARCH="aarch64"
else
TARGET="x86_64-apple-darwin"
ARCH="x64"
fi
TARBALL="src-tauri/target/${TARGET}/release/bundle/macos/Psysonic.app.tar.gz"
if [ ! -f "$TARBALL" ]; then
echo "::error::Expected tarball missing: $TARBALL"
ls -la "$(dirname "$TARBALL")" || true
exit 1
fi
npx @tauri-apps/cli signer sign "$TARBALL"
cp "${TARBALL}.sig" "Psysonic_${ARCH}.app.tar.gz.sig"
gh release upload "$RELEASE_TAG" "Psysonic_${ARCH}.app.tar.gz.sig" --clobber
generate-manifest:
needs: [create-release, build-macos-windows]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref }}
- name: generate latest.json
env:
VERSION: ${{ needs.create-release.outputs.package_version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/generate-update-manifest.js
- name: upload latest.json to release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
gh release upload "$RELEASE_TAG" latest.json --clobber
build-linux:
needs: create-release
permissions:
contents: write
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v5
with:
ref: ${{ inputs.source_ref }}
- name: install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf \
libasound2-dev squashfs-tools cmake
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
cache: "npm"
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: cache cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: src-tauri
- name: install npm dependencies
run: npm install
- name: build
env:
VITE_LASTFM_API_KEY: ${{ secrets.VITE_LASTFM_API_KEY }}
VITE_LASTFM_API_SECRET: ${{ secrets.VITE_LASTFM_API_SECRET }}
APPIMAGE_EXTRACT_AND_RUN: 1
run: npm run tauri:build -- --bundles deb,rpm,appimage
- name: upload Linux artifacts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
RELEASE_TAG="${{ needs.create-release.outputs.release_tag }}"
find src-tauri/target/release/bundle \
\( -name "*.deb" -o -name "*.rpm" -o -name "*.AppImage" \) \
| xargs gh release upload "$RELEASE_TAG" --clobber
verify-nix:
if: ${{ inputs.verify_nix }}
needs: create-release
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ inputs.target_branch }}
- name: install Nix
uses: DeterminateSystems/nix-installer-action@v15
- name: configure Cachix (managed signing)
uses: cachix/cachix-action@v15
with:
name: psysonic
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- name: compute npmDepsHash from package-lock.json
id: npm-hash
run: |
set -euo pipefail
HASH="$(nix run nixpkgs/nixos-unstable#prefetch-npm-deps -- package-lock.json)"
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
echo "Computed npmDepsHash: $HASH"
- name: write npmDepsHash into nix/upstream-sources.json
run: |
set -euo pipefail
HASH='${{ steps.npm-hash.outputs.hash }}'
jq --arg h "$HASH" '.npmDepsHash = $h' nix/upstream-sources.json > nix/upstream-sources.json.new
mv nix/upstream-sources.json.new nix/upstream-sources.json
- name: refresh flake.lock (nixpkgs pin)
run: nix flake update --accept-flake-config
- name: verify nix build + push to Cachix
run: |
set -euo pipefail
nix build .#psysonic --accept-flake-config --no-link --print-build-logs
nix path-info --recursive .#psysonic | cachix push psysonic
- name: open + auto-merge PR with refreshed lock and hash (if changed)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add flake.lock nix/upstream-sources.json
if git diff --cached --quiet; then
echo "flake.lock / nix/upstream-sources.json unchanged — nothing to commit."
exit 0
fi
VERSION="${{ needs.create-release.outputs.package_version }}"
BRANCH="chore/nix-lock-refresh-${{ inputs.target_branch }}-v${VERSION}"
git checkout -b "$BRANCH"
git commit -m "chore(nix): refresh lock + npmDepsHash for v${VERSION}"
git push origin "$BRANCH"
gh pr create \
--base "${{ inputs.target_branch }}" \
--head "$BRANCH" \
--title "chore(nix): refresh lock + npmDepsHash for v${VERSION}" \
--body "Auto-generated for the \`${{ inputs.channel }}\` channel after v${VERSION}: refreshes \`flake.lock\` and \`nix/upstream-sources.json\`."
bump-main-to-next-dev:
if: ${{ inputs.channel == 'release' }}
needs: create-release
runs-on: ubuntu-24.04
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
ref: main
- name: setup node
uses: actions/setup-node@v5
with:
node-version: lts/*
- name: compute next dev version
id: next-dev
env:
RELEASE_VERSION: ${{ needs.create-release.outputs.package_version }}
run: |
set -euo pipefail
NEXT_DEV="$(node -e 'const v=process.env.RELEASE_VERSION; const m=v.match(/^(\d+)\.(\d+)\.(\d+)/); if(!m){throw new Error(`Invalid release version: ${v}`)}; const major=Number(m[1]); const minor=Number(m[2]) + 1; process.stdout.write(`${major}.${minor}.0-dev`)')"
echo "value=$NEXT_DEV" >> "$GITHUB_OUTPUT"
- name: bump package version in main
run: |
set -euo pipefail
TARGET_VERSION="${{ steps.next-dev.outputs.value }}"
CURRENT_VERSION="$(node -p 'require("./package.json").version')"
SHOULD_BUMP="$(node -e '
const parse = (v) => {
const m = v.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/);
if (!m) throw new Error(`Invalid semver: ${v}`);
const pre = m[4] ?? "";
const preRank = pre === "" ? 3 : pre.startsWith("rc.") ? 2 : pre === "dev" ? 1 : 0;
return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]), preRank };
};
const a = parse(process.argv[1]); // current
const b = parse(process.argv[2]); // target
const keys = ["major", "minor", "patch", "preRank"];
for (const k of keys) {
if (b[k] > a[k]) { process.stdout.write("true"); process.exit(0); }
if (b[k] < a[k]) { process.stdout.write("false"); process.exit(0); }
}
process.stdout.write("false");
' "$CURRENT_VERSION" "$TARGET_VERSION")"
if [[ "$SHOULD_BUMP" != "true" ]]; then
echo "main already at ${CURRENT_VERSION} (target ${TARGET_VERSION} is not newer)"
exit 0
fi
npm version --no-git-tag-version "$TARGET_VERSION"
- name: open PR with dev bump (if changed)
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json
if git diff --cached --quiet; then
echo "No dev version bump required."
exit 0
fi
VERSION="${{ steps.next-dev.outputs.value }}"
SAFE_VERSION="${VERSION//./-}"
SAFE_VERSION="${SAFE_VERSION//\//-}"
BRANCH="chore/version-bump-main-${SAFE_VERSION}"
git checkout -b "$BRANCH"
git commit -m "chore(release): bump main to ${VERSION}"
git push origin "$BRANCH"
gh pr create \
--base main \
--head "$BRANCH" \
--title "chore(release): bump main to ${VERSION}" \
--body "Auto-generated after stable release: updates \`package.json\` and \`package-lock.json\` to the next development version."
@@ -0,0 +1,77 @@
name: Validate Main Green CI
on:
workflow_dispatch:
inputs:
branch:
description: "Branch to validate"
required: false
default: main
type: string
workflow_call:
inputs:
branch:
required: false
default: main
type: string
jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
checks: read
statuses: read
steps:
- name: validate branch checks are green
uses: actions/github-script@v7
env:
TARGET_BRANCH: ${{ inputs.branch || github.event.inputs.branch || 'main' }}
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const branch = process.env.TARGET_BRANCH || "main";
const branchResp = await github.rest.repos.getBranch({ owner, repo, branch });
const sha = branchResp.data.commit.sha;
core.info(`Validating checks for ${branch} @ ${sha}`);
const combined = await github.rest.repos.getCombinedStatusForRef({
owner,
repo,
ref: sha,
});
const statusState = combined.data.state;
const statusCount = combined.data.statuses.length;
core.info(`Combined status state: ${statusState} (${statusCount} status contexts)`);
const checks = await github.paginate(github.rest.checks.listForRef, {
owner,
repo,
ref: sha,
per_page: 100,
});
if (checks.length === 0 && statusCount === 0) {
core.setFailed(`No checks/statuses found for ${branch}. Refusing promotion.`);
return;
}
const badChecks = checks.filter((c) => {
if (c.status !== "completed") return true;
return !["success", "neutral", "skipped"].includes(c.conclusion || "");
});
if (badChecks.length > 0) {
const details = badChecks.map((c) => `${c.name}: status=${c.status}, conclusion=${c.conclusion}`).join("\n");
core.setFailed(`Branch ${branch} is not green.\n${details}`);
return;
}
if (!["success", "neutral"].includes(statusState)) {
core.setFailed(`Combined status is ${statusState} for ${branch}.`);
return;
}
core.info(`Branch ${branch} is green.`);
+237
View File
@@ -0,0 +1,237 @@
# Release Process (Strict SOP)
This document defines the **only allowed** release workflow for this repository.
All maintainers should follow it exactly.
## 1) Branch roles
- `main`:
- primary development branch
- all regular feature/fix work lands here via PR
- should usually carry a development version (for example `X.Y.Z-dev`)
- `next`:
- release-candidate (RC) stabilization branch
- receives promoted changes from `main`
- receives RC-only fixes during freeze
- `release`:
- stable release branch
- only receives promoted commits from `next`
Direct push to these branches is not part of normal human workflow. Use PRs and promotion workflows.
## 2) Versioning rules (mandatory)
Version is authoritative in `package.json` and `package-lock.json`.
- `main` version format: `X.Y.Z-dev`
- `next` version format: `X.Y.Z-rc.N`
- `release` version format: `X.Y.Z`
Rules:
1. Never edit versions manually in random commits.
2. Version transitions must happen through the defined promotion workflows.
3. Tags must match package version:
- RC: `app-vX.Y.Z-rc.N`
- Stable: `app-vX.Y.Z`
## 3) Standard release flow
### Step A: Prepare in `main`
1. Merge ready PRs into `main`.
2. Confirm CI is green on `main`.
### Step B: Promote to RC (`next`)
1. Run workflow: **Promote main to next**.
2. Workflow behavior:
- validates that `main` checks are green before promotion
- resets `next` to `main` snapshot
- auto-bump package version in `next` to next `-rc.N`
- commit and push version bump
3. Push on `next` triggers **Next Channel** workflow:
- build/publish RC artifacts for all platforms
- run Nix verification path
### Step C: Stabilize RC
1. Test RC artifacts.
2. If fixes are needed, follow Section 5 (RC fix policy).
3. Repeat Step B as needed until release candidate is accepted.
### Step D: Promote to stable (`release`)
1. Run workflow: **Promote next to release**.
2. Workflow behavior:
- resets `release` to `next` snapshot
- finalize version from `-rc.N` to `X.Y.Z`
- commit and push finalized version
3. Push on `release` triggers **Release Channel** workflow:
- stable artifact publish
- Nix verification
- opens PR to bump `main` to next minor `-dev`
### Step E: Move `main` forward
1. Merge the auto-generated PR that bumps `main` to next minor dev version.
2. Confirm `main` now uses `X.(Y+1).0-dev`.
3. Update AUR package metadata for the same stable version:
- bump `pkgver` in `packages/aur/PKGBUILD`
- regenerate `packages/aur/.SRCINFO`
- publish/update in AUR remote
## 4) Freeze policy (RC stabilization window)
When RC freeze starts:
- Do **not** run `Promote main to next` automatically or casually.
- Only approved release manager(s) may run promotion workflows.
- `next` accepts only stabilization changes (fixes/docs/chore required for release quality).
- New features remain in `main` and wait for next cycle.
Freeze ends after `next -> release` promotion is complete.
## 5) RC fix policy (strict backport/forward-port rules)
If a bug is discovered during RC stabilization:
1. Create dedicated fix branch from `next`:
- example: `fix/rc-crash-login`
2. Open PR: `fix/rc-crash-login -> next`
3. After merge to `next`, create dedicated backport branch from `main`:
- example: `fix/backport-rc-crash-login-main`
4. Cherry-pick (or re-apply) same fix.
5. Open PR: `fix/backport-rc-crash-login-main -> main`
6. Merge this `main` backport PR before the next `Promote main to next` run.
This is mandatory. RC-only fixes may not stay only in `next`.
Alternative allowed order:
- implement first in `main`, then promote `main -> next`.
But if `main` is ahead with non-release features and promotion is frozen, use the `next-first + mandatory main backport` flow above.
## 6) Post-release critical hotfix policy (default path)
After a stable release `X.Y.Z`, critical fixes must be shipped as a patch release:
- next stable target is always `X.Y.(Z+1)`
- RC tags for hotfix cycle: `app-vX.Y.(Z+1)-rc.N`
- final stable tag: `app-vX.Y.(Z+1)`
Never re-use or overwrite `X.Y.Z` tags/releases.
### Case A: `next` is not yet used for the next minor
This case is uncommon in this repository but allowed.
1. Create hotfix branch from `release`.
2. Implement fix and open PR to `release`.
3. Move patch line through `next` RC flow (`X.Y.(Z+1)-rc.N`).
4. Promote `next -> release` for final `X.Y.(Z+1)`.
5. Backport fix to `main` via dedicated PR (mandatory).
### Case B (default): `next` already tracks next minor
This is the expected real-world case.
Assume:
- `release` is `1.9.0`
- `main`/`next` already moved to `1.10.0-*`
- critical bug requires `1.9.1`
Required steps:
1. Announce **hotfix override window** and freeze normal next-minor RC flow.
2. Create hotfix branch from `release` (`1.9.0` baseline).
3. Implement fix and merge into `release` branch via PR.
4. Temporarily align `next` to the hotfix patch line for RC publication.
5. Publish hotfix RC(s): `1.9.1-rc.N`.
6. Promote `next -> release` to finalize `1.9.1`.
7. Backport/cherry-pick same fix into `main` via dedicated PR (mandatory).
8. Restore `next` back to the normal next-minor line from `main`.
9. Announce end of hotfix override and resume normal RC cycle.
Hard rule: no feature work may be merged into `next` during hotfix override.
## 7) Idempotency and rerun behavior
Manual workflow reruns should be safe:
- rerunning **Promote main to next**:
- no change if no new commits
- version bump occurs only when needed for next RC number
- rerunning **Promote next to release**:
- no change if release already matches next
- no extra version increment beyond `X.Y.Z`
- rerunning release publish:
- main dev bump step should no-op when `main` already has target dev version
Rerun is allowed for recovery, but must be announced in release channel/chat.
## 8) Hard rules and prohibitions
Do:
- use PRs for all code changes
- keep channel promotions deterministic and force-push only through approved promotion workflows
- require green CI before promotions
- document exceptions in PR description
Do not:
- manually retag or overwrite release tags
- manually edit `package.json` version outside defined release flow
- merge feature PRs into `next` during freeze
- skip the `next/release -> main` backport for RC fixes or hotfixes
- force-push `next` or `release` manually outside promotion workflows
## 9) Incident handling
If an incorrect promotion happened:
1. Stop further promotions immediately.
2. Announce incident and current branch SHAs.
3. Create corrective PRs (do not use destructive git history rewrites on protected branches).
4. Re-run affected workflows only after corrective PRs are merged.
## 10) Operator checklist (quick)
Before `main -> next`:
- [ ] `main` CI green
- [ ] freeze status known
- [ ] release manager approval
- [ ] branch rules allow workflow `--force-with-lease` on `next`
Before `next -> release`:
- [ ] RC validation complete
- [ ] all RC fixes merged to `next`
- [ ] corresponding backports to `main` completed or queued with owners
- [ ] branch rules allow workflow `--force-with-lease` on `release`
After stable release:
- [ ] verify stable artifacts exist
- [ ] merge auto PR for next `-dev` bump in `main`
- [ ] publish AUR update (`PKGBUILD` + `.SRCINFO`)
- [ ] announce cycle close
For post-release hotfix:
- [ ] patch target decided: `X.Y.(Z+1)`
- [ ] hotfix override for `next` announced
- [ ] fix merged to release patch line
- [ ] hotfix backport PR to `main` merged
- [ ] `next` restored to normal next-minor line
Nix note:
- `nix-npm-deps-hash-sync.yml` runs on pushes to `main`, `next`, and `release`.
- `verify-nix` in channel publish still performs full lock/hash refresh verification for release artifacts.
- Channel-local nix refresh PRs are advisory and can be overwritten by later reset-based promotions.
- If a nix refresh must survive release cycles, ensure the same change is merged into `main`.