Skip to content

[release/10.0.1xx-sr10] Restore native iOS 26 TabBar behavior (#37542) #2762

[release/10.0.1xx-sr10] Restore native iOS 26 TabBar behavior (#37542)

[release/10.0.1xx-sr10] Restore native iOS 26 TabBar behavior (#37542) #2762

name: Release Readiness

Check failure on line 1 in .github/workflows/release-readiness.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/release-readiness.yml

Invalid workflow file

(Line: 463, Col: 14): Exceeded max expression length 21000
# Unified release-readiness workflow. Each day:
# 1. detect-trackers: invoke Find-ReleaseReadinessTrackers -AllActiveMajors to enumerate every
# active in-flight/candidate branch across all active majors (SR + preview).
# 2. matrix expansion: emit one matrix job per tracker (≤ a handful per day).
# 3. per-tracker readiness: dispatch the right report script based on branchType
# ('sr' -> Get-ReleaseReadiness.ps1; 'preview' -> Get-PreviewReadiness.ps1)
# and write a daily "[Release Readiness]" issue idempotently:
# - reuse open tracker issue by canonicalKey marker if it already exists
# - otherwise close any older daily issues for the same tracker and create a new one
# - skip new-issue creation when the tracker has zero recent commits AND no open tracker issue
# 4. validate: PR-trigger path runs the same scripts but only validates output — no issue creation.
#
# Permissions: the cron/dispatch path requires `issues: write`; PR validation runs with the minimum.
on:
schedule:
# Every 3h from 08:30 to 20:30 UTC, all 7 days. Runs more often (vs the old
# weekdays-only 08:30) so a material change (CI flip, new source PR, milestone
# gate) is picked up within ~3h instead of a day — WITHOUT spamming watchers:
# the SR engine's semantic-hash no-op skips `gh issue edit` when nothing
# material changed, and issue-body edits are notification-silent regardless.
- cron: "30 8-20/3 * * *"
workflow_dispatch:
inputs:
branch:
description: "Restrict to a single branch (e.g. release/10.0.1xx-sr8 or release/11.0.1xx-preview6). Empty = all detected trackers."
required: false
default: ""
create_issue:
description: "Create/update the daily public Release Readiness issue(s)"
type: boolean
required: false
default: true
pull_request:
types: [opened, synchronize]
paths:
- '.github/workflows/release-readiness.yml'
- '.github/skills/release-readiness/**'
- '.github/scripts/shared/MauiReleaseVersioning.psm1'
# ── Scoped base-repo event triggers (refresh ONLY the affected tracker) ──
# These keep trackers fresh between the 3-hourly schedule when a MATERIAL
# signal changes, without a full fan-out. The "Detect release trackers" step
# maps each event to a BRANCH_FILTER / MAJOR_FILTER so only the relevant
# tracker(s) recompute (see that step). All are BASE-REPO events — no fork
# code is ever checked out or executed — and every attacker-influencable
# field (issue/milestone title, label name) is read via env:, never inlined
# into a run: block. pull_request_target is deliberately NOT used (fork
# secret-exfil vector).
issues:
# A regressed-in-* / high-priority label added or an issue closed changes
# the regression + high-priority sections of the affected major's tracker.
types: [labeled, closed]
milestone:
# Creating/closing a milestone flips ship-check gates (e.g. the preview
# milestone existence check).
types: [created, closed]
push:
# A push advances HEAD on a tracked line, which is exactly what flips CI
# freshness to stale. No path filter: any commit on these branches matters.
branches:
- main
- net11.0
- 'release/**'
permissions:
contents: read
# Run-level concurrency. Keyed by event + PR/branch so redundant runs of the SAME
# kind collapse. cancel-in-progress is TRUE only for pull_request (a new push
# supersedes a stale validation — saves CI) and FALSE for every writer event
# (schedule / workflow_dispatch / push / issues / milestone): such a run must
# never be cancelled mid-flight, because its per-tracker job may be part-way
# through a `gh issue edit` that splices human-authored Release Captain Notes.
# cancel-in-progress:false does NOT let bursts pile up — GitHub keeps at most one
# in-progress run plus one pending run per group and cancels any older PENDING
# run, so a flurry of pushes/labels collapses to (running + latest-pending)
# without ever killing the in-flight writer. That bounds the request budget while
# keeping the issue edit atomic. NOTE: this run-level group only serializes runs
# of the SAME event kind. The real cross-run guard against two DIFFERENT event
# kinds racing the same tracker's issue edit is the per-tracker concurrency group
# on the per-tracker-report job below.
concurrency:
# The pushed branch (github.ref_name) is folded into the key so a burst of pushes to
# DIFFERENT tracked branches (main / net11.0 / release/**) does NOT collapse into one
# 'push-all' group and drop a needed per-branch refresh when pending-run supersede
# cancels the older PENDING run. For non-push events ref_name resolves to the default
# branch, so their keys are unchanged (pull_request → PR number; workflow_dispatch →
# inputs.branch; schedule/issues/milestone → default branch, i.e. one group each).
group: release-readiness-${{ github.event_name }}-${{ github.event.pull_request.number || inputs.branch || github.ref_name || 'all' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# ────────────────────────────────────────────────────────────────────
# Job 1 — detect trackers and emit a JSON matrix
# ────────────────────────────────────────────────────────────────────
detect-trackers:
name: Detect release trackers
runs-on: ubuntu-latest
# Skip PR validation runs (handled by the validate job) and never run on a
# fork — the writer path manages issues on github.repository, so restrict the
# whole pipeline to the canonical repo (mirrors the org gate in rebase.yml).
if: github.event_name != 'pull_request' && github.repository == 'dotnet/maui'
outputs:
matrix: ${{ steps.detect.outputs.matrix }}
has-trackers: ${{ steps.detect.outputs.has-trackers }}
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Find-Trackers needs full history + tags for tag-existence detection
- name: Detect release-readiness trackers
id: detect
env:
GH_TOKEN: ${{ github.token }}
# Raw event context. SECURITY: every attacker-influencable field
# (issue label names, milestone title) is passed as an env var here and
# parsed with jq/grep in the script below — NONE is interpolated into
# the run: block via ${{ }}, so untrusted text can never reach the shell
# as code. github.ref_name / inputs.branch are likewise env-passed.
EVENT_NAME: ${{ github.event_name }}
DISPATCH_BRANCH: ${{ inputs.branch }}
PUSH_REF_NAME: ${{ github.ref_name }}
LABELS_JSON: ${{ toJson(github.event.issue.labels) }}
MILESTONE_TITLE: ${{ github.event.milestone.title }}
shell: bash
run: |
set -euo pipefail
# ── Resolve the triggering event → scope filters ────────────────────
# BRANCH_FILTER matches a tracker's branchName/surveyRef exactly;
# MAJOR_FILTER is a comma-list of .NET majors matched against
# .majorVersion. FANOUT=1 marks the ONLY events for which an empty filter
# means "refresh ALL trackers": schedule and workflow_dispatch. For every
# scoped EVENT trigger (push / issues / milestone) FANOUT stays 0, so an
# empty filter yields an EMPTY matrix and the pipeline short-circuits
# (has-trackers=false) instead of fanning out to every tracker. An
# unrelated issue close, a non-`regressed-in` label, or a non-versioned
# milestone must NOT recompute the world — the 3-hourly schedule backstops
# full coverage. This reuses the existing single-tracker plumbing
# (workflow_dispatch already fed BRANCH_FILTER) so events only recompute
# the tracker(s) they actually affect.
BRANCH_FILTER=""
MAJOR_FILTER=""
FANOUT="0"
case "$EVENT_NAME" in
workflow_dispatch)
# Manual refresh. Empty branch ⇒ intentional full fan-out (FANOUT=1);
# a specific branch narrows via BRANCH_FILTER below.
BRANCH_FILTER="${DISPATCH_BRANCH:-}"
FANOUT="1"
;;
push)
# A push advances HEAD on exactly one branch → refresh the tracker
# that surveys it (SR branchName, or a preview surveyRef like net11.0).
# A branch with no tracker (e.g. main during an SR-only phase) yields
# an empty matrix and the pipeline short-circuits.
BRANCH_FILTER="${PUSH_REF_NAME:-}"
;;
issues)
# Union of the .NET majors named by this issue's regressed-in-<major>*
# labels (covers both `labeled` — the new label is already in the set
# — and `closed`). Reduced to digits so nothing but a version number
# reaches jq. An issue that names no tracked major yields an empty
# MAJOR_FILTER and (FANOUT=0) an EMPTY matrix → the run short-circuits
# rather than recomputing every tracker for an unrelated close/label.
# The next scheduled run still covers it.
MAJOR_FILTER="$(
printf '%s' "${LABELS_JSON:-[]}" \
| jq -r '.[]?.name // empty' 2>/dev/null \
| grep -oiE 'regressed-in-[0-9]+' \
| grep -oE '[0-9]+' | sort -u | paste -sd, - || true
)"
;;
milestone)
# First version-looking number in the milestone title (".NET 11" →
# 11, "10.0.1xx-sr8" → 10). Digits only; a non-versioned title
# (e.g. "Backlog") yields an empty MAJOR_FILTER and (FANOUT=0) an
# empty matrix → skip, not a full fan-out.
MAJOR_FILTER="$(
printf '%s' "${MILESTONE_TITLE:-}" \
| grep -oE '[0-9]+' | head -1 || true
)"
;;
schedule)
# The recurring cron: the ONE event that intentionally recomputes
# every tracker. Empty filters + FANOUT=1 ⇒ full fan-out.
FANOUT="1"
;;
*)
# Any future/unrecognized event: leave FANOUT=0 so an empty filter
# skips rather than surprising us with an unscoped full fan-out. A new
# trigger that WANTS fan-out must opt in with its own case arm above.
:
;;
esac
echo "Event='$EVENT_NAME' BranchFilter='${BRANCH_FILTER}' MajorFilter='${MAJOR_FILTER}' Fanout='${FANOUT}'"
pwsh -NoProfile -File .github/skills/release-readiness/scripts/Find-ReleaseReadinessTrackers.ps1 \
-AllActiveMajors \
-OutputJson trackers.json
if [ ! -s trackers.json ]; then
echo "::error::Find-ReleaseReadinessTrackers produced no JSON"
exit 1
fi
# Flatten majors[].trackers[] into a single matrix array. Narrow by
# BRANCH_FILTER (exact branchName/surveyRef) OR MAJOR_FILTER (any major
# in the comma-list vs .majorVersion). Empty filters keep ALL trackers
# ONLY when FANOUT=1 (schedule / workflow_dispatch); for a scoped event
# trigger with no computed filter the select matches nothing → empty
# matrix → has-trackers=false → the report job is skipped.
jq --arg filter "$BRANCH_FILTER" --arg major "$MAJOR_FILTER" --arg fanout "$FANOUT" '
[ .majors[].trackers[]
| select(
($fanout == "1" and $filter == "" and $major == "")
or ($filter != "" and (.branchName == $filter or .surveyRef == $filter))
or ($major != "" and ((.majorVersion | tostring) as $mv | ($major | split(",")) | index($mv) != null))
)
| {
canonicalKey: .canonicalKey,
branchType: .branchType,
branchName: .branchName,
branchExists: .branchExists,
surveyRef: .surveyRef,
mode: .mode,
# Detection-time diagnostics only. Never use these fields for
# lifecycle gating: the generated BODY_FILE markers are the
# authoritative report-time state after refs/tags may move.
hotfixInProgress: (.hotfixInProgress // false),
hotfixVersion: (.hotfixVersion // ""),
hotfixCommit: (.hotfixCommit // ""),
majorVersion: .majorVersion,
issueTitle: .issueTitle,
milestoneName: .milestoneName,
recentCommitCount: .recentCommitCount,
hasRecentActivity: .hasRecentActivity,
expectedTag: (.expectedTag // ""),
# SR-only fields (null for preview trackers)
priorSrBranch: (.priorSrBranch // ""),
regressionLabels: (.regressionLabels // []),
# Preview-only fields (null for SR trackers)
previewNumber: (.previewNumber // null)
}
]
' trackers.json > matrix.json
MATRIX_LEN=$(jq 'length' matrix.json)
echo "Detected $MATRIX_LEN tracker(s)"
jq -c '.' matrix.json
# Encode matrix for GitHub Actions matrix expansion.
MATRIX_JSON=$(jq -c '{include: .}' matrix.json)
echo "matrix=$MATRIX_JSON" >> "$GITHUB_OUTPUT"
if [ "$MATRIX_LEN" -gt 0 ]; then
echo "has-trackers=true" >> "$GITHUB_OUTPUT"
else
echo "has-trackers=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload trackers.json
if: always()
uses: actions/upload-artifact@v4
with:
name: release-readiness-trackers
path: |
trackers.json
matrix.json
retention-days: 30
# ────────────────────────────────────────────────────────────────────
# Job 2 — per-tracker readiness report (one matrix job per tracker)
# ────────────────────────────────────────────────────────────────────
per-tracker-report:
name: ${{ matrix.canonicalKey }} (${{ matrix.branchType }})
needs: detect-trackers
if: needs.detect-trackers.outputs.has-trackers == 'true'
runs-on: ubuntu-latest
# Per-tracker writer serialization. This is the load-bearing guard against the
# Release Captain Notes clobber race: the "Update or create tracker issue" step
# re-reads the live issue body and awk-splices the human-notes block back into the
# fresh report just before `gh issue edit`. Two runs writing the SAME tracker
# concurrently (e.g. a scheduled run and an event-triggered run — added in a later
# commit) could interleave read/splice/edit and silently drop notes. Keying this
# group on matrix.canonicalKey forces same-tracker writers to run one-at-a-time
# ACROSS runs, regardless of event kind (the run-level group only serializes runs
# of the same event kind). Different trackers stay parallel (distinct keys).
# cancel-in-progress is FALSE on purpose: serialize, don't cancel, so an in-flight
# `gh issue edit` always completes rather than being killed part-way.
concurrency:
group: release-readiness-tracker-${{ matrix.canonicalKey }}
cancel-in-progress: false
permissions:
contents: read
issues: write
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.detect-trackers.outputs.matrix) }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate readiness report
id: report
env:
GH_TOKEN: ${{ github.token }}
BRANCH_TYPE: ${{ matrix.branchType }}
BRANCH_NAME: ${{ matrix.branchName }}
BRANCH_EXISTS: ${{ matrix.branchExists }}
SURVEY_REF: ${{ matrix.surveyRef }}
MODE: ${{ matrix.mode }}
TRACKER_KEY: ${{ matrix.canonicalKey }}
ISSUE_TITLE: ${{ matrix.issueTitle }}
PRIOR_SR: ${{ matrix.priorSrBranch }}
REG_LABELS: ${{ join(matrix.regressionLabels, ',') }}
EXPECTED_TAG: ${{ matrix.expectedTag }}
shell: bash
run: |
set -euo pipefail
mkdir -p readiness-out
# Detection and report generation are separate jobs. If an in-flight
# SR's stable tag lands in that gap, regenerate it as shipped now so
# the report body emits authoritative shipped-generation markers.
if [ "$BRANCH_TYPE" = "sr" ] \
&& [ "$MODE" = "in-flight" ] \
&& [ -n "$EXPECTED_TAG" ] \
&& git rev-parse --verify --quiet "refs/tags/${EXPECTED_TAG}^{commit}" >/dev/null; then
echo "Stable tag ${EXPECTED_TAG} appeared after detection; generating this SR as shipped."
MODE="shipped"
TRACKER_MAJOR="${TRACKER_KEY#net}"
TRACKER_MAJOR="${TRACKER_MAJOR%%-*}"
TRACKER_SR="${TRACKER_KEY##*-sr}"
ISSUE_TITLE="[Release Readiness] .NET ${TRACKER_MAJOR} SR${TRACKER_SR} — shipped (${BRANCH_NAME})"
fi
if [ "$BRANCH_TYPE" = "sr" ]; then
# SR readiness:
# in-flight → -SrBranch <branchName> (no Candidate flag)
# candidate → -SrBranch <priorSrBranch> -Candidate
# Find-ReleaseReadinessTrackers's New-RegressionLabelList always
# returns at least one label for every SR, so REG_LABELS is never
# empty here — wire the labels through directly without the
# legacy -InferRegressionLabels fallback.
if [ -z "$REG_LABELS" ]; then
echo "::error::SR tracker $TRACKER_KEY missing regressionLabels (Find-Trackers should always emit ≥1)"
exit 1
fi
REG_LABEL_ARG=(-RegressionLabels "$REG_LABELS")
CANDIDATE_ARG=()
if [ "$MODE" = "candidate" ]; then
if [ -z "$PRIOR_SR" ]; then
echo "::error::SR candidate tracker $TRACKER_KEY missing priorSrBranch"
exit 1
fi
SR_ARG="$PRIOR_SR"
CANDIDATE_ARG=(-Candidate)
elif [ "$MODE" = "shipped" ]; then
# Already-tagged SR: survey the branch directly (same as in-flight),
# but pass -Shipped so the rendered header reads mode=shipped rather
# than misreporting the post-ship tracker as in-flight.
SR_ARG="$BRANCH_NAME"
CANDIDATE_ARG=(-Shipped)
else
SR_ARG="$BRANCH_NAME"
fi
pwsh -NoProfile -File .github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 \
-SrBranch "$SR_ARG" \
"${CANDIDATE_ARG[@]}" \
"${REG_LABEL_ARG[@]}" \
-TrackerKey "$TRACKER_KEY" \
-OutputDir readiness-out
BODY_FILE="readiness-out/release-readiness.md"
elif [ "$BRANCH_TYPE" = "preview" ]; then
# Preview readiness — Get-PreviewReadiness.ps1 always takes the
# canonical preview branch name (whether it exists yet or not);
# candidate mode flips -SurveyRef to net<major>.0.
pwsh -NoProfile -File .github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 \
-Branch "$BRANCH_NAME" \
-Mode "$MODE" \
-SurveyRef "$SURVEY_REF" \
-TrackerKey "$TRACKER_KEY" \
-OutputDir readiness-out \
-OutputFormat markdown
BODY_FILE="readiness-out/preview-readiness.md"
else
echo "::error::Unknown branchType '$BRANCH_TYPE'"
exit 1
fi
if [ ! -s "$BODY_FILE" ]; then
echo "::error::Readiness body file is empty: $BODY_FILE"
exit 1
fi
# Reconcile the user-visible title from the generated lifecycle markers
# too. A hotfix commit or tag can land between detector and report jobs;
# body markers are already authoritative for gating, so the title must
# describe the same report-time generation.
if [ "$BRANCH_TYPE" = "sr" ]; then
REPORT_HOTFIX_MARKER=$(LC_ALL=C grep -m1 -E '^<!-- release-readiness-hotfix: [^>]+ -->$' "$BODY_FILE" || true)
REPORT_SHIPPED_MARKER=$(LC_ALL=C grep -m1 -E '^<!-- release-readiness-shipped: [^>]+ -->$' "$BODY_FILE" || true)
TRACKER_MAJOR="${TRACKER_KEY#net}"
TRACKER_MAJOR="${TRACKER_MAJOR%%-*}"
TRACKER_SR="${TRACKER_KEY##*-sr}"
if [ -n "$REPORT_HOTFIX_MARKER" ]; then
REPORT_HOTFIX_ID="${REPORT_HOTFIX_MARKER#<!-- release-readiness-hotfix: }"
REPORT_HOTFIX_ID="${REPORT_HOTFIX_ID% -->}"
REPORT_HOTFIX_VERSION="${REPORT_HOTFIX_ID%%@*}"
if [ "$REPORT_HOTFIX_VERSION" = "version-pending" ]; then
ISSUE_TITLE="[Release Readiness] .NET ${TRACKER_MAJOR} SR${TRACKER_SR} — hotfix version pending (${BRANCH_NAME})"
else
ISSUE_TITLE="[Release Readiness] .NET ${TRACKER_MAJOR} SR${TRACKER_SR} — hotfix ${REPORT_HOTFIX_VERSION} in progress (${BRANCH_NAME})"
fi
MODE="shipped"
elif [ -n "$REPORT_SHIPPED_MARKER" ]; then
ISSUE_TITLE="[Release Readiness] .NET ${TRACKER_MAJOR} SR${TRACKER_SR} — shipped (${BRANCH_NAME})"
MODE="shipped"
fi
fi
echo "mode=$MODE" >> "$GITHUB_OUTPUT"
echo "issue_title=$ISSUE_TITLE" >> "$GITHUB_OUTPUT"
echo "body-file=$BODY_FILE" >> "$GITHUB_OUTPUT"
{
echo "## ${TRACKER_KEY} (${BRANCH_TYPE})"
echo ""
cat "$BODY_FILE"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload readiness artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: readiness-${{ matrix.canonicalKey }}
path: readiness-out/
retention-days: 30
- name: Update or create tracker issue
# Write for every trigger that reaches this job EXCEPT a workflow_dispatch
# that explicitly opted out via create_issue=false. schedule/push/issues/
# milestone always write; pull_request never reaches here (separate
# validate job, gated by the detect-trackers if:).
if: github.event_name != 'workflow_dispatch' || inputs.create_issue
env:
GH_TOKEN: ${{ github.token }}
TRACKER_KEY: ${{ matrix.canonicalKey }}
ISSUE_TITLE: ${{ steps.report.outputs.issue_title }}
MILESTONE_NAME: ${{ matrix.milestoneName }}
BODY_FILE: ${{ steps.report.outputs.body-file }}
RECENT_COMMIT_COUNT: ${{ matrix.recentCommitCount }}
MODE: ${{ steps.report.outputs.mode }}
shell: bash
run: |
set -euo pipefail
source .github/skills/release-readiness/scripts/TrackerIssueLifecycle.sh
# Find any open issue whose body carries the canonical marker for this tracker.
# This is the idempotent join key — Get-ReleaseReadiness / Get-PreviewReadiness
# both embed `<!-- release-readiness-tracker: $TRACKER_KEY -->`.
MARKER="<!-- release-readiness-tracker: ${TRACKER_KEY} -->"
# The generated report is authoritative for lifecycle state. Detector
# matrix values can become stale if a tag or branch commit lands between
# detection and report generation; the body markers reflect the exact
# refs the report actually surveyed.
HOTFIX_MARKER=$(LC_ALL=C grep -m1 -E '^<!-- release-readiness-hotfix: [^>]+ -->$' "$BODY_FILE" || true)
SHIPPED_MARKER=$(LC_ALL=C grep -m1 -E '^<!-- release-readiness-shipped: [^>]+ -->$' "$BODY_FILE" || true)
REPORT_HOTFIX_IN_PROGRESS=false
[ -n "$HOTFIX_MARKER" ] && REPORT_HOTFIX_IN_PROGRESS=true
CREATE_GENERATION=false
GENERATION_MARKER=""
if [ "$MODE" = "shipped" ]; then
GENERATION_MARKER="$SHIPPED_MARKER"
[ "$REPORT_HOTFIX_IN_PROGRESS" = "true" ] && GENERATION_MARKER="$HOTFIX_MARKER"
if [ -z "$GENERATION_MARKER" ]; then
echo "::warning::Shipped report for ${TRACKER_KEY} has no lifecycle generation marker; refusing to create or infer closure state."
exit 0
fi
fi
EXISTING=$(gh issue list \
--repo "${{ github.repository }}" \
--state open \
--label area-infrastructure \
--search "in:body \"${MARKER}\"" \
--json number,title,createdAt,body,labels \
--limit 100 | jq -r \
--arg tracker "$MARKER" '
[.[] | select(
([.labels[].name] | index("area-infrastructure") != null) and
(((.body // "") | gsub("\r"; "") | split("\n") | index($tracker)) != null)
)] | sort_by(.createdAt) | .[].number
')
# Do not silently orphan or duplicate a tracker whose ownership label
# was removed. Exact marker text proves a candidate exists, but without
# the durable label the workflow must not edit or adopt it automatically.
if [ -z "$EXISTING" ]; then
UNOWNED_EXISTING=$(gh issue list \
--repo "${{ github.repository }}" \
--state open \
--search "in:body \"${MARKER}\"" \
--json number,createdAt,body,labels \
--limit 100 | jq -r \
--arg tracker "$MARKER" '
[.[] | select(
([.labels[].name] | index("area-infrastructure") == null) and
(((.body // "") | gsub("\r"; "") | split("\n") | index($tracker)) != null)
)] | sort_by(.createdAt) | .[].number
')
if [ -n "$UNOWNED_EXISTING" ]; then
echo "::warning::Open tracker marker ${MARKER} exists on unlabeled issue(s): $(echo "$UNOWNED_EXISTING" | tr '\n' ' '). Restore the area-infrastructure label before automation resumes; refusing to create a duplicate."
exit 0
fi
fi
# Each exact shipped-tag or hotfix-tip generation is create-once. A
# later hotfix commit intentionally has a new version@commit marker.
# This distinguishes an intentionally closed generation from one that was
# never tracked (for example, a hotfix tag published before the first
# scheduled updater run). Search the exact generated marker directly so
# the result is not diluted by a generic-marker --limit window.
if [ "$MODE" = "shipped" ]; then
EXACT_OPEN=$(gh issue list \
--repo "${{ github.repository }}" \
--state open \
--label area-infrastructure \
--search "in:body \"${GENERATION_MARKER}\"" \
--json number,createdAt,body,labels \
--limit 100 | jq -r \
--arg tracker "$MARKER" \
--arg generation "$GENERATION_MARKER" '
[.[] | select(
([.labels[].name] | index("area-infrastructure") != null) and
(((.body // "") | gsub("\r"; "") | split("\n")) as $lines |
($lines | index($tracker) != null) and
($lines | index($generation) != null))
)] | sort_by(.createdAt) | .[].number
')
if [ -n "$EXACT_OPEN" ]; then
# The exact open proves this generation was tracked, but the oldest
# generic tracker remains canonical. It may carry Release Captain
# Notes that must not be discarded when duplicate cleanup runs.
echo "Current generation is already open as issue(s): $(echo "$EXACT_OPEN" | tr '\n' ' '); preserving oldest tracker #$(rr_select_oldest_tracker "$EXISTING") as canonical."
else
# Narrow server-side to tracker-labeled issues, then enforce exact
# marker lines locally. Legacy/current trackers may be human-created
# and later adopted by this workflow, so author identity is not a
# durable ownership signal.
CLOSED_GENERATION=$(gh issue list \
--repo "${{ github.repository }}" \
--state closed \
--label area-infrastructure \
--search "in:body \"${GENERATION_MARKER}\"" \
--json number,closedAt,body,labels \
--limit 100 | jq -r \
--arg tracker "$MARKER" \
--arg generation "$GENERATION_MARKER" '
[.[] | select(
([.labels[].name] | index("area-infrastructure") != null) and
(((.body // "") | gsub("\r"; "") | split("\n")) as $lines |
($lines | index($tracker) != null) and
($lines | index($generation) != null))
)] | sort_by(.closedAt) | last | .number // empty
')
if [ -n "$CLOSED_GENERATION" ]; then
# A prior best-effort duplicate close may have failed. Reconcile
# stale generic opens before honoring the exact closed generation.
for stale in $EXISTING; do
echo "Closing stale tracker issue #$stale because exact generation ${GENERATION_MARKER} is already closed as #${CLOSED_GENERATION}"
gh issue close "$stale" \
--repo "${{ github.repository }}" \
--reason "completed" \
--comment "Closing stale tracker: exact release-readiness generation was intentionally closed as #${CLOSED_GENERATION}." || true
done
echo "Tracker generation ${GENERATION_MARKER} was intentionally closed as #${CLOSED_GENERATION} — not recreating."
exit 0
fi
# Keep one open tracker stable across commits so Release Captain
# Notes and subscriptions survive. A new issue is needed only when
# there is no open tracker and this exact generation was never closed.
[ -z "$EXISTING" ] && CREATE_GENERATION=true
fi
fi
# Activity gate: when there is no recent activity AND no open tracker issue,
# skip new-issue creation. A newly observed shipped/hotfix generation
# is exempt even when its commits fall outside the activity window.
if [ "$RECENT_COMMIT_COUNT" -eq 0 ] && [ "$CREATE_GENERATION" != "true" ] && [ -z "$EXISTING" ]; then
echo "Skipping ${TRACKER_KEY}: no recent commits and no open tracker issue."
exit 0
fi
if [ -n "$EXISTING" ]; then
# Reuse the OLDEST open tracker issue (first in chronological order).
# Close any duplicates created by past misfires before refreshing the canonical one.
CANONICAL=$(rr_select_oldest_tracker "$EXISTING")
DUPLICATES=$(echo "$EXISTING" | tail -n +2 || true)
for dup in $DUPLICATES; do
echo "Closing duplicate tracker issue #$dup"
gh issue close "$dup" \
--repo "${{ github.repository }}" \
--reason "not planned" \
--comment "Closing as duplicate of #${CANONICAL} — there should be exactly one open tracker per release branch." || true
done
echo "Refreshing tracker issue #${CANONICAL} for ${TRACKER_KEY}"
# Preserve the human-editable "Release Captain Notes" block and avoid
# churning the issue when nothing material changed. Both engines emit
# <!-- release-readiness:human-notes:begin/end --> markers; the SR engine
# additionally embeds <!-- release-readiness-hash: sha=... -->.
#
# Concurrency note: the per-tracker `concurrency` group on this job
# (release-readiness-tracker-<canonicalKey>, cancel-in-progress:false) is
# the PRIMARY guard against two runs racing this splice+edit for the same
# tracker — it serializes same-tracker writers across every event kind. The
# read-modify-write below (re-read live body → splice notes → edit) is the
# defense-in-depth second layer: even if serialization were ever bypassed,
# the fetch is taken IMMEDIATELY before the edit and every ambiguous state
# skips the edit (see the guards below) rather than risk clobbering notes.
CUR_BODY_FILE="$(mktemp)"
# Capture the live issue body. A transient fetch failure must NOT lead
# to an overwrite: an empty CUR_BODY_FILE would skip the notes splice
# AND zero out OLD_HASH, falling through to `gh issue edit` and wiping
# the human-authored Release Captain Notes. Guard the exit status and
# skip the whole refresh instead (a missing refresh self-heals next run;
# lost notes do not).
CUR_FETCH_OK=1
CUR_META=$(gh issue view "$CANONICAL" \
--repo "${{ github.repository }}" \
--json body,updatedAt \
--jq '[.updatedAt, ((.body // "") | @base64)] | @tsv' 2>/dev/null) || CUR_FETCH_OK=0
if [ "$CUR_FETCH_OK" -eq 1 ]; then
IFS=$'\t' read -r CUR_UPDATED_AT CUR_BODY_B64 <<< "$CUR_META"
printf '%s' "$CUR_BODY_B64" | base64 -d > "$CUR_BODY_FILE" || CUR_FETCH_OK=0
fi
if [ "$CUR_FETCH_OK" -ne 1 ]; then
echo "::warning::Could not read live body of issue #${CANONICAL}; skipping refresh to protect Release Captain Notes."
else
# Detect the human-notes block using the SAME anchored full-line
# markers the awk splice relies on. A substring (unanchored) guard
# desyncs from the awk and silently wipes the Release Captain Notes:
# * a note that merely MENTIONS the end token makes the count 2, the
# -eq 1 guard fails, the splice is skipped, and the edit overwrites
# the notes; and
# * a marker line carrying trailing text passes a substring guard but
# the anchored awk matches nothing, splicing in an EMPTY block.
# The anchors tolerate the CRLF bodies GitHub returns (\r is ASCII
# whitespace in every locale). LC_ALL=C is MANDATORY on every grep and
# awk here: GNU grep in the runner's UTF-8 locale treats Unicode spaces
# (e.g. U+00A0 NO-BREAK SPACE, easily pasted from a web editor) as
# [[:space:]], but mawk (the runner default) does not — so a UTF-8
# grep guard could PASS while the awk extracts nothing, splicing an
# EMPTY block over real notes. Forcing C locale makes grep and awk
# agree on ASCII-only [[:space:]], so a weird space fails the guard and
# freezes the issue (safe) instead of destroying the notes.
NOTES_BEGIN_RE='^[[:space:]]*<!-- release-readiness:human-notes:begin -->[[:space:]]*$'
NOTES_END_RE='^[[:space:]]*<!-- release-readiness:human-notes:end -->[[:space:]]*$'
CUR_HAS_CLEAN_NOTES=0
if [ "$(LC_ALL=C grep -cE "$NOTES_BEGIN_RE" "$CUR_BODY_FILE")" -eq 1 ] \
&& [ "$(LC_ALL=C grep -cE "$NOTES_END_RE" "$CUR_BODY_FILE")" -eq 1 ]; then
CUR_HAS_CLEAN_NOTES=1
fi
# Does the FRESH body carry exactly one clean begin+end pair? If a
# truncated/markerless fresh body would be used to overwrite an issue
# that HAS real notes, those notes are lost — so we require this too.
BODY_HAS_CLEAN_NOTES=0
if [ "$(LC_ALL=C grep -cE "$NOTES_BEGIN_RE" "$BODY_FILE")" -eq 1 ] \
&& [ "$(LC_ALL=C grep -cE "$NOTES_END_RE" "$BODY_FILE")" -eq 1 ]; then
BODY_HAS_CLEAN_NOTES=1
fi
SKIP_EDIT=0
# 1) Splice any human-authored notes from the live issue into the fresh
# body, replacing the freshly generated placeholder block. Require a
# COMPLETE, single begin+end marker pair in BOTH bodies — an
# unterminated or duplicated block would otherwise capture the entire
# stale report to EOF and re-inject it, growing the body every run.
if [ "$CUR_HAS_CLEAN_NOTES" -eq 1 ] && [ "$BODY_HAS_CLEAN_NOTES" -eq 1 ]; then
MERGED_BODY_FILE="$(mktemp)"
# Markers are matched as ANCHORED FULL LINES so a note that merely
# mentions the marker text cannot prematurely terminate capture.
# LC_ALL=C keeps awk's [[:space:]] ASCII-only, matching the grep guard.
LC_ALL=C awk '
/^[[:space:]]*<!-- release-readiness:human-notes:begin -->[[:space:]]*$/ {
if (FNR==NR) { cap=1; next } else { print; printf "%s", notes; skip=1; next }
}
/^[[:space:]]*<!-- release-readiness:human-notes:end -->[[:space:]]*$/ {
if (FNR==NR) { cap=0; next } else { print; skip=0; next }
}
FNR==NR { if (cap) { notes = notes $0 "\n" } ; next }
{ if (!skip) print }
' "$CUR_BODY_FILE" "$BODY_FILE" > "$MERGED_BODY_FILE"
mv "$MERGED_BODY_FILE" "$BODY_FILE"
echo "Preserved existing Release Captain Notes block."
elif [ "$CUR_HAS_CLEAN_NOTES" -eq 1 ] && [ "$BODY_HAS_CLEAN_NOTES" -ne 1 ]; then
# The live issue HAS clean notes but the freshly generated body does
# NOT carry a clean begin+end pair (e.g. truncated below the cap, or
# markers otherwise missing). Splicing is impossible and overwriting
# would wipe the live notes, so skip the edit entirely. Self-heals on
# the next run once the fresh body regains its markers.
echo "::warning::Fresh report for #${CANONICAL} lacks clean notes markers (truncated?); skipping edit to protect existing Release Captain Notes."
SKIP_EDIT=1
elif [ "$CUR_HAS_CLEAN_NOTES" -ne 1 ] \
&& LC_ALL=C grep -q 'release-readiness:human-notes:' "$CUR_BODY_FILE"; then
# The live body carries notes markers that don't resolve to a single
# clean begin+end pair (corrupted, duplicated, or text on the marker
# line). We can't splice safely and overwriting would wipe the notes,
# so skip the edit entirely — self-heals once the markers are a clean
# pair again (a stale refresh recovers; destroyed captain notes do not).
echo "::warning::Issue #${CANONICAL} has malformed Release Captain Notes markers; skipping edit to protect them."
SKIP_EDIT=1
fi
# 1b) Final body-size guard. The awk splice above injects the LIVE
# notes block (which a captain may have grown to many KB) into the
# freshly capped body. The engines cap the FRESH body, reserving
# room only for the small notes PLACEHOLDER — they never see the
# live-notes size — so a busy report plus large notes can push the
# merged body past GitHub's 65,536-byte issue-body limit, which
# makes `gh issue edit` 422 and fail the run under set -e. Skip the
# edit instead (notes stay safe; the report just stays stale this
# run) and self-heal once the report or the notes shrink. `wc -c`
# counts bytes — matching the engines' byte-based cap — and is
# conservative against GitHub's character limit.
if [ "$SKIP_EDIT" -ne 1 ]; then
MERGED_SIZE=$(wc -c < "$BODY_FILE")
if [ "$MERGED_SIZE" -gt 65536 ]; then
echo "::warning::Body for #${CANONICAL} is ${MERGED_SIZE} bytes (> GitHub's 65536-byte limit) after splicing live notes; skipping edit to avoid a failed gh issue edit. Self-heals once the report or notes shrink."
SKIP_EDIT=1
fi
fi
# 2) Idempotent no-op: if the semantic hash is unchanged, skip the edit
# so scheduled re-runs don't spam watchers. The engine emits its hash
# at the very TOP of the body, ABOVE the human-notes block, so scope
# extraction to the pre-notes region with `sed '/begin/q'`. Anchoring
# the grep to the full HTML-comment form is not enough on its own: the
# `<!-- release-readiness-hash: sha=... -->` line is exactly what a
# captain copies from a prior raw-markdown run and may paste INTO their
# notes; the splice then carries it into the fresh body. On Preview
# trackers (which emit NO hash and must refresh every run) that pasted
# line would make OLD_HASH==NEW_HASH and FREEZE the issue. Scoping to
# above the notes block makes any hash inside the notes invisible to the
# compare, regardless of paste form. The anchored grep keeps the match
# precise and drops a trailing CRLF \r from the captured hash.
if [ "$SKIP_EDIT" -ne 1 ]; then
OLD_HASH=$(sed '/<!-- release-readiness:human-notes:begin -->/q' "$CUR_BODY_FILE" | grep -oE '<!-- release-readiness-hash: sha=[0-9a-f]+ -->' | head -n1 | sed 's/.*sha=//; s/ -->//') || true
NEW_HASH=$(sed '/<!-- release-readiness:human-notes:begin -->/q' "$BODY_FILE" | grep -oE '<!-- release-readiness-hash: sha=[0-9a-f]+ -->' | head -n1 | sed 's/.*sha=//; s/ -->//') || true
if [ -n "$NEW_HASH" ] && [ "$OLD_HASH" = "$NEW_HASH" ]; then
echo "Semantic hash unchanged (${NEW_HASH}) — skipping issue edit (no-op)."
else
# A human can close the issue while this run is preparing the
# notes splice. Re-read state immediately before the write to
# minimize the close/edit race, then compensate below when
# GitHub timestamps prove the edit landed after the closure.
GENERATION_TRANSITION=false
if [ -n "$GENERATION_MARKER" ] && ! rr_has_exact_marker_line "$CUR_BODY_FILE" "$GENERATION_MARKER"; then
GENERATION_TRANSITION=true
fi
PRE_EDIT_META=$(gh issue view "$CANONICAL" \
--repo "${{ github.repository }}" \
--json state,body,updatedAt \
--jq '[.state, .updatedAt, ((.body // "") | @base64)] | @tsv' 2>/dev/null || true)
IFS=$'\t' read -r PRE_EDIT_STATE PRE_EDIT_UPDATED_AT PRE_EDIT_BODY_B64 <<< "$PRE_EDIT_META"
if [ "$PRE_EDIT_STATE" != "OPEN" ]; then
echo "::warning::Issue #${CANONICAL} is no longer open; skipping refresh to preserve the human closure."
elif [ "$PRE_EDIT_UPDATED_AT" != "$CUR_UPDATED_AT" ] || [ "$PRE_EDIT_BODY_B64" != "$CUR_BODY_B64" ]; then
echo "::warning::Issue #${CANONICAL} changed while this refresh was preparing; skipping edit to preserve concurrent Release Captain Notes. The next run will merge the latest body."
else
if [ "$GENERATION_TRANSITION" = "true" ]; then
# Capture the timestamp and body from this exact mutation.
# A later issue event can advance the aggregate updatedAt,
# so a post-read timestamp cannot prove edit/close order.
EDIT_RESULT=$(gh api --method PATCH \
"repos/${{ github.repository }}/issues/${CANONICAL}" \
-F title="$ISSUE_TITLE" \
-F body=@"$BODY_FILE" \
--jq '[.updated_at, ((.body // "") | @base64)] | @tsv')
IFS=$'\t' read -r EDIT_UPDATED_AT EDIT_BODY_B64 <<< "$EDIT_RESULT"
POST_EDIT_META=''
if ! POST_EDIT_META=$(gh issue view "$CANONICAL" \
--repo "${{ github.repository }}" \
--json state,closedAt,updatedAt,body \
--jq '[.state, (.closedAt // ""), (.updatedAt // ""), ((.body // "") | @base64)] | @tsv' 2>/dev/null); then
echo "::warning::Could not re-read issue #${CANONICAL} after the generation-transition edit; race compensation could not be evaluated."
elif [ -z "$POST_EDIT_META" ]; then
echo "::warning::Issue #${CANONICAL} returned no post-edit metadata; race compensation could not be evaluated."
else
IFS=$'\t' read -r POST_EDIT_STATE POST_CLOSED_AT POST_UPDATED_AT POST_BODY_B64 <<< "$POST_EDIT_META"
if [ "$POST_EDIT_STATE" = "CLOSED" ] &&
[ -n "$EDIT_BODY_B64" ] &&
[ -n "$POST_BODY_B64" ] &&
[ "$POST_BODY_B64" = "$EDIT_BODY_B64" ] &&
rr_edit_landed_after_close "$POST_CLOSED_AT" "$EDIT_UPDATED_AT"; then
# The mutation response proves this exact edit happened
# after the human closure and remains the live body.
# Start from the live closed body, then recheck its revision
# immediately before removing only the raced marker.
RACE_CURRENT_BODY_FILE="$(mktemp)"
RACE_BODY_FILE="$(mktemp)"
echo "$POST_BODY_B64" | base64 --decode > "$RACE_CURRENT_BODY_FILE"
rr_remove_exact_marker_line "$RACE_CURRENT_BODY_FILE" "$RACE_BODY_FILE" "$GENERATION_MARKER"
PRE_RACE_META=$(gh issue view "$CANONICAL" \
--repo "${{ github.repository }}" \
--json state,updatedAt,body \
--jq '[.state, .updatedAt, ((.body // "") | @base64)] | @tsv' 2>/dev/null || true)
IFS=$'\t' read -r PRE_RACE_STATE PRE_RACE_UPDATED_AT PRE_RACE_BODY_B64 <<< "$PRE_RACE_META"
if [ "$PRE_RACE_STATE" != "CLOSED" ] ||
[ "$PRE_RACE_UPDATED_AT" != "$POST_UPDATED_AT" ] ||
[ "$PRE_RACE_BODY_B64" != "$POST_BODY_B64" ]; then
echo "::warning::Issue #${CANONICAL} changed during generation-race compensation; preserving the latest human body and leaving reconciliation to the next run."
else
if gh issue edit "$CANONICAL" \
--repo "${{ github.repository }}" \
--body-file "$RACE_BODY_FILE"; then
echo "::warning::Issue #${CANONICAL} closed before the generation-transition edit completed; removed the raced marker from the live closed body so the next run can recreate the new generation."
else
echo "::warning::Could not remove the raced generation marker from closed issue #${CANONICAL}; manual reconciliation may be required."
fi
fi
rm -f "$RACE_CURRENT_BODY_FILE" "$RACE_BODY_FILE"
fi
fi
else
gh issue edit "$CANONICAL" \
--repo "${{ github.repository }}" \
--title "$ISSUE_TITLE" \
--body-file "$BODY_FILE"
fi
fi
fi
fi
fi
else
echo "Creating new tracker issue for ${TRACKER_KEY}"
CREATE_ARGS=(
--repo "${{ github.repository }}"
--title "$ISSUE_TITLE"
--body-file "$BODY_FILE"
)
# area-infrastructure is the durable ownership boundary used by the
# closed-generation lookup. Refuse to create an issue that the
# lifecycle code could not recognize later.
if gh api "repos/${{ github.repository }}/labels/area-infrastructure" --jq '.name' >/dev/null 2>&1; then
CREATE_ARGS+=(--label "area-infrastructure")
else
echo "::error::Required label 'area-infrastructure' not found; refusing to create an untrackable release-readiness issue."
exit 1
fi
# The remaining organizational labels are best-effort.
for lbl in "report" "s/triaged"; do
if gh api "repos/${{ github.repository }}/labels/${lbl//\//%2F}" --jq '.name' >/dev/null 2>&1; then
CREATE_ARGS+=(--label "$lbl")
else
echo "::warning::Label '$lbl' not found; creating issue without it."
fi
done
# Best-effort milestone attach — never fail the job for a missing milestone.
if [ -n "$MILESTONE_NAME" ]; then
if gh api "repos/${{ github.repository }}/milestones?state=open&per_page=100" \
--jq ".[] | select(.title == \"$MILESTONE_NAME\") | .number" \
| grep -q .; then
CREATE_ARGS+=(--milestone "$MILESTONE_NAME")
else
echo "::warning::Milestone '$MILESTONE_NAME' not found; creating issue without milestone."
fi
fi
gh issue create "${CREATE_ARGS[@]}"
fi
# ────────────────────────────────────────────────────────────────────
# PR validation — run scripts without touching issues
# ────────────────────────────────────────────────────────────────────
validate:
name: Validate (PR)
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run unit tests
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
pwsh -NoProfile -File .github/skills/release-readiness/tests/Test-ReleaseReadiness.ps1
- name: Run Find-Trackers (no issue side-effects)
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
pwsh -NoProfile -File .github/skills/release-readiness/scripts/Find-ReleaseReadinessTrackers.ps1 \
-AllActiveMajors \
-OutputJson trackers.json
if [ ! -s trackers.json ]; then
echo "::error::Find-ReleaseReadinessTrackers produced no JSON"
exit 1
fi
echo "Detection JSON sample (first 200 lines):"
head -200 trackers.json
- name: Smoke-run report scripts for each detected tracker
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
mkdir -p validate-out
# For each tracker, just smoke-test the report-generation path (~30s/tracker).
jq -c '.majors[].trackers[]' trackers.json | while IFS= read -r tracker; do
CANONICAL=$(echo "$tracker" | jq -r '.canonicalKey')
BRANCH_TYPE=$(echo "$tracker" | jq -r '.branchType')
BRANCH_NAME=$(echo "$tracker" | jq -r '.branchName')
SURVEY_REF=$(echo "$tracker" | jq -r '.surveyRef')
MODE=$(echo "$tracker" | jq -r '.mode')
PRIOR_SR=$(echo "$tracker" | jq -r '.priorSrBranch // ""')
REG_LABELS=$(echo "$tracker" | jq -r '.regressionLabels // [] | join(",")')
OUT_DIR="validate-out/${CANONICAL}"
mkdir -p "$OUT_DIR"
echo "::group::Validate ${CANONICAL} (${BRANCH_TYPE})"
if [ "$BRANCH_TYPE" = "sr" ]; then
# New-RegressionLabelList always emits ≥1 label, so the
# -InferRegressionLabels fallback is unreachable. Wire labels
# through directly and fail loudly if upstream regressed.
if [ -z "$REG_LABELS" ]; then
echo "::error::SR tracker $CANONICAL missing regressionLabels"
exit 1
fi
REG_LABEL_ARG=(-RegressionLabels "$REG_LABELS")
CANDIDATE_ARG=()
if [ "$MODE" = "candidate" ]; then
SR_ARG="$PRIOR_SR"
CANDIDATE_ARG=(-Candidate)
elif [ "$MODE" = "shipped" ]; then
# Mirror the production job: survey the branch directly but pass
# -Shipped so the smoke-run exercises the shipped code path (header
# relabel + semantic-hash mode fold) instead of running as in-flight.
SR_ARG="$BRANCH_NAME"
CANDIDATE_ARG=(-Shipped)
else
SR_ARG="$BRANCH_NAME"
fi
pwsh -NoProfile -File .github/skills/release-readiness/scripts/Get-ReleaseReadiness.ps1 \
-SrBranch "$SR_ARG" \
"${CANDIDATE_ARG[@]}" \
"${REG_LABEL_ARG[@]}" \
-TrackerKey "$CANONICAL" \
-OutputDir "$OUT_DIR"
elif [ "$BRANCH_TYPE" = "preview" ]; then
pwsh -NoProfile -File .github/skills/release-readiness/scripts/Get-PreviewReadiness.ps1 \
-Branch "$BRANCH_NAME" \
-Mode "$MODE" \
-SurveyRef "$SURVEY_REF" \
-TrackerKey "$CANONICAL" \
-OutputDir "$OUT_DIR" \
-OutputFormat markdown
fi
echo "::endgroup::"
done
- name: Upload validation artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: release-readiness-validate
path: |
trackers.json
validate-out/
retention-days: 7