diff --git a/.editorconfig b/.editorconfig index f0c1cf12..930052fb 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,6 @@ -# SPDX-FileCopyrightText: 2014-2026 Bernard Ladenthin +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin # -# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 # https://editorconfig.org root = true diff --git a/.gitattributes b/.gitattributes index 0a1b8d5d..440bfb57 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,6 @@ -# SPDX-FileCopyrightText: 2014-2026 Bernard Ladenthin +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin # -# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 * text=auto eol=lf +*.gguf binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ce0569d6..eaead476 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,6 +1,6 @@ -# SPDX-FileCopyrightText: 2014-2026 Bernard Ladenthin +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin # -# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 # https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners # Default reviewer for all paths. diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 204af027..c5f202d6 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,6 +1,6 @@ -# SPDX-FileCopyrightText: 2014-2026 Bernard Ladenthin +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin # -# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 # Sponsorship configuration for github.com/bernardladenthin. # Uncomment any line below to advertise a funding channel on the repository. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index b6ca577e..40b634ed 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,6 +1,6 @@ -# SPDX-FileCopyrightText: 2014-2026 Bernard Ladenthin +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin # -# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 blank_issues_enabled: false contact_links: diff --git a/.github/actions/setup-openjml/action.yml b/.github/actions/setup-openjml/action.yml index fd6e6ce0..7d5dd6f0 100644 --- a/.github/actions/setup-openjml/action.yml +++ b/.github/actions/setup-openjml/action.yml @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: 2014-2026 Bernard Ladenthin # -# SPDX-License-Identifier: Apache-2.0 +# SPDX-License-Identifier: MIT OR Apache-2.0 name: Set up OpenJML description: > diff --git a/.github/buildcheck/__init__.py b/.github/buildcheck/__init__.py new file mode 100644 index 00000000..add4f0f5 --- /dev/null +++ b/.github/buildcheck/__init__.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 +"""Build checks as a library, so buildcheck/tests can test them. Standard library only. + +Two kinds of module live here. workflow.py, releasegate.py, sharedfiles.py, versions.py and +runscripts.py (with their tests and the check-*.py entry points next to this package) are kept +BYTE-IDENTICAL in java-llama.cpp, BitcoinAddressFinder, srcmorph and streambuffer: each repository +lists them in .github/shared-files.sha256, which its `shared-files` job checks (see sharedfiles.py). +Every other module is the repository's own. + +Run the tests from the repository root: + python3 -m unittest discover -s .github/buildcheck/tests -t .github +""" diff --git a/.github/buildcheck/releasegate.py b/.github/buildcheck/releasegate.py new file mode 100644 index 00000000..729e2150 --- /dev/null +++ b/.github/buildcheck/releasegate.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 +"""Every job of publish.yml gates both publish jobs, unless the repository lists it in +.github/release-gate-exemptions.txt with a reason. + +A job that nothing waits for can go red and a release still ships -- the natives-build jobs that +`package` once forgot to wait for, and the aarch64 fat jars that were signed and attached for +releases without any job launching them, were both of that shape. So "not gating" has to be a +decision written down here, not the default a new job gets by being forgotten in two `needs:` lists. + +The check runs both ways: a job outside the gates and outside the exemptions fails, and so does an +exemption that names no job or a job that gates both publish jobs after all (a stale +exemption would hide the next job of that name). +""" + +from . import workflow + +GATES = ("publish-snapshot", "publish-release") + +# Each repository lists its own exemptions here, one `: ` per line (# comments). +EXEMPTIONS_FILE = ".github/release-gate-exemptions.txt" + + +def read_exemptions(text): + """The exemptions file: job -> reason. A line without a reason is an error -- the point of the + file is that every job allowed to stay red says why.""" + exemptions = {} + for number, line in enumerate(text.splitlines(), 1): + line = line.strip() + if not line or line.startswith("#"): + continue + job, _, reason = line.partition(":") + if not reason.strip() or not job.strip(): + raise ValueError(f"line {number}: expected `: `, got {line!r}") + if job.strip() in exemptions: + raise ValueError(f"line {number}: {job.strip()} is listed twice") + exemptions[job.strip()] = reason.strip() + return exemptions + + +def check(jobs, non_gating, gates=GATES): + missing = [g for g in gates if g not in jobs] + if missing: + return [f"publish.yml has no job {g}" for g in missing] + closures = [workflow.closure(jobs, g) for g in gates] + failures = [] + for name in jobs: + gated = [g for g, c in zip(gates, closures) if name in c] + if name in non_gating: + if len(gated) == len(gates): + failures.append(f"release-gate-exemptions.txt lists {name}, which gates {', '.join(gates)} " + f"-- remove the stale exemption") + elif len(gated) != len(gates): + ungated = [g for g in gates if g not in gated] + failures.append(f"publish.yml: {', '.join(ungated)} does not wait for {name} -- add it to the " + f"needs, or to release-gate-exemptions.txt with the reason it may stay red") + failures += [f"release-gate-exemptions.txt lists {name}, which is no job of publish.yml" + for name in sorted(set(non_gating) - set(jobs))] + return failures diff --git a/.github/buildcheck/runscripts.py b/.github/buildcheck/runscripts.py new file mode 100644 index 00000000..ea685058 --- /dev/null +++ b/.github/buildcheck/runscripts.py @@ -0,0 +1,273 @@ +# SPDX-FileCopyrightText: 2026 Bernard Ladenthin +# +# SPDX-License-Identifier: MIT OR Apache-2.0 +"""Every `run:` script of the workflows and composite actions that runs in bash, parsed with +`bash -n` -- so a broken script fails the `shared-files` job in the first minutes of a run instead +of the job that executes it, possibly hours later or only on a release path. + +The case this exists for: a cleanup lost the trailing backslash of six continued lines, leaving a +line that starts with `||` or `-e` -- in a step only a publish run or a native build reaches. Neither +actionlint (it parses scripts only when shellcheck is installed) nor a review saw it; `bash -n` sees +every one of them. + +Which scripts are bash is decided the way the runner decides it: the step's `shell:`, else the job's +and then the workflow's `defaults.run.shell`, else the runner's default -- PowerShell on a Windows +runner, bash everywhere else. A runner chosen by an expression (a matrix, a workflow input) counts +as Windows only when the step's `if:` says so; a step without `shell:` on such a job runs on every +OS of the matrix and has to be valid bash anyway. Scripts in `sh`, `bash -el {0}` or a bash given by +path are bash; pwsh, powershell, cmd and python are skipped. + +Like workflow.py this reads the two-space layout the workflows are written in, not general YAML. +The run value is taken as YAML defines it: a literal block (`|`) line for line, a folded block +(`>`) and a plain scalar folded into one line, a single-quoted scalar unquoted. A double-quoted +scalar is skipped (its escapes are not worth a YAML parser). Every `${{ ... }}` expression becomes +a word, which is what the runner substitutes before bash sees the script. +""" + +import glob +import os +import re +import shutil +import subprocess +import sys + +RUN = re.compile(r"^(?P\s*(?:- )?)run:(?:\s+(?P.*?))?\s*$") +BLOCK_HEADER = re.compile(r"^(?P