diff --git a/.github/actions/pr-dev-close.mjs b/.github/actions/pr-dev-close.mjs index c6ec31e675c8..141d7b5eb5b5 100644 --- a/.github/actions/pr-dev-close.mjs +++ b/.github/actions/pr-dev-close.mjs @@ -2,10 +2,17 @@ /** * Dev-close PR notice — single-file workflow worker. * - * Walks every open non-draft PR targeting `main`, diffs its public Custom - * Elements Manifest against the latest published manifest on jsDelivr, and - * posts (once, idempotently) a "do not merge during dev close" comment if - * any public-API changes are detected. + * Two modes: + * + * notice (MODE=notice, in-window) — walks every open non-draft PR + * targeting `main`, diffs its public Custom Elements Manifest against + * the latest published manifest on jsDelivr, and posts (once, + * idempotently) a "do not merge during dev close" comment if any + * public-API changes are detected. + * + * clear (MODE=clear, out-of-window) — walks every open non-draft PR + * that carries the notice marker and posts (once, idempotently) a + * follow-up "release shipped, clear to merge" comment. * * Triggered from .github/workflows/pr-dev-close-notice.yaml; the workflow * has already run `yarn install && yarn generate` on the runner before @@ -18,11 +25,14 @@ * ship if the PR merged + cut a release today, which is exactly the * "should not merge during dev close" question. Saves ~2 min per PR. * - * Inputs (env, all required): + * Inputs (env): * GH_TOKEN — token for `gh` CLI (read PRs, post comments) * GITHUB_REPO — "owner/repo" - * RELEASE — release identifier shown in the comment (e.g. "next") - * RELEASE_DATE — release date shown in the comment (YYYY-MM-DD UTC) + * MODE — "notice" | "clear" (defaults to "notice") + * RELEASE — release identifier shown in the notice comment + * (required in notice mode; ignored in clear mode) + * RELEASE_DATE — release date shown in the notice comment + * (required in notice mode; ignored in clear mode) * * Always exits 0 (this is advisory CI; failures should not block). */ @@ -32,6 +42,7 @@ import { execFileSync } from "node:child_process"; import { join } from "node:path"; const MARKER = ""; +const CLEAR_MARKER = ""; // REPORT_ONLY modes (local validation, no comments posted to GitHub): // "list" / "1" — fast: only the candidate table, no builds @@ -44,10 +55,13 @@ const REPORT_MODE = REPORT_ONLY === "diff" ? "diff" : (REPORT_ONLY ? "list" : nu const LIMIT = Number(process.env.LIMIT) || 0; const REPO = required("GITHUB_REPO"); -// Release / release-date are only used to render the comment body. Skip the -// strictness in REPORT_ONLY mode so callers don't need to pass them. -const RELEASE = REPORT_MODE ? (process.env.RELEASE ?? "next") : required("RELEASE"); -const RELEASE_DATE = REPORT_MODE ? (process.env.RELEASE_DATE ?? "(unset)") : required("RELEASE_DATE"); +const MODE = process.env.MODE === "clear" ? "clear" : "notice"; +// Release / release-date are only used to render the notice comment body. +// Skip the strictness in REPORT_ONLY and clear modes so callers don't need +// to pass them. +const NEEDS_RELEASE = MODE === "notice" && !REPORT_MODE; +const RELEASE = NEEDS_RELEASE ? required("RELEASE") : (process.env.RELEASE ?? "next"); +const RELEASE_DATE = NEEDS_RELEASE ? required("RELEASE_DATE") : (process.env.RELEASE_DATE ?? "(unset)"); // --------------------------------------------------------------------------- // Helpers @@ -352,13 +366,17 @@ function pickPRs() { return listOpenPRs(); } -function hasMarkerComment(prNumber) { +function hasCommentWithMarker(prNumber, marker) { const json = gh([ "api", `repos/${REPO}/issues/${prNumber}/comments`, "--paginate", "--jq", "[.[] | .body] | join(\"\\n---\\n\")", ]); - return json.includes(MARKER); + return json.includes(marker); +} + +function hasMarkerComment(prNumber) { + return hasCommentWithMarker(prNumber, MARKER); } function postComment(prNumber, body) { @@ -403,6 +421,17 @@ function commentBody(diffMarkdown) { ].join("\n"); } +function clearCommentBody() { + return [ + CLEAR_MARKER, + `### ✅ Dev close is over — this PR is clear to merge`, + ``, + `The release has shipped and we are outside the dev-close window. The earlier notice on this thread no longer applies — you can go ahead and merge whenever you (and reviewers) are ready.`, + ``, + `_Posted automatically by the [Dev Close Notice](../blob/main/.github/workflows/pr-dev-close-notice.yaml) workflow._`, + ].join("\n"); +} + /** Build CEMs by checking out the PR head, regenerating sources, and running * the per-package CEM analyzer. `yarn generate` builds the in-source assets * (templates, CSS, i18n) the analyzer reads alongside the .ts files; it does @@ -508,11 +537,43 @@ async function processPR(pr, basesByPackage) { } } +/** Clear-mode sweep. For every open non-draft PR that carries the notice + * marker but not yet the clear marker, post the "release shipped, clear to + * merge" follow-up. No builds, no CEM work — just comment plumbing. */ +function sweepClearNotices() { + const prs = listOpenPRs(); + console.log(`\nClear-notice sweep — scanning ${prs.length} open non-draft PR(s).`); + + let posted = 0; + for (const pr of prs) { + try { + if (!hasCommentWithMarker(pr.number, MARKER)) continue; + if (hasCommentWithMarker(pr.number, CLEAR_MARKER)) { + console.log(` · #${pr.number}: already cleared, skipping`); + continue; + } + postComment(pr.number, clearCommentBody()); + console.log(` · #${pr.number}: posted clear notice`); + posted++; + } catch (e) { + console.error(` · #${pr.number}: ${e.message}`); + } + } + console.log(`\nClear notices posted: ${posted}.`); +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- async function main() { + if (MODE === "clear") { + console.log(`Dev close clear-notice sweep`); + sweepClearNotices(); + console.log(`\nDone.`); + return; + } + console.log(`Dev close notice — release "${RELEASE}" (${RELEASE_DATE} UTC)${REPORT_MODE ? ` [REPORT_ONLY=${REPORT_MODE}]` : ""}`); // Report-only LIST mode: skip jsdelivr, skip builds, skip comments. diff --git a/.github/workflows/pr-dev-close-notice.yaml b/.github/workflows/pr-dev-close-notice.yaml index 6d8b4e43b5de..d031cc747240 100644 --- a/.github/workflows/pr-dev-close-notice.yaml +++ b/.github/workflows/pr-dev-close-notice.yaml @@ -1,9 +1,15 @@ name: PR Dev Close Notice -# During the "dev close" period (the days leading up to a release), comment -# on every open PR targeting `main` whose public Custom Elements Manifest -# differs from the latest published version on npm — reminding contributors -# not to merge until the release ships. +# Two modes, driven by whether today falls inside a dev-close window: +# +# * notice (in-window) — comment on every open PR targeting `main` whose +# public Custom Elements Manifest differs from the latest published +# version on npm, reminding contributors not to merge until the release +# ships. +# * clear (out-of-window) — sweep open PRs that already carry the +# dev-close notice and post a follow-up "clear to merge" comment on +# each. Runs on schedule / workflow_dispatch only; pull_request events +# outside a window do nothing. # # Detection lives entirely in `.github/actions/pr-dev-close.mjs`. The base # manifest is fetched from jsdelivr (no second build), the head is the @@ -11,7 +17,8 @@ name: PR Dev Close Notice # # Triggers: # * schedule (daily, 07:17 UTC) — sweeps every open PR. Catches PRs that -# were already open when the window started. +# were already open when the window started, and delivers the clear +# notice the day after a release ships. # * pull_request — per-event during the window. Closes the up-to-24h gap # for PRs opened or pushed *after* the day's sweep. # * workflow_dispatch — manual button for testing. @@ -32,59 +39,124 @@ on: permissions: contents: read + issues: read pull-requests: write jobs: notice: runs-on: ubuntu-latest steps: - - name: Check whether we are in a dev-close window + - name: Decide mode based on the release schedule id: window uses: actions/github-script@v7 with: script: | - // Add new entries here when the release schedule is known. - const DEV_CLOSE_PERIODS = [ - { release: "next", devCloseStart: "2026-06-24", releaseDate: "2026-07-09" }, - ]; + // The release schedule is maintained as a Markdown table on this + // tracking issue — single source of truth, edited in place when + // the plan slips. We parse it here rather than hardcoding, so a + // date change doesn't need a workflow PR. + // + // Expected shape (each row is one release): + // | Version | Dev Start | Dev Close | Release | + // |---------|-----------|-----------|----------| + // | 2.25 | 09.07.26 | 22.07.26 | 29.07.26 | + // + // Any row we can't parse (typo, missing cell, non-DD.MM.YY date) + // is skipped with a warning — the workflow keeps working on the + // remaining rows. + const SCHEDULE_ISSUE = 10568; + + function parseDate(cell) { + // "DD.MM.YY" → "20YY-MM-DD" + const m = cell.trim().match(/^(\d{1,2})\.(\d{1,2})\.(\d{2})$/); + if (!m) return null; + const [, dd, mm, yy] = m; + return `20${yy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}`; + } + + const { data: issue } = await github.rest.issues.get({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: SCHEDULE_ISSUE, + }); + + const periods = []; + for (const line of (issue.body || "").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("|")) continue; + const cells = trimmed.split("|").map(c => c.trim()); + // A well-formed row splits to ["", version, devStart, devClose, release, ""] + if (cells.length < 6) continue; + const release = cells[1]; + const devClose = cells[3]; + const releaseDate = cells[4]; + // Skip header / separator rows — only version-shaped strings continue. + if (!/^\d+\.\d+/.test(release)) continue; + const dc = parseDate(devClose); + const rd = parseDate(releaseDate); + if (!dc || !rd) { + core.warning(`Schedule row for "${release}" skipped — unparseable dates (devClose="${devClose}", release="${releaseDate}").`); + continue; + } + periods.push({ release, devCloseStart: dc, releaseDate: rd }); + } + + if (!periods.length) { + core.warning(`No parseable schedule rows found on issue #${SCHEDULE_ISSUE}; exiting.`); + core.setOutput("mode", "none"); + return; + } + + core.info(`Parsed ${periods.length} schedule entr${periods.length === 1 ? "y" : "ies"} from issue #${SCHEDULE_ISSUE}:`); + for (const p of periods) core.info(` · ${p.release}: dev-close ${p.devCloseStart} → release ${p.releaseDate}`); const today = new Date(); today.setUTCHours(0, 0, 0, 0); - const active = DEV_CLOSE_PERIODS.find(p => { + const active = periods.find(p => { const start = new Date(`${p.devCloseStart}T00:00:00Z`); const end = new Date(`${p.releaseDate}T00:00:00Z`); return today >= start && today < end; }); - if (!active) { + if (active) { + core.info(`In dev-close window for "${active.release}" (${active.devCloseStart} → ${active.releaseDate}).`); + core.setOutput("mode", "notice"); + core.setOutput("release", active.release); + core.setOutput("releaseDate", active.releaseDate); + return; + } + + // Outside a window. On pull_request events we do nothing — the + // follow-up "clear to merge" sweep only runs on schedule / + // workflow_dispatch to avoid churn on every PR event. + if (context.eventName === "pull_request") { core.info(`Today (${today.toISOString().slice(0, 10)}) is not in a dev-close window — exiting.`); - core.setOutput("active", "false"); + core.setOutput("mode", "none"); return; } - core.info(`In dev-close window for "${active.release}" (${active.devCloseStart} → ${active.releaseDate}).`); - core.setOutput("active", "true"); - core.setOutput("release", active.release); - core.setOutput("releaseDate", active.releaseDate); + core.info(`Today (${today.toISOString().slice(0, 10)}) is not in a dev-close window — running clear-notice sweep.`); + core.setOutput("mode", "clear"); - name: Checkout main - if: steps.window.outputs.active == 'true' + if: steps.window.outputs.mode != 'none' uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Node - if: steps.window.outputs.active == 'true' + if: steps.window.outputs.mode != 'none' uses: actions/setup-node@v4.1.0 with: node-version: 22 - name: Run dev-close script - if: steps.window.outputs.active == 'true' + if: steps.window.outputs.mode != 'none' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPO: ${{ github.repository }} + MODE: ${{ steps.window.outputs.mode }} RELEASE: ${{ steps.window.outputs.release }} RELEASE_DATE: ${{ steps.window.outputs.releaseDate }} # Set on pull_request events; absent on schedule / workflow_dispatch.