diff --git a/.github/workflows/create-release.yaml b/.github/workflows/create-release.yaml new file mode 100644 index 0000000000..5802aeef7b --- /dev/null +++ b/.github/workflows/create-release.yaml @@ -0,0 +1,269 @@ +name: Create Release + +on: + workflow_dispatch: + inputs: + version_bump: + description: "Version bump type (hotfix branches force 'patch')" + 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 + 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 + with: + app-id: ${{ vars.PUSH_TO_MAIN_APP_ID }} + private-key: ${{ secrets.PUSH_TO_MAIN_APP_PRIVATE_KEY }} + owner: Zipstack + repositories: | + unstract + + - 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." + exit 1 + + - 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: | + 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::Could not find a base release to bump from (mode=$MODE)." + exit 1 + fi + + if [[ ! "$LATEST_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + 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 "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: | + VERSION="${LATEST_TAG#v}" + MAJOR=$(echo "$VERSION" | cut -d. -f1) + MINOR=$(echo "$VERSION" | cut -d. -f2) + PATCH=$(echo "$VERSION" | cut -d. -f3) + + case "$BUMP_TYPE" in + patch) + PATCH=$((PATCH + 1)) + ;; + minor) + MINOR=$((MINOR + 1)) + PATCH=0 + ;; + major) + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + ;; + *) + echo "::error::Unknown bump type: '$BUMP_TYPE'" + exit 1 + ;; + esac + + NEW_TAG="v${MAJOR}.${MINOR}.${PATCH}" + + # 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" + 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: | + RELEASE_ARGS=( + "$NEW_TAG" + --title "$NEW_TAG" + --target "$TARGET_BRANCH" + --generate-notes + --notes-start-tag "$LATEST_TAG" + ) + + # 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 + + 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: 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: | + { + 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 bcf4faa3e3..1b34e106c4 100644 --- a/.github/workflows/production-build.yaml +++ b/.github/workflows/production-build.yaml @@ -19,7 +19,80 @@ on: run-name: "[${{ github.event.release.tag_name || github.event.inputs.tag }}] Docker Image Build and Push (Production)" jobs: + # 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="${RELEASE_TAG:-$INPUT_TAG}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + + - name: Validate tag format + env: + TAG: ${{ steps.get-tag.outputs.tag }} + run: | + 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 (same-line) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEW_TAG: ${{ steps.get-tag.outputs.tag }} + run: | + # 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) + + 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 prior release in line v${NEW_MAJOR_MINOR}.x (new minor/major bump). Skipping increment check." + exit 0 + fi + + 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 + + echo "Previous release on line: $PREV_TAG" + echo "New release: $NEW_TAG" + + 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 on line v${NEW_MAJOR_MINOR}.x." + exit 1 + fi + + echo "Version increment valid: $PREV_TAG -> $NEW_TAG" + build-and-push: + needs: validate-tag runs-on: ubuntu-latest strategy: matrix: @@ -64,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 @@ -129,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