Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 23 additions & 8 deletions .github/workflows/production-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
name: Production Release - Publish to pub.dev

on:
# Trigger when PR to master is merged
# Trigger when PR to master is merged (legacy path; promotion flow now preferred)
pull_request:
types:
- closed
Expand All @@ -51,6 +51,21 @@ on:
type: boolean
default: false

# Allow being called from other workflows
workflow_call:
inputs:
version:
required: true
type: string
skip_tests:
required: false
type: boolean
default: false
dry_run:
required: false
type: boolean
default: false

# Ensure only one production release runs at a time
concurrency:
group: production-release
Expand All @@ -67,8 +82,8 @@ jobs:
name: 🔍 Validate Release
runs-on: ubuntu-latest

# Only run if PR was actually merged (not just closed)
if: github.event_name == 'workflow_dispatch' || github.event.pull_request.merged == true
# Run when manually dispatched, called by another workflow, or when a PR merge event happens
if: github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' || github.event.pull_request.merged == true

outputs:
version: ${{ steps.get-version.outputs.version }}
Expand All @@ -84,8 +99,8 @@ jobs:
- name: 🔍 Validate release source
id: validate
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "Manual workflow dispatch - skipping branch validation"
if [[ "${{ github.event_name }}" == "workflow_dispatch" || "${{ github.event_name }}" == "workflow_call" ]]; then
echo "Manual/called run - skipping branch validation"
echo "is_release_branch=true" >> $GITHUB_OUTPUT
echo "is_valid=true" >> $GITHUB_OUTPUT
else
Expand All @@ -109,9 +124,9 @@ jobs:
- name: 📝 Get version from pubspec.yaml
id: get-version
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
VERSION="${{ github.event.inputs.version }}"
echo "Using manual version: $VERSION"
if [[ "${{ github.event_name }}" == "workflow_dispatch" || "${{ github.event_name }}" == "workflow_call" ]]; then
VERSION="${{ inputs.version || github.event.inputs.version }}"
echo "Using provided version: $VERSION"
else
# Extract version from pubspec.yaml
VERSION=$(grep "^version:" pubspec.yaml | sed 's/version: //' | tr -d ' ')
Expand Down
97 changes: 97 additions & 0 deletions .github/workflows/promote-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: Promote Release - Merge on QA Pass and Publish

on:
pull_request:
types: [labeled, synchronize, reopened, ready_for_review]
branches:
- master
pull_request_review:
types: [submitted]
branches:
- master

concurrency:
group: promote-release-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true

jobs:
gate-and-merge:
name: 🔐 Gate, Verify Checks, and Merge
if: >-
${ { github.event.pull_request.head.ref } } == '' || startsWith(github.event.pull_request.head.ref, 'releases/')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
checks: read
statuses: read
outputs:
merged: ${{ steps.merge.outputs.merged }}
version: ${{ steps.version.outputs.version }}
steps:
- name: 🧠 Evaluate conditions
id: eval
uses: actions/github-script@v7
with:
script: |
const core = require('@actions/core');
const pr = context.payload.pull_request || (await github.rest.pulls.get({owner: context.repo.owner, repo: context.repo.repo, pull_number: context.payload.pull_request?.number || context.issue.number})).data;
if (!pr) core.setFailed('No PR context');
const hasLabel = pr.labels.some(l => l.name === 'pass QA ready for deploy');
if (!hasLabel) core.setFailed('Required label not present: pass QA ready for deploy');
// Check approvals
const reviews = await github.rest.pulls.listReviews({owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number});
const approved = reviews.data.some(r => r.state === 'APPROVED');
if (!approved) core.setFailed('No approval found on the PR');
core.setOutput('pr_number', pr.number.toString());
- name: ⏳ Wait for required status checks to pass
uses: actions/github-script@v7
with:
script: |
const prNumber = Number(core.getInput('pr_number', { required: false })) || ${{ steps.eval.outputs.pr_number || '0' }};
const { data: pr } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber });
const ref = pr.head.sha;
const start = Date.now();
const timeoutMs = 60*60*1000; // 60 minutes
const sleep = ms => new Promise(r => setTimeout(r, ms));
while (true) {
const { data: combined } = await github.rest.repos.getCombinedStatusForRef({ owner: context.repo.owner, repo: context.repo.repo, ref });
const checksOk = combined.state === 'success';
if (checksOk) break;
if (Date.now() - start > timeoutMs) throw new Error('Timeout waiting for status checks to pass');
core.info(`Waiting for checks. Current state: ${combined.state}`);
await sleep(15000);
}
- name: 📥 Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 🔀 Merge PR immediately
id: merge
uses: actions/github-script@v7
with:
script: |
const prNumber = Number(${ { steps.eval.outputs.pr_number } });
const { data: pr } = await github.rest.pulls.get({ owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber });
if (pr.merged) { core.setOutput('merged', 'true'); return; }
const method = 'merge'; // use repo default merge method
await github.rest.pulls.merge({ owner: context.repo.owner, repo: context.repo.repo, pull_number: pr.number, merge_method: method });
core.setOutput('merged', 'true');
- name: 📝 Read version from pubspec on master
id: version
run: |
git fetch origin master:master
git checkout master
VER=$(grep '^version:' pubspec.yaml | sed 's/version: //' | tr -d ' ')
echo "version=$VER" >> $GITHUB_OUTPUT

call-production:
name: 🚀 Production Release
needs: gate-and-merge
if: needs.gate-and-merge.outputs.merged == 'true'
uses: ./.github/workflows/production-release.yml
with:
version: ${{ needs.gate-and-merge.outputs.version }}
skip_tests: false
dry_run: false
secrets: inherit
Loading