From 8806a7fac14ff74f16488a0993339aa6f569b693 Mon Sep 17 00:00:00 2001 From: Jonathan Miller <230051081+jmiller-moko@users.noreply.github.com> Date: Tue, 23 Dec 2025 22:47:37 -0600 Subject: [PATCH] Update release_pipeline.yml --- .github/workflows/release_pipeline.yml | 288 ++++++++++++++----------- 1 file changed, 165 insertions(+), 123 deletions(-) diff --git a/.github/workflows/release_pipeline.yml b/.github/workflows/release_pipeline.yml index 1a999ef..59bf882 100644 --- a/.github/workflows/release_pipeline.yml +++ b/.github/workflows/release_pipeline.yml @@ -23,31 +23,14 @@ # INGROUP: MokoStandards.Release # REPO: https://github.com/mokoconsulting-tech/MokoStandards # PATH: /.github/workflows/release_pipeline.yml -# VERSION: 01.00.02 -# BRIEF: Enterprise release pipeline enforcing dev to rc to version to main. Promotes and deletes source branches, builds Joomla artifacts from version branches, publishes prereleases, and optionally creates a squash PR to main. -# NOTE: Key controls: strict branch gating, mandatory branch deletion after promotion, least privilege permissions, key-only SFTP, ZIP-only distribution, no checksum generation. +# VERSION: 01.01.00 +# BRIEF: Enterprise release pipeline enforcing dev to rc to version to main. Creates prerelease when rc is created. Creates full release when version is created, and pushes version to main while retaining the version branch. +# NOTE: Key controls: strict branch gating, mandatory source branch deletion after promotion, least privilege permissions, key-only SFTP, ZIP-only distribution, overwrite enabled, no checksum generation. # name: Release Pipeline (dev to rc to version to main) on: workflow_dispatch: - inputs: - squash_to_main: - description: "Create a PR that squashes version/ into main" - required: true - default: false - type: boolean - delete_version_branch: - description: "Delete version/ after PR creation" - required: true - default: false - type: boolean - # SFTP upload is mandatory for all releases - sftp_upload: - description: "Upload ZIP to SFTP (key-only, mandatory, overwrite enabled)" - required: true - default: true - type: boolean release: types: - created @@ -75,10 +58,10 @@ jobs: source_branch: ${{ steps.meta.outputs.source_branch }} source_prefix: ${{ steps.meta.outputs.source_prefix }} target_branch: ${{ steps.meta.outputs.target_branch }} - version_branch: ${{ steps.meta.outputs.version_branch }} + promoted_branch: ${{ steps.meta.outputs.promoted_branch }} today_utc: ${{ steps.meta.outputs.today_utc }} channel: ${{ steps.meta.outputs.channel }} - release_ready: ${{ steps.meta.outputs.release_ready }} + release_mode: ${{ steps.meta.outputs.release_mode }} steps: - name: Validate trigger and extract metadata @@ -93,9 +76,9 @@ jobs: SOURCE_BRANCH="" SOURCE_PREFIX="" TARGET_BRANCH="" - VERSION_BRANCH="" + PROMOTED_BRANCH="" CHANNEL="" - RELEASE_READY="false" + RELEASE_MODE="none" if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then echo "${REF_NAME}" | grep -E '^(dev|rc)/[0-9]+[.][0-9]+[.][0-9]+$' @@ -105,14 +88,17 @@ jobs: VERSION="${REF_NAME#*/}" if [ "${SOURCE_PREFIX}" = "dev" ]; then + # dev -> rc, then prerelease TARGET_BRANCH="rc/${VERSION}" + PROMOTED_BRANCH="rc/${VERSION}" CHANNEL="rc" - RELEASE_READY="false" + RELEASE_MODE="prerelease" else + # rc -> version, then full release + push to main TARGET_BRANCH="version/${VERSION}" - VERSION_BRANCH="version/${VERSION}" - CHANNEL="rc" - RELEASE_READY="true" + PROMOTED_BRANCH="version/${VERSION}" + CHANNEL="stable" + RELEASE_MODE="stable" fi elif [ "${EVENT_NAME}" = "release" ]; then @@ -122,8 +108,10 @@ jobs: if [ "${{ github.event.release.prerelease }}" = "true" ]; then CHANNEL="rc" + RELEASE_MODE="prerelease" else CHANNEL="stable" + RELEASE_MODE="stable" fi else @@ -137,10 +125,10 @@ jobs: echo "source_branch=${SOURCE_BRANCH}" >> "${GITHUB_OUTPUT}" echo "source_prefix=${SOURCE_PREFIX}" >> "${GITHUB_OUTPUT}" echo "target_branch=${TARGET_BRANCH}" >> "${GITHUB_OUTPUT}" - echo "version_branch=${VERSION_BRANCH}" >> "${GITHUB_OUTPUT}" + echo "promoted_branch=${PROMOTED_BRANCH}" >> "${GITHUB_OUTPUT}" echo "today_utc=${TODAY_UTC}" >> "${GITHUB_OUTPUT}" echo "channel=${CHANNEL}" >> "${GITHUB_OUTPUT}" - echo "release_ready=${RELEASE_READY}" >> "${GITHUB_OUTPUT}" + echo "release_mode=${RELEASE_MODE}" >> "${GITHUB_OUTPUT}" { echo "### Guard report" @@ -151,9 +139,9 @@ jobs: echo " \"version\": \"${VERSION}\"," echo " \"source_branch\": \"${SOURCE_BRANCH}\"," echo " \"target_branch\": \"${TARGET_BRANCH}\"," - echo " \"version_branch\": \"${VERSION_BRANCH}\"," + echo " \"promoted_branch\": \"${PROMOTED_BRANCH}\"," echo " \"channel\": \"${CHANNEL}\"," - echo " \"release_ready\": ${RELEASE_READY}," + echo " \"release_mode\": \"${RELEASE_MODE}\"," echo " \"today_utc\": \"${TODAY_UTC}\"" echo "}" echo "```" @@ -228,32 +216,23 @@ jobs: echo "```" } >> "${GITHUB_STEP_SUMMARY}" - - name: Stop after dev to rc promotion - if: ${{ needs.guard.outputs.release_ready != 'true' }} - run: | - set -euo pipefail - { - echo "### Next step" - echo "Run this workflow from rc/${{ needs.guard.outputs.version }} to promote rc to version and build." - } >> "${GITHUB_STEP_SUMMARY}" - normalize_dates: - name: 02 Normalize dates on version branch + name: 02 Normalize dates on promoted branch runs-on: ubuntu-latest needs: - guard - promote_branch - if: ${{ github.event_name == 'workflow_dispatch' && needs.guard.outputs.release_ready == 'true' }} + if: ${{ github.event_name == 'workflow_dispatch' }} permissions: contents: write steps: - - name: Checkout version branch + - name: Checkout promoted branch uses: actions/checkout@v4 with: - ref: ${{ needs.guard.outputs.version_branch }} + ref: ${{ needs.guard.outputs.promoted_branch }} fetch-depth: 0 - name: Configure Git identity @@ -302,16 +281,16 @@ jobs: git add -A git commit -m "chore(release): normalize dates for ${{ needs.guard.outputs.version }}" - git push origin "HEAD:${{ needs.guard.outputs.version_branch }}" + git push origin "HEAD:${{ needs.guard.outputs.promoted_branch }}" - build_and_prerelease: - name: 03 Build Joomla ZIP and publish prerelease + build_and_release: + name: 03 Build ZIP, upload to SFTP, create GitHub release runs-on: ubuntu-latest needs: - guard - normalize_dates - if: ${{ github.event_name == 'workflow_dispatch' && needs.guard.outputs.release_ready == 'true' }} + if: ${{ github.event_name == 'workflow_dispatch' }} permissions: contents: write @@ -319,10 +298,10 @@ jobs: attestations: write steps: - - name: Checkout version branch + - name: Checkout promoted branch uses: actions/checkout@v4 with: - ref: ${{ needs.guard.outputs.version_branch }} + ref: ${{ needs.guard.outputs.promoted_branch }} fetch-depth: 0 - name: Configure Git identity @@ -339,6 +318,7 @@ jobs: VERSION="${{ needs.guard.outputs.version }}" REPO="${{ github.event.repository.name }}" + CHANNEL="${{ needs.guard.outputs.channel }}" test -d src || (echo "ERROR: src directory missing" && exit 1) @@ -369,7 +349,7 @@ jobs: exit 1 fi - EXT_TYPE="$(grep -o 'type="[^"]*"' "${MANIFEST}" | head -n 1 | cut -d '"' -f2)" + EXT_TYPE="$(grep -o 'type=\"[^\"]*\"' "${MANIFEST}" | head -n 1 | cut -d '\"' -f2)" if [ -z "${EXT_TYPE}" ]; then EXT_TYPE="unknown" fi @@ -379,7 +359,7 @@ jobs: ROOT="${MANIFEST_DIR}" fi - ZIP="${REPO}-${VERSION}-${{ needs.guard.outputs.channel }}.zip" + ZIP="${REPO}-${VERSION}-${CHANNEL}.zip" (cd "${ROOT}" && zip -r -X "../dist/${ZIP}" . \ -x "**/.git/**" \ @@ -392,9 +372,7 @@ jobs: echo "manifest=${MANIFEST}" >> "${GITHUB_OUTPUT}" echo "ext_type=${EXT_TYPE}" >> "${GITHUB_OUTPUT}" - - name: Always upload ZIP to SFTP (key-only, overwrite) - # Mandatory enterprise control: always upload - if: ${{ always() }} + - name: Upload ZIP to SFTP (key-only, overwrite) env: FTP_HOST: ${{ secrets.FTP_HOST }} FTP_USER: ${{ secrets.FTP_USER }} @@ -444,31 +422,155 @@ jobs: lftp -e "set sftp:auto-confirm yes; open -u '${FTP_USER}', sftp://${HOSTPORT}; mkdir -p '${REMOTE_PATH}'; cd '${REMOTE_PATH}'; put -E 'dist/${ZIP}'; ls; bye" - - name: Create or update tag and prerelease + - name: Create Git tag for release + id: tag + run: | + set -euo pipefail + + VERSION="${{ needs.guard.outputs.version }}" + MODE="${{ needs.guard.outputs.release_mode }}" + + if [ "${MODE}" = "prerelease" ]; then + TAG="v${VERSION}-rc" + else + TAG="v${VERSION}" + fi + + git fetch --tags + if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then + echo "Tag ${TAG} already exists" >> "${GITHUB_STEP_SUMMARY}" + else + git tag -a "${TAG}" -m "${MODE} ${VERSION}" + git push origin "refs/tags/${TAG}" + fi + + echo "tag=${TAG}" >> "${GITHUB_OUTPUT}" + + - name: Generate release notes from CHANGELOG.md + id: notes + run: | + set -euo pipefail + + VERSION="${{ needs.guard.outputs.version }}" + ZIP_ASSET="${{ steps.build.outputs.zip_name }}" + + 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}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + + { + echo "" + echo "Assets:" + echo "- ${ZIP_ASSET}" + } >> RELEASE_NOTES.md + + - name: Create GitHub release and attach ZIP uses: softprops/action-gh-release@v2 with: - tag_name: ${{ needs.guard.outputs.version }} - name: Prerelease ${{ needs.guard.outputs.version }} - prerelease: true - body: "Automated prerelease from version branch" + tag_name: ${{ steps.tag.outputs.tag }} + name: ${{ needs.guard.outputs.release_mode }} ${{ needs.guard.outputs.version }} + prerelease: ${{ needs.guard.outputs.release_mode == 'prerelease' }} + body_path: RELEASE_NOTES.md files: | dist/*.zip + - name: Attest build provenance + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + dist/*.zip + - name: Publish JSON report to job summary run: | set -euo pipefail REPO_FULL="${{ github.repository }}" VERSION="${{ needs.guard.outputs.version }}" - BRANCH="${{ needs.guard.outputs.version_branch }}" + BRANCH="${{ needs.guard.outputs.promoted_branch }}" + TAG="${{ steps.tag.outputs.tag }}" ZIP_NAME="${{ steps.build.outputs.zip_name }}" CHANNEL="${{ needs.guard.outputs.channel }}" + MODE="${{ needs.guard.outputs.release_mode }}" echo "### Release report (JSON)" >> "${GITHUB_STEP_SUMMARY}" echo "```json" >> "${GITHUB_STEP_SUMMARY}" - echo "{\"repository\":\"${REPO_FULL}\",\"version\":\"${VERSION}\",\"branch\":\"${BRANCH}\",\"channel\":\"${CHANNEL}\",\"zip\":\"${ZIP_NAME}\",\"sha\":null}" >> "${GITHUB_STEP_SUMMARY}" + echo "{\"repository\":\"${REPO_FULL}\",\"version\":\"${VERSION}\",\"branch\":\"${BRANCH}\",\"tag\":\"${TAG}\",\"mode\":\"${MODE}\",\"channel\":\"${CHANNEL}\",\"zip\":\"${ZIP_NAME}\",\"sha\":null}" >> "${GITHUB_STEP_SUMMARY}" echo "```" >> "${GITHUB_STEP_SUMMARY}" + push_version_to_main: + name: 04 Push version to main (stable only, keep version branch) + runs-on: ubuntu-latest + needs: + - guard + - build_and_release + + if: ${{ github.event_name == 'workflow_dispatch' && needs.guard.outputs.release_mode == 'stable' }} + + 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: Create PR from version branch to main + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + VERSION="${{ needs.guard.outputs.version }}" + HEAD="${{ needs.guard.outputs.promoted_branch }}" + TITLE="Release ${VERSION} to main" + + gh pr create \ + --base main \ + --head "${HEAD}" \ + --title "${TITLE}" \ + --body "Automated PR created by release pipeline. Version branch is retained by policy." \ + || true + + - name: Attempt to merge PR (best-effort) + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + VERSION="${{ needs.guard.outputs.version }}" + HEAD="${{ needs.guard.outputs.promoted_branch }}" + + PR_NUMBER="$(gh pr list --head "${HEAD}" --base main --json number --jq '.[0].number' || true)" + + if [ -z "${PR_NUMBER}" ] || [ "${PR_NUMBER}" = "null" ]; then + echo "ERROR: PR not found for head ${HEAD}" >> "${GITHUB_STEP_SUMMARY}" + exit 1 + fi + + # Enterprise-safe default: merge commit, no branch deletion. + gh pr merge "${PR_NUMBER}" --merge --delete-branch=false \ + || echo "PR merge blocked by branch protection or policy" >> "${GITHUB_STEP_SUMMARY}" + + { + echo "### Main branch promotion" + echo "```json" + echo "{\"version\":\"${VERSION}\",\"head\":\"${HEAD}\",\"pr\":${PR_NUMBER}}" + echo "```" + } >> "${GITHUB_STEP_SUMMARY}" + release_event_report: name: 99 Release event report (GitHub UI created release) runs-on: ubuntu-latest @@ -497,63 +599,3 @@ jobs: echo "```json" >> "${GITHUB_STEP_SUMMARY}" echo "{\"version\":\"${VERSION}\",\"tag\":\"${TAG}\",\"prerelease\":${{ github.event.release.prerelease }}}" >> "${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_and_prerelease - - if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.squash_to_main == true && needs.guard.outputs.release_ready == '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: 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 fetch origin --prune - git checkout main - git pull --ff-only origin main - - git checkout -B "${MERGE_BRANCH}" main - git merge --squash "${SOURCE_REF}" - - if git diff --cached --quiet; then - echo "No changes to merge" >> "${GITHUB_STEP_SUMMARY}" - 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." || true - - - name: Optional delete version branch after PR creation - if: ${{ github.event.inputs.delete_version_branch == true }} - run: | - set -euo pipefail - git push origin --delete "${{ needs.guard.outputs.version_branch }}" || true