Skip to content

Commit ee9f8a6

Browse files
Add standards guard scripts and enforce them in CI/releases
1 parent 01e62d3 commit ee9f8a6

13 files changed

Lines changed: 705 additions & 1 deletion

.github/workflows/ci.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,18 @@ jobs:
6868
chmod +x scripts/install_smoke.sh
6969
bash scripts/install_smoke.sh
7070
71+
- name: Standards guard checks
72+
env:
73+
FVPLUS_UNRAID_MATRIX: ${{ secrets.FVPLUS_UNRAID_MATRIX }}
74+
run: |
75+
chmod +x scripts/api_contract_guard.sh scripts/i18n_guard.sh scripts/lang_usage_guard.sh scripts/theme_scope_guard.sh scripts/perf_budget_guard.sh scripts/unraid_matrix_smoke.sh
76+
bash scripts/api_contract_guard.sh
77+
bash scripts/i18n_guard.sh
78+
bash scripts/lang_usage_guard.sh
79+
bash scripts/theme_scope_guard.sh
80+
bash scripts/perf_budget_guard.sh
81+
bash scripts/unraid_matrix_smoke.sh
82+
7183
- name: Optional browser smoke checks
7284
env:
7385
FVPLUS_BROWSER_SMOKE_URL: ${{ secrets.FVPLUS_BROWSER_SMOKE_URL }}

.github/workflows/release-beta.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ jobs:
2020
ref: beta
2121
fetch-depth: 0
2222

23+
- name: Setup Node
24+
uses: actions/setup-node@v4
25+
with:
26+
node-version: '20'
27+
2328
- name: Configure git
2429
run: |
2530
git config user.name "github-actions[bot]"
@@ -99,6 +104,18 @@ jobs:
99104
chmod +x scripts/install_smoke.sh
100105
bash scripts/install_smoke.sh
101106
107+
- name: Standards guard checks
108+
env:
109+
FVPLUS_UNRAID_MATRIX: ${{ secrets.FVPLUS_UNRAID_MATRIX }}
110+
run: |
111+
chmod +x scripts/api_contract_guard.sh scripts/i18n_guard.sh scripts/lang_usage_guard.sh scripts/theme_scope_guard.sh scripts/perf_budget_guard.sh scripts/unraid_matrix_smoke.sh
112+
bash scripts/api_contract_guard.sh
113+
bash scripts/i18n_guard.sh
114+
bash scripts/lang_usage_guard.sh
115+
bash scripts/theme_scope_guard.sh
116+
bash scripts/perf_budget_guard.sh
117+
bash scripts/unraid_matrix_smoke.sh
118+
102119
- name: Optional browser smoke checks
103120
env:
104121
FVPLUS_BROWSER_SMOKE_URL: ${{ secrets.FVPLUS_BROWSER_SMOKE_URL }}

.github/workflows/release-main.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ jobs:
2020
ref: main
2121
fetch-depth: 0
2222

23+
- name: Setup Node
24+
uses: actions/setup-node@v4
25+
with:
26+
node-version: '20'
27+
2328
- name: Configure git
2429
run: |
2530
git config user.name "github-actions[bot]"
@@ -52,6 +57,18 @@ jobs:
5257
chmod +x scripts/install_smoke.sh
5358
bash scripts/install_smoke.sh
5459
60+
- name: Standards guard checks
61+
env:
62+
FVPLUS_UNRAID_MATRIX: ${{ secrets.FVPLUS_UNRAID_MATRIX }}
63+
run: |
64+
chmod +x scripts/api_contract_guard.sh scripts/i18n_guard.sh scripts/lang_usage_guard.sh scripts/theme_scope_guard.sh scripts/perf_budget_guard.sh scripts/unraid_matrix_smoke.sh
65+
bash scripts/api_contract_guard.sh
66+
bash scripts/i18n_guard.sh
67+
bash scripts/lang_usage_guard.sh
68+
bash scripts/theme_scope_guard.sh
69+
bash scripts/perf_budget_guard.sh
70+
bash scripts/unraid_matrix_smoke.sh
71+
5572
- name: Optional browser smoke checks
5673
env:
5774
FVPLUS_BROWSER_SMOKE_URL: ${{ secrets.FVPLUS_BROWSER_SMOKE_URL }}

.github/workflows/release-on-main.yml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,18 @@ jobs:
5959
chmod +x scripts/install_smoke.sh
6060
bash scripts/install_smoke.sh
6161
62+
- name: Standards guard checks
63+
env:
64+
FVPLUS_UNRAID_MATRIX: ${{ secrets.FVPLUS_UNRAID_MATRIX }}
65+
run: |
66+
chmod +x scripts/api_contract_guard.sh scripts/i18n_guard.sh scripts/lang_usage_guard.sh scripts/theme_scope_guard.sh scripts/perf_budget_guard.sh scripts/unraid_matrix_smoke.sh
67+
bash scripts/api_contract_guard.sh
68+
bash scripts/i18n_guard.sh
69+
bash scripts/lang_usage_guard.sh
70+
bash scripts/theme_scope_guard.sh
71+
bash scripts/perf_budget_guard.sh
72+
bash scripts/unraid_matrix_smoke.sh
73+
6274
- name: Run test suite
6375
run: |
6476
node --test tests/*.mjs

.github/workflows/release-stable.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ jobs:
2020
ref: main
2121
fetch-depth: 0
2222

23+
- name: Setup Node
24+
uses: actions/setup-node@v4
25+
with:
26+
node-version: '20'
27+
2328
- name: Configure git
2429
run: |
2530
git config user.name "github-actions[bot]"
@@ -60,6 +65,18 @@ jobs:
6065
chmod +x scripts/install_smoke.sh
6166
bash scripts/install_smoke.sh
6267
68+
- name: Standards guard checks
69+
env:
70+
FVPLUS_UNRAID_MATRIX: ${{ secrets.FVPLUS_UNRAID_MATRIX }}
71+
run: |
72+
chmod +x scripts/api_contract_guard.sh scripts/i18n_guard.sh scripts/lang_usage_guard.sh scripts/theme_scope_guard.sh scripts/perf_budget_guard.sh scripts/unraid_matrix_smoke.sh
73+
bash scripts/api_contract_guard.sh
74+
bash scripts/i18n_guard.sh
75+
bash scripts/lang_usage_guard.sh
76+
bash scripts/theme_scope_guard.sh
77+
bash scripts/perf_budget_guard.sh
78+
bash scripts/unraid_matrix_smoke.sh
79+
6380
- name: Optional browser smoke checks
6481
env:
6582
FVPLUS_BROWSER_SMOKE_URL: ${{ secrets.FVPLUS_BROWSER_SMOKE_URL }}

scripts/api_contract_guard.sh

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
SERVER_DIR="${ROOT_DIR}/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/server"
6+
# shellcheck source=scripts/lib.sh
7+
source "${ROOT_DIR}/scripts/lib.sh"
8+
9+
fvplus::require_commands find grep
10+
11+
[[ -d "${SERVER_DIR}" ]] || fvplus::fail "Missing server directory: ${SERVER_DIR}"
12+
13+
files=()
14+
while IFS= read -r -d '' file; do
15+
files+=("${file}")
16+
done < <(find "${SERVER_DIR}" -maxdepth 1 -type f -name '*.php' -print0)
17+
18+
if [[ ${#files[@]} -eq 0 ]]; then
19+
fvplus::fail "No PHP endpoint files found in ${SERVER_DIR}"
20+
fi
21+
22+
require_lib_exceptions=("lib.php" "cpu.php")
23+
legacy_json_endpoints=("read.php" "read_info.php" "read_order.php" "read_unraid_order.php")
24+
plain_text_endpoints=("cpu.php" "version.php")
25+
mutation_endpoints=(
26+
"create.php"
27+
"update.php"
28+
"delete.php"
29+
"prefs.php"
30+
"reorder.php"
31+
"sync_order.php"
32+
"bulk_assign.php"
33+
"bulk_folder_action.php"
34+
"upload_custom_icon.php"
35+
)
36+
multi_action_guard_endpoints=("backup.php" "templates.php" "diagnostics.php")
37+
38+
contains_item() {
39+
local needle="${1:-}"
40+
shift || true
41+
local item
42+
for item in "$@"; do
43+
if [[ "${item}" == "${needle}" ]]; then
44+
return 0
45+
fi
46+
done
47+
return 1
48+
}
49+
50+
for file in "${files[@]}"; do
51+
name="$(basename "${file}")"
52+
53+
if ! contains_item "${name}" "${require_lib_exceptions[@]}"; then
54+
if ! grep -q 'require_once("/usr/local/emhttp/plugins/folderview.plus/server/lib.php")' "${file}"; then
55+
fvplus::fail "Endpoint ${name} is missing lib.php include."
56+
fi
57+
fi
58+
59+
if contains_item "${name}" "${plain_text_endpoints[@]}"; then
60+
continue
61+
fi
62+
63+
if contains_item "${name}" "${legacy_json_endpoints[@]}"; then
64+
if ! grep -q 'catch (Throwable' "${file}"; then
65+
fvplus::fail "Legacy JSON endpoint ${name} is missing Throwable catch."
66+
fi
67+
if ! grep -Eq "'ok'[[:space:]]*=>[[:space:]]*false" "${file}"; then
68+
fvplus::fail "Legacy JSON endpoint ${name} must return ok=false on errors."
69+
fi
70+
if ! grep -Eq "'error'[[:space:]]*=>" "${file}"; then
71+
fvplus::fail "Legacy JSON endpoint ${name} must return error field on failures."
72+
fi
73+
continue
74+
fi
75+
76+
if ! grep -Eq 'fvplus_json_try\(|fvplus_json_ok\(' "${file}"; then
77+
fvplus::fail "Endpoint ${name} is missing JSON response contract wrapper."
78+
fi
79+
done
80+
81+
for name in "${mutation_endpoints[@]}"; do
82+
file="${SERVER_DIR}/${name}"
83+
[[ -f "${file}" ]] || fvplus::fail "Missing mutation endpoint: ${name}"
84+
if ! grep -q 'requireMutationRequestGuard()' "${file}"; then
85+
fvplus::fail "Mutation endpoint ${name} is missing requireMutationRequestGuard()."
86+
fi
87+
done
88+
89+
for name in "${multi_action_guard_endpoints[@]}"; do
90+
file="${SERVER_DIR}/${name}"
91+
[[ -f "${file}" ]] || fvplus::fail "Missing multi-action endpoint: ${name}"
92+
if ! grep -q 'requireMutationRequestGuard()' "${file}"; then
93+
fvplus::fail "Multi-action endpoint ${name} must guard mutation actions."
94+
fi
95+
done
96+
97+
echo "API contract guard passed."

scripts/i18n_guard.sh

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
LANG_DIR="${ROOT_DIR}/src/folderview.plus/usr/local/emhttp/plugins/folderview.plus/langs"
6+
# shellcheck source=scripts/lib.sh
7+
source "${ROOT_DIR}/scripts/lib.sh"
8+
9+
fvplus::require_commands node
10+
11+
if [[ ! -d "${LANG_DIR}" ]]; then
12+
fvplus::fail "Missing language directory: ${LANG_DIR}"
13+
fi
14+
15+
node - "${LANG_DIR}" <<'NODE'
16+
const fs = require('fs');
17+
const path = require('path');
18+
19+
const langDir = process.argv[2];
20+
const files = fs.readdirSync(langDir).filter((name) => name.endsWith('.json')).sort();
21+
22+
if (!files.includes('en.json')) {
23+
console.error(`ERROR: Missing base locale file: ${path.join(langDir, 'en.json')}`);
24+
process.exit(1);
25+
}
26+
27+
const readLocale = (file) => {
28+
const fullPath = path.join(langDir, file);
29+
let parsed;
30+
try {
31+
parsed = JSON.parse(fs.readFileSync(fullPath, 'utf8'));
32+
} catch (error) {
33+
console.error(`ERROR: Invalid JSON in ${fullPath}: ${error.message}`);
34+
process.exit(1);
35+
}
36+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
37+
console.error(`ERROR: Locale file must contain a JSON object: ${fullPath}`);
38+
process.exit(1);
39+
}
40+
return parsed;
41+
};
42+
43+
const base = readLocale('en.json');
44+
const baseKeys = Object.keys(base).sort();
45+
let failed = false;
46+
47+
for (const key of baseKeys) {
48+
if (key === '@metadata') {
49+
if (!base[key] || typeof base[key] !== 'object' || Array.isArray(base[key])) {
50+
console.error('ERROR: en.json key "@metadata" must map to an object value.');
51+
failed = true;
52+
}
53+
continue;
54+
}
55+
if (typeof base[key] !== 'string') {
56+
console.error(`ERROR: en.json key "${key}" must map to a string value.`);
57+
failed = true;
58+
}
59+
}
60+
61+
for (const file of files) {
62+
const locale = readLocale(file);
63+
const localeKeys = Object.keys(locale).sort();
64+
const missing = baseKeys.filter((key) => !Object.prototype.hasOwnProperty.call(locale, key));
65+
const extra = localeKeys.filter((key) => !Object.prototype.hasOwnProperty.call(base, key));
66+
if (missing.length > 0) {
67+
console.error(`ERROR: ${file} is missing ${missing.length} key(s): ${missing.slice(0, 12).join(', ')}`);
68+
failed = true;
69+
}
70+
if (extra.length > 0) {
71+
console.error(`ERROR: ${file} has ${extra.length} unexpected key(s): ${extra.slice(0, 12).join(', ')}`);
72+
failed = true;
73+
}
74+
for (const key of localeKeys) {
75+
if (key === '@metadata') {
76+
if (!locale[key] || typeof locale[key] !== 'object' || Array.isArray(locale[key])) {
77+
console.error(`ERROR: ${file} key "@metadata" must map to an object value.`);
78+
failed = true;
79+
}
80+
continue;
81+
}
82+
if (typeof locale[key] !== 'string') {
83+
console.error(`ERROR: ${file} key "${key}" must map to a string value.`);
84+
failed = true;
85+
}
86+
}
87+
}
88+
89+
if (failed) {
90+
process.exit(1);
91+
}
92+
93+
console.log(`i18n guard passed: ${files.length} locale file(s) aligned with en.json (${baseKeys.length} keys).`);
94+
NODE

0 commit comments

Comments
 (0)