604 lines
26 KiB
YAML
604 lines
26 KiB
YAML
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
|
|
#
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
#
|
|
# FILE INFORMATION
|
|
# DEFGROUP: MokoGitea.Workflow
|
|
# INGROUP: mokocli.CI
|
|
# REPO: https://git.mokoconsulting.tech/MokoConsulting/Template-Generic
|
|
# PATH: /.mokogitea/workflows/pr-check.yml
|
|
# VERSION: 09.23.00
|
|
# BRIEF: PR gate — branch policy + code validation before merge
|
|
|
|
name: "Universal: PR Check"
|
|
|
|
on:
|
|
pull_request:
|
|
types: [opened, synchronize, reopened, edited]
|
|
|
|
permissions:
|
|
contents: read
|
|
pull-requests: write
|
|
|
|
env:
|
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
|
|
jobs:
|
|
# ── Branch Policy ──────────────────────────────────────────────────────
|
|
branch-policy:
|
|
name: Branch Policy
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Check branch merge target
|
|
run: |
|
|
HEAD="${{ github.head_ref }}"
|
|
BASE="${{ github.base_ref }}"
|
|
|
|
echo "PR: ${HEAD} → ${BASE}"
|
|
|
|
ALLOWED=true
|
|
REASON=""
|
|
|
|
case "$HEAD" in
|
|
feature/*|feat/*)
|
|
if [ "$BASE" != "dev" ]; then
|
|
ALLOWED=false
|
|
REASON="Feature branches must target 'dev', not '${BASE}'"
|
|
fi
|
|
;;
|
|
fix/*|bugfix/*)
|
|
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
|
ALLOWED=false
|
|
REASON="Fix branches must target 'dev' or 'main', not '${BASE}'"
|
|
fi
|
|
;;
|
|
patch/*)
|
|
if [ "$BASE" != "dev" ] && [ "$BASE" != "rc" ] && [ "$BASE" != "main" ]; then
|
|
ALLOWED=false
|
|
REASON="Patch branches must target 'dev', 'rc', or 'main', not '${BASE}'"
|
|
fi
|
|
;;
|
|
hotfix/*)
|
|
if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
|
|
ALLOWED=false
|
|
REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
|
|
fi
|
|
;;
|
|
rc)
|
|
if [ "$BASE" != "main" ]; then
|
|
ALLOWED=false
|
|
REASON="RC branch can only merge into 'main', not '${BASE}'"
|
|
fi
|
|
;;
|
|
dev)
|
|
if [ "$BASE" != "main" ]; then
|
|
ALLOWED=false
|
|
REASON="Dev branch can only merge into 'main', not '${BASE}'"
|
|
fi
|
|
;;
|
|
esac
|
|
|
|
if [ "$ALLOWED" = false ]; then
|
|
echo "::error::${REASON}"
|
|
echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "${REASON}" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
|
|
echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
|
|
echo "- \`fix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
|
echo "- \`patch/*\` → \`dev\`, \`rc\`, or \`main\`" >> $GITHUB_STEP_SUMMARY
|
|
echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
|
|
echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
|
echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
|
|
exit 1
|
|
fi
|
|
|
|
echo "Branch policy: OK (${HEAD} → ${BASE})"
|
|
echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
|
|
|
|
# ── Docs Update Gate (main PRs) ─────────────────────────────────────────
|
|
require-docs:
|
|
name: Require Docs Update
|
|
runs-on: ubuntu-latest
|
|
# Enforce only on PRs merging into main: README.md + CHANGELOG.md must both be updated.
|
|
if: ${{ github.base_ref == 'main' }}
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Require README.md and CHANGELOG.md in the PR diff
|
|
run: |
|
|
BASE="${{ github.event.pull_request.base.sha }}"
|
|
HEAD="${{ github.event.pull_request.head.sha }}"
|
|
CHANGED="$(git diff --name-only "$BASE" "$HEAD" 2>/dev/null || true)"
|
|
if [ -z "$CHANGED" ]; then
|
|
git fetch -q origin "${{ github.base_ref }}" 2>/dev/null || true
|
|
CHANGED="$(git diff --name-only "origin/${{ github.base_ref }}...HEAD" 2>/dev/null || true)"
|
|
fi
|
|
echo "Changed files in PR:"
|
|
echo "$CHANGED"
|
|
MISSING=""
|
|
echo "$CHANGED" | grep -qxE 'README\.md' || MISSING="README.md"
|
|
echo "$CHANGED" | grep -qxE 'CHANGELOG\.md' || MISSING="${MISSING:+$MISSING, }CHANGELOG.md"
|
|
if [ -n "$MISSING" ]; then
|
|
echo "::error::PRs into main must update: ${MISSING}"
|
|
{
|
|
echo "## Docs Update Required"
|
|
echo ""
|
|
echo "PRs merging into \`main\` must update both **README.md** and **CHANGELOG.md**."
|
|
echo ""
|
|
echo "Not updated in this PR: **${MISSING}**"
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
exit 1
|
|
fi
|
|
echo "Docs update present (README.md + CHANGELOG.md)"
|
|
echo "## Docs Update: Passed" >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
# ── Wiki Update Reminder (main PRs, non-blocking) ───────────────────────
|
|
wiki-reminder:
|
|
name: Wiki Update Reminder
|
|
runs-on: ubuntu-latest
|
|
if: ${{ github.base_ref == 'main' }}
|
|
steps:
|
|
- name: Remind to update the wiki
|
|
env:
|
|
TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
|
SERVER: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
|
REPO: ${{ github.repository }}
|
|
PR: ${{ github.event.pull_request.number }}
|
|
run: |
|
|
set -uo pipefail
|
|
{
|
|
echo "## Wiki Update Reminder"
|
|
echo ""
|
|
echo "Docs are **wiki-first** at MokoConsulting. If this change affects behavior, usage, configuration, or standards, update the repo wiki:"
|
|
echo ""
|
|
echo "- ${SERVER}/${REPO}/wiki"
|
|
echo ""
|
|
echo "_Non-blocking reminder._"
|
|
} >> "$GITHUB_STEP_SUMMARY"
|
|
# Post a single PR comment (idempotent via hidden marker); best-effort, never fails.
|
|
API="${SERVER}/api/v1/repos/${REPO}/issues/${PR}/comments"
|
|
if [ -n "${TOKEN:-}" ] && [ -n "${PR:-}" ]; then
|
|
existing="$(curl -sf -H "Authorization: token ${TOKEN}" "$API" 2>/dev/null | grep -c 'wiki-reminder' || true)"
|
|
if [ "${existing:-0}" -eq 0 ]; then
|
|
curl -sf -H "Authorization: token ${TOKEN}" -H "Content-Type: application/json" -X POST "$API" \
|
|
-d '{"body":"<!-- wiki-reminder -->\n\n**Wiki reminder:** docs are wiki-first -- if this PR changes behavior, usage, config, or standards, please update the repo wiki before/after merge. _(non-blocking)_"}' >/dev/null 2>&1 || true
|
|
fi
|
|
fi
|
|
echo "Wiki reminder emitted (non-blocking)."
|
|
|
|
# ── Secret Scanning ──────────────────────────────────────────────────
|
|
gitleaks:
|
|
name: Secret Scan
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0
|
|
|
|
- name: Install Gitleaks
|
|
run: |
|
|
GITLEAKS_VERSION="8.21.2"
|
|
curl -sSL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \
|
|
| tar -xz -C /usr/local/bin gitleaks
|
|
|
|
- name: Scan PR commits for secrets
|
|
run: |
|
|
if gitleaks detect --source . --verbose \
|
|
--log-opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }} 2>&1; then
|
|
echo "**No secrets detected.**" >> $GITHUB_STEP_SUMMARY
|
|
else
|
|
echo "::error::Potential secrets detected in PR commits"
|
|
exit 1
|
|
fi
|
|
|
|
# ── Code Validation ────────────────────────────────────────────────────
|
|
validate:
|
|
name: Validate PR
|
|
runs-on: ubuntu-latest
|
|
# Skip on template repos (Template-*) — no real manifest/source/changelog to validate.
|
|
if: ${{ !startsWith(github.event.repository.name, 'Template-') }}
|
|
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Check for merge conflict markers
|
|
run: |
|
|
CONFLICTS=$(grep -rn '<<<<<<< \|>>>>>>> \|^=======$' --exclude-dir='.git' --exclude-dir='.mokogitea' --include='*.php' --include='*.xml' --include='*.css' --include='*.js' --include='*.json' --include='*.md' --include='*.yml' --include='*.yaml' --include='*.ini' --include='*.txt' . 2>/dev/null | grep -v '.git/' || true)
|
|
if [ -n "$CONFLICTS" ]; then
|
|
echo "::error::Merge conflict markers found in source files"
|
|
echo "## Conflict Markers Found" >> $GITHUB_STEP_SUMMARY
|
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
|
echo "$CONFLICTS" >> $GITHUB_STEP_SUMMARY
|
|
echo '```' >> $GITHUB_STEP_SUMMARY
|
|
exit 1
|
|
fi
|
|
echo "No conflict markers found"
|
|
|
|
- name: Detect platform
|
|
id: platform
|
|
run: |
|
|
# Platform comes from the MokoGitea metadata API (public GET); manifest.xml is no longer used.
|
|
API="${GITHUB_SERVER_URL:-https://git.mokoconsulting.tech}/api/v1/repos/${GITHUB_REPOSITORY}/metadata"
|
|
PLATFORM="$(curl -sf "$API" 2>/dev/null | python3 -c "import sys, json; print(json.load(sys.stdin).get('platform') or '')" 2>/dev/null || true)"
|
|
[ -z "$PLATFORM" ] && PLATFORM="generic"
|
|
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
|
|
echo "Detected platform: $PLATFORM"
|
|
|
|
- name: Setup PHP
|
|
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
|
run: |
|
|
if ! command -v php &> /dev/null; then
|
|
sudo apt-get update -qq
|
|
sudo apt-get install -y -qq php-cli php-mbstring php-xml >/dev/null 2>&1
|
|
fi
|
|
|
|
- name: PHP syntax check
|
|
if: steps.platform.outputs.platform == 'joomla' || steps.platform.outputs.platform == 'dolibarr'
|
|
run: |
|
|
ERRORS=0
|
|
while IFS= read -r -d '' file; do
|
|
if ! php -l "$file" 2>&1 | grep -q "No syntax errors"; then
|
|
ERRORS=$((ERRORS + 1))
|
|
fi
|
|
done < <(find . -name "*.php" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
|
echo "PHP lint: ${ERRORS} error(s)"
|
|
[ "$ERRORS" -eq 0 ] || { echo "::error::PHP syntax errors found"; exit 1; }
|
|
|
|
- name: Joomla JEXEC guard check
|
|
if: steps.platform.outputs.platform == 'joomla'
|
|
run: |
|
|
ERRORS=0
|
|
while IFS= read -r -d '' file; do
|
|
# Skip vendor, node_modules, and index.html stub files
|
|
case "$file" in ./vendor/*|./node_modules/*) continue ;; esac
|
|
# Scan the whole file for the JEXEC/JPATH guard: it is placed after
|
|
# the SPDX/file-header docblock, which commonly runs past 20 lines.
|
|
if ! grep -qE "defined\s*\(\s*['\"](_JEXEC|JPATH_BASE|\\\\JPATH_PLATFORM)['\"]" "$file"; then
|
|
echo "::error file=${file}::Missing JEXEC guard: ${file}"
|
|
ERRORS=$((ERRORS + 1))
|
|
fi
|
|
done < <(find . -name "*.php" -path "*/src/*" -not -path "./.git/*" -not -path "./vendor/*" -print0)
|
|
if [ "$ERRORS" -gt 0 ]; then
|
|
echo "::error::${ERRORS} PHP file(s) missing defined('_JEXEC') or die guard"
|
|
echo "## JEXEC Guard Check: Failed" >> $GITHUB_STEP_SUMMARY
|
|
echo "${ERRORS} file(s) in src/ are missing the Joomla execution guard." >> $GITHUB_STEP_SUMMARY
|
|
exit 1
|
|
fi
|
|
echo "JEXEC guard: OK"
|
|
|
|
- name: Joomla directory listing protection
|
|
if: steps.platform.outputs.platform == 'joomla'
|
|
run: |
|
|
MISSING=0
|
|
SOURCE_DIR="src"
|
|
[ ! -d "$SOURCE_DIR" ] && exit 0
|
|
while IFS= read -r dir; do
|
|
if [ ! -f "${dir}/index.html" ]; then
|
|
echo "::warning::Missing index.html in ${dir} (directory listing protection)"
|
|
MISSING=$((MISSING + 1))
|
|
fi
|
|
done < <(find "$SOURCE_DIR" -type d -not -path "./.git/*" -not -path "*/vendor/*" -not -path "*/node_modules/*")
|
|
if [ "$MISSING" -gt 0 ]; then
|
|
echo "## Directory Protection" >> $GITHUB_STEP_SUMMARY
|
|
echo "${MISSING} director(ies) missing index.html" >> $GITHUB_STEP_SUMMARY
|
|
fi
|
|
echo "Directory protection: ${MISSING} missing (advisory)"
|
|
|
|
- name: Joomla script file and asset checks
|
|
if: steps.platform.outputs.platform == 'joomla'
|
|
run: |
|
|
ERRORS=0
|
|
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
|
[ -z "$MANIFEST" ] && exit 0
|
|
MANIFEST_DIR=$(dirname "$MANIFEST")
|
|
|
|
# Check scriptfile exists if declared
|
|
SCRIPTFILE=$(sed -n 's/.*<scriptfile>\([^<]*\)<\/scriptfile>.*/\1/p' "$MANIFEST" 2>/dev/null)
|
|
if [ -n "$SCRIPTFILE" ]; then
|
|
if [ ! -f "${MANIFEST_DIR}/${SCRIPTFILE}" ]; then
|
|
echo "::error::Manifest declares <scriptfile>${SCRIPTFILE}</scriptfile> but file not found at ${MANIFEST_DIR}/${SCRIPTFILE}"
|
|
ERRORS=$((ERRORS + 1))
|
|
else
|
|
echo "Script file: ${MANIFEST_DIR}/${SCRIPTFILE} (OK)"
|
|
fi
|
|
fi
|
|
|
|
# Require joomla.asset.json and validate it
|
|
ASSET_JSON=$(find "$MANIFEST_DIR" -name "joomla.asset.json" -not -path "./.git/*" 2>/dev/null | head -1)
|
|
if [ -z "$ASSET_JSON" ]; then
|
|
echo "::error::joomla.asset.json not found — Joomla asset system is required"
|
|
ERRORS=$((ERRORS + 1))
|
|
else
|
|
if command -v php &> /dev/null; then
|
|
php -r "json_decode(file_get_contents('$ASSET_JSON')); if(json_last_error()!==JSON_ERROR_NONE){echo json_last_error_msg();exit(1);}" 2>&1 || {
|
|
echo "::error::joomla.asset.json is not valid JSON"
|
|
ERRORS=$((ERRORS + 1))
|
|
}
|
|
fi
|
|
echo "joomla.asset.json: valid"
|
|
fi
|
|
|
|
# Validate all XML files in src/ are well-formed
|
|
XML_ERRORS=0
|
|
if command -v php &> /dev/null; then
|
|
while IFS= read -r -d '' xmlfile; do
|
|
if ! php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$xmlfile'); if(!\$x){foreach(libxml_get_errors() as \$e) echo trim(\$e->message) . ' in $xmlfile'; exit(1);}" 2>&1; then
|
|
XML_ERRORS=$((XML_ERRORS + 1))
|
|
fi
|
|
done < <(find "$MANIFEST_DIR" -name "*.xml" -not -path "./.git/*" -print0)
|
|
fi
|
|
if [ "$XML_ERRORS" -gt 0 ]; then
|
|
echo "::error::${XML_ERRORS} XML file(s) are malformed"
|
|
ERRORS=$((ERRORS + 1))
|
|
else
|
|
echo "XML well-formedness: OK"
|
|
fi
|
|
|
|
[ "$ERRORS" -gt 0 ] && exit 1
|
|
echo "Joomla asset checks: OK"
|
|
|
|
- name: Validate platform manifest
|
|
run: |
|
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
|
case "$PLATFORM" in
|
|
joomla)
|
|
MANIFEST=$(find . -maxdepth 3 -name "*.xml" ! -path "./.git/*" -exec grep -l '<extension' {} \; 2>/dev/null | head -1)
|
|
if [ -z "$MANIFEST" ]; then
|
|
echo "::warning::No Joomla manifest found (MokoSuite site)"
|
|
exit 0
|
|
fi
|
|
echo "Manifest: ${MANIFEST}"
|
|
if command -v php &> /dev/null; then
|
|
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('$MANIFEST'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::Manifest XML is malformed"; exit 1; }
|
|
fi
|
|
for ELEMENT in name version description; do
|
|
grep -q "<${ELEMENT}>" "$MANIFEST" || { echo "::error::Missing <${ELEMENT}> in manifest"; exit 1; }
|
|
done
|
|
# Block legacy raw/branch update server URLs on MokoGitea
|
|
RAW_URLS=$(grep -n 'raw/branch' "$MANIFEST" | grep -i 'mokoconsulting\|mokogitea\|git\.mokoconsulting\.tech' || true)
|
|
if [ -n "$RAW_URLS" ]; then
|
|
echo "::error::Manifest contains legacy raw/branch update server URL on MokoGitea. Use the MokoGitea Pages URL instead (e.g. /{REPO}/updates.xml not /{REPO}/raw/branch/main/updates.xml)"
|
|
echo "$RAW_URLS"
|
|
exit 1
|
|
fi
|
|
echo "Joomla manifest valid"
|
|
;;
|
|
dolibarr)
|
|
MOD_FILE=$(find . -maxdepth 4 -name "mod*.class.php" ! -path "./.git/*" -exec grep -l 'extends DolibarrModules' {} \; 2>/dev/null | head -1)
|
|
if [ -z "$MOD_FILE" ]; then
|
|
echo "::error::No mod*.class.php found"
|
|
exit 1
|
|
fi
|
|
echo "Dolibarr module: ${MOD_FILE}"
|
|
;;
|
|
*)
|
|
echo "Generic platform — no manifest validation"
|
|
;;
|
|
esac
|
|
|
|
- name: Check update stream format
|
|
run: |
|
|
PLATFORM="${{ steps.platform.outputs.platform }}"
|
|
case "$PLATFORM" in
|
|
joomla)
|
|
if [ -f "updates.xml" ]; then
|
|
if command -v php &> /dev/null; then
|
|
php -r "libxml_use_internal_errors(true); \$x = simplexml_load_file('updates.xml'); if(!\$x){foreach(libxml_get_errors() as \$e) echo \$e->message; exit(1);}" || { echo "::error::updates.xml is malformed"; exit 1; }
|
|
fi
|
|
echo "updates.xml valid"
|
|
fi
|
|
;;
|
|
dolibarr)
|
|
[ -f "update.txt" ] && echo "update.txt present" || echo "::warning::No update.txt"
|
|
;;
|
|
esac
|
|
|
|
- name: Validate Joomla language files
|
|
if: steps.platform.outputs.platform == 'joomla'
|
|
run: |
|
|
ERRORS=0
|
|
WARNINGS=0
|
|
|
|
# Require both en-GB and en-US language directories
|
|
LANG_ROOT=$(find . -path "*/language" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
|
if [ -z "$LANG_ROOT" ]; then
|
|
echo "No language/ directory found — skipping"
|
|
exit 0
|
|
fi
|
|
|
|
if [ ! -d "$LANG_ROOT/en-GB" ]; then
|
|
echo "::error::Missing en-GB language directory (${LANG_ROOT}/en-GB)"
|
|
ERRORS=$((ERRORS + 1))
|
|
fi
|
|
if [ ! -d "$LANG_ROOT/en-US" ]; then
|
|
echo "::error::Missing en-US language directory (${LANG_ROOT}/en-US)"
|
|
ERRORS=$((ERRORS + 1))
|
|
fi
|
|
|
|
# Check that en-GB and en-US have matching .ini files
|
|
if [ -d "$LANG_ROOT/en-GB" ] && [ -d "$LANG_ROOT/en-US" ]; then
|
|
for GB_INI in "$LANG_ROOT/en-GB"/*.ini; do
|
|
[ ! -f "$GB_INI" ] && continue
|
|
US_INI="$LANG_ROOT/en-US/$(basename "$GB_INI")"
|
|
if [ ! -f "$US_INI" ]; then
|
|
echo "::error::$(basename "$GB_INI") exists in en-GB but missing from en-US"
|
|
ERRORS=$((ERRORS + 1))
|
|
fi
|
|
done
|
|
for US_INI in "$LANG_ROOT/en-US"/*.ini; do
|
|
[ ! -f "$US_INI" ] && continue
|
|
GB_INI="$LANG_ROOT/en-GB/$(basename "$US_INI")"
|
|
if [ ! -f "$GB_INI" ]; then
|
|
echo "::error::$(basename "$US_INI") exists in en-US but missing from en-GB"
|
|
ERRORS=$((ERRORS + 1))
|
|
fi
|
|
done
|
|
fi
|
|
|
|
# Find all .ini language files
|
|
INI_FILES=$(find . -path "*/language/*/*.ini" -not -path "./.git/*" 2>/dev/null)
|
|
if [ -z "$INI_FILES" ]; then
|
|
echo "No .ini language files found"
|
|
[ "$ERRORS" -gt 0 ] && exit 1
|
|
exit 0
|
|
fi
|
|
|
|
echo "Found $(echo "$INI_FILES" | wc -l) language file(s)"
|
|
|
|
for FILE in $INI_FILES; do
|
|
FNAME=$(basename "$FILE")
|
|
LINENUM=0
|
|
SEEN_KEYS=""
|
|
|
|
while IFS= read -r line || [ -n "$line" ]; do
|
|
LINENUM=$((LINENUM + 1))
|
|
|
|
# Skip empty lines and comments
|
|
[ -z "$line" ] && continue
|
|
echo "$line" | grep -qE '^\s*;' && continue
|
|
echo "$line" | grep -qE '^\s*$' && continue
|
|
|
|
# Must match KEY="VALUE" format
|
|
if ! echo "$line" | grep -qE '^[A-Z_][A-Z0-9_]*=".*"$'; then
|
|
echo "::error file=${FILE},line=${LINENUM}::Malformed line: ${line}"
|
|
ERRORS=$((ERRORS + 1))
|
|
continue
|
|
fi
|
|
|
|
# Extract key and check for duplicates
|
|
KEY=$(echo "$line" | sed 's/=.*//')
|
|
if echo "$SEEN_KEYS" | grep -qx "$KEY"; then
|
|
echo "::error file=${FILE},line=${LINENUM}::Duplicate key: ${KEY}"
|
|
ERRORS=$((ERRORS + 1))
|
|
fi
|
|
SEEN_KEYS="${SEEN_KEYS}
|
|
${KEY}"
|
|
done < "$FILE"
|
|
|
|
echo " ${FILE}: checked ${LINENUM} lines"
|
|
done
|
|
|
|
# Cross-check en-GB vs en-US key consistency
|
|
GB_DIR=$(find . -path "*/language/en-GB" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
|
US_DIR=$(find . -path "*/language/en-US" -type d -not -path "./.git/*" 2>/dev/null | head -1)
|
|
|
|
if [ -n "$GB_DIR" ] && [ -n "$US_DIR" ]; then
|
|
for GB_FILE in "$GB_DIR"/*.ini; do
|
|
[ ! -f "$GB_FILE" ] && continue
|
|
FNAME=$(basename "$GB_FILE")
|
|
US_FILE="$US_DIR/$FNAME"
|
|
[ ! -f "$US_FILE" ] && continue
|
|
|
|
GB_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$GB_FILE" 2>/dev/null | sort)
|
|
US_KEYS=$(grep -oP '^[A-Z_][A-Z0-9_]*(?==)' "$US_FILE" 2>/dev/null | sort)
|
|
|
|
# Keys in en-GB but not en-US
|
|
MISSING_US=$(comm -23 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
|
if [ -n "$MISSING_US" ]; then
|
|
echo "::warning::Keys in en-GB/$FNAME but missing from en-US/$FNAME:"
|
|
echo "$MISSING_US" | while read -r k; do echo " - $k"; done
|
|
WARNINGS=$((WARNINGS + 1))
|
|
fi
|
|
|
|
# Keys in en-US but not en-GB
|
|
MISSING_GB=$(comm -13 <(echo "$GB_KEYS") <(echo "$US_KEYS"))
|
|
if [ -n "$MISSING_GB" ]; then
|
|
echo "::warning::Keys in en-US/$FNAME but missing from en-GB/$FNAME:"
|
|
echo "$MISSING_GB" | while read -r k; do echo " - $k"; done
|
|
WARNINGS=$((WARNINGS + 1))
|
|
fi
|
|
done
|
|
fi
|
|
|
|
{
|
|
echo "### Language File Validation"
|
|
echo "| Metric | Count |"
|
|
echo "|---|---|"
|
|
echo "| Files checked | $(echo "$INI_FILES" | wc -l) |"
|
|
echo "| Errors | ${ERRORS} |"
|
|
echo "| Warnings | ${WARNINGS} |"
|
|
} >> $GITHUB_STEP_SUMMARY
|
|
|
|
if [ "$ERRORS" -gt 0 ]; then
|
|
echo "::error::Language validation failed with ${ERRORS} error(s)"
|
|
exit 1
|
|
fi
|
|
echo "Language files: OK (${WARNINGS} warning(s))"
|
|
|
|
- name: Check changelog has unreleased entry
|
|
run: |
|
|
if [ ! -f "CHANGELOG.md" ]; then
|
|
echo "::warning::No CHANGELOG.md found"
|
|
exit 0
|
|
fi
|
|
# Check for content under [Unreleased] section
|
|
if ! grep -q "## \[Unreleased\]" CHANGELOG.md; then
|
|
echo "::error::CHANGELOG.md missing [Unreleased] section"
|
|
exit 1
|
|
fi
|
|
# Check there's at least one entry (Added/Changed/Fixed/Removed) under Unreleased
|
|
UNRELEASED_CONTENT=$(sed -n '/## \[Unreleased\]/,/## \[/p' CHANGELOG.md | grep -cE '^\s*-\s' || true)
|
|
if [ "$UNRELEASED_CONTENT" -eq 0 ]; then
|
|
echo "::error::CHANGELOG.md [Unreleased] section has no entries. Add a changelog entry describing your changes."
|
|
echo "## Changelog Check: Failed" >> $GITHUB_STEP_SUMMARY
|
|
echo "The \`[Unreleased]\` section in CHANGELOG.md has no entries." >> $GITHUB_STEP_SUMMARY
|
|
echo "Add a line like \`- Description of your change\` under a heading (\`### Added\`, \`### Changed\`, \`### Fixed\`, etc.)" >> $GITHUB_STEP_SUMMARY
|
|
exit 1
|
|
fi
|
|
echo "Changelog: ${UNRELEASED_CONTENT} entry/entries in [Unreleased]"
|
|
|
|
- name: Verify package source
|
|
run: |
|
|
SOURCE_DIR="src"
|
|
[ ! -d "$SOURCE_DIR" ] && SOURCE_DIR="htdocs"
|
|
if [ ! -d "$SOURCE_DIR" ]; then
|
|
echo "::warning::No src/ or htdocs/ directory"
|
|
exit 0
|
|
fi
|
|
FILE_COUNT=$(find "$SOURCE_DIR" -type f | wc -l)
|
|
echo "Source: ${FILE_COUNT} files"
|
|
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
|
|
|
|
# ── Pre-Release RC Build ─────────────────────────────────────────────────
|
|
pre-release:
|
|
name: Build RC Package
|
|
runs-on: ubuntu-latest
|
|
needs: [branch-policy, validate]
|
|
# Run only when both gates succeeded; always() forces evaluation so a skipped
|
|
# validate (e.g. template repos) skips this job cleanly instead of hanging.
|
|
if: ${{ always() && needs.branch-policy.result == 'success' && needs.validate.result == 'success' }}
|
|
|
|
steps:
|
|
- name: Trigger RC pre-release
|
|
env:
|
|
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
|
|
REPO: ${{ github.repository }}
|
|
BRANCH: ${{ github.head_ref }}
|
|
MOKOGITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
|
|
run: |
|
|
curl -s -X POST "${MOKOGITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${MOKOGITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
|
|
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
|
|
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
|
|
|
|
# ── Issue Reporter ──────────────────────────────────────────────────────
|
|
report-issues:
|
|
name: Report Issues
|
|
needs: [branch-policy, validate]
|
|
if: >-
|
|
always() &&
|
|
needs.validate.result == 'failure'
|
|
uses: ./.mokogitea/workflows/ci-issue-reporter.yml
|
|
with:
|
|
gate: "PR Validation"
|
|
workflow: "PR Check"
|
|
severity: error
|
|
details: "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
|
|
secrets: inherit
|