From 438ad37c62533df822c3a8a85f175846b6bc806a Mon Sep 17 00:00:00 2001 From: Jonathan Miller <230051081+jmiller-moko@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:24:44 -0600 Subject: [PATCH] Update repo_health.yml --- .github/workflows/repo_health.yml | 707 ++++++++++++++++++++++++++++++ 1 file changed, 707 insertions(+) diff --git a/.github/workflows/repo_health.yml b/.github/workflows/repo_health.yml index e69de29..2a2d473 100644 --- a/.github/workflows/repo_health.yml +++ b/.github/workflows/repo_health.yml @@ -0,0 +1,707 @@ +# ============================================================================ +# Copyright (C) 2025 Moko Consulting +# +# This file is part of a Moko Consulting project. +# +# SPDX-License-Identifier: GPL-3.0-or-later +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# FILE INFORMATION +# DEFGROUP: GitHub.Workflow +# INGROUP: MokoStandards.Validation +# REPO: https://github.com/mokoconsulting-tech/MokoStandards +# PATH: /.github/workflows/repo_health.yml +# VERSION: 03.05.00 +# BRIEF: Enforces Joomla repository guardrails by validating release configuration, required validation scripts, tooling availability, and core repository health artifacts. +# ============================================================================ + +name: Repo Health + +concurrency: + group: repo-health-${{ github.repository }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +on: + workflow_dispatch: + inputs: + profile: + description: Which configuration profile to validate. release checks SFTP variables used by release pipeline. scripts checks baseline script prerequisites. repo runs repository health only. all runs release, scripts, and repo health. + required: true + default: all + type: choice + options: + - all + - release + - scripts + - repo + pull_request: + paths: + - .github/workflows/** + - scripts/** + - docs/** + - dev/** + +permissions: + contents: read + +jobs: + access_check: + name: Access control + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + + outputs: + allowed: ${{ steps.perm.outputs.allowed }} + permission: ${{ steps.perm.outputs.permission }} + + steps: + - name: Check actor permission admin only + id: perm + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const username = context.actor; + + const res = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username, + }); + + const permission = (res?.data?.permission || "unknown").toLowerCase(); + const allowed = (permission === "admin"); + + core.setOutput("permission", permission); + core.setOutput("allowed", allowed ? "true" : "false"); + + const lines = []; + lines.push("### Access control"); + lines.push(""); + lines.push(`Actor: ${username}`); + lines.push(`Permission: ${permission}`); + lines.push(`Allowed: ${allowed}`); + lines.push(""); + lines.push("Policy: This workflow runs only for users with admin permission on the repository."); + await core.summary.addRaw(lines.join("\n")).write(); + + - name: Deny execution when not permitted + if: ${{ steps.perm.outputs.allowed != 'true' }} + run: | + set -euo pipefail + echo "ERROR: Access denied. Actor must have admin permission to run this workflow." >> "${GITHUB_STEP_SUMMARY}" + exit 1 + + release_config: + name: Release configuration + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [access_check] + if: ${{ needs.access_check.outputs.allowed == 'true' }} + permissions: + contents: read + + steps: + $1 + - name: Load guardrails definition + env: + GUARDRAILS_DEFINITION_URL: ${{ vars.MOKOSTANDARDS_GUARDRAILS_URL || 'https://raw.githubusercontent.com/mokoconsulting-tech/MokoStandards/main/repo-guardrails.definition.json' }} + run: | + set -euo pipefail + + url="${GUARDRAILS_DEFINITION_URL}" + echo "### Guardrails policy source" >> "${GITHUB_STEP_SUMMARY}" + echo "${url}" >> "${GITHUB_STEP_SUMMARY}" + + if ! curl -fsSL "${url}" -o /tmp/repo_guardrails.definition.json; then + echo "Warning: Unable to fetch guardrails definition. Falling back to workflow defaults." >> "${GITHUB_STEP_SUMMARY}" + echo "GUARDRAILS_LOADED=false" >> "${GITHUB_ENV}" + exit 0 + fi + + python3 - <<'PY' +import json +import os + +path = "/tmp/repo_guardrails.definition.json" +with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + +env_path = os.environ.get("GITHUB_ENV") +if not env_path: + raise SystemExit("GITHUB_ENV not set") + +def put_multiline(key: str, values): + vals = [str(v) for v in (values or []) if str(v).strip()] + with open(env_path, "a", encoding="utf-8") as w: + w.write(f"{key}<> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = "scripts" ] || [ "${profile}" = "repo" ]; then + echo "Profile ${profile} selected. Skipping release configuration checks." >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + required=("FTP_HOST" "FTP_USER" "FTP_KEY" "FTP_PATH") + optional=("FTP_PASSWORD" "FTP_PROTOCOL" "FTP_PORT" "FTP_PATH_SUFFIX") + + missing=() + missing_optional=() + + for k in "${required[@]}"; do + v="${!k:-}" + [ -z "${v}" ] && missing+=("${k}") + done + + for k in "${optional[@]}"; do + v="${!k:-}" + [ -z "${v}" ] && missing_optional+=("${k}") + done + + proto="${FTP_PROTOCOL:-sftp}" + if [ -n "${FTP_PROTOCOL:-}" ] && [ "${proto}" != "sftp" ]; then + missing+=("FTP_PROTOCOL_INVALID") + fi + + if [ "${#missing_optional[@]}" -gt 0 ]; then + echo "### Missing optional release configuration" >> "${GITHUB_STEP_SUMMARY}" + for m in "${missing_optional[@]}"; do echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}"; done + fi + + if [ "${#missing[@]}" -gt 0 ]; then + echo "### Missing required release configuration" >> "${GITHUB_STEP_SUMMARY}" + for m in "${missing[@]}"; do echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}"; done + echo "ERROR: Guardrails failed. Missing required release configuration." >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + + echo "### Guardrails release configuration" >> "${GITHUB_STEP_SUMMARY}" + echo "All required release variables present." >> "${GITHUB_STEP_SUMMARY}" + + - name: Guardrails SFTP connectivity + env: + PROFILE_RAW: "${{ github.event.inputs.profile }}" + FTP_HOST: "${{ secrets.FTP_HOST }}" + FTP_USER: "${{ secrets.FTP_USER }}" + FTP_KEY: "${{ secrets.FTP_KEY }}" + FTP_PASSWORD: "${{ secrets.FTP_PASSWORD }}" + FTP_PORT: "${{ secrets.FTP_PORT }}" + run: | + set -euo pipefail + + profile="${PROFILE_RAW:-all}" + case "${profile}" in + all|release|scripts|repo) ;; + *) + echo "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = "scripts" ] || [ "${profile}" = "repo" ]; then + echo "Profile ${profile} selected. Skipping SFTP connectivity check." >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + mkdir -p "$HOME/.ssh" + key_file="$HOME/.ssh/ci_sftp_key" + printf '%s' "${FTP_KEY}" > "${key_file}" + printf '\n' >> "${key_file}" + chmod 600 "${key_file}" + + if [ -n "${FTP_PASSWORD:-}" ]; then + first_line="$(head -n 1 "${key_file}" || true)" + if printf '%s' "${first_line}" | grep -q '^PuTTY-User-Key-File-'; then + echo "ERROR: FTP_KEY appears to be a PuTTY PPK. Provide an OpenSSH private key." >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + ssh-keygen -p -P "${FTP_PASSWORD}" -N "" -f "${key_file}" >/dev/null + fi + + port="${FTP_PORT:-22}" + + echo "### SFTP connectivity test" >> "${GITHUB_STEP_SUMMARY}" + echo "Attempting non-destructive SFTP session" >> "${GITHUB_STEP_SUMMARY}" + + set +e + printf 'pwd +bye +' | sftp -oBatchMode=yes -oStrictHostKeyChecking=no -P "${port}" -i "${key_file}" "${FTP_USER}@${FTP_HOST}" >/tmp/sftp_check.log 2>&1 + sftp_rc=$? + set -e + + echo "### SFTP connectivity result" >> "${GITHUB_STEP_SUMMARY}" + if [ "${sftp_rc}" -eq 0 ]; then + echo "Status: SUCCESS" >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + echo "Status: FAILED (exit code ${sftp_rc})" >> "${GITHUB_STEP_SUMMARY}" + echo "" >> "${GITHUB_STEP_SUMMARY}" + echo "Last SFTP output" >> "${GITHUB_STEP_SUMMARY}" + tail -n 20 /tmp/sftp_check.log >> "${GITHUB_STEP_SUMMARY}" || true + exit 1 + + scripts_config: + name: Scripts and tooling + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [access_check] + if: ${{ needs.access_check.outputs.allowed == 'true' }} + permissions: + contents: read + + steps: + $1 + - name: Load guardrails definition + env: + GUARDRAILS_DEFINITION_URL: ${{ vars.MOKOSTANDARDS_GUARDRAILS_URL || 'https://raw.githubusercontent.com/mokoconsulting-tech/MokoStandards/main/repo-guardrails.definition.json' }} + run: | + set -euo pipefail + + url="${GUARDRAILS_DEFINITION_URL}" + echo "### Guardrails policy source" >> "${GITHUB_STEP_SUMMARY}" + echo "${url}" >> "${GITHUB_STEP_SUMMARY}" + + if ! curl -fsSL "${url}" -o /tmp/repo_guardrails.definition.json; then + echo "Warning: Unable to fetch guardrails definition. Falling back to workflow defaults." >> "${GITHUB_STEP_SUMMARY}" + echo "GUARDRAILS_LOADED=false" >> "${GITHUB_ENV}" + exit 0 + fi + + python3 - <<'PY' +import json +import os + +path = "/tmp/repo_guardrails.definition.json" +with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + +env_path = os.environ.get("GITHUB_ENV") +if not env_path: + raise SystemExit("GITHUB_ENV not set") + +def put_multiline(key: str, values): + vals = [str(v) for v in (values or []) if str(v).strip()] + with open(env_path, "a", encoding="utf-8") as w: + w.write(f"{key}<> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = "release" ] || [ "${profile}" = "repo" ]; then + echo "Profile ${profile} selected. Skipping scripts checks." >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + # scripts/ is OPTIONAL and informational only + if [ ! -d "scripts" ]; then + echo "### Scripts folder not present" >> "${GITHUB_STEP_SUMMARY}" + echo "Warning: scripts/ directory is optional. No scripts governance enforced." >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + required_script_dirs=( + "scripts/fix" + "scripts/lib" + "scripts/release" + "scripts/run" + "scripts/validate" + ) + + optional_script_dirs=( + "scripts/config" + "scripts/tools" + "scripts/docs" + ) + + allowed_script_dirs=( + "scripts" + "scripts/fix" + "scripts/lib" + "scripts/release" + "scripts/run" + "scripts/validate" + "scripts/config" + "scripts/tools" + "scripts/docs" + ) + + missing_dirs=() + unapproved_dirs=() + + for d in "${required_script_dirs[@]}"; do + [ ! -d "${d}" ] && missing_dirs+=("${d}/") + done + + while IFS= read -r d; do + allowed=false + for a in "${allowed_script_dirs[@]}"; do + [ "${d}" = "${a}" ] && allowed=true + done + [ "${allowed}" = false ] && unapproved_dirs+=("${d}/") + done < <(find scripts -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##') + + if [ "${#missing_dirs[@]}" -gt 0 ]; then + echo "### Scripts governance warnings" >> "${GITHUB_STEP_SUMMARY}" + echo "Missing recommended script directories:" >> "${GITHUB_STEP_SUMMARY}" + for m in "${missing_dirs[@]}"; do echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}"; done + fi + + if [ "${#unapproved_dirs[@]}" -gt 0 ]; then + echo "### Scripts governance warnings" >> "${GITHUB_STEP_SUMMARY}" + echo "Unapproved script directories detected:" >> "${GITHUB_STEP_SUMMARY}" + for m in "${unapproved_dirs[@]}"; do echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}"; done + fi + + { + echo "### Scripts governance summary" + echo "| Area | Status | Notes |" + echo "|------|--------|-------|" + if [ "${#missing_dirs[@]}" -gt 0 ]; then + echo "| Recommended directories | Warning | Missing recommended subfolders |" + else + echo "| Recommended directories | OK | All recommended subfolders present |" + fi + if [ "${#unapproved_dirs[@]}" -gt 0 ]; then + echo "| Directory policy | Warning | Unapproved directories detected |" + else + echo "| Directory policy | OK | No unapproved directories |" + fi + echo "| Enforcement mode | Advisory | scripts folder is optional |" + } >> "${GITHUB_STEP_SUMMARY}" + + echo "Scripts governance completed in advisory mode." >> "${GITHUB_STEP_SUMMARY}" + + + repo_health: + name: Repository health + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [access_check] + if: ${{ needs.access_check.outputs.allowed == 'true' }} + permissions: + contents: read + + steps: + $1 + - name: Load guardrails definition + env: + GUARDRAILS_DEFINITION_URL: ${{ vars.MOKOSTANDARDS_GUARDRAILS_URL || 'https://raw.githubusercontent.com/mokoconsulting-tech/MokoStandards/main/repo-guardrails.definition.json' }} + run: | + set -euo pipefail + + url="${GUARDRAILS_DEFINITION_URL}" + echo "### Guardrails policy source" >> "${GITHUB_STEP_SUMMARY}" + echo "${url}" >> "${GITHUB_STEP_SUMMARY}" + + if ! curl -fsSL "${url}" -o /tmp/repo_guardrails.definition.json; then + echo "Warning: Unable to fetch guardrails definition. Falling back to workflow defaults." >> "${GITHUB_STEP_SUMMARY}" + echo "GUARDRAILS_LOADED=false" >> "${GITHUB_ENV}" + exit 0 + fi + + python3 - <<'PY' +import json +import os + +path = "/tmp/repo_guardrails.definition.json" +with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + +env_path = os.environ.get("GITHUB_ENV") +if not env_path: + raise SystemExit("GITHUB_ENV not set") + +def put_multiline(key: str, values): + vals = [str(v) for v in (values or []) if str(v).strip()] + with open(env_path, "a", encoding="utf-8") as w: + w.write(f"{key}<> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = "release" ] || [ "${profile}" = "scripts" ]; then + echo "Profile ${profile} selected. Skipping repository health checks." >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + required_files=( + "README.md" + "LICENSE" + "CHANGELOG.md" + "CONTRIBUTING.md" + "CODE_OF_CONDUCT.md" + "TODO.md" + "docs/docs-index.md" + ) + + optional_files=( + "SECURITY.md" + "GOVERNANCE.md" + ".editorconfig" + ".gitattributes" + ".gitignore" + ) + + required_paths=( + ".github/workflows" + "scripts" + "docs" + "dev" + ) + + missing_required=() + missing_optional=() + + for f in "${required_files[@]}"; do + [ ! -f "${f}" ] && missing_required+=("${f}") + done + + for f in "${optional_files[@]}"; do + [ ! -f "${f}" ] && missing_optional+=("${f}") + done + + for p in "${required_paths[@]}"; do + [ ! -d "${p}" ] && missing_required+=("${p}/") + done + + if [ -d "src" ]; then + missing_required+=("src/ (disallowed, use dev/ only)") + fi + + git fetch origin --prune + + dev_paths=() + dev_branches=() + + while IFS= read -r b; do + name="${b#origin/}" + if [ "${name}" = "dev" ]; then + dev_branches+=("${name}") + else + dev_paths+=("${name}") + fi + done < <(git branch -r --list "origin/dev*" | sed 's/^ *//') + + if [ "${#dev_paths[@]}" -eq 0 ]; then + missing_required+=("dev/* branch (e.g. dev/01.00.00)") + fi + + if [ "${#dev_branches[@]}" -gt 0 ]; then + missing_required+=("invalid branch dev (must be dev/)") + fi + + content_warnings=() + + if [ -f "CHANGELOG.md" ] && ! grep -Eq '^# Changelog' CHANGELOG.md; then + content_warnings+=("CHANGELOG.md missing '# Changelog' header") + fi + + if [ -f "LICENSE" ] && ! grep -qiE 'GNU GENERAL PUBLIC LICENSE|GPL' LICENSE; then + content_warnings+=("LICENSE does not look like a GPL text") + fi + + if [ -f "README.md" ] && ! grep -qiE 'moko|Moko' README.md; then + content_warnings+=("README.md missing expected brand keyword") + fi + + export MISSING_REQUIRED="$(printf '%s\n' "${missing_required[@]:-}")" + export MISSING_OPTIONAL="$(printf '%s\n' "${missing_optional[@]:-}")" + export CONTENT_WARNINGS="$(printf '%s\n' "${content_warnings[@]:-}")" + + report_json="$(python3 - <<'PY' +import json +import os + +profile = os.environ.get("PROFILE_RAW") or "all" +required_files = ["README.md","LICENSE","CHANGELOG.md","CONTRIBUTING.md","CODE_OF_CONDUCT.md","TODO.md","docs/docs-index.md"] +optional_files = ["SECURITY.md","GOVERNANCE.md",".editorconfig",".gitattributes",".gitignore"] +required_paths = [".github/workflows","scripts","docs","dev"] + +missing_required = os.environ.get("MISSING_REQUIRED", "").splitlines() if os.environ.get("MISSING_REQUIRED") else [] +missing_optional = os.environ.get("MISSING_OPTIONAL", "").splitlines() if os.environ.get("MISSING_OPTIONAL") else [] +content_warnings = os.environ.get("CONTENT_WARNINGS", "").splitlines() if os.environ.get("CONTENT_WARNINGS") else [] + +out = { + "profile": profile, + "checked": { + "required_files": required_files, + "optional_files": optional_files, + "required_paths": required_paths, + }, + "missing_required": [x for x in missing_required if x], + "missing_optional": [x for x in missing_optional if x], + "content_warnings": [x for x in content_warnings if x], +} + +print(json.dumps(out, indent=2)) +PY +)" + + { + printf "### Guardrails repository health\n\n" + printf "### Guardrails report (JSON)\n" + printf "```json\n" + printf "%s\n" "${report_json}" + printf "```\n" + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${#missing_required[@]}" -gt 0 ]; then + echo "### Missing required repo artifacts" >> "${GITHUB_STEP_SUMMARY}" + for m in "${missing_required[@]}"; do echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}"; done + echo "ERROR: Guardrails failed. Missing required repository artifacts." >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + + if [ "${#missing_optional[@]}" -gt 0 ]; then + echo "### Missing optional repo artifacts" >> "${GITHUB_STEP_SUMMARY}" + for m in "${missing_optional[@]}"; do echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}"; done + fi + + if [ "${#content_warnings[@]}" -gt 0 ]; then + echo "### Repo content warnings" >> "${GITHUB_STEP_SUMMARY}" + for m in "${content_warnings[@]}"; do echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}"; done + fi + + echo "Repository health guardrails passed." >> "${GITHUB_STEP_SUMMARY}" +\n