diff --git a/.github/actions/generate-release-pr-body/action.yaml b/.github/actions/generate-release-pr-body/action.yaml new file mode 100644 index 0000000000..72ccd754e3 --- /dev/null +++ b/.github/actions/generate-release-pr-body/action.yaml @@ -0,0 +1,44 @@ +name: 'Generate Release Notes' +description: 'Generate release notes for a given version' + +inputs: + version: + description: 'The version being released' + required: true + head_ref: + description: 'The commit SHA or reference of the head of the release branch' + required: true + prior_ref: + description: 'The previous version to compare against' + required: true + +outputs: + pr_body_file: + description: 'Path to the generated PR body file' + value: ${{ steps.generate.outputs.pr_body_file }} + +runs: + using: 'composite' + steps: + - name: Generate release notes + id: generate + shell: bash + env: + VERSION: ${{ inputs.version }} + HEAD_REF: ${{ inputs.head_ref }} + PRIOR_REF: ${{ inputs.prior_ref }} + TEMPLATE_FILE: "${{ github.action_path }}/pr_body_template.txt" + run: | + git fetch origin --tags + + { + sed -e "s/{{VERSION}}/${VERSION}/g" \ + -e "s/{{PRIOR_VERSION}}/${PRIOR_REF}/g" \ + "$TEMPLATE_FILE" + git log --pretty=format:"- %s (%h)" --reverse ${PRIOR_REF}..${HEAD_REF} + echo "" + echo "---" + echo "*This release PR was generated automatically.*" + } > pr_body.txt + + echo "pr_body_file=pr_body.txt" >> $GITHUB_OUTPUT diff --git a/.github/actions/generate-release-pr-body/pr_body_template.txt b/.github/actions/generate-release-pr-body/pr_body_template.txt new file mode 100644 index 0000000000..e63e7c2041 --- /dev/null +++ b/.github/actions/generate-release-pr-body/pr_body_template.txt @@ -0,0 +1,19 @@ +# Release v{{VERSION}} + +## How to Release + +Push the release tag to trigger the release: + ```bash + git fetch && git tag v{{VERSION}} origin/release/{{VERSION}} + git push origin v{{VERSION}} + ``` +This PR will auto-merge once the tag is pushed. + +## Important Notes + +- All commits in this release should have corresponding cherry-picks in `main` +- This PR can be closed if the release is not needed. + +## Changes in This Release + +**Comparing:** `{{PRIOR_VERSION}}...v{{VERSION}}` diff --git a/.github/workflows/check-release-pr.yaml b/.github/workflows/check-release-pr.yaml new file mode 100644 index 0000000000..d5462ee417 --- /dev/null +++ b/.github/workflows/check-release-pr.yaml @@ -0,0 +1,57 @@ +name: Check Release PR + +on: + pull_request: + types: + - opened + - synchronize + branches: + - main + +jobs: + check-commits: + runs-on: ubuntu-latest + if: startsWith(github.head_ref, 'release/') + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - name: Check all PR commits exist in main + run: | + git fetch origin main + + CHERRY_OUTPUT=$(git cherry origin/main HEAD) + + if [ -z "$CHERRY_OUTPUT" ]; then + echo "✅ All commits already exist in main" + exit 0 + fi + + echo "Cherry check results:" + echo "$CHERRY_OUTPUT" + + MISSING_COMMITS=$(echo "$CHERRY_OUTPUT" | grep '^+' | cut -d' ' -f2) + + if [ -z "$MISSING_COMMITS" ]; then + echo "✅ All commits exist in main" + exit 0 + fi + + COMMIT_COUNT=$(echo "$MISSING_COMMITS" | wc -l) + FIRST_COMMIT=$(git rev-list --reverse HEAD ^origin/main | head -1) + + if [ "$COMMIT_COUNT" -eq 1 ] && echo "$MISSING_COMMITS" | grep -q "$FIRST_COMMIT"; then + echo "✅ Only version bump commit is unique" + git log --oneline -1 "$FIRST_COMMIT" + else + echo "❌ Found commits that should exist in main:" + for commit in $MISSING_COMMITS; do + if [ "$commit" != "$FIRST_COMMIT" ]; then + git log --oneline -1 "$commit" + fi + done + echo "Make sure commits have equivalents in main. If you've since updated main, re-run this job" + exit 1 + fi diff --git a/.github/workflows/create-release-pr.yaml b/.github/workflows/create-release-pr.yaml new file mode 100644 index 0000000000..7208f4e228 --- /dev/null +++ b/.github/workflows/create-release-pr.yaml @@ -0,0 +1,82 @@ +name: Release Workflow + +on: + workflow_call: + inputs: + bump_type: + description: 'Type of version bump (minor or patch)' + required: true + type: string + target_branch: + description: 'Target branch for the pull request' + required: false + type: string + default: 'main' + +permissions: + contents: write + pull-requests: write + +jobs: + create-release: + runs-on: ubuntu-latest + env: + BUMP_TYPE: ${{ inputs.bump_type }} + TARGET_BRANCH: ${{ inputs.target_branch }} + + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + with: + ref: ${{ inputs.target_branch }} + fetch-depth: 0 # to generate complete release log + + - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + + - name: Validate input and set old version + run: | + if [[ "$BUMP_TYPE" != "minor" && "$BUMP_TYPE" != "patch" ]]; then + echo "Error: bump_type must be 'minor' or 'patch'" + exit 1 + fi + + - name: create release branch + run: | + PRIOR_VERSION=$(just get-tag-version) + if [[ "$BUMP_TYPE" == "minor" ]]; then + VERSION=$(just get-next-minor-version) + else + VERSION=$(just get-next-patch-version) + fi + + echo "prior_ref=v$PRIOR_VERSION" >> $GITHUB_ENV + echo "version=$VERSION" >> $GITHUB_ENV + echo "Version: $VERSION" + + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + + just prepare-release $VERSION + BRANCH_NAME=$(git branch --show-current) + echo "branch_name=$BRANCH_NAME" >> $GITHUB_ENV + echo "Branch: $BRANCH_NAME" + + - name: push release branch + run: | + git push origin "${{ env.branch_name }}" + + - name: Generate release notes + uses: ./.github/actions/generate-release-pr-body + with: + version: ${{ env.version }} + head_ref: ${{ github.event.pull_request.head.sha || github.sha }} + prior_ref: ${{ env.prior_ref }} + + - name: Create Pull Request + run: | + gh pr create \ + -B "$TARGET_BRANCH" \ + -H "${{ env.branch_name }}" \ + --title "chore(release): release version ${{ env.version }} ($BUMP_TYPE)" \ + --body-file pr_body.txt + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/merge-release-pr-on-tag.yaml b/.github/workflows/merge-release-pr-on-tag.yaml new file mode 100644 index 0000000000..cbf122f7c5 --- /dev/null +++ b/.github/workflows/merge-release-pr-on-tag.yaml @@ -0,0 +1,148 @@ +name: Merge release PR on tag push + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + +permissions: + actions: write + contents: write + pull-requests: write + checks: read + +jobs: + trigger-patch-release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + + - name: Extract version from tag + id: version + env: + TAG: ${{ github.ref_name }} + run: | + VERSION=${TAG#v} + BRANCH="release/${VERSION}" + + echo "tag=${TAG}" >> $GITHUB_OUTPUT + echo "version=${VERSION}" >> $GITHUB_OUTPUT + echo "branch=${BRANCH}" >> $GITHUB_OUTPUT + + echo "Tag: ${TAG}" + echo "Version: ${VERSION}" + echo "Expected branch: ${BRANCH}" + + - name: Find matching PR + id: find_pr + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ steps.version.outputs.branch }} + run: | + PR_NUMBER=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') + + if [ -z "$PR_NUMBER" ]; then + echo "❌ No open PR found for branch: $BRANCH" + echo "pr_found=false" >> $GITHUB_OUTPUT + exit 1 + else + echo "✅ Found PR #$PR_NUMBER for branch: $BRANCH" + echo "pr_found=true" >> $GITHUB_OUTPUT + echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT + fi + + - name: Get PR details and check status + if: steps.find_pr.outputs.pr_found == 'true' + id: pr_status + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.find_pr.outputs.pr_number }} + run: | + PR_DATA=$(gh pr view $PR_NUMBER --json title,headRefName,baseRefName,mergeable,statusCheckRollup) + + TITLE=$(echo "$PR_DATA" | jq -r '.title') + HEAD_BRANCH=$(echo "$PR_DATA" | jq -r '.headRefName') + BASE_BRANCH=$(echo "$PR_DATA" | jq -r '.baseRefName') + MERGEABLE=$(echo "$PR_DATA" | jq -r '.mergeable') + + echo "PR Title: $TITLE" + echo "Head Branch: $HEAD_BRANCH" + echo "Base Branch: $BASE_BRANCH" + echo "Mergeable: $MERGEABLE" + + if [ "$MERGEABLE" != "MERGEABLE" ]; then + echo "❌ PR is not in a mergeable state: $MERGEABLE" + echo "can_merge=false" >> $GITHUB_OUTPUT + echo "merge_reason=PR is not mergeable (state: $MERGEABLE)" >> $GITHUB_OUTPUT + exit 0 + fi + + STATUS_CHECKS=$(echo "$PR_DATA" | jq -r '.statusCheckRollup[]? | select(.conclusion != null) | "\(.context): \(.conclusion)"') + FAILED_CHECKS=$(echo "$PR_DATA" | jq -r '.statusCheckRollup[]? | select(.conclusion == "FAILURE" or .conclusion == "CANCELLED" or .conclusion == "TIMED_OUT") | .context') + PENDING_CHECKS=$(echo "$PR_DATA" | jq -r '.statusCheckRollup[]? | select(.conclusion == null) | .context') + + echo "Status checks:" + if [ -n "$STATUS_CHECKS" ]; then + echo "$STATUS_CHECKS" + else + echo "No status checks found" + fi + + if [ -n "$FAILED_CHECKS" ]; then + echo "❌ Failed checks found:" + echo "$FAILED_CHECKS" + echo "can_merge=false" >> $GITHUB_OUTPUT + echo "merge_reason=Some checks are failing" >> $GITHUB_OUTPUT + exit 0 + fi + + if [ -n "$PENDING_CHECKS" ]; then + echo "⏳ Pending checks found:" + echo "$PENDING_CHECKS" + echo "can_merge=false" >> $GITHUB_OUTPUT + echo "merge_reason=Some checks are still pending" >> $GITHUB_OUTPUT + exit 0 + fi + + echo "✅ All checks are passing and PR is ready to merge" + echo "can_merge=true" >> $GITHUB_OUTPUT + + - name: Get branch SHA before merge + if: steps.find_pr.outputs.pr_found == 'true' && steps.pr_status.outputs.can_merge == 'true' + id: branch_info + env: + BRANCH: ${{ steps.version.outputs.branch }} + run: | + git fetch origin + + BRANCH_SHA=$(git rev-parse "origin/$BRANCH") + echo "branch_sha=$BRANCH_SHA" >> $GITHUB_OUTPUT + echo "Branch SHA: $BRANCH_SHA" + + - name: Merge PR + if: steps.find_pr.outputs.pr_found == 'true' && steps.pr_status.outputs.can_merge == 'true' + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ steps.find_pr.outputs.pr_number }} + VERSION: ${{ steps.version.outputs.version }} + TAG: ${{ steps.version.outputs.tag }} + run: | + gh pr merge $PR_NUMBER --squash --delete-branch --subject "Release $VERSION" --body "Auto-merged release PR after tag $TAG was pushed" + + - name: Restore branch + if: steps.find_pr.outputs.pr_found == 'true' && steps.pr_status.outputs.can_merge == 'true' + env: + BRANCH: ${{ steps.version.outputs.branch }} + BRANCH_SHA: ${{ steps.branch_info.outputs.branch_sha }} + run: | + git checkout -b "$BRANCH" "$BRANCH_SHA" + git push origin "$BRANCH" + + - name: Trigger patch release + env: + BRANCH: ${{ steps.version.outputs.branch }} + GH_TOKEN: ${{ github.token }} + run: | + gh workflow run patch-release.yaml \ + --field target_branch=$BRANCH diff --git a/.github/workflows/minor-release.yaml b/.github/workflows/minor-release.yaml new file mode 100644 index 0000000000..99ce40a439 --- /dev/null +++ b/.github/workflows/minor-release.yaml @@ -0,0 +1,16 @@ +name: Create Minor Release PR + +permissions: + contents: write + pull-requests: write + +on: + schedule: + - cron: '0 0 * * 2' + workflow_dispatch: + +jobs: + release: + uses: ./.github/workflows/create-release-pr.yaml + with: + bump_type: "minor" diff --git a/.github/workflows/patch-release.yaml b/.github/workflows/patch-release.yaml new file mode 100644 index 0000000000..29ed3c0760 --- /dev/null +++ b/.github/workflows/patch-release.yaml @@ -0,0 +1,20 @@ +name: Create Patch Release PR + +permissions: + contents: write + pull-requests: write + +on: + workflow_dispatch: + inputs: + target_branch: + description: 'Target branch for hotfix' + required: true + type: string + +jobs: + hotfix: + uses: ./.github/workflows/create-release-pr.yaml + with: + bump_type: "patch" + target_branch: ${{ inputs.target_branch }} diff --git a/.github/workflows/update-release-pr.yaml b/.github/workflows/update-release-pr.yaml new file mode 100644 index 0000000000..83a962212d --- /dev/null +++ b/.github/workflows/update-release-pr.yaml @@ -0,0 +1,65 @@ +name: Update Release Notes + +on: + pull_request: + types: + - synchronize + branches: + - main + - release/** + +permissions: + contents: read + pull-requests: write + +jobs: + update-release-notes: + runs-on: ubuntu-latest + if: startsWith(github.event.pull_request.head.ref, 'release/') && github.event.pull_request.user.login == 'github-actions[bot]' + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + with: + fetch-depth: 0 # to generate complete release log + + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + with: + ref: ${{ github.base_ref }} + path: './prior-version' + + - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + + - name: Extract version from branch name + env: + REF_NAME: ${{ github.head_ref }} + run: | + BRANCH_NAME="$REF_NAME" + VERSION=$(echo "$BRANCH_NAME" | sed 's/release\///') + echo "version=$VERSION" >> $GITHUB_ENV + echo "Version: $VERSION" + + - name: Get prior version + working-directory: './prior-version' + run: | + PRIOR_VERSION=$(just get-tag-version) + echo "prior_ref=v$PRIOR_VERSION" >> $GITHUB_ENV + + - name: Generate release notes + uses: ./.github/actions/generate-release-pr-body + with: + version: ${{ env.version }} + head_ref: ${{ github.event.pull_request.head.sha }} + prior_ref: ${{ env.prior_ref }} + + - name: Update Pull Request + env: + REF_NAME: ${{ github.head_ref }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER=$(gh pr list --head "$REF_NAME" --json number --jq '.[0].number') + + if [[ -z "$PR_NUMBER" || "$PR_NUMBER" == "null" ]]; then + echo "No PR found for branch $REF_NAME" + exit 1 + fi + + gh pr edit "$PR_NUMBER" --body-file pr_body.txt diff --git a/Justfile b/Justfile index ca22e0cd6f..515b9b847a 100644 --- a/Justfile +++ b/Justfile @@ -268,6 +268,12 @@ validate version: exit 1 fi +get-next-minor-version: + @python -c "import sys; v=sys.argv[1].split('.'); print(f'{v[0]}.{int(v[1])+1}.0')" $(just get-tag-version) + +get-next-patch-version: + @python -c "import sys; v=sys.argv[1].split('.'); print(f'{v[0]}.{v[1]}.{int(v[2])+1}')" $(just get-tag-version) + # set cargo and app versions, must be semver prepare-release version: @just validate {{ version }} || exit 1