diff --git a/.github/workflows/coverage-on-pr.yml b/.github/workflows/coverage-on-pr.yml index 03260cacc..66405beda 100644 --- a/.github/workflows/coverage-on-pr.yml +++ b/.github/workflows/coverage-on-pr.yml @@ -15,9 +15,37 @@ jobs: env: COVERAGE_COMMENT: 'true' steps: + # The pipeline skips its coverage steps when a PR touches no Go code. + - name: Check the run produced a coverage artifact + id: artifact + env: + GH_TOKEN: ${{ github.token }} + run: | + count=$(gh api "repos/$GITHUB_REPOSITORY/actions/runs/${{ github.event.workflow_run.id }}/artifacts?name=octocov-pr" \ + --jq '.total_count') + echo "count=$count" >> "$GITHUB_OUTPUT" + + # A PR that reverts its Go changes produces no artifact, but octocov's + # comment for the earlier head stays. Match the marker it keys off itself. + - name: Delete the stale coverage comment + if: steps.artifact.outputs.count == '0' + env: + GH_TOKEN: ${{ github.token }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + run: | + number=$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls?state=open&per_page=100" \ + --jq ".[] | select(.head.sha == \"$HEAD_SHA\") | .number" | head -n1) + [ -n "$number" ] || exit 0 + id=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$number/comments" \ + --jq '.[] | select(.user.login == "github-actions[bot]" and (.body | contains(""))) | .id' | head -n1) + if [ -n "$id" ]; then + gh api -X DELETE "repos/$GITHUB_REPOSITORY/issues/comments/$id" + fi + # Only the config, from the base branch: this job holds a write token, so # it must never check out the fork. - name: Check out the octocov config + if: steps.artifact.outputs.count != '0' uses: actions/checkout@v7 with: sparse-checkout: .octocov.yml @@ -27,6 +55,7 @@ jobs: # Into a subdirectory. A pull_request run executes the fork's own copy of # pipeline.yml, so every file in here is attacker-controlled. - uses: actions/download-artifact@v8 + if: steps.artifact.outputs.count != '0' with: name: octocov-pr path: untrusted @@ -35,6 +64,7 @@ jobs: - name: Verify the artifact and take the coverage profile id: pr + if: steps.artifact.outputs.count != '0' env: GH_TOKEN: ${{ github.token }} HEAD_SHA: ${{ github.event.workflow_run.head_sha }} @@ -51,6 +81,7 @@ jobs: echo "number=$number" >> "$GITHUB_OUTPUT" - uses: k1LoW/octocov-action@v1 + if: steps.artifact.outputs.count != '0' env: # A workflow_run job looks like a push to the default branch. Point # octocov back at the pull request and at the run that produced it. diff --git a/.github/workflows/detect-changes.sh b/.github/workflows/detect-changes.sh new file mode 100755 index 000000000..c70c0c0c0 --- /dev/null +++ b/.github/workflows/detect-changes.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# +# Emits per-area change flags to $GITHUB_OUTPUT so the pipeline can skip work a +# pull request cannot affect: +# +# go - Go sources, module files, linter config, embedded resources and +# the go:generate inputs whose generated output CI verifies +# js - anything under ui/ +# i18n - translation files and their validation script +# build - anything that ends up in a binary, image or package (i.e. every +# change except the doc-only paths in $DOC_ONLY_RE) +# +# pipeline.yml and this script are inputs to every suite they gate: a wrong +# gating expression skips a suite green, in every run, with no other signal. +# +# Only pull requests are narrowed. Master pushes, tags and manual runs always get +# every flag, so a release can never be built from a partially validated tree. +# +# Flags gate STEPS, not jobs: a job-level skip propagates through the needs +# chain (actions/runner#491) and would take the release jobs down with it. +# +# Compares HEAD against $BASE_REF (default master). Requires full history +# (fetch-depth: 0 in CI). +set -uo pipefail +export LC_ALL=C + +GO_RE='(\.go$|(^|/)go\.(mod|sum)$|^Makefile$|^\.golangci\.yml$|^resources/|^db/migrations/|^tests/|^plugins/manifest-schema\.json$|^\.github/workflows/(pipeline\.yml|detect-changes\.sh)$)' +JS_RE='(^ui/|^\.github/workflows/(pipeline\.yml|detect-changes\.sh)$)' +I18N_RE='(^resources/i18n/|^ui/src/i18n/en\.json$|^\.github/workflows/validate-translations\.sh$|^\.github/workflows/(pipeline\.yml|detect-changes\.sh)$)' +DOC_ONLY_RE='(\.md$|^LICENSE$|^\.git-blame-ignore-revs$|^\.gitignore$|^\.devcontainer/)' + +emit() { printf '%s=%s\n' "$1" "$2" | tee -a "${GITHUB_OUTPUT:-/dev/null}"; } + +if [ "${GITHUB_EVENT_NAME:-}" != "pull_request" ]; then + echo "Not a pull request — running everything." + for area in go js i18n build; do emit "$area" true; done + exit 0 +fi + +BASE_REF="${BASE_REF:-master}" +git fetch --no-tags --quiet origin "+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" || true +# Guard the diff, not the fetch: checkout already created the ref, so a failed +# refresh is harmless, but an unresolvable ref would emit every flag as false. +if ! git rev-parse --verify --quiet "origin/${BASE_REF}" >/dev/null; then + printf '::error::Cannot resolve origin/%s. In CI, check out with fetch-depth: 0.\n' "$BASE_REF" >&2 + exit 1 +fi + +# --no-renames: rename detection reports only the destination, so moving a file +# out of a gated area would drop the source path from every filter. +files="$(git diff --no-renames --name-only "origin/${BASE_REF}...HEAD")" +echo "Changed files:" +printf '%s\n' "$files" | sed 's/^/ /' +echo + +flag() { # $1=name $2=regex + if grep -qE "$2" <<< "$files"; then emit "$1" true; else emit "$1" false; fi +} + +flag go "$GO_RE" +flag js "$JS_RE" +flag i18n "$I18N_RE" + +if [ -n "$(grep -vE "$DOC_ONLY_RE" <<< "$files")" ]; then + emit build true +else + emit build false +fi diff --git a/.github/workflows/detect-changes_test.sh b/.github/workflows/detect-changes_test.sh new file mode 100755 index 000000000..18e1e8d4a --- /dev/null +++ b/.github/workflows/detect-changes_test.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# Tests detect-changes.sh against a throwaway repo: builds a synthetic PR for +# each change shape and asserts the four emitted flags. +# +# ./.github/workflows/detect-changes_test.sh # tests the sibling script +# ./.github/workflows/detect-changes_test.sh # tests another copy +# +# To confirm a case still bites, edit a pattern out of detect-changes.sh and +# re-run: exactly the case that covers it should fail. +set -uo pipefail +SCRIPT="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/detect-changes.sh}" +T=$(mktemp -d); O=$(mktemp -d) +cd "$T" +git init -q -b master . +git config user.email t@t; git config user.name t +mkdir -p ui/src/i18n resources/i18n .github/workflows db/migrations +echo x > README.md; echo x > main.go; echo x > ui/src/a.js +echo x > resources/i18n/pt.json; echo x > ui/src/i18n/en.json; echo x > go.mod +git add -A; git commit -qm base +git clone -q --bare . "$O/origin.git" +git remote add origin "$O/origin.git" +git fetch -q origin + +fails=0 +run() { # $1=label $2=expected "go js i18n build" ; rest=files + label="$1"; want="$2"; shift 2 + git checkout -q -B test master + for f in "$@"; do mkdir -p "$(dirname "$f")"; echo change >> "$f"; done + git add -A >/dev/null; git commit -qm "$label" + got=$(GITHUB_EVENT_NAME=pull_request BASE_REF=master bash "$SCRIPT" 2>&1 \ + | grep -E '^(go|js|i18n|build)=' | cut -d= -f2 | tr '\n' ' ' | sed 's/ $//') + if [ "$got" = "$want" ]; then printf 'ok %-32s %s\n' "$label" "$got" + else printf 'FAIL %-32s got[%s] want[%s]\n' "$label" "$got" "$want"; fails=$((fails+1)); fi +} + +# go js i18n build +run "docs only" "false false false false" README.md +run "gitignore only" "false false false false" .gitignore +run "go only" "true false false true" core/thing.go +run "ui only" "false true false true" ui/src/b.js +run "i18n resources" "true false true true" resources/i18n/fr.json +run "ui en.json" "false true true true" ui/src/i18n/en.json +run "ui other i18n" "false true false true" ui/src/i18n/provider.js +run "db migration sql" "true false false true" db/migrations/20260101000000_x.sql +run "tests fixture" "true false false true" tests/fixtures/playlist.m3u +run "tests toml" "true false false true" tests/navidrome-test.toml +run "conf testdata" "false false false true" conf/testdata/cfg.toml +run "manifest schema" "true false false true" plugins/manifest-schema.json +run "other plugin json" "false false false true" plugins/testdata/fake/manifest-schema.json +run "nested go.mod" "true false false true" plugins/testdata/x/go.mod +run "Dockerfile only" "false false false true" Dockerfile +run "pipeline.yml" "true true true true" .github/workflows/pipeline.yml +run "detect-changes.sh" "true true true true" .github/workflows/detect-changes.sh +run "other workflow" "false false false true" .github/workflows/stale.yml +run "validate-trans.sh" "false false true true" .github/workflows/validate-translations.sh + +echo "--- large diff must not lose flags to SIGPIPE ---" +git checkout -q -B test master +mkdir -p big/pkg +python3 -c " +import os +os.makedirs('big/pkg', exist_ok=True) +open('big/pkg/aaa_first.go','w').write('x') +for i in range(2500): open('big/pkg/filler_%04d.txt' % i,'w').write('x') +" +git add -A >/dev/null; git commit -qm big +nfiles=$(git diff --no-renames --name-only master...HEAD | wc -l | tr -d ' ') +for i in 1 2 3 4 5; do + got=$(GITHUB_EVENT_NAME=pull_request BASE_REF=master bash "$SCRIPT" 2>&1 \ + | grep -E '^(go|js|i18n|build)=' | cut -d= -f2 | tr '\n' ' ' | sed 's/ $//') + if [ "$got" = "true false false true" ]; then printf 'ok %-32s %s (%s files)\n' "large diff run $i" "$got" "$nfiles" + else printf 'FAIL %-32s got[%s] want[true false false true] (%s files)\n' "large diff run $i" "$got" "$nfiles"; fails=$((fails+1)); fi +done + +echo "--- renames must count the source path ---" +rn() { # $1=label $2=expected $3=from $4=to + git checkout -q -B test master + mkdir -p "$(dirname "$4")"; git mv "$3" "$4" + git add -A >/dev/null; git commit -qm "$1" + got=$(GITHUB_EVENT_NAME=pull_request BASE_REF=master bash "$SCRIPT" 2>&1 \ + | grep -E '^(go|js|i18n|build)=' | cut -d= -f2 | tr '\n' ' ' | sed 's/ $//') + if [ "$got" = "$2" ]; then printf 'ok %-32s %s\n' "$1" "$got" + else printf 'FAIL %-32s got[%s] want[%s]\n' "$1" "$got" "$2"; fails=$((fails+1)); fi +} +# go js i18n build +rn "ui .js -> docs .md" "false true false true" ui/src/a.js docs/a.js.md +rn "go -> docs .md" "true false false true" main.go docs/main.go.md + +echo "--- non-PR events ---" +git checkout -q master +for ev in push workflow_dispatch; do + got=$(GITHUB_EVENT_NAME=$ev bash "$SCRIPT" 2>&1 | grep -E '^(go|js|i18n|build)=' | cut -d= -f2 | tr '\n' ' ' | sed 's/ $//') + if [ "$got" = "true true true true" ]; then printf 'ok %-32s %s\n' "$ev" "$got" + else printf 'FAIL %-32s got[%s]\n' "$ev" "$got"; fails=$((fails+1)); fi +done + +echo "--- unresolvable base ref must fail closed ---" +git checkout -q -B test master; echo x >> main.go; git add -A >/dev/null; git commit -qm x +out=$(GITHUB_EVENT_NAME=pull_request BASE_REF=does-not-exist bash "$SCRIPT" 2>&1); rc=$? +if [ "$rc" != "0" ] && ! grep -qE '^(go|js|i18n|build)=' <<<"$out"; then + printf 'ok %-32s exit=%s, no flags emitted\n' "bad base ref" "$rc" +else + printf 'FAIL %-32s exit=%s out[%s]\n' "bad base ref" "$rc" "$out"; fails=$((fails+1)) +fi + +echo +echo "failures: $fails" +cd /; rm -rf "$T" "$O" +exit "$fails" diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 80ec18e90..115eb2e34 100644 --- a/.github/workflows/download-link-on-pr.yml +++ b/.github/workflows/download-link-on-pr.yml @@ -35,18 +35,27 @@ jobs: const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id}); const downloadable = artifacts.filter((art) => !art.name.startsWith('octocov-')); - if (!downloadable.length) { - return core.error(`No artifacts found`); - } const header = `Download the artifacts for this pull request:`; + + const comments = await github.paginate(github.rest.issues.listComments, {repo, owner, issue_number}); + // Match on the body too: octocov also comments as github-actions[bot]. + const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header)); + + // A PR that changes nothing reaching a binary builds nothing. Delete + // rather than reword: the matcher above keys off the header. + if (!downloadable.length) { + if (existing_comment) { + core.info(`Deleting stale comment ${existing_comment.id}`); + await github.rest.issues.deleteComment({repo, owner, comment_id: existing_comment.id}); + } + return core.info(`No artifacts found`); + } + let body = `${header}\n`; for (const art of downloadable) { body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`; } - const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number}); - // Match on the body too: octocov also comments as github-actions[bot]. - const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(header)); if (existing_comment) { core.info(`Updating comment ${existing_comment.id}`); await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index aa8e29e49..cc90b370a 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -8,6 +8,7 @@ on: pull_request: branches: - master + workflow_dispatch: concurrency: group: ${{ startsWith(github.ref, 'refs/tags/v') && 'tag' || 'branch' }}-${{ github.ref }} @@ -58,13 +59,36 @@ jobs: echo "GIT_TAG=$GIT_TAG" echo "GIT_SHA=$GIT_SHA" + # Outputs gate steps, never jobs: a job-level skip propagates through the needs + # chain (actions/runner#491) and would skip all release jobs on tag pushes. + changes: + name: Detect changed areas + runs-on: ubuntu-latest + outputs: + go: ${{ steps.detect.outputs.go }} + js: ${{ steps.detect.outputs.js }} + i18n: ${{ steps.detect.outputs.i18n }} + build: ${{ steps.detect.outputs.build }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - name: Detect changed areas + id: detect + env: + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: ./.github/workflows/detect-changes.sh + go-lint: name: Lint Go code runs-on: ubuntu-latest + needs: [changes] steps: - uses: actions/checkout@v7 + if: needs.changes.outputs.go == 'true' - uses: actions/setup-go@v6 + if: needs.changes.outputs.go == 'true' with: go-version-file: go.mod @@ -72,9 +96,11 @@ jobs: # cannot turn red in CI just because a new golangci-lint was released. - name: Resolve golangci-lint version id: golangci-version + if: needs.changes.outputs.go == 'true' run: echo "version=$(grep '^GOLANGCI_LINT_VERSION' Makefile | cut -d ' ' -f 3)" >> "$GITHUB_OUTPUT" - name: golangci-lint + if: needs.changes.outputs.go == 'true' uses: golangci/golangci-lint-action@v9 with: version: ${{ steps.golangci-version.outputs.version }} @@ -82,9 +108,12 @@ jobs: args: --timeout 2m - name: Run go goimports + if: needs.changes.outputs.go == 'true' run: go run golang.org/x/tools/cmd/goimports@latest -w `find . -name '*.go' | grep -v '_gen.go$' | grep -v '.pb.go$'` - - run: go mod tidy + - if: needs.changes.outputs.go == 'true' + run: go mod tidy - name: Verify no changes from goimports and go mod tidy + if: needs.changes.outputs.go == 'true' run: | git status --porcelain if [ -n "$(git status --porcelain)" ]; then @@ -93,8 +122,10 @@ jobs: fi - name: Run go generate + if: needs.changes.outputs.go == 'true' run: go generate ./... - name: Verify no changes from go generate + if: needs.changes.outputs.go == 'true' run: | git status --porcelain if [ -n "$(git status --porcelain)" ]; then @@ -126,23 +157,29 @@ jobs: go: name: Test Go code runs-on: ubuntu-latest + needs: [changes] steps: - name: Check out code into the Go module directory + if: needs.changes.outputs.go == 'true' uses: actions/checkout@v7 - uses: actions/setup-go@v6 + if: needs.changes.outputs.go == 'true' with: go-version-file: go.mod - name: Download dependencies + if: needs.changes.outputs.go == 'true' run: go mod download # Name must stay unique across the workflow: octocov matches step names # by name across every job, and waits for each match to finish. - name: Test with coverage + if: needs.changes.outputs.go == 'true' run: go test -shuffle=on -tags netgo,sqlite_fts5 -race -v -covermode=atomic -coverprofile=coverage.out $(go list ./... | grep -v '/plugins$') - name: Test ndpgen + if: needs.changes.outputs.go == 'true' run: | cd plugins/cmd/ndpgen go test -shuffle=on -v @@ -150,6 +187,7 @@ jobs: ./ndpgen --help - name: Upload coverage profile + if: needs.changes.outputs.go == 'true' uses: actions/upload-artifact@v7 with: name: octocov-go @@ -159,18 +197,22 @@ jobs: go-plugins: name: Test Go plugins runs-on: ubuntu-latest + needs: [changes] steps: - name: Check out code into the Go module directory + if: needs.changes.outputs.go == 'true' uses: actions/checkout@v7 - uses: actions/setup-go@v6 id: setup-go + if: needs.changes.outputs.go == 'true' with: go-version-file: go.mod # Without this, the suite recompiles every test plugin WASM module, # which dominates its runtime under -race. - name: Cache the WASM compilation cache + if: needs.changes.outputs.go == 'true' uses: actions/cache@v6 with: path: plugins/testdata/.wazero-cache @@ -178,9 +220,11 @@ jobs: restore-keys: wazero-${{ runner.os }}- - name: Test plugins + if: needs.changes.outputs.go == 'true' run: go tool ginkgo -p -race -tags netgo,sqlite_fts5 --cover --covermode=atomic --coverprofile=coverage.out --output-dir=. ./plugins/ - name: Upload coverage profile + if: needs.changes.outputs.go == 'true' uses: actions/upload-artifact@v7 with: name: octocov-plugins @@ -190,7 +234,7 @@ jobs: coverage: name: Report coverage runs-on: ubuntu-latest - needs: [go, go-plugins] + needs: [changes, go, go-plugins] permissions: contents: read actions: write @@ -198,27 +242,31 @@ jobs: COVERAGE_COMMENT: 'false' steps: - uses: actions/checkout@v7 + if: needs.changes.outputs.go == 'true' - uses: actions/download-artifact@v8 + if: needs.changes.outputs.go == 'true' with: pattern: octocov-* # Merge here rather than letting octocov do it: octocov reports statement # coverage for a single profile, but switches to line counting for several. - name: Merge coverage profiles + if: needs.changes.outputs.go == 'true' run: | echo "mode: atomic" > coverage.out awk 'FNR==1 && /^mode:/ {next} {k=$1" "$2; c[k]+=$3} END {for (k in c) print k, c[k]}' \ octocov-*/coverage.out | sort >> coverage.out - uses: k1LoW/octocov-action@v1 + if: needs.changes.outputs.go == 'true' - name: Save the PR number for the comment workflow - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && needs.changes.outputs.go == 'true' run: echo "${{ github.event.pull_request.number }}" > pr_number - name: Upload the merged profile for the comment workflow - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' && needs.changes.outputs.go == 'true' uses: actions/upload-artifact@v7 with: name: octocov-pr @@ -230,27 +278,33 @@ jobs: go-windows: name: Test Go code (Windows) runs-on: windows-2022 + needs: [changes] env: FFMPEG_VERSION: "7.1" FFMPEG_REPOSITORY: navidrome/ffmpeg-windows-builds steps: - uses: actions/checkout@v7 + if: needs.changes.outputs.go == 'true' - uses: actions/setup-go@v6 + if: needs.changes.outputs.go == 'true' with: go-version-file: go.mod - uses: msys2/setup-msys2@v2 + if: needs.changes.outputs.go == 'true' with: msystem: MINGW64 install: mingw-w64-x86_64-gcc update: false - name: Add mingw64 to PATH + if: needs.changes.outputs.go == 'true' shell: bash run: echo "C:/msys64/mingw64/bin" >> $GITHUB_PATH - name: Cache ffmpeg + if: needs.changes.outputs.go == 'true' id: ffmpeg-cache uses: actions/cache@v6 with: @@ -258,7 +312,7 @@ jobs: key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64 - name: Download ffmpeg - if: steps.ffmpeg-cache.outputs.cache-hit != 'true' + if: needs.changes.outputs.go == 'true' && steps.ffmpeg-cache.outputs.cache-hit != 'true' shell: pwsh run: | $asset = "ffmpeg-n${env:FFMPEG_VERSION}-latest-win64-gpl-${env:FFMPEG_VERSION}" @@ -270,10 +324,12 @@ jobs: Copy-Item "C:\ffmpeg-extracted\$asset\bin\ffprobe.exe" C:\ffmpeg\bin - name: Add ffmpeg to PATH + if: needs.changes.outputs.go == 'true' shell: bash run: echo "C:/ffmpeg/bin" >> $GITHUB_PATH - name: Verify toolchain + if: needs.changes.outputs.go == 'true' shell: pwsh run: | go version @@ -283,16 +339,19 @@ jobs: ffprobe -version - name: Download dependencies + if: needs.changes.outputs.go == 'true' shell: bash run: go mod download - name: Test + if: needs.changes.outputs.go == 'true' shell: bash env: CGO_ENABLED: "1" run: go test -shuffle=on -tags netgo,sqlite_fts5 ./... -v - name: Test ndpgen + if: needs.changes.outputs.go == 'true' shell: bash run: | cd plugins/cmd/ndpgen @@ -303,32 +362,39 @@ jobs: js: name: Test JS code runs-on: ubuntu-latest + needs: [changes] env: NODE_OPTIONS: "--max_old_space_size=4096" steps: - uses: actions/checkout@v7 + if: needs.changes.outputs.js == 'true' - uses: actions/setup-node@v6 + if: needs.changes.outputs.js == 'true' with: node-version: 24 cache: "npm" cache-dependency-path: "**/package-lock.json" - name: npm install dependencies + if: needs.changes.outputs.js == 'true' run: | cd ui npm ci - name: npm lint + if: needs.changes.outputs.js == 'true' run: | cd ui npm run check-formatting && npm run lint - name: npm test + if: needs.changes.outputs.js == 'true' run: | cd ui npm test - name: npm build + if: needs.changes.outputs.js == 'true' run: | cd ui npm run build @@ -336,9 +402,12 @@ jobs: i18n-lint: name: Lint i18n files runs-on: ubuntu-latest + needs: [changes] steps: - uses: actions/checkout@v7 - - run: | + if: needs.changes.outputs.i18n == 'true' + - if: needs.changes.outputs.i18n == 'true' + run: | set -e for file in resources/i18n/*.json; do echo "Validating $file" @@ -350,6 +419,7 @@ jobs: fi done - run: ./.github/workflows/validate-translations.sh -v + if: needs.changes.outputs.i18n == 'true' check-push-enabled: @@ -364,7 +434,7 @@ jobs: build: name: Build - needs: [js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] + needs: [changes, js, go, go-plugins, go-windows, go-lint, i18n-lint, git-version, check-push-enabled, validate-migrations] strategy: matrix: platform: [ linux/amd64, linux/arm64, linux/arm/v5, linux/arm/v6, linux/arm/v7, linux/386, linux/riscv64, darwin/amd64, darwin/arm64, windows/amd64, windows/386 ] @@ -373,19 +443,23 @@ jobs: IS_LINUX: ${{ startsWith(matrix.platform, 'linux/') && 'true' || 'false' }} IS_ARMV5: ${{ matrix.platform == 'linux/arm/v5' && 'true' || 'false' }} IS_DOCKER_PUSH_CONFIGURED: ${{ needs.check-push-enabled.outputs.is_enabled == 'true' }} + SHOULD_BUILD: ${{ needs.changes.outputs.build }} DOCKER_BUILD_SUMMARY: false GIT_SHA: ${{ needs.git-version.outputs.git_sha }} GIT_TAG: ${{ needs.git-version.outputs.git_tag }} steps: - name: Sanitize platform name + if: env.SHOULD_BUILD == 'true' id: set-platform run: | PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_') echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV - uses: actions/checkout@v7 + if: env.SHOULD_BUILD == 'true' - name: Prepare Docker Buildx + if: env.SHOULD_BUILD == 'true' uses: ./.github/actions/prepare-docker id: docker with: @@ -395,6 +469,7 @@ jobs: hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }} - name: Build Binaries + if: env.SHOULD_BUILD == 'true' uses: docker/build-push-action@v7 with: context: . @@ -408,14 +483,14 @@ jobs: GIT_TAG=${{ env.GIT_TAG }} - name: Set up QEMU for smoke test - if: env.IS_LINUX == 'true' + if: env.SHOULD_BUILD == 'true' && env.IS_LINUX == 'true' uses: docker/setup-qemu-action@v4 # The binary is static, so binfmt+qemu runs it directly on the runner. # Catches startup crashes in cross-compiled binaries before they ship, # e.g. the broken ifunc relocations on 32-bit arm from issue #5738. - name: Smoke-test binary - if: env.IS_LINUX == 'true' + if: env.SHOULD_BUILD == 'true' && env.IS_LINUX == 'true' run: | BIN=./output/${{ env.PLATFORM }}/navidrome chmod +x "$BIN" @@ -423,6 +498,7 @@ jobs: echo "OK: ${{ matrix.platform }} binary starts" - name: Upload Binaries + if: env.SHOULD_BUILD == 'true' uses: actions/upload-artifact@v7 with: name: navidrome-${{ env.PLATFORM }} @@ -431,7 +507,7 @@ jobs: - name: Build and push image by digest id: push-image - if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false' + if: env.SHOULD_BUILD == 'true' && env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false' uses: docker/build-push-action@v7 with: context: . @@ -446,7 +522,7 @@ jobs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true - name: Export digest - if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false' + if: env.SHOULD_BUILD == 'true' && env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false' run: | mkdir -p /tmp/digests digest="${{ steps.push-image.outputs.digest }}" @@ -454,7 +530,7 @@ jobs: - name: Upload digest uses: actions/upload-artifact@v7 - if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false' + if: env.SHOULD_BUILD == 'true' && env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false' with: name: digests-${{ env.PLATFORM }} path: /tmp/digests/* @@ -467,8 +543,8 @@ jobs: contents: read packages: write runs-on: ubuntu-latest - needs: [build, check-push-enabled] - if: needs.check-push-enabled.outputs.is_enabled == 'true' + needs: [changes, build, check-push-enabled] + if: needs.check-push-enabled.outputs.is_enabled == 'true' && needs.changes.outputs.build == 'true' env: REGISTRY_IMAGE: ghcr.io/${{ github.repository }} steps: @@ -502,8 +578,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - needs: [build, check-push-enabled] - if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != '' + needs: [changes, build, check-push-enabled] + if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != '' && needs.changes.outputs.build == 'true' continue-on-error: true steps: - uses: actions/checkout@v7 @@ -555,22 +631,26 @@ jobs: msi: name: Build Windows installers - needs: [build, git-version] + needs: [changes, build, git-version] runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 + if: needs.changes.outputs.build == 'true' - uses: actions/download-artifact@v8 + if: needs.changes.outputs.build == 'true' with: path: ./binaries pattern: navidrome-windows* merge-multiple: true - name: Install Wix + if: needs.changes.outputs.build == 'true' run: sudo apt-get install -y wixl jq - name: Build MSI + if: needs.changes.outputs.build == 'true' env: GIT_TAG: ${{ needs.git-version.outputs.git_tag }} run: | @@ -580,6 +660,7 @@ jobs: du -h binaries/msi/*.msi - name: Upload MSI files + if: needs.changes.outputs.build == 'true' uses: actions/upload-artifact@v7 with: name: navidrome-windows-installers @@ -588,29 +669,33 @@ jobs: release: name: Package/Release - needs: [build, msi] + needs: [changes, build, msi] runs-on: ubuntu-latest outputs: package_list: ${{ steps.set-package-list.outputs.package_list }} steps: - uses: actions/checkout@v7 + if: needs.changes.outputs.build == 'true' with: fetch-depth: 0 fetch-tags: true - uses: actions/download-artifact@v8 + if: needs.changes.outputs.build == 'true' with: path: ./binaries pattern: navidrome-* merge-multiple: true - run: ls -lR ./binaries + if: needs.changes.outputs.build == 'true' - name: Set RELEASE_FLAGS for snapshot releases - if: env.IS_RELEASE == 'false' + if: needs.changes.outputs.build == 'true' && env.IS_RELEASE == 'false' run: echo 'RELEASE_FLAGS=--skip=publish --snapshot' >> $GITHUB_ENV - name: Run GoReleaser + if: needs.changes.outputs.build == 'true' uses: goreleaser/goreleaser-action@v7 with: version: '2.16.0' @@ -619,17 +704,20 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Remove build artifacts + if: needs.changes.outputs.build == 'true' run: | ls -l ./dist rm ./dist/*.tar.gz ./dist/*.zip - name: Upload all-packages artifact + if: needs.changes.outputs.build == 'true' uses: actions/upload-artifact@v7 with: name: packages path: dist/navidrome_0* - id: set-package-list + if: needs.changes.outputs.build == 'true' name: Export list of generated packages run: | cd dist @@ -641,7 +729,10 @@ jobs: upload-packages: name: Upload Linux PKG runs-on: ubuntu-latest - needs: [release] + needs: [changes, release] + # Job-level skip is safe here: nothing needs this job, and fromJson would + # error on the empty package_list a skipped release leaves behind. + if: needs.changes.outputs.build == 'true' strategy: matrix: item: ${{ fromJson(needs.release.outputs.package_list) }}