Update release_pipeline.yml

This commit is contained in:
2025-12-27 00:03:35 -06:00
parent 3eb92225a4
commit 513ce83b4e

View File

@@ -1,5 +1,3 @@
#!/usr/bin/env sh
# ============================================================================ # ============================================================================
# Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech> # Copyright (C) 2025 Moko Consulting <hello@mokoconsulting.tech>
# #
@@ -18,103 +16,909 @@
# GNU General Public License for more details. # GNU General Public License for more details.
# #
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program (./LICENSE.md). # along with this program. If not, see <https://www.gnu.org/licenses/>.
# ============================================================================ #
# ============================================================================
# FILE INFORMATION # FILE INFORMATION
# ============================================================================ # DEFGROUP: GitHub.Workflow
# DEFGROUP: Script.Library # INGROUP: MokoStandards.Release
# INGROUP: RepoHealth # REPO: https://github.com/mokoconsulting-tech/MokoStandards
# REPO: https://github.com/mokoconsulting-tech # PATH: /.github/workflows/release_pipeline.yml
# PATH: /scripts/lib/find_files.sh # VERSION: 03.05.00
# VERSION: 01.00.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 promotes to main while retaining the version branch.
# BRIEF: Find files by glob patterns with standard ignore rules for CI checks
# NOTE:
# ============================================================================ # ============================================================================
set -eu name: Release Pipeline (dev > rc > version > main)
# Shared utilities on:
. "$(dirname "$0")/common.sh" workflow_dispatch:
inputs:
release_classification:
description: "Manual override for classification. auto follows branch policy; rc forces prerelease behavior; stable forces full release behavior."
required: true
default: auto
type: choice
options:
- auto
- rc
- stable
release:
types:
- created
- prereleased
- published
# ---------------------------------------------------------------------------- concurrency:
# Purpose: group: release-pipeline-${{ github.ref_name }}
# - Provide a consistent, reusable file discovery primitive for repo scripts. cancel-in-progress: false
# - Support multiple glob patterns.
# - Apply standard ignore rules to reduce noise (vendor, node_modules, .git).
# - Output one path per line, relative to repo root.
#
# Usage:
# ./scripts/lib/find_files.sh <glob> [<glob> ...]
#
# Examples:
# ./scripts/lib/find_files.sh "*.yml" "*.yaml"
# ./scripts/lib/find_files.sh "src/**/*.php" "tests/**/*.php"
# ----------------------------------------------------------------------------
ROOT="$(script_root)" defaults:
run:
shell: bash
if [ "${1:-}" = "" ]; then permissions:
die "Usage: $0 <glob> [<glob> ...]" contents: read
jobs:
guard:
name: 00 Guard and derive promotion metadata
runs-on: ubuntu-latest
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: Verify actor has admin or maintain role
env:
GH_TOKEN: "${{ github.token }}"
run: |
set -euo pipefail
ACTOR="${GITHUB_ACTOR}"
REPO="${GITHUB_REPOSITORY}"
PERMISSION="$(gh api "/repos/${REPO}/collaborators/${ACTOR}/permission" --jq '.permission')"
{
echo "### Authorization check"
echo "```json"
echo "{\"actor\":\"${ACTOR}\",\"permission\":\"${PERMISSION}\"}"
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${PERMISSION}" != "admin" ] && [ "${PERMISSION}" != "maintain" ]; then
echo "ERROR: Actor ${ACTOR} lacks required role (admin or maintain)." >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi fi
require_cmd find - name: Validate trigger and extract metadata
require_cmd sed id: meta
env:
RELEASE_CLASSIFICATION: "${{ github.event.inputs.release_classification }}"
RELEASE_PRERELEASE: "${{ github.event.release.prerelease }}"
run: |
set -euxo pipefail
# Standard excludes (pragmatic defaults for CI) EVENT_NAME="${GITHUB_EVENT_NAME}"
# Note: Keep these broad to avoid scanning generated or third-party content. REF_NAME="${GITHUB_REF_NAME}"
EXCLUDES='
-path "*/.git/*" -o
-path "*/.github/*/node_modules/*" -o
-path "*/node_modules/*" -o
-path "*/vendor/*" -o
-path "*/dist/*" -o
-path "*/build/*" -o
-path "*/cache/*" -o
-path "*/tmp/*" -o
-path "*/.tmp/*" -o
-path "*/.cache/*"
'
# Convert a glob (bash-like) to a find -path pattern. VERSION=""
# - Supports ** for "any directories" by translating to * SOURCE_BRANCH=""
# - Ensures leading */ so patterns apply anywhere under repo root SOURCE_PREFIX=""
glob_to_find_path() { TARGET_BRANCH=""
g="$1" PROMOTED_BRANCH=""
CHANNEL=""
RELEASE_MODE="none"
# normalize path separators for WSL/CI compatibility OVERRIDE="${RELEASE_CLASSIFICATION:-auto}"
g="$(normalize_path "$g")" if [ -z "${OVERRIDE}" ]; then
OVERRIDE="auto"
fi
# translate ** to * (find -path uses shell glob semantics) if [ "${EVENT_NAME}" = "workflow_dispatch" ]; then
g="$(printf '%s' "$g" | sed 's|\*\*|*|g')" echo "${REF_NAME}" | grep -E '^(dev|rc)/[0-9]+[.][0-9]+[.][0-9]+$'
case "$g" in SOURCE_BRANCH="${REF_NAME}"
/*) printf '%s\n' "$g" ;; SOURCE_PREFIX="${REF_NAME%%/*}"
*) printf '%s\n' "*/$g" ;; VERSION="${REF_NAME#*/}"
esac
}
# Build a single find invocation that ORs all patterns. if [ "${SOURCE_PREFIX}" = "dev" ]; then
# Shell portability note: avoid arrays; build an expression string. TARGET_BRANCH="rc/${VERSION}"
PAT_EXPR="" PROMOTED_BRANCH="rc/${VERSION}"
for GLOB in "$@"; do CHANNEL="rc"
P="$(glob_to_find_path "$GLOB")" RELEASE_MODE="prerelease"
if [ -z "$PAT_EXPR" ]; then
PAT_EXPR="-path \"$P\""
else else
PAT_EXPR="$PAT_EXPR -o -path \"$P\"" TARGET_BRANCH="version/${VERSION}"
PROMOTED_BRANCH="version/${VERSION}"
CHANNEL="stable"
RELEASE_MODE="stable"
fi
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}"
VERSION="${VERSION%-rc}"
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 " \"repository\": \"${GITHUB_REPOSITORY}\","
echo " \"workflow\": \"${GITHUB_WORKFLOW}\","
echo " \"job\": \"${GITHUB_JOB}\","
echo " \"run_id\": ${GITHUB_RUN_ID},"
echo " \"run_number\": ${GITHUB_RUN_NUMBER},"
echo " \"run_attempt\": ${GITHUB_RUN_ATTEMPT},"
echo " \"run_url\": \"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}\","
echo " \"actor\": \"${GITHUB_ACTOR}\","
echo " \"sha\": \"${GITHUB_SHA}\","
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
- name: Configure Git identity
run: |
set -euxo 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 -euxo pipefail
SRC="${{ needs.guard.outputs.source_branch }}"
DST="${{ needs.guard.outputs.target_branch }}"
git fetch origin --prune
if [ -z "${SRC}" ] || [ -z "${DST}" ]; then
echo "ERROR: guard did not emit SRC or DST" >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
if ! git show-ref --verify --quiet "refs/remotes/origin/${SRC}"; then
echo "ERROR: origin/${SRC} not found" >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
if git show-ref --verify --quiet "refs/remotes/origin/${DST}"; then
echo "ERROR: origin/${DST} already exists" >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
- name: Promote and delete source
run: |
set -euxo pipefail
SRC="${{ needs.guard.outputs.source_branch }}"
DST="${{ needs.guard.outputs.target_branch }}"
git checkout -B "${DST}" "origin/${SRC}"
git push origin "${DST}"
git push origin --delete "${SRC}"
{
echo "### Promotion report"
echo "```json"
echo "{"
echo " \"repository\": \"${GITHUB_REPOSITORY}\","
echo " \"workflow\": \"${GITHUB_WORKFLOW}\","
echo " \"job\": \"${GITHUB_JOB}\","
echo " \"run_id\": ${GITHUB_RUN_ID},"
echo " \"run_number\": ${GITHUB_RUN_NUMBER},"
echo " \"run_attempt\": ${GITHUB_RUN_ATTEMPT},"
echo " \"run_url\": \"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}\","
echo " \"actor\": \"${GITHUB_ACTOR}\","
echo " \"sha\": \"${GITHUB_SHA}\","
echo " \"promoted\": \"${SRC} -> ${DST}\","
echo " \"deleted\": \"${SRC}\""
echo "}"
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
normalize_dates:
name: 02 Normalize dates on promoted branch
runs-on: ubuntu-latest
needs:
- guard
- promote_branch
if: ${{ github.event_name == 'workflow_dispatch' }}
permissions:
contents: 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: |
set -euxo 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 repo prerequisites
run: |
set -euxo 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 -euxo 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}"
CANDIDATES=(
"scripts/update_dates.sh"
"scripts/release/update_dates.sh"
"scripts/release/update_dates"
)
SCRIPT=""
for c in "${CANDIDATES[@]}"; do
if [ -f "${c}" ]; then
SCRIPT="${c}"
break
fi fi
done done
# Execute find and emit relative paths. if [ -z "${SCRIPT}" ]; then
# - Use eval to apply the constructed predicate string safely as a single expression. FOUND="$(find . -maxdepth 3 -type f \( -name 'update_dates.sh' -o -name 'update-dates.sh' \) 2>/dev/null | head -n 5 || true)"
# - We scope to files only. {
# - We prune excluded directories. echo "ERROR: Date normalization script not found in approved locations."
cd "$ROOT" echo "Approved locations:"
printf '%s
' "${CANDIDATES[@]}"
echo "Discovered candidates (first 5):"
echo "${FOUND:-<none>}"
echo "Required action: add scripts/update_dates.sh (or scripts/release/update_dates.sh) to the repo."
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
# shellcheck disable=SC2086 echo "Using date script: ${SCRIPT}" >> "${GITHUB_STEP_SUMMARY}"
eval "find . \\( $EXCLUDES \\) -prune -o -type f \\( $PAT_EXPR \\) -print" \
| sed 's|^\./||' \ chmod +x "${SCRIPT}"
| sed '/^$/d' \ "${SCRIPT}" "${TODAY}" "${VERSION}" >> "${GITHUB_STEP_SUMMARY}"
| sort -u
{
echo "### Date normalization diffstat"
echo "```"
git diff --stat || true
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
- name: Commit normalized dates (if changed)
run: |
set -euxo pipefail
if git diff --quiet; then
echo "No date changes to commit" >> "${GITHUB_STEP_SUMMARY}"
exit 0
fi
git add -A
git commit -m "chore(release): normalize dates" || true
git push origin "HEAD:${{ needs.guard.outputs.promoted_branch }}"
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: |
set -euxo 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 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: |
set -euo pipefail
missing=()
[ -n "${FTP_HOST:-}" ] || missing+=("FTP_HOST")
[ -n "${FTP_USER:-}" ] || missing+=("FTP_USER")
[ -n "${FTP_KEY:-}" ] || missing+=("FTP_KEY")
[ -n "${FTP_PATH:-}" ] || missing+=("FTP_PATH")
proto="${FTP_PROTOCOL:-sftp}"
if [ -n "${FTP_PROTOCOL:-}" ] && [ "${proto}" != "sftp" ]; then
missing+=("FTP_PROTOCOL_INVALID")
fi
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
key_format="missing"
fi
{
echo "### Configuration guardrails"
echo "```json"
printf '{"status":"%s","missing":[' "$( [ "${#missing[@]}" -gt 0 ] && echo fail || echo ok )"
sep=""
for m in "${missing[@]}"; do
printf '%s"%s"' "${sep}" "${m}"
sep=",";
done
printf '],"key_format":"%s","channel":"%s"}
' "${key_format}" "${CHANNEL}"
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#missing[@]}" -gt 0 ]; then
exit 1
fi
- name: Run repository validation scripts (workflow-controlled)
run: |
set -euxo pipefail
required_scripts=(
"scripts/validate_manifest.sh"
"scripts/validate_xml_wellformed.sh"
)
optional_scripts=(
"scripts/validate_changelog.sh"
"scripts/validate_tabs.sh"
"scripts/validate_paths.sh"
"scripts/validate_version_alignment.sh"
"scripts/validate_language_structure.sh"
"scripts/validate_php_syntax.sh"
"scripts/validate_no_secrets.sh"
"scripts/validate_license_headers.sh"
)
missing=()
for s in "${required_scripts[@]}"; do
if [ ! -f "${s}" ]; then
missing+=("${s}")
fi
done
if [ "${#missing[@]}" -gt 0 ]; then
{
echo "### Script guardrails"
echo "```json"
printf '{"status":"fail","missing_required_scripts":['
sep=""
for m in "${missing[@]}"; do
printf '%s"%s"' "${sep}" "${m}"
sep=",";
done
printf ']}
'
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
ran=()
skipped=()
for s in "${required_scripts[@]}" "${optional_scripts[@]}"; do
if [ -f "${s}" ]; then
chmod +x "${s}"
"${s}" >> "${GITHUB_STEP_SUMMARY}"
ran+=("${s}")
else
skipped+=("${s}")
fi
done
{
echo "### Script execution report"
echo "```json"
printf '{"ran":['
sep=""
for r in "${ran[@]}"; do
printf '%s"%s"' "${sep}" "${r}"
sep=",";
done
printf '],"skipped_optional":['
sep=""
for k in "${skipped[@]}"; do
printf '%s"%s"' "${sep}" "${k}"
sep=",";
done
printf ']}
'
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
- name: Build Joomla ZIP (extension type aware)
id: build
run: |
set -euxo pipefail
VERSION="${{ needs.guard.outputs.version }}"
REPO_NAME="${{ 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}"
MANIFEST=""
if [ -f "src/templateDetails.xml" ]; then
MANIFEST="src/templateDetails.xml"
elif find src -maxdepth 4 -type f -name 'templateDetails.xml' | head -n 1 | grep -q .; then
MANIFEST="$(find src -maxdepth 4 -type f -name 'templateDetails.xml' | head -n 1)"
elif find src -maxdepth 4 -type f -name 'pkg_*.xml' | head -n 1 | grep -q .; then
MANIFEST="$(find src -maxdepth 4 -type f -name 'pkg_*.xml' | head -n 1)"
elif find src -maxdepth 4 -type f -name 'com_*.xml' | head -n 1 | grep -q .; then
MANIFEST="$(find src -maxdepth 4 -type f -name 'com_*.xml' | head -n 1)"
elif find src -maxdepth 4 -type f -name 'mod_*.xml' | head -n 1 | grep -q .; then
MANIFEST="$(find src -maxdepth 4 -type f -name 'mod_*.xml' | head -n 1)"
elif find src -maxdepth 6 -type f -name 'plg_*.xml' | head -n 1 | grep -q .; then
MANIFEST="$(find src -maxdepth 6 -type f -name 'plg_*.xml' | head -n 1)"
else
MANIFEST="$(grep -Rsl --include='*.xml' '<extension' src | head -n 1 || true)"
fi
if [ -z "${MANIFEST}" ]; then
echo "ERROR: No Joomla manifest XML found under src" >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
EXT_TYPE="$(grep -Eo 'type="[^"]+"' "${MANIFEST}" | head -n 1 | cut -d '"' -f2 || true)"
if [ -z "${EXT_TYPE}" ]; then
EXT_TYPE="unknown"
fi
ROOT="$(dirname "${MANIFEST}")"
ZIP="${REPO_NAME}-${VERSION}-${CHANNEL}-${EXT_TYPE}.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}")"
{
echo "### Build report"
echo "```json"
echo "{\"repository\":\"${GITHUB_REPOSITORY}\",\"workflow\":\"${GITHUB_WORKFLOW}\",\"job\":\"${GITHUB_JOB}\",\"run_id\":${GITHUB_RUN_ID},\"run_number\":${GITHUB_RUN_NUMBER},\"run_attempt\":${GITHUB_RUN_ATTEMPT},\"run_url\":\"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}\",\"actor\":\"${GITHUB_ACTOR}\",\"sha\":\"${GITHUB_SHA}\",\"root\":\"${ROOT}\",\"manifest\":\"${MANIFEST}\",\"extension_type\":\"${EXT_TYPE}\",\"zip\":\"${DIST_DIR}/${ZIP}\",\"zip_bytes\":${ZIP_BYTES}}"
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_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: |
set -euo pipefail
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
if printf '%s' "${FTP_KEY}" | head -n 1 | grep -q '^PuTTY-User-Key-File-'; then
printf '%s' "${FTP_KEY}" > ~/.ssh/key.ppk
chmod 600 ~/.ssh/key.ppk
if grep -Eq '^Encryption: *none[[:space:]]*$' ~/.ssh/key.ppk; then
PPK_PASSPHRASE=""
else
if [ -z "${FTP_PASSWORD:-}" ]; then
echo "ERROR: Encrypted PPK detected but FTP_PASSWORD not provided" >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
PPK_PASSPHRASE="${FTP_PASSWORD:-}"
fi
if [ -n "${PPK_PASSPHRASE}" ]; then
puttygen ~/.ssh/key.ppk -O private-openssh --passphrase "${PPK_PASSPHRASE}" -o ~/.ssh/id_rsa
else
puttygen ~/.ssh/key.ppk -O private-openssh -o ~/.ssh/id_rsa
fi
if [ ! -s ~/.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
printf '%s' "${FTP_KEY}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
fi
ssh-keyscan -H "${FTP_HOST}" >> ~/.ssh/known_hosts
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}; \
mkdir -p '${REMOTE_PATH}'; \
cd '${REMOTE_PATH}'; \
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 "{\"protocol\":\"sftp\",\"host\":\"${FTP_HOST}\",\"port\":\"${PORT:-default}\",\"remote_path\":\"${REMOTE_PATH}\",\"zip\":\"${ZIP}\",\"zip_bytes\":${ZIP_BYTES},\"overwrite\":true,\"key_only\":true}"
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
- name: Create Git tag
id: tag
run: |
set -euxo 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 -euxo 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 release report
run: |
set -euxo pipefail
echo "### Release report (JSON)" >> "${GITHUB_STEP_SUMMARY}"
echo "```json" >> "${GITHUB_STEP_SUMMARY}"
printf '{"repository":"%s","workflow":"%s","job":"%s","run_id":%s,"run_number":%s,"run_attempt":%s,"run_url":"%s","actor":"%s","sha":"%s","version":"%s","branch":"%s","tag":"%s","mode":"%s","channel":"%s","override":"%s","zip":"%s"}
' \
"${GITHUB_REPOSITORY}" \
"${GITHUB_WORKFLOW}" \
"${GITHUB_JOB}" \
"${GITHUB_RUN_ID}" \
"${GITHUB_RUN_NUMBER}" \
"${GITHUB_RUN_ATTEMPT}" \
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
"${GITHUB_ACTOR}" \
"${GITHUB_SHA}" \
"${{ needs.guard.outputs.version }}" \
"${{ needs.guard.outputs.promoted_branch }}" \
"${{ steps.tag.outputs.tag }}" \
"${{ needs.guard.outputs.release_mode }}" \
"${{ needs.guard.outputs.channel }}" \
"${{ needs.guard.outputs.override }}" \
"${{ steps.build.outputs.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 -euxo 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
env:
IS_PRERELEASE: "${{ github.event.release.prerelease }}"
run: |
set -euxo pipefail
VERSION="${{ needs.guard.outputs.version }}"
TAG="${{ github.ref_name }}"
echo "### Release event report (JSON)" >> "${GITHUB_STEP_SUMMARY}"
echo "```json" >> "${GITHUB_STEP_SUMMARY}"
printf '{"repository":"%s","workflow":"%s","job":"%s","run_id":%s,"run_number":%s,"run_attempt":%s,"run_url":"%s","actor":"%s","sha":"%s","version":"%s","tag":"%s","prerelease":%s}
' \
"${GITHUB_REPOSITORY}" \
"${GITHUB_WORKFLOW}" \
"${GITHUB_JOB}" \
"${GITHUB_RUN_ID}" \
"${GITHUB_RUN_NUMBER}" \
"${GITHUB_RUN_ATTEMPT}" \
"${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
"${GITHUB_ACTOR}" \
"${GITHUB_SHA}" \
"${VERSION}" \
"${TAG}" \
"${IS_PRERELEASE}" >> "${GITHUB_STEP_SUMMARY}"
echo "```" >> "${GITHUB_STEP_SUMMARY}"