diff --git a/.github/workflows/release_from_version.yml b/.github/workflows/release_from_version.yml index bc75f57..c16bfea 100644 --- a/.github/workflows/release_from_version.yml +++ b/.github/workflows/release_from_version.yml @@ -1,350 +1,640 @@ -name: Release from Version Branch Pipeline +# +# 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. +# +# FILE INFORMATION +# DEFGROUP: MokoStandards.Joomla +# INGROUP: GitHub.Versioning.Branching +# 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 + +name: Create version branch and bump versions on: workflow_dispatch: + inputs: + 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 + default: "false" + type: choice + options: + - "true" + - "false" concurrency: - group: release-from-dev-${{ github.ref_name }} + group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.inputs.new_version }} cancel-in-progress: false permissions: - contents: read + contents: write + +defaults: + run: + shell: bash jobs: - guard: - name: 00 Guard and derive release metadata + version-bump: runs-on: ubuntu-latest - outputs: - version: ${{ steps.extract.outputs.version }} - dev_branch: ${{ steps.extract.outputs.dev_branch }} - version_branch: ${{ steps.extract.outputs.version_branch }} - today_utc: ${{ steps.extract.outputs.today_utc }} + env: + NEW_VERSION: ${{ github.event.inputs.new_version }} + 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: Validate calling branch and extract version - id: extract - run: | - set -euo pipefail - - BRANCH="${GITHUB_REF_NAME}" - echo "Invoked from branch: $BRANCH" - echo "$BRANCH" | grep -E '^dev/[0-9]+\.[0-9]+\.[0-9]+$' - - VERSION="${BRANCH#dev/}" - DEV_BRANCH="dev/$VERSION" - VERSION_BRANCH="version/$VERSION" - TODAY_UTC="$(date -u +%Y-%m-%d)" - - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "dev_branch=$DEV_BRANCH" >> "$GITHUB_OUTPUT" - echo "version_branch=$VERSION_BRANCH" >> "$GITHUB_OUTPUT" - echo "today_utc=$TODAY_UTC" >> "$GITHUB_OUTPUT" - - promote_branch: - name: 01 Promote dev to version branch - runs-on: ubuntu-latest - needs: guard - - permissions: - contents: write - - steps: - - name: Checkout dev branch + - name: Checkout repository uses: actions/checkout@v4 with: - ref: ${{ needs.guard.outputs.dev_branch }} fetch-depth: 0 + ref: ${{ github.ref_name }} - - name: Configure Git identity + - name: Init CI helpers run: | - set -euo pipefail + set -Eeuo pipefail + + : > "$ERROR_LOG" + + cat > "$CI_HELPERS" <<'SH' + set -Eeuo pipefail + + 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 + } + + moko_on_err() { + local step_name="$1" + local line_no="$2" + local last_cmd="$3" + + echo "[FATAL] ${step_name} failed at line ${line_no}" >&2 + echo "[FATAL] Last command: ${last_cmd}" >&2 + + if [[ -n "${ERROR_LOG:-}" ]]; then + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | ${step_name} | line ${line_no} | ${last_cmd}" >> "$ERROR_LOG" || true + fi + } + + + moko_bool() { + local v="${1:-false}" + [[ "${v}" == "true" ]] + } + SH + + chmod 0755 "$CI_HELPERS" + + - name: Validate inputs + run: | + source "$CI_HELPERS" + moko_init "Validate inputs" + + echo "[INFO] Inputs received:" + echo " NEW_VERSION=${NEW_VERSION}" + 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; } + + if [[ "${BRANCH_PREFIX}" != "dev/" ]]; then + echo "[FATAL] BRANCH_PREFIX is locked by policy. Expected 'dev/' but got '${BRANCH_PREFIX}'." >&2 + 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 + exit 2 + fi + + git ls-remote --exit-code --heads origin "${BASE_BRANCH}" >/dev/null 2>&1 || { + echo "[ERROR] Base branch does not exist on origin: ${BASE_BRANCH}" >&2 + echo "[INFO] Remote branches:" >&2 + git ls-remote --heads origin | awk '{sub("refs/heads/","",$2); print $2}' >&2 + exit 2 + } + + echo "[INFO] Input validation passed" + + - name: Enterprise policy gate (required files) + run: | + source "$CI_HELPERS" + moko_init "Enterprise policy gate" + + required=( + "LICENSE.md" + "CONTRIBUTING.md" + "CODE_OF_CONDUCT.md" + "SECURITY.md" + "GOVERNANCE.md" + "CHANGELOG.md" + ) + + missing=0 + for f in "${required[@]}"; do + if [[ ! -f "${f}" ]]; then + echo "[ERROR] Missing required file: ${f}" >&2 + missing=1 + continue + fi + if [[ ! -s "${f}" ]]; then + echo "[ERROR] Required file is empty: ${f}" >&2 + missing=1 + continue + fi + done + + if [[ "${missing}" -ne 0 ]]; then + echo "[FATAL] Policy gate failed. Add missing governance artifacts before versioning." >&2 + 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 + 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" - git config --global --add safe.directory "$GITHUB_WORKSPACE" + echo "[INFO] Git identity configured" - - name: Enforce branch promotion preconditions + - name: Branch namespace collision defense run: | - set -euo pipefail + source "$CI_HELPERS" + moko_init "Branch namespace collision defense" - SRC="${{ needs.guard.outputs.dev_branch }}" - DST="${{ needs.guard.outputs.version_branch }}" - - git fetch origin --prune - - if ! git show-ref --verify --quiet "refs/remotes/origin/$SRC"; then - echo "ERROR: origin/$SRC not found." - exit 1 + 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 + exit 2 fi - if git show-ref --verify --quiet "refs/remotes/origin/$DST"; then - echo "ERROR: origin/$DST already exists." - exit 1 + echo "[INFO] No namespace collision detected for BRANCH_PREFIX=${BRANCH_PREFIX}" + + - name: Create version branch (local) + if: ${{ env.REPORT_ONLY != 'true' }} + run: | + source "$CI_HELPERS" + moko_init "Create version branch (local)" + + BRANCH_NAME="${BRANCH_PREFIX}${NEW_VERSION}" + echo "[INFO] Creating local branch: ${BRANCH_NAME} from origin/${BASE_BRANCH}" + + 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 + exit 2 fi - - name: Promote dev branch to version branch + 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) + if: ${{ env.REPORT_ONLY != 'true' }} run: | - set -euo pipefail + source "$CI_HELPERS" + moko_init "CHANGELOG governance" - SRC="${{ needs.guard.outputs.dev_branch }}" - DST="${{ needs.guard.outputs.version_branch }}" + python3 - <<'PY' + import os + import re + from datetime import datetime, timezone + from pathlib import Path - git checkout -B "$DST" "origin/$SRC" - git push origin "$DST" + nl = chr(10) + cr = chr(13) - git push origin --delete "$SRC" + new_version = (os.environ.get('NEW_VERSION') or '').strip() or '00.00.00' - echo "Promotion complete: $SRC -> $DST" + p = Path('CHANGELOG.md') + if not p.exists(): + raise SystemExit('[FATAL] CHANGELOG.md missing') - normalize_dates: - name: 02 Normalize dates on version branch - runs-on: ubuntu-latest - needs: - - guard - - promote_branch + lines = p.read_text(encoding='utf-8', errors='replace').splitlines(True) - permissions: - contents: write + # 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) - steps: - - name: Checkout version branch - uses: actions/checkout@v4 - with: - ref: ${{ needs.guard.outputs.version_branch }} - fetch-depth: 0 + bullet_re = re.compile(r'^[ ]*[-*+][ ]+') + blank_re = re.compile(r'^[ ]*$') + unreleased_re = re.compile(r'^[ ]*##[ ]*(?:\[[ ]*UNRELEASED[ ]*\]|UNRELEASED)[ ]*$', re.IGNORECASE) - - name: Configure Git identity + 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 + + - name: Preflight discovery (governed version markers outside .github) run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git config --global --add safe.directory "$GITHUB_WORKSPACE" + source "$CI_HELPERS" + moko_init "Preflight discovery" - - name: Validate repository release prerequisites - run: | - set -euo pipefail - test -d src || (echo "ERROR: src directory missing." && exit 1) - test -f CHANGELOG.md || (echo "ERROR: CHANGELOG.md missing." && exit 1) + echo "[INFO] Scanning all directories except .github" - VERSION="${{ needs.guard.outputs.version }}" - if ! grep -qE "^## \\[$VERSION\\] " CHANGELOG.md; then - echo "ERROR: CHANGELOG.md does not contain a heading for version [$VERSION]." - exit 1 + 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}" + + if [[ "${COUNT}" -eq 0 && "${COUNT2}" -eq 0 ]]; then + echo "[ERROR] No VERSION: (NN.NN.NN) or tags found outside .github" >&2 + exit 2 fi - - - name: Update dates using repo script when available, otherwise apply baseline updates + - name: Bump versions and update manifest dates (targeted, excluding .github) run: | - set -euo pipefail + source "$CI_HELPERS" + moko_init "Version bump" - TODAY="${{ needs.guard.outputs.today_utc }}" - VERSION="${{ needs.guard.outputs.version }}" + python3 - <<'PY' + import json + import os + import re + from pathlib import Path + from collections import defaultdict + from datetime import datetime, timezone - echo "Release version: $VERSION" - echo "Release date (UTC): $TODAY" + new_version = (os.environ.get('NEW_VERSION') or '').strip() + if not new_version: + raise SystemExit('[FATAL] NEW_VERSION env var missing') - if [ -f scripts/update_dates.sh ]; then - chmod +x scripts/update_dates.sh - scripts/update_dates.sh "$TODAY" "$VERSION" + 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)()([^<]*?)()'), + ] + + 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 = [] + updated_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 + + # Release only artifacts at repo root + if p.parent == root and p.name.lower() in {'update.xml', 'updates.xml'}: + counters['skipped_release_artifacts'] += 1 + continue + + try: + text = p.read_text(encoding='utf-8', errors='replace') + except Exception: + counters['skipped_read_error'] += 1 + continue + + original = text + + text, n1 = header_re.subn(lambda m: m.group(1) + new_version, text) + if n1: + counters['header_replacements'] += n1 + + if p.suffix.lower() == '.xml' and manifest_marker_re.search(text): + text2, n2 = xml_version_re.subn(lambda m: m.group(1) + new_version + m.group(3), text) + text = text2 + if n2: + counters['xml_version_replacements'] += n2 + + for rx in xml_date_res: + text3, n3 = rx.subn(lambda m: m.group(1) + stamp + m.group(3), text) + text = text3 + if n3: + counters['xml_date_replacements'] += n3 + + if text != original: + updated_manifests.append(str(p)) + + if text != original: + p.write_text(text, encoding='utf-8') + updated.append(str(p)) + + report = { + 'new_version': new_version, + 'stamp_utc': stamp, + 'counters': dict(counters), + 'updated_files': updated, + 'updated_manifests': updated_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] Scan summary') + for k in sorted(counters.keys()): + print(' ' + k + ': ' + str(counters[k])) + + print('[INFO] Updated files: ' + str(len(updated))) + print('[INFO] Updated manifests: ' + str(len(updated_manifests))) + + if not updated: + print('[INFO] No eligible files updated. Skipping version bump without failure.') + raise SystemExit(0) + PY + + - name: Enforce update.xml is release generated only + if: ${{ env.REPORT_ONLY != 'true' }} + run: | + source "$CI_HELPERS" + moko_init "Enforce update.xml is release generated only" + + if [[ -f "update.xml" ]]; then + echo "[INFO] update.xml present at repo root. Removing file because it is release generated only." + + # Ensure we can delete + chmod u+rw "update.xml" || true + + rm -f "update.xml" + + if [[ -f "update.xml" ]]; then + echo "[FATAL] update.xml could not be deleted." >&2 + ls -la "update.xml" || true + exit 2 + fi + + echo "[INFO] update.xml deleted successfully." + echo "[INFO] Confirming working tree reflects deletion" + git status --porcelain=v1 update.xml || true + git diff -- update.xml || true else - echo "scripts/update_dates.sh not found. Applying baseline date normalization." - - find . -type f -name "*.xml" \ - -not -path "./.git/*" \ - -print0 | while IFS= read -r -d '' f; do - sed -i "s#[^<]*#${TODAY}#g" "$f" || true - sed -i "s#[^<]*#${TODAY}#g" "$f" || true - sed -i "s#[^<]*#${TODAY}#g" "$f" || true - done - - sed -i -E "s#^(## \\[${VERSION}\\]) [0-9]{4}-[0-9]{2}-[0-9]{2}#\\1 ${TODAY}#g" CHANGELOG.md || true + echo "[INFO] update.xml not present. No action taken." fi - - name: Commit and push date updates + - name: Change scope guard (block .github edits) run: | - set -euo pipefail + source "$CI_HELPERS" + moko_init "Change scope guard" - if git diff --quiet; then - echo "No date changes detected. No commit required." + if [[ -z "$(git status --porcelain=v1)" ]]; then + echo "[INFO] No changes detected. Scope guard skipped." exit 0 fi - git add -A - git commit -m "chore(release): normalize dates for ${{ needs.guard.outputs.version }}" - git push origin "HEAD:${{ needs.guard.outputs.version_branch }}" + echo "[INFO] Evaluating changed paths" + git diff --name-only > /tmp/changed_paths.txt - build_update_and_release: - name: 03 Build Joomla ZIP, update update.xml, prerelease - runs-on: ubuntu-latest - needs: - - guard - - normalize_dates + bad=0 + while IFS= read -r p; do + if [[ "$p" == .github/* ]] && [[ "$p" != .github/version-bump-report.json ]]; then + echo "[ERROR] .github change is not permitted by this workflow: $p" >&2 + bad=1 + fi + done < /tmp/changed_paths.txt - permissions: - contents: write - id-token: write - - environment: - name: release - - steps: - - name: Checkout version branch - uses: actions/checkout@v4 - with: - ref: ${{ needs.guard.outputs.version_branch }} - fetch-depth: 0 - - - name: Configure Git identity - run: | - set -euo pipefail - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git config --global --add safe.directory "$GITHUB_WORKSPACE" - - - name: Build Joomla compliant ZIP from src - id: build - run: | - set -euo pipefail - - VERSION="${{ needs.guard.outputs.version }}" - REPO="${{ github.event.repository.name }}" - ZIP="${REPO}-${VERSION}.zip" - - test -d src || (echo "ERROR: src directory missing." && exit 1) - - mkdir -p dist - - # Joomla compliant packaging: src contents at ZIP root (no nested src folder) - cd src - zip -r "../dist/$ZIP" . - cd .. - - echo "zip_name=$ZIP" >> "$GITHUB_OUTPUT" - ls -la dist - - - name: Compute SHA256 for ZIP - id: sha - run: | - set -euo pipefail - ZIP="${{ steps.build.outputs.zip_name }}" - SHA="$(sha256sum "dist/$ZIP" | awk '{print $1}')" - echo "sha256=$SHA" >> "$GITHUB_OUTPUT" - printf "%s %s\n" "$SHA" "$ZIP" > dist/SHA256SUMS.txt - cat dist/SHA256SUMS.txt - - - name: Update update.xml with download URL and sha256 - run: | - set -euo pipefail - - VERSION="${{ needs.guard.outputs.version }}" - TODAY="${{ needs.guard.outputs.today_utc }}" - ZIP="${{ steps.build.outputs.zip_name }}" - SHA="${{ steps.sha.outputs.sha256 }}" - - OWNER="${{ github.repository_owner }}" - REPO="${{ github.event.repository.name }}" - - DOWNLOAD_URL="https://github.com/${OWNER}/${REPO}/releases/download/${VERSION}/${ZIP}" - - echo "Version: $VERSION" - echo "Download URL: $DOWNLOAD_URL" - echo "SHA256: $SHA" - - # If a template exists, instantiate it - if [ -f "docs/templates/template_update.xml" ]; then - cp -f "docs/templates/template_update.xml" "update.xml" + if [[ "$bad" -ne 0 ]]; then + echo "[FATAL] Change scope guard failed. Workflow attempted to modify .github content." >&2 + echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | Change scope guard | attempted .github modifications" >> "$ERROR_LOG" || true + exit 2 fi - if [ ! -f "update.xml" ]; then - echo "ERROR: update.xml not found and docs/templates/template_update.xml not found." - exit 1 + echo "[INFO] Scope guard passed" + + - name: Publish audit trail to job summary + 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 "- New branch: ${BRANCH_NAME:-}" >> "$GITHUB_STEP_SUMMARY" + echo "- Version: ${NEW_VERSION}" >> "$GITHUB_STEP_SUMMARY" + echo "- Commit changes: ${COMMIT_CHANGES}" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + if [[ -f ".github/version-bump-report.json" ]]; then + echo "## 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 - # Replace common placeholders if present - sed -i "s#{{VERSION}}#${VERSION}#g" update.xml || true - sed -i "s#{{DATE}}#${TODAY}#g" update.xml || true - sed -i "s#{{DOWNLOADURL}}#${DOWNLOAD_URL}#g" update.xml || true - sed -i "s#{{SHA256}}#${SHA}#g" update.xml || true - sed -i "s#{{ZIP}}#${ZIP}#g" update.xml || true + echo "## Error summary" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" - # Also enforce canonical tag replacement inside common XML elements - sed -i "s#[^<]*#${DOWNLOAD_URL}#g" update.xml || true - sed -i "s#[^<]*#${SHA}#g" update.xml || true - sed -i "s#[^<]*#${SHA}#g" update.xml || true - sed -i "s#[^<]*#${VERSION}#g" update.xml || true - sed -i "s#[^<]*#${TODAY}#g" update.xml || true + 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 - echo "update.xml updated." - - - name: Commit update.xml changes (and any related date deltas) to version branch + - name: Show git status run: | - set -euo pipefail + source "$CI_HELPERS" + moko_init "Show git status" - if git diff --quiet; then - echo "No update.xml changes detected. No commit required." + git status --porcelain=v1 + + - name: Commit changes + id: commit + if: ${{ env.REPORT_ONLY != 'true' }} + run: | + source "$CI_HELPERS" + moko_init "Commit changes" + + git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "[ERROR] Not inside a git work tree" >&2; exit 2; } + + if [[ -z "$(git status --porcelain=v1)" ]]; then + echo "[INFO] No changes detected. Skipping commit and push." + echo "committed=false" >> "$GITHUB_OUTPUT" exit 0 fi - git add -A - git commit -m "chore(release): update update.xml for ${{ needs.guard.outputs.version }}" - git push origin "HEAD:${{ needs.guard.outputs.version_branch }}" + echo "[INFO] Staging all changes except .github" + git add -A -- . ":(exclude).github" - - name: Create and push annotated tag after final release commit + git commit -m "chore(release): bump version to ${NEW_VERSION}" + echo "committed=true" >> "$GITHUB_OUTPUT" + + - name: Push branch + if: ${{ env.REPORT_ONLY != 'true' }} run: | - set -euo pipefail + source "$CI_HELPERS" + moko_init "Push branch" - VERSION="${{ needs.guard.outputs.version }}" - - git fetch --tags - - if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then - echo "ERROR: Tag $VERSION already exists." - exit 1 + if [[ -z "${BRANCH_NAME:-}" ]]; then + echo "[FATAL] BRANCH_NAME is not set. Branch creation step may have failed." >&2 + exit 2 fi - git tag -a "$VERSION" -m "Prerelease $VERSION" - git push origin "refs/tags/$VERSION" + echo "[INFO] Pushing branch and commits to origin/${BRANCH_NAME}" + git push --set-upstream origin "${BRANCH_NAME}" - - name: Generate release notes from CHANGELOG.md + - name: Output branch name + if: always() run: | - set -euo pipefail + source "$CI_HELPERS" + moko_init "Output branch name" - VERSION="${{ needs.guard.outputs.version }}" - - awk "/^## \\[$VERSION\\]/{flag=1;next}/^## \\[/{flag=0}flag" CHANGELOG.md > RELEASE_NOTES.md || true - - if [ ! -s RELEASE_NOTES.md ]; then - echo "ERROR: Release notes extraction failed for $VERSION." - exit 1 - fi - - printf "\n\nAssets:\n- %s\n- update.xml\n- SHA256SUMS.txt\n" "${{ steps.build.outputs.zip_name }}" >> RELEASE_NOTES.md - - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: release-assets - path: | - dist/*.zip - dist/SHA256SUMS.txt - update.xml - RELEASE_NOTES.md - retention-days: 30 - - - name: Attest build provenance - uses: actions/attest-build-provenance@v2 - with: - subject-path: | - dist/*.zip - dist/SHA256SUMS.txt - - - name: Create GitHub prerelease and attach assets - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ needs.guard.outputs.version }} - name: Prerelease ${{ needs.guard.outputs.version }} - prerelease: true - body_path: RELEASE_NOTES.md - files: | - dist/*.zip - update.xml - dist/SHA256SUMS.txt + echo "[INFO] Created branch: ${BRANCH_NAME:-}"