diff --git a/.gitea/workflows/cicd.yaml b/.gitea/workflows/cicd.yaml index a62c8c7..13aa4bf 100644 --- a/.gitea/workflows/cicd.yaml +++ b/.gitea/workflows/cicd.yaml @@ -788,6 +788,7 @@ jobs: dependency-audits: name: Dependency Audits (Informational) runs-on: ubuntu-act + continue-on-error: true timeout-minutes: 15 needs: build_cicd steps: @@ -2465,9 +2466,13 @@ jobs: outputs: release_version: ${{ steps.promote.outputs.release_version }} release_short_sha: ${{ steps.promote.outputs.release_short_sha }} + release_tagged_version: ${{ steps.promote.outputs.release_tagged_version }} + previous_release_version: ${{ steps.promote.outputs.previous_release_version }} backend_release_version_ref: ${{ steps.promote.outputs.backend_release_version_ref }} + backend_release_tagged_ref: ${{ steps.promote.outputs.backend_release_tagged_ref }} backend_release_build_ref: ${{ steps.promote.outputs.backend_release_build_ref }} frontend_release_version_ref: ${{ steps.promote.outputs.frontend_release_version_ref }} + frontend_release_tagged_ref: ${{ steps.promote.outputs.frontend_release_tagged_ref }} frontend_release_build_ref: ${{ steps.promote.outputs.frontend_release_build_ref }} steps: - *identify_runner_step @@ -2485,7 +2490,32 @@ jobs: run: | set -euo pipefail umask 077 - trap 'rm -f /tmp/release_ssh_key' EXIT + TMP_REPO_DIR="$(mktemp -d)" + trap 'rm -f /tmp/release_ssh_key; rm -rf "${TMP_REPO_DIR}"' EXIT + + retry_cmd() { + attempts="${1:-5}" + backoff="${2:-2}" + shift 2 + + attempt=1 + while [ "${attempt}" -le "${attempts}" ]; do + if "$@"; then + return 0 + fi + + if [ "${attempt}" -lt "${attempts}" ]; then + sleep_seconds=$((backoff * attempt)) + echo "Command failed (attempt ${attempt}/${attempts}): $*" + echo "Retrying in ${sleep_seconds}s" + sleep "${sleep_seconds}" + fi + attempt=$((attempt + 1)) + done + + echo "Command failed after ${attempts} attempts: $*" + return 1 + } docker_login_with_retry() { attempts="${1:-5}" @@ -2555,50 +2585,88 @@ jobs: | sort -V \ | tail -n 1)" - if [ -n "${HEAD_SEMVER_TAG}" ]; then - RELEASE_VERSION="${HEAD_SEMVER_TAG}" - else - BASE_TAG="$({ GIT_SSH_COMMAND="${GIT_SSH_COMMAND}" git ls-remote --tags --refs "${GITEA_REPO_SSH_URL}" 2>/dev/null || true; } \ - | awk '{sub("refs/tags/", "", $2); print $2}' \ - | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ - | sort -V \ - | tail -n 1)" - - if [ -z "${BASE_TAG}" ]; then - BASE_TAG="v0.0.0" - fi - - BASE_MAJOR="$(printf '%s' "${BASE_TAG}" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\1/')" - BASE_MINOR="$(printf '%s' "${BASE_TAG}" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\2/')" - BASE_PATCH="$(printf '%s' "${BASE_TAG}" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\3/')" - - EXISTING_TAGS_JSON="$({ curl -fsSL -u "${REGISTRY_USER}:${PACKAGE_ACCESS_TOKEN}" "http://${GITEA_REGISTRY}/v2/darkhelm.org/deployable-backend/tags/list" 2>/dev/null || true; })" - - MAX_PATCH_FOR_LINE="${BASE_PATCH}" - if [ -n "${EXISTING_TAGS_JSON}" ] && command -v python3 >/dev/null 2>&1; then - REGISTRY_MAX_PATCH="$(printf '%s' "${EXISTING_TAGS_JSON}" | python3 -c "import json,re,sys; data=json.load(sys.stdin); tags=data.get('tags') or []; major=int(sys.argv[1]); minor=int(sys.argv[2]); patches=[int(m.group(3)) for tag in tags for m in [re.match(r'^v(\\d+)\\.(\\d+)\\.(\\d+)$', str(tag))] if m and int(m.group(1))==major and int(m.group(2))==minor]; print(max(patches) if patches else -1)" "${BASE_MAJOR}" "${BASE_MINOR}")" - - if [ "${REGISTRY_MAX_PATCH}" -ge "${MAX_PATCH_FOR_LINE}" ]; then - MAX_PATCH_FOR_LINE="${REGISTRY_MAX_PATCH}" - fi - fi - - NEXT_PATCH="$((MAX_PATCH_FOR_LINE + 1))" - RELEASE_VERSION="v${BASE_MAJOR}.${BASE_MINOR}.${NEXT_PATCH}" + if [ -z "${HEAD_SEMVER_TAG}" ]; then + echo "No semver tag found on HEAD (${HEAD_SHA}); skipping release promotion and release notes generation." + echo "release_version=" >> "$GITHUB_OUTPUT" + echo "release_short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "release_tagged_version=" >> "$GITHUB_OUTPUT" + echo "previous_release_version=" >> "$GITHUB_OUTPUT" + echo "backend_release_version_ref=" >> "$GITHUB_OUTPUT" + echo "backend_release_tagged_ref=" >> "$GITHUB_OUTPUT" + echo "backend_release_build_ref=" >> "$GITHUB_OUTPUT" + echo "frontend_release_version_ref=" >> "$GITHUB_OUTPUT" + echo "frontend_release_tagged_ref=" >> "$GITHUB_OUTPUT" + echo "frontend_release_build_ref=" >> "$GITHUB_OUTPUT" + exit 0 fi - RELEASE_BUILD_TAG="${RELEASE_VERSION}-${SHORT_SHA}" + TAG_MAJOR="$(printf '%s' "${HEAD_SEMVER_TAG}" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\1/')" + TAG_MINOR="$(printf '%s' "${HEAD_SEMVER_TAG}" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\2/')" + RELEASE_VERSION="v${TAG_MAJOR}.${TAG_MINOR}.0" + RELEASE_TAGGED_VERSION="${HEAD_SEMVER_TAG}" + RELEASE_BUILD_TAG="${RELEASE_TAGGED_VERSION}-${SHORT_SHA}" + + RELEASE_TAG_LIST="$({ GIT_SSH_COMMAND="${GIT_SSH_COMMAND}" git ls-remote --tags --refs "${GITEA_REPO_SSH_URL}" 2>/dev/null || true; } \ + | awk '{sub("refs/tags/", "", $2); print $2}' \ + | grep -E '^v[0-9]+\.[0-9]+\.0$' \ + | sort -V)" + + PREVIOUS_RELEASE_VERSION="$(printf '%s\n' "${RELEASE_TAG_LIST}" | awk -v current="${RELEASE_VERSION}" 'NF {if ($0 == current) {print prev; found=1; exit} prev=$0} END {if (!found) print prev}')" RELEASE_BACKEND_REPO="${GITEA_REGISTRY}/darkhelm.org/deployable-backend" RELEASE_FRONTEND_REPO="${GITEA_REGISTRY}/darkhelm.org/deployable-frontend" BACKEND_RELEASE_VERSION_REF="${RELEASE_BACKEND_REPO}:${RELEASE_VERSION}" + BACKEND_RELEASE_TAGGED_REF="${RELEASE_BACKEND_REPO}:${RELEASE_TAGGED_VERSION}" BACKEND_RELEASE_BUILD_REF="${RELEASE_BACKEND_REPO}:${RELEASE_BUILD_TAG}" BACKEND_RELEASE_LATEST_REF="${RELEASE_BACKEND_REPO}:latest" FRONTEND_RELEASE_VERSION_REF="${RELEASE_FRONTEND_REPO}:${RELEASE_VERSION}" + FRONTEND_RELEASE_TAGGED_REF="${RELEASE_FRONTEND_REPO}:${RELEASE_TAGGED_VERSION}" FRONTEND_RELEASE_BUILD_REF="${RELEASE_FRONTEND_REPO}:${RELEASE_BUILD_TAG}" FRONTEND_RELEASE_LATEST_REF="${RELEASE_FRONTEND_REPO}:latest" + retry_cmd 5 2 git -C "${TMP_REPO_DIR}" init -q + retry_cmd 5 3 env GIT_SSH_COMMAND="${GIT_SSH_COMMAND}" git -C "${TMP_REPO_DIR}" remote add origin "${GITEA_REPO_SSH_URL}" + retry_cmd 5 3 env GIT_SSH_COMMAND="${GIT_SSH_COMMAND}" git -C "${TMP_REPO_DIR}" fetch --no-tags --depth 200 origin "${HEAD_SHA}" + retry_cmd 5 3 env GIT_SSH_COMMAND="${GIT_SSH_COMMAND}" git -C "${TMP_REPO_DIR}" fetch --tags --force origin + + RELEASE_NOTES_FILE="$(mktemp)" + if [ -n "${PREVIOUS_RELEASE_VERSION}" ]; then + LOG_RANGE="${PREVIOUS_RELEASE_VERSION}..${HEAD_SHA}" + CHANGES_HEADER="Changes since previous release ${PREVIOUS_RELEASE_VERSION}:" + else + LOG_RANGE="${HEAD_SHA}" + CHANGES_HEADER="Changes included in this initial tracked release:" + fi + + { + echo "## Release ${RELEASE_VERSION}" + echo + echo "- Release series tag: ${RELEASE_VERSION}" + echo "- Version tag on commit: ${RELEASE_TAGGED_VERSION}" + echo "- Build tag: ${RELEASE_BUILD_TAG}" + if [ -n "${PREVIOUS_RELEASE_VERSION}" ]; then + echo "- Previous release: ${PREVIOUS_RELEASE_VERSION}" + else + echo "- Previous release: (none)" + fi + echo + echo "${CHANGES_HEADER}" + git -C "${TMP_REPO_DIR}" log --no-merges --pretty='- %s (%h)' "${LOG_RANGE}" || true + } > "${RELEASE_NOTES_FILE}" + + if ! grep -q '^- ' "${RELEASE_NOTES_FILE}"; then + { + echo + echo "- No commit-summary entries were found in the selected range." + } >> "${RELEASE_NOTES_FILE}" + fi + + { + echo "### Generated Release Notes" + cat "${RELEASE_NOTES_FILE}" + } >> "$GITHUB_STEP_SUMMARY" + docker_login_with_retry 5 3 retry_registry_op pull "${STAGING_BACKEND_DIGEST_REF}" 5 3 retry_registry_op pull "${STAGING_FRONTEND_DIGEST_REF}" 5 3 @@ -2607,24 +2675,32 @@ jobs: FRONTEND_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "${STAGING_FRONTEND_DIGEST_REF}")" docker tag "${BACKEND_IMAGE_ID}" "${BACKEND_RELEASE_VERSION_REF}" + docker tag "${BACKEND_IMAGE_ID}" "${BACKEND_RELEASE_TAGGED_REF}" docker tag "${BACKEND_IMAGE_ID}" "${BACKEND_RELEASE_BUILD_REF}" docker tag "${BACKEND_IMAGE_ID}" "${BACKEND_RELEASE_LATEST_REF}" docker tag "${FRONTEND_IMAGE_ID}" "${FRONTEND_RELEASE_VERSION_REF}" + docker tag "${FRONTEND_IMAGE_ID}" "${FRONTEND_RELEASE_TAGGED_REF}" docker tag "${FRONTEND_IMAGE_ID}" "${FRONTEND_RELEASE_BUILD_REF}" docker tag "${FRONTEND_IMAGE_ID}" "${FRONTEND_RELEASE_LATEST_REF}" retry_registry_op push "${BACKEND_RELEASE_VERSION_REF}" 5 4 + retry_registry_op push "${BACKEND_RELEASE_TAGGED_REF}" 5 4 retry_registry_op push "${BACKEND_RELEASE_BUILD_REF}" 5 4 retry_registry_op push "${BACKEND_RELEASE_LATEST_REF}" 5 4 retry_registry_op push "${FRONTEND_RELEASE_VERSION_REF}" 5 4 + retry_registry_op push "${FRONTEND_RELEASE_TAGGED_REF}" 5 4 retry_registry_op push "${FRONTEND_RELEASE_BUILD_REF}" 5 4 retry_registry_op push "${FRONTEND_RELEASE_LATEST_REF}" 5 4 echo "release_version=${RELEASE_VERSION}" >> "$GITHUB_OUTPUT" echo "release_short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + echo "release_tagged_version=${RELEASE_TAGGED_VERSION}" >> "$GITHUB_OUTPUT" + echo "previous_release_version=${PREVIOUS_RELEASE_VERSION}" >> "$GITHUB_OUTPUT" echo "backend_release_version_ref=${BACKEND_RELEASE_VERSION_REF}" >> "$GITHUB_OUTPUT" + echo "backend_release_tagged_ref=${BACKEND_RELEASE_TAGGED_REF}" >> "$GITHUB_OUTPUT" echo "backend_release_build_ref=${BACKEND_RELEASE_BUILD_REF}" >> "$GITHUB_OUTPUT" echo "frontend_release_version_ref=${FRONTEND_RELEASE_VERSION_REF}" >> "$GITHUB_OUTPUT" + echo "frontend_release_tagged_ref=${FRONTEND_RELEASE_TAGGED_REF}" >> "$GITHUB_OUTPUT" echo "frontend_release_build_ref=${FRONTEND_RELEASE_BUILD_REF}" >> "$GITHUB_OUTPUT" - *failure_diagnostics_step diff --git a/docs/CICD_MULTI_STAGE_BUILD.md b/docs/CICD_MULTI_STAGE_BUILD.md index f946dd6..2257f48 100644 --- a/docs/CICD_MULTI_STAGE_BUILD.md +++ b/docs/CICD_MULTI_STAGE_BUILD.md @@ -171,16 +171,19 @@ jobs: promote-release-images: needs: [build_cicd, build-release-images, integration-tests, e2e-tests] # retags staging images to deployable-backend/deployable-frontend with: - # - release version tag (semver) - # - release build tag (-<7-char-short-sha>) + # - release series tag (..0) + # - exact git semver tag from HEAD (authoritative release tag) + # - release build tag (-<7-char-short-sha>) # - latest ``` ### Release Tagging Model -- If the current commit has a semver git tag (`v..`), promotion uses that exact version. -- Otherwise, promotion finds the latest semver baseline and auto-increments patch for the next successful release in that major/minor line. -- Each promoted release also publishes a build-distinguishing tag: `-`, where `` is the 7-character commit shorthand. +- Promotion is release-tag driven: if HEAD has no semver git tag (`v..`), promotion is skipped. +- Release identity is derived from the git tag on HEAD. +- Release series tag is normalized to `..0`. +- Each promoted release also publishes a build-distinguishing tag: `-`, where `` is the 7-character commit shorthand. +- Promotion emits generated release notes summarizing commit subjects since the previous `..0` release tag. ### Responsibility Split diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index ac801fb..243ba15 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -415,7 +415,7 @@ The pipeline is intentionally staged so expensive image jobs run only after sour 4. Runtime validation lanes: `Runtime Black-Box Integration Tests` and `End-to-End Tests` validate staged runtime artifacts. 5. Promotion lane: - `Promote Release Images` retags validated staging artifacts to release repos (`deployable-backend`, `deployable-frontend`) with `latest`, ``, and `-<7-char-short-sha>` tags. + `Promote Release Images` runs only when HEAD has a semver git tag and retags validated staging artifacts to release repos (`deployable-backend`, `deployable-frontend`) with `latest`, `..0`, ``, and `-<7-char-short-sha>` tags. 6. Postmortem lanes: targeted postmortem jobs run when key lanes fail to capture diagnostics even when primary jobs fail early. @@ -426,6 +426,7 @@ Current workflow behavior includes: - registry auth realm host pinning from `WWW-Authenticate` challenge when registry tokens are issued from a different host - bounded retry logic for docker login/pull/push operations in image lanes - digest/tag contract checks for deployable image references before runtime black-box tests +- release-note summary generation in the promotion lane (commit bullets since previous `..0` release tag) - context hydration for image-build lanes by copying `/workspace` from the published CICD image - runner split between `ubuntu-act` and `ubuntu-act-8gb` based on lane resource requirements