diff --git a/.github/workflows/version_branch.yml b/.github/workflows/version_branch.yml index 9fdaa16..da956bf 100644 --- a/.github/workflows/version_branch.yml +++ b/.github/workflows/version_branch.yml @@ -21,8 +21,8 @@ # REPO: https://github.com/mokoconsulting-tech/MokoStandards # PATH: /.github/workflows/version_branch.yml # VERSION: 01.00.00 -# BRIEF: Create a version branch and align versions across governed files -# NOTE: Enterprise gates: policy checks, collision defense, manifest targeting, audit summary, error summary +# BRIEF: Create a dev/ branch and align versions across governed files +# NOTE: Enterprise gates: policy checks, namespace defense, scoped edits, audit summary, deterministic report output name: Create version branch and bump versions @@ -32,14 +32,6 @@ on: new_version: description: "New version in format NN.NN.NN (example 03.01.00)" required: true - commit_changes: - description: "Commit and push changes" - required: false - default: "true" - type: choice - options: - - "true" - - "false" report_only: description: "Report only mode (no branch creation, no file writes, report output only)" required: false @@ -48,6 +40,14 @@ on: options: - "true" - "false" + commit_changes: + description: "Commit and push changes (forced to true when report_only=false)" + required: false + default: "true" + type: choice + options: + - "true" + - "false" concurrency: group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.inputs.new_version }} @@ -62,16 +62,17 @@ defaults: jobs: version-bump: + name: Version branch and bump runs-on: ubuntu-latest env: NEW_VERSION: ${{ github.event.inputs.new_version }} + REPORT_ONLY: ${{ github.event.inputs.report_only }} + COMMIT_CHANGES: ${{ github.event.inputs.commit_changes }} BASE_BRANCH: ${{ github.ref_name }} BRANCH_PREFIX: dev/ - COMMIT_CHANGES: ${{ github.event.inputs.commit_changes }} ERROR_LOG: /tmp/version_branch_errors.log CI_HELPERS: /tmp/moko_ci_helpers.sh - REPORT_ONLY: ${{ github.event.inputs.report_only }} steps: - name: Checkout repository @@ -83,7 +84,6 @@ jobs: - name: Init CI helpers run: | set -Eeuo pipefail - : > "$ERROR_LOG" cat > "$CI_HELPERS" <<'SH' @@ -91,7 +91,6 @@ jobs: moko_init() { local step_name="${1:-step}" - export PS4='+ ['"${step_name}"':${BASH_SOURCE##*/}:${LINENO}] ' set -x trap "moko_on_err '${step_name}' \"\$LINENO\" \"\$BASH_COMMAND\"" ERR @@ -110,7 +109,6 @@ jobs: fi } - moko_bool() { local v="${1:-false}" [[ "${v}" == "true" ]] @@ -119,17 +117,17 @@ jobs: chmod 0755 "$CI_HELPERS" - - name: Validate inputs + - name: Validate inputs and policy locks run: | source "$CI_HELPERS" - moko_init "Validate inputs" + moko_init "Validate inputs and policy locks" echo "[INFO] Inputs received:" echo " NEW_VERSION=${NEW_VERSION}" + echo " REPORT_ONLY=${REPORT_ONLY}" + echo " COMMIT_CHANGES=${COMMIT_CHANGES}" echo " BASE_BRANCH=${BASE_BRANCH}" echo " BRANCH_PREFIX=${BRANCH_PREFIX}" - echo " COMMIT_CHANGES=${COMMIT_CHANGES}" - echo " REPORT_ONLY=${REPORT_ONLY}" [[ -n "${NEW_VERSION}" ]] || { echo "[ERROR] new_version missing" >&2; exit 2; } [[ "${NEW_VERSION}" =~ ^[0-9]{2}[.][0-9]{2}[.][0-9]{2}$ ]] || { echo "[ERROR] Invalid version format: ${NEW_VERSION}" >&2; exit 2; } @@ -139,9 +137,8 @@ jobs: exit 2 fi - if ! moko_bool "${REPORT_ONLY}" && [[ "${COMMIT_CHANGES}" != "true" ]]; then - echo "[FATAL] commit_changes must be 'true' when report_only is 'false' to ensure version branch is auditable and consistent." >&2 + echo "[FATAL] commit_changes must be 'true' when report_only is 'false' to ensure the branch is auditable." >&2 exit 2 fi @@ -152,9 +149,7 @@ jobs: exit 2 } - echo "[INFO] Input validation passed" - - - name: Enterprise policy gate (required files) + - name: Enterprise policy gate run: | source "$CI_HELPERS" moko_init "Enterprise policy gate" @@ -187,21 +182,15 @@ jobs: exit 2 fi - if [[ -f ".github/CODEOWNERS" ]] && [[ ! -s ".github/CODEOWNERS" ]]; then - echo "[ERROR] .github/CODEOWNERS exists but is empty" >&2 - exit 2 - fi - echo "[INFO] Policy gate passed" - name: Configure git identity + if: ${{ env.REPORT_ONLY != 'true' }} run: | source "$CI_HELPERS" moko_init "Configure git identity" - git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - echo "[INFO] Git identity configured" - name: Branch namespace collision defense run: | @@ -210,16 +199,10 @@ jobs: PREFIX_TOP="${BRANCH_PREFIX%%/*}" if git ls-remote --exit-code --heads origin "${PREFIX_TOP}" >/dev/null 2>&1; then - echo "[ERROR] Branch namespace collision detected" >&2 - echo "[ERROR] A branch named '${PREFIX_TOP}' exists on origin, so '${BRANCH_PREFIX}' cannot be created." >&2 - echo "[ERROR] Remediation options:" >&2 - echo " - Change BRANCH_PREFIX to a non colliding namespace (example: release/dev/)" >&2 - echo " - Rename the existing '${PREFIX_TOP}' branch (organizational policy permitting)" >&2 + echo "[FATAL] Branch namespace collision detected: '${PREFIX_TOP}' exists on origin." >&2 exit 2 fi - echo "[INFO] No namespace collision detected for BRANCH_PREFIX=${BRANCH_PREFIX}" - - name: Create version branch (local) if: ${{ env.REPORT_ONLY != 'true' }} run: | @@ -232,152 +215,51 @@ jobs: git fetch --all --tags --prune if git ls-remote --exit-code --heads origin "${BRANCH_NAME}" >/dev/null 2>&1; then - echo "[ERROR] Branch already exists on origin: ${BRANCH_NAME}" >&2 + echo "[FATAL] Branch already exists on origin: ${BRANCH_NAME}" >&2 exit 2 fi git checkout -B "${BRANCH_NAME}" "origin/${BASE_BRANCH}" echo "BRANCH_NAME=${BRANCH_NAME}" >> "$GITHUB_ENV" - echo "[INFO] Local branch created. Push will occur after governed changes are committed." - - - name: Ensure CHANGELOG.md rolls UNRELEASED into the release (no TODO) + - name: Enforce release generated update feeds are absent (update.xml, updates.xml) if: ${{ env.REPORT_ONLY != 'true' }} run: | source "$CI_HELPERS" - moko_init "CHANGELOG governance" + moko_init "Enforce update feed deletion" - python3 - <<'PY' - import os - import re - from datetime import datetime, timezone - from pathlib import Path + git rm -f --ignore-unmatch update.xml updates.xml || true + rm -f update.xml updates.xml || true - nl = chr(10) - cr = chr(13) + if [[ -f update.xml || -f updates.xml ]]; then + echo "[FATAL] update feed files still present after deletion attempt." >&2 + ls -la update.xml updates.xml 2>/dev/null || true + exit 2 + fi - new_version = (os.environ.get('NEW_VERSION') or '').strip() or '00.00.00' + if git ls-files --error-unmatch update.xml >/dev/null 2>&1; then + echo "[FATAL] update.xml is still tracked after deletion." >&2 + exit 2 + fi - p = Path('CHANGELOG.md') - if not p.exists(): - raise SystemExit('[FATAL] CHANGELOG.md missing') - - lines = p.read_text(encoding='utf-8', errors='replace').splitlines(True) - - # Accept repo H1 variants, including: - # # Changelog - # # Changelog - Project (VERSION: 03.05.00) - # # Changelog — Project (VERSION: 03.05.00) - h1_re = re.compile(r'^#\s+Changelog\b.*$', re.IGNORECASE) - - bullet_re = re.compile(r'^[ ]*[-*+][ ]+') - blank_re = re.compile(r'^[ ]*$') - unreleased_re = re.compile(r'^[ ]*##[ ]*(?:\[[ ]*UNRELEASED[ ]*\]|UNRELEASED)[ ]*$', re.IGNORECASE) - - stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d') - version_h2 = '## [' + new_version + '] ' + stamp + nl - version_prefix = '## [' + new_version + '] ' - - # No duplicates - if any(l.strip().startswith(version_prefix) for l in lines): - print('[INFO] Version H2 already present. No action taken.') - raise SystemExit(0) - - # Locate H1 - h1_idx = None - for i, line in enumerate(lines): - if h1_re.match(line.strip()): - h1_idx = i - break - - if h1_idx is None: - print('[ERROR] CHANGELOG.md missing required H1 beginning with: # Changelog') - raise SystemExit(2) - - # Insertion point is immediately after H1 and any following blank lines - insert_at = h1_idx + 1 - while insert_at < len(lines) and blank_re.match(lines[insert_at].rstrip(nl).rstrip(cr)): - insert_at += 1 - - # Locate UNRELEASED - unreleased_idx = None - for i, line in enumerate(lines): - if unreleased_re.match(line.strip()): - unreleased_idx = i - break - - if unreleased_idx is not None: - # Convert UNRELEASED into this version - lines[unreleased_idx] = version_h2 - - k = unreleased_idx + 1 - moved = [] - while k < len(lines): - if lines[k].lstrip().startswith('## '): - break - moved.append(lines[k]) - k += 1 - - # Normalize empty or placeholder content into a controlled bullet - if not any(bullet_re.match(x.rstrip(nl).rstrip(cr)) for x in moved): - moved = ['- Version bump' + nl] - - # Ensure VERSION line exists at top of moved block - if not any(x.lstrip().startswith('- VERSION:') for x in moved): - moved.insert(0, '- Version bump' + nl) - - lines[unreleased_idx + 1:k] = moved - - # Reinsert a fresh UNRELEASED block after H1 insertion point - insert_unreleased = nl + '## [UNRELEASED]' + nl + '- ' + nl + nl - lines.insert(insert_at, insert_unreleased) - - else: - # No UNRELEASED block: insert a new release section after H1 - insert = ( - nl + - '## [' + new_version + '] ' + stamp + nl + - '- Version bump' + nl - ) - lines.insert(insert_at, insert) - - # Update displayed VERSION in: - # - FILE INFORMATION block line: VERSION: NN.NN.NN - # - H1 title line: (VERSION: NN.NN.NN) - text = ''.join(lines) - - text = re.sub( - r'(?im)^(\s*VERSION\s*:\s*)\d{2}\.\d{2}\.\d{2}(\s*)$', - r'\g<1>' + new_version + r'\2', - text, - count=1, - ) - - text = re.sub( - r'(?im)^(#\s+Changelog\b.*\(VERSION:\s*)(\d{2}\.\d{2}\.\d{2})(\)\s*)$', - r'\g<1>' + new_version + r'\g<3>', - text, - count=1, - ) - - p.write_text(text, encoding='utf-8') - PY + if git ls-files --error-unmatch updates.xml >/dev/null 2>&1; then + echo "[FATAL] updates.xml is still tracked after deletion." >&2 + exit 2 + fi - name: Preflight discovery (governed version markers outside .github) run: | source "$CI_HELPERS" moko_init "Preflight discovery" - echo "[INFO] Scanning all directories except .github" - COUNT=$(grep -RIn --exclude-dir=.git --exclude-dir=.github -i -E "VERSION[[:space:]]*:[[:space:]]*[0-9]{2}[.][0-9]{2}[.][0-9]{2}" . | wc -l || true) - echo "[INFO] VERSION: hits (repo-wide): ${COUNT}" - COUNT2=$(grep -RIn --exclude-dir=.git --exclude-dir=.github " hits (repo-wide): ${COUNT2}" + + echo "[INFO] VERSION: hits (repo wide): ${COUNT}" + echo "[INFO] hits (repo wide): ${COUNT2}" if [[ "${COUNT}" -eq 0 && "${COUNT2}" -eq 0 ]]; then - echo "[ERROR] No VERSION: (NN.NN.NN) or tags found outside .github" >&2 + echo "[FATAL] No governed version markers found outside .github" >&2 exit 2 fi @@ -394,14 +276,172 @@ jobs: from collections import defaultdict from datetime import datetime, timezone - new_version = (os.environ.get('NEW_VERSION') or '').strip() + new_version = (os.environ.get("NEW_VERSION") or "").strip() if not new_version: - raise SystemExit('[FATAL] NEW_VERSION env var missing') + raise SystemExit("[FATAL] NEW_VERSION env var missing") - report_only = (os.environ.get('REPORT_ONLY') or '').strip().lower() == 'true' + report_only = (os.environ.get("REPORT_ONLY") or "").strip().lower() == "true" + stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d") + root = Path(".").resolve() - stamp = datetime.now(timezone.utc).strftime('%Y-%m-%d') - root = Path('.').resolve() + header_re = re.compile(r"(?im)(VERSION[ ]*:[ ]*)([0-9]{2}[.][0-9]{2}[.][0-9]{2})") + manifest_marker_re = re.compile(r"(?is))([^<]*?)()") + xml_date_res = [ + re.compile(r"(?is)()([^<]*?)()"), + re.compile(r"(?is)()([^<]*?)()"), + re.compile(r"(?is)()([^<]*?)()"), + ] - header_re = re.compile(r'(?im)(VERSION[ ]*:[ ]*)([0-9]{2}[.][0-9]{2}[.][0-9]{2})') - manifest_marker_re = re.compile(r'(?is) bool: + if p.suffix.lower() in skip_ext: + counters["skipped_by_ext"] += 1 + return True + parts = {x.lower() for x in p.parts} + if any(d in parts for d in skip_dirs): + counters["skipped_by_dir"] += 1 + return True + return False + + for p in root.rglob("*"): + if not p.is_file(): + continue + if should_skip(p): + continue + + if p.parent == root and p.name.lower() in {"update.xml", "updates.xml"}: + counters["skipped_release_artifacts"] += 1 + continue + + try: + original = p.read_text(encoding="utf-8", errors="replace") + except Exception: + counters["skipped_read_error"] += 1 + continue + + text = original + + text, n1 = header_re.subn(lambda m: m.group(1) + new_version, text) + if n1: + counters["header_replacements"] += n1 + + is_manifest = (p.suffix.lower() == ".xml" and manifest_marker_re.search(original) is not None) + if is_manifest: + text, n2 = xml_version_re.subn(lambda m: m.group(1) + new_version + m.group(3), text) + if n2: + counters["xml_version_replacements"] += n2 + + for rx in xml_date_res: + text, n3 = rx.subn(lambda m: m.group(1) + stamp + m.group(3), text) + if n3: + counters["xml_date_replacements"] += n3 + + if text != original: + would_update_files.append(str(p)) + if is_manifest: + would_update_manifests.append(str(p)) + + if not report_only: + p.write_text(text, encoding="utf-8") + updated_files.append(str(p)) + if is_manifest: + updated_manifests.append(str(p)) + + report = { + "mode": "report_only" if report_only else "apply", + "new_version": new_version, + "stamp_utc": stamp, + "counters": dict(counters), + "updated_files": updated_files, + "updated_manifests": updated_manifests, + "would_update_files": would_update_files, + "would_update_manifests": would_update_manifests, + } + + Path(".github").mkdir(parents=True, exist_ok=True) + Path(".github/version-bump-report.json").write_text(json.dumps(report, indent=2), encoding="utf-8") + + print("[INFO] Mode:", report["mode"]) + print("[INFO] Would update files:", len(would_update_files)) + print("[INFO] Would update manifests:", len(would_update_manifests)) + print("[INFO] Updated files:", len(updated_files)) + print("[INFO] Updated manifests:", len(updated_manifests)) + PY + + - name: Commit changes + if: ${{ env.REPORT_ONLY != 'true' }} + run: | + source "$CI_HELPERS" + moko_init "Commit changes" + + if [[ -z "$(git status --porcelain=v1)" ]]; then + echo "[INFO] No changes detected. Skipping commit." + exit 0 + fi + + git add -A + git commit -m "chore(release): bump version to ${NEW_VERSION}" + + - name: Push branch + if: ${{ env.REPORT_ONLY != 'true' }} + run: | + source "$CI_HELPERS" + moko_init "Push branch" + + if [[ -z "${BRANCH_NAME:-}" ]]; then + echo "[FATAL] BRANCH_NAME not set." >&2 + exit 2 + fi + + git push --set-upstream origin "${BRANCH_NAME}" + + - name: Publish audit trail + if: always() + run: | + source "$CI_HELPERS" + moko_init "Publish audit trail" + + echo "# Version branch run" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- Repository: $GITHUB_REPOSITORY" >> "$GITHUB_STEP_SUMMARY" + echo "- Base branch: ${BASE_BRANCH}" >> "$GITHUB_STEP_SUMMARY" + echo "- Branch prefix: ${BRANCH_PREFIX}" >> "$GITHUB_STEP_SUMMARY" + echo "- New version: ${NEW_VERSION}" >> "$GITHUB_STEP_SUMMARY" + echo "- Report only: ${REPORT_ONLY}" >> "$GITHUB_STEP_SUMMARY" + echo "- Commit changes: ${COMMIT_CHANGES}" >> "$GITHUB_STEP_SUMMARY" + echo "- New branch: ${BRANCH_NAME:-}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + if [[ -f ".github/version-bump-report.json" ]]; then + echo "## Version bump report" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "\`\`\`json" >> "$GITHUB_STEP_SUMMARY" + head -c 12000 ".github/version-bump-report.json" >> "$GITHUB_STEP_SUMMARY" || true + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "\`\`\`" >> "$GITHUB_STEP_SUMMARY" + fi + + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "## Error summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + if [[ -f "$ERROR_LOG" && -s "$ERROR_LOG" ]]; then + echo "\`\`\`" >> "$GITHUB_STEP_SUMMARY" + tail -n 200 "$ERROR_LOG" >> "$GITHUB_STEP_SUMMARY" || true + echo "\`\`\`" >> "$GITHUB_STEP_SUMMARY" + else + echo "No errors recorded." >> "$GITHUB_STEP_SUMMARY" + fi