diff --git a/.github/workflows/release-calendar.yml b/.github/workflows/release-calendar.yml new file mode 100644 index 000000000..c2f0d402e --- /dev/null +++ b/.github/workflows/release-calendar.yml @@ -0,0 +1,404 @@ +name: Release Calendar + +# Creates release PRs on a schedule or manually via workflow_dispatch. +# +# HOW IT WORKS: +# 1. Creates snapshot branches (release/staging-YYYY-MM-DD or release/production-YYYY-MM-DD) +# 2. Opens PRs from these branches (NOT directly from dev/staging) +# 3. New commits to dev/staging won't auto-update the PR - you control what's released +# +# SCHEDULE: +# • Friday 17:00 UTC (10am PT): Creates release/staging-* branch from dev → staging PR +# • Sunday 17:00 UTC (10am PT): Creates release/production-* branch from staging → main PR +# +# MANUAL TRIGGER: +# Run via workflow_dispatch from main branch: +# - staging: Creates dev snapshot → staging PR +# - production: Creates staging snapshot → main PR +# +# REQUIREMENTS: +# • Scheduled cron only runs when this file exists on the default branch (main) +# • Manual triggers work from any branch, but should run from main for consistency + +on: + workflow_dispatch: + inputs: + job_to_run: + description: "Which job to run (staging: dev→staging, production: staging→main)" + required: false + type: choice + options: + - staging + - production + default: staging + push: + branches: + - dev + paths: + - ".github/workflows/release-calendar.yml" + schedule: + # Friday 17:00 UTC (see timezone conversions above) to prepare the weekend staging PR. + - cron: "0 17 * * 5" + # Sunday 17:00 UTC (same times as above) to prepare the production release PR. + - cron: "0 17 * * 0" + +permissions: + contents: read + pull-requests: write + issues: write # Required for creating labels + +jobs: + release_to_staging: + name: Create dev to staging release PR + runs-on: ubuntu-latest + steps: + - name: Guard Friday schedule + id: guard_schedule + shell: bash + run: | + set -euo pipefail + + # Allow push events (when workflow file is modified) to run + if [ "${{ github.event_name }}" == "push" ]; then + if [ "${{ github.ref_name }}" == "dev" ]; then + echo "Triggered by push event on dev. Running staging job." + echo "continue=true" >> "$GITHUB_OUTPUT" + else + echo "Triggered by push event on non-dev. Skipping staging job." + echo "continue=false" >> "$GITHUB_OUTPUT" + fi + exit 0 + fi + + # Allow workflow_dispatch based on input + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + JOB_TO_RUN="${{ inputs.job_to_run }}" + if [ "$JOB_TO_RUN" == "staging" ]; then + echo "Manual trigger: running staging job (job_to_run=${JOB_TO_RUN})" + echo "continue=true" >> "$GITHUB_OUTPUT" + else + echo "Manual trigger: skipping staging job (job_to_run=${JOB_TO_RUN})" + echo "continue=false" >> "$GITHUB_OUTPUT" + fi + exit 0 + fi + + # For schedule events, check day of week + DOW=$(date -u +%u) + if [ "$DOW" != "5" ]; then + echo "Not Friday in UTC (current day-of-week: $DOW). Exiting job early." + echo "continue=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "continue=true" >> "$GITHUB_OUTPUT" + + - name: Check out repository + if: ${{ steps.guard_schedule.outputs.continue == 'true' }} + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for existing dev to staging PR + if: ${{ steps.guard_schedule.outputs.continue == 'true' }} + id: check_dev_staging + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + PR_DATE=$(date +%Y-%m-%d) + BRANCH_NAME="release/staging-${PR_DATE}" + echo "date=${PR_DATE}" >> "$GITHUB_OUTPUT" + echo "branch_name=${BRANCH_NAME}" >> "$GITHUB_OUTPUT" + + echo "Checking for existing pull requests from ${BRANCH_NAME} to staging..." + EXISTING_PR=$(gh pr list --base staging --head "${BRANCH_NAME}" --state open --limit 1 --json number --jq '.[0].number // ""') + echo "existing_pr=${EXISTING_PR}" >> "$GITHUB_OUTPUT" + + if [ -n "$EXISTING_PR" ]; then + echo "Found existing release PR: #${EXISTING_PR}. Skipping creation." + else + echo "No existing release PR found. Proceeding to create a new one." + fi + + - name: Log existing PR + if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.check_dev_staging.outputs.existing_pr != '' }} + run: | + echo "Release PR already exists: #${{ steps.check_dev_staging.outputs.existing_pr }}" + + - name: Ensure release labels exist + if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.check_dev_staging.outputs.existing_pr == '' }} + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + + for LABEL in release automated staging; do + if ! gh label list --json name --jq '.[].name' | grep -q "^${LABEL}$"; then + echo "Creating missing label: ${LABEL}" + gh label create "${LABEL}" --color BFD4F2 + else + echo "Label ${LABEL} already exists." + fi + done + + - name: Create release branch from dev + if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.check_dev_staging.outputs.existing_pr == '' }} + env: + BRANCH_NAME: ${{ steps.check_dev_staging.outputs.branch_name }} + shell: bash + run: | + set -euo pipefail + + echo "Creating release branch ${BRANCH_NAME} from dev" + git fetch origin dev + git checkout -b "${BRANCH_NAME}" origin/dev + git push origin "${BRANCH_NAME}" + + - name: Create dev to staging release PR + if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.check_dev_staging.outputs.existing_pr == '' }} + env: + GH_TOKEN: ${{ github.token }} + PR_DATE: ${{ steps.check_dev_staging.outputs.date }} + BRANCH_NAME: ${{ steps.check_dev_staging.outputs.branch_name }} + shell: bash + run: | + set -euo pipefail + + python <<'PY' + import os + import pathlib + import textwrap + from datetime import datetime + + pr_date = os.environ["PR_DATE"] + branch_name = os.environ["BRANCH_NAME"] + formatted_date = datetime.strptime(pr_date, "%Y-%m-%d").strftime("%B %d, %Y") + + pathlib.Path("pr_body.md").write_text(textwrap.dedent(f"""\ + ## 🚀 Weekly Release to Staging + + **Release Date:** {formatted_date} + **Release Branch:** `{branch_name}` + + This automated PR promotes a snapshot of `dev` to `staging` for testing. + + ### What's Included + All commits merged to `dev` up to the branch creation time. + + **Note:** This PR uses a dedicated release branch, so new commits to `dev` will NOT automatically appear here. + + ### Review Checklist + - [ ] All CI checks pass + - [ ] Code review completed + - [ ] QA team notified + - [ ] Ready to merge to staging environment + + ### Next Steps + After merging, the staging environment will be updated. A production release PR will be created on Sunday. + + --- + *This PR was automatically created by the Release Calendar workflow on {formatted_date}* + """)) + PY + + TITLE="Release to Staging - ${PR_DATE}" + echo "Creating PR with title: ${TITLE} from branch ${BRANCH_NAME}" + + gh pr create \ + --base staging \ + --head "${BRANCH_NAME}" \ + --title "${TITLE}" \ + --label release \ + --label automated \ + --label staging \ + --body-file pr_body.md + + release_to_production: + name: Create staging to main release PR + runs-on: ubuntu-latest + steps: + - name: Guard Sunday schedule + id: guard_schedule + shell: bash + run: | + set -euo pipefail + + # Skip production job on push events (we only test on dev) + if [ "${{ github.event_name }}" == "push" ]; then + echo "Push event: skipping production job." + echo "continue=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Allow workflow_dispatch based on input + if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + JOB_TO_RUN="${{ inputs.job_to_run }}" + if [ "$JOB_TO_RUN" == "production" ]; then + echo "Manual trigger: running production job (job_to_run=${JOB_TO_RUN})" + echo "continue=true" >> "$GITHUB_OUTPUT" + else + echo "Manual trigger: skipping production job (job_to_run=${JOB_TO_RUN})" + echo "continue=false" >> "$GITHUB_OUTPUT" + fi + exit 0 + fi + + # For schedule events, check day of week + DOW=$(date -u +%u) + if [ "$DOW" != "7" ]; then + echo "Not Sunday in UTC (current day-of-week: $DOW). Exiting job early." + echo "continue=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "continue=true" >> "$GITHUB_OUTPUT" + + - name: Check out repository + if: ${{ steps.guard_schedule.outputs.continue == 'true' }} + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine release readiness + if: ${{ steps.guard_schedule.outputs.continue == 'true' }} + id: production_status + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + + PR_DATE=$(date +%Y-%m-%d) + BRANCH_NAME="release/production-${PR_DATE}" + echo "date=${PR_DATE}" >> "$GITHUB_OUTPUT" + echo "branch_name=${BRANCH_NAME}" >> "$GITHUB_OUTPUT" + + echo "Fetching latest branches..." + git fetch origin main staging + + COMMITS_AHEAD=$(git rev-list --count origin/main..origin/staging) + echo "commits=${COMMITS_AHEAD}" >> "$GITHUB_OUTPUT" + + if [ "$COMMITS_AHEAD" -eq 0 ]; then + echo "staging_not_ahead=true" >> "$GITHUB_OUTPUT" + echo "Staging is up to date with main. No release PR needed." + exit 0 + fi + + echo "staging_not_ahead=false" >> "$GITHUB_OUTPUT" + + echo "Checking for existing pull requests from ${BRANCH_NAME} to main..." + EXISTING_PR=$(gh pr list --base main --head "${BRANCH_NAME}" --state open --limit 1 --json number --jq '.[0].number // ""') + echo "existing_pr=${EXISTING_PR}" >> "$GITHUB_OUTPUT" + + if [ -n "$EXISTING_PR" ]; then + echo "Found existing production release PR: #${EXISTING_PR}. Skipping creation." + else + echo "No existing production release PR found. Ready to create a new one." + fi + + - name: Log staging up to date + if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.production_status.outputs.staging_not_ahead == 'true' }} + run: | + echo "Staging branch is up to date with main. Skipping production release PR creation." + + - name: Log existing PR + if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.production_status.outputs.existing_pr != '' }} + run: | + echo "Production release PR already exists: #${{ steps.production_status.outputs.existing_pr }}" + + - name: Ensure release labels exist + if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.production_status.outputs.staging_not_ahead != 'true' && steps.production_status.outputs.existing_pr == '' }} + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + + for LABEL in release automated production; do + if ! gh label list --json name --jq '.[].name' | grep -q "^${LABEL}$"; then + echo "Creating missing label: ${LABEL}" + gh label create "${LABEL}" --color BFD4F2 + else + echo "Label ${LABEL} already exists." + fi + done + + - name: Create release branch from staging + if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.production_status.outputs.staging_not_ahead != 'true' && steps.production_status.outputs.existing_pr == '' }} + env: + BRANCH_NAME: ${{ steps.production_status.outputs.branch_name }} + shell: bash + run: | + set -euo pipefail + + echo "Creating release branch ${BRANCH_NAME} from staging" + git fetch origin staging + git checkout -b "${BRANCH_NAME}" origin/staging + git push origin "${BRANCH_NAME}" + + - name: Create staging to main release PR + if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.production_status.outputs.staging_not_ahead != 'true' && steps.production_status.outputs.existing_pr == '' }} + env: + GH_TOKEN: ${{ github.token }} + PR_DATE: ${{ steps.production_status.outputs.date }} + BRANCH_NAME: ${{ steps.production_status.outputs.branch_name }} + COMMITS_AHEAD: ${{ steps.production_status.outputs.commits }} + shell: bash + run: | + set -euo pipefail + + python <<'PY' + import os + import pathlib + import textwrap + from datetime import datetime + + commits_ahead = os.environ["COMMITS_AHEAD"] + pr_date = os.environ["PR_DATE"] + branch_name = os.environ["BRANCH_NAME"] + formatted_date = datetime.strptime(pr_date, "%Y-%m-%d").strftime("%B %d, %Y") + + pathlib.Path("pr_body.md").write_text(textwrap.dedent(f"""\ + ## 🎯 Production Release + + **Release Date:** {formatted_date} + **Release Branch:** `{branch_name}` + **Commits ahead**: {commits_ahead} + + This automated PR promotes tested changes from `staging` to `main` for production deployment. + + ### What's Included + All changes that have been verified in the staging environment. + + **Note:** This PR uses a dedicated release branch, so new commits to `staging` will NOT automatically appear here. + + ### Pre-Deployment Checklist + - [ ] All staging tests passed + - [ ] QA sign-off received + - [ ] Stakeholder approval obtained + - [ ] Deployment plan reviewed + - [ ] Rollback plan confirmed + + ### Deployment Notes + Merging this PR will trigger production deployment. + + --- + *This PR was automatically created by the Release Calendar workflow on {formatted_date}* + """)) + PY + + TITLE="Release to Production - ${PR_DATE}" + echo "Creating PR with title: ${TITLE} from branch ${BRANCH_NAME} with ${COMMITS_AHEAD} commits ahead." + + gh pr create \ + --base main \ + --head "${BRANCH_NAME}" \ + --title "${TITLE}" \ + --label release \ + --label automated \ + --label production \ + --body-file pr_body.md