diff --git a/.github/workflows/version_branch.yml b/.github/workflows/version_branch.yml
index 7c50702..63593cc 100644
--- a/.github/workflows/version_branch.yml
+++ b/.github/workflows/version_branch.yml
@@ -15,597 +15,815 @@
# 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: MokoStandards.Joomla
-# INGROUP: GitHub.Versioning.Branching
+# DEFGROUP: GitHub.Workflow
+# INGROUP: MokoStandards.Release
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
-# PATH: /.github/workflows/version_branch.yml
+# PATH: /.github/workflows/release_pipeline.yml
# VERSION: 03.05.00
-# BRIEF: Create a dev/ branch and align versions across governed files
-# NOTE: Enterprise gates: required artifacts, namespace defense, deterministic reporting, control character guard
-
-name: Create version branch and bump versions
+# 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 promotes to main while retaining the version branch.
+# NOTE: Controls: strict branch gating, mandatory source branch deletion after promotion, key-only SFTP with verbose logs, ZIP-only distribution with overwrite, no checksum generation.
+#
+name: Release Pipeline (dev > rc > version > main)
on:
workflow_dispatch:
inputs:
- new_version:
- description: "New version in format NN.NN.NN (example 03.01.00)"
+ release_classification:
+ description: "Manual override for classification. auto follows branch policy; rc forces prerelease behavior; stable forces full release behavior."
required: true
- 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"
+ default: auto
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"
+ - auto
+ - rc
+ - stable
+ release:
+ types:
+ - created
+ - prereleased
+ - published
concurrency:
- group: ${{ github.workflow }}-${{ github.repository }}-${{ github.event.inputs.new_version }}
+ group: release-pipeline-${{ github.ref_name }}
cancel-in-progress: false
-permissions:
- contents: write
-
defaults:
run:
shell: bash
+permissions:
+ contents: read
+
jobs:
- version-bump:
- name: Version branch and bump
+ guard:
+ name: 00 Guard and derive promotion metadata
runs-on: ubuntu-latest
- 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: /tmp/version-bump-report.json
+ outputs:
+ version: ${{ steps.meta.outputs.version }}
+ source_branch: ${{ steps.meta.outputs.source_branch }}
+ source_prefix: ${{ steps.meta.outputs.source_prefix }}
+ target_branch: ${{ steps.meta.outputs.target_branch }}
+ promoted_branch: ${{ steps.meta.outputs.promoted_branch }}
+ today_utc: ${{ steps.meta.outputs.today_utc }}
+ channel: ${{ steps.meta.outputs.channel }}
+ release_mode: ${{ steps.meta.outputs.release_mode }}
+ override: ${{ steps.meta.outputs.override }}
steps:
- - name: Checkout repository
+ - name: Validate trigger and extract metadata
+ id: meta
+ env:
+ RELEASE_CLASSIFICATION: ${{ github.event.inputs.release_classification }}
+ RELEASE_PRERELEASE: ${{ github.event.release.prerelease }}
+ run: |
+ set -euo pipefail
+
+ EVENT_NAME="${GITHUB_EVENT_NAME}"
+ REF_NAME="${GITHUB_REF_NAME}"
+
+ VERSION=""
+ SOURCE_BRANCH=""
+ SOURCE_PREFIX=""
+ TARGET_BRANCH=""
+ PROMOTED_BRANCH=""
+ CHANNEL=""
+ RELEASE_MODE="none"
+
+ OVERRIDE="${RELEASE_CLASSIFICATION:-auto}"
+ if [ -z "${OVERRIDE}" ]; then
+ OVERRIDE="auto"
+ fi
+
+ if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then
+ echo "${REF_NAME}" | grep -E '^(dev|rc)/[0-9]+[.][0-9]+[.][0-9]+$'
+
+ SOURCE_BRANCH="${REF_NAME}"
+ SOURCE_PREFIX="${REF_NAME%%/*}"
+ VERSION="${REF_NAME#*/}"
+
+ if [ "${SOURCE_PREFIX}" = "dev" ]; then
+ # dev -> rc
+ TARGET_BRANCH="rc/${VERSION}"
+ PROMOTED_BRANCH="rc/${VERSION}"
+ CHANNEL="rc"
+ RELEASE_MODE="prerelease"
+ else
+ # rc -> version
+ TARGET_BRANCH="version/${VERSION}"
+ PROMOTED_BRANCH="version/${VERSION}"
+ CHANNEL="stable"
+ RELEASE_MODE="stable"
+ fi
+
+ # Manual override: classification only. Promotion path does not change.
+ if [ "${OVERRIDE}" = "rc" ]; then
+ CHANNEL="rc"
+ RELEASE_MODE="prerelease"
+ elif [ "${OVERRIDE}" = "stable" ]; then
+ CHANNEL="stable"
+ RELEASE_MODE="stable"
+ else
+ OVERRIDE="auto"
+ fi
+
+ elif [ "${EVENT_NAME}" = "release" ]; then
+ TAG_NAME="${REF_NAME}"
+ VERSION="${TAG_NAME#v}"
+ echo "${VERSION}" | grep -E '^[0-9]+[.][0-9]+[.][0-9]+$'
+
+ if [ "${RELEASE_PRERELEASE:-false}" = "true" ]; then
+ CHANNEL="rc"
+ RELEASE_MODE="prerelease"
+ else
+ CHANNEL="stable"
+ RELEASE_MODE="stable"
+ fi
+
+ OVERRIDE="auto"
+
+ else
+ echo "ERROR: Unsupported trigger ${EVENT_NAME}" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ fi
+
+ TODAY_UTC="$(date -u +%Y-%m-%d)"
+
+ echo "version=${VERSION}" >> "${GITHUB_OUTPUT}"
+ echo "source_branch=${SOURCE_BRANCH}" >> "${GITHUB_OUTPUT}"
+ echo "source_prefix=${SOURCE_PREFIX}" >> "${GITHUB_OUTPUT}"
+ echo "target_branch=${TARGET_BRANCH}" >> "${GITHUB_OUTPUT}"
+ echo "promoted_branch=${PROMOTED_BRANCH}" >> "${GITHUB_OUTPUT}"
+ echo "today_utc=${TODAY_UTC}" >> "${GITHUB_OUTPUT}"
+ echo "channel=${CHANNEL}" >> "${GITHUB_OUTPUT}"
+ echo "release_mode=${RELEASE_MODE}" >> "${GITHUB_OUTPUT}"
+ echo "override=${OVERRIDE}" >> "${GITHUB_OUTPUT}"
+
+ {
+ echo "### Guard report"
+ echo "```json"
+ echo "{"
+ echo " \"event\": \"${EVENT_NAME}\","
+ echo " \"ref\": \"${REF_NAME}\","
+ echo " \"version\": \"${VERSION}\","
+ echo " \"source_branch\": \"${SOURCE_BRANCH}\","
+ echo " \"target_branch\": \"${TARGET_BRANCH}\","
+ echo " \"promoted_branch\": \"${PROMOTED_BRANCH}\","
+ echo " \"channel\": \"${CHANNEL}\","
+ echo " \"release_mode\": \"${RELEASE_MODE}\","
+ echo " \"override\": \"${OVERRIDE}\","
+ echo " \"today_utc\": \"${TODAY_UTC}\""
+ echo "}"
+ echo "```"
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ promote_branch:
+ name: 01 Promote branch and delete source
+ runs-on: ubuntu-latest
+ needs: guard
+
+ if: ${{ github.event_name == 'workflow_dispatch' }}
+
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout source branch
uses: actions/checkout@v4
with:
+ ref: ${{ needs.guard.outputs.source_branch }}
fetch-depth: 0
- ref: ${{ github.ref_name }}
- - name: Init CI helpers
+ - name: Configure Git identity
run: |
- set -Eeuo pipefail
- : > "$ERROR_LOG"
+ 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}"
- 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
+ - name: Enforce promotion preconditions
run: |
- source "$CI_HELPERS"
- moko_init "Validate inputs and policy locks"
+ set -euo pipefail
- VERSION_TEXT="$(moko_trim "${VERSION_TEXT}")"
+ SRC="${{ needs.guard.outputs.source_branch }}"
+ DST="${{ needs.guard.outputs.target_branch }}"
- 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}"
+ git fetch origin --prune
- [[ -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
+ if [ -z "${SRC}" ] || [ -z "${DST}" ]; then
+ echo "ERROR: guard did not emit SRC or DST" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
fi
- 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
+ if ! git show-ref --verify --quiet "refs/remotes/origin/${SRC}"; then
+ echo "ERROR: origin/${SRC} not found" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
fi
- 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
+ if git show-ref --verify --quiet "refs/remotes/origin/${DST}"; then
+ echo "ERROR: origin/${DST} already exists" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
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 "VERSION_TEXT=${VERSION_TEXT}" >> "$GITHUB_ENV"
-
- - name: Sanity check workflow file (no literal tabs or control chars)
+ - name: Promote and delete source
run: |
- source "$CI_HELPERS"
- moko_init "Sanity check workflow file"
+ set -euo pipefail
- python3 - <<'PY'
- from pathlib import Path
+ SRC="${{ needs.guard.outputs.source_branch }}"
+ DST="${{ needs.guard.outputs.target_branch }}"
- target = Path('.github/workflows/version_branch.yml')
- if not target.exists():
- raise SystemExit('[FATAL] Missing workflow file: .github/workflows/version_branch.yml')
+ git checkout -B "${DST}" "origin/${SRC}"
+ git push origin "${DST}"
+ git push origin --delete "${SRC}"
- data = target.read_bytes()
+ {
+ echo "### Promotion report"
+ echo "```json"
+ echo "{"
+ echo " \"promoted\": \"${SRC} -> ${DST}\","
+ echo " \"deleted\": \"${SRC}\""
+ echo "}"
+ echo "```"
+ } >> "${GITHUB_STEP_SUMMARY}"
- # Disallow literal tab (0x09) and other ASCII control characters except LF (0x0A) and CR (0x0D).
- # Report line numbers without printing the raw characters.
+ normalize_dates:
+ name: 02 Normalize dates on promoted branch
+ runs-on: ubuntu-latest
+ needs:
+ - guard
+ - promote_branch
- def byte_to_line(blob: bytes, idx: int) -> int:
- return blob[:idx].count(b'\n') + 1
+ if: ${{ github.event_name == 'workflow_dispatch' }}
- bad = []
- for i, b in enumerate(data):
- if b == 0x09:
- bad.append(('TAB', i, b))
- elif b < 0x20 and b not in (0x0A, 0x0D):
- bad.append(('CTRL', i, b))
+ permissions:
+ contents: write
- if bad:
- print('[ERROR] Disallowed characters detected in workflow file:')
- for kind, off, val in bad[:200]:
- line_no = byte_to_line(data, off)
- if kind == 'TAB':
- print(f' line {line_no}: TAB_PRESENT')
- else:
- print(f' line {line_no}: CTRL_0x{val:02X}_PRESENT')
- raise SystemExit(2)
+ steps:
+ - name: Checkout promoted branch
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.guard.outputs.promoted_branch }}
+ fetch-depth: 0
- print('[INFO] Sanity check passed')
- PY
-
- - name: Enterprise policy gate
+ - name: Configure Git identity
run: |
- source "$CI_HELPERS"
- moko_init "Enterprise policy gate"
+ 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}"
- required=(
- "LICENSE.md"
- "CONTRIBUTING.md"
- "CODE_OF_CONDUCT.md"
- "SECURITY.md"
- "GOVERNANCE.md"
- "CHANGELOG.md"
+ - name: Validate repo 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 -F "## [${VERSION}] " CHANGELOG.md >/dev/null; then
+ echo "ERROR: CHANGELOG.md missing heading for version [${VERSION}]" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ fi
+
+ - name: Normalize dates using repository script only
+ run: |
+ set -euo pipefail
+
+ TODAY="${{ needs.guard.outputs.today_utc }}"
+ VERSION="${{ needs.guard.outputs.version }}"
+
+ {
+ echo "### Date normalization (repo script only)"
+ echo "```json"
+ echo "{\"today_utc\":\"${TODAY}\",\"version\":\"${VERSION}\"}"
+ echo "```"
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ # Enterprise control: repo-provided date normalization is mandatory.
+ CANDIDATES=(
+ "scripts/update_dates.sh"
+ "scripts/release/update_dates.sh"
+ "scripts/release/update_dates"
)
- 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
+ SCRIPT=""
+ for c in "${CANDIDATES[@]}"; do
+ if [ -f "${c}" ]; then
+ SCRIPT="${c}"
+ break
fi
done
- if [[ "${missing}" -ne 0 ]]; then
- echo "[FATAL] Policy gate failed. Add missing governance artifacts before versioning." >&2
- exit 2
+ if [ -z "${SCRIPT}" ]; then
+ FOUND="$(find . -maxdepth 3 -type f \( -name 'update_dates.sh' -o -name 'update-dates.sh' \) 2>/dev/null | head -n 5 || true)"
+ {
+ echo "ERROR: Date normalization script not found in approved locations."
+ echo "Approved locations:"
+ printf '%s\n' "${CANDIDATES[@]}"
+ echo "Discovered candidates (first 5):"
+ echo "${FOUND:-}"
+ echo "Required action: add scripts/update_dates.sh (or scripts/release/update_dates.sh) to the repo."
+ } >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
fi
- echo "[INFO] Policy gate passed"
+ echo "Using date script: ${SCRIPT}" >> "${GITHUB_STEP_SUMMARY}"
- - name: Branch namespace collision defense
+ chmod +x "${SCRIPT}"
+ "${SCRIPT}" "${TODAY}" "${VERSION}" >> "${GITHUB_STEP_SUMMARY}"
+
+ # Diffstat for audit visibility
+ {
+ echo "### Date normalization diffstat"
+ echo "```"
+ git diff --stat || true
+ echo "```"
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ 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' }}
+
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
+
+ steps:
+ - name: Checkout promoted branch
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ needs.guard.outputs.promoted_branch }}
+ fetch-depth: 0
+
+ - name: Configure Git identity
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"
-
+ 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 version branch (local)
- if: ${{ env.REPORT_ONLY != 'true' }}
+ - name: Validate required secrets and variables
+ env:
+ FTP_HOST: ${{ secrets.FTP_HOST }}
+ FTP_USER: ${{ secrets.FTP_USER }}
+ FTP_KEY: ${{ secrets.FTP_KEY }}
+ FTP_PASSWORD: ${{ secrets.FTP_PASSWORD }}
+ FTP_PATH: ${{ secrets.FTP_PATH }}
+ FTP_PROTOCOL: ${{ secrets.FTP_PROTOCOL }}
+ FTP_PORT: ${{ secrets.FTP_PORT }}
+ FTP_PATH_SUFFIX: ${{ vars.FTP_PATH_SUFFIX }}
+ CHANNEL: ${{ needs.guard.outputs.channel }}
run: |
- source "$CI_HELPERS"
- moko_init "Create version branch (local)"
+ set -euo pipefail
- BRANCH_NAME="${BRANCH_PREFIX}${NEW_VERSION}"
- echo "[INFO] Creating local branch: ${BRANCH_NAME} from origin/${BASE_BRANCH}"
+ missing=()
- git fetch --all --tags --prune
+ [ -n "${FTP_HOST:-}" ] || missing+=("FTP_HOST")
+ [ -n "${FTP_USER:-}" ] || missing+=("FTP_USER")
+ [ -n "${FTP_KEY:-}" ] || missing+=("FTP_KEY")
+ [ -n "${FTP_PATH:-}" ] || missing+=("FTP_PATH")
- 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
+ proto="${FTP_PROTOCOL:-sftp}"
+ if [ "${proto}" != "sftp" ]; then
+ echo "ERROR: FTP_PROTOCOL must be 'sftp'" >> "${GITHUB_STEP_SUMMARY}"
+ missing+=("FTP_PROTOCOL")
fi
- git checkout -B "${BRANCH_NAME}" "origin/${BASE_BRANCH}"
- echo "BRANCH_NAME=${BRANCH_NAME}" >> "$GITHUB_ENV"
-
- - name: Enforce update feed files 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()
-
- stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d")
- root = Path(".").resolve()
-
- # Use escape sequences only. Do not introduce literal tab characters.
- 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 = []
-
- exclude_root = {"update.xml", "updates.xml"}
-
- 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 exclude_root:
- 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,
- }
-
- Path(report_path).write_text(json.dumps(report, indent=2), encoding="utf-8")
-
- print("[INFO] Report written to:", report_path)
- 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: Update CHANGELOG (move Unreleased into version entry)
- run: |
- source "$CI_HELPERS"
- moko_init "Update CHANGELOG"
-
- if [[ ! -f "CHANGELOG.md" ]]; then
- echo "[FATAL] CHANGELOG.md not found." >&2
- exit 2
- fi
-
- python3 - <<'PY'
- import os
- from datetime import datetime, timezone
- from pathlib import Path
-
- new_version = (os.environ.get("NEW_VERSION") or "").strip()
- if not new_version:
- raise SystemExit("[FATAL] NEW_VERSION not set")
-
- stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d")
- p = Path("CHANGELOG.md")
- lines = p.read_text(encoding="utf-8", errors="replace").splitlines(True)
-
- def is_h2(line: str) -> bool:
- return line.lstrip().startswith("## ")
-
- def norm(line: str) -> str:
- return line.strip().lower()
-
- def find_idx(predicate):
- for i, ln in enumerate(lines):
- if predicate(ln):
- return i
- return None
-
- unreleased_idx = find_idx(lambda ln: norm(ln) == "## [unreleased]")
- version_idx = find_idx(lambda ln: ln.lstrip().startswith(f"## [{new_version}]"))
-
- def version_header() -> list[str]:
- return ["\n", f"## [{new_version}] - {stamp}\n", "\n"]
-
- if unreleased_idx is None:
- if version_idx is None:
- insert_at = 0
- for i, ln in enumerate(lines):
- if ln.lstrip().startswith("# "):
- insert_at = i + 1
- while insert_at < len(lines) and lines[insert_at].strip() == "":
- insert_at += 1
- break
- entry = version_header() + ["- No changes recorded.\n", "\n"]
- lines[insert_at:insert_at] = entry
- p.write_text("".join(lines), encoding="utf-8")
- raise SystemExit(0)
-
- u_start = unreleased_idx + 1
- u_end = len(lines)
- for j in range(u_start, len(lines)):
- if is_h2(lines[j]):
- u_end = j
- break
-
- unreleased_body = "".join(lines[u_start:u_end]).strip()
-
- if version_idx is None:
- lines[u_end:u_end] = version_header()
-
- version_idx = find_idx(lambda ln: ln.lstrip().startswith(f"## [{new_version}]"))
- if version_idx is None:
- raise SystemExit("[FATAL] Failed to locate version header after insertion")
-
- if unreleased_body:
- insert_at = version_idx + 1
- while insert_at < len(lines) and lines[insert_at].strip() == "":
- insert_at += 1
-
- moved = ["\n"] + [ln + "\n" for ln in unreleased_body.split("\n") if ln != ""] + ["\n"]
- lines[insert_at:insert_at] = moved
-
- unreleased_idx = find_idx(lambda ln: norm(ln) == "## [unreleased]")
- if unreleased_idx is not None:
- u_start = unreleased_idx + 1
- u_end = len(lines)
- for j in range(u_start, len(lines)):
- if is_h2(lines[j]):
- u_end = j
- break
- lines[u_start:u_end] = ["\n"]
-
- p.write_text("".join(lines), encoding="utf-8")
- 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
-
- MSG="chore(release): bump version to ${NEW_VERSION}"
- if [[ -n "${VERSION_TEXT}" ]]; then
- MSG="${MSG} (${VERSION_TEXT})"
- fi
-
- git commit -m "${MSG}"
-
- - name: Push branch
- if: ${{ env.REPORT_ONLY != 'true' }}
- run: |
- source "$CI_HELPERS"
- moko_init "Push branch"
-
- if [[ -z "${BRANCH_NAME:-}" ]]; then
- echo "[FATAL] BRANCH_NAME not set." >&2
- exit 2
- fi
-
- git push --set-upstream origin "${BRANCH_NAME}"
-
- - name: Publish audit trail
- 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 "- 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"
-
- echo "## Version bump report" >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
-
- if [[ -f "${REPORT_PATH}" ]]; then
- echo "\`\`\`json" >> "$GITHUB_STEP_SUMMARY"
- head -c 12000 "${REPORT_PATH}" >> "$GITHUB_STEP_SUMMARY" || true
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "\`\`\`" >> "$GITHUB_STEP_SUMMARY"
+ # Key format guardrail (OpenSSH private key or PuTTY PPK)
+ first_line="$(printf '%s' "${FTP_KEY:-}" | head -n 1 || true)"
+ if [ -n "${FTP_KEY:-}" ]; then
+ if printf '%s' "${first_line}" | grep -q '^PuTTY-User-Key-File-'; then
+ key_format="ppk"
+ elif printf '%s' "${first_line}" | grep -q '^-----BEGIN '; then
+ key_format="openssh"
+ else
+ key_format="unknown"
+ missing+=("FTP_KEY_FORMAT")
+ fi
else
- echo "Report file not found at: ${REPORT_PATH}" >> "$GITHUB_STEP_SUMMARY"
+ key_format="missing"
fi
- echo "" >> "$GITHUB_STEP_SUMMARY"
- echo "## Error summary" >> "$GITHUB_STEP_SUMMARY"
- echo "" >> "$GITHUB_STEP_SUMMARY"
+ if [ "${#missing[@]}" -gt 0 ]; then
+ {
+ echo "### Configuration guardrails"
+ echo "```json"
+ printf '{"status":"fail","missing":['
+ sep=""
+ for m in "${missing[@]}"; do
+ printf '%s"%s"' "${sep}" "${m}"
+ sep=","
+ done
+ printf '],"key_format":"%s","channel":"%s"}
+' "${key_format}" "${CHANNEL}"
+ echo "```"
+ echo "Required action: set missing repository or organization secrets or variables."
+ } >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ fi
- 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"
+ {
+ echo "### Configuration guardrails"
+ echo "```json"
+ printf '{"status":"ok","key_format":"%s","channel":"%s","ftp_path_suffix":"%s","ftp_port":"%s"}
+' \
+ "${key_format}" "${CHANNEL}" "${FTP_PATH_SUFFIX:-}" "${FTP_PORT:-}"
+ echo "```"
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ # Policy note: FTP_PASSWORD is used only to decrypt an encrypted PPK, never for authentication.
+
+$1 id: build
+ run: |
+ set -euo pipefail
+
+ 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)
+
+ DIST_DIR="${GITHUB_WORKSPACE}/dist"
+
+ mkdir -p "${DIST_DIR}"
+
+ 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
+
+ MANIFEST=""
+ if [ -f "${ROOT}/templateDetails.xml" ]; then
+ MANIFEST="${ROOT}/templateDetails.xml"
+ elif [ -f "src/templates/templateDetails.xml" ]; then
+ MANIFEST="src/templates/templateDetails.xml"
+ elif find "src/templates" -mindepth 2 -maxdepth 2 -name "templateDetails.xml" -type f | head -n 1 | grep -q .; then
+ MANIFEST="$(find "src/templates" -mindepth 2 -maxdepth 2 -name "templateDetails.xml" -type f | head -n 1)"
else
- echo "No errors recorded." >> "$GITHUB_STEP_SUMMARY"
+ CANDIDATE="$(find "${ROOT}" -maxdepth 1 -type f -name "*.xml" | head -n 1 || true)"
+ if [ -n "${CANDIDATE}" ]; then
+ MANIFEST="${CANDIDATE}"
+ fi
fi
+
+ if [ -z "${MANIFEST}" ]; then
+ echo "ERROR: No Joomla manifest XML found" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ fi
+
+ EXT_TYPE="$(grep -o 'type=\"[^\"]*\"' "${MANIFEST}" | head -n 1 | cut -d '"' -f2)"
+ if [ -z "${EXT_TYPE}" ]; then
+ EXT_TYPE="unknown"
+ fi
+
+ MANIFEST_DIR="$(dirname "${MANIFEST}")"
+ if [ "${EXT_TYPE}" = "template" ] && [ "${MANIFEST_DIR}" != "${ROOT}" ]; then
+ ROOT="${MANIFEST_DIR}"
+ fi
+
+ ZIP="${REPO}-${VERSION}-${CHANNEL}.zip"
+
+ (cd "${ROOT}" && zip -r -X "${DIST_DIR}/${ZIP}" . \
+ -x "**/.git/**" \
+ -x "**/.github/**" \
+ -x "**/.DS_Store" \
+ -x "**/__MACOSX/**")
+
+ echo "zip_name=${ZIP}" >> "${GITHUB_OUTPUT}"
+ echo "dist_dir=${DIST_DIR}" >> "${GITHUB_OUTPUT}"
+ echo "root=${ROOT}" >> "${GITHUB_OUTPUT}"
+ echo "manifest=${MANIFEST}" >> "${GITHUB_OUTPUT}"
+ echo "ext_type=${EXT_TYPE}" >> "${GITHUB_OUTPUT}"
+
+ ZIP_BYTES="$(stat -c%s "${DIST_DIR}/${ZIP}")"
+ ZIP_SHA=""
+ if command -v sha256sum >/dev/null 2>&1; then
+ ZIP_SHA="$(sha256sum "${DIST_DIR}/${ZIP}" | awk '{print $1}')"
+ fi
+
+ {
+ echo "### Build report"
+ echo "```json"
+ echo "{"
+ echo " \"root\": \"${ROOT}\","
+ echo " \"manifest\": \"${MANIFEST}\","
+ echo " \"extension_type\": \"${EXT_TYPE}\","
+ echo " \"zip\": \"${DIST_DIR}/${ZIP}\","
+ echo " \"zip_bytes\": ${ZIP_BYTES},"
+ echo " \"zip_sha256\": \"${ZIP_SHA}\""
+ echo "}"
+ echo "```"
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ - name: Upload ZIP to SFTP (key-only, overwrite, verbose)
+ env:
+ FTP_HOST: ${{ secrets.FTP_HOST }}
+ FTP_USER: ${{ secrets.FTP_USER }}
+ FTP_KEY: ${{ secrets.FTP_KEY }}
+ FTP_PATH: ${{ secrets.FTP_PATH }}
+ FTP_PROTOCOL: ${{ secrets.FTP_PROTOCOL }}
+ FTP_PORT: ${{ secrets.FTP_PORT }}
+ FTP_PATH_SUFFIX: ${{ vars.FTP_PATH_SUFFIX }}
+ CHANNEL: ${{ needs.guard.outputs.channel }}
+ run: |
+ set -euo pipefail
+ set -x
+
+ ZIP="${{ steps.build.outputs.zip_name }}"
+
+ : "${FTP_HOST:?Missing secret FTP_HOST}"
+ : "${FTP_USER:?Missing secret FTP_USER}"
+ : "${FTP_KEY:?Missing secret FTP_KEY}"
+ : "${FTP_PATH:?Missing secret FTP_PATH}"
+
+ PROTOCOL="${FTP_PROTOCOL:-sftp}"
+ if [ "${PROTOCOL}" != "sftp" ]; then
+ echo "ERROR: Only SFTP permitted" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ fi
+
+ PORT="${FTP_PORT:-}"
+ if [ -n "${PORT}" ]; then
+ HOSTPORT="${FTP_HOST}:${PORT}"
+ else
+ HOSTPORT="${FTP_HOST}"
+ fi
+
+ SUFFIX="${FTP_PATH_SUFFIX:-}"
+ if [ -n "${SUFFIX}" ]; then
+ REMOTE_PATH="${FTP_PATH%/}/${SUFFIX%/}/${CHANNEL}"
+ else
+ REMOTE_PATH="${FTP_PATH%/}/${CHANNEL}"
+ fi
+
+ echo "SFTP target: sftp://${HOSTPORT}${REMOTE_PATH}" >> "${GITHUB_STEP_SUMMARY}"
+
+ sudo apt-get update -y
+ sudo apt-get install -y lftp openssh-client putty-tools
+
+ mkdir -p ~/.ssh
+
+ # Key material is sourced exclusively from FTP_KEY.
+ # Supported formats:
+ # - OpenSSH private key (unencrypted)
+ # - PuTTY .ppk (unencrypted or encrypted; encryption unlocked via FTP_PASSWORD)
+ # Authentication remains key-only; passwords are never used for login.
+ if printf '%s' "${FTP_KEY}" | head -n 1 | grep -q '^PuTTY-User-Key-File-'; then
+ echo "Detected PuTTY PPK key format" >> "${GITHUB_STEP_SUMMARY}"
+
+ printf '%s' "${FTP_KEY}" > ~/.ssh/key.ppk
+ chmod 600 ~/.ssh/key.ppk
+
+ # Determine encryption state
+ if grep -Eq '^Encryption: *none[[:space:]]*$' ~/.ssh/key.ppk; then
+ echo "PPK encryption: none" >> "${GITHUB_STEP_SUMMARY}"
+ PPK_PASSPHRASE_ARG=""
+ else
+ if [ -z "${FTP_PASSWORD:-}" ]; then
+ echo "ERROR: Encrypted PPK detected but FTP_PASSWORD not provided" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ fi
+ echo "PPK encryption: enabled (using FTP_PASSWORD)" >> "${GITHUB_STEP_SUMMARY}"
+ PPK_PASSPHRASE_ARG="--passphrase ${FTP_PASSWORD}"
+ fi
+
+ # Log PPK header fields (sanitized, no key material)
+ {
+ echo "PPK header (sanitized):"
+ grep -E '^(PuTTY-User-Key-File-|Encryption:|Comment:|Public-Lines:|Private-Lines:|Private-MAC:)' ~/.ssh/key.ppk || true
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ # Convert to OpenSSH private key
+ if ! puttygen ~/.ssh/key.ppk -O private-openssh ${PPK_PASSPHRASE_ARG} -o ~/.ssh/id_rsa; then
+ echo "ERROR: PPK conversion failed" >> "${GITHUB_STEP_SUMMARY}"
+ exit 1
+ fi
+
+ chmod 600 ~/.ssh/id_rsa
+ rm -f ~/.ssh/key.ppk
+ else
+ echo "Detected OpenSSH private key format" >> "${GITHUB_STEP_SUMMARY}"
+ printf '%s' "${FTP_KEY}" > ~/.ssh/id_rsa
+ chmod 600 ~/.ssh/id_rsa
+ fi
+
+ ssh-keyscan -H "${FTP_HOST}" >> ~/.ssh/known_hosts
+
+ # Hard‑enforced key‑only authentication. Password auth explicitly disabled.
+ lftp -d -e "\
+ set sftp:auto-confirm yes; \
+ set cmd:trace yes; \
+ set net:timeout 30; \
+ set net:max-retries 3; \
+ set net:reconnect-interval-base 5; \
+ set sftp:connect-program 'ssh -a -x -i ~/.ssh/id_rsa -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no -o ChallengeResponseAuthentication=no -o PubkeyAuthentication=yes'; \
+ open -u '${FTP_USER}', sftp://${HOSTPORT}; \
+ pwd; ls; \
+ mkdir -p '${REMOTE_PATH}'; \
+ cd '${REMOTE_PATH}'; \
+ pwd; \
+ put -E '${{ steps.build.outputs.dist_dir }}/${ZIP}'; \
+ ls; \
+ bye"
+
+ ZIP_BYTES="$(stat -c%s "${{ steps.build.outputs.dist_dir }}/${ZIP}")"
+ {
+ echo "### SFTP upload report"
+ echo "```json"
+ echo "{"
+ echo " \"protocol\": \"sftp\","
+ echo " \"host\": \"${FTP_HOST}\","
+ echo " \"port\": \"${PORT:-default}\","
+ echo " \"remote_path\": \"${REMOTE_PATH}\","
+ echo " \"zip\": \"${ZIP}\","
+ echo " \"zip_bytes\": ${ZIP_BYTES},"
+ echo " \"overwrite\": true,"
+ echo " \"key_only\": true"
+ echo "}"
+ echo "```"
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ - 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
+ 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: ${{ 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.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 }}"
+ OVERRIDE="${{ needs.guard.outputs.override }}"
+
+ echo "### Release report (JSON)" >> "${GITHUB_STEP_SUMMARY}"
+ echo "```json" >> "${GITHUB_STEP_SUMMARY}"
+ echo "{\"repository\":\"${REPO_FULL}\",\"version\":\"${VERSION}\",\"branch\":\"${BRANCH}\",\"tag\":\"${TAG}\",\"mode\":\"${MODE}\",\"channel\":\"${CHANNEL}\",\"override\":\"${OVERRIDE}\",\"zip\":\"${ZIP_NAME}\"}" >> "${GITHUB_STEP_SUMMARY}"
+ echo "```" >> "${GITHUB_STEP_SUMMARY}"
+
+ push_version_to_main:
+ name: 04 Promote version branch 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 }}"
+
+ gh pr create \
+ --base main \
+ --head "${HEAD}" \
+ --title "Release ${VERSION} to main" \
+ --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
+
+ 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
+
+ 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 "{\"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
+ needs: guard
+
+ if: ${{ github.event_name == 'release' }}
+
+ permissions:
+ contents: read
+
+ steps:
+ - name: Checkout tag
+ uses: actions/checkout@v4
+ with:
+ ref: ${{ github.ref_name }}
+ fetch-depth: 0
+
+ - name: Publish JSON report to job summary
+ run: |
+ set -euo pipefail
+
+ VERSION="${{ needs.guard.outputs.version }}"
+ TAG="${{ github.ref_name }}"
+
+ echo "### Release event report (JSON)" >> "${GITHUB_STEP_SUMMARY}"
+ echo "```json" >> "${GITHUB_STEP_SUMMARY}"
+ echo "{\"version\":\"${VERSION}\",\"tag\":\"${TAG}\",\"prerelease\":${{ github.event.release.prerelease }}}" >> "${GITHUB_STEP_SUMMARY}"
+ echo "```" >> "${GITHUB_STEP_SUMMARY}"