diff --git a/.github/workflows/release_from_version.yml b/.github/workflows/release_from_version.yml index 3a24ecd..5e397ac 100644 --- a/.github/workflows/release_from_version.yml +++ b/.github/workflows/release_from_version.yml @@ -43,473 +43,7 @@ on: 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 - -concurrency: - group: release-from-dev-${{ github.ref_name }} - cancel-in-progress: false - -permissions: - contents: read - -defaults: - run: - shell: bash - -jobs: - guard: - name: 00 Guard and derive release metadata - runs-on: ubuntu-latest - - outputs: - version: ${{ steps.extract.outputs.version }} - dev_branch: ${{ steps.extract.outputs.dev_branch }} - version_branch: ${{ steps.extract.outputs.version_branch }} - target_branch: ${{ steps.extract.outputs.target_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}" - - # Enterprise gate: only allow manual runs from dev/.. - 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)" - - PROMOTE_INPUT="${{ github.event.inputs.promote_to_version }}" - if [ "${PROMOTE_INPUT}" = "true" ]; then - TARGET_BRANCH="${VERSION_BRANCH}" - else - TARGET_BRANCH="${DEV_BRANCH}" - fi - - echo "version=${VERSION}" >> "${GITHUB_OUTPUT}" - echo "dev_branch=${DEV_BRANCH}" >> "${GITHUB_OUTPUT}" - echo "version_branch=${VERSION_BRANCH}" >> "${GITHUB_OUTPUT}" - echo "target_branch=${TARGET_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 - - if: ${{ github.event.inputs.promote_to_version == 'true' && startsWith(github.ref_name, 'dev/') }} - - 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 branch 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 - 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 release working branch - runs-on: ubuntu-latest - needs: - - guard - - promote_branch - - # Control: if promotion is requested, require promote_branch success. - if: ${{ (github.event.inputs.promote_to_version != 'true') || (needs.promote_branch.result == 'success') }} - - permissions: - contents: write - - steps: - - name: Checkout release working branch - uses: actions/checkout@v4 - with: - ref: ${{ needs.guard.outputs.target_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.target_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 - - steps: - - name: Checkout release working branch - uses: actions/checkout@v4 - with: - ref: ${{ needs.guard.outputs.target_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 the Joomla extension root inside src. - # Rules: - # - If src contains a single top-level directory, prefer that directory as the extension root. - # - Otherwise, use src as the extension root. - # - The extension root must contain a manifest XML at its root. - ROOT="src" - TOP_COUNT="$(find src -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" - if [ "${TOP_COUNT}" = "1" ]; then - ONLY_DIR="$(find src -mindepth 1 -maxdepth 1 -type d -print -quit)" - ROOT="${ONLY_DIR}" - fi - - echo "Candidate extension root: ${ROOT}" - - # Locate the manifest XML at the root of ROOT. - # Priority: - # - templateDetails.xml for templates - # - otherwise, any *.xml containing a Joomla root element - MANIFEST="" - if [ -f "${ROOT}/templateDetails.xml" ]; then - MANIFEST="${ROOT}/templateDetails.xml" - 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 the root of ${ROOT}." - echo "Expected templateDetails.xml or a *.xml file containing an element." - exit 1 - fi - - echo "Manifest: ${MANIFEST}" - - # Derive extension type for logging and compliance checks. - 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}" - - # Basic Joomla compliance checks by 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: ZIP root is the extension root. - # Zip the CONTENTS of ROOT, not ROOT itself. - (cd "${ROOT}" && zip -r -X "../dist/${ZIP}" . \ - -x "**/.git/**" \ - -x "**/.github/**" \ - -x "**/.DS_Store" \ - -x "**/__MACOSX/**") - - 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 updates.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" "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 update.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 - 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 - - # 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 "updates.xml updated." - - - name: Commit updates.xml changes (and any related date deltas) - run: | - set -euo pipefail - - if git diff --quiet; then - echo "No updates.xml changes detected. No commit required." - 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.target_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 - 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 "- updates.xml" - echo "- SHA256SUMS.txt" - } >> RELEASE_NOTES.md - - - 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: - token: ${{ github.token }} - 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 + name: 04 Optional squash merge version branch to main (PR-based) runs-on: ubuntu-latest needs: - guard @@ -519,6 +53,7 @@ jobs: permissions: contents: write + pull-requests: write steps: - name: Checkout main @@ -539,17 +74,28 @@ jobs: set -euo pipefail git fetch origin --prune - - name: Squash merge version branch into main + - name: Create squash-merge branch targeting main + env: + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail VERSION="${{ needs.guard.outputs.version }}" VBRANCH="origin/${{ needs.guard.outputs.version_branch }}" + MERGE_BRANCH="merge/${VERSION}" - # Governance control: if main is protected from direct pushes, this will fail by design. - # Enforce PR-based merge in that scenario. + # Create a dedicated merge branch off main, squash version branch into it, then open a PR. + # This is compatible with enterprise branch protections on main. git checkout main + git pull --ff-only origin main + + # Ensure merge branch is fresh + 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 "${VBRANCH}" if git diff --cached --quiet; then @@ -558,13 +104,21 @@ jobs: fi git commit -m "chore(release): squash ${VERSION} into main" - git push origin "HEAD:main" + git push -u origin "${MERGE_BRANCH}" - - name: Optional delete version branch after squash + # Create PR (idempotent): if it already exists, do not fail. + gh pr create \ + --base main \ + --head "${MERGE_BRANCH}" \ + --title "Release ${VERSION} (squash)" \ + --body "Squash merge of version/${VERSION} into main. Generated 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 }}" + git push origin --delete "${{ needs.guard.outputs.version_branch }}" || true else echo "Version branch retention enabled. Skipping deletion." fi