From bf38288febba5033c262b38dc4ab1fac11dc554d Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:20:01 +0300 Subject: [PATCH] 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. --- .github/workflows/next.yml | 18 + .github/workflows/nix-npm-deps-hash-sync.yml | 4 +- .github/workflows/promote-main-to-next.yml | 63 +++ .github/workflows/promote-next-to-release.yml | 49 +++ .github/workflows/release.yml | 325 +------------- .../workflows/reusable-channel-publish.yml | 409 ++++++++++++++++++ .github/workflows/validate-main-green-ci.yml | 77 ++++ RELEASE_PROCESS.md | 237 ++++++++++ 8 files changed, 868 insertions(+), 314 deletions(-) create mode 100644 .github/workflows/next.yml create mode 100644 .github/workflows/promote-main-to-next.yml create mode 100644 .github/workflows/promote-next-to-release.yml create mode 100644 .github/workflows/reusable-channel-publish.yml create mode 100644 .github/workflows/validate-main-green-ci.yml create mode 100644 RELEASE_PROCESS.md diff --git a/.github/workflows/next.yml b/.github/workflows/next.yml new file mode 100644 index 00000000..7b0ee6ff --- /dev/null +++ b/.github/workflows/next.yml @@ -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 diff --git a/.github/workflows/nix-npm-deps-hash-sync.yml b/.github/workflows/nix-npm-deps-hash-sync.yml index 16ce6f23..3df9ec55 100644 --- a/.github/workflows/nix-npm-deps-hash-sync.yml +++ b/.github/workflows/nix-npm-deps-hash-sync.yml @@ -2,12 +2,12 @@ # Runs in CI with Nix — contributors do not need Nix locally. # # 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 on: push: - branches: [main] + branches: [main, next, release] paths: - package-lock.json - package.json diff --git a/.github/workflows/promote-main-to-next.yml b/.github/workflows/promote-main-to-next.yml new file mode 100644 index 00000000..49e68ae4 --- /dev/null +++ b/.github/workflows/promote-main-to-next.yml @@ -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 diff --git a/.github/workflows/promote-next-to-release.yml b/.github/workflows/promote-next-to-release.yml new file mode 100644 index 00000000..0ecb9162 --- /dev/null +++ b/.github/workflows/promote-next-to-release.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9562397a..3498b63b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,317 +1,18 @@ -name: Release +name: Release Channel + on: push: - tags: - - 'v*' + branches: [release] workflow_dispatch: 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 }} - steps: - - uses: actions/checkout@v5 - - 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: 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 - }); - 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 - - 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 + publish-release: + uses: ./.github/workflows/reusable-channel-publish.yml + with: + channel: release + source_ref: ${{ github.ref_name }} + target_branch: release + prerelease: false + draft_release: true + verify_nix: true + secrets: inherit diff --git a/.github/workflows/reusable-channel-publish.yml b/.github/workflows/reusable-channel-publish.yml new file mode 100644 index 00000000..ab54d42f --- /dev/null +++ b/.github/workflows/reusable-channel-publish.yml @@ -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." diff --git a/.github/workflows/validate-main-green-ci.yml b/.github/workflows/validate-main-green-ci.yml new file mode 100644 index 00000000..260b1fc3 --- /dev/null +++ b/.github/workflows/validate-main-green-ci.yml @@ -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.`); diff --git a/RELEASE_PROCESS.md b/RELEASE_PROCESS.md new file mode 100644 index 00000000..d2c0c39d --- /dev/null +++ b/RELEASE_PROCESS.md @@ -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`.