diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..5b93b16 --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -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" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e989477..efc8d3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5924aa7..6c84a58 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" @@ -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' @@ -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-') @@ -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 @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 0101fc4..ba2457f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4217,7 +4217,7 @@ dependencies = [ [[package]] name = "retune-desktop" -version = "0.2.1" +version = "0.3.0" dependencies = [ "base64 0.22.1", "chrono", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index a7d5a14..83ed6eb 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -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" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index db14dea..aa5c484 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -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", diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index fe50a7d..401b756 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -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, @@ -57,14 +59,36 @@ 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--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..`; 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--aarch64.tar.gz`, `Retune--windows-x64-setup.exe`, `Retune--windows-arm64-setup.exe`, `retune__amd64.deb`, `retune__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: @@ -72,7 +96,8 @@ Run the local release contract check with: 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 diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 9eb5f53..fe1dfb1 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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 | | --- | --- | @@ -14,19 +14,19 @@ 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--aarch64.tar.gz` | +| Windows | x64 | `Retune--windows-x64-setup.exe` | +| Windows | ARM64 | `Retune--windows-arm64-setup.exe` | +| Debian/Ubuntu | amd64 | `retune__amd64.deb` | +| Debian/Ubuntu | arm64 | `retune__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. @@ -34,17 +34,18 @@ 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--windows-x64-setup.exe`. ## macOS with Homebrew diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs index 47125e9..c11688f 100644 --- a/scripts/check-docs.mjs +++ b/scripts/check-docs.mjs @@ -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--aarch64.tar.gz", + "Retune--windows-x64-setup.exe", + "Retune--windows-arm64-setup.exe", + "retune__amd64.deb", + "retune__arm64.deb", "brew install --cask open-cli-collective/tap/retune", "winget install --exact --id OpenCLICollective.Retune", "sudo apt install retune", @@ -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.", diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index a3d42c5..ec7b2b5 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -10,6 +10,7 @@ const required = (text, value, message = value) => assert.ok(text.includes(value const tauri = JSON.parse(read('apps/desktop/src-tauri/tauri.conf.json')) const desktopCargo = read('apps/desktop/src-tauri/Cargo.toml') const lock = read('Cargo.lock') +const autoWorkflow = read('.github/workflows/auto-release.yml') const workflow = read('.github/workflows/release.yml') const ci = read('.github/workflows/ci.yml') const gitignore = read('.gitignore') @@ -23,10 +24,17 @@ const frontendState = [ ].join('\n') const nativeBundleStep = workflow.match(/- name: Build native bundle\n[\s\S]*?(?=\n - name:)/)?.[0] ?? '' +const credentialStep = workflow.match(/- name: Require Last\.fm credentials\n[\s\S]*?(?=\n - name:)/)?.[0] ?? '' +const macPackageStep = workflow.match(/- name: Package macOS app\n[\s\S]*?(?=\n - name:)/)?.[0] ?? '' +const windowsRenameStep = workflow.match(/- name: Rename Windows NSIS installer\n[\s\S]*?(?=\n - name:)/)?.[0] ?? '' +const debRenameStep = workflow.match(/- name: Rename Debian package\n[\s\S]*?(?=\n - name:)/)?.[0] ?? '' +const autoDryRunStep = autoWorkflow.match(/- name: Report dry-run tag\n[\s\S]*?(?=\n - name:)/)?.[0] ?? '' +const autoTagStep = autoWorkflow.match(/- name: Tag\n[\s\S]*$/)?.[0] ?? '' const cargoVersion = desktopCargo.match(/name = "retune-desktop"\s+version = "([^"]+)"/s)?.[1] const lockVersion = lock.match(/\[\[package\]\]\s+name = "retune-desktop"\s+version = "([^"]+)"/s)?.[1] assert.match(tauri.version, /^\d+\.\d+\.\d+$/) +assert.match(tauri.version, /^\d+\.\d+\.0$/) assert.equal(cargoVersion, tauri.version) assert.equal(lockVersion, tauri.version) assert.equal(tauri.identifier, 'com.rianjs.retune') @@ -34,11 +42,37 @@ assert.equal(tauri.bundle.linux.deb.section, 'sound') required(workflow, 'workflow_dispatch:') required(workflow, 'tags:\n - "v*"') -required(nativeBundleStep, 'RETUNE_LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }}', 'trusted Last.fm API key mapping') +required(autoWorkflow, 'push:\n branches: ["main"]', 'automatic release main trigger') +required(autoWorkflow, 'workflow_dispatch:', 'automatic release manual trigger') +required(autoWorkflow, 'fetch-depth: 0') +required(autoWorkflow, 'fetch-tags: true') +required(autoWorkflow, 'persist-credentials: false') +required(autoWorkflow, 'open-cli-collective/.github/actions/auto-release@74d24fcd862d7b9cbe8f6fdda31db6a833e3d706') +required(autoWorkflow, 'release-paths: apps/**,crates/**,Cargo.toml,Cargo.lock,packaging/**,scripts/**,.github/workflows/release.yml,.github/workflows/auto-release.yml') +required(autoWorkflow, 'version-file: apps/desktop/src-tauri/tauri.conf.json') +const contractIndex = autoWorkflow.indexOf('- name: Check release contract') +const gateIndex = autoWorkflow.indexOf('- id: gate') +assert.ok(contractIndex >= 0 && contractIndex < gateIndex, 'release contract must run before automatic release gate') +required(autoWorkflow, 'run: node scripts/check-release.mjs', 'automatic release contract check') +required(autoDryRunStep, "if: github.event_name == 'workflow_dispatch' && steps.gate.outputs.should-release == 'true'", 'automatic release dry-run condition') +assert.doesNotMatch(autoDryRunStep, /TAP_GITHUB_TOKEN|TAG_TOKEN|push origin/, 'automatic release dry-run token/push isolation') +required(autoTagStep, "if: github.event_name == 'push' && steps.gate.outputs.should-release == 'true'", 'automatic release tag condition') +required(autoTagStep, 'TAP_GITHUB_TOKEN') +required(autoTagStep, 'Same-tag/same-SHA') +required(autoTagStep, 'collision') +required(autoTagStep, 'push origin "refs/tags/$TAG"', 'automatic release tag push') +assert.doesNotMatch(autoTagStep, /DRY_RUN|workflow_dispatch/, 'automatic release tag dry-run branch') +assert.doesNotMatch(autoWorkflow, /dry_run/) +assert.doesNotMatch(autoWorkflow, /version\.txt|identity\.yml|goreleaser/i) +required(credentialStep, 'RETUNE_LASTFM_API_KEY: ${{ vars.LASTFM_API_KEY }}', 'trusted Last.fm API key variable mapping') +required(credentialStep, 'RETUNE_LASTFM_SHARED_SECRET: ${{ secrets.LASTFM_API_SECRET }}', 'trusted Last.fm shared-secret mapping') +required(credentialStep, "node --input-type=module -e \"for (const name of ['RETUNE_LASTFM_API_KEY', 'RETUNE_LASTFM_SHARED_SECRET'])", 'release Last.fm credential presence check') +assert.doesNotMatch(credentialStep, /tauri build|writeFileSync/) +required(nativeBundleStep, 'RETUNE_LASTFM_API_KEY: ${{ vars.LASTFM_API_KEY }}', 'native Last.fm API key variable mapping') required(nativeBundleStep, 'RETUNE_LASTFM_SHARED_SECRET: ${{ secrets.LASTFM_API_SECRET }}', 'trusted Last.fm shared-secret mapping') -required(nativeBundleStep, "RETUNE_LASTFM_API_KEY', 'RETUNE_LASTFM_SHARED_SECRET", 'release Last.fm credential presence check') +assert.equal((workflow.match(/vars\.LASTFM_API_KEY/g) ?? []).length, 2) +assert.equal((workflow.match(/secrets\.LASTFM_API_SECRET/g) ?? []).length, 2) assert.doesNotMatch(ci, /LASTFM_API_KEY|LASTFM_API_SECRET|RETUNE_LASTFM/) -assert.equal((workflow.match(/secrets\.LASTFM_API_(?:KEY|SECRET)/g) ?? []).length, 2) required(buildInstall, '.env.lastfm.local') required(buildInstall, 'chmod 600') required(buildInstall, 'unset RETUNE_LASTFM_API_KEY RETUNE_LASTFM_SHARED_SECRET') @@ -68,6 +102,8 @@ for (const asset of [ ]) required(workflow, asset, `asset contract ${asset}`) const sharedCommit = '74d24fcd862d7b9cbe8f6fdda31db6a833e3d706' +required(ci, `open-cli-collective/.github/actions/pr-title@${sharedCommit}`) +required(ci, 'title: ${{ github.event.pull_request.title }}') for (const action of ['macos-codesign-setup', 'homebrew-alias', 'winget-submit']) { required(workflow, `open-cli-collective/.github/actions/${action}@${sharedCommit}`) } @@ -99,6 +135,21 @@ assert.doesNotMatch(workflow, /uses:.*notariz/i) required(workflow, 'alias-tokens: ""') required(workflow, 'fetch-depth: 0') required(workflow, 'git merge-base --is-ancestor "$GITHUB_SHA" origin/main', 'release main-branch guard') +required(workflow, 'release_line="${baseline%.*}"', 'configured release line') +required(workflow, '[[ ! "$TAG" =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+$ ]]', 'strict release tag') +required(workflow, 'tag_line="${version%.*}"', 'tag-derived release line') +required(nativeBundleStep, 'VERSION: ${{ needs.prepare.outputs.version }}', 'tag version environment') +required(nativeBundleStep, "require('node:fs').writeFileSync('src-tauri/tauri.release.conf.json'", 'Tauri version override config') +required(nativeBundleStep, 'npx tauri build --config src-tauri/tauri.release.conf.json', 'Tauri version override') +assert.doesNotMatch(nativeBundleStep, /shell: bash/) +required(macPackageStep, 'CFBundleShortVersionString', 'macOS package version assertion') +required(macPackageStep, '/usr/libexec/PlistBuddy', 'macOS package metadata assertion') +required(macPackageStep, '[ "$actual" = "$VERSION" ]', 'macOS package version match') +required(windowsRenameStep, 'BaseName -notmatch', 'Windows package version assertion') +required(windowsRenameStep, 'VersionInfo.ProductVersion', 'Windows installer ProductVersion metadata assertion') +required(windowsRenameStep, '$productVersion -ne $env:VERSION', 'Windows installer ProductVersion match') +required(debRenameStep, 'dpkg-deb -f', 'Debian package version assertion') +required(debRenameStep, '[ "$package_version" = "$VERSION" ]', 'Debian package version match') required(ci, 'push:\n branches: ["main"]', 'CI push main-only guard') required(workflow, 'bootstrap: true') required(workflow, 'x64-marker: Retune-${{ needs.prepare.outputs.version }}-windows-x64-setup.exe')