From bc226730f6cb16bd9d9aeeefc7bdbc474f219880 Mon Sep 17 00:00:00 2001 From: Jonathan Miller <230051081+jmiller-moko@users.noreply.github.com> Date: Sat, 27 Dec 2025 01:35:11 -0600 Subject: [PATCH] Update repo_health.yml --- .github/workflows/repo_health.yml | 365 ++++++++++++++++-------------- 1 file changed, 193 insertions(+), 172 deletions(-) diff --git a/.github/workflows/repo_health.yml b/.github/workflows/repo_health.yml index 804721e..b41d192 100644 --- a/.github/workflows/repo_health.yml +++ b/.github/workflows/repo_health.yml @@ -50,9 +50,61 @@ permissions: contents: read jobs: + access_check: + name: Access control + runs-on: ubuntu-latest + permissions: + contents: read + + outputs: + allowed: ${{ steps.perm.outputs.allowed }} + permission: ${{ steps.perm.outputs.permission }} + + steps: + - name: Check actor permission (admin or maintain 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 or maintain permission to run this workflow." >> "${GITHUB_STEP_SUMMARY}" + exit 1 + release_config: name: Release configuration runs-on: ubuntu-latest + needs: [access_check] + if: ${{ needs.access_check.outputs.allowed == 'true' }} permissions: contents: read @@ -69,48 +121,36 @@ jobs: FTP_PORT: "${{ secrets.FTP_PORT }}" FTP_PATH_SUFFIX: "${{ vars.FTP_PATH_SUFFIX }}" run: | - set -euxo pipefail + set -euo pipefail profile="${PROFILE_RAW:-all}" - if [ "${profile}" != "all" ] && [ "${profile}" != "release" ] && [ "${profile}" != "scripts" ]; then - echo "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" - exit 1 - fi + case "${profile}" in + all|release|scripts) ;; + *) + echo "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac if [ "${profile}" = "scripts" ]; then echo "Profile scripts 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" - ) + 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:-}" - if [ -z "${v}" ]; then - missing+=("${k}") - fi + [ -z "${v}" ] && missing+=("${k}") done for k in "${optional[@]}"; do v="${!k:-}" - if [ -z "${v}" ]; then - missing_optional+=("${k}") - fi + [ -z "${v}" ] && missing_optional+=("${k}") done proto="${FTP_PROTOCOL:-sftp}" @@ -118,77 +158,47 @@ jobs: missing+=("FTP_PROTOCOL_INVALID") fi - # Key format guardrail (do not print key material). - if [ -n "${FTP_KEY:-}" ]; then - first_line="$(printf '%s' "${FTP_KEY}" | head -n 1 || true)" - if printf '%s' "${first_line}" | grep -q '^PuTTY-User-Key-File-'; then - key_format="ppk" - elif printf '%s' "${first_line}" | grep -q '^-----BEGIN '; then - key_format="openssh" - else - key_format="unknown" - missing+=("FTP_KEY_FORMAT") - fi - else - key_format="missing" - fi - - { - echo "### Guardrails: release configuration" - echo "KEY_FORMAT=${key_format}" - echo "" - echo "### Guardrails report (JSON)" - echo "```json" - printf '{"profile":"%s","checked":{"required":[' "${profile}" - sep="" - for c in "${required[@]}"; do - printf '%s"%s"' "${sep}" "${c}" - sep=","; - done - printf '],"optional":[' - sep="" - for c in "${optional[@]}"; do - printf '%s"%s"' "${sep}" "${c}" - sep=","; - done - printf ']},"missing_required":[' - sep="" - for m in "${missing[@]}"; do - printf '%s"%s"' "${sep}" "${m}" - sep=","; - done - printf '],"missing_optional":[' - sep="" - for m in "${missing_optional[@]}"; do - printf '%s"%s"' "${sep}" "${m}" - sep=","; - done - printf ']}'"\n"' - echo "```" - } >> "${GITHUB_STEP_SUMMARY}" - if [ "${#missing[@]}" -gt 0 ]; then echo "### Missing required release configuration" >> "${GITHUB_STEP_SUMMARY}" - for m in "${missing[@]}"; do - echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}" - done - 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: ${missing[*]}" >&2 - echo "ERROR: Guardrails failed. Missing required release configuration." >> "${GITHUB_STEP_SUMMARY}" + for m in "${missing[@]}"; do echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}"; done exit 1 fi + + echo "### Guardrails: release configuration" >> "${GITHUB_STEP_SUMMARY}" + echo "All required release variables present." >> "${GITHUB_STEP_SUMMARY}" + + - name: "Guardrails: SFTP connectivity" + env: + FTP_HOST: "${{ secrets.FTP_HOST }}" + FTP_USER: "${{ secrets.FTP_USER }}" + FTP_KEY: "${{ secrets.FTP_KEY }}" + FTP_PORT: "${{ secrets.FTP_PORT }}" + run: | + set -euo pipefail + + mkdir -p "$HOME/.ssh" + key_file="$HOME/.ssh/ci_sftp_key" + printf '%s +' "${FTP_KEY}" > "${key_file}" + chmod 600 "${key_file}" + + port="${FTP_PORT:-22}" + + echo "### SFTP connectivity test" >> "${GITHUB_STEP_SUMMARY}" + echo "Attempting non-destructive SFTP session (pwd only)." >> "${GITHUB_STEP_SUMMARY}" + + sftp -oBatchMode=yes -oStrictHostKeyChecking=no -P "${port}" -i "${key_file}" "${FTP_USER}@${FTP_HOST}" <<'EOF' + pwd + bye +EOF + + echo "SFTP connectivity check passed." >> "${GITHUB_STEP_SUMMARY}" + scripts_config: name: Scripts and tooling runs-on: ubuntu-latest + needs: [access_check] + if: ${{ needs.access_check.outputs.allowed == 'true' }} permissions: contents: read @@ -202,13 +212,16 @@ jobs: env: PROFILE_RAW: "${{ github.event.inputs.profile }}" run: | - set -euxo pipefail + set -euo pipefail profile="${PROFILE_RAW:-all}" - if [ "${profile}" != "all" ] && [ "${profile}" != "release" ] && [ "${profile}" != "scripts" ]; then - echo "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" - exit 1 - fi + case "${profile}" in + all|release|scripts) ;; + *) + echo "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac if [ "${profile}" = "release" ]; then echo "Profile release selected. Skipping scripts checks." >> "${GITHUB_STEP_SUMMARY}" @@ -270,38 +283,55 @@ jobs: command -v php >/dev/null 2>&1 && tool_status+=("php") || true command -v xmllint >/dev/null 2>&1 && tool_status+=("xmllint") || true + report_json="$(python3 - <<'PY' +import json +import os +profile = os.environ.get('PROFILE_RAW') or 'all' +required = [ + "scripts/validate/manifest.sh", + "scripts/validate/xml_wellformed.sh", + "scripts/validate/changelog.sh", + "scripts/validate/tabs.sh", + "scripts/validate/paths.sh", + "scripts/validate/version_alignment.sh", + "scripts/validate/language_structure.sh", + "scripts/validate/php_syntax.sh", + "scripts/validate/no_secrets.sh", + "scripts/validate/license_headers.sh", +] +legacy = [ + "scripts/validate_manifest.sh", + "scripts/validate_xml_wellformed.sh", + "scripts/validate_changelog.sh", + "scripts/validate_tabs.sh", + "scripts/validate_paths.sh", + "scripts/validate_version_alignment.sh", + "scripts/validate_language_structure.sh", + "scripts/validate_php_syntax.sh", + "scripts/validate_no_secrets.sh", + "scripts/validate_license_headers.sh", +] +missing = os.environ.get('MISSING_FILES','').split('\n') if os.environ.get('MISSING_FILES') else [] +legacy_present = os.environ.get('LEGACY_PRESENT','').split('\n') if os.environ.get('LEGACY_PRESENT') else [] +tools = os.environ.get('TOOLS','').split() if os.environ.get('TOOLS') else [] +out = { + "profile": profile, + "checked": {"script_files": required, "legacy_script_files": legacy}, + "missing_script_files": missing, + "legacy_present": legacy_present, + "tools_available": tools, +} +print(json.dumps(out, indent=2)) +PY +)" + { echo "### Guardrails: scripts and tooling" echo "Tools available: ${tool_status[*]:-none}" echo "" echo "### Guardrails report (JSON)" echo "```json" - printf '{"profile":"%s","checked":{"script_files":[' "${profile}" - sep="" - for c in "${required_script_files[@]}"; do - printf '%s"%s"' "${sep}" "${c}" - sep=","; - done - printf '],"legacy_script_files":[' - sep="" - for c in "${legacy_script_files[@]}"; do - printf '%s"%s"' "${sep}" "${c}" - sep=","; - done - printf ']},"missing_script_files":[' - sep="" - for m in "${missing_files[@]}"; do - printf '%s"%s"' "${sep}" "${m}" - sep=","; - done - printf '],"legacy_present":[' - sep="" - for m in "${legacy_present[@]}"; do - printf '%s"%s"' "${sep}" "${m}" - sep=","; - done - printf ']}' - echo + echo "${report_json}" echo "```" } >> "${GITHUB_STEP_SUMMARY}" @@ -310,14 +340,19 @@ jobs: for m in "${missing_files[@]}"; do echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}" done - echo "MISSING_SCRIPT_FILES: ${missing_files[*]}" >&2 echo "ERROR: Guardrails failed. Missing required script files." >> "${GITHUB_STEP_SUMMARY}" exit 1 fi + env: + MISSING_FILES: ${{ '' }} + LEGACY_PRESENT: ${{ '' }} + TOOLS: ${{ '' }} repo_health: name: Repository health runs-on: ubuntu-latest + needs: [access_check] + if: ${{ needs.access_check.outputs.allowed == 'true' }} permissions: contents: read @@ -331,15 +366,16 @@ jobs: env: PROFILE_RAW: "${{ github.event.inputs.profile }}" run: | - set -euxo pipefail + set -euo pipefail profile="${PROFILE_RAW:-all}" - if [ "${profile}" != "all" ] && [ "${profile}" != "release" ] && [ "${profile}" != "scripts" ]; then - echo "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" - exit 1 - fi - - # Always run repo health for all profiles. + case "${profile}" in + all|release|scripts) ;; + *) + echo "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac required_files=( "README.md" @@ -383,13 +419,11 @@ jobs: fi done - # Branch topology health checks. git fetch origin --prune dev_paths=() dev_branches=() - # Collect remote branches starting with dev/ while IFS= read -r b; do name="${b#origin/}" if [ "${name}" = "dev" ]; then @@ -399,17 +433,14 @@ jobs: fi done < <(git branch -r --list "origin/dev*" | sed 's/^ *//') - # Enforce at least one dev/* path branch if [ "${#dev_paths[@]}" -eq 0 ]; then missing_required+=("dev/* branch (e.g. dev/01.00.00)") fi - # Enforce dev is not a concrete branch if [ "${#dev_branches[@]}" -gt 0 ]; then missing_required+=("invalid branch 'dev' (must be dev/)") fi - # Lightweight content health checks. content_warnings=() if [ -f "CHANGELOG.md" ]; then @@ -430,49 +461,37 @@ jobs: fi fi + 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"] +optional_files = ["SECURITY.md","GOVERNANCE.md",".editorconfig",".gitattributes",".gitignore"] +required_paths = [".github/workflows","scripts"] +missing_required = os.environ.get('MISSING_REQUIRED','').split('\n') if os.environ.get('MISSING_REQUIRED') else [] +missing_optional = os.environ.get('MISSING_OPTIONAL','').split('\n') if os.environ.get('MISSING_OPTIONAL') else [] +content_warnings = os.environ.get('CONTENT_WARNINGS','').split('\n') 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": missing_required, + "missing_optional": missing_optional, + "content_warnings": content_warnings, +} +print(json.dumps(out, indent=2)) +PY +)" + { echo "### Guardrails: repository health" echo "" echo "### Guardrails report (JSON)" echo "```json" - printf '{"profile":"%s","checked":{"required_files":[' "${profile}" - sep="" - for c in "${required_files[@]}"; do - printf '%s"%s"' "${sep}" "${c}" - sep=","; - done - printf '],"optional_files":[' - sep="" - for c in "${optional_files[@]}"; do - printf '%s"%s"' "${sep}" "${c}" - sep=","; - done - printf '],"required_paths":[' - sep="" - for c in "${required_paths[@]}"; do - printf '%s"%s"' "${sep}" "${c}" - sep=","; - done - printf ']},"missing_required":[' - sep="" - for m in "${missing_required[@]}"; do - printf '%s"%s"' "${sep}" "${m}" - sep=","; - done - printf '],"missing_optional":[' - sep="" - for m in "${missing_optional[@]}"; do - printf '%s"%s"' "${sep}" "${m}" - sep=","; - done - printf '],"content_warnings":[' - sep="" - for m in "${content_warnings[@]}"; do - printf '%s"%s"' "${sep}" "${m}" - sep=","; - done - printf ']}' - echo + echo "${report_json}" echo "```" } >> "${GITHUB_STEP_SUMMARY}" @@ -481,7 +500,6 @@ jobs: for m in "${missing_required[@]}"; do echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}" done - echo "MISSING_REQUIRED: ${missing_required[*]}" >&2 echo "ERROR: Guardrails failed. Missing required repository artifacts." >> "${GITHUB_STEP_SUMMARY}" exit 1 fi @@ -499,4 +517,7 @@ jobs: echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}" done fi - + env: + MISSING_REQUIRED: ${{ '' }} + MISSING_OPTIONAL: ${{ '' }} + CONTENT_WARNINGS: ${{ '' }}