Update version_branch.yml

This commit is contained in:
2025-12-23 15:24:21 -06:00
parent 468ee4d1eb
commit 5f311f0373

View File

@@ -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/<version> 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}<version>' 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 "<version" . | wc -l || true)
echo "[INFO] <version> hits (repo-wide): ${COUNT2}"
echo "[INFO] VERSION: hits (repo wide): ${COUNT}"
echo "[INFO] <version> hits (repo wide): ${COUNT2}"
if [[ "${COUNT}" -eq 0 && "${COUNT2}" -eq 0 ]]; then
echo "[ERROR] No VERSION: (NN.NN.NN) or <version> 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)<extension")
xml_version_re = re.compile(r"(?is)(<version[ ]*>)([^<]*?)(</version[ ]*>)")
xml_date_res = [
re.compile(r"(?is)(<creationDate[ ]*>)([^<]*?)(</creationDate[ ]*>)"),
re.compile(r"(?is)(<date[ ]*>)([^<]*?)(</date[ ]*>)"),
re.compile(r"(?is)(<releaseDate[ ]*>)([^<]*?)(</releaseDate[ ]*>)"),
]
header_re = re.compile(r'(?im)(VERSION[ ]*:[ ]*)([0-9]{2}[.][0-9]{2}[.][0-9]{2})')
manifest_marker_re = re.compile(r'(?is)<extension\b')
skip_ext = {
".json", ".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".pdf",
".zip", ".7z", ".tar", ".gz", ".woff", ".woff2", ".ttf", ".otf",
".mp3", ".mp4",
}
skip_dirs = {".git", ".github", "node_modules", "vendor", ".venv", "dist", "build"}
counters = defaultdict(int)
updated_files = []
updated_manifests = []
would_update_files = []
would_update_manifests = []
def should_skip(p: Path) -> 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