From ce17f8df4eed78f5ad2f299afe58ed0e1df91119 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 3 Jan 2026 12:56:48 -0600 Subject: [PATCH 1/5] Revise CI workflow and update copyright year Updated copyright year and modified workflow steps for clarity and functionality. Signed-off-by: Jonathan Miller --- .github/workflows/ci.yml | 153 ++++++++++++++------------------------- 1 file changed, 54 insertions(+), 99 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22f6ae7..b19ed5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,13 +1,12 @@ -# ============================================================================ -# Copyright (C) 2025 Moko Consulting +# Copyright (C) 2026 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 +# 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 +# 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, @@ -19,44 +18,41 @@ # along with this program. If not, see . # # FILE INFORMATION -# DEFGROUP: MokoStandards -# INGROUP: GitHub.Actions.ContinuousIntegration +# DEFGROUP: GitHub.Workflow +# INGROUP: MokoStandards.CI # REPO: https://github.com/mokoconsulting-tech/MokoStandards # PATH: /.github/workflows/ci.yml # VERSION: 01.00.00 -# BRIEF: Continuous integration governance workflow for standards enforcement. -# NOTE: Runs on every push. Auto-normalizes YAML tabs to two spaces before validation. -# ============================================================================ +# BRIEF: Continuous integration workflow enforcing repository standards. +# NOTE: -name: Continuous integration +name: Continuous Integration on: push: + branches: + - main + - dev/** + - rc/** + - version/** pull_request: - workflow_dispatch: - inputs: - auto_fix/tabs: - description: "Run scripts/fix/tabs.sh before validation (does not commit changes)" - required: false - default: false - type: boolean - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + branches: + - main + - dev/** + - rc/** + - version/** permissions: contents: read -defaults: - run: - shell: bash - jobs: ci: - name: Standards Continuous integration Validation + name: Repository Validation Pipeline runs-on: ubuntu-latest - timeout-minutes: 15 + + env: + CI: true + PROFILE: all steps: - name: Checkout repository @@ -64,85 +60,44 @@ jobs: with: fetch-depth: 0 - - name: Auto-fix YAML tabs when YAML changes detected - if: ${{ github.event_name != 'workflow_dispatch' || inputs.auto_fix/tabs }} + - name: Normalize line endings run: | - set -euo pipefail + git config --global core.autocrlf false - if ! command -v git >/dev/null 2>&1; then - echo "git not available, skipping tab normalization" - exit 0 - fi - - # Determine change window - # - pull_request: compare base SHA to head SHA - # - push: compare event.before to event.after (current SHA) - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - BASE_SHA="${{ github.event.pull_request.base.sha }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - RANGE="$BASE_SHA...$HEAD_SHA" - elif [ "${GITHUB_EVENT_NAME}" = "push" ]; then - BEFORE_SHA="${{ github.event.before }}" - AFTER_SHA="${{ github.sha }}" - RANGE="$BEFORE_SHA...$AFTER_SHA" - else - RANGE="" - fi - - if [ -n "$RANGE" ]; then - CHANGED_YAML=$(git diff --name-only "$RANGE" -- '*.yml' '*.yaml' || true) - else - CHANGED_YAML=$(git ls-files '*.yml' '*.yaml' 2>/dev/null || true) - fi - - if [ -n "$CHANGED_YAML" ]; then - echo "YAML changes detected. Running fix/tabs.sh" - if [ -x "./scripts/fix/tabs.sh" ]; then - ./scripts/fix/tabs.sh - else - echo "fix/tabs.sh not present, skipping" - fi - else - echo "No YAML changes detected. Skipping fix/tabs.sh" - fi - - - name: Validate YAML tabs usage + - name: Verify script executability run: | - set -euo pipefail - if [ -x "./scripts/validate/tabs.sh" ]; then - ./scripts/validate/tabs.sh - else - echo "validate/tabs.sh not present, skipping" - fi + chmod +x scripts/**/*.sh || true - - name: Validate file paths + - name: Required validations run: | - set -euo pipefail - if [ -x "./scripts/validate/paths.sh" ]; then - ./scripts/validate/paths.sh - else - echo "validate/paths.sh not present, skipping" - fi + set -e - - name: Validate CHANGELOG governance - run: | - set -euo pipefail - if [ -x "./scripts/validate/changelog.sh" ]; then - ./scripts/validate/changelog.sh - else - echo "validate/changelog.sh not present, skipping" - fi + scripts/validate/manifest.sh + scripts/validate/xml_wellformed.sh - - name: Validate Joomla manifests + - name: Optional validations run: | - set -euo pipefail - if [ -x "./scripts/validate/manifest.sh" ]; then - ./scripts/validate/manifest.sh - else - echo "validate/manifest.sh not present, skipping" - fi + set +e - - name: Continuous integration completion + scripts/validate/changelog.sh + scripts/validate/language_structure.sh + scripts/validate/license_headers.sh + scripts/validate/no_secrets.sh + scripts/validate/paths.sh + scripts/validate/php_syntax.sh + scripts/validate/tabs.sh + scripts/validate/version_alignment.sh + + - name: CI summary + if: always() run: | - set -euo pipefail - echo "Continuous integration checks completed successfully" + { + echo "### CI Execution Summary" + echo "" + echo "- Repository: $GITHUB_REPOSITORY" + echo "- Branch: $GITHUB_REF_NAME" + echo "- Commit: $GITHUB_SHA" + echo "- Runner: ubuntu-latest" + echo "" + echo "CI completed. Review logs above for validation outcomes." + } >> "$GITHUB_STEP_SUMMARY" From d5195276ebdb2c03ab572754484bffb4dd2d318a Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 3 Jan 2026 13:06:07 -0600 Subject: [PATCH 2/5] Update ci.yml Signed-off-by: Jonathan Miller --- .github/workflows/ci.yml | 882 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 813 insertions(+), 69 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b19ed5f..4f6a198 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,103 +1,847 @@ -# Copyright (C) 2026 Moko Consulting +# ============================================================================ +# 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.CI +# INGROUP: MokoStandards.Validation # REPO: https://github.com/mokoconsulting-tech/MokoStandards -# PATH: /.github/workflows/ci.yml +# PATH: /.github/workflows/repo_health.yml # VERSION: 01.00.00 -# BRIEF: Continuous integration workflow enforcing repository standards. -# NOTE: +# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts. +# NOTE: Field is user-managed. +# ============================================================================ -name: Continuous Integration +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. al[...] + required: true + default: all + type: choice + options: + - all + - release + - scripts + - repo + pull_request: + paths: + - .github/workflows/** + - scripts/** + - docs/** + - dev/** push: branches: - main + paths: + - .github/workflows/** + - scripts/** + - docs/** - dev/** - - rc/** - - version/** - pull_request: - branches: - - main - - dev/** - - rc/** - - version/** permissions: contents: read -jobs: - ci: - name: Repository Validation Pipeline - runs-on: ubuntu-latest +env: + # Global policy variables baked into workflow + ALLOWED_SFTP_PROTOCOLS: sftp - env: - CI: true - PROFILE: all + # Release policy + RELEASE_REQUIRED_VARS: FTP_HOST,FTP_USER,FTP_PATH + RELEASE_OPTIONAL_VARS: FTP_KEY,FTP_PASSWORD,FTP_PROTOCOL,FTP_PORT,FTP_PATH_SUFFIX + + # Scripts governance policy + # Note: directories listed without a trailing slash. + SCRIPTS_REQUIRED_DIRS: + SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate + + # Repo health policy + # Files are listed as-is; directories must end with a trailing slash. + REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.github/workflows/,src/ + REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/,dev/ + REPO_DISALLOWED_DIRS: + REPO_DISALLOWED_FILES: TODO.md,todo.md + + # Extended checks toggles + EXTENDED_CHECKS: "true" + + # Operational toggles + SFTP_VERBOSE: "false" + + # File / directory variables (moved to top-level env) + DOCS_INDEX: docs/docs-index.md + SCRIPT_DIR: scripts + WORKFLOWS_DIR: .github/workflows + SHELLCHECK_PATTERN: '*.sh' + SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml' + +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: Checkout repository + - name: Check actor permission (admin only) + id: perm + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const res = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: context.actor, + }); + + 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: ${context.actor}`); + 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 + printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}" + exit 1 + + release_config: + name: Release configuration + needs: access_check + if: ${{ needs.access_check.outputs.allowed == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Normalize line endings + - name: Guardrails release vars + 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_PATH: ${{ secrets.FTP_PATH }} + FTP_PROTOCOL: ${{ secrets.FTP_PROTOCOL }} + FTP_PORT: ${{ secrets.FTP_PORT }} + FTP_PATH_SUFFIX: ${{ vars.FTP_PATH_SUFFIX }} run: | - git config --global core.autocrlf false + set -euo pipefail - - name: Verify script executability - run: | - chmod +x scripts/**/*.sh || true + profile="${PROFILE_RAW:-all}" + case "${profile}" in + all|release|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac - - name: Required validations + if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then + { + printf '%s\n' '### Release configuration' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' 'Status: SKIPPED' + printf '%s\n' 'Reason: profile excludes release validation' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_VARS}" + IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_VARS}" + IFS=',' read -r -a allowed_proto <<< "${ALLOWED_SFTP_PROTOCOLS}" + + 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:-}" ]; then + ok=false + for ap in "${allowed_proto[@]}"; do + [ "${proto}" = "${ap}" ] && ok=true + done + [ "${ok}" = false ] && missing+=("FTP_PROTOCOL_INVALID") + fi + + target_path="${FTP_PATH:-}" + if [ -n "${FTP_PATH_SUFFIX:-}" ]; then + target_path="${target_path%/}/${FTP_PATH_SUFFIX#/}" + fi + + auth_method='none' + [ -n "${FTP_KEY:-}" ] && auth_method='key' + [ -z "${FTP_KEY:-}" ] && [ -n "${FTP_PASSWORD:-}" ] && auth_method='password' + + { + printf '%s\n' '### Release configuration' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' '| Control | Value |' + printf '%s\n' '|---|---|' + printf '%s\n' "| Protocol | ${proto} |" + printf '%s\n' "| Port | ${FTP_PORT:-22} |" + printf '%s\n' "| Auth | ${auth_method} |" + printf '%s\n' "| Path (resolved) | ${target_path} |" + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${#missing_optional[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing optional release configuration' + for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + else + { + printf '%s\n' '### Optional release configuration' + printf '%s\n' 'None missing.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + if [ "${#missing[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing required release configuration' + for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done + printf '%s\n' 'ERROR: Guardrails failed. Missing required release configuration.' + } >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + + { + printf '%s\n' '### Release configuration result' + printf '%s\n' 'Status: OK' + printf '%s\n' 'All required release variables present.' + printf '\n' + } >> "${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 }} + FTP_PATH: ${{ secrets.FTP_PATH }} + FTP_PATH_SUFFIX: ${{ vars.FTP_PATH_SUFFIX }} run: | + set -euo pipefail + + profile="${PROFILE_RAW:-all}" + case "${profile}" in + all|release|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then + { + printf '%s\n' '### SFTP connectivity' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' 'Status: SKIPPED' + printf '%s\n' 'Reason: profile excludes release validation' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + port="${FTP_PORT:-22}" + + target_path="${FTP_PATH}" + if [ -n "${FTP_PATH_SUFFIX:-}" ]; then + target_path="${target_path%/}/${FTP_PATH_SUFFIX#/}" + fi + + sftp_verbose="${SFTP_VERBOSE:-false}" + sftp_v_opt=() + [ "${sftp_verbose}" = 'true' ] && sftp_v_opt=(-vv) + + auth_method='none' + if [ -n "${FTP_KEY:-}" ]; then + auth_method='key' + elif [ -n "${FTP_PASSWORD:-}" ]; then + auth_method='password' + fi + + { + printf '%s\n' '### SFTP connectivity' + printf '%s\n' '| Control | Value |' + printf '%s\n' '|---|---|' + printf '%s\n' "| Host | ${FTP_HOST} |" + printf '%s\n' "| User | ${FTP_USER} |" + printf '%s\n' "| Port | ${port} |" + printf '%s\n' "| Auth | ${auth_method} |" + printf '%s\n' "| Path (resolved) | ${target_path} |" + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + sftp_cmds="$(printf 'cd %s\npwd\nbye\n' "${target_path}")" + + set +e + if [ -n "${FTP_KEY:-}" ]; then + mkdir -p "$HOME/.ssh" + key_file="$HOME/.ssh/ci_sftp_key" + printf '%s\n' "${FTP_KEY}" > "${key_file}" + chmod 600 "${key_file}" + + if [ -n "${FTP_PASSWORD:-}" ]; then + first_line="$(head -n 1 "${key_file}" || true)" + if printf '%s\n' "${first_line}" | grep -q '^PuTTY-User-Key-File-'; then + { + printf '%s\n' '### SFTP connectivity result' + printf '%s\n' 'Status: FAILED' + printf '%s\n' 'Reason: FTP_KEY appears to be a PuTTY PPK. Provide an OpenSSH private key.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + ssh-keygen -p -P "${FTP_PASSWORD}" -N '' -f "${key_file}" >/dev/null + fi + + printf '%s' "${sftp_cmds}" | sftp "${sftp_v_opt[@]}" -oBatchMode=yes -oStrictHostKeyChecking=no -P "${port}" -i "${key_file}" "${FTP_USER}@${FTP_HOST}" >/tmp/sftp_check.log 2>&1 + sftp_rc=$? + elif [ -n "${FTP_PASSWORD:-}" ]; then + command -v sshpass >/dev/null 2>&1 || (sudo apt-get update -qq && sudo apt-get install -y sshpass >/dev/null) + printf '%s' "${sftp_cmds}" | sshpass -p "${FTP_PASSWORD}" sftp "${sftp_v_opt[@]}" -oBatchMode=no -oStrictHostKeyChecking=no -P "${port}" "${FTP_USER}@${FTP_HOST}" >/tmp/sftp_check.log 2>&1 + sftp_rc=$? + else + { + printf '%s\n' '### SFTP connectivity result' + printf '%s\n' 'Status: FAILED' + printf '%s\n' 'Reason: No FTP_KEY or FTP_PASSWORD provided for SFTP authentication.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi set -e - scripts/validate/manifest.sh - scripts/validate/xml_wellformed.sh - - - name: Optional validations - run: | - set +e - - scripts/validate/changelog.sh - scripts/validate/language_structure.sh - scripts/validate/license_headers.sh - scripts/validate/no_secrets.sh - scripts/validate/paths.sh - scripts/validate/php_syntax.sh - scripts/validate/tabs.sh - scripts/validate/version_alignment.sh - - - name: CI summary - if: always() - run: | { - echo "### CI Execution Summary" - echo "" - echo "- Repository: $GITHUB_REPOSITORY" - echo "- Branch: $GITHUB_REF_NAME" - echo "- Commit: $GITHUB_SHA" - echo "- Runner: ubuntu-latest" - echo "" - echo "CI completed. Review logs above for validation outcomes." - } >> "$GITHUB_STEP_SUMMARY" + printf '%s\n' '### SFTP connectivity result' + if [ "${sftp_rc}" -eq 0 ]; then + printf '%s\n' 'Status: SUCCESS' + printf '%s\n' 'Validated host connectivity and remote path access.' + else + printf '%s\n' "Status: FAILED (exit code ${sftp_rc})" + printf '\n' + printf '%s\n' 'Last SFTP output' + tail -n 60 /tmp/sftp_check.log || true + fi + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + [ "${sftp_rc}" -eq 0 ] || exit 1 + + scripts_governance: + name: Scripts governance + needs: access_check + if: ${{ needs.access_check.outputs.allowed == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Scripts folder checks + env: + PROFILE_RAW: ${{ github.event.inputs.profile }} + run: | + set -euo pipefail + + profile="${PROFILE_RAW:-all}" + case "${profile}" in + all|release|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then + { + printf '%s\n' '### Scripts governance' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' 'Status: SKIPPED' + printf '%s\n' 'Reason: profile excludes scripts governance' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + if [ ! -d "${SCRIPT_DIR}" ]; then + { + printf '%s\n' '### Scripts governance' + printf '%s\n' 'Status: OK (advisory)' + printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}" + IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}" + + missing_dirs=() + unapproved_dirs=() + + for d in "${required_dirs[@]}"; do + req="${d%/}" + [ ! -d "${req}" ] && missing_dirs+=("${req}/") + done + + while IFS= read -r d; do + allowed=false + for a in "${allowed_dirs[@]}"; do + a_norm="${a%/}" + [ "${d%/}" = "${a_norm}" ] && allowed=true + done + [ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/") + done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##') + + { + printf '%s\n' '### Scripts governance' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' '| Area | Status | Notes |' + printf '%s\n' '|---|---|---|' + + if [ "${#missing_dirs[@]}" -gt 0 ]; then + printf '%s\n' '| Required directories | Warning | Missing required subfolders |' + else + printf '%s\n' '| Required directories | OK | All required subfolders present |' + fi + + if [ "${#unapproved_dirs[@]}" -gt 0 ]; then + printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |' + else + printf '%s\n' '| Directory policy | OK | No unapproved directories |' + fi + + printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |' + printf '\n' + + if [ "${#missing_dirs[@]}" -gt 0 ]; then + printf '%s\n' 'Missing required script directories:' + for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + else + printf '%s\n' 'Missing required script directories: none.' + printf '\n' + fi + + if [ "${#unapproved_dirs[@]}" -gt 0 ]; then + printf '%s\n' 'Unapproved script directories detected:' + for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + else + printf '%s\n' 'Unapproved script directories detected: none.' + printf '\n' + fi + + printf '%s\n' 'Scripts governance completed in advisory mode.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + repo_health: + name: Repository health + needs: access_check + if: ${{ needs.access_check.outputs.allowed == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Repository health checks + env: + PROFILE_RAW: ${{ github.event.inputs.profile }} + run: | + set -euo pipefail + + profile="${PROFILE_RAW:-all}" + case "${profile}" in + all|release|scripts|repo) ;; + *) + printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + ;; + esac + + if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then + { + printf '%s\n' '### Repository health' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' 'Status: SKIPPED' + printf '%s\n' 'Reason: profile excludes repository health' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 0 + fi + + IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}" + IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}" + IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}" + IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES}" + + missing_required=() + missing_optional=() + + for item in "${required_artifacts[@]}"; do + if printf '%s' "${item}" | grep -q '/$'; then + d="${item%/}" + [ ! -d "${d}" ] && missing_required+=("${item}") + else + [ ! -f "${item}" ] && missing_required+=("${item}") + fi + done + + for f in "${optional_files[@]}"; do + [ ! -f "${f}" ] && missing_optional+=("${f}") + done + + for d in "${disallowed_dirs[@]}"; do + d_norm="${d%/}" + [ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)") + done + + for f in "${disallowed_files[@]}"; do + [ -f "${f}" ] && missing_required+=("${f} (disallowed)") + done + + 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 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then + content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)") + 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 PROFILE_RAW="${profile}" + 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' + + 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, + '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 '%s\n' '### Repository health' + printf '%s\n' "Profile: ${profile}" + printf '%s\n' '| Metric | Value |' + printf '%s\n' '|---|---|' + printf '%s\n' "| Missing required | ${#missing_required[@]} |" + printf '%s\n' "| Missing optional | ${#missing_optional[@]} |" + printf '%s\n' "| Content warnings | ${#content_warnings[@]} |" + printf '\n' + + printf '%s\n' '### Guardrails report (JSON)' + printf '%s\n' '```json' + printf '%s\n' "${report_json}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${#missing_required[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing required repo artifacts' + for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done + printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + + if [ "${#missing_optional[@]}" -gt 0 ]; then + { + printf '%s\n' '### Missing optional repo artifacts' + for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + if [ "${#content_warnings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Repo content warnings' + for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + extended_enabled="${EXTENDED_CHECKS:-true}" + extended_findings=() + + if [ "${extended_enabled}" = 'true' ]; then + # CODEOWNERS presence + if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then + : + else + extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)") + fi + + # Workflow pinning advisory: flag uses @main/@master + if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then + bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)" + if [ -n "${bad_refs}" ]; then + extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt") + { + printf '%s\n' '### Workflow pinning advisory' + printf '%s\n' 'Found uses: entries pinned to main/master:' + printf '%s\n' '```' + printf '%s\n' "${bad_refs}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + # Docs index link integrity (docs/docs-index.md) + if [ -f "${DOCS_INDEX}" ]; then + missing_links="$(python3 - <<'PY' + import os + import re + + idx = os.environ.get('DOCS_INDEX', 'docs/docs-index.md') + base = os.getcwd() + + bad = [] + pat = re.compile(r'\[[^\]]+\]\(([^)]+)\)') + + with open(idx, 'r', encoding='utf-8') as f: + for line in f: + for m in pat.findall(line): + link = m.strip() + if link.startswith('http://') or link.startswith('https://') or link.startswith('#') or link.startswith('mailto:'): + continue + if link.startswith('/'): + rel = link.lstrip('/') + else: + rel = os.path.normpath(os.path.join(os.path.dirname(idx), link)) + rel = rel.split('#', 1)[0] + rel = rel.split('?', 1)[0] + if not rel: + continue + p = os.path.join(base, rel) + if not os.path.exists(p): + bad.append(rel) + + print('\n'.join(sorted(set(bad)))) + PY + )" + if [ -n "${missing_links}" ]; then + extended_findings+=("docs/docs-index.md contains broken relative links") + { + printf '%s\n' '### Docs index link integrity' + printf '%s\n' 'Broken relative links:' + while IFS= read -r l; do [ -n "${l}" ] && printf '%s\n' "- ${l}"; done <<< "${missing_links}" + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + # ShellCheck advisory + if [ -d "${SCRIPT_DIR}" ]; then + if ! command -v shellcheck >/dev/null 2>&1; then + sudo apt-get update -qq + sudo apt-get install -y shellcheck >/dev/null + fi + + sc_out='' + while IFS= read -r shf; do + [ -z "${shf}" ] && continue + out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)" + if [ -n "${out_one}" ]; then + sc_out="${sc_out}${out_one}\n" + fi + done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort) + + if [ -n "${sc_out}" ]; then + extended_findings+=("ShellCheck warnings detected (advisory)") + sc_head="$(printf '%s' "${sc_out}" | head -n 200)" + { + printf '%s\n' '### ShellCheck (advisory)' + printf '%s\n' '```' + printf '%s\n' "${sc_head}" + printf '%s\n' '```' + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + # SPDX header advisory for common source types + spdx_missing=() + IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}" + spdx_args=() + for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done + + while IFS= read -r f; do + [ -z "${f}" ] && continue + if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then + spdx_missing+=("${f}") + fi + done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true) + + if [ "${#spdx_missing[@]}" -gt 0 ]; then + extended_findings+=("SPDX header missing in some tracked files (advisory)") + { + printf '%s\n' '### SPDX header advisory' + printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):' + for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + # Git hygiene advisory: branches older than 180 days (remote) + stale_cutoff_days=180 + stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 [...] + if [ -n "${stale_branches}" ]; then + extended_findings+=("Stale remote branches detected (advisory)") + { + printf '%s\n' '### Git hygiene advisory' + printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):" + while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}" + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + fi + + { + printf '%s\n' '### Guardrails coverage matrix' + printf '%s\n' '| Domain | Status | Notes |' + printf '%s\n' '|---|---|---|' + printf '%s\n' '| Access control | OK | Admin-only execution gate |' + printf '%s\n' '| Release configuration | OK | Variable presence and protocol gate |' + printf '%s\n' '| SFTP connectivity | OK | Connectivity plus remote path resolution |' + printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |' + printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |' + printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |' + if [ "${extended_enabled}" = 'true' ]; then + if [ "${#extended_findings[@]}" -gt 0 ]; then + printf '%s\n' '| Extended checks | Warning | See extended findings below |' + else + printf '%s\n' '| Extended checks | OK | No findings |' + fi + else + printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |' + fi + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + + if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then + { + printf '%s\n' '### Extended findings (advisory)' + for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done + printf '\n' + } >> "${GITHUB_STEP_SUMMARY}" + fi + + printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}" From 8f1153799e092f58b265e7eea1c105b533925e64 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 3 Jan 2026 13:06:46 -0600 Subject: [PATCH 3/5] Update CI workflow for repository standards Signed-off-by: Jonathan Miller --- .github/workflows/ci.yml | 868 +++------------------------------------ 1 file changed, 62 insertions(+), 806 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f6a198..b19ed5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,847 +1,103 @@ -# ============================================================================ -# Copyright (C) 2025 Moko Consulting +# Copyright (C) 2026 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 +# INGROUP: MokoStandards.CI # REPO: https://github.com/mokoconsulting-tech/MokoStandards -# PATH: /.github/workflows/repo_health.yml +# PATH: /.github/workflows/ci.yml # VERSION: 01.00.00 -# BRIEF: Enforces repository guardrails by validating release configuration, scripts governance, tooling availability, and core repository health artifacts. -# NOTE: Field is user-managed. -# ============================================================================ +# BRIEF: Continuous integration workflow enforcing repository standards. +# NOTE: -name: Repo Health - -concurrency: - group: repo-health-${{ github.repository }}-${{ github.ref }} - cancel-in-progress: true - -defaults: - run: - shell: bash +name: Continuous Integration 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. al[...] - required: true - default: all - type: choice - options: - - all - - release - - scripts - - repo - pull_request: - paths: - - .github/workflows/** - - scripts/** - - docs/** - - dev/** push: branches: - main - paths: - - .github/workflows/** - - scripts/** - - docs/** - dev/** + - rc/** + - version/** + pull_request: + branches: + - main + - dev/** + - rc/** + - version/** permissions: contents: read -env: - # Global policy variables baked into workflow - ALLOWED_SFTP_PROTOCOLS: sftp - - # Release policy - RELEASE_REQUIRED_VARS: FTP_HOST,FTP_USER,FTP_PATH - RELEASE_OPTIONAL_VARS: FTP_KEY,FTP_PASSWORD,FTP_PROTOCOL,FTP_PORT,FTP_PATH_SUFFIX - - # Scripts governance policy - # Note: directories listed without a trailing slash. - SCRIPTS_REQUIRED_DIRS: - SCRIPTS_ALLOWED_DIRS: scripts,scripts/fix,scripts/lib,scripts/release,scripts/run,scripts/validate - - # Repo health policy - # Files are listed as-is; directories must end with a trailing slash. - REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.github/workflows/,src/ - REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/,dev/ - REPO_DISALLOWED_DIRS: - REPO_DISALLOWED_FILES: TODO.md,todo.md - - # Extended checks toggles - EXTENDED_CHECKS: "true" - - # Operational toggles - SFTP_VERBOSE: "false" - - # File / directory variables (moved to top-level env) - DOCS_INDEX: docs/docs-index.md - SCRIPT_DIR: scripts - WORKFLOWS_DIR: .github/workflows - SHELLCHECK_PATTERN: '*.sh' - SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml' - jobs: - access_check: - name: Access control + ci: + name: Repository Validation Pipeline runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: read - outputs: - allowed: ${{ steps.perm.outputs.allowed }} - permission: ${{ steps.perm.outputs.permission }} + env: + CI: true + PROFILE: all steps: - - name: Check actor permission (admin only) - id: perm - uses: actions/github-script@v7 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const res = await github.rest.repos.getCollaboratorPermissionLevel({ - owner: context.repo.owner, - repo: context.repo.repo, - username: context.actor, - }); - - 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: ${context.actor}`); - 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 - printf '%s\n' 'ERROR: Access denied. Admin permission required.' >> "${GITHUB_STEP_SUMMARY}" - exit 1 - - release_config: - name: Release configuration - needs: access_check - if: ${{ needs.access_check.outputs.allowed == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: read - - steps: - - name: Checkout + - name: Checkout repository uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Guardrails release vars - 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_PATH: ${{ secrets.FTP_PATH }} - FTP_PROTOCOL: ${{ secrets.FTP_PROTOCOL }} - FTP_PORT: ${{ secrets.FTP_PORT }} - FTP_PATH_SUFFIX: ${{ vars.FTP_PATH_SUFFIX }} + - name: Normalize line endings run: | - set -euo pipefail + git config --global core.autocrlf false - profile="${PROFILE_RAW:-all}" - case "${profile}" in - all|release|scripts|repo) ;; - *) - printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" - exit 1 - ;; - esac - - if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then - { - printf '%s\n' '### Release configuration' - printf '%s\n' "Profile: ${profile}" - printf '%s\n' 'Status: SKIPPED' - printf '%s\n' 'Reason: profile excludes release validation' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - exit 0 - fi - - IFS=',' read -r -a required <<< "${RELEASE_REQUIRED_VARS}" - IFS=',' read -r -a optional <<< "${RELEASE_OPTIONAL_VARS}" - IFS=',' read -r -a allowed_proto <<< "${ALLOWED_SFTP_PROTOCOLS}" - - 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:-}" ]; then - ok=false - for ap in "${allowed_proto[@]}"; do - [ "${proto}" = "${ap}" ] && ok=true - done - [ "${ok}" = false ] && missing+=("FTP_PROTOCOL_INVALID") - fi - - target_path="${FTP_PATH:-}" - if [ -n "${FTP_PATH_SUFFIX:-}" ]; then - target_path="${target_path%/}/${FTP_PATH_SUFFIX#/}" - fi - - auth_method='none' - [ -n "${FTP_KEY:-}" ] && auth_method='key' - [ -z "${FTP_KEY:-}" ] && [ -n "${FTP_PASSWORD:-}" ] && auth_method='password' - - { - printf '%s\n' '### Release configuration' - printf '%s\n' "Profile: ${profile}" - printf '%s\n' '| Control | Value |' - printf '%s\n' '|---|---|' - printf '%s\n' "| Protocol | ${proto} |" - printf '%s\n' "| Port | ${FTP_PORT:-22} |" - printf '%s\n' "| Auth | ${auth_method} |" - printf '%s\n' "| Path (resolved) | ${target_path} |" - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - - if [ "${#missing_optional[@]}" -gt 0 ]; then - { - printf '%s\n' '### Missing optional release configuration' - for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - else - { - printf '%s\n' '### Optional release configuration' - printf '%s\n' 'None missing.' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - - if [ "${#missing[@]}" -gt 0 ]; then - { - printf '%s\n' '### Missing required release configuration' - for m in "${missing[@]}"; do printf '%s\n' "- ${m}"; done - printf '%s\n' 'ERROR: Guardrails failed. Missing required release configuration.' - } >> "${GITHUB_STEP_SUMMARY}" - exit 1 - fi - - { - printf '%s\n' '### Release configuration result' - printf '%s\n' 'Status: OK' - printf '%s\n' 'All required release variables present.' - printf '\n' - } >> "${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 }} - FTP_PATH: ${{ secrets.FTP_PATH }} - FTP_PATH_SUFFIX: ${{ vars.FTP_PATH_SUFFIX }} + - name: Verify script executability run: | - set -euo pipefail + chmod +x scripts/**/*.sh || true - profile="${PROFILE_RAW:-all}" - case "${profile}" in - all|release|scripts|repo) ;; - *) - printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" - exit 1 - ;; - esac - - if [ "${profile}" = 'scripts' ] || [ "${profile}" = 'repo' ]; then - { - printf '%s\n' '### SFTP connectivity' - printf '%s\n' "Profile: ${profile}" - printf '%s\n' 'Status: SKIPPED' - printf '%s\n' 'Reason: profile excludes release validation' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - exit 0 - fi - - port="${FTP_PORT:-22}" - - target_path="${FTP_PATH}" - if [ -n "${FTP_PATH_SUFFIX:-}" ]; then - target_path="${target_path%/}/${FTP_PATH_SUFFIX#/}" - fi - - sftp_verbose="${SFTP_VERBOSE:-false}" - sftp_v_opt=() - [ "${sftp_verbose}" = 'true' ] && sftp_v_opt=(-vv) - - auth_method='none' - if [ -n "${FTP_KEY:-}" ]; then - auth_method='key' - elif [ -n "${FTP_PASSWORD:-}" ]; then - auth_method='password' - fi - - { - printf '%s\n' '### SFTP connectivity' - printf '%s\n' '| Control | Value |' - printf '%s\n' '|---|---|' - printf '%s\n' "| Host | ${FTP_HOST} |" - printf '%s\n' "| User | ${FTP_USER} |" - printf '%s\n' "| Port | ${port} |" - printf '%s\n' "| Auth | ${auth_method} |" - printf '%s\n' "| Path (resolved) | ${target_path} |" - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - - sftp_cmds="$(printf 'cd %s\npwd\nbye\n' "${target_path}")" - - set +e - if [ -n "${FTP_KEY:-}" ]; then - mkdir -p "$HOME/.ssh" - key_file="$HOME/.ssh/ci_sftp_key" - printf '%s\n' "${FTP_KEY}" > "${key_file}" - chmod 600 "${key_file}" - - if [ -n "${FTP_PASSWORD:-}" ]; then - first_line="$(head -n 1 "${key_file}" || true)" - if printf '%s\n' "${first_line}" | grep -q '^PuTTY-User-Key-File-'; then - { - printf '%s\n' '### SFTP connectivity result' - printf '%s\n' 'Status: FAILED' - printf '%s\n' 'Reason: FTP_KEY appears to be a PuTTY PPK. Provide an OpenSSH private key.' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - exit 1 - fi - ssh-keygen -p -P "${FTP_PASSWORD}" -N '' -f "${key_file}" >/dev/null - fi - - printf '%s' "${sftp_cmds}" | sftp "${sftp_v_opt[@]}" -oBatchMode=yes -oStrictHostKeyChecking=no -P "${port}" -i "${key_file}" "${FTP_USER}@${FTP_HOST}" >/tmp/sftp_check.log 2>&1 - sftp_rc=$? - elif [ -n "${FTP_PASSWORD:-}" ]; then - command -v sshpass >/dev/null 2>&1 || (sudo apt-get update -qq && sudo apt-get install -y sshpass >/dev/null) - printf '%s' "${sftp_cmds}" | sshpass -p "${FTP_PASSWORD}" sftp "${sftp_v_opt[@]}" -oBatchMode=no -oStrictHostKeyChecking=no -P "${port}" "${FTP_USER}@${FTP_HOST}" >/tmp/sftp_check.log 2>&1 - sftp_rc=$? - else - { - printf '%s\n' '### SFTP connectivity result' - printf '%s\n' 'Status: FAILED' - printf '%s\n' 'Reason: No FTP_KEY or FTP_PASSWORD provided for SFTP authentication.' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - exit 1 - fi + - name: Required validations + run: | set -e - { - printf '%s\n' '### SFTP connectivity result' - if [ "${sftp_rc}" -eq 0 ]; then - printf '%s\n' 'Status: SUCCESS' - printf '%s\n' 'Validated host connectivity and remote path access.' - else - printf '%s\n' "Status: FAILED (exit code ${sftp_rc})" - printf '\n' - printf '%s\n' 'Last SFTP output' - tail -n 60 /tmp/sftp_check.log || true - fi - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" + scripts/validate/manifest.sh + scripts/validate/xml_wellformed.sh - [ "${sftp_rc}" -eq 0 ] || exit 1 - - scripts_governance: - name: Scripts governance - needs: access_check - if: ${{ needs.access_check.outputs.allowed == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: read - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Scripts folder checks - env: - PROFILE_RAW: ${{ github.event.inputs.profile }} + - name: Optional validations run: | - set -euo pipefail + set +e - profile="${PROFILE_RAW:-all}" - case "${profile}" in - all|release|scripts|repo) ;; - *) - printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" - exit 1 - ;; - esac + scripts/validate/changelog.sh + scripts/validate/language_structure.sh + scripts/validate/license_headers.sh + scripts/validate/no_secrets.sh + scripts/validate/paths.sh + scripts/validate/php_syntax.sh + scripts/validate/tabs.sh + scripts/validate/version_alignment.sh - if [ "${profile}" = 'release' ] || [ "${profile}" = 'repo' ]; then - { - printf '%s\n' '### Scripts governance' - printf '%s\n' "Profile: ${profile}" - printf '%s\n' 'Status: SKIPPED' - printf '%s\n' 'Reason: profile excludes scripts governance' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - exit 0 - fi - - if [ ! -d "${SCRIPT_DIR}" ]; then - { - printf '%s\n' '### Scripts governance' - printf '%s\n' 'Status: OK (advisory)' - printf '%s\n' 'scripts/ directory not present. No scripts governance enforced.' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - exit 0 - fi - - IFS=',' read -r -a required_dirs <<< "${SCRIPTS_REQUIRED_DIRS}" - IFS=',' read -r -a allowed_dirs <<< "${SCRIPTS_ALLOWED_DIRS}" - - missing_dirs=() - unapproved_dirs=() - - for d in "${required_dirs[@]}"; do - req="${d%/}" - [ ! -d "${req}" ] && missing_dirs+=("${req}/") - done - - while IFS= read -r d; do - allowed=false - for a in "${allowed_dirs[@]}"; do - a_norm="${a%/}" - [ "${d%/}" = "${a_norm}" ] && allowed=true - done - [ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/") - done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##') - - { - printf '%s\n' '### Scripts governance' - printf '%s\n' "Profile: ${profile}" - printf '%s\n' '| Area | Status | Notes |' - printf '%s\n' '|---|---|---|' - - if [ "${#missing_dirs[@]}" -gt 0 ]; then - printf '%s\n' '| Required directories | Warning | Missing required subfolders |' - else - printf '%s\n' '| Required directories | OK | All required subfolders present |' - fi - - if [ "${#unapproved_dirs[@]}" -gt 0 ]; then - printf '%s\n' '| Directory policy | Warning | Unapproved directories detected |' - else - printf '%s\n' '| Directory policy | OK | No unapproved directories |' - fi - - printf '%s\n' '| Enforcement mode | Advisory | scripts folder is optional |' - printf '\n' - - if [ "${#missing_dirs[@]}" -gt 0 ]; then - printf '%s\n' 'Missing required script directories:' - for m in "${missing_dirs[@]}"; do printf '%s\n' "- ${m}"; done - printf '\n' - else - printf '%s\n' 'Missing required script directories: none.' - printf '\n' - fi - - if [ "${#unapproved_dirs[@]}" -gt 0 ]; then - printf '%s\n' 'Unapproved script directories detected:' - for m in "${unapproved_dirs[@]}"; do printf '%s\n' "- ${m}"; done - printf '\n' - else - printf '%s\n' 'Unapproved script directories detected: none.' - printf '\n' - fi - - printf '%s\n' 'Scripts governance completed in advisory mode.' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - - repo_health: - name: Repository health - needs: access_check - if: ${{ needs.access_check.outputs.allowed == 'true' }} - runs-on: ubuntu-latest - timeout-minutes: 20 - permissions: - contents: read - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Repository health checks - env: - PROFILE_RAW: ${{ github.event.inputs.profile }} + - name: CI summary + if: always() run: | - set -euo pipefail - - profile="${PROFILE_RAW:-all}" - case "${profile}" in - all|release|scripts|repo) ;; - *) - printf '%s\n' "ERROR: Unknown profile: ${profile}" >> "${GITHUB_STEP_SUMMARY}" - exit 1 - ;; - esac - - if [ "${profile}" = 'release' ] || [ "${profile}" = 'scripts' ]; then - { - printf '%s\n' '### Repository health' - printf '%s\n' "Profile: ${profile}" - printf '%s\n' 'Status: SKIPPED' - printf '%s\n' 'Reason: profile excludes repository health' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - exit 0 - fi - - IFS=',' read -r -a required_artifacts <<< "${REPO_REQUIRED_ARTIFACTS}" - IFS=',' read -r -a optional_files <<< "${REPO_OPTIONAL_FILES}" - IFS=',' read -r -a disallowed_dirs <<< "${REPO_DISALLOWED_DIRS}" - IFS=',' read -r -a disallowed_files <<< "${REPO_DISALLOWED_FILES}" - - missing_required=() - missing_optional=() - - for item in "${required_artifacts[@]}"; do - if printf '%s' "${item}" | grep -q '/$'; then - d="${item%/}" - [ ! -d "${d}" ] && missing_required+=("${item}") - else - [ ! -f "${item}" ] && missing_required+=("${item}") - fi - done - - for f in "${optional_files[@]}"; do - [ ! -f "${f}" ] && missing_optional+=("${f}") - done - - for d in "${disallowed_dirs[@]}"; do - d_norm="${d%/}" - [ -d "${d_norm}" ] && missing_required+=("${d_norm}/ (disallowed)") - done - - for f in "${disallowed_files[@]}"; do - [ -f "${f}" ] && missing_required+=("${f} (disallowed)") - done - - 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 'CHANGELOG.md' ] && grep -Eq '^[# ]*Unreleased' CHANGELOG.md; then - content_warnings+=("CHANGELOG.md contains Unreleased section (review release readiness)") - 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 PROFILE_RAW="${profile}" - 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' - - 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, - '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 '%s\n' '### Repository health' - printf '%s\n' "Profile: ${profile}" - printf '%s\n' '| Metric | Value |' - printf '%s\n' '|---|---|' - printf '%s\n' "| Missing required | ${#missing_required[@]} |" - printf '%s\n' "| Missing optional | ${#missing_optional[@]} |" - printf '%s\n' "| Content warnings | ${#content_warnings[@]} |" - printf '\n' - - printf '%s\n' '### Guardrails report (JSON)' - printf '%s\n' '```json' - printf '%s\n' "${report_json}" - printf '%s\n' '```' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - - if [ "${#missing_required[@]}" -gt 0 ]; then - { - printf '%s\n' '### Missing required repo artifacts' - for m in "${missing_required[@]}"; do printf '%s\n' "- ${m}"; done - printf '%s\n' 'ERROR: Guardrails failed. Missing required repository artifacts.' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - exit 1 - fi - - if [ "${#missing_optional[@]}" -gt 0 ]; then - { - printf '%s\n' '### Missing optional repo artifacts' - for m in "${missing_optional[@]}"; do printf '%s\n' "- ${m}"; done - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - - if [ "${#content_warnings[@]}" -gt 0 ]; then - { - printf '%s\n' '### Repo content warnings' - for m in "${content_warnings[@]}"; do printf '%s\n' "- ${m}"; done - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - - extended_enabled="${EXTENDED_CHECKS:-true}" - extended_findings=() - - if [ "${extended_enabled}" = 'true' ]; then - # CODEOWNERS presence - if [ -f '.github/CODEOWNERS' ] || [ -f 'CODEOWNERS' ] || [ -f 'docs/CODEOWNERS' ]; then - : - else - extended_findings+=("CODEOWNERS not found (.github/CODEOWNERS preferred)") - fi - - # Workflow pinning advisory: flag uses @main/@master - if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then - bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)" - if [ -n "${bad_refs}" ]; then - extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt") - { - printf '%s\n' '### Workflow pinning advisory' - printf '%s\n' 'Found uses: entries pinned to main/master:' - printf '%s\n' '```' - printf '%s\n' "${bad_refs}" - printf '%s\n' '```' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - fi - - # Docs index link integrity (docs/docs-index.md) - if [ -f "${DOCS_INDEX}" ]; then - missing_links="$(python3 - <<'PY' - import os - import re - - idx = os.environ.get('DOCS_INDEX', 'docs/docs-index.md') - base = os.getcwd() - - bad = [] - pat = re.compile(r'\[[^\]]+\]\(([^)]+)\)') - - with open(idx, 'r', encoding='utf-8') as f: - for line in f: - for m in pat.findall(line): - link = m.strip() - if link.startswith('http://') or link.startswith('https://') or link.startswith('#') or link.startswith('mailto:'): - continue - if link.startswith('/'): - rel = link.lstrip('/') - else: - rel = os.path.normpath(os.path.join(os.path.dirname(idx), link)) - rel = rel.split('#', 1)[0] - rel = rel.split('?', 1)[0] - if not rel: - continue - p = os.path.join(base, rel) - if not os.path.exists(p): - bad.append(rel) - - print('\n'.join(sorted(set(bad)))) - PY - )" - if [ -n "${missing_links}" ]; then - extended_findings+=("docs/docs-index.md contains broken relative links") - { - printf '%s\n' '### Docs index link integrity' - printf '%s\n' 'Broken relative links:' - while IFS= read -r l; do [ -n "${l}" ] && printf '%s\n' "- ${l}"; done <<< "${missing_links}" - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - fi - - # ShellCheck advisory - if [ -d "${SCRIPT_DIR}" ]; then - if ! command -v shellcheck >/dev/null 2>&1; then - sudo apt-get update -qq - sudo apt-get install -y shellcheck >/dev/null - fi - - sc_out='' - while IFS= read -r shf; do - [ -z "${shf}" ] && continue - out_one="$(shellcheck -S warning -x "${shf}" 2>/dev/null || true)" - if [ -n "${out_one}" ]; then - sc_out="${sc_out}${out_one}\n" - fi - done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort) - - if [ -n "${sc_out}" ]; then - extended_findings+=("ShellCheck warnings detected (advisory)") - sc_head="$(printf '%s' "${sc_out}" | head -n 200)" - { - printf '%s\n' '### ShellCheck (advisory)' - printf '%s\n' '```' - printf '%s\n' "${sc_head}" - printf '%s\n' '```' - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - fi - - # SPDX header advisory for common source types - spdx_missing=() - IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}" - spdx_args=() - for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done - - while IFS= read -r f; do - [ -z "${f}" ] && continue - if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then - spdx_missing+=("${f}") - fi - done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true) - - if [ "${#spdx_missing[@]}" -gt 0 ]; then - extended_findings+=("SPDX header missing in some tracked files (advisory)") - { - printf '%s\n' '### SPDX header advisory' - printf '%s\n' 'Files missing SPDX-License-Identifier (first 40 lines scan):' - for f in "${spdx_missing[@]}"; do printf '%s\n' "- ${f}"; done - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - - # Git hygiene advisory: branches older than 180 days (remote) - stale_cutoff_days=180 - stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 [...] - if [ -n "${stale_branches}" ]; then - extended_findings+=("Stale remote branches detected (advisory)") - { - printf '%s\n' '### Git hygiene advisory' - printf '%s\n' "Branches with last commit older than ${stale_cutoff_days} days (sample up to 50):" - while IFS= read -r b; do [ -n "${b}" ] && printf '%s\n' "- ${b}"; done <<< "${stale_branches}" - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - fi - - { - printf '%s\n' '### Guardrails coverage matrix' - printf '%s\n' '| Domain | Status | Notes |' - printf '%s\n' '|---|---|---|' - printf '%s\n' '| Access control | OK | Admin-only execution gate |' - printf '%s\n' '| Release configuration | OK | Variable presence and protocol gate |' - printf '%s\n' '| SFTP connectivity | OK | Connectivity plus remote path resolution |' - printf '%s\n' '| Scripts governance | OK | Directory policy and advisory reporting |' - printf '%s\n' '| Repo required artifacts | OK | Required, optional, disallowed enforcement |' - printf '%s\n' '| Repo content heuristics | OK | Brand, license, changelog structure |' - if [ "${extended_enabled}" = 'true' ]; then - if [ "${#extended_findings[@]}" -gt 0 ]; then - printf '%s\n' '| Extended checks | Warning | See extended findings below |' - else - printf '%s\n' '| Extended checks | OK | No findings |' - fi - else - printf '%s\n' '| Extended checks | SKIPPED | EXTENDED_CHECKS disabled |' - fi - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - - if [ "${extended_enabled}" = 'true' ] && [ "${#extended_findings[@]}" -gt 0 ]; then - { - printf '%s\n' '### Extended findings (advisory)' - for f in "${extended_findings[@]}"; do printf '%s\n' "- ${f}"; done - printf '\n' - } >> "${GITHUB_STEP_SUMMARY}" - fi - - printf '%s\n' 'Repository health guardrails passed.' >> "${GITHUB_STEP_SUMMARY}" + echo "### CI Execution Summary" + echo "" + echo "- Repository: $GITHUB_REPOSITORY" + echo "- Branch: $GITHUB_REF_NAME" + echo "- Commit: $GITHUB_SHA" + echo "- Runner: ubuntu-latest" + echo "" + echo "CI completed. Review logs above for validation outcomes." + } >> "$GITHUB_STEP_SUMMARY" From f008cdef027b694002b95d8e744027a2cceab877 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 3 Jan 2026 13:07:04 -0600 Subject: [PATCH 4/5] Refactor repo_health.yml for improved clarity Updated variable references and descriptions in the workflow file. Signed-off-by: Jonathan Miller --- .github/workflows/repo_health.yml | 33 ++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/.github/workflows/repo_health.yml b/.github/workflows/repo_health.yml index f920ce0..4f6a198 100644 --- a/.github/workflows/repo_health.yml +++ b/.github/workflows/repo_health.yml @@ -29,7 +29,7 @@ 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. + description: Which configuration profile to validate. release checks SFTP variables used by release pipeline. scripts checks baseline script prerequisites. repo runs repository health only. al[...] required: true default: all type: choice @@ -82,6 +82,13 @@ env: # Operational toggles SFTP_VERBOSE: "false" + # File / directory variables (moved to top-level env) + DOCS_INDEX: docs/docs-index.md + SCRIPT_DIR: scripts + WORKFLOWS_DIR: .github/workflows + SHELLCHECK_PATTERN: '*.sh' + SPDX_FILE_GLOBS: '*.sh,*.php,*.js,*.ts,*.css,*.xml,*.yml,*.yaml' + jobs: access_check: name: Access control @@ -417,7 +424,7 @@ jobs: exit 0 fi - if [ ! -d scripts ]; then + if [ ! -d "${SCRIPT_DIR}" ]; then { printf '%s\n' '### Scripts governance' printf '%s\n' 'Status: OK (advisory)' @@ -445,7 +452,7 @@ jobs: [ "${d%/}" = "${a_norm}" ] && allowed=true done [ "${allowed}" = false ] && unapproved_dirs+=("${d%/}/") - done < <(find scripts -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##') + done < <(find "${SCRIPT_DIR}" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sed 's#^\./##') { printf '%s\n' '### Scripts governance' @@ -682,8 +689,8 @@ jobs: fi # Workflow pinning advisory: flag uses @main/@master - if ls .github/workflows/*.yml >/dev/null 2>&1; then - bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' .github/workflows 2>/dev/null || true)" + if ls "${WORKFLOWS_DIR}"/*.yml >/dev/null 2>&1 || ls "${WORKFLOWS_DIR}"/*.yaml >/dev/null 2>&1; then + bad_refs="$(grep -RIn --include='*.yml' --include='*.yaml' -E '^[[:space:]]*uses:[[:space:]]*[^#]+@(main|master)\b' "${WORKFLOWS_DIR}" 2>/dev/null || true)" if [ -n "${bad_refs}" ]; then extended_findings+=("Workflows reference actions @main/@master (pin versions): see log excerpt") { @@ -698,12 +705,12 @@ jobs: fi # Docs index link integrity (docs/docs-index.md) - if [ -f 'docs/docs-index.md' ]; then + if [ -f "${DOCS_INDEX}" ]; then missing_links="$(python3 - <<'PY' import os import re - idx = 'docs/docs-index.md' + idx = os.environ.get('DOCS_INDEX', 'docs/docs-index.md') base = os.getcwd() bad = [] @@ -742,7 +749,7 @@ jobs: fi # ShellCheck advisory - if [ -d 'scripts' ]; then + if [ -d "${SCRIPT_DIR}" ]; then if ! command -v shellcheck >/dev/null 2>&1; then sudo apt-get update -qq sudo apt-get install -y shellcheck >/dev/null @@ -755,7 +762,7 @@ jobs: if [ -n "${out_one}" ]; then sc_out="${sc_out}${out_one}\n" fi - done < <(find scripts -type f -name '*.sh' 2>/dev/null | sort) + done < <(find "${SCRIPT_DIR}" -type f -name "${SHELLCHECK_PATTERN}" 2>/dev/null | sort) if [ -n "${sc_out}" ]; then extended_findings+=("ShellCheck warnings detected (advisory)") @@ -772,12 +779,16 @@ jobs: # SPDX header advisory for common source types spdx_missing=() + IFS=',' read -r -a spdx_globs <<< "${SPDX_FILE_GLOBS}" + spdx_args=() + for g in "${spdx_globs[@]}"; do spdx_args+=("${g}"); done + while IFS= read -r f; do [ -z "${f}" ] && continue if ! head -n 40 "${f}" | grep -q 'SPDX-License-Identifier:'; then spdx_missing+=("${f}") fi - done < <(git ls-files '*.sh' '*.php' '*.js' '*.ts' '*.css' '*.xml' '*.yml' '*.yaml' 2>/dev/null || true) + done < <(git ls-files "${spdx_args[@]}" 2>/dev/null || true) if [ "${#spdx_missing[@]}" -gt 0 ]; then extended_findings+=("SPDX header missing in some tracked files (advisory)") @@ -791,7 +802,7 @@ jobs: # Git hygiene advisory: branches older than 180 days (remote) stale_cutoff_days=180 - stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 > days*86400) print $1}' | sed 's#^origin/##' | grep -v '^HEAD$' | head -n 50 || true)" + stale_branches="$(git for-each-ref --format='%(refname:short) %(committerdate:unix)' refs/remotes/origin 2>/dev/null | awk -v now="$(date +%s)" -v days="${stale_cutoff_days}" '{if (now-$2 [...] if [ -n "${stale_branches}" ]; then extended_findings+=("Stale remote branches detected (advisory)") { From 0f9131b27327e79ed0ba0ffb018540ae0694c23f Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 3 Jan 2026 13:16:03 -0600 Subject: [PATCH 5/5] Update optional files list in repo_health.yml Signed-off-by: Jonathan Miller --- .github/workflows/repo_health.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/repo_health.yml b/.github/workflows/repo_health.yml index 4f6a198..23f43d5 100644 --- a/.github/workflows/repo_health.yml +++ b/.github/workflows/repo_health.yml @@ -72,7 +72,7 @@ env: # Repo health policy # Files are listed as-is; directories must end with a trailing slash. REPO_REQUIRED_ARTIFACTS: README.md,LICENSE,CHANGELOG.md,CONTRIBUTING.md,CODE_OF_CONDUCT.md,.github/workflows/,src/ - REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/,dev/ + REPO_OPTIONAL_FILES: SECURITY.md,GOVERNANCE.md,.editorconfig,.gitattributes,.gitignore,README.md,docs/ REPO_DISALLOWED_DIRS: REPO_DISALLOWED_FILES: TODO.md,todo.md @@ -555,8 +555,14 @@ jobs: fi done + # Optional entries: handle files and directories (trailing slash indicates dir) for f in "${optional_files[@]}"; do - [ ! -f "${f}" ] && missing_optional+=("${f}") + if printf '%s' "${f}" | grep -q '/$'; then + d="${f%/}" + [ ! -d "${d}" ] && missing_optional+=("${f}") + else + [ ! -f "${f}" ] && missing_optional+=("${f}") + fi done for d in "${disallowed_dirs[@]}"; do @@ -573,6 +579,8 @@ jobs: dev_paths=() dev_branches=() + # Look for remote branches matching origin/dev*. + # A plain origin/dev is considered invalid; we require dev/ branches. while IFS= read -r b; do name="${b#origin/}" if [ "${name}" = 'dev' ]; then @@ -582,10 +590,12 @@ jobs: fi done < <(git branch -r --list 'origin/dev*' | sed 's/^ *//') + # If there are no dev/* branches, fail the guardrail. if [ "${#dev_paths[@]}" -eq 0 ]; then missing_required+=("dev/* branch (e.g. dev/01.00.00)") fi + # If a plain dev branch exists (origin/dev), flag it as invalid. if [ "${#dev_branches[@]}" -gt 0 ]; then missing_required+=("invalid branch dev (must be dev/)") fi