From 0e2f0859ad6baba388c387e89605061e14f7c513 Mon Sep 17 00:00:00 2001 From: Jonathan Miller <230051081+jmiller-moko@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:59:22 -0600 Subject: [PATCH] Update release_from_version.yml --- .github/workflows/release_from_version.yml | 842 +++++++-------------- 1 file changed, 276 insertions(+), 566 deletions(-) diff --git a/.github/workflows/release_from_version.yml b/.github/workflows/release_from_version.yml index c16bfea..bc75f57 100644 --- a/.github/workflows/release_from_version.yml +++ b/.github/workflows/release_from_version.yml @@ -1,640 +1,350 @@ -# -# 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 +name: Release from Version Branch Pipeline 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: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.inputs.new_version }} + group: release-from-dev-${{ github.ref_name }} cancel-in-progress: false permissions: - contents: write - -defaults: - run: - shell: bash + contents: read jobs: - version-bump: + guard: + name: 00 Guard and derive release metadata runs-on: ubuntu-latest - 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 }} + 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 }} steps: - - name: Checkout repository + - 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 uses: actions/checkout@v4 with: + ref: ${{ needs.guard.outputs.dev_branch }} fetch-depth: 0 - ref: ${{ github.ref_name }} - - name: Init CI helpers + - name: Configure Git identity run: | - 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" - + set -euo pipefail git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - echo "[INFO] Git identity configured" + git config --global --add safe.directory "$GITHUB_WORKSPACE" - - name: Branch namespace collision defense + - name: Enforce branch promotion preconditions run: | - source "$CI_HELPERS" - moko_init "Branch namespace collision defense" + set -euo pipefail - 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 + 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 fi - 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 + if git show-ref --verify --quiet "refs/remotes/origin/$DST"; then + echo "ERROR: origin/$DST already exists." + exit 1 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) - if: ${{ env.REPORT_ONLY != 'true' }} + - name: Promote dev branch to version branch run: | - source "$CI_HELPERS" - moko_init "CHANGELOG governance" + set -euo pipefail - python3 - <<'PY' - import os - import re - from datetime import datetime, timezone - from pathlib import Path + SRC="${{ needs.guard.outputs.dev_branch }}" + DST="${{ needs.guard.outputs.version_branch }}" - nl = chr(10) - cr = chr(13) + git checkout -B "$DST" "origin/$SRC" + git push origin "$DST" - new_version = (os.environ.get('NEW_VERSION') or '').strip() or '00.00.00' + git push origin --delete "$SRC" - p = Path('CHANGELOG.md') - if not p.exists(): - raise SystemExit('[FATAL] CHANGELOG.md missing') + echo "Promotion complete: $SRC -> $DST" - lines = p.read_text(encoding='utf-8', errors='replace').splitlines(True) + normalize_dates: + name: 02 Normalize dates on version branch + runs-on: ubuntu-latest + needs: + - guard + - promote_branch - # 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) + permissions: + contents: write - bullet_re = re.compile(r'^[ ]*[-*+][ ]+') - blank_re = re.compile(r'^[ ]*$') - unreleased_re = re.compile(r'^[ ]*##[ ]*(?:\[[ ]*UNRELEASED[ ]*\]|UNRELEASED)[ ]*$', re.IGNORECASE) + steps: + - name: Checkout version branch + uses: actions/checkout@v4 + with: + ref: ${{ needs.guard.outputs.version_branch }} + fetch-depth: 0 - 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) + - name: Configure Git identity run: | - source "$CI_HELPERS" - moko_init "Preflight discovery" + 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" - echo "[INFO] Scanning all directories except .github" + - 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) - 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 + 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: Bump versions and update manifest dates (targeted, excluding .github) + + - name: Update dates using repo script when available, otherwise apply baseline updates run: | - source "$CI_HELPERS" - moko_init "Version bump" + set -euo pipefail - python3 - <<'PY' - import json - import os - import re - from pathlib import Path - from collections import defaultdict - from datetime import datetime, timezone + TODAY="${{ needs.guard.outputs.today_utc }}" + VERSION="${{ needs.guard.outputs.version }}" - new_version = (os.environ.get('NEW_VERSION') or '').strip() - if not new_version: - raise SystemExit('[FATAL] NEW_VERSION env var missing') + echo "Release version: $VERSION" + echo "Release date (UTC): $TODAY" - 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 + if [ -f scripts/update_dates.sh ]; then + chmod +x scripts/update_dates.sh + scripts/update_dates.sh "$TODAY" "$VERSION" else - echo "[INFO] update.xml not present. No action taken." + 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: Change scope guard (block .github edits) + - name: Commit and push date updates run: | - source "$CI_HELPERS" - moko_init "Change scope guard" + set -euo pipefail - if [[ -z "$(git status --porcelain=v1)" ]]; then - echo "[INFO] No changes detected. Scope guard skipped." + if git diff --quiet; then + echo "No date changes detected. No commit required." exit 0 fi - echo "[INFO] Evaluating changed paths" - git diff --name-only > /tmp/changed_paths.txt + git add -A + git commit -m "chore(release): normalize dates for ${{ needs.guard.outputs.version }}" + git push origin "HEAD:${{ needs.guard.outputs.version_branch }}" - 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 + build_update_and_release: + name: 03 Build Joomla ZIP, update update.xml, prerelease + runs-on: ubuntu-latest + needs: + - guard + - normalize_dates - 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 + 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" fi - 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" + if [ ! -f "update.xml" ]; then + echo "ERROR: update.xml not found and docs/templates/template_update.xml not found." + exit 1 fi - echo "## Error summary" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" + # 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 - 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 + # 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 - - name: Show git status + echo "update.xml updated." + + - name: Commit update.xml changes (and any related date deltas) to version branch run: | - source "$CI_HELPERS" - moko_init "Show git status" + set -euo pipefail - 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" + if git diff --quiet; then + echo "No update.xml changes detected. No commit required." exit 0 fi - echo "[INFO] Staging all changes except .github" - git add -A -- . ":(exclude).github" + 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 }}" - git commit -m "chore(release): bump version to ${NEW_VERSION}" - echo "committed=true" >> "$GITHUB_OUTPUT" - - - name: Push branch - if: ${{ env.REPORT_ONLY != 'true' }} + - name: Create and push annotated tag after final release commit run: | - source "$CI_HELPERS" - moko_init "Push branch" + set -euo pipefail - if [[ -z "${BRANCH_NAME:-}" ]]; then - echo "[FATAL] BRANCH_NAME is not set. Branch creation step may have failed." >&2 - exit 2 + 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 fi - echo "[INFO] Pushing branch and commits to origin/${BRANCH_NAME}" - git push --set-upstream origin "${BRANCH_NAME}" + git tag -a "$VERSION" -m "Prerelease $VERSION" + git push origin "refs/tags/$VERSION" - - name: Output branch name - if: always() + - name: Generate release notes from CHANGELOG.md run: | - source "$CI_HELPERS" - moko_init "Output branch name" + set -euo pipefail - echo "[INFO] Created branch: ${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