Update Aspire branding and links in MAUI template (#37850) #2829
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release Readiness | ||
| # 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 | ||
| pwsh -NoProfile -File .github/skills/release-readiness/tests/Test-ReleaseHandoff.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 | ||