From cb60f51b125e28c4a3738051e081372d2d5beaa9 Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 16 Apr 2026 11:07:03 +0530 Subject: [PATCH 1/2] UN-3408 [MISC] Add automated release workflow and version validation Add create-release.yaml for automated release creation with version_bump input (patch/minor/major), confirm_major gate, and dry_run mode. Add validate-tag job to production-build.yaml that blocks Docker builds on malformed tags or unexpected version increments. Prevents recurrence of the v0.152.0 -> v1.53.0 typo incident. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/create-release.yaml | 186 ++++++++++++++++++++++++ .github/workflows/production-build.yaml | 57 ++++++++ 2 files changed, 243 insertions(+) create mode 100644 .github/workflows/create-release.yaml diff --git a/.github/workflows/create-release.yaml b/.github/workflows/create-release.yaml new file mode 100644 index 0000000000..49bea1f817 --- /dev/null +++ b/.github/workflows/create-release.yaml @@ -0,0 +1,186 @@ +name: Create Release + +on: + workflow_dispatch: + inputs: + version_bump: + description: "Version bump type" + required: true + default: "patch" + type: choice + options: + - patch + - minor + - major + confirm_major: + description: "Confirm major version bump (required when 'major' is selected)" + required: false + type: boolean + default: false + pre_release: + description: "Create as pre-release" + required: false + type: boolean + default: false + dry_run: + description: "Dry run - only compute and display the next version without creating a release" + required: false + type: boolean + default: false + +concurrency: + group: release-creation + cancel-in-progress: false + +run-name: "Create Release (${{ github.event.inputs.version_bump }}${{ github.event.inputs.dry_run == 'true' && ' - dry run' || '' }})" + +jobs: + create-release: + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Generate GitHub App Token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.PUSH_TO_MAIN_APP_ID }} + private-key: ${{ secrets.PUSH_TO_MAIN_APP_PRIVATE_KEY }} + owner: Zipstack + repositories: | + unstract + + - name: Checkout repository + uses: actions/checkout@v6 + with: + token: ${{ steps.generate-token.outputs.token }} + fetch-depth: 0 + + - name: Validate major bump confirmation + if: github.event.inputs.version_bump == 'major' && github.event.inputs.confirm_major != 'true' + run: | + echo "::error::Major version bump requires the 'Confirm major version bump' checkbox to be checked." + echo "This is a safety measure to prevent accidental major version changes." + exit 1 + + - name: Fetch latest release version + id: get-latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + LATEST_TAG=$(gh release list --limit 1 --exclude-drafts --exclude-pre-releases --json tagName --jq '.[0].tagName') + + if [[ -z "$LATEST_TAG" ]]; then + echo "::error::No existing releases found." + exit 1 + fi + + # Validate format + if [[ ! "$LATEST_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Latest release tag '$LATEST_TAG' does not match expected format 'vX.Y.Z'" + exit 1 + fi + + echo "latest_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT" + echo "Latest release: $LATEST_TAG" + + - name: Compute next version + id: compute-version + run: | + LATEST_TAG="${{ steps.get-latest.outputs.latest_tag }}" + BUMP_TYPE="${{ github.event.inputs.version_bump }}" + + # Strip 'v' prefix and parse components + VERSION="${LATEST_TAG#v}" + MAJOR=$(echo "$VERSION" | cut -d. -f1) + MINOR=$(echo "$VERSION" | cut -d. -f2) + PATCH=$(echo "$VERSION" | cut -d. -f3) + + echo "Current version: $MAJOR.$MINOR.$PATCH" + + case "$BUMP_TYPE" in + patch) + PATCH=$((PATCH + 1)) + ;; + minor) + MINOR=$((MINOR + 1)) + PATCH=0 + ;; + major) + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + ;; + esac + + NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}" + echo "New version: $NEW_TAG" + + # Validate: new version must be greater than current + CURRENT_NUM="${LATEST_TAG#v}" + NEW_NUM="${NEW_TAG#v}" + if ! printf '%s\n%s\n' "$CURRENT_NUM" "$NEW_NUM" | sort -V -C; then + echo "::error::Computed version $NEW_TAG is not greater than current version $LATEST_TAG" + exit 1 + fi + + echo "new_tag=$NEW_TAG" >> "$GITHUB_OUTPUT" + + - name: Dry run summary + if: github.event.inputs.dry_run == 'true' + run: | + echo "## Dry Run Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Current version | ${{ steps.get-latest.outputs.latest_tag }} |" >> $GITHUB_STEP_SUMMARY + echo "| Bump type | ${{ github.event.inputs.version_bump }} |" >> $GITHUB_STEP_SUMMARY + echo "| Next version | ${{ steps.compute-version.outputs.new_tag }} |" >> $GITHUB_STEP_SUMMARY + echo "| Pre-release | ${{ github.event.inputs.pre_release }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "No release was created (dry run mode)." >> $GITHUB_STEP_SUMMARY + + - name: Create GitHub release + if: github.event.inputs.dry_run != 'true' + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + run: | + NEW_TAG="${{ steps.compute-version.outputs.new_tag }}" + LATEST_TAG="${{ steps.get-latest.outputs.latest_tag }}" + + echo "Creating release $NEW_TAG (previous: $LATEST_TAG)" + + # Build release command + RELEASE_ARGS=( + "$NEW_TAG" + --title "$NEW_TAG" + --target "${{ github.ref_name }}" + --generate-notes + --notes-start-tag "$LATEST_TAG" + ) + + if [[ "${{ github.event.inputs.pre_release }}" == "true" ]]; then + RELEASE_ARGS+=(--prerelease) + fi + + gh release create "${RELEASE_ARGS[@]}" + + echo "Release $NEW_TAG created successfully." + + - name: Summary + if: github.event.inputs.dry_run != 'true' + run: | + NEW_TAG="${{ steps.compute-version.outputs.new_tag }}" + + echo "## Release Created" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY + echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY + echo "| Previous version | ${{ steps.get-latest.outputs.latest_tag }} |" >> $GITHUB_STEP_SUMMARY + echo "| New version | $NEW_TAG |" >> $GITHUB_STEP_SUMMARY + echo "| Bump type | ${{ github.event.inputs.version_bump }} |" >> $GITHUB_STEP_SUMMARY + echo "| Pre-release | ${{ github.event.inputs.pre_release }} |" >> $GITHUB_STEP_SUMMARY + echo "| Triggered by | ${{ github.actor }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "The [production build](${{ github.server_url }}/${{ github.repository }}/actions/workflows/production-build.yaml) workflow will be triggered automatically." >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/production-build.yaml b/.github/workflows/production-build.yaml index bcf4faa3e3..a37017d2fb 100644 --- a/.github/workflows/production-build.yaml +++ b/.github/workflows/production-build.yaml @@ -19,7 +19,64 @@ on: run-name: "[${{ github.event.release.tag_name || github.event.inputs.tag }}] Docker Image Build and Push (Production)" jobs: + # Safety-net validation. Primary protection is the GitHub tag protection ruleset + # restricting v* tag creation to the release bot. This job is defense-in-depth for + # the workflow_dispatch tag input and any future bypass of the ruleset. + validate-tag: + runs-on: ubuntu-latest + steps: + - name: Determine tag + id: get-tag + run: | + TAG="${{ github.event.release.tag_name || github.event.inputs.tag }}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Validate tag format + run: | + TAG="${{ steps.get-tag.outputs.tag }}" + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Tag '$TAG' does not match expected format 'vX.Y.Z'." + exit 1 + fi + echo "Tag format valid: $TAG" + + - name: Validate version increment + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + NEW_TAG="${{ steps.get-tag.outputs.tag }}" + + # Get the previous release (skip the one just created) + PREV_TAG=$(gh release list --repo "${{ github.repository }}" --limit 2 --exclude-drafts --exclude-pre-releases --json tagName --jq '.[1].tagName') + + if [[ -z "$PREV_TAG" ]]; then + echo "No previous release found, skipping version increment check." + exit 0 + fi + + echo "Previous release: $PREV_TAG" + echo "New release: $NEW_TAG" + + NEW_VER="${NEW_TAG#v}" + PREV_VER="${PREV_TAG#v}" + NEW_MAJOR=$(echo "$NEW_VER" | cut -d. -f1) + PREV_MAJOR=$(echo "$PREV_VER" | cut -d. -f1) + + if [[ "$NEW_MAJOR" != "$PREV_MAJOR" ]]; then + echo "::error::Unexpected major version change: $PREV_TAG -> $NEW_TAG. Use the 'Create Release' workflow with the 'major' bump type if this is intentional." + exit 1 + fi + + if ! printf '%s\n%s\n' "$PREV_VER" "$NEW_VER" | sort -V -C; then + echo "::error::New version $NEW_TAG is not greater than previous version $PREV_TAG." + exit 1 + fi + + echo "Version increment valid: $PREV_TAG -> $NEW_TAG" + build-and-push: + needs: validate-tag runs-on: ubuntu-latest strategy: matrix: From a274f13bf18927757dee37d077e6f1de28b8754d Mon Sep 17 00:00:00 2001 From: ali Date: Thu, 16 Apr 2026 12:31:24 +0530 Subject: [PATCH 2/2] UN-3408 [MISC] Address PR review: hotfix support, line-aware validation, safety fixes - create-release.yaml: branch-aware mode (main vs vX.Y.Z-hotfix), uses gh api releases/latest for main (immune to hotfix creation-date ordering), same-line lookup for hotfixes, explicit --latest flag per Hotfix Guide - Pre-release/draft collision check before creating a release - Equal-version guard (sort -V -C alone treats equal as valid) - Default case in bump type switch - Summary step gated on success() - Removed unused checkout step - set -euo pipefail via job defaults - production-build.yaml: same-line increment check (handles hotfixes correctly without contradicting create-release major bumps) - jq null -> empty via '// empty' pattern - workflow_dispatch now also validates increment (was bypass) - build-summary gated on validate-tag success - Set version tag uses env block (avoids user-input interpolation in run) - PREV_TAG format regex check before parsing Addresses review feedback from jaseemjaskp, coderabbitai, greptile-apps on PR #1915. Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/create-release.yaml | 193 +++++++++++++++++------- .github/workflows/production-build.yaml | 73 +++++---- 2 files changed, 185 insertions(+), 81 deletions(-) diff --git a/.github/workflows/create-release.yaml b/.github/workflows/create-release.yaml index 49bea1f817..5802aeef7b 100644 --- a/.github/workflows/create-release.yaml +++ b/.github/workflows/create-release.yaml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: version_bump: - description: "Version bump type" + description: "Version bump type (hotfix branches force 'patch')" required: true default: "patch" type: choice @@ -39,8 +39,14 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + defaults: + run: + shell: bash -euo pipefail {0} steps: + # A GitHub App token is required (not the default GITHUB_TOKEN) because releases + # created with GITHUB_TOKEN do not trigger downstream `release: created` workflows + # due to GitHub's anti-recursion policy. Same pattern used in unstract-cloud. - name: Generate GitHub App Token id: generate-token uses: actions/create-github-app-token@v2 @@ -51,54 +57,86 @@ jobs: repositories: | unstract - - name: Checkout repository - uses: actions/checkout@v6 - with: - token: ${{ steps.generate-token.outputs.token }} - fetch-depth: 0 + - name: Validate branch and determine release mode + id: branch-mode + env: + BRANCH: ${{ github.ref_name }} + run: | + if [[ "$BRANCH" == "main" ]]; then + MODE=main + HOTFIX_LINE="" + elif [[ "$BRANCH" =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)-hotfix$ ]]; then + MODE=hotfix + HOTFIX_LINE="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" + echo "Hotfix branch detected. Line: v${HOTFIX_LINE}.x" + else + echo "::error::Releases must be cut from 'main' or a 'vX.Y.Z-hotfix' branch. Got: '$BRANCH'" + exit 1 + fi + + echo "mode=$MODE" >> "$GITHUB_OUTPUT" + echo "hotfix_line=$HOTFIX_LINE" >> "$GITHUB_OUTPUT" + + - name: Validate inputs for hotfix mode + if: steps.branch-mode.outputs.mode == 'hotfix' + env: + BUMP: ${{ github.event.inputs.version_bump }} + run: | + if [[ "$BUMP" != "patch" ]]; then + echo "::error::Hotfix branches only support 'patch' bumps. Got: '$BUMP'" + exit 1 + fi - name: Validate major bump confirmation if: github.event.inputs.version_bump == 'major' && github.event.inputs.confirm_major != 'true' run: | echo "::error::Major version bump requires the 'Confirm major version bump' checkbox to be checked." - echo "This is a safety measure to prevent accidental major version changes." exit 1 - - name: Fetch latest release version + - name: Fetch base release version id: get-latest env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + MODE: ${{ steps.branch-mode.outputs.mode }} + HOTFIX_LINE: ${{ steps.branch-mode.outputs.hotfix_line }} run: | - LATEST_TAG=$(gh release list --limit 1 --exclude-drafts --exclude-pre-releases --json tagName --jq '.[0].tagName') + if [[ "$MODE" == "main" ]]; then + # Use the "latest" API (releases/latest) which respects the explicit + # "Set as latest release" flag. Hotfix releases are created with + # --latest=false, so this never returns a hotfix tag. + LATEST_TAG=$(gh api "repos/${{ github.repository }}/releases/latest" --jq '.tag_name // empty') + else + # For hotfixes, find the latest tag on this specific v..x line + LATEST_TAG=$(gh release list --repo "${{ github.repository }}" \ + --limit 100 --exclude-drafts --exclude-pre-releases \ + --json tagName \ + --jq "[.[] | select(.tagName | startswith(\"v${HOTFIX_LINE}.\"))] | .[0].tagName // empty") + fi if [[ -z "$LATEST_TAG" ]]; then - echo "::error::No existing releases found." + echo "::error::Could not find a base release to bump from (mode=$MODE)." exit 1 fi - # Validate format if [[ ! "$LATEST_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::Latest release tag '$LATEST_TAG' does not match expected format 'vX.Y.Z'" + echo "::error::Base release tag '$LATEST_TAG' does not match expected format 'vX.Y.Z'" exit 1 fi echo "latest_tag=$LATEST_TAG" >> "$GITHUB_OUTPUT" - echo "Latest release: $LATEST_TAG" + echo "Base release: $LATEST_TAG" - name: Compute next version id: compute-version + env: + LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }} + BUMP_TYPE: ${{ github.event.inputs.version_bump }} run: | - LATEST_TAG="${{ steps.get-latest.outputs.latest_tag }}" - BUMP_TYPE="${{ github.event.inputs.version_bump }}" - - # Strip 'v' prefix and parse components VERSION="${LATEST_TAG#v}" MAJOR=$(echo "$VERSION" | cut -d. -f1) MINOR=$(echo "$VERSION" | cut -d. -f2) PATCH=$(echo "$VERSION" | cut -d. -f3) - echo "Current version: $MAJOR.$MINOR.$PATCH" - case "$BUMP_TYPE" in patch) PATCH=$((PATCH + 1)) @@ -112,75 +150,120 @@ jobs: MINOR=0 PATCH=0 ;; + *) + echo "::error::Unknown bump type: '$BUMP_TYPE'" + exit 1 + ;; esac NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}" - echo "New version: $NEW_TAG" - # Validate: new version must be greater than current + # Strict monotonic increase: equal is NOT allowed, sort -V -C alone + # accepts equal values (it checks non-decreasing order). CURRENT_NUM="${LATEST_TAG#v}" NEW_NUM="${NEW_TAG#v}" + if [[ "$CURRENT_NUM" == "$NEW_NUM" ]]; then + echo "::error::Computed version $NEW_TAG equals current version $LATEST_TAG" + exit 1 + fi if ! printf '%s\n%s\n' "$CURRENT_NUM" "$NEW_NUM" | sort -V -C; then echo "::error::Computed version $NEW_TAG is not greater than current version $LATEST_TAG" exit 1 fi echo "new_tag=$NEW_TAG" >> "$GITHUB_OUTPUT" + echo "Current: $LATEST_TAG -> New: $NEW_TAG" + + - name: Check for tag collision + id: collision-check + env: + GH_TOKEN: ${{ steps.generate-token.outputs.token }} + NEW_TAG: ${{ steps.compute-version.outputs.new_tag }} + run: | + # Guard against collisions with existing pre-releases or drafts + # (which are hidden by --exclude-pre-releases in the earlier lookup). + if gh release view "$NEW_TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "::error::A release with tag '$NEW_TAG' already exists (possibly as a pre-release or draft). Delete it first or pick a different bump type." + exit 1 + fi - name: Dry run summary if: github.event.inputs.dry_run == 'true' + env: + LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }} + NEW_TAG: ${{ steps.compute-version.outputs.new_tag }} + BUMP: ${{ github.event.inputs.version_bump }} + PRERELEASE: ${{ github.event.inputs.pre_release }} + MODE: ${{ steps.branch-mode.outputs.mode }} run: | - echo "## Dry Run Summary" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY - echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY - echo "| Current version | ${{ steps.get-latest.outputs.latest_tag }} |" >> $GITHUB_STEP_SUMMARY - echo "| Bump type | ${{ github.event.inputs.version_bump }} |" >> $GITHUB_STEP_SUMMARY - echo "| Next version | ${{ steps.compute-version.outputs.new_tag }} |" >> $GITHUB_STEP_SUMMARY - echo "| Pre-release | ${{ github.event.inputs.pre_release }} |" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "No release was created (dry run mode)." >> $GITHUB_STEP_SUMMARY + { + echo "## Dry Run Summary" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| Mode | $MODE |" + echo "| Current version | $LATEST_TAG |" + echo "| Bump type | $BUMP |" + echo "| Next version | $NEW_TAG |" + echo "| Pre-release | $PRERELEASE |" + echo "" + echo "No release was created (dry run mode)." + } >> "$GITHUB_STEP_SUMMARY" - name: Create GitHub release + id: create-release if: github.event.inputs.dry_run != 'true' env: GH_TOKEN: ${{ steps.generate-token.outputs.token }} + NEW_TAG: ${{ steps.compute-version.outputs.new_tag }} + LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }} + MODE: ${{ steps.branch-mode.outputs.mode }} + PRERELEASE: ${{ github.event.inputs.pre_release }} + TARGET_BRANCH: ${{ github.ref_name }} run: | - NEW_TAG="${{ steps.compute-version.outputs.new_tag }}" - LATEST_TAG="${{ steps.get-latest.outputs.latest_tag }}" - - echo "Creating release $NEW_TAG (previous: $LATEST_TAG)" - - # Build release command RELEASE_ARGS=( "$NEW_TAG" --title "$NEW_TAG" - --target "${{ github.ref_name }}" + --target "$TARGET_BRANCH" --generate-notes --notes-start-tag "$LATEST_TAG" ) - if [[ "${{ github.event.inputs.pre_release }}" == "true" ]]; then - RELEASE_ARGS+=(--prerelease) + # Only mark as "latest" on main releases; hotfixes never override latest + # (matches the convention documented in the Hotfix Deployment Guide). + if [[ "$MODE" == "main" ]]; then + RELEASE_ARGS+=(--latest=true) + else + RELEASE_ARGS+=(--latest=false) fi - gh release create "${RELEASE_ARGS[@]}" + if [[ "$PRERELEASE" == "true" ]]; then + RELEASE_ARGS+=(--prerelease) + fi + gh release create --repo "${{ github.repository }}" "${RELEASE_ARGS[@]}" echo "Release $NEW_TAG created successfully." - name: Summary - if: github.event.inputs.dry_run != 'true' + if: success() && github.event.inputs.dry_run != 'true' + env: + LATEST_TAG: ${{ steps.get-latest.outputs.latest_tag }} + NEW_TAG: ${{ steps.compute-version.outputs.new_tag }} + BUMP: ${{ github.event.inputs.version_bump }} + PRERELEASE: ${{ github.event.inputs.pre_release }} + MODE: ${{ steps.branch-mode.outputs.mode }} run: | - NEW_TAG="${{ steps.compute-version.outputs.new_tag }}" - - echo "## Release Created" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Field | Value |" >> $GITHUB_STEP_SUMMARY - echo "|-------|-------|" >> $GITHUB_STEP_SUMMARY - echo "| Previous version | ${{ steps.get-latest.outputs.latest_tag }} |" >> $GITHUB_STEP_SUMMARY - echo "| New version | $NEW_TAG |" >> $GITHUB_STEP_SUMMARY - echo "| Bump type | ${{ github.event.inputs.version_bump }} |" >> $GITHUB_STEP_SUMMARY - echo "| Pre-release | ${{ github.event.inputs.pre_release }} |" >> $GITHUB_STEP_SUMMARY - echo "| Triggered by | ${{ github.actor }} |" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "The [production build](${{ github.server_url }}/${{ github.repository }}/actions/workflows/production-build.yaml) workflow will be triggered automatically." >> $GITHUB_STEP_SUMMARY + { + echo "## Release Created" + echo "" + echo "| Field | Value |" + echo "|-------|-------|" + echo "| Mode | $MODE |" + echo "| Previous version | $LATEST_TAG |" + echo "| New version | $NEW_TAG |" + echo "| Bump type | $BUMP |" + echo "| Pre-release | $PRERELEASE |" + echo "| Triggered by | ${{ github.actor }} |" + echo "" + echo "The [production build](${{ github.server_url }}/${{ github.repository }}/actions/workflows/production-build.yaml) workflow will be triggered automatically." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/production-build.yaml b/.github/workflows/production-build.yaml index a37017d2fb..1b34e106c4 100644 --- a/.github/workflows/production-build.yaml +++ b/.github/workflows/production-build.yaml @@ -19,57 +19,73 @@ on: run-name: "[${{ github.event.release.tag_name || github.event.inputs.tag }}] Docker Image Build and Push (Production)" jobs: - # Safety-net validation. Primary protection is the GitHub tag protection ruleset - # restricting v* tag creation to the release bot. This job is defense-in-depth for - # the workflow_dispatch tag input and any future bypass of the ruleset. + # Validates tag format and version monotonicity within the same major.minor line. + # This guards the workflow_dispatch tag input and any release event. + # The primary defense against bad tags should be a GitHub tag protection ruleset + # restricting v* tag creation to the release bot; this job is the in-workflow + # safety net that runs regardless. validate-tag: runs-on: ubuntu-latest + defaults: + run: + shell: bash -euo pipefail {0} steps: - name: Determine tag id: get-tag + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ github.event.inputs.tag }} run: | - TAG="${{ github.event.release.tag_name || github.event.inputs.tag }}" + TAG="${RELEASE_TAG:-$INPUT_TAG}" echo "tag=$TAG" >> "$GITHUB_OUTPUT" - name: Validate tag format + env: + TAG: ${{ steps.get-tag.outputs.tag }} run: | - TAG="${{ steps.get-tag.outputs.tag }}" if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "::error::Tag '$TAG' does not match expected format 'vX.Y.Z'." exit 1 fi echo "Tag format valid: $TAG" - - name: Validate version increment - if: github.event_name == 'release' + - name: Validate version increment (same-line) env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEW_TAG: ${{ steps.get-tag.outputs.tag }} run: | - NEW_TAG="${{ steps.get-tag.outputs.tag }}" + # Find the previous release on the same major.minor line. This handles + # both normal releases (next patch/minor on main) and hotfixes on older + # lines, which are NOT marked as "latest" and would otherwise compare + # incorrectly against the unrelated main-line latest. + NEW_VER="${NEW_TAG#v}" + NEW_MAJOR_MINOR=$(echo "$NEW_VER" | cut -d. -f1-2) - # Get the previous release (skip the one just created) - PREV_TAG=$(gh release list --repo "${{ github.repository }}" --limit 2 --exclude-drafts --exclude-pre-releases --json tagName --jq '.[1].tagName') + PREV_TAG=$(gh release list --repo "${{ github.repository }}" \ + --limit 100 --exclude-drafts --exclude-pre-releases \ + --json tagName \ + --jq "[.[] | select(.tagName != \"$NEW_TAG\" and (.tagName | startswith(\"v${NEW_MAJOR_MINOR}.\")))] | .[0].tagName // empty") if [[ -z "$PREV_TAG" ]]; then - echo "No previous release found, skipping version increment check." + echo "No prior release in line v${NEW_MAJOR_MINOR}.x (new minor/major bump). Skipping increment check." exit 0 fi - echo "Previous release: $PREV_TAG" - echo "New release: $NEW_TAG" + if [[ ! "$PREV_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Previous tag '$PREV_TAG' has unexpected format. Refusing to compare." + exit 1 + fi - NEW_VER="${NEW_TAG#v}" - PREV_VER="${PREV_TAG#v}" - NEW_MAJOR=$(echo "$NEW_VER" | cut -d. -f1) - PREV_MAJOR=$(echo "$PREV_VER" | cut -d. -f1) + echo "Previous release on line: $PREV_TAG" + echo "New release: $NEW_TAG" - if [[ "$NEW_MAJOR" != "$PREV_MAJOR" ]]; then - echo "::error::Unexpected major version change: $PREV_TAG -> $NEW_TAG. Use the 'Create Release' workflow with the 'major' bump type if this is intentional." + PREV_VER="${PREV_TAG#v}" + if [[ "$PREV_VER" == "$NEW_VER" ]]; then + echo "::error::New version $NEW_TAG equals previous version $PREV_TAG on the same line." exit 1 fi - if ! printf '%s\n%s\n' "$PREV_VER" "$NEW_VER" | sort -V -C; then - echo "::error::New version $NEW_TAG is not greater than previous version $PREV_TAG." + echo "::error::New version $NEW_TAG is not greater than previous version $PREV_TAG on line v${NEW_MAJOR_MINOR}.x." exit 1 fi @@ -121,10 +137,14 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - # Set version tag based on event type + # Set version tag based on event type. Uses env: to avoid interpolating + # user input directly into the run block. - name: Set version tag id: set-tag - run: echo "DOCKER_VERSION_TAG=${{ github.event.release.tag_name || github.event.inputs.tag }}" >> $GITHUB_ENV + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ github.event.inputs.tag }} + run: echo "DOCKER_VERSION_TAG=${RELEASE_TAG:-$INPUT_TAG}" >> "$GITHUB_ENV" # Set up additional tags for release builds - name: Set image tags @@ -186,11 +206,12 @@ jobs: path: build-status/${{ matrix.service_name }}.json retention-days: 1 - # Summary job that runs after all builds + # Summary job that runs after all builds. Depends on validate-tag so it doesn't + # produce misleading output when validation blocked the build. build-summary: - needs: build-and-push + needs: [validate-tag, build-and-push] runs-on: ubuntu-latest - if: always() + if: always() && needs.validate-tag.result == 'success' steps: # Download all build status artifacts - name: Download build statuses