Skip to content

Release-Automation-25190087487 #201

Release-Automation-25190087487

Release-Automation-25190087487 #201

name: Release Automation
run-name: Release-Automation-${{ github.run_id }}
env:
SCHEDULE_FILE: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.schedule_file) || 'release-schedule.md' }}
LOCAL_SCHEDULE_FILE: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.local_schedule_file) || '' }}
SCHEDULE_REPO: microsoft/Microsoft-365-Agents-Toolkit-Release-Schedule
RELEASE_VERSION: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.release_version) || '' }}
DRY_RUN: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run) || 'false' }}
CREATE_BRANCH: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.create_branch) || 'true' }}
RERUN_CD_ONLY: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.rerun_cd_only) || 'false' }}
GO_PRODUCT: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.go_product) || 'false' }}
on:
workflow_dispatch:
inputs:
schedule_file:
description: "Path to release schedule file (relative to schedule repo root)"
required: false
default: "release-schedule.md"
local_schedule_file:
description: "Optional: Path to a local schedule file in this repo (for testing)."
required: false
default: ""
release_version:
description: "Optional: target release Version to process (e.g., 6.5.6). If empty, selects by Cut Bits Date (UTC+8 today)."
required: false
default: ""
rerun_cd_only:
description: "Rerun CD only: select latest pending release (not canceled), skip PR creation/merge checks, and trigger CD directly (useful for regenerating artifacts after fixes on release branch)."
type: boolean
default: false
go_product:
description: "When triggering CD, set goproduct=true (publish TTK as product)."
type: boolean
default: false
dry_run:
description: "Dry run mode (will not create branches or trigger CD)"
type: boolean
default: false
create_branch:
description: "Create release branch if it doesn't exist"
type: boolean
default: true
schedule:
- cron: "0 21 * * *" # Run daily at 05:00 (UTC+8) => 21:00 UTC
pull_request:
types: [closed]
branches:
- "release/**"
permissions:
contents: read
jobs:
parse-schedule:
runs-on: ubuntu-latest
if: ${{ github.event_name != 'pull_request' }}
outputs:
releases: ${{ steps.parse.outputs.releases }}
releases_count: ${{ steps.parse.outputs.releases_count }}
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Checkout release schedule repo
if: ${{ env.LOCAL_SCHEDULE_FILE == '' }}
uses: actions/checkout@v3
with:
repository: ${{ env.SCHEDULE_REPO }}
token: ${{ secrets.RELEASE_AUTOMATION_TOKEN }}
path: schedule-repo
sparse-checkout: |
${{ env.SCHEDULE_FILE }}
sparse-checkout-cone-mode: false
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Parse release schedule
id: parse
run: |
# Determine which schedule file to use
if [ -n "${{ env.LOCAL_SCHEDULE_FILE }}" ]; then
SCHEDULE_PATH="${{ env.LOCAL_SCHEDULE_FILE }}"
echo "📁 Using local schedule file: $SCHEDULE_PATH"
else
SCHEDULE_PATH="schedule-repo/${{ env.SCHEDULE_FILE }}"
echo "📁 Using schedule file from private repo: $SCHEDULE_PATH"
fi
# Parse schedule-driven release parameters
python .github/scripts/parse_release_schedule.py "$SCHEDULE_PATH" > releases.json
# Process all VS Code entries (prerelease, stable, hotfix)
cat releases.json | jq '[.[] | select(((.product | ascii_downcase) == "vsc") or (.product | ascii_downcase | contains("vs code")))]' > releases.filtered.json
mv releases.filtered.json releases.json
echo "📋 Parsed Release Schedule:"
cat releases.json | python -m json.tool
# Set outputs
RELEASES=$(cat releases.json | jq -c '.')
RELEASES_COUNT=$(cat releases.json | jq 'length')
echo "releases=$RELEASES" >> $GITHUB_OUTPUT
echo "releases_count=$RELEASES_COUNT" >> $GITHUB_OUTPUT
echo ""
echo "Found $RELEASES_COUNT pending release(s)"
- name: Upload releases configuration
uses: actions/upload-artifact@v4
with:
name: releases-config
path: releases.json
select-release:
needs: parse-schedule
runs-on: ubuntu-latest
if: ${{ github.event_name != 'pull_request' }}
outputs:
found: ${{ steps.select.outputs.found }}
reason: ${{ steps.select.outputs.reason }}
product: ${{ steps.select.outputs.product }}
release_type: ${{ steps.select.outputs.release_type }}
version: ${{ steps.select.outputs.version }}
cut_date: ${{ steps.select.outputs.cut_date }}
branch: ${{ steps.select.outputs.branch }}
preid: ${{ steps.select.outputs.preid }}
series: ${{ steps.select.outputs.series }}
vsrelease: ${{ steps.select.outputs.vsrelease }}
steps:
- name: Download releases configuration
uses: actions/download-artifact@v4
with:
name: releases-config
- name: Select release (by version or Cut Bits Date)
id: select
shell: bash
run: |
set -euo pipefail
VERSION_INPUT="${{ env.RELEASE_VERSION }}"
RERUN_CD_ONLY="${{ env.RERUN_CD_ONLY }}"
TODAY_UTC8=$(date -u -d '+8 hours' +%Y-%m-%d)
if [ -n "$VERSION_INPUT" ]; then
REASON="version=$VERSION_INPUT"
RELEASE=$(jq -c --arg v "$VERSION_INPUT" '[.[] | select(.version == $v)] | .[0]' releases.json)
elif [ "$RERUN_CD_ONLY" = "true" ]; then
REASON="cut_date_window=$TODAY_UTC8 (UTC+8)"
RELEASE=$(jq -c --arg d "$TODAY_UTC8" '
[ .[] | select((.cut_date // "") != "") ]
| sort_by(.cut_date) as $s
| if ($s|length) == 0 then null
else
(
[ range(0; ($s|length)) as $i
| select(
($s[$i].cut_date <= $d)
and (
($i + 1) >= ($s|length)
or ($s[$i+1].cut_date > $d)
)
)
| $s[$i]
]
| .[0]
)
// (
[ $s[] | select(.cut_date > $d) ]
| .[0]
)
// $s[-1]
end
' releases.json)
else
REASON="cut_date=$TODAY_UTC8 (UTC+8)"
RELEASE=$(jq -c --arg d "$TODAY_UTC8" '[.[] | select(.cut_date == $d)] | .[0]' releases.json)
fi
if [ "$RELEASE" = "null" ] || [ -z "$RELEASE" ]; then
{
echo "## ℹ️ No release selected"
echo ""
echo "No matching release was found for ${REASON}."
echo ""
echo "Notes:"
echo "- Cut Bits Date must be in ISO format (YYYY-MM-DD)."
echo "- Status values like Cancel/Cancelled/Canceled are ignored by the parser."
} >> "$GITHUB_STEP_SUMMARY"
echo "found=false" >> "$GITHUB_OUTPUT"
echo "reason=$REASON" >> "$GITHUB_OUTPUT"
exit 0
fi
PRODUCT=$(echo "$RELEASE" | jq -r '.product')
RELEASE_TYPE=$(echo "$RELEASE" | jq -r '.release_type')
VERSION=$(echo "$RELEASE" | jq -r '.version')
CUT_DATE=$(echo "$RELEASE" | jq -r '.cut_date')
BRANCH=$(echo "$RELEASE" | jq -r '.branch')
PREID=$(echo "$RELEASE" | jq -r '.cd_params.preid')
SERIES=$(echo "$RELEASE" | jq -r '.cd_params.series')
VSRELEASE=$(echo "$RELEASE" | jq -r '.cd_params.vsrelease')
{
echo "## ✅ Selected release"
echo ""
echo "- Reason: ${REASON}"
echo "- Rerun CD only: ${RERUN_CD_ONLY}"
echo "- Product: ${PRODUCT}"
echo "- Release Type: ${RELEASE_TYPE}"
echo "- Version: ${VERSION}"
echo "- Cut Bits Date: ${CUT_DATE}"
echo "- Branch: ${BRANCH}"
} >> "$GITHUB_STEP_SUMMARY"
echo "found=true" >> "$GITHUB_OUTPUT"
echo "reason=$REASON" >> "$GITHUB_OUTPUT"
echo "product=$PRODUCT" >> "$GITHUB_OUTPUT"
echo "release_type=$RELEASE_TYPE" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "cut_date=$CUT_DATE" >> "$GITHUB_OUTPUT"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "preid=$PREID" >> "$GITHUB_OUTPUT"
echo "series=$SERIES" >> "$GITHUB_OUTPUT"
echo "vsrelease=$VSRELEASE" >> "$GITHUB_OUTPUT"
review-releases:
needs: parse-schedule
runs-on: ubuntu-latest
if: ${{ github.event_name != 'pull_request' && needs.parse-schedule.outputs.releases_count > 0 }}
steps:
- name: Download releases configuration
uses: actions/download-artifact@v4
with:
name: releases-config
- name: Display releases for review
run: |
{
echo "# 📦 Release Automation Plan"
echo ""
echo "The following releases are ready for processing:"
echo ""
jq -r 'to_entries[] | "## Release \(.key + 1): \(.value.product) \(.value.version)\n\n**Release Type:** \(.value.release_type)\n**Branch:** `\(.value.branch)`\n**Cut Date:** \(.value.cut_date)\n\n### CD Pipeline Parameters:\n- **preid:** `\(.value.cd_params.preid)`\n- **series:** `\(.value.cd_params.series)`\n- **vsrelease:** `\(.value.cd_params.vsrelease)`\n\n---\n"' releases.json
} | tee -a "$GITHUB_STEP_SUMMARY"
prepare-release:
needs:
- parse-schedule
- select-release
runs-on: ubuntu-latest
if: ${{ github.event_name != 'pull_request' && needs.select-release.outputs.found == 'true' }}
env:
APP_GITHUB_APP_ID: ${{ vars.APP_GITHUB_APP_ID }}
APP_GITHUB_APP_PRIVATE_KEY: ${{ secrets.APP_GITHUB_APP_PRIVATE_KEY }}
MAIL_CLIENT_ID: ${{ secrets.MAIL_CLIENT_ID }}
MAIL_CLIENT_SECRET: ${{ secrets.MAIL_CLIENT_SECRET }}
MAIL_TENANT_ID: ${{ secrets.MAIL_TENANT_ID }}
outputs:
branch: ${{ steps.config.outputs.branch }}
product: ${{ steps.config.outputs.product }}
release_type: ${{ steps.config.outputs.release_type }}
version: ${{ steps.config.outputs.version }}
preid: ${{ steps.config.outputs.preid }}
series: ${{ steps.config.outputs.series }}
vsrelease: ${{ steps.config.outputs.vsrelease }}
is_prerelease: ${{ steps.config.outputs.is_prerelease }}
is_stable: ${{ steps.config.outputs.is_stable }}
is_hotfix: ${{ steps.config.outputs.is_hotfix }}
pr_needed: ${{ steps.sync_pr.outputs.pr_needed }}
pr_url: ${{ steps.sync_pr.outputs.pr_url }}
pr_number: ${{ steps.sync_pr.outputs.pr_number }}
branch_created: ${{ steps.create_branch.outputs.created }}
auto_cd: ${{ steps.decide_auto_cd.outputs.auto_cd }}
steps:
- id: generate-token
if: ${{ env.APP_GITHUB_APP_ID != '' && env.APP_GITHUB_APP_PRIVATE_KEY != '' }}
uses: actions/create-github-app-token@v2
with:
app-id: ${{ env.APP_GITHUB_APP_ID }}
private-key: ${{ env.APP_GITHUB_APP_PRIVATE_KEY }}
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ steps.generate-token.outputs.token || github.token }}
- name: Download releases configuration
uses: actions/download-artifact@v4
with:
name: releases-config
- name: Set selected release outputs
id: config
shell: bash
run: |
PRODUCT='${{ needs.select-release.outputs.product }}'
RELEASE_TYPE='${{ needs.select-release.outputs.release_type }}'
VERSION='${{ needs.select-release.outputs.version }}'
BRANCH='${{ needs.select-release.outputs.branch }}'
PREID='${{ needs.select-release.outputs.preid }}'
SERIES='${{ needs.select-release.outputs.series }}'
VSRELEASE='${{ needs.select-release.outputs.vsrelease }}'
echo "Selected release:"
echo " Product: $PRODUCT"
echo " Release Type: $RELEASE_TYPE"
echo " Version: $VERSION"
echo " Branch: $BRANCH"
# Derive release type flags
RELEASE_TYPE_LOWER=$(echo "$RELEASE_TYPE" | tr '[:upper:]' '[:lower:]')
IS_PRERELEASE=false; IS_STABLE=false; IS_HOTFIX=false
case "$RELEASE_TYPE_LOWER" in
*prerelease*) IS_PRERELEASE=true ;;
*hotfix*) IS_HOTFIX=true ;;
*stable*) IS_STABLE=true ;;
*) echo "❌ Unknown release type: $RELEASE_TYPE" >&2; exit 1 ;;
esac
# Hotfix releases only support rerun_cd_only mode
RERUN_CD_ONLY="${{ env.RERUN_CD_ONLY }}"
if [ "$IS_HOTFIX" = "true" ] && [ "$RERUN_CD_ONLY" != "true" ]; then
echo "❌ Hotfix releases only support rerun_cd_only mode. Set rerun_cd_only=true and retry." >&2
exit 1
fi
# For stable releases, derive source_branch from the last prerelease entry in the schedule
SOURCE_BRANCH=""
if [ "$IS_STABLE" = "true" ] && [ "$RERUN_CD_ONLY" != "true" ]; then
SOURCE_BRANCH=$(jq -r --arg v "$VERSION" '
[ .[] | select(.release_type | ascii_downcase | contains("prerelease")) ]
| sort_by(.cut_date)
| last
| .branch // empty
' releases.json)
if [ -z "$SOURCE_BRANCH" ]; then
echo "⚠️ No previous prerelease found in schedule. Falling back to dev as source branch."
SOURCE_BRANCH="dev"
fi
echo " Source Branch (derived): $SOURCE_BRANCH"
fi
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "product=$PRODUCT" >> "$GITHUB_OUTPUT"
echo "release_type=$RELEASE_TYPE" >> "$GITHUB_OUTPUT"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "preid=$PREID" >> "$GITHUB_OUTPUT"
echo "series=$SERIES" >> "$GITHUB_OUTPUT"
echo "vsrelease=$VSRELEASE" >> "$GITHUB_OUTPUT"
echo "source_branch=$SOURCE_BRANCH" >> "$GITHUB_OUTPUT"
echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT"
echo "is_stable=$IS_STABLE" >> "$GITHUB_OUTPUT"
echo "is_hotfix=$IS_HOTFIX" >> "$GITHUB_OUTPUT"
- name: Setup git
run: |
git config --global user.name 'github-actions[bot]'
git config --global user.email 'github-actions[bot]@users.noreply.github.com'
- name: Check if branch exists
id: check_branch
run: |
BRANCH="${{ steps.config.outputs.branch }}"
if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "✅ Branch $BRANCH already exists locally"
elif git ls-remote --heads origin "$BRANCH" | grep -q "$BRANCH"; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "✅ Branch $BRANCH exists on remote"
git fetch origin "$BRANCH:$BRANCH"
else
echo "exists=false" >> $GITHUB_OUTPUT
echo "ℹ️ Branch $BRANCH does not exist"
fi
- name: Create PR to merge dev into existing release branch
id: sync_pr
if: ${{ steps.check_branch.outputs.exists == 'true' && env.DRY_RUN == 'false' && env.RERUN_CD_ONLY == 'false' && steps.config.outputs.is_prerelease == 'true' }}
uses: actions/github-script@v7
with:
github-token: ${{ secrets.RELEASE_AUTOMATION_TOKEN || github.token }}
script: |
const product = '${{ steps.config.outputs.product }}';
const version = '${{ steps.config.outputs.version }}';
const releaseType = '${{ steps.config.outputs.release_type }}';
const base = '${{ steps.config.outputs.branch }}';
const head = 'dev';
const preid = '${{ steps.config.outputs.preid }}';
const series = '${{ steps.config.outputs.series }}';
const vsrelease = '${{ steps.config.outputs.vsrelease }}' === 'true';
const goproduct = '${{ env.GO_PRODUCT }}' === 'true';
const workflowRunUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const markerPayload = { product, version, releaseType, branch: base, preid, series, vsrelease, goproduct };
const marker = `<!-- release-automation-cd-params: ${JSON.stringify(markerPayload)} -->`;
const owner = context.repo.owner;
const repo = context.repo.repo;
const label = 'release-automation-sync';
// Check if dev has new commits not in the release branch
const compare = await github.rest.repos.compareCommits({ owner, repo, base, head });
const aheadBy = compare.data.ahead_by ?? 0;
if (aheadBy === 0) {
core.notice(`No sync PR needed: '${head}' is not ahead of '${base}'.`);
core.setOutput('pr_needed', 'false');
core.setOutput('pr_url', '');
core.setOutput('pr_number', '');
return;
}
// Avoid creating duplicate PRs
const existing = await github.rest.pulls.list({
owner,
repo,
state: 'open',
base,
head: `${owner}:${head}`,
per_page: 10,
});
if (existing.data.length > 0) {
const pr = existing.data[0];
core.notice(`Sync PR already exists: ${pr.html_url}`);
// Ensure marker + label exist for merge-trigger workflow
const currentBody = pr.body || '';
const markerRegex = /<!--\s*release-automation-cd-params:[\s\S]*?-->/;
let updatedBody = currentBody;
if (markerRegex.test(currentBody)) {
updatedBody = currentBody.replace(markerRegex, marker);
} else {
updatedBody = [
currentBody,
'',
`Release Automation Run: ${workflowRunUrl}`,
marker,
].join('\n');
}
if (updatedBody !== currentBody) {
await github.rest.pulls.update({ owner, repo, pull_number: pr.number, body: updatedBody });
}
try {
await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [label] });
} catch (e) {
core.notice(`Unable to add label '${label}' (may already exist): ${e.message}`);
}
core.setOutput('pr_needed', 'true');
core.setOutput('pr_url', pr.html_url);
core.setOutput('pr_number', String(pr.number));
return;
}
const title = `chore: merge dev into ${base} (${product} ${version})`;
const body = [
'This PR is created by Release Automation to sync latest changes from `dev` into the release branch.',
'',
`- Product: ${product}`,
`- Release Type: ${releaseType}`,
`- Version: ${version}`,
`- Base (target): \`${base}\``,
`- Head (source): \`${head}\``,
`- Commits ahead: ${aheadBy}`,
'',
`Release Automation Run: ${workflowRunUrl}`,
marker,
'',
'After merging, approve the Release Automation run to trigger CD.'
].join('\n');
const created = await github.rest.pulls.create({ owner, repo, title, head, base, body });
core.notice(`Created sync PR: ${created.data.html_url}`);
try {
await github.rest.issues.addLabels({ owner, repo, issue_number: created.data.number, labels: [label] });
} catch (e) {
core.notice(`Unable to add label '${label}': ${e.message}`);
}
core.setOutput('pr_needed', 'true');
core.setOutput('pr_url', created.data.html_url);
core.setOutput('pr_number', String(created.data.number));
- name: Create PR in samples repo (merge dev into main)
if: ${{ env.DRY_RUN == 'false' && env.RERUN_CD_ONLY == 'false' && steps.config.outputs.is_hotfix != 'true' }}
uses: actions/github-script@v7
with:
github-token: ${{ secrets.RELEASE_AUTOMATION_TOKEN || github.token }}
script: |
const owner = 'OfficeDev';
const repo = 'microsoft-365-agents-toolkit-samples';
const base = 'main';
const head = 'dev';
const product = '${{ steps.config.outputs.product }}';
const version = '${{ steps.config.outputs.version }}';
const branch = '${{ steps.config.outputs.branch }}';
// Check if dev has new commits not in main
const compare = await github.rest.repos.compareCommits({ owner, repo, base, head });
const aheadBy = compare.data.ahead_by ?? 0;
if (aheadBy === 0) {
core.notice(`No samples sync PR needed: '${head}' is not ahead of '${base}'.`);
return;
}
// Avoid creating duplicate PRs
const existing = await github.rest.pulls.list({
owner,
repo,
state: 'open',
base,
head: `${owner}:${head}`,
per_page: 10,
});
if (existing.data.length > 0) {
const pr = existing.data[0];
core.notice(`Samples sync PR already exists: ${pr.html_url}`);
return;
}
const title = `chore: merge dev into ${base} (samples)`;
const body = [
'This PR is created by Release Automation to sync latest changes from `dev` into `main` in the samples repo.',
'',
`Triggered by release: ${product} ${version} (branch: \`${branch}\`)`,
`- Base (target): \`${base}\``,
`- Head (source): \`${head}\``,
`- Commits ahead: ${aheadBy}`,
].join('\n');
const created = await github.rest.pulls.create({ owner, repo, title, head, base, body });
core.notice(`Created samples sync PR: ${created.data.html_url}`);
core.summary
.addHeading('📌 Samples Repo Sync PR', 2)
.addRaw(`\nCreated: ${created.data.html_url}\n`)
.write();
- name: Create release branch
id: create_branch
if: ${{ steps.check_branch.outputs.exists == 'false' && env.CREATE_BRANCH == 'true' && env.DRY_RUN == 'false' && env.RERUN_CD_ONLY == 'false' && steps.config.outputs.is_hotfix != 'true' }}
run: |
BRANCH="${{ steps.config.outputs.branch }}"
IS_STABLE="${{ steps.config.outputs.is_stable }}"
SOURCE_BRANCH="${{ steps.config.outputs.source_branch }}"
if [ "$IS_STABLE" = "true" ]; then
echo "Creating branch $BRANCH from source branch $SOURCE_BRANCH..."
git fetch origin "$SOURCE_BRANCH"
git checkout "origin/$SOURCE_BRANCH"
git checkout -b "$BRANCH"
else
echo "Creating branch $BRANCH from dev..."
git checkout dev
git pull origin dev
git checkout -b "$BRANCH"
fi
git push origin "$BRANCH"
echo "✅ Branch $BRANCH created successfully"
echo "created=true" >> $GITHUB_OUTPUT
- name: Decide auto-CD for new release branch
id: decide_auto_cd
shell: bash
run: |
set -euo pipefail
BRANCH="${{ steps.config.outputs.branch }}"
VERSION="${{ steps.config.outputs.version }}"
BRANCH_CREATED="${{ steps.create_branch.outputs.created }}"
IS_STABLE="${{ steps.config.outputs.is_stable }}"
AUTO_CD=false
if [ "$BRANCH_CREATED" = "true" ]; then
if [ "$IS_STABLE" = "true" ]; then
# Stable releases auto-trigger CD on branch creation (no PR merge gate)
AUTO_CD=true
elif [[ "$BRANCH" =~ ^release/6\.([0-9]+)$ ]]; then
# Prerelease: auto-trigger CD only for odd minor branches
MINOR="${BASH_REMATCH[1]}"
if (( MINOR % 2 == 1 )); then
AUTO_CD=true
fi
fi
fi
echo "auto_cd=$AUTO_CD" >> "$GITHUB_OUTPUT"
echo "Auto CD decision: branch=$BRANCH, version=$VERSION, branch_created=$BRANCH_CREATED, is_stable=$IS_STABLE, auto_cd=$AUTO_CD" >> "$GITHUB_STEP_SUMMARY"
- name: Validate branch exists for rerun mode
if: ${{ env.RERUN_CD_ONLY == 'true' && steps.check_branch.outputs.exists != 'true' }}
run: |
echo "❌ Rerun CD only mode is enabled, but branch '${{ steps.config.outputs.branch }}' does not exist." >&2
echo " Fix the schedule branch value or create the branch, then rerun." >&2
exit 1
- name: Compose notification (shown + email payload)
id: notify
run: |
DATE=$(date -u -d '+8 hours' +%Y-%m-%d)
PRODUCT="${{ steps.config.outputs.product }}"
VERSION="${{ steps.config.outputs.version }}"
RELEASE_TYPE="${{ steps.config.outputs.release_type }}"
BRANCH="${{ steps.config.outputs.branch }}"
PREID="${{ steps.config.outputs.preid }}"
SERIES="${{ steps.config.outputs.series }}"
VSRELEASE="${{ steps.config.outputs.vsrelease }}"
SOURCE_BRANCH="${{ steps.config.outputs.source_branch }}"
IS_PRERELEASE="${{ steps.config.outputs.is_prerelease }}"
IS_STABLE="${{ steps.config.outputs.is_stable }}"
IS_HOTFIX="${{ steps.config.outputs.is_hotfix }}"
DRY_RUN="${{ env.DRY_RUN }}"
RERUN_CD_ONLY="${{ env.RERUN_CD_ONLY }}"
BRANCH_EXISTS="${{ steps.check_branch.outputs.exists }}"
BRANCH_CREATED="${{ steps.create_branch.outputs.created }}"
PR_NEEDED_RAW="${{ steps.sync_pr.outputs.pr_needed }}"
PR_URL="${{ steps.sync_pr.outputs.pr_url }}"
# Normalize values
PR_NEEDED="${PR_NEEDED_RAW:-false}"
if [ -z "$BRANCH_EXISTS" ]; then BRANCH_EXISTS="unknown"; fi
if [ -z "$BRANCH_CREATED" ]; then BRANCH_CREATED="false"; fi
WORKFLOW_URL="${{ github.server_url }}/${{ github.repository }}/actions/workflows/release-automation.yml"
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
SUBJECT="ATK Release Automation Notification: ${PRODUCT} ${VERSION} (${BRANCH})"
ACTION_REQUIRED="Approve the environment gate (auto-release) to trigger CD."
if [ "$PR_NEEDED" = "true" ]; then
ACTION_REQUIRED="ACTION REQUIRED: approve & merge the sync PR, then approve the environment gate (auto-release) to trigger CD."
fi
if [ "$RERUN_CD_ONLY" = "true" ]; then
ACTION_REQUIRED="RERUN CD ONLY: skip PR/branch actions; approve the environment gate (auto-release) to trigger CD."
fi
if [ "$DRY_RUN" = "true" ]; then
ACTION_REQUIRED="DRY RUN: no changes were made. Re-run with dry_run=false to create branch/PR and proceed."
fi
{
echo "# 📧 Release Automation Notification"
echo ""
echo "**Subject:** ${SUBJECT}"
echo ""
echo "## Release"
echo "- **Product:** ${PRODUCT}"
echo "- **Release Type:** ${RELEASE_TYPE}"
echo "- **Version:** ${VERSION}"
echo "- **Branch:** ${BRANCH}"
echo ""
echo "## CD Parameters"
echo "- **preid:** ${PREID}"
echo "- **series:** ${SERIES}"
echo "- **vsrelease:** ${VSRELEASE}"
echo ""
echo "## Branch / PR"
if [ "$DRY_RUN" = "true" ]; then
echo "- Dry run only (no branch/PR created)."
else
if [ "$BRANCH_CREATED" = "true" ]; then
if [ "$IS_STABLE" = "true" ]; then
echo "- ✅ Created branch from source branch: ${SOURCE_BRANCH}."
else
echo "- ✅ Created branch from dev."
fi
else
echo "- Branch exists: ${BRANCH_EXISTS}"
fi
if [ "$PR_NEEDED" = "true" ]; then
echo "- 🔁 Sync PR required: ${PR_URL}"
else
echo "- Sync PR required: false"
fi
fi
echo ""
echo "## Next steps"
echo "- ${ACTION_REQUIRED}"
echo "- Workflow: ${WORKFLOW_URL}"
echo "- Run: ${RUN_URL}"
} | tee -a "$GITHUB_STEP_SUMMARY"
BODY=$(jq -c -n \
--arg date "$DATE" \
--arg product "$PRODUCT" \
--arg version "$VERSION" \
--arg release_type "$RELEASE_TYPE" \
--arg branch "$BRANCH" \
--arg preid "$PREID" \
--arg series "$SERIES" \
--arg vsrelease "$VSRELEASE" \
--arg dry_run "$DRY_RUN" \
--arg pr_needed "$PR_NEEDED" \
--arg pr_url "$PR_URL" \
--arg workflow_url "$WORKFLOW_URL" \
--arg run_url "$RUN_URL" \
--arg action_required "$ACTION_REQUIRED" \
'{
date: $date,
product: $product,
version: $version,
release_type: $release_type,
branch: $branch,
cd_params: { preid: $preid, series: $series, vsrelease: $vsrelease },
dry_run: $dry_run,
pr_needed: $pr_needed,
pr_url: $pr_url,
action_required: $action_required,
workflow_url: $workflow_url,
run_url: $run_url
}')
echo "subject=$SUBJECT" >> $GITHUB_OUTPUT
echo "body=$BODY" >> $GITHUB_OUTPUT
- name: Send email notification
if: ${{ env.DRY_RUN == 'false' && env.MAIL_CLIENT_ID != '' && env.MAIL_CLIENT_SECRET != '' && env.MAIL_TENANT_ID != '' }}
env:
MAIL_CLIENT_ID: ${{ env.MAIL_CLIENT_ID }}
MAIL_CLIENT_SECRET: ${{ env.MAIL_CLIENT_SECRET }}
MAIL_TENANT_ID: ${{ env.MAIL_TENANT_ID }}
TO: ${{ vars.RELEASE_NOTIFICATION_EMAIL || 'M365AgentsToolkitEngineerTeam@microsoft.com' }}
SUBJECT: ${{ steps.notify.outputs.subject }}
BODY: ${{ steps.notify.outputs.body }}
uses: ./.github/actions/send-email-report
trigger-cd:
needs: prepare-release
runs-on: ubuntu-latest
if: ${{ github.event_name != 'pull_request' && needs.prepare-release.result == 'success' && ((github.event_name == 'workflow_dispatch' && github.event.inputs.dry_run == 'false' && (github.event.inputs.rerun_cd_only == 'true' || needs.prepare-release.outputs.auto_cd == 'true')) || (github.event_name == 'schedule' && needs.prepare-release.outputs.auto_cd == 'true')) }}
outputs:
product: ${{ needs.prepare-release.outputs.product }}
release_type: ${{ needs.prepare-release.outputs.release_type }}
version: ${{ needs.prepare-release.outputs.version }}
branch: ${{ needs.prepare-release.outputs.branch }}
preid: ${{ needs.prepare-release.outputs.preid }}
series: ${{ needs.prepare-release.outputs.series }}
vsrelease: ${{ needs.prepare-release.outputs.vsrelease }}
goproduct: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.go_product) || 'false' }}
env:
APP_GITHUB_APP_ID: ${{ vars.APP_GITHUB_APP_ID }}
APP_GITHUB_APP_PRIVATE_KEY: ${{ secrets.APP_GITHUB_APP_PRIVATE_KEY }}
steps:
- id: generate-token
if: ${{ env.APP_GITHUB_APP_ID != '' && env.APP_GITHUB_APP_PRIVATE_KEY != '' }}
uses: actions/create-github-app-token@v2
with:
app-id: ${{ env.APP_GITHUB_APP_ID }}
private-key: ${{ env.APP_GITHUB_APP_PRIVATE_KEY }}
- name: Trigger CD workflow
uses: actions/github-script@v7
with:
github-token: ${{ steps.generate-token.outputs.token || github.token }}
script: |
const branch = '${{ needs.prepare-release.outputs.branch }}';
const preid = '${{ needs.prepare-release.outputs.preid }}';
const series = '${{ needs.prepare-release.outputs.series }}';
const vsrelease = '${{ needs.prepare-release.outputs.vsrelease }}' === 'true';
const goproduct = '${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.go_product) || 'false' }}' === 'true';
const isPrerelease = '${{ needs.prepare-release.outputs.is_prerelease }}' === 'true';
const finalPreid = (goproduct && !isPrerelease) ? 'stable' : preid;
console.log('Triggering CD workflow with parameters:');
console.log(` Branch: ${branch}`);
console.log(` preid: ${finalPreid} (original: ${preid}, goproduct: ${goproduct})`);
console.log(` series: ${series}`);
console.log(` vsrelease: ${vsrelease}`);
console.log(` goproduct: ${goproduct}`);
console.log(` run_test_cases: true`);
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'cd.yml',
ref: branch,
inputs: {
preid: finalPreid,
series: series,
vsrelease: vsrelease.toString(),
vstemplate: 'false',
goproduct: goproduct.toString(),
run_test_cases: 'true'
}
});
core.summary
.addHeading('🚀 CD Workflow Triggered', 2)
.addRaw('\n')
.addRaw(`Branch: \`${branch}\`\n`)
.addRaw(`View workflow runs: [Actions](../../actions/workflows/cd.yml)\n`)
.write();
trigger-cd-on-pr-merge:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'dev' && startsWith(github.event.pull_request.base.ref, 'release/') && contains(github.event.pull_request.labels.*.name, 'release-automation-sync') }}
outputs:
product: ${{ steps.trigger.outputs.product }}
release_type: ${{ steps.trigger.outputs.release_type }}
version: ${{ steps.trigger.outputs.version }}
branch: ${{ steps.trigger.outputs.branch }}
preid: ${{ steps.trigger.outputs.preid }}
series: ${{ steps.trigger.outputs.series }}
vsrelease: ${{ steps.trigger.outputs.vsrelease }}
goproduct: ${{ steps.trigger.outputs.goproduct }}
steps:
- name: Trigger CD on automation sync PR merge
id: trigger
uses: actions/github-script@v7
with:
github-token: ${{ secrets.RELEASE_AUTOMATION_TOKEN || github.token }}
script: |
const pr = context.payload.pull_request;
const base = pr.base?.ref;
const head = pr.head?.ref;
const labels = (pr.labels || []).map(l => l.name);
const body = pr.body || '';
if (head !== 'dev') {
core.notice(`Skip: head branch is '${head}', expected 'dev'.`);
return;
}
if (!base || !base.startsWith('release/')) {
core.notice(`Skip: base branch '${base}' is not a release/* branch.`);
return;
}
const hasLabel = labels.includes('release-automation-sync');
const markerMatch = body.match(/<!--\s*release-automation-cd-params:\s*(\{[\s\S]*?\})\s*-->/);
if (!hasLabel && !markerMatch) {
core.notice('Skip: PR is not identified as Release Automation sync PR (missing label/marker).');
return;
}
if (!markerMatch) {
core.setFailed('Release Automation marker is missing; cannot determine CD parameters.');
return;
}
let params;
try {
params = JSON.parse(markerMatch[1]);
} catch (e) {
core.setFailed(`Failed to parse CD params marker JSON: ${e.message}`);
return;
}
const product = String(params.product || '');
const version = String(params.version || '');
const releaseType = String(params.releaseType || params.release_type || '');
const preid = String(params.preid || '');
const series = String(params.series || '');
const vsrelease = Boolean(params.vsrelease);
const goproduct = Boolean(params.goproduct);
const releaseTypeLower = releaseType.toLowerCase();
const isPrerelease = releaseTypeLower.includes('prerelease');
const finalPreid = (goproduct && !isPrerelease) ? 'stable' : preid;
if (!finalPreid) {
core.setFailed('CD param preid is missing in marker.');
return;
}
core.summary
.addHeading('🚀 Trigger CD on PR merge', 2)
.addRaw(`\nPR: ${pr.html_url}\n`)
.addRaw(`\nBranch: \`${base}\`\n`)
.addRaw(`\nProduct: \`${product}\`\n`)
.addRaw(`\nRelease Type: \`${releaseType}\`\n`)
.addRaw(`\nVersion: \`${version}\`\n`)
.addRaw(`\npreid: \`${finalPreid}\` (original: \`${preid}\`, goproduct: \`${goproduct}\`)\n`)
.addRaw(`\nseries: \`${series}\`\n`)
.addRaw(`\nvsrelease: \`${vsrelease}\`\n`)
.addRaw(`\ngoproduct: \`${goproduct}\`\n`)
.write();
await github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'cd.yml',
ref: base,
inputs: {
preid: finalPreid,
series,
vsrelease: vsrelease.toString(),
vstemplate: 'false',
goproduct: goproduct.toString(),
run_test_cases: 'true'
},
});
core.setOutput('product', product);
core.setOutput('release_type', releaseType);
core.setOutput('version', version);
core.setOutput('branch', base);
core.setOutput('preid', preid);
core.setOutput('series', series);
core.setOutput('vsrelease', vsrelease.toString());
core.setOutput('goproduct', goproduct.toString());
core.setOutput('run_test_cases', 'true');
notify-cd-and-completion:
runs-on: ubuntu-latest
needs:
- trigger-cd
- trigger-cd-on-pr-merge
if: ${{ always() && (needs.trigger-cd.result == 'success' || needs.trigger-cd-on-pr-merge.result == 'success') }}
env:
MAIL_CLIENT_ID: ${{ secrets.MAIL_CLIENT_ID }}
MAIL_CLIENT_SECRET: ${{ secrets.MAIL_CLIENT_SECRET }}
MAIL_TENANT_ID: ${{ secrets.MAIL_TENANT_ID }}
steps:
- name: Compose notification payload
id: payload
shell: bash
env:
PRODUCT: ${{ (needs.trigger-cd.result == 'success' && needs.trigger-cd.outputs.product) || needs.trigger-cd-on-pr-merge.outputs.product }}
RELEASE_TYPE: ${{ (needs.trigger-cd.result == 'success' && needs.trigger-cd.outputs.release_type) || needs.trigger-cd-on-pr-merge.outputs.release_type }}
VERSION: ${{ (needs.trigger-cd.result == 'success' && needs.trigger-cd.outputs.version) || needs.trigger-cd-on-pr-merge.outputs.version }}
BRANCH: ${{ (needs.trigger-cd.result == 'success' && needs.trigger-cd.outputs.branch) || needs.trigger-cd-on-pr-merge.outputs.branch }}
PREID: ${{ (needs.trigger-cd.result == 'success' && needs.trigger-cd.outputs.preid) || needs.trigger-cd-on-pr-merge.outputs.preid }}
SERIES: ${{ (needs.trigger-cd.result == 'success' && needs.trigger-cd.outputs.series) || needs.trigger-cd-on-pr-merge.outputs.series }}
VSRELEASE: ${{ (needs.trigger-cd.result == 'success' && needs.trigger-cd.outputs.vsrelease) || needs.trigger-cd-on-pr-merge.outputs.vsrelease }}
GOPRODUCT: ${{ (needs.trigger-cd.result == 'success' && needs.trigger-cd.outputs.goproduct) || needs.trigger-cd-on-pr-merge.outputs.goproduct }}
run: |
set -euo pipefail
WORKFLOW_URL="${{ github.server_url }}/${{ github.repository }}/actions/workflows/cd.yml"
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
SUBJECT="ATK Release Automation Notification: ${PRODUCT} ${VERSION} CD Triggered"
BODY=$(jq -c -n \
--arg product "$PRODUCT" \
--arg version "$VERSION" \
--arg branch "$BRANCH" \
--arg release_type "$RELEASE_TYPE" \
--arg preid "$PREID" \
--arg series "$SERIES" \
--arg vsrelease "$VSRELEASE" \
--arg goproduct "$GOPRODUCT" \
--arg workflow_url "$WORKFLOW_URL" \
--arg run_url "$RUN_URL" \
' {
product: $product,
version: $version,
branch: $branch,
release_type: $release_type,
preid: $preid,
series: $series,
vsrelease: $vsrelease,
goproduct: $goproduct,
workflow_url: $workflow_url,
run_url: $run_url,
message: "The CD pipeline has been triggered. Please monitor the workflow progress."
} ')
echo "subject=$SUBJECT" >> "$GITHUB_OUTPUT"
echo "body=$BODY" >> "$GITHUB_OUTPUT"
- name: Send email notification
if: ${{ env.MAIL_CLIENT_ID != '' && env.MAIL_CLIENT_SECRET != '' && env.MAIL_TENANT_ID != '' }}
env:
MAIL_CLIENT_ID: ${{ env.MAIL_CLIENT_ID }}
MAIL_CLIENT_SECRET: ${{ env.MAIL_CLIENT_SECRET }}
MAIL_TENANT_ID: ${{ env.MAIL_TENANT_ID }}
TO: ${{ vars.RELEASE_NOTIFICATION_EMAIL || 'M365AgentsToolkitEngineerTeam@microsoft.com' }}
SUBJECT: ${{ steps.payload.outputs.subject }}
BODY: ${{ steps.payload.outputs.body }}
uses: ./.github/actions/send-email-report
- name: Post completion comment
uses: actions/github-script@v7
env:
PRODUCT: ${{ (needs.trigger-cd.result == 'success' && needs.trigger-cd.outputs.product) || needs.trigger-cd-on-pr-merge.outputs.product }}
VERSION: ${{ (needs.trigger-cd.result == 'success' && needs.trigger-cd.outputs.version) || needs.trigger-cd-on-pr-merge.outputs.version }}
BRANCH: ${{ (needs.trigger-cd.result == 'success' && needs.trigger-cd.outputs.branch) || needs.trigger-cd-on-pr-merge.outputs.branch }}
with:
script: |
const product = process.env.PRODUCT || '';
const version = process.env.VERSION || '';
const branch = process.env.BRANCH || '';
core.notice(`✅ Release automation completed for ${product} ${version} on branch ${branch}`);
core.notice('ℹ️ Email notification is best-effort (may be skipped if mail secrets are missing).');