Skip to content

Commit ccdac51

Browse files
committed
ci: Add the pull-request workflow
First workflow that runs on pull requests — every existing one is gated with 'if: github.event_name != pull_request'. - dco: every non-merge commit in the PR needs a Signed-off-by matching its author (brain decision 0008). Pull requests only: history predating that decision is unsigned and is not rewritten. - license-headers: runs .github/scripts/spdx-headers.sh, vendored here from the second brain. - shellcheck and the Bash test suite run but do not gate yet; the comments in the file say what has to happen before they do. Signed-off-by: Simon Walz <contact@simonwalz.de>
1 parent 103e31a commit ccdac51

2 files changed

Lines changed: 307 additions & 0 deletions

File tree

‎.github/scripts/spdx-headers.sh‎

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# spdx-headers.sh — check or add SPDX license headers in a CuOS repository.
5+
#
6+
# ./spdx-headers.sh check [REPO_DIR] # list files missing a header; exit 1 if any
7+
# ./spdx-headers.sh fix [REPO_DIR] # insert the header where it is missing
8+
# ./spdx-headers.sh list [REPO_DIR] # list every file considered in scope
9+
#
10+
# REPO_DIR defaults to the current directory. Only git-tracked files are
11+
# considered, so build output and untracked scratch files are ignored.
12+
#
13+
# Policy: Apache-2.0 for every file CuOS itself authors — see
14+
# brain/decisions/0007-apache-2-0-everywhere.md. There is still no license map:
15+
# every in-scope file gets the same identifier. What there now *is* is a short
16+
# list of vendored third-party files that must never be stamped at all, because
17+
# they carry someone else's license — see third_party() below.
18+
#
19+
# Scope and exclusions are defined in third_party()/in_scope()/comment_style()
20+
# below, with a reason per exclusion. Keep them there and nowhere else.
21+
#
22+
# Run by .github/workflows/ci.yml. The same file is vendored in all four CuOS
23+
# repositories; change it in one and copy it to the others.
24+
25+
set -euo pipefail
26+
27+
IDENTIFIER="Apache-2.0"
28+
MODE="${1:-check}"
29+
REPO_DIR="${2:-.}"
30+
31+
usage() {
32+
sed -n '3,20p' "${BASH_SOURCE[0]}" | sed 's|^# \?||'
33+
exit 2
34+
}
35+
36+
case "${MODE}" in
37+
check|fix|list) ;;
38+
*) usage ;;
39+
esac
40+
41+
cd "${REPO_DIR}" || { echo "Error: cannot enter ${REPO_DIR}" >&2; exit 2; }
42+
git rev-parse --git-dir >/dev/null 2>&1 || {
43+
echo "Error: ${REPO_DIR} is not a git repository." >&2; exit 2; }
44+
45+
# Which comment syntax a file uses — also decides whether it is in scope at all.
46+
comment_style() {
47+
local file="$1"
48+
case "${file}" in
49+
# ---- in scope -----------------------------------------------------------
50+
*.sh|Dockerfile|*/Dockerfile|Dockerfile.*|*/Dockerfile.*) echo hash ;;
51+
.github/*.yml|.github/*.yaml|*/.github/*.yml|*/.github/*.yaml) echo hash ;;
52+
docker-compose*.yml|*/docker-compose*.yml) echo hash ;;
53+
*.js|*.jsx|*.mjs|*.cjs|*.ts|*.tsx) echo slash ;;
54+
*.css) echo block ;;
55+
56+
# ---- out of scope, with the reason --------------------------------------
57+
# *.json — JSON cannot carry comments (schemas, fixtures, package*)
58+
# *.md — documentation; the license is stated in LICENSE/NOTICE
59+
# *.handlebars — could use {{!-- --}}; deliberately left out for now
60+
# *.test.*.yml — test fixtures: a header may change what a test compares
61+
# *.gpg, *.key — third-party archive keys and binary material
62+
# *.png — binary
63+
# LICENSE*, NOTICE, DCO.txt, *.rc, *.service, *.socket, *.env, *.cmd
64+
# — legal texts and small config files
65+
# NB cuos/system/armbianEnv.txt carries an Apache-2.0 header anyway (U-Boot
66+
# skips '#' lines on "env import -t", verified 2026-08-26). It is out of
67+
# scope here, so the tool neither checks nor maintains it.
68+
*) echo "" ;;
69+
esac
70+
}
71+
72+
# Vendored third-party files. These carry someone else's copyright, so this
73+
# script must never stamp them with the project identifier — not even if the
74+
# extension is later brought into scope. They are listed here rather than in
75+
# comment_style() so the reason survives a change to the scope rules.
76+
# Attribution for each one lives in the repo's NOTICE.
77+
third_party() {
78+
case "$1" in
79+
# Armbian U-Boot boot script for Allwinner H616 (Orange Pi Zero 3), from
80+
# armbian/build config/bootscripts/boot-sun50iw9.cmd — GPL-2.0-only.
81+
system/armbian-boot.cmd|*/system/armbian-boot.cmd) return 0 ;;
82+
esac
83+
return 1
84+
}
85+
86+
in_scope() {
87+
local file="$1"
88+
89+
# Someone else's license: never in scope, whatever the extension says.
90+
third_party "${file}" && return 1
91+
92+
# The test *scripts* are ordinary shell code and must be matched BEFORE the
93+
# fixture exclusion below — `api.test.sh` also matches `*.test.*`.
94+
case "${file}" in
95+
*.test.sh) return 0 ;;
96+
esac
97+
# Test fixtures: never touch, whatever the extension. A header could change
98+
# what a test compares.
99+
case "${file}" in
100+
*.test.*|*/init.test.sys_class_net/*) return 1 ;;
101+
esac
102+
103+
case "${file}" in
104+
*/node_modules/*|node_modules/*) return 1 ;;
105+
*/dist/*|dist/*) return 1 ;;
106+
*.min.js) return 1 ;;
107+
esac
108+
109+
[[ -n "$(comment_style "${file}")" ]] && return 0
110+
111+
# Extensionless files count when they carry a shebang (dev-container/docker,
112+
# iac/api/*, initramfs hooks).
113+
if [[ "$(basename "${file}")" != *.* ]] && [[ -f "${file}" ]]; then
114+
head -c2 "${file}" 2>/dev/null | grep -q '#!' && return 0
115+
fi
116+
return 1
117+
}
118+
119+
style_for() { # extensionless shebang files are shell
120+
local s
121+
s="$(comment_style "$1")"
122+
echo "${s:-hash}"
123+
}
124+
125+
has_header() {
126+
head -5 "$1" 2>/dev/null | grep -q "SPDX-License-Identifier"
127+
}
128+
129+
header_line() {
130+
case "$1" in
131+
hash) echo "# SPDX-License-Identifier: ${IDENTIFIER}" ;;
132+
slash) echo "// SPDX-License-Identifier: ${IDENTIFIER}" ;;
133+
block) echo "/* SPDX-License-Identifier: ${IDENTIFIER} */" ;;
134+
esac
135+
}
136+
137+
# Insert the header. A shebang MUST stay on line 1 — inserting above it breaks
138+
# the file. This bug shipped twice during the initial sweep; do not "simplify"
139+
# it away.
140+
insert_header() {
141+
local file="$1" line="$2" tmp
142+
tmp="$(mktemp)"
143+
if head -1 "${file}" | grep -q '^#!'; then
144+
{ head -1 "${file}"; echo "${line}"; tail -n +2 "${file}"; } >"${tmp}"
145+
else
146+
{ echo "${line}"; cat "${file}"; } >"${tmp}"
147+
fi
148+
cat "${tmp}" >"${file}" # preserves mode and ownership
149+
rm -f "${tmp}"
150+
}
151+
152+
missing=0
153+
fixed=0
154+
scoped=0
155+
156+
while IFS= read -r file; do
157+
[[ -f "${file}" ]] || continue # deleted but still tracked
158+
in_scope "${file}" || continue
159+
scoped=$((scoped + 1))
160+
161+
if [[ "${MODE}" == "list" ]]; then
162+
printf '%s\t%s\n' "$(style_for "${file}")" "${file}"
163+
continue
164+
fi
165+
166+
has_header "${file}" && continue
167+
168+
if [[ "${MODE}" == "fix" ]]; then
169+
insert_header "${file}" "$(header_line "$(style_for "${file}")")"
170+
echo "added: ${file}"
171+
fixed=$((fixed + 1))
172+
else
173+
echo "missing header: ${file}"
174+
missing=$((missing + 1))
175+
fi
176+
done < <(git ls-files)
177+
178+
case "${MODE}" in
179+
list)
180+
echo "---"
181+
echo "${scoped} files in scope"
182+
;;
183+
fix)
184+
echo "---"
185+
echo "${fixed} header(s) added, ${scoped} files in scope"
186+
;;
187+
check)
188+
if (( missing > 0 )); then
189+
echo "---" >&2
190+
echo "FAIL: ${missing} of ${scoped} in-scope files lack an SPDX header." >&2
191+
echo "Run: $(basename "${BASH_SOURCE[0]}") fix" >&2
192+
exit 1
193+
fi
194+
echo "OK: all ${scoped} in-scope files carry an SPDX header."
195+
;;
196+
esac

‎.github/workflows/ci.yml‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
name: CI
3+
4+
on:
5+
pull_request:
6+
push:
7+
branches:
8+
- main
9+
- development
10+
11+
permissions:
12+
contents: read
13+
14+
jobs:
15+
dco:
16+
name: DCO sign-off
17+
# Only pull requests. History predating brain decision 0008 is unsigned and
18+
# is deliberately not rewritten, so checking pushes would fail on ancestors.
19+
if: github.event_name == 'pull_request'
20+
runs-on: ubuntu-latest
21+
steps:
22+
- name: Checkout repository
23+
uses: actions/checkout@v7
24+
with:
25+
fetch-depth: 0
26+
27+
- name: Every commit carries a matching Signed-off-by
28+
shell: bash
29+
env:
30+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
31+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
32+
run: |
33+
set -euo pipefail
34+
missing=0
35+
checked=0
36+
# Merge commits are exempt: they carry no authored change.
37+
while read -r sha; do
38+
[[ -n "${sha}" ]] || continue
39+
checked=$((checked + 1))
40+
author="$(git log -1 --format='%aN <%aE>' "${sha}")"
41+
if ! git log -1 --format='%(trailers:key=Signed-off-by,valueonly)' "${sha}" \
42+
| grep -qiFx "${author}"; then
43+
echo "::error::${sha:0:8} $(git log -1 --format=%s "${sha}")"
44+
echo " no Signed-off-by matching the author: ${author}"
45+
missing=$((missing + 1))
46+
fi
47+
done < <(git rev-list --no-merges "${BASE_SHA}..${HEAD_SHA}")
48+
49+
if (( missing > 0 )); then
50+
echo
51+
echo "${missing} of ${checked} commit(s) lack a matching sign-off."
52+
echo "Fix with: git rebase --signoff ${BASE_SHA}"
53+
echo "Then: git push --force-with-lease"
54+
echo "See DCO.txt and CONTRIBUTING.md."
55+
exit 1
56+
fi
57+
echo "OK: all ${checked} commit(s) signed off."
58+
59+
license-headers:
60+
name: SPDX license headers
61+
runs-on: ubuntu-latest
62+
steps:
63+
- name: Checkout repository
64+
uses: actions/checkout@v7
65+
66+
- name: Every in-scope file carries an SPDX header
67+
run: .github/scripts/spdx-headers.sh check
68+
69+
shellcheck:
70+
name: shellcheck
71+
runs-on: ubuntu-latest
72+
# Not yet a gate: the existing scripts have never been linted, so the
73+
# backlog is unknown. Look at a few runs, fix what it finds, then remove
74+
# this line to make it blocking.
75+
continue-on-error: true
76+
steps:
77+
- name: Checkout repository
78+
uses: actions/checkout@v7
79+
80+
- name: Run shellcheck
81+
shell: bash
82+
run: |
83+
set -euo pipefail
84+
command -v shellcheck >/dev/null || sudo apt-get install -y shellcheck
85+
mapfile -t files < <(git ls-files '*.sh')
86+
if (( ${#files[@]} == 0 )); then
87+
echo "No shell scripts."
88+
exit 0
89+
fi
90+
shellcheck --severity=warning --external-sources "${files[@]}"
91+
92+
tests:
93+
name: Bash test suite
94+
runs-on: ubuntu-latest
95+
# Not yet a gate: two suites fail on missing tools (yq mikefarah v4, jv),
96+
# not on defects — see brain/tasks/fix-failing-tests.md. Remove this line
97+
# once those are installed here or the tests skip cleanly.
98+
continue-on-error: true
99+
steps:
100+
- name: Checkout repository
101+
uses: actions/checkout@v7
102+
103+
- name: Run every *.test.sh
104+
shell: bash
105+
run: |
106+
set -euo pipefail
107+
if [[ ! -x .pipeline-test.sh ]]; then
108+
echo "No .pipeline-test.sh in this repository."
109+
exit 0
110+
fi
111+
./.pipeline-test.sh

0 commit comments

Comments
 (0)