Compare commits

...

11 commits

Author SHA1 Message Date
Deluan
27abc83ac8 ci: add the detect-changes test suite
Thirty assertions over the change shapes the filters have to get right,
including the ones that only appeared under review: a large diff that used to
lose flags to SIGPIPE, renames that hide their source path, and an unresolvable
base ref that must fail closed rather than emit every flag as false.

It builds a throwaway repo per case, so it needs no fixtures and leaves nothing
behind. Nothing runs it in CI yet; it is a development tool for whoever changes
a regex next.
2026-09-06 23:49:42 -04:00
Deluan Quintão
bd611e7f6c
Merge branch 'master' into ci-skip-unchanged 2026-09-06 23:30:21 -04:00
Deluan
dd6d9b7891 ci: treat the plugin manifest schema as a Go change
plugins/manifest.go:37 declares manifest-schema.json as the input to a real
go:generate directive producing manifest_gen.go, so a schema-only edit skipped
the very check that catches a forgotten regeneration. Verified by adding a
property to the schema and re-running go generate, which changed the output.

All nine commits that ever touched the schema were already caught, but that is
the check working rather than evidence it is unnecessary: an author who forgets
to regenerate cannot reach merged history while the check runs.

This is the only gap of its kind. The repo has two go:generate directives, and
wire's inputs are all Go source.
2026-09-05 14:40:35 -04:00
Deluan
4f3e12738a ci: delete the stale coverage comment when a run produces no profile
A PR that reverts its Go changes but keeps a doc change produces no coverage
artifact, so every step after the probe skips, octocov never runs, and its
comment for the earlier head stays on the PR reporting a delta that is zero by
construction. Gating those steps is what made this reachable.

The comment is matched on `<!-- octocov -->`, the signature octocov appends and
keys `updatePrevious` off itself, which is also what distinguishes it from the
download-link comment posted by the same bot.

The PR number comes from the base repo's workflow_run head sha rather than from
the artifact, so the fork-controlled-input cross-check that guards the profile
path does not apply: nothing is downloaded or executed on this path.
2026-09-05 14:33:36 -04:00
Deluan
d3bed09363 ci: fix three change-detection gaps found in review
`grep -q` exits on its first match, which closes the pipe under the still-
writing `printf`. Under `pipefail` the killed writer, not the successful match,
sets the exit status, so a large diff emitted `false` for an area that had
changed. It is a race rather than a buffer threshold: it starts firing at
around 1200 paths, well under the 64 KiB pipe buffer, and it kills the second
`grep` in the build pipeline too. Both pipelines are now here-strings. The
largest diff in this repo's history is 519 files, so this was latent, but a
repo-wide sweep or a mass directory move reaches it.

pipeline.yml and this script are now inputs to the go, js and i18n filters. A
pipeline-only change previously skipped every suite that pipeline.yml defines,
so a broken test command merged green and surfaced on master. Worse, a wrong
gating expression is invisible in every run: `outputs.golang` instead of
`outputs.go` reads as empty on master pushes too, and the suite disappears
silently and permanently. This change adds 63 such expressions. Of 82
historical pipeline-only commits, 38 edited a job that is now gated, and the
measured cost is about 12 extra full runs a year.

The download-link workflow returned before looking for its own previous
comment, so a PR that built binaries and then became docs-only kept advertising
artifacts from a commit that is no longer the head. That branch was unreachable
until this PR gated the build. It now deletes the stale comment. Deleting
rather than rewording is deliberate: the comment is matched by its header, so a
reworded body would either have to keep a header that contradicts it or start
posting duplicates. The lookup is also paginated now; it saw only the first 30
comments.
2026-09-05 14:25:03 -04:00
Deluan
86e6c62cf1 ci: count the source path of a rename in change detection
Rename detection makes `git diff --name-only` report only the destination, so
moving a file out of a gated area dropped the source from every filter. A
rename of ui/src/a.js to docs/a.js.md emitted all four flags as false, skipping
the build for a change that deleted UI source.

--no-renames turns the rename back into a delete plus an add, so both paths
participate. It is also about 8x faster on a large diff, since rename detection
is the expensive part, and it can only ever move a flag from false to true.
2026-09-05 14:09:39 -04:00
Deluan
98e435490d ci: treat everything under tests/ as a Go change
tests/ holds only Go test scaffolding: the shared mocks, tests/fixtures, and
tests/navidrome-test.toml, which tests.Init reads for every suite. A change to
any of it can alter a Go test result, and the directory can never collide with
a frontend or docs path.

This deliberately does not cover testdata/ generally. Measured over the full
history, 99 of 103 pull requests touching testdata/ or tests/fixtures/ already
matched the Go filter, and the single missed pull request was a frontend one
that a broader pattern would have made worse by running the whole Go matrix.
2026-09-05 14:00:07 -04:00
Deluan Quintão
579cc3c9ff
Merge branch 'master' into ci-skip-unchanged 2026-09-05 13:53:34 -04:00
Deluan
596bbf91b4 ci: do not treat an empty artifact list as an error on a PR
A pull request that changes nothing reaching a binary now builds nothing, so
the download-link workflow finding no artifacts is an expected outcome rather
than a problem worth an error annotation.
2026-09-05 13:50:44 -04:00
Deluan
16b9f60a21 ci: close change-detection gaps found in review
Four changes, three of them gaps in the filters added by the previous commit.

Go tests execute db/migrations/*.sql for real: persistence_suite_test.go calls
db.Init, which runs goose against an in-memory database. `make migration-sql`
produces a .sql-only diff, and six such commits exist in history, so those PRs
would have run zero Go tests. Added ^db/migrations/ to the Go filter.

validate-translations.sh reads ui/src/i18n/en.json as its reference, not
resources/i18n/en.json, which does not exist. A commit that only removes an
English key (dd4802c0c is one) would have skipped the only check that reports
the orphaned keys left in all 36 translations.

The coverage-artifact probe was unpaginated. A full Go pipeline run produces
exactly 30 artifacts, the API default page size, and octocov-pr sorts to index
27 because the test jobs finish first. Run 33503006884 already produced 33 and
pushed all three coverage artifacts off page one. Server-side ?name= filtering
has no count ceiling.

The script also now fails closed if the base ref cannot be resolved. The fetch
itself stays non-fatal: actions/checkout already created the ref, so a failed
refresh is harmless, and making it fatal would turn a transient blip into a red
pipeline.

Also adds workflow_dispatch, so a manual run is possible and gets every flag.
2026-09-05 13:47:20 -04:00
Deluan
afd04c742c ci: skip pipeline steps for areas a pull request does not touch
A new `changes` job diffs the PR against its base branch and emits four
flags (go, js, i18n, build) that gate the steps of the test, lint and build
jobs. A docs-only PR now runs no Go tests and no 11-platform build.

The flags gate steps rather than jobs on purpose: a job-level skip
propagates through the needs chain (actions/runner#491) and would take the
release jobs down with it on tag pushes. Only upload-packages and the
push-manifest jobs skip at the job level, where nothing depends on them.

Master pushes and tags always get every flag, so a release can never be
built from a partially validated tree.

coverage-on-pr.yml now probes for the coverage artifact before downloading
it, since a PR with no Go changes produces none.
2026-09-05 13:31:45 -04:00
5 changed files with 334 additions and 25 deletions

View file

@ -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("<!-- octocov -->"))) | .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.

68
.github/workflows/detect-changes.sh vendored Executable file
View file

@ -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

110
.github/workflows/detect-changes_test.sh vendored Executable file
View file

@ -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 <path> # 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"

View file

@ -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});

View file

@ -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) }}