Update repo_health.yml

This commit is contained in:
2025-12-27 01:35:11 -06:00
parent ac7732d3c6
commit bc226730f6

View File

@@ -50,9 +50,61 @@ permissions:
contents: read contents: read
jobs: 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: release_config:
name: Release configuration name: Release configuration
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [access_check]
if: ${{ needs.access_check.outputs.allowed == 'true' }}
permissions: permissions:
contents: read contents: read
@@ -69,48 +121,36 @@ jobs:
FTP_PORT: "${{ secrets.FTP_PORT }}" FTP_PORT: "${{ secrets.FTP_PORT }}"
FTP_PATH_SUFFIX: "${{ vars.FTP_PATH_SUFFIX }}" FTP_PATH_SUFFIX: "${{ vars.FTP_PATH_SUFFIX }}"
run: | run: |
set -euxo pipefail set -euo pipefail
profile="${PROFILE_RAW:-all}" profile="${PROFILE_RAW:-all}"
if [ "${profile}" != "all" ] && [ "${profile}" != "release" ] && [ "${profile}" != "scripts" ]; then case "${profile}" in
echo "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" all|release|scripts) ;;
exit 1 *)
fi echo "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = "scripts" ]; then if [ "${profile}" = "scripts" ]; then
echo "Profile scripts selected. Skipping release configuration checks." >> "${GITHUB_STEP_SUMMARY}" echo "Profile scripts selected. Skipping release configuration checks." >> "${GITHUB_STEP_SUMMARY}"
exit 0 exit 0
fi fi
required=( required=("FTP_HOST" "FTP_USER" "FTP_KEY" "FTP_PATH")
"FTP_HOST" optional=("FTP_PASSWORD" "FTP_PROTOCOL" "FTP_PORT" "FTP_PATH_SUFFIX")
"FTP_USER"
"FTP_KEY"
"FTP_PATH"
)
optional=(
"FTP_PASSWORD"
"FTP_PROTOCOL"
"FTP_PORT"
"FTP_PATH_SUFFIX"
)
missing=() missing=()
missing_optional=() missing_optional=()
for k in "${required[@]}"; do for k in "${required[@]}"; do
v="${!k:-}" v="${!k:-}"
if [ -z "${v}" ]; then [ -z "${v}" ] && missing+=("${k}")
missing+=("${k}")
fi
done done
for k in "${optional[@]}"; do for k in "${optional[@]}"; do
v="${!k:-}" v="${!k:-}"
if [ -z "${v}" ]; then [ -z "${v}" ] && missing_optional+=("${k}")
missing_optional+=("${k}")
fi
done done
proto="${FTP_PROTOCOL:-sftp}" proto="${FTP_PROTOCOL:-sftp}"
@@ -118,77 +158,47 @@ jobs:
missing+=("FTP_PROTOCOL_INVALID") missing+=("FTP_PROTOCOL_INVALID")
fi 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 if [ "${#missing[@]}" -gt 0 ]; then
echo "### Missing required release configuration" >> "${GITHUB_STEP_SUMMARY}" echo "### Missing required release configuration" >> "${GITHUB_STEP_SUMMARY}"
for m in "${missing[@]}"; do for m in "${missing[@]}"; do echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}"; done
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}"
exit 1 exit 1
fi 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: scripts_config:
name: Scripts and tooling name: Scripts and tooling
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [access_check]
if: ${{ needs.access_check.outputs.allowed == 'true' }}
permissions: permissions:
contents: read contents: read
@@ -202,13 +212,16 @@ jobs:
env: env:
PROFILE_RAW: "${{ github.event.inputs.profile }}" PROFILE_RAW: "${{ github.event.inputs.profile }}"
run: | run: |
set -euxo pipefail set -euo pipefail
profile="${PROFILE_RAW:-all}" profile="${PROFILE_RAW:-all}"
if [ "${profile}" != "all" ] && [ "${profile}" != "release" ] && [ "${profile}" != "scripts" ]; then case "${profile}" in
echo "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" all|release|scripts) ;;
exit 1 *)
fi echo "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
;;
esac
if [ "${profile}" = "release" ]; then if [ "${profile}" = "release" ]; then
echo "Profile release selected. Skipping scripts checks." >> "${GITHUB_STEP_SUMMARY}" 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 php >/dev/null 2>&1 && tool_status+=("php") || true
command -v xmllint >/dev/null 2>&1 && tool_status+=("xmllint") || 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 "### Guardrails: scripts and tooling"
echo "Tools available: ${tool_status[*]:-none}" echo "Tools available: ${tool_status[*]:-none}"
echo "" echo ""
echo "### Guardrails report (JSON)" echo "### Guardrails report (JSON)"
echo "```json" echo "```json"
printf '{"profile":"%s","checked":{"script_files":[' "${profile}" echo "${report_json}"
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 "```" echo "```"
} >> "${GITHUB_STEP_SUMMARY}" } >> "${GITHUB_STEP_SUMMARY}"
@@ -310,14 +340,19 @@ jobs:
for m in "${missing_files[@]}"; do for m in "${missing_files[@]}"; do
echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}" echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}"
done done
echo "MISSING_SCRIPT_FILES: ${missing_files[*]}" >&2
echo "ERROR: Guardrails failed. Missing required script files." >> "${GITHUB_STEP_SUMMARY}" echo "ERROR: Guardrails failed. Missing required script files." >> "${GITHUB_STEP_SUMMARY}"
exit 1 exit 1
fi fi
env:
MISSING_FILES: ${{ '' }}
LEGACY_PRESENT: ${{ '' }}
TOOLS: ${{ '' }}
repo_health: repo_health:
name: Repository health name: Repository health
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [access_check]
if: ${{ needs.access_check.outputs.allowed == 'true' }}
permissions: permissions:
contents: read contents: read
@@ -331,15 +366,16 @@ jobs:
env: env:
PROFILE_RAW: "${{ github.event.inputs.profile }}" PROFILE_RAW: "${{ github.event.inputs.profile }}"
run: | run: |
set -euxo pipefail set -euo pipefail
profile="${PROFILE_RAW:-all}" profile="${PROFILE_RAW:-all}"
if [ "${profile}" != "all" ] && [ "${profile}" != "release" ] && [ "${profile}" != "scripts" ]; then case "${profile}" in
echo "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" all|release|scripts) ;;
exit 1 *)
fi echo "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
# Always run repo health for all profiles. ;;
esac
required_files=( required_files=(
"README.md" "README.md"
@@ -383,13 +419,11 @@ jobs:
fi fi
done done
# Branch topology health checks.
git fetch origin --prune git fetch origin --prune
dev_paths=() dev_paths=()
dev_branches=() dev_branches=()
# Collect remote branches starting with dev/
while IFS= read -r b; do while IFS= read -r b; do
name="${b#origin/}" name="${b#origin/}"
if [ "${name}" = "dev" ]; then if [ "${name}" = "dev" ]; then
@@ -399,17 +433,14 @@ jobs:
fi fi
done < <(git branch -r --list "origin/dev*" | sed 's/^ *//') done < <(git branch -r --list "origin/dev*" | sed 's/^ *//')
# Enforce at least one dev/* path branch
if [ "${#dev_paths[@]}" -eq 0 ]; then if [ "${#dev_paths[@]}" -eq 0 ]; then
missing_required+=("dev/* branch (e.g. dev/01.00.00)") missing_required+=("dev/* branch (e.g. dev/01.00.00)")
fi fi
# Enforce dev is not a concrete branch
if [ "${#dev_branches[@]}" -gt 0 ]; then if [ "${#dev_branches[@]}" -gt 0 ]; then
missing_required+=("invalid branch 'dev' (must be dev/<version>)") missing_required+=("invalid branch 'dev' (must be dev/<version>)")
fi fi
# Lightweight content health checks.
content_warnings=() content_warnings=()
if [ -f "CHANGELOG.md" ]; then if [ -f "CHANGELOG.md" ]; then
@@ -430,49 +461,37 @@ jobs:
fi fi
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 "### Guardrails: repository health"
echo "" echo ""
echo "### Guardrails report (JSON)" echo "### Guardrails report (JSON)"
echo "```json" echo "```json"
printf '{"profile":"%s","checked":{"required_files":[' "${profile}" echo "${report_json}"
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 "```" echo "```"
} >> "${GITHUB_STEP_SUMMARY}" } >> "${GITHUB_STEP_SUMMARY}"
@@ -481,7 +500,6 @@ jobs:
for m in "${missing_required[@]}"; do for m in "${missing_required[@]}"; do
echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}" echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}"
done done
echo "MISSING_REQUIRED: ${missing_required[*]}" >&2
echo "ERROR: Guardrails failed. Missing required repository artifacts." >> "${GITHUB_STEP_SUMMARY}" echo "ERROR: Guardrails failed. Missing required repository artifacts." >> "${GITHUB_STEP_SUMMARY}"
exit 1 exit 1
fi fi
@@ -499,4 +517,7 @@ jobs:
echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}" echo "- ${m}" >> "${GITHUB_STEP_SUMMARY}"
done done
fi fi
env:
MISSING_REQUIRED: ${{ '' }}
MISSING_OPTIONAL: ${{ '' }}
CONTENT_WARNINGS: ${{ '' }}