diff --git a/.editorconfig b/.editorconfig index 07c6fd18b..a80b3ab21 100644 --- a/.editorconfig +++ b/.editorconfig @@ -15,6 +15,7 @@ indent_size = 4 [*.yml] indent_size = 2 +indent_style = space [*.conf] indent_style = space diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ffaa92b7f..bb06b89d8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,167 +1,274 @@ -on: - release: - types: [prereleased, released] +name: Prepare And Publish Release -env: - TAG_NAME: ${{ github.ref_name }} - TARGET_BRANCH: ${{ github.event.release.target_commitish }} +on: + workflow_dispatch: + inputs: + release_tag: + description: "Release tag (e.g. v3.4.5)" + required: true + type: string + target_branch: + description: "Branch to release from" + required: false + default: "master" + type: string + prerelease: + description: "Mark GitHub release as prerelease" + required: false + default: false + type: boolean + publish_only: + description: "Skip preparation and publish from an existing release commit" + required: false + default: false + type: boolean + release_sha: + description: "Optional commit SHA to publish from in publish-only mode (defaults to target branch HEAD)" + required: false + type: string permissions: contents: write pull-requests: write actions: read -name: Release Version Update - jobs: - validate-release-version: - name: Validate Release Version + validate-release-tag: + name: Validate Release Tag runs-on: ubuntu-latest - if: github.event_name == 'release' outputs: - version: ${{ steps.parse-version.outputs.version }} - is-valid: ${{ steps.parse-version.outputs.is-valid }} - + version: ${{ steps.parse_version.outputs.version }} + env: + RELEASE_TAG: ${{ inputs.release_tag }} + TARGET_BRANCH: ${{ inputs.target_branch }} steps: - - name: Checkout + - name: Checkout target branch uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ inputs.target_branch }} + fetch-depth: 0 - - name: Parse and Validate Version - id: parse-version + - name: Parse and validate version + id: parse_version run: | - TAG_NAME="${{ env.TAG_NAME }}" - - # Remove 'v' prefix if present and validate semver format - if [[ $TAG_NAME =~ ^v?([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.-]+)?(\+[a-zA-Z0-9\.-]+)?)$ ]]; then - VERSION=${BASH_REMATCH[1]} - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "is-valid=true" >> $GITHUB_OUTPUT - echo "✅ Valid version format: $VERSION" + set -euo pipefail + + RELEASE_TAG="${{ env.RELEASE_TAG }}" + + # Validate semver format and require the release tag to start with 'v'. + if [[ "$RELEASE_TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9\.-]+)?(\+[a-zA-Z0-9\.-]+)?)$ ]]; then + VERSION="${BASH_REMATCH[1]}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "✅ Valid semver tag: $RELEASE_TAG" else - echo "is-valid=false" >> $GITHUB_OUTPUT - echo "❌ Invalid version format: $TAG_NAME" - echo "Version must follow semver format (e.g., v1.0.0, 1.0.0, v1.0.0-beta.1)" + echo "❌ Invalid version format: $RELEASE_TAG" + echo "ℹ️ Version must start with 'v' and follow semver (e.g., v1.0.0, v1.0.0-beta.1)" exit 1 fi - update-version: - name: Update Version - needs: validate-release-version - runs-on: ubuntu-latest - if: needs.validate-release-version.outputs.is-valid == 'true' + - name: Check local tag availability + if: inputs.publish_only != true + run: | + set -euo pipefail + if git rev-parse -q --verify "refs/tags/${{ env.RELEASE_TAG }}" >/dev/null; then + echo "⚠️ Tag ${{ env.RELEASE_TAG }} already exists locally." + exit 1 + fi + + - name: Check remote tag availability + if: inputs.publish_only != true + run: | + set -euo pipefail + + if git ls-remote --exit-code --tags origin "refs/tags/${{ env.RELEASE_TAG }}" >/dev/null 2>&1; then + echo "⚠️ Tag ${{ env.RELEASE_TAG }} already exists on origin." + echo "Published tags are immutable. Choose a new tag." + exit 1 + fi + + prepare-release: + if: inputs.publish_only != true + name: Prepare Release + runs-on: ubuntu-latest + needs: validate-release-tag + outputs: + release_sha: ${{ steps.resolve_release_sha.outputs.release_sha }} + env: + RELEASE_TAG: ${{ inputs.release_tag }} + TARGET_BRANCH: ${{ inputs.target_branch }} steps: - - name: Checkout + - name: Checkout target branch uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ inputs.target_branch }} fetch-depth: 0 - - name: Setup Git + - name: Setup git identity run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - name: Update Version in cli/version.php + - name: Update version file run: | - VERSION="${{ needs.validate-release-version.outputs.version }}" - - # If this is a prerelease, remove any prerelease tags to get base version - if [ "${{ github.event.action }}" = "prereleased" ]; then - BASE_VERSION=$(echo "$VERSION" | sed 's/-.*$//') - echo "🔄 Prerelease detected: using base version $BASE_VERSION instead of $VERSION" - VERSION="$BASE_VERSION" - fi + set -euo pipefail + VERSION="${{ needs.validate-release-tag.outputs.version }}" CURRENT_VERSION=$(grep -oP "(?<=\\\$version = ')[^']+" cli/version.php) - if [ "$VERSION" = "$CURRENT_VERSION" ]; then - echo "ℹ️ cli/version.php already has version $VERSION, skipping update" + if [[ "$VERSION" == "$CURRENT_VERSION" ]]; then + echo "ℹ️ version.php already set to $VERSION" else - echo "📦 Updating cli/version.php from $CURRENT_VERSION to $VERSION" sed -i "s/\$version = '[^']*';/\$version = '$VERSION';/" cli/version.php - echo "✅ Successfully updated version to $VERSION" + echo "✅ Updated version.php: $CURRENT_VERSION -> $VERSION" fi - - name: Update Changelog Release Heading + - name: Detect changelog file + id: changelog_file run: | - VERSION="${{ needs.validate-release-version.outputs.version }}" - TAG_NAME="${{ env.TAG_NAME }}" - REPO="${{ github.repository }}" - CURRENT_DATE=$(date +%Y-%m-%d) + set -euo pipefail # Check if CHANGELOG.md exists - if [ ! -f "CHANGELOG.md" ]; then - echo "⚠️ CHANGELOG.md not found, skipping changelog update" - exit 0 + if [[ -f "CHANGELOG.md" ]]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "❌ CHANGELOG.md not found. Release requires changelog updates." + exit 1 fi + - name: Detect unreleased changelog heading + id: changelog_unreleased + if: steps.changelog_file.outputs.exists == 'true' + run: | + set -euo pipefail + # Check if Unreleased section exists (with or without a trailing URL) - if ! grep -qP "^## \[Unreleased\]" CHANGELOG.md; then - echo "ℹ️ No [Unreleased] section found in CHANGELOG.md, skipping update" - exit 0 + if grep -qP "^## \[Unreleased\]" CHANGELOG.md; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "❌ No [Unreleased] section found in CHANGELOG.md." + echo "Release requires converting [Unreleased] into the release heading." + exit 1 fi - # Replace the entire [Unreleased] heading line (including any trailing URL) with - # a versioned release heading that links to the tree at the tag. - RELEASE_URL="${{ github.server_url }}/${REPO}/tree/${TAG_NAME}" + - name: Update changelog release heading + if: steps.changelog_unreleased.outputs.exists == 'true' + run: | + set -euo pipefail + + VERSION="${{ needs.validate-release-tag.outputs.version }}" + REPO="${{ github.repository }}" + CURRENT_DATE=$(date +%Y-%m-%d) + + # Replace the entire [Unreleased] heading line (including any trailing URL) + # with a versioned release heading that links to the tree at the tag. + RELEASE_URL="${{ github.server_url }}/${REPO}/tree/${{ env.RELEASE_TAG }}" NEW_HEADING="## [${VERSION}](${RELEASE_URL}) - ${CURRENT_DATE}" sed -i "s|^## \[Unreleased\].*|${NEW_HEADING}|" CHANGELOG.md + echo "✅ Updated CHANGELOG.md [Unreleased] heading to [$VERSION]" + + - name: Verify version metadata + run: | + set -euo pipefail - echo "✅ Updated CHANGELOG.md:" - echo " [Unreleased] → [${VERSION}]" - echo " Date: ${CURRENT_DATE}" - echo " URL: ${RELEASE_URL}" + VERSION="${{ needs.validate-release-tag.outputs.version }}" + FILE_VERSION=$(grep -oP "(?<=\\\$version = ')[^']+" cli/version.php) - - name: Check for Changes - id: git-check + if [[ "$FILE_VERSION" != "$VERSION" ]]; then + echo "❌ version.php mismatch: expected $VERSION, found $FILE_VERSION" + exit 1 + fi + + - name: Verify changelog metadata run: | - # Check for any modified or untracked files - if [ -n "$(git status --porcelain)" ]; then - echo "changes=true" >> $GITHUB_OUTPUT - echo "📝 Changes detected:" - git status --porcelain - else - echo "changes=false" >> $GITHUB_OUTPUT - echo "ℹ️ No changes to commit" + set -euo pipefail + + VERSION="${{ needs.validate-release-tag.outputs.version }}" + + if ! grep -qE "^## \[$VERSION\]" CHANGELOG.md; then + echo "❌ CHANGELOG.md does not contain a release heading for [$VERSION]" + echo "❌ Refusing to release because changelog metadata is incomplete." + exit 1 fi - - name: Create New Branch - if: steps.git-check.outputs.changes == 'true' - id: create-branch - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Verify required release file changes + id: git_check + run: | + set -euo pipefail + + VERSION_CHANGED=true + CHANGELOG_CHANGED=true + + if git diff --quiet -- cli/version.php; then + VERSION_CHANGED=false + fi + + if git diff --quiet -- CHANGELOG.md; then + CHANGELOG_CHANGED=false + fi + + if [[ "$VERSION_CHANGED" != "true" ]]; then + echo "❌ version.php was not changed." + echo "❌ A release requires a version bump." + exit 1 + fi + + if [[ "$CHANGELOG_CHANGED" != "true" ]]; then + echo "❌ CHANGELOG.md was not changed." + echo "❌ A release requires a changelog update." + exit 1 + fi + + echo "changes=true" >> "$GITHUB_OUTPUT" + echo "📝 Required release changes detected:" + git status --porcelain -- cli/version.php CHANGELOG.md + + - name: Prepare release branch name + if: steps.git_check.outputs.changes == 'true' + id: release_branch run: | - set -e # Exit on any error - BRANCH_NAME="release/${{ env.TAG_NAME }}" - echo "branch-name=$BRANCH_NAME" >> $GITHUB_OUTPUT + set -euo pipefail + + # Create a release branch name based on the tag name + BRANCH_NAME="release/${{ env.RELEASE_TAG }}" + echo "branch_name=$BRANCH_NAME" >> "$GITHUB_OUTPUT" + + - name: Check release branch doesn't already exist remotely + if: steps.git_check.outputs.changes == 'true' + run: | + set -euo pipefail + + BRANCH_NAME="${{ steps.release_branch.outputs.branch_name }}" - # Check if branch already exists remotely if git ls-remote --exit-code --heads origin "$BRANCH_NAME" >/dev/null 2>&1; then - echo "❌ Cannot create $BRANCH_NAME branch as it already exists. This may indicate:" - echo " - A previous workflow run failed partway through" - echo " - The branch was manually created" - echo "Please manually delete the branch or check for an existing PR and resolve manually." + echo "❌ Branch $BRANCH_NAME already exists on origin" + echo "Delete the existing branch or use a new tag name." exit 1 fi + echo "✅ Release branch name ($BRANCH_NAME) is available" + + - name: Create release branch + if: steps.git_check.outputs.changes == 'true' + run: | + set -euo pipefail + + BRANCH_NAME="${{ steps.release_branch.outputs.branch_name }}" + # Create and checkout new branch git checkout -b "$BRANCH_NAME" || { - echo "❌ Failed to create branch $BRANCH_NAME" + echo "❌ Failed to create release branch: $BRANCH_NAME" exit 1 } - echo "✅ Created $BRANCH_NAME branch" + echo "✅ Created release branch: $BRANCH_NAME" - - name: Commit Changes - if: steps.git-check.outputs.changes == 'true' - id: commit-changes - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Commit release update changes + if: steps.git_check.outputs.changes == 'true' run: | - set -e # Exit on any error + set -euo pipefail # Stage and commit changes git add cli/version.php CHANGELOG.md || { @@ -169,44 +276,48 @@ jobs: exit 1 } - git commit -m "chore: version bump and update changelog" || { + git commit -m "chore: version bump and update changelog for ${{ env.RELEASE_TAG }}" || { echo "❌ Failed to commit changes" exit 1 } echo "✅ Committed changes" + - name: Push to remote + if: steps.git_check.outputs.changes == 'true' + run: | + set -euo pipefail # Push to remote - BRANCH_NAME="${{ steps.create-branch.outputs.branch-name }}" - git push origin "$BRANCH_NAME" || { - echo "❌ Failed to push changes to remote" + git push -u origin "${{ steps.release_branch.outputs.branch_name }}" || { + echo "❌ Failed to push release branch to remote: ${{ steps.release_branch.outputs.branch_name }}" exit 1 } - echo "✅ Pushed changes to remote on $BRANCH_NAME branch" + echo "✅ Pushed release branch to remote" - - name: Create Pull Request - if: steps.git-check.outputs.changes == 'true' - id: create-pr + - name: Create pull request + if: steps.git_check.outputs.changes == 'true' + id: create_pr env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - set -e # Exit on any error - BRANCH_NAME="${{ steps.create-branch.outputs.branch-name }}" + set -euo pipefail + + BRANCH_NAME="${{ steps.release_branch.outputs.branch_name }}" echo "Creating pull request on $BRANCH_NAME branch into ${{ env.TARGET_BRANCH }}" - # Set PR title and body based on release type + # Determine release type for PR title and body RELEASE_TYPE="release" - if [ "${{ github.event.action }}" = "prereleased" ]; then + if [[ "${{ inputs.prerelease }}" == "true" ]]; then RELEASE_TYPE="pre-release" fi # Define PR details - # ^ capitalises the first letter - PR_TITLE="${RELEASE_TYPE^} ${{ env.TAG_NAME }}" - PR_BODY="Automated version bump for $RELEASE_TYPE ${{ env.TAG_NAME }}." + # ^ capitalizes the first letter + PR_TITLE="${RELEASE_TYPE^} ${{ env.RELEASE_TAG }}" + PR_BODY="Automated version bump and changelog update for $RELEASE_TYPE ${{ env.RELEASE_TAG }}." PR_LABELS="auto version bump,release" - # Create pull request + # Create pull request and get the returned URL PR_URL=$(gh pr create \ --base "${{ env.TARGET_BRANCH }}" \ --head "$BRANCH_NAME" \ @@ -220,64 +331,237 @@ jobs: # Extract PR number from URL and store as output PR_NUMBER=$(echo "$PR_URL" | grep -oP '\d+$') - echo "pr-number=$PR_NUMBER" >> $GITHUB_OUTPUT - echo "pr-url=$PR_URL" >> $GITHUB_OUTPUT - - # Enable GitHub PR auto-merge - gh pr merge "$PR_URL" --auto --squash || { - echo "❌ Failed to enable auto-merge" - echo "This may be due to:" - echo " - Auto-merge not being enabled in repository settings" - echo " - Required status checks not configured" - echo " - Insufficient permissions" + echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "pr_url=$PR_URL" >> "$GITHUB_OUTPUT" + + - name: Verify repository auto-merge is enabled + if: steps.git_check.outputs.changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + # Check if auto-merge is allowed for the repository using GitHub GraphQL API + AUTO_MERGE_ALLOWED=$(gh api graphql \ + -f query='query($owner:String!, $repo:String!){ repository(owner:$owner, name:$repo){ autoMergeAllowed } }' \ + -f owner='${{ github.repository_owner }}' \ + -f repo='${{ github.event.repository.name }}' \ + --jq '.data.repository.autoMergeAllowed') + + if [[ "$AUTO_MERGE_ALLOWED" != "true" ]]; then + echo "❌ Repository auto-merge is disabled." + echo "ℹ️ Enable auto-merge in repository settings, or manually merge the release PR and rerun with publish_only=true." + exit 1 + fi + + - name: Queue pull request for auto-merge + if: steps.git_check.outputs.changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # Merge PR using auto-merge + gh pr merge "${{ steps.create_pr.outputs.pr_url }}" --auto --squash || { + echo "❌ Failed to queue pull request for auto-merge" exit 1 } - echo "✅ Enabled auto-merge for pull request" + echo "✅ Pull request queued for auto-merge" - - name: Wait for PR Merge - if: steps.git-check.outputs.changes == 'true' + - name: Wait for PR merge + if: steps.git_check.outputs.changes == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - PR_NUMBER="${{ steps.create-pr.outputs.pr-number }}" + set -euo pipefail + + PR_NUMBER="${{ steps.create_pr.outputs.pr_number }}" echo "⏳ Waiting for pull request #$PR_NUMBER to be merged..." - # Wait for PR to be merged (timeout after 5 minutes) - TIMEOUT=300 + # Wait for PR to be merged (timeout after 10 minutes) + TIMEOUT=600 ELAPSED=0 INTERVAL=5 - while [ $ELAPSED -lt $TIMEOUT ]; do + while [[ $ELAPSED -lt $TIMEOUT ]]; do # Get PR state and merge status in one call - PR_DATA=$(gh pr view "$PR_NUMBER" --json state,mergedAt 2>/dev/null || echo '{"state":"UNKNOWN","mergedAt":null}') + PR_DATA=$(gh pr view "$PR_NUMBER" --json state,mergedAt) PR_STATE=$(echo "$PR_DATA" | jq -r '.state') PR_MERGED_AT=$(echo "$PR_DATA" | jq -r '.mergedAt') - if [ "$PR_STATE" = "MERGED" ] || [ "$PR_MERGED_AT" != "null" ]; then + if [[ "$PR_STATE" == "MERGED" || "$PR_MERGED_AT" != "null" ]]; then echo "✅ Pull request successfully merged" - break + exit 0 fi - sleep $INTERVAL - ELAPSED=$((ELAPSED + INTERVAL)) echo "⏳ Still waiting... (${ELAPSED}s elapsed)" + sleep "$INTERVAL" + ELAPSED=$((ELAPSED + INTERVAL)) done - if [ $ELAPSED -ge $TIMEOUT ]; then - echo "❌ Timeout waiting for pull request to merge" + echo "❌ Timeout waiting for pull request to merge" + exit 1 + + - name: Resolve release commit SHA + id: resolve_release_sha + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + if [[ "${{ steps.git_check.outputs.changes }}" == "true" ]]; then + RELEASE_SHA=$(gh pr view "${{ steps.create_pr.outputs.pr_number }}" --json mergeCommit --jq '.mergeCommit.oid') + else + git fetch origin "${{ env.TARGET_BRANCH }}" + RELEASE_SHA=$(git rev-parse "origin/${{ env.TARGET_BRANCH }}") + fi + + echo "release_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT" + echo "✅ Resolved release commit SHA: $RELEASE_SHA" + + resolve-release-sha-publish-only: + if: inputs.publish_only == true + name: Resolve Release SHA (Publish Only) + runs-on: ubuntu-latest + needs: validate-release-tag + outputs: + release_sha: ${{ steps.resolve_publish_only_sha.outputs.release_sha }} + env: + TARGET_BRANCH: ${{ inputs.target_branch }} + steps: + - name: Checkout target branch + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ inputs.target_branch }} + fetch-depth: 0 + + - name: Resolve release commit SHA + id: resolve_publish_only_sha + run: | + set -euo pipefail + + INPUT_SHA="${{ inputs.release_sha }}" + + if [[ -n "$INPUT_SHA" ]]; then + RELEASE_SHA="$INPUT_SHA" + else + git fetch origin "${{ env.TARGET_BRANCH }}" + RELEASE_SHA=$(git rev-parse "origin/${{ env.TARGET_BRANCH }}") + fi + + git cat-file -e "$RELEASE_SHA^{commit}" || { + echo "❌ Could not resolve a valid commit SHA for publish-only mode" + exit 1 + } + + echo "release_sha=$RELEASE_SHA" >> "$GITHUB_OUTPUT" + echo "✅ Publish-only release SHA resolved: $RELEASE_SHA" + + publish-release: + name: Publish Release + runs-on: ubuntu-latest + if: always() && needs.validate-release-tag.result == 'success' && (needs.prepare-release.result == 'success' || needs.resolve-release-sha-publish-only.result == 'success') + needs: + - validate-release-tag + - prepare-release + - resolve-release-sha-publish-only + env: + RELEASE_TAG: ${{ inputs.release_tag }} + TARGET_BRANCH: ${{ inputs.target_branch }} + RELEASE_SHA: ${{ inputs.publish_only && needs.resolve-release-sha-publish-only.outputs.release_sha || needs.prepare-release.outputs.release_sha }} + steps: + - name: Checkout target branch + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ref: ${{ inputs.target_branch }} + fetch-depth: 0 + + - name: Fetch target branch + run: | + set -euo pipefail + git fetch origin "${{ env.TARGET_BRANCH }}" + + - name: Verify release commit exists locally + run: | + set -euo pipefail + git cat-file -e "${{ env.RELEASE_SHA }}^{commit}" + echo "✅ Release commit exists locally: ${{ env.RELEASE_SHA }}" + + - name: Detect remote tag state + id: remote_tag + run: | + set -euo pipefail + + TAG_SHA=$(git ls-remote --tags origin "refs/tags/${{ env.RELEASE_TAG }}" | awk '{print $1}') + + # Check if the tag exists remotely and output the result + if [[ -n "$TAG_SHA" ]]; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "tag_sha=$TAG_SHA" >> "$GITHUB_OUTPUT" + echo "ℹ️ Remote tag already exists: ${{ env.RELEASE_TAG }} ($TAG_SHA)" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "tag_sha=" >> "$GITHUB_OUTPUT" + echo "ℹ️ Remote tag does not exist yet: ${{ env.RELEASE_TAG }}" + fi + + - name: Validate remote tag state for normal mode + if: inputs.publish_only != true && steps.remote_tag.outputs.exists == 'true' + run: | + set -euo pipefail + + echo "❌ Tag ${{ env.RELEASE_TAG }} already exists on origin." + echo "Published tags are immutable. Choose a new tag." + exit 1 + + - name: Validate existing tag for publish-only mode + if: inputs.publish_only == true && steps.remote_tag.outputs.exists == 'true' + run: | + set -euo pipefail + + # Check if the existing tag points to the expected release commit + if [[ "${{ steps.remote_tag.outputs.tag_sha }}" != "${{ env.RELEASE_SHA }}" ]]; then + echo "❌ Existing tag ${{ env.RELEASE_TAG }} points to ${{ steps.remote_tag.outputs.tag_sha }}, expected ${{ env.RELEASE_SHA }}" + echo "ℹ️ Refusing to continue to avoid publishing from a mismatched immutable tag." exit 1 fi - - name: Update Tag Reference - if: steps.git-check.outputs.changes == 'true' + echo "✅ Existing immutable tag already points to the expected commit" + + - name: Create immutable tag + if: steps.remote_tag.outputs.exists != 'true' + run: | + set -euo pipefail + git tag "${{ env.RELEASE_TAG }}" "${{ env.RELEASE_SHA }}" + echo "✅ Created immutable tag locally: ${{ env.RELEASE_TAG }}" + + - name: Push immutable tag + if: steps.remote_tag.outputs.exists != 'true' + run: | + set -euo pipefail + git push origin "${{ env.RELEASE_TAG }}" + echo "✅ Pushed immutable tag: ${{ env.RELEASE_TAG }}" + + - name: Publish GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Fetch the latest changes from target branch - git fetch origin ${{ env.TARGET_BRANCH }} - git checkout ${{ env.TARGET_BRANCH }} - git pull origin ${{ env.TARGET_BRANCH }} - - # Move the existing tag to point to the new commit (preserves GitHub release relationship) - git tag ${{ env.TAG_NAME }} -f - git push origin ${{ env.TAG_NAME }} -f - echo "✅ Updated tag ${{ env.TAG_NAME }} to point to release commit" + set -euo pipefail + + RELEASE_ARGS=() + if [[ "${{ inputs.prerelease }}" == "true" ]]; then + RELEASE_ARGS+=(--prerelease) + fi + + gh release create "${{ env.RELEASE_TAG }}" \ + --target "${{ env.RELEASE_SHA }}" \ + --generate-notes \ + "${RELEASE_ARGS[@]}" + + if [[ "${{ inputs.prerelease }}" == "true" ]]; then + echo "✅ Published prerelease: ${{ env.RELEASE_TAG }}" + else + echo "✅ Published release: ${{ env.RELEASE_TAG }}" + fi