Refine release pipeline description and logic

Updated the release pipeline YAML to clarify the brief description and remove unnecessary conditional checks for channel and release mode.

Signed-off-by: Jonathan Miller <jmiller2979@gmail.com>
This commit is contained in:
Jonathan Miller
2026-01-03 11:25:21 -06:00
committed by GitHub
parent 5162ae93e6
commit 721f9288dd

View File

@@ -24,7 +24,7 @@
# REPO: https://github.com/mokoconsulting-tech/MokoStandards
# PATH: /.github/workflows/release_pipeline.yml
# VERSION: 03.05.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: 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 release flow.
# NOTE:
# ============================================================================
@@ -171,966 +171,4 @@ jobs:
VERSION="${VERSION%-rc}"
echo "${VERSION}" | grep -E '^[0-9]+[.][0-9]+[.][0-9]+$' >/dev/null
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 " \"actor_permission\": \"${{ steps.auth.outputs.permission }}\","
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}"
- name:
- name: Report job snapshot (always)
if: ${{ always() }}
run: |
set -euo pipefail
{
echo "### Job snapshot"
echo "```"
echo "event=${GITHUB_EVENT_NAME} ref=${GITHUB_REF_NAME} sha=${GITHUB_SHA} actor=${GITHUB_ACTOR}"
git --version || true
git status --porcelain=v1 || true
git log -1 --pretty=fuller || true
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
{
echo "### Git snapshot"
echo "```"
git --version || true
git status --porcelain=v1 || true
git log -1 --pretty=fuller || true
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 -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config --global --add safe.directory "${GITHUB_WORKSPACE}"
- name: Enforce promotion preconditions
run: |
set -euo pipefail
SRC="${{ needs.guard.outputs.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 -euo 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 "{\"source\":\"${SRC}\",\"target\":\"${DST}\",\"status\":\"ok\"}"
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
- name:
- name: Report job snapshot (always)
if: ${{ always() }}
run: |
set -euo pipefail
{
echo "### Job snapshot"
echo "```"
echo "event=${GITHUB_EVENT_NAME} ref=${GITHUB_REF_NAME} sha=${GITHUB_SHA} actor=${GITHUB_ACTOR}"
git --version || true
git status --porcelain=v1 || true
git log -1 --pretty=fuller || true
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 -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 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}"
CANDIDATES=(
"scripts/release/update_dates.sh"
"scripts/release/update_dates"
"scripts/update_dates.sh"
)
SCRIPT=""
for c in "${CANDIDATES[@]}"; do
if [ -f "${c}" ]; then
SCRIPT="${c}"
break
fi
done
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:-<none>}"
echo "Required action: add scripts/release/update_dates.sh (preferred) to the repo."
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
echo "Using date script: ${SCRIPT}" >> "${GITHUB_STEP_SUMMARY}"
chmod +x "${SCRIPT}"
"${SCRIPT}" "${TODAY}" "${VERSION}" >> "${GITHUB_STEP_SUMMARY}"
{
echo "### Date normalization diffstat"
echo "```"
git diff --stat || true
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
- name: Commit normalized dates (if changed)
run: |
set -euo 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 }}"
- name:
- name: Report job snapshot (always)
if: ${{ always() }}
run: |
set -euo pipefail
{
echo "### Job snapshot"
echo "```"
echo "event=${GITHUB_EVENT_NAME} ref=${GITHUB_REF_NAME} sha=${GITHUB_SHA} actor=${GITHUB_ACTOR}"
git --version || true
git status --porcelain=v1 || true
git log -1 --pretty=fuller || 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: |
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 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 }}
DEPLOY_DRY_RUN: ${{ vars.DEPLOY_DRY_RUN }}
run: |
set -euo pipefail
missing=()
[ -n "${FTP_HOST:-}" ] || missing+=("FTP_HOST")
[ -n "${FTP_USER:-}" ] || missing+=("FTP_USER")
[ -n "${FTP_PATH:-}" ] || missing+=("FTP_PATH")
proto="${FTP_PROTOCOL:-sftp}"
if [ -n "${FTP_PROTOCOL:-}" ] && [ "${proto}" != "sftp" ]; then
missing+=("FTP_PROTOCOL_INVALID")
fi
key_present=false
if [ -n "${FTP_KEY:-}" ]; then
key_present=true
fi
pw_present=false
if [ -n "${FTP_PASSWORD:-}" ]; then
pw_present=true
fi
auth_mode="password"
if [ "${key_present}" = "true" ]; then
auth_mode="key"
fi
if [ "${auth_mode}" = "password" ] && [ "${pw_present}" != "true" ]; then
missing+=("FTP_PASSWORD_REQUIRED")
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 '],"channel":"%s","deploy_dry_run":"%s","credential_presence":{"FTP_KEY":"%s","FTP_PASSWORD":"%s"}}\n' \
"${CHANNEL}" "${DEPLOY_DRY_RUN:-false}" \
"$( [ "${key_present}" = "true" ] && echo present || echo missing )" \
"$( [ "${pw_present}" = "true" ] && echo present || echo missing )"
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#missing[@]}" -gt 0 ]; then
exit 1
fi
- name: Run repository validation scripts (workflow-controlled)
run: |
set -euo pipefail
required_scripts=(
"scripts/validate/validate_manifest.sh"
"scripts/validate/validate_xml_wellformed.sh"
)
optional_scripts=(
"scripts/validate/validate_changelog.sh"
"scripts/validate/validate_language_structure.sh"
"scripts/validate/validate_license_headers.sh"
"scripts/validate/validate_no_secrets.sh"
"scripts/validate/validate_paths.sh"
"scripts/validate/validate_php_syntax.sh"
"scripts/validate/validate_tabs.sh"
"scripts/validate/validate_version_alignment.sh"
)
missing_required=()
for s in "${required_scripts[@]}"; do
if [ ! -f "${s}" ]; then
missing_required+=("${s}")
fi
done
if [ "${#missing_required[@]}" -gt 0 ]; then
{
echo "### Script guardrails"
echo "```json"
printf '{"status":"fail","missing_required_scripts":['
sep=""
for m in "${missing_required[@]}"; do
printf '%s"%s"' "${sep}" "${m}"
sep=",";
done
printf ']}\n'
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
ran_required=()
ran_optional=()
failed_optional=()
skipped_optional=()
# Required scripts are release-blocking.
for s in "${required_scripts[@]}"; do
chmod +x "${s}"
echo "" >> "${GITHUB_STEP_SUMMARY}"
echo "### Run required: ${s}" >> "${GITHUB_STEP_SUMMARY}"
echo "```" >> "${GITHUB_STEP_SUMMARY}"
"${s}" >> "${GITHUB_STEP_SUMMARY}"
echo "```" >> "${GITHUB_STEP_SUMMARY}"
ran_required+=("${s}")
done
# Optional scripts provide signal only. Failures become warnings, not blockers.
for s in "${optional_scripts[@]}"; do
if [ -f "${s}" ]; then
chmod +x "${s}"
set +e
echo "" >> "${GITHUB_STEP_SUMMARY}"
echo "### Run optional: ${s}" >> "${GITHUB_STEP_SUMMARY}"
echo "```" >> "${GITHUB_STEP_SUMMARY}"
"${s}" >> "${GITHUB_STEP_SUMMARY}"
rc=$?
echo "```" >> "${GITHUB_STEP_SUMMARY}"
set -e
ran_optional+=("${s}")
if [ "${rc}" -ne 0 ]; then
failed_optional+=("${s}")
echo "::warning::Optional validation failed (${s}) exit_code=${rc}"
fi
else
skipped_optional+=("${s}")
fi
done
{
echo "### Validation inventory"
echo "```json"
printf '{'
printf '"required_count":%s,' "${#required_scripts[@]}"
printf '"optional_count":%s,' "${#optional_scripts[@]}"
printf '"ran_required_count":%s,' "${#ran_required[@]}"
printf '"ran_optional_count":%s,' "${#ran_optional[@]}"
printf '"failed_optional_count":%s,' "${#failed_optional[@]}"
printf '"skipped_optional_count":%s,' "${#skipped_optional[@]}"
printf '"ran_required":['
sep=""
for x in "${ran_required[@]}"; do
printf '%s"%s"' "${sep}" "${x}"
sep=",";
done
printf '],"ran_optional":['
sep=""
for x in "${ran_optional[@]}"; do
printf '%s"%s"' "${sep}" "${x}"
sep=",";
done
printf '],"failed_optional":['
sep=""
for x in "${failed_optional[@]}"; do
printf '%s"%s"' "${sep}" "${x}"
sep=",";
done
printf '],"skipped_optional":['
sep=""
for x in "${skipped_optional[@]}"; do
printf '%s"%s"' "${sep}" "${x}"
sep=",";
done
printf ']}\n'
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
if [ "${#failed_optional[@]}" -gt 0 ]; then
{
echo "### Optional validation failures"
echo "These are non-blocking by policy. Treat as backlog until resolved."
echo ""
printf '%s
' "${failed_optional[@]}"
} >> "${GITHUB_STEP_SUMMARY}"
fi
- name: Build Joomla ZIP (extension type aware, src-only archive)
id: build
run: |
set -euo 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
ZIP="${REPO_NAME}-${VERSION}-${CHANNEL}-${EXT_TYPE}.zip"
zip -r -X "${DIST_DIR}/${ZIP}" src \
-x "src/**/.git/**" \
-x "src/**/.github/**" \
-x "src/**/.DS_Store" \
-x "src/**/__MACOSX/**"
echo "zip_name=${ZIP}" >> "${GITHUB_OUTPUT}"
echo "dist_dir=${DIST_DIR}" >> "${GITHUB_OUTPUT}"
echo "root=src" >> "${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\":\"src\",\"manifest\":\"${MANIFEST}\",\"extension_type\":\"${EXT_TYPE}\",\"zip\":\"${DIST_DIR}/${ZIP}\",\"zip_bytes\":${ZIP_BYTES},\"archive_policy\":\"src_only\"}"
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
- name: ZIP inventory (audit)
run: |
set -euo pipefail
DIST_DIR="${{ steps.build.outputs.dist_dir }}"
ZIP_NAME="${{ steps.build.outputs.zip_name }}"
{
echo "### ZIP inventory"
echo "```"
ls -la "${DIST_DIR}" || true
echo ""
echo "ZIP file list (first 200):"
unzip -l "${DIST_DIR}/${ZIP_NAME}" | head -n 200 || true
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
- name: Upload ZIP to SFTP (key-preferred, password-fallback, overwrite, verified, classified)
id: sftp
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 }}
DEPLOY_DRY_RUN: ${{ vars.DEPLOY_DRY_RUN }}
run: |
set -euo pipefail
ZIP="${{ steps.build.outputs.zip_name }}"
DIST_DIR="${{ steps.build.outputs.dist_dir }}"
: "${FTP_HOST:?Missing secret FTP_HOST}"
: "${FTP_USER:?Missing secret FTP_USER}"
: "${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
if [ -z "${REMOTE_PATH}" ] || [ "${REMOTE_PATH}" = "/" ]; then
echo "ERROR: Unsafe REMOTE_PATH resolved (${REMOTE_PATH:-<empty>})" >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
if printf '%s' "${REMOTE_PATH}" | awk -F/ '{print NF-1}' | grep -Eq '^[01]$'; then
echo "ERROR: Remote path lacks depth guardrail: ${REMOTE_PATH}" >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
AUTH_MODE="password"
if [ -n "${FTP_KEY:-}" ]; then
AUTH_MODE="key"
fi
PASSWORD_PRESENT="$( [ -n "${FTP_PASSWORD:-}" ] && echo true || echo false )"
KEY_PRESENT="$( [ -n "${FTP_KEY:-}" ] && echo true || echo false )"
if [ "${AUTH_MODE}" = "password" ] && [ "${PASSWORD_PRESENT}" != "true" ]; then
echo "ERROR: FTP_PASSWORD required when FTP_KEY is not provided" >> "${GITHUB_STEP_SUMMARY}"
exit 1
fi
DRY_RUN="${DEPLOY_DRY_RUN:-false}"
if [ "${DRY_RUN}" != "true" ]; then
DRY_RUN="false"
fi
{
echo "### Deployment intent"
echo "```json"
printf '{'
printf '"protocol":"sftp",'
printf '"auth_mode":"%s",' "${AUTH_MODE}"
printf '"host":"%s",' "${FTP_HOST}"
printf '"port":"%s",' "${PORT:-default}"
printf '"remote_path":"%s",' "${REMOTE_PATH}"
printf '"overwrite_policy":"same_filename_only",'
printf '"cleanup_policy":"disabled",'
printf '"dry_run":%s,' "${DRY_RUN}"
printf '"zip":"%s",' "${ZIP}"
printf '"credential_presence":{'
printf '"FTP_KEY":"%s",' "$( [ "${KEY_PRESENT}" = "true" ] && echo present || echo missing )"
printf '"FTP_PASSWORD":"%s"' "$( [ "${PASSWORD_PRESENT}" = "true" ] && echo present || echo missing )"
printf '}'
printf '}\n'
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
sudo apt-get update -y
sudo apt-get install -y lftp openssh-client putty-tools
mkdir -p ~/.ssh
chmod 700 ~/.ssh
if [ "${AUTH_MODE}" = "key" ]; then
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
rm -f ~/.ssh/key.ppk
chmod 600 ~/.ssh/id_rsa
else
printf '%s' "${FTP_KEY}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
fi
fi
ssh-keyscan -H "${FTP_HOST}" >> ~/.ssh/known_hosts
if [ "${AUTH_MODE}" = "key" ]; then
CONNECT="set sftp:connect-program 'ssh -a -x -i ~/.ssh/id_rsa -o PubkeyAuthentication=yes -o PasswordAuthentication=no'"
OPEN="open -u '${FTP_USER}', sftp://${HOSTPORT}"
else
CONNECT="set sftp:connect-program 'ssh -a -x -o PubkeyAuthentication=no -o PasswordAuthentication=yes'"
OPEN="open -u '${FTP_USER}','${FTP_PASSWORD}', sftp://${HOSTPORT}"
fi
ZIP_BYTES_LOCAL="$(stat -c%s "${DIST_DIR}/${ZIP}")"
set +e
preflight_log="$(mktemp)"
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; \
${CONNECT}; \
${OPEN}; \
mkdir -p '${REMOTE_PATH}'; \
cd '${REMOTE_PATH}'; \
ls -la; \
bye" >"${preflight_log}" 2>&1
preflight_rc=$?
set -e
if [ "${preflight_rc}" -ne 0 ]; then
{
echo "### SFTP preflight log"
echo "```"
tail -n 400 "${preflight_log}" || true
echo "```"
} >> "${GITHUB_STEP_SUMMARY}" || true
exit "${preflight_rc}"
fi
if [ "${DRY_RUN}" = "true" ]; then
{
echo "### Dry run"
echo "Dry run enabled. Upload skipped."
} >> "${GITHUB_STEP_SUMMARY}"
echo "auth_mode=${AUTH_MODE}" >> "${GITHUB_OUTPUT}"
echo "remote_path=${REMOTE_PATH}" >> "${GITHUB_OUTPUT}"
echo "host=${FTP_HOST}" >> "${GITHUB_OUTPUT}"
echo "port=${PORT:-default}" >> "${GITHUB_OUTPUT}"
exit 0
fi
set +e
upload_log="$(mktemp)"
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; \
${CONNECT}; \
${OPEN}; \
cd '${REMOTE_PATH}'; \
put -E '${DIST_DIR}/${ZIP}'; \
ls -l; \
bye" >"${upload_log}" 2>&1
rc=$?
set -e
if [ "${rc}" -ne 0 ]; then
{
echo "### SFTP session log"
echo "```"
tail -n 400 "${upload_log}" || true
echo "```"
} >> "${GITHUB_STEP_SUMMARY}" || true
exit "${rc}"
fi
{
echo "### SFTP upload report"
echo "```json"
echo "{\"status\":\"ok\",\"protocol\":\"sftp\",\"auth_mode\":\"${AUTH_MODE}\",\"host\":\"${FTP_HOST}\",\"port\":\"${PORT:-default}\",\"remote_path\":\"${REMOTE_PATH}\",\"zip\":\"${ZIP}\",\"zip_bytes_local\":${ZIP_BYTES_LOCAL},\"overwrite\":true,\"cleanup_policy\":\"disabled\"}"
echo "```"
} >> "${GITHUB_STEP_SUMMARY}"
echo "auth_mode=${AUTH_MODE}" >> "${GITHUB_OUTPUT}"
echo "remote_path=${REMOTE_PATH}" >> "${GITHUB_OUTPUT}"
echo "host=${FTP_HOST}" >> "${GITHUB_OUTPUT}"
echo "port=${PORT:-default}" >> "${GITHUB_OUTPUT}"
- name: Create Git tag
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
env:
SFTP_AUTH_MODE: ${{ steps.sftp.outputs.auth_mode }}
SFTP_REMOTE_PATH: ${{ steps.sftp.outputs.remote_path }}
SFTP_HOST: ${{ steps.sftp.outputs.host }}
SFTP_PORT: ${{ steps.sftp.outputs.port }}
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}"
echo ""
echo "Deployment metadata:"
echo "- auth_mode: ${SFTP_AUTH_MODE:-unknown}"
echo "- remote_path: ${SFTP_REMOTE_PATH:-unknown}"
echo "- host: ${SFTP_HOST:-unknown}"
echo "- port: ${SFTP_PORT:-unknown}"
} >> 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:
- name: Report job snapshot (always)
if: ${{ always() }}
run: |
set -euo pipefail
{
echo "### Job snapshot"
echo "```"
echo "event=${GITHUB_EVENT_NAME} ref=${GITHUB_REF_NAME} sha=${GITHUB_SHA} actor=${GITHUB_ACTOR}"
git --version || true
git status --porcelain=v1 || true
git log -1 --pretty=fuller || true
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}"
- name:
- name: Report job snapshot (always)
if: ${{ always() }}
run: |
set -euo pipefail
{
echo "### Job snapshot"
echo "```"
echo "event=${GITHUB_EVENT_NAME} ref=${GITHUB_REF_NAME} sha=${GITHUB_SHA} actor=${GITHUB_ACTOR}"
if [