name: Release Calendar # Creates release PRs on a schedule or manually via workflow_dispatch. # # HOW IT WORKS: # 1. Dev → Staging: Creates snapshot branch (release/staging-YYYY-MM-DD) to prevent PR drift # 2. Staging → Main: Opens PR directly from staging (no feature branch needed) # 3. New commits to dev won't auto-update staging PRs - 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 staging → main PR (direct from staging) # # MANUAL TRIGGER: # Run via workflow_dispatch from main branch: # - staging: Creates dev snapshot → staging PR # - production: Creates staging → main PR (direct from staging) # # 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 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" jobs: release_to_staging: name: Create dev to staging release PR runs-on: ubuntu-latest permissions: contents: write pull-requests: write issues: write steps: - name: Guard Friday schedule id: guard_schedule shell: bash run: | set -euo pipefail # 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@v6 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: ${{ secrets.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: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail for LABEL in release automated staging; do echo "Ensuring label exists: ${LABEL}" gh label create "${LABEL}" --color BFD4F2 --force || true 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 # Check if branch already exists locally if git show-ref --verify --quiet refs/heads/"${BRANCH_NAME}"; then echo "Branch ${BRANCH_NAME} already exists locally, checking out..." git checkout "${BRANCH_NAME}" else git checkout -b "${BRANCH_NAME}" origin/dev fi # Check if branch already exists on remote if git ls-remote --heads origin "${BRANCH_NAME}" | grep -q "${BRANCH_NAME}"; then echo "Branch ${BRANCH_NAME} already exists on remote. Skipping push." else echo "Pushing branch ${BRANCH_NAME} to remote..." if ! git push origin "${BRANCH_NAME}"; then echo "❌ ERROR: Failed to push branch ${BRANCH_NAME} to remote" exit 1 fi echo "✓ Successfully pushed branch ${BRANCH_NAME}" fi - name: Read app version if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.check_dev_staging.outputs.existing_pr == '' }} id: app_version shell: bash run: | set -euo pipefail python - <> "$GITHUB_OUTPUT" import json import pathlib package_json = pathlib.Path("app/package.json") version = json.loads(package_json.read_text())["version"] print(f"app_version={version}") PY - name: Create dev to staging release PR if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.check_dev_staging.outputs.existing_pr == '' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_DATE: ${{ steps.check_dev_staging.outputs.date }} BRANCH_NAME: ${{ steps.check_dev_staging.outputs.branch_name }} APP_VERSION: ${{ steps.app_version.outputs.app_version }} 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 v${APP_VERSION} - ${PR_DATE}" echo "Creating PR with title: ${TITLE} from branch ${BRANCH_NAME}" if ! gh pr create \ --base staging \ --head "${BRANCH_NAME}" \ --title "${TITLE}" \ --label release \ --label automated \ --label staging \ --body-file pr_body.md; then echo "❌ ERROR: Failed to create PR" exit 1 fi echo "✅ PR created successfully" release_to_production: name: Create staging to main release PR runs-on: ubuntu-latest permissions: contents: write pull-requests: write issues: write steps: - name: Guard Sunday schedule id: guard_schedule shell: bash run: | set -euo pipefail # 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@v6 with: fetch-depth: 0 - name: Determine release readiness if: ${{ steps.guard_schedule.outputs.continue == 'true' }} id: production_status env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail PR_DATE=$(date +%Y-%m-%d) echo "date=${PR_DATE}" >> "$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 staging to main..." EXISTING_PR=$(gh pr list --base main --head staging --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: ${{ secrets.GITHUB_TOKEN }} shell: bash run: | set -euo pipefail for LABEL in release automated production; do echo "Ensuring label exists: ${LABEL}" gh label create "${LABEL}" --color BFD4F2 --force || true done - name: Read app version if: ${{ steps.guard_schedule.outputs.continue == 'true' && steps.production_status.outputs.staging_not_ahead != 'true' && steps.production_status.outputs.existing_pr == '' }} id: app_version shell: bash run: | set -euo pipefail python - <> "$GITHUB_OUTPUT" import json import pathlib package_json = pathlib.Path("app/package.json") version = json.loads(package_json.read_text())["version"] print(f"app_version={version}") PY - 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: ${{ secrets.GITHUB_TOKEN }} PR_DATE: ${{ steps.production_status.outputs.date }} COMMITS_AHEAD: ${{ steps.production_status.outputs.commits }} APP_VERSION: ${{ steps.app_version.outputs.app_version }} 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"] 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} **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 is directly from `staging`, so new commits merged to `staging` will 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 v${APP_VERSION} - ${PR_DATE}" echo "Creating PR with title: ${TITLE} from staging with ${COMMITS_AHEAD} commits ahead." gh pr create \ --base main \ --head staging \ --title "${TITLE}" \ --label release \ --label automated \ --label production \ --body-file pr_body.md