From 2ac70c7d53781571c602fe7841756046862bd6d8 Mon Sep 17 00:00:00 2001 From: Jonathan Miller <230051081+jmiller-moko@users.noreply.github.com> Date: Tue, 23 Dec 2025 21:41:17 -0600 Subject: [PATCH] Create release_pipeline.yml --- .github/workflows/release_pipeline.yml | 533 +++++++++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 .github/workflows/release_pipeline.yml diff --git a/.github/workflows/release_pipeline.yml b/.github/workflows/release_pipeline.yml new file mode 100644 index 0000000..9d131af --- /dev/null +++ b/.github/workflows/release_pipeline.yml @@ -0,0 +1,533 @@ +# +# 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. +# +# 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/MokoStandards +# PATH: /.github/workflows/release_pipeline.yml +# VERSION: 01.00.00 +# BRIEF: Enterprise release pipeline that promotes dev/ or rc/ to version/, deletes dev branch, builds Joomla artifacts, publishes prereleases, and optionally creates a squash PR to main. +# NOTE: Invocation is restricted to dev/.. branches. +# +name: Release Pipeline + +on: + workflow_dispatch: + inputs: + squash_to_main: + description: "Create a PR that squashes version/ into main (enterprise-safe)" + required: true + default: false + type: boolean + delete_version_branch: + description: "Delete version/ after PR creation (best-effort)" + required: true + default: false + type: boolean + +concurrency: + group: release-from-dev-${{ github.ref_name }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +permissions: + contents: read + +jobs: + guard: + name: 00 Guard and derive release metadata + runs-on: ubuntu-latest + + outputs: + version: ${{ steps.extract.outputs.version }} + source_branch: ${{ steps.extract.outputs.source_branch }} + version_branch: ${{ steps.extract.outputs.version_branch }} + today_utc: ${{ steps.extract.outputs.today_utc }} + + steps: + - name: Validate calling branch and extract version + id: extract + run: | + set -euo pipefail + + BRANCH="${GITHUB_REF_NAME}" + echo "Invoked from branch: ${BRANCH}" + + # Gate: only allow manual runs from dev/.. or rc/.. + echo "${BRANCH}" | grep -E '^(dev|rc)/[0-9]+\.[0-9]+\.[0-9]+$' + + VERSION="${BRANCH#*/}" + SOURCE_BRANCH="${BRANCH}" + VERSION_BRANCH="version/${VERSION}" + TODAY_UTC="$(date -u +%Y-%m-%d)" + + echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" + echo "source_branch=${SOURCE_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 (mandatory) + 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 + + - 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: Enforce promotion preconditions + run: | + set -euo pipefail + + 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 + + if git show-ref --verify --quiet "refs/remotes/origin/${DST}"; then + echo "ERROR: origin/${DST} already exists." + exit 1 + fi + + - name: Promote dev branch to version branch and delete dev 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}" + + # Mandatory hygiene: always delete dev/ after promotion. + git push origin --delete "${SRC}" + + echo "Promotion complete: ${SRC} -> ${DST} (dev branch deleted)" + + normalize_dates: + name: 02 Normalize dates on version branch + runs-on: ubuntu-latest + needs: + - guard + - promote_branch + + if: ${{ needs.promote_branch.result == 'success' }} + + 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 updates.xml, prerelease + runs-on: ubuntu-latest + needs: + - guard + - normalize_dates + + permissions: + contents: write + id-token: write + attestations: 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: Build Joomla compliant ZIP (template, component, module, plugin) + id: build + run: | + set -euo pipefail + + VERSION="${{ needs.guard.outputs.version }}" + REPO="${{ github.event.repository.name }}" + + test -d src || (echo "ERROR: src directory missing." && exit 1) + + mkdir -p dist + + # Determine extension root inside src. + # - If src contains a single top-level directory, that directory is the extension root. + # - Otherwise, src itself is the extension root. + ROOT="src" + TOP_DIRS="$(find src -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" + if [ "${TOP_DIRS}" = "1" ]; then + ROOT="$(find src -mindepth 1 -maxdepth 1 -type d -print -quit)" + fi + + echo "Candidate extension root: ${ROOT}" + + # Require a manifest at the root of ROOT. + MANIFEST="" + + # Primary: templateDetails.xml at root + if [ -f "${ROOT}/templateDetails.xml" ]; then + MANIFEST="${ROOT}/templateDetails.xml" + + # Secondary: standard Joomla template layouts + elif [ -f "src/templates/templateDetails.xml" ]; then + MANIFEST="src/templates/templateDetails.xml" + + # Tertiary: namespaced Joomla template layout src/templates//templateDetails.xml + elif find "src/templates" -mindepth 2 -maxdepth 2 -name "templateDetails.xml" -type f | grep -q .; then + MANIFEST="$(find "src/templates" -mindepth 2 -maxdepth 2 -name "templateDetails.xml" -type f | head -n 1)" + + # Fallback: any root-level XML with an element + else + while IFS= read -r -d '' f; do + if grep -qE ']' "${f}"; then + MANIFEST="${f}" + break + fi + done < <(find "${ROOT}" -maxdepth 1 -type f -name "*.xml" -print0) + fi + + if [ -z "${MANIFEST}" ]; then + echo "ERROR: No Joomla manifest XML found at root of ${ROOT}." + echo "Expected templateDetails.xml or a root-level *.xml containing an element." + exit 1 + fi + + echo "Manifest: ${MANIFEST}" + + EXT_TYPE="$(grep -oE ']*type=\"[^\"]+\"' "${MANIFEST}" | head -n 1 | sed -E 's/.*type=\"([^\"]+)\".*/\1/')" + if [ -z "${EXT_TYPE}" ]; then + EXT_TYPE="unknown" + fi + echo "Detected extension type: ${EXT_TYPE}" + + case "${EXT_TYPE}" in + template) + test -f "${ROOT}/templateDetails.xml" || (echo "ERROR: templateDetails.xml missing for template build." && exit 1) + ;; + component) + if ! ls "${ROOT}"/com_*.xml >/dev/null 2>&1; then + echo "WARNING: No com_*.xml manifest found at root. Using detected manifest anyway." + fi + ;; + module) + if ! ls "${ROOT}"/mod_*.xml >/dev/null 2>&1; then + echo "WARNING: No mod_*.xml manifest found at root. Using detected manifest anyway." + fi + ;; + plugin) + : + ;; + *) + echo "WARNING: Extension type could not be determined reliably. Proceeding with generic packaging." + ;; + esac + + ZIP="${REPO}-${VERSION}.zip" + + # Joomla install expectation: the ZIP root is the extension root. + # Zip the CONTENTS of ROOT. + (cd "${ROOT}" && zip -r -X "../dist/${ZIP}" . \ + -x "**/.git/**" \ + -x "**/.github/**" \ + -x "**/.DS_Store" \ + -x "**/__MACOSX/**") + + echo "zip_name=${ZIP}" >> "${GITHUB_OUTPUT}" + echo "root=${ROOT}" >> "${GITHUB_OUTPUT}" + echo "manifest=${MANIFEST}" >> "${GITHUB_OUTPUT}" + echo "ext_type=${EXT_TYPE}" >> "${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: 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 + fi + + git tag -a "${VERSION}" -m "Prerelease ${VERSION}" + git push origin "refs/tags/${VERSION}" + + - name: Generate release notes from CHANGELOG.md + run: | + set -euo pipefail + + 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 + + ZIP_ASSET="${{ steps.build.outputs.zip_name }}" + { + echo "" + echo "Assets:" + echo "- ${ZIP_ASSET}" + + echo "- SHA256SUMS.txt" + } >> RELEASE_NOTES.md + - name: Publish JSON report to job summary (JSON-only, no file) + run: | + set -euo pipefail + + OWNER="${{ github.repository_owner }}" + REPO="${{ github.event.repository.name }}" + VERSION="${{ needs.guard.outputs.version }}" + BRANCH="${{ needs.guard.outputs.version_branch }}" + TAG="${{ needs.guard.outputs.version }}" + TODAY_UTC="${{ needs.guard.outputs.today_utc }}" + ZIP_NAME="${{ steps.build.outputs.zip_name }}" + ZIP_SHA256="${{ steps.sha.outputs.sha256 }}" + EXT_ROOT="${{ steps.build.outputs.root }}" + MANIFEST_PATH="${{ steps.build.outputs.manifest }}" + EXT_TYPE="${{ steps.build.outputs.ext_type }}" + + DOWNLOAD_URL="https://github.com/${OWNER}/${REPO}/releases/download/${VERSION}/${ZIP_NAME}" + + echo "### Release report (JSON)" >> "${GITHUB_STEP_SUMMARY}" + echo "```json" >> "${GITHUB_STEP_SUMMARY}" + + jq -n \ + --arg repository "${{ github.repository }}" \ + --arg version "${VERSION}" \ + --arg branch "${BRANCH}" \ + --arg tag "${TAG}" \ + --arg today_utc "${TODAY_UTC}" \ + --arg commit_sha "${{ github.sha }}" \ + --arg ext_type "${EXT_TYPE}" \ + --arg ext_root "${EXT_ROOT}" \ + --arg manifest_path "${MANIFEST_PATH}" \ + --arg zip_name "${ZIP_NAME}" \ + --arg zip_sha256 "${ZIP_SHA256}" \ + --arg download_url "${DOWNLOAD_URL}" \ + '{ + repository: $repository, + version: $version, + branch: $branch, + tag: $tag, + prerelease: true, + today_utc: $today_utc, + commit_sha: $commit_sha, + joomla: { + extension_type: $ext_type, + extension_root: $ext_root, + manifest_path: $manifest_path + }, + assets: { + zip: { + name: $zip_name, + sha256: $zip_sha256, + download_url: $download_url + }, + + sha256sums: "dist/SHA256SUMS.txt", + release_notes: "RELEASE_NOTES.md" + } + }' >> "${GITHUB_STEP_SUMMARY}" + + echo "```" >> "${GITHUB_STEP_SUMMARY}" + squash_to_main: + name: 04 Optional squash merge version branch to main (PR-based) + runs-on: ubuntu-latest + needs: + - guard + - build_update_and_release + + if: ${{ github.event.inputs.squash_to_main == true }} + + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout main + uses: actions/checkout@v4 + with: + ref: main + 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: Fetch branches + run: | + set -euo pipefail + git fetch origin --prune + + - name: Create squash PR targeting main + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + VERSION="${{ needs.guard.outputs.version }}" + MERGE_BRANCH="merge/${VERSION}" + SOURCE_REF="origin/${{ needs.guard.outputs.version_branch }}" + + git checkout main + git pull --ff-only origin main + + if git show-ref --verify --quiet "refs/heads/${MERGE_BRANCH}"; then + git branch -D "${MERGE_BRANCH}" + fi + + git checkout -b "${MERGE_BRANCH}" main + git merge --squash "${SOURCE_REF}" + + if git diff --cached --quiet; then + echo "No changes to merge from ${SOURCE_REF}." + exit 0 + fi + + git commit -m "chore(release): squash ${VERSION} into main" + git push -u origin "${MERGE_BRANCH}" + + gh pr create \ + --base main \ + --head "${MERGE_BRANCH}" \ + --title "Release ${VERSION} (squash)" \ + --body "Squash merge prepared by release pipeline." \ + || echo "PR may already exist for ${MERGE_BRANCH}." + + - name: Optional delete version branch after PR creation + run: | + set -euo pipefail + if [ "${{ github.event.inputs.delete_version_branch }}" = "true" ]; then + git push origin --delete "${{ needs.guard.outputs.version_branch }}" || true + else + echo "Version branch retention enabled. Skipping deletion." + fi