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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 74 additions & 13 deletions .github/actions/pr-dev-close.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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).
*/
Expand All @@ -32,6 +42,7 @@ import { execFileSync } from "node:child_process";
import { join } from "node:path";

const MARKER = "<!-- dev-close-notice -->";
const CLEAR_MARKER = "<!-- dev-close-cleared -->";

// REPORT_ONLY modes (local validation, no comments posted to GitHub):
// "list" / "1" — fast: only the candidate table, no builds
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
112 changes: 92 additions & 20 deletions .github/workflows/pr-dev-close-notice.yaml
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
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
# tree we just generated.
#
# 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.
Expand All @@ -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.
Expand Down
Loading