diff --git a/.github/workflows/version_branch.yml b/.github/workflows/version_branch.yml
index 03ff0e8..ab5481f 100644
--- a/.github/workflows/version_branch.yml
+++ b/.github/workflows/version_branch.yml
@@ -15,467 +15,468 @@
# 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.Release
-# REPO: https://github.com/mokoconsulting-tech/moko-cassiopeia
-# PATH: /.github/workflows/release_from_version.yml
+# 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: Enterprise release pipeline for promoting dev branches, building Joomla artifacts, publishing prereleases, and optionally squashing to main.
-# NOTE: Designed for Joomla and Dolibarr projects following MokoStandards governance.
-#
-#
-name: Release from Version Branch Pipeline
+# BRIEF: Create a dev/ branch and align versions across governed files
+# NOTE: Enterprise gates: required artifacts, namespace defense, deterministic reporting, least-change commits
+
+name: Create version branch and bump versions
on:
workflow_dispatch:
inputs:
- promote_to_version:
- description: "Promote dev/ to version/"
+ new_version:
+ description: "New version in format NN.NN.NN (example 03.01.00)"
required: true
- default: true
- type: boolean
- delete_dev_branch:
- description: "Delete dev/ after promotion"
- required: true
- default: true
- type: boolean
- squash_to_main:
- description: "Squash merge version/ into main"
- required: true
- default: false
- type: boolean
- delete_version_branch:
- description: "Delete version/ after squash merge to main"
- required: true
- default: false
- type: boolean
+ type: string
+ version_text:
+ description: "Optional version label text (example: LTS, RC1, hotfix)"
+ required: false
+ default: ""
+ type: string
+ report_only:
+ description: "Report only mode (no branch creation, no file writes, report output only)"
+ required: false
+ default: "false"
+ type: choice
+ 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: 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:
+ name: Version branch and 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 }}
+ VERSION_TEXT: ${{ github.event.inputs.version_text }}
+ REPORT_ONLY: ${{ github.event.inputs.report_only }}
+ COMMIT_CHANGES: ${{ github.event.inputs.commit_changes }}
+ BASE_BRANCH: ${{ github.ref_name }}
+ BRANCH_PREFIX: dev/
+ ERROR_LOG: /tmp/version_branch_errors.log
+ CI_HELPERS: /tmp/moko_ci_helpers.sh
+ REPORT_PATH: ${{ runner.temp }}/version-bump-report.json
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|version)/[0-9]+\.[0-9]+\.[0-9]+$'
-
- VERSION="${BRANCH#dev/}"
- VERSION="${VERSION#version/}"
- DEV_BRANCH="dev/${VERSION}"
- VERSION_BRANCH="version/${VERSION}"
-
- # If invoked from an existing version/ branch, treat it as already promoted
- if echo "${BRANCH}" | grep -qE '^version/'; then
- VERSION_BRANCH="${BRANCH}"
- fi
- 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:
- if: ${{ github.event.inputs.promote_to_version == 'true' && startsWith(github.ref_name, 'dev/') }}
- 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
- 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"
+ set -Eeuo pipefail
+ : > "$ERROR_LOG"
- - name: Enforce branch promotion preconditions
+ 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" ]]
+ }
+
+ moko_trim() {
+ local s="${1:-}"
+ s="${s#${s%%[![:space:]]*}}"
+ s="${s%${s##*[![:space:]]}}"
+ printf '%s' "$s"
+ }
+ SH
+
+ chmod 0755 "$CI_HELPERS"
+
+ - name: Validate inputs and policy locks
run: |
- set -euo pipefail
+ source "$CI_HELPERS"
+ moko_init "Validate inputs and policy locks"
- SRC="${{ needs.guard.outputs.dev_branch }}"
- DST="${{ needs.guard.outputs.version_branch }}"
+ VERSION_TEXT="$(moko_trim "${VERSION_TEXT}")"
- git fetch origin --prune
+ echo "[INFO] Inputs received:"
+ echo " NEW_VERSION=${NEW_VERSION}"
+ echo " VERSION_TEXT=${VERSION_TEXT}"
+ echo " REPORT_ONLY=${REPORT_ONLY}"
+ echo " COMMIT_CHANGES=${COMMIT_CHANGES}"
+ echo " BASE_BRANCH=${BASE_BRANCH}"
+ echo " BRANCH_PREFIX=${BRANCH_PREFIX}"
- if ! git show-ref --verify --quiet "refs/remotes/origin/$SRC"; then
- echo "ERROR: origin/$SRC not found."
- exit 1
+ [[ -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 git show-ref --verify --quiet "refs/remotes/origin/$DST"; then
- echo "ERROR: origin/$DST already exists."
- exit 1
+ if ! moko_bool "${REPORT_ONLY}" && [[ "${COMMIT_CHANGES}" != "true" ]]; then
+ echo "[FATAL] commit_changes must be 'true' when report_only is 'false' to ensure the branch is auditable." >&2
+ exit 2
fi
- - name: Promote dev branch to version branch
- run: |
- set -euo pipefail
-
- SRC="${{ needs.guard.outputs.dev_branch }}"
- DST="${{ needs.guard.outputs.version_branch }}"
-
- git checkout -B "$DST" "origin/$SRC"
- git push origin "$DST"
-
- if [ "${{ github.event.inputs.delete_dev_branch }}" = "true" ]; then
- git push origin --delete "${SRC}"
- else
- echo "Dev branch retention enabled. Skipping deletion of ${SRC}."
- fi
-
- echo "Promotion complete: $SRC -> $DST"
-
- normalize_dates:
- name: 02 Normalize dates on version branch
- runs-on: ubuntu-latest
- needs:
- - guard
- - promote_branch
-
- permissions:
- contents: write
-
- 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: 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)
-
- 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
- fi
-
- - name: Update dates using repo script when available, otherwise apply baseline updates
- run: |
- set -euo pipefail
-
- TODAY="${{ needs.guard.outputs.today_utc }}"
- VERSION="${{ needs.guard.outputs.version }}"
-
- echo "Release version: $VERSION"
- echo "Release date (UTC): $TODAY"
-
- if [ -f scripts/update_dates.sh ]; then
- chmod +x scripts/update_dates.sh
- scripts/update_dates.sh "$TODAY" "$VERSION"
- 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
- fi
-
- - name: Commit and push date updates
- run: |
- set -euo pipefail
-
- if git diff --quiet; then
- echo "No date changes detected. No commit required."
- 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 }}"
-
- build_update_and_release:
- name: 03 Build Joomla ZIP, update update.xml, prerelease
- runs-on: ubuntu-latest
- needs:
- - guard
- - normalize_dates
-
- 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
- # Preferred canonical template location: docs/templates/
- if [ -f "docs/templates/template_update.xml" ]; then
- cp -f "docs/templates/template_update.xml" "updates.xml"
- elif [ -f "docs/templates/update_template.xml" ]; then
- cp -f "docs/templates/update_template.xml" "updates.xml"
- fi
-
- if [ ! -f "updates.xml" ]; then
- # Backward compatibility: allow repos that still keep updates.xml
- if [ -f "update.xml" ]; then
- mv -f "update.xml" "updates.xml"
- else
- echo "ERROR: updates.xml not found and no template present in docs/templates."
- exit 1
+ if [[ -n "${VERSION_TEXT}" ]]; then
+ if [[ ! "${VERSION_TEXT}" =~ ^[A-Za-z0-9._-]{1,32}$ ]]; then
+ echo "[FATAL] version_text must match ^[A-Za-z0-9._-]{1,32}$ when set." >&2
+ exit 2
fi
fi
- # Replace common placeholders if present
- sed -i "s#{{VERSION}}#${VERSION}#g" updates.xml || true
- sed -i "s#{{DATE}}#${TODAY}#g" updates.xml || true
- sed -i "s#{{DOWNLOADURL}}#${DOWNLOAD_URL}#g" updates.xml || true
- sed -i "s#{{SHA256}}#${SHA}#g" updates.xml || true
- sed -i "s#{{ZIP}}#${ZIP}#g" updates.xml || true
+ 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
+ }
- # Also enforce canonical tag replacement inside common XML elements
- sed -i "s#[^<]*#${DOWNLOAD_URL}#g" updates.xml || true
- sed -i "s#[^<]*#${SHA}#g" updates.xml || true
- sed -i "s#[^<]*#${SHA}#g" updates.xml || true
- sed -i "s#[^<]*#${VERSION}#g" updates.xml || true
- sed -i "s#[^<]*#${TODAY}#g" updates.xml || true
+ echo "VERSION_TEXT=${VERSION_TEXT}" >> "$GITHUB_ENV"
- echo "updates.xml updated."
-
- - name: Commit update.xml changes (and any related date deltas) to version branch
+ - name: Enterprise policy gate
run: |
- set -euo pipefail
+ source "$CI_HELPERS"
+ moko_init "Enterprise policy gate"
- if git diff --quiet; then
- echo "No updates.xml changes detected. No commit required."
+ 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
+
+ echo "[INFO] Policy gate passed"
+
+ - name: Branch namespace collision defense
+ run: |
+ source "$CI_HELPERS"
+ moko_init "Branch namespace collision defense"
+
+ PREFIX_TOP="${BRANCH_PREFIX%%/*}"
+ if git ls-remote --exit-code --heads origin "${PREFIX_TOP}" >/dev/null 2>&1; then
+ echo "[FATAL] Branch namespace collision detected: '${PREFIX_TOP}' exists on origin." >&2
+ exit 2
+ fi
+
+ - 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"
+
+ - 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 "[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"
+
+ - name: Enforce release generated update feeds are absent (update.xml, updates.xml)
+ if: ${{ env.REPORT_ONLY != 'true' }}
+ run: |
+ source "$CI_HELPERS"
+ moko_init "Enforce update feed deletion"
+
+ git rm -f --ignore-unmatch update.xml updates.xml || true
+ rm -f update.xml updates.xml || true
+
+ 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
+
+ - name: Preflight discovery (governed version markers outside .github)
+ run: |
+ source "$CI_HELPERS"
+ moko_init "Preflight discovery"
+
+ 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)
+ COUNT2=$(grep -RIn --exclude-dir=.git --exclude-dir=.github " hits (repo wide): ${COUNT2}"
+
+ if [[ "${COUNT}" -eq 0 && "${COUNT2}" -eq 0 ]]; then
+ echo "[FATAL] No governed version markers found outside .github" >&2
+ exit 2
+ fi
+
+ - name: Bump versions and update manifest dates (targeted, excluding .github)
+ run: |
+ source "$CI_HELPERS"
+ moko_init "Version bump"
+
+ python3 - <<'PY'
+import json
+import os
+import re
+from pathlib import Path
+from collections import defaultdict
+from datetime import datetime, timezone
+
+new_version = (os.environ.get("NEW_VERSION") or "").strip()
+version_text = (os.environ.get("VERSION_TEXT") or "").strip()
+report_only = (os.environ.get("REPORT_ONLY") or "").strip().lower() == "true"
+report_path = (os.environ.get("REPORT_PATH") or "").strip() or None
+
+stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+root = Path(".").resolve()
+
+# No literal tab characters. Use explicit escape sequences.
+header_re = re.compile(r"(?im)(VERSION[ \t]*:[ \t]*)([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_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,
+ "version_text": version_text,
+ "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,
+}
+
+payload = json.dumps(report, indent=2)
+
+if report_path:
+ Path(report_path).write_text(payload, encoding="utf-8")
+else:
+ print(payload)
+
+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): update updates.xml for ${{ needs.guard.outputs.version }}"
- git push origin "HEAD:${{ needs.guard.outputs.version_branch }}"
- - name: Create and push annotated tag after final release commit
- run: |
- set -euo pipefail
-
- 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
+ MSG="chore(release): bump version to ${NEW_VERSION}"
+ if [[ -n "${VERSION_TEXT}" ]]; then
+ MSG="${MSG} (${VERSION_TEXT})"
fi
- git tag -a "$VERSION" -m "Prerelease $VERSION"
- git push origin "refs/tags/$VERSION"
+ git commit -m "${MSG}"
- - name: Generate release notes from CHANGELOG.md
+ - name: Push branch
+ if: ${{ env.REPORT_ONLY != 'true' }}
run: |
- set -euo pipefail
+ source "$CI_HELPERS"
+ moko_init "Push branch"
- 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
+ if [[ -z "${BRANCH_NAME:-}" ]]; then
+ echo "[FATAL] BRANCH_NAME not set." >&2
+ exit 2
fi
- printf "\n\nAssets:\n- %s\n- update.xml\n- SHA256SUMS.txt\n" "${{ steps.build.outputs.zip_name }}" >> RELEASE_NOTES.md
+ git push --set-upstream origin "${BRANCH_NAME}"
- - name: Upload build artifacts
- uses: actions/upload-artifact@v4
- with:
- name: release-assets
- path: |
- dist/*.zip
- dist/SHA256SUMS.txt
- updates.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
- updates.xml
- dist/SHA256SUMS.txt
-
- squash_to_main:
- name: 04 Optional squash merge version branch to main
- runs-on: ubuntu-latest
- needs:
- - guard
- - build_update_and_release
-
- if: ${{ github.event.inputs.squash_to_main == 'true' }}
-
- permissions:
- contents: write
-
- steps:
- - name: Checkout main
- uses: actions/checkout@v4
- with:
- ref: main
- fetch-depth: 0
-
- - name: Configure Git identity
+ - name: Publish audit trail
+ if: always()
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 "Publish audit trail"
- - name: Fetch version branch
- run: |
- set -euo pipefail
- git fetch origin --prune
+ 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 "- Version text: ${VERSION_TEXT}" >> "$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"
- - name: Squash merge version branch into main
- run: |
- set -euo pipefail
-
- VERSION="${{ needs.guard.outputs.version }}"
- VBRANCH="origin/${{ needs.guard.outputs.version_branch }}"
-
- # Governance control: if main is protected from direct pushes, this will fail by design.
- # Enforce PR-based merge in that scenario.
-
- git checkout main
- git merge --squash "${VBRANCH}"
-
- if git diff --cached --quiet; then
- echo "No changes to merge from ${VBRANCH}."
- exit 0
- fi
-
- git commit -m "chore(release): squash ${VERSION} into main"
- git push origin "HEAD:main"
-
- - name: Optional delete version branch after squash
- run: |
- set -euo pipefail
- if [ "${{ github.event.inputs.delete_version_branch }}" = "true" ]; then
- git push origin --delete "${{ needs.guard.outputs.version_branch }}"
+ if [[ -f "${REPORT_PATH}" ]]; then
+ echo "## Version bump report" >> "$GITHUB_STEP_SUMMARY"
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+ echo "\`\`\`json" >> "$GITHUB_STEP_SUMMARY"
+ head -c 12000 "${REPORT_PATH}" >> "$GITHUB_STEP_SUMMARY" || true
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+ echo "\`\`\`" >> "$GITHUB_STEP_SUMMARY"
else
- echo "Version branch retention enabled. Skipping deletion."
+ echo "## Version bump report" >> "$GITHUB_STEP_SUMMARY"
+ echo "" >> "$GITHUB_STEP_SUMMARY"
+ echo "Report file not found at: ${REPORT_PATH}" >> "$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