11name : Release Calendar
22
3- # Goal: automatically create release pull requests when the clock hits 17:00 UTC (5:00 PM).
4- # For reference, 17:00 UTC corresponds to:
5- # • 10:30 PM IST (India)
6- # • 10:00 AM PT (San Francisco)
7- # • 6:00 PM CET / 7:00 PM CEST (Paris)
8- # • 12:00 PM EST / 1:00 PM EDT (New York)
9- # • 6:00 PM CET / 7:00 PM CEST (Warsaw)
10- # Adjust locally if daylight saving time is in effect.
3+ # Creates release PRs on a schedule or manually via workflow_dispatch.
114#
12- # Testing: This workflow automatically runs when merged to dev (when the workflow file itself
13- # is modified), allowing you to test changes immediately. You can also manually trigger it via
14- # workflow_dispatch and choose which job(s) to run (staging or production).
5+ # HOW IT WORKS:
6+ # 1. Creates snapshot branches (release/staging-YYYY-MM-DD or release/production-YYYY-MM-DD)
7+ # 2. Opens PRs from these branches (NOT directly from dev/staging)
8+ # 3. New commits to dev/staging won't auto-update the PR - you control what's released
9+ #
10+ # SCHEDULE:
11+ # • Friday 17:00 UTC (10am PT): Creates release/staging-* branch from dev → staging PR
12+ # • Sunday 17:00 UTC (10am PT): Creates release/production-* branch from staging → main PR
13+ #
14+ # MANUAL TRIGGER:
15+ # Run via workflow_dispatch from main branch:
16+ # - staging: Creates dev snapshot → staging PR
17+ # - production: Creates staging snapshot → main PR
18+ #
19+ # REQUIREMENTS:
20+ # • Scheduled cron only runs when this file exists on the default branch (main)
21+ # • Manual triggers work from any branch, but should run from main for consistency
1522
1623on :
1724 workflow_dispatch :
1825 inputs :
1926 job_to_run :
20- description : " Which job to run (staging or production)"
27+ description : " Which job to run (staging: dev→staging, production: staging→main )"
2128 required : false
2229 type : choice
2330 options :
@@ -101,10 +108,12 @@ jobs:
101108 run : |
102109 set -euo pipefail
103110 PR_DATE=$(date +%Y-%m-%d)
111+ BRANCH_NAME="release/staging-${PR_DATE}"
104112 echo "date=${PR_DATE}" >> "$GITHUB_OUTPUT"
113+ echo "branch_name=${BRANCH_NAME}" >> "$GITHUB_OUTPUT"
105114
106- echo "Checking for existing pull requests from dev to staging..."
107- EXISTING_PR=$(gh pr list --base staging --head dev --state open --limit 1 --json number --jq '.[0].number // ""')
115+ echo "Checking for existing pull requests from ${BRANCH_NAME} to staging..."
116+ EXISTING_PR=$(gh pr list --base staging --head "${BRANCH_NAME}" --state open --limit 1 --json number --jq '.[0].number // ""')
108117 echo "existing_pr=${EXISTING_PR}" >> "$GITHUB_OUTPUT"
109118
110119 if [ -n "$EXISTING_PR" ]; then
@@ -135,26 +144,51 @@ jobs:
135144 fi
136145 done
137146
147+ - name : Create release branch from dev
148+ if : ${{ steps.guard_schedule.outputs.continue == 'true' && steps.check_dev_staging.outputs.existing_pr == '' }}
149+ env :
150+ BRANCH_NAME : ${{ steps.check_dev_staging.outputs.branch_name }}
151+ shell : bash
152+ run : |
153+ set -euo pipefail
154+
155+ echo "Creating release branch ${BRANCH_NAME} from dev"
156+ git fetch origin dev
157+ git checkout -b "${BRANCH_NAME}" origin/dev
158+ git push origin "${BRANCH_NAME}"
159+
138160 - name : Create dev to staging release PR
139161 if : ${{ steps.guard_schedule.outputs.continue == 'true' && steps.check_dev_staging.outputs.existing_pr == '' }}
140162 env :
141163 GH_TOKEN : ${{ github.token }}
142164 PR_DATE : ${{ steps.check_dev_staging.outputs.date }}
165+ BRANCH_NAME : ${{ steps.check_dev_staging.outputs.branch_name }}
143166 shell : bash
144167 run : |
145168 set -euo pipefail
146169
147170 python <<'PY'
171+ import os
148172 import pathlib
149173 import textwrap
174+ from datetime import datetime
175+
176+ pr_date = os.environ["PR_DATE"]
177+ branch_name = os.environ["BRANCH_NAME"]
178+ formatted_date = datetime.strptime(pr_date, "%Y-%m-%d").strftime("%B %d, %Y")
150179
151- pathlib.Path("pr_body.md").write_text(textwrap.dedent("""\
180+ pathlib.Path("pr_body.md").write_text(textwrap.dedent(f """\
152181 ## 🚀 Weekly Release to Staging
153182
154- This automated PR promotes all changes from `dev` to `staging` for testing.
183+ **Release Date:** {formatted_date}
184+ **Release Branch:** `{branch_name}`
185+
186+ This automated PR promotes a snapshot of `dev` to `staging` for testing.
155187
156188 ### What's Included
157- All commits merged to `dev` since the last staging release.
189+ All commits merged to `dev` up to the branch creation time.
190+
191+ **Note:** This PR uses a dedicated release branch, so new commits to `dev` will NOT automatically appear here.
158192
159193 ### Review Checklist
160194 - [ ] All CI checks pass
@@ -166,16 +200,16 @@ jobs:
166200 After merging, the staging environment will be updated. A production release PR will be created on Sunday.
167201
168202 ---
169- *This PR was automatically created by the Release Calendar workflow*
203+ *This PR was automatically created by the Release Calendar workflow on {formatted_date} *
170204 """))
171205 PY
172206
173207 TITLE="Release to Staging - ${PR_DATE}"
174- echo "Creating PR with title: ${TITLE}"
208+ echo "Creating PR with title: ${TITLE} from branch ${BRANCH_NAME} "
175209
176210 gh pr create \
177211 --base staging \
178- --head dev \
212+ --head "${BRANCH_NAME}" \
179213 --title "${TITLE}" \
180214 --label release \
181215 --label automated \
@@ -237,6 +271,11 @@ jobs:
237271 run : |
238272 set -euo pipefail
239273
274+ PR_DATE=$(date +%Y-%m-%d)
275+ BRANCH_NAME="release/production-${PR_DATE}"
276+ echo "date=${PR_DATE}" >> "$GITHUB_OUTPUT"
277+ echo "branch_name=${BRANCH_NAME}" >> "$GITHUB_OUTPUT"
278+
240279 echo "Fetching latest branches..."
241280 git fetch origin main staging
242281
@@ -251,8 +290,8 @@ jobs:
251290
252291 echo "staging_not_ahead=false" >> "$GITHUB_OUTPUT"
253292
254- echo "Checking for existing pull requests from staging to main..."
255- EXISTING_PR=$(gh pr list --base main --head staging --state open --limit 1 --json number --jq '.[0].number // ""')
293+ echo "Checking for existing pull requests from ${BRANCH_NAME} to main..."
294+ EXISTING_PR=$(gh pr list --base main --head "${BRANCH_NAME}" --state open --limit 1 --json number --jq '.[0].number // ""')
256295 echo "existing_pr=${EXISTING_PR}" >> "$GITHUB_OUTPUT"
257296
258297 if [ -n "$EXISTING_PR" ]; then
@@ -261,9 +300,6 @@ jobs:
261300 echo "No existing production release PR found. Ready to create a new one."
262301 fi
263302
264- PR_DATE=$(date +%Y-%m-%d)
265- echo "date=${PR_DATE}" >> "$GITHUB_OUTPUT"
266-
267303 - name : Log staging up to date
268304 if : ${{ steps.guard_schedule.outputs.continue == 'true' && steps.production_status.outputs.staging_not_ahead == 'true' }}
269305 run : |
@@ -291,11 +327,25 @@ jobs:
291327 fi
292328 done
293329
330+ - name : Create release branch from staging
331+ if : ${{ steps.guard_schedule.outputs.continue == 'true' && steps.production_status.outputs.staging_not_ahead != 'true' && steps.production_status.outputs.existing_pr == '' }}
332+ env :
333+ BRANCH_NAME : ${{ steps.production_status.outputs.branch_name }}
334+ shell : bash
335+ run : |
336+ set -euo pipefail
337+
338+ echo "Creating release branch ${BRANCH_NAME} from staging"
339+ git fetch origin staging
340+ git checkout -b "${BRANCH_NAME}" origin/staging
341+ git push origin "${BRANCH_NAME}"
342+
294343 - name : Create staging to main release PR
295344 if : ${{ steps.guard_schedule.outputs.continue == 'true' && steps.production_status.outputs.staging_not_ahead != 'true' && steps.production_status.outputs.existing_pr == '' }}
296345 env :
297346 GH_TOKEN : ${{ github.token }}
298347 PR_DATE : ${{ steps.production_status.outputs.date }}
348+ BRANCH_NAME : ${{ steps.production_status.outputs.branch_name }}
299349 COMMITS_AHEAD : ${{ steps.production_status.outputs.commits }}
300350 shell : bash
301351 run : |
@@ -305,18 +355,26 @@ jobs:
305355 import os
306356 import pathlib
307357 import textwrap
358+ from datetime import datetime
308359
309360 commits_ahead = os.environ["COMMITS_AHEAD"]
361+ pr_date = os.environ["PR_DATE"]
362+ branch_name = os.environ["BRANCH_NAME"]
363+ formatted_date = datetime.strptime(pr_date, "%Y-%m-%d").strftime("%B %d, %Y")
310364
311365 pathlib.Path("pr_body.md").write_text(textwrap.dedent(f"""\
312366 ## 🎯 Production Release
313367
368+ **Release Date:** {formatted_date}
369+ **Release Branch:** `{branch_name}`
370+ **Commits ahead**: {commits_ahead}
371+
314372 This automated PR promotes tested changes from `staging` to `main` for production deployment.
315373
316374 ### What's Included
317375 All changes that have been verified in the staging environment.
318376
319- **Commits ahead**: {commits_ahead}
377+ **Note:** This PR uses a dedicated release branch, so new commits to `staging` will NOT automatically appear here.
320378
321379 ### Pre-Deployment Checklist
322380 - [ ] All staging tests passed
@@ -329,16 +387,16 @@ jobs:
329387 Merging this PR will trigger production deployment.
330388
331389 ---
332- *This PR was automatically created by the Release Calendar workflow*
390+ *This PR was automatically created by the Release Calendar workflow on {formatted_date} *
333391 """))
334392 PY
335393
336394 TITLE="Release to Production - ${PR_DATE}"
337- echo "Creating PR with title: ${TITLE} and ${COMMITS_AHEAD} commits ahead."
395+ echo "Creating PR with title: ${TITLE} from branch ${BRANCH_NAME} with ${COMMITS_AHEAD} commits ahead."
338396
339397 gh pr create \
340398 --base main \
341- --head staging \
399+ --head "${BRANCH_NAME}" \
342400 --title "${TITLE}" \
343401 --label release \
344402 --label automated \
0 commit comments