From d9b51c00d7f39e9185c2be77face034f88ebf77d Mon Sep 17 00:00:00 2001 From: ilhan007 Date: Wed, 3 Jun 2026 17:37:00 +0300 Subject: [PATCH 1/3] ci: add dev-close-notice workflow (trial run) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Posts a comment on open PRs targeting `main` during the dev-close period (week before a release) to remind contributors that the PR should not be merged until the release ships. Trigger: pull_request events + daily schedule (07:17 UTC) + manual. Trial run: only the next dev-close window is configured inline: 2026-06-29 (dev close start) → 2026-07-06 (release date) If this works as expected we'll extend the schedule (and likely move it to a JSON file for easier editing). The workflow is idempotent — it skips PRs that already have the marker comment, so re-runs won't spam. --- .github/workflows/dev-close-notice.yaml | 156 ++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 .github/workflows/dev-close-notice.yaml diff --git a/.github/workflows/dev-close-notice.yaml b/.github/workflows/dev-close-notice.yaml new file mode 100644 index 000000000000..d496b03be7ba --- /dev/null +++ b/.github/workflows/dev-close-notice.yaml @@ -0,0 +1,156 @@ +name: Dev Close Notice + +# Posts a comment on open feature PRs (`feat(...)` / `feat:` / `feat!:`) targeting +# `main` during the "dev close" period (the week leading up to a release) +# reminding contributors that the PR should not be merged until the release +# ships. Bugfixes, chores, docs, etc. are NOT flagged — only features. +# +# Trial run: only the next dev-close window is configured below. Once we +# confirm the workflow behaves as expected, we'll extend the list (and +# probably move it to a JSON file). +# +# Dates are interpreted in UTC. devCloseStart is inclusive; releaseDate is +# exclusive (i.e. on releaseDate itself the workflow stops commenting). + +on: + pull_request: + types: [opened, reopened, ready_for_review, synchronize, edited] + branches: + - main + + # Run daily so PRs opened before dev-close still get notified once we enter + # the window, and so the comment lands even if a PR has had no activity. + schedule: + - cron: "17 7 * * *" # 07:17 UTC daily — odd minute on purpose + + # Manual trigger for testing. + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Comment on PRs during dev close + uses: actions/github-script@v7 + with: + script: | + // ----- Configuration ------------------------------------------------- + // Add new entries here when the release schedule is known. + // devCloseStart: first day PRs should NOT be merged (inclusive, UTC) + // releaseDate: day the release ships (exclusive — period ends here) + const DEV_CLOSE_PERIODS = [ + { release: "next", devCloseStart: "2026-06-29", releaseDate: "2026-07-06" }, + ]; + + // Marker so we never post the same comment twice on a PR. + const MARKER = ""; + + // Match Conventional Commit "feat" type (with optional scope and "!"), + // OR ANY type marked with "!" (breaking change), e.g. fix!:, refactor!:. + // Both patterns indicate a change that should NOT land during dev close: + // feat: add foo ✅ feature + // feat(ui5-button): bar ✅ feature + // feat!: drop legacy api ✅ feature + breaking + // feat(ui5-table)!: bar ✅ feature + breaking + // fix(ui5-button)!: rename event ✅ breaking (public API change) + // refactor!: drop public method ✅ breaking + // fix(ui5-button): typo ❌ + // chore: bump deps ❌ + const FEAT_TITLE_RE = /^feat(\([^)]+\))?!?:/i; + const BREAKING_TITLE_RE = /^[a-z]+(\([^)]+\))?!:/i; + const isBlockedTitle = (title) => + FEAT_TITLE_RE.test(title || "") || BREAKING_TITLE_RE.test(title || ""); + + // -------------------------------------------------------------------- + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + const activePeriod = DEV_CLOSE_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 (!activePeriod) { + core.info(`Today (${today.toISOString().slice(0, 10)}) is not in a dev-close window — nothing to do.`); + return; + } + + core.info(`In dev-close window for release "${activePeriod.release}" (${activePeriod.devCloseStart} → ${activePeriod.releaseDate}).`); + + const body = [ + MARKER, + `### ⏸️ Dev close in effect`, + ``, + `This repository is currently in **dev close** ahead of release \`${activePeriod.release}\` (scheduled **${activePeriod.releaseDate}**, UTC).`, + ``, + `This PR is detected as a **feature or a breaking change** (Conventional Commit title \`feat:\` / \`feat(...)\` / any type with \`!\`). Please **do not merge it into \`main\`** until the release ships — features and public API changes should land in the next dev cycle.`, + ``, + `Bugfixes and chores without API impact are unaffected. If you believe this PR was flagged in error (or should bypass dev close), please coordinate with the release captain.`, + ``, + `_This notice is posted automatically by the [Dev Close Notice](../blob/main/.github/workflows/dev-close-notice.yaml) workflow. Public-API changes that don't use a Conventional Commit \`!\` marker may not be detected — please double-check this PR's impact._`, + ].join("\n"); + + // Pick the PR list based on event: + // - pull_request: just the one PR that triggered the run + // - schedule / workflow_dispatch: every open PR targeting `main` + let prs; + if (context.eventName === "pull_request") { + prs = [{ + number: context.payload.pull_request.number, + title: context.payload.pull_request.title, + base: { ref: context.payload.pull_request.base.ref }, + draft: context.payload.pull_request.draft, + }]; + } else { + prs = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + base: "main", + per_page: 100, + }); + } + + core.info(`Considering ${prs.length} PR(s).`); + + for (const pr of prs) { + if (pr.base.ref !== "main") continue; + + // Skip drafts — they aren't merge candidates yet. + if (pr.draft) { + core.info(`PR #${pr.number}: draft, skipping.`); + continue; + } + + // Only flag features and breaking changes. + if (!isBlockedTitle(pr.title)) { + core.info(`PR #${pr.number}: not a feat / breaking PR ("${pr.title}"), skipping.`); + continue; + } + + // Has the marker already been posted? + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + per_page: 100, + }); + + if (comments.some(c => c.body && c.body.includes(MARKER))) { + core.info(`PR #${pr.number}: marker already present, skipping.`); + continue; + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body, + }); + core.info(`PR #${pr.number}: posted dev-close notice.`); + } From a72f11a9628986c2473195221734f8fbe5d54681 Mon Sep 17 00:00:00 2001 From: ilhan007 Date: Wed, 3 Jun 2026 17:51:50 +0300 Subject: [PATCH 2/3] ci(dev-close-notice): add CEM-based public-API change detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version flagged PRs purely from their Conventional Commit title (`feat:` / `feat(...)` / any type with `!`). That misses the common case in this repo: an additive public-API change committed under a `fix:` or `refactor:` title. Now the workflow has two layers: 1. **CEM diff (preferred).** On pull_request events that touched `packages/*/src/**`, build `custom-elements-internal.json` on PR base AND PR head and diff for public-API additions, removals and type changes. Diff logic lives in: - `.github/scripts/dev-close/diff-cem.mjs` Walks both manifests, filters by `_ui5privacy === 'public'` (or `privacy === 'public'`), emits a structured JSON diff. - `.github/scripts/dev-close/format-diff.mjs` Turns the JSON diff into a Markdown bullet list for the comment. We diff the *internal* manifest (not the post-processed public one) so we control the privacy filter ourselves rather than depending on the build's `processPublicAPI` stripper. 2. **Title regex (fallback).** When CEM diff couldn't run — daily sweep, no src changes, or the build failed — the workflow falls back to the title regex from the previous version. Soft-fails throughout: a failed install/generate on either side just means the diff step reports `status=failed` and the notify job falls back to the title regex. The workflow itself never fails. Behavior matrix: pull_request + src changed + cem ok → CEM diff (lists changes) pull_request + src changed + cem 'no-changes' → no comment pull_request + src changed + cem failed → title regex pull_request + no src changes → title regex schedule / workflow_dispatch → title regex --- .github/scripts/dev-close/diff-cem.mjs | 160 ++++++++++++++++ .github/scripts/dev-close/format-diff.mjs | 51 +++++ .github/workflows/dev-close-notice.yaml | 219 +++++++++++++++++++--- 3 files changed, 402 insertions(+), 28 deletions(-) create mode 100644 .github/scripts/dev-close/diff-cem.mjs create mode 100644 .github/scripts/dev-close/format-diff.mjs diff --git a/.github/scripts/dev-close/diff-cem.mjs b/.github/scripts/dev-close/diff-cem.mjs new file mode 100644 index 000000000000..7c2e8b8f74b2 --- /dev/null +++ b/.github/scripts/dev-close/diff-cem.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * Diff two `custom-elements-internal.json` manifests for **public** API changes. + * + * We diff the *internal* manifest (which contains every entry, with privacy + * flags) rather than the public `custom-elements.json` because the public file + * is post-processed by `processPublicAPI` in the build — anything stripped + * there silently disappears, and we'd have no chance to flag it. Working from + * the internal manifest and filtering by `_ui5privacy: "public"` (or + * `privacy: "public"`) ourselves keeps the workflow honest about what counts + * as a public-API change. + * + * Usage: + * node diff-cem.mjs + * + * Each is expected to contain `.json` files (the + * custom-elements-internal manifests, one per ui5 package). Filenames before + * the extension are used as the package label in the output. + * + * Output: JSON on stdout describing additions, removals, and changes per + * package. Returns exit code 0 always (this script's job is to produce data, + * not to gate CI). + */ + +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { join, basename, extname } from "node:path"; + +const [, , baseDir, headDir] = process.argv; +if (!baseDir || !headDir) { + console.error("Usage: diff-cem.mjs "); + process.exit(2); +} + +/** Read every *.json under `dir` keyed by filename-without-extension. */ +function readManifests(dir) { + if (!existsSync(dir)) return {}; + const out = {}; + for (const file of readdirSync(dir)) { + if (extname(file) !== ".json") continue; + const key = basename(file, ".json"); + try { + out[key] = JSON.parse(readFileSync(join(dir, file), "utf8")); + } catch (e) { + // Treat unreadable manifest as missing on this side — caller decides + // what to do. We only log on stderr so the workflow can still fall + // back to the title regex if every manifest fails to parse. + console.error(`Failed to read ${join(dir, file)}: ${e.message}`); + } + } + return out; +} + +/** "public" iff explicit privacy says so. Default-undefined is treated as + * public for top-level declarations marked as customElement (UI5 convention), + * and as non-public for members. */ +function isPublic(node, { topLevel = false } = {}) { + const p = node?._ui5privacy ?? node?.privacy; + if (p === "public") return true; + if (p) return false; // explicit non-public + // No privacy declared. Custom-element declarations are public by default; + // members without privacy are private by default. + return topLevel && node?.customElement === true; +} + +/** Collapse a manifest into a flat lookup of public entries: + * "::" → declaration node + * ":::::" + * → member node + */ +function flattenPublic(manifest) { + const flat = new Map(); + const modules = manifest?.modules ?? []; + for (const mod of modules) { + const path = mod.path ?? "(unknown)"; + for (const decl of mod.declarations ?? []) { + if (!isPublic(decl, { topLevel: true })) continue; + const declKey = `${path}::${decl.name}`; + flat.set(declKey, { kind: "declaration", node: decl }); + + const groups = [ + ["members", decl.members], + ["events", decl.events], + ["slots", decl.slots], + ["cssProperties", decl.cssProperties], + ["cssParts", decl.cssParts], + ["attributes", decl.attributes], + ]; + for (const [groupName, arr] of groups) { + if (!Array.isArray(arr)) continue; + for (const m of arr) { + // CSS properties / parts / attributes don't carry privacy fields + // in the manifest schema — they're always public when present + // on a public declaration. Members/events/slots use _ui5privacy. + const alwaysPublic = groupName === "cssProperties" || groupName === "cssParts" || groupName === "attributes"; + if (!alwaysPublic && !isPublic(m)) continue; + const memberKey = `${declKey}::${groupName}:${m.name}`; + flat.set(memberKey, { kind: groupName, node: m }); + } + } + } + } + return flat; +} + +/** Compare two member nodes shallowly: returns a list of changed field names + * (only fields that affect API surface — type text, default value, deprecated). */ +function memberFieldDiff(a, b) { + const changed = []; + const aType = a?.type?.text ?? a?._ui5type?.text; + const bType = b?.type?.text ?? b?._ui5type?.text; + if (aType !== bType) changed.push("type"); + if ((a?.default ?? null) !== (b?.default ?? null)) changed.push("default"); + if (Boolean(a?.deprecated) !== Boolean(b?.deprecated)) changed.push("deprecated"); + if ((a?.readonly ?? false) !== (b?.readonly ?? false)) changed.push("readonly"); + return changed; +} + +function diffPackage(baseManifest, headManifest) { + const baseFlat = flattenPublic(baseManifest); + const headFlat = flattenPublic(headManifest); + + const added = []; + const removed = []; + const changed = []; + + for (const [key, value] of headFlat) { + if (!baseFlat.has(key)) { + added.push({ key, kind: value.kind, name: value.node.name }); + } else { + const fields = memberFieldDiff(baseFlat.get(key).node, value.node); + if (fields.length) changed.push({ key, kind: value.kind, name: value.node.name, fields }); + } + } + for (const [key, value] of baseFlat) { + if (!headFlat.has(key)) { + removed.push({ key, kind: value.kind, name: value.node.name }); + } + } + return { added, removed, changed }; +} + +const baseManifests = readManifests(baseDir); +const headManifests = readManifests(headDir); + +const allPackages = new Set([...Object.keys(baseManifests), ...Object.keys(headManifests)]); +const result = { byPackage: {}, totals: { added: 0, removed: 0, changed: 0 } }; + +for (const pkg of allPackages) { + const d = diffPackage(baseManifests[pkg], headManifests[pkg]); + if (d.added.length || d.removed.length || d.changed.length) { + result.byPackage[pkg] = d; + result.totals.added += d.added.length; + result.totals.removed += d.removed.length; + result.totals.changed += d.changed.length; + } +} + +result.hasChanges = result.totals.added + result.totals.removed + result.totals.changed > 0; + +console.log(JSON.stringify(result, null, 2)); diff --git a/.github/scripts/dev-close/format-diff.mjs b/.github/scripts/dev-close/format-diff.mjs new file mode 100644 index 000000000000..c4c039f1b4f2 --- /dev/null +++ b/.github/scripts/dev-close/format-diff.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * Format a CEM diff result (from diff-cem.mjs) into a Markdown bullet list + * suitable for embedding in a GitHub PR comment. + * + * Reads JSON from stdin, prints Markdown to stdout. Empty diff → empty output. + */ + +import { readFileSync } from "node:fs"; + +const input = readFileSync(0, "utf8"); +let diff; +try { + diff = JSON.parse(input); +} catch (e) { + console.error(`Invalid JSON on stdin: ${e.message}`); + process.exit(2); +} + +if (!diff?.hasChanges) { + process.exit(0); +} + +const lines = []; +const KIND_LABEL = { + declaration: "element", + members: "member", + events: "event", + slots: "slot", + cssProperties: "CSS property", + cssParts: "CSS part", + attributes: "attribute", +}; + +const packages = Object.keys(diff.byPackage).sort(); +for (const pkg of packages) { + const { added, removed, changed } = diff.byPackage[pkg]; + lines.push(`**\`${pkg}\`**`); + for (const entry of added) { + lines.push(`- ➕ added ${KIND_LABEL[entry.kind] ?? entry.kind}: \`${entry.name}\``); + } + for (const entry of removed) { + lines.push(`- ➖ removed ${KIND_LABEL[entry.kind] ?? entry.kind}: \`${entry.name}\``); + } + for (const entry of changed) { + lines.push(`- 🔄 changed ${KIND_LABEL[entry.kind] ?? entry.kind} \`${entry.name}\` (${entry.fields.join(", ")})`); + } + lines.push(""); +} + +console.log(lines.join("\n").trimEnd()); diff --git a/.github/workflows/dev-close-notice.yaml b/.github/workflows/dev-close-notice.yaml index d496b03be7ba..d06ab0513155 100644 --- a/.github/workflows/dev-close-notice.yaml +++ b/.github/workflows/dev-close-notice.yaml @@ -1,9 +1,18 @@ name: Dev Close Notice -# Posts a comment on open feature PRs (`feat(...)` / `feat:` / `feat!:`) targeting -# `main` during the "dev close" period (the week leading up to a release) -# reminding contributors that the PR should not be merged until the release -# ships. Bugfixes, chores, docs, etc. are NOT flagged — only features. +# Posts a comment on open PRs targeting `main` during the "dev close" period +# (the week leading up to a release) reminding contributors that the PR +# should not be merged until the release ships. +# +# Detection has two layers: +# 1. CEM diff (preferred, source of truth): build the +# custom-elements-internal.json manifest on PR base AND PR head, diff for +# public-API additions/removals/changes. Catches additive API changes +# that don't use a Conventional Commit `!` marker (the 99% case in this +# repo). Runs only on pull_request events that touched packages/*/src/**. +# 2. Title regex (fallback): when the CEM diff couldn't run (path filter +# didn't match, build failed, or it's the daily schedule sweep), match +# Conventional Commit `feat:` / `feat(...)` / any `!` to flag the PR. # # Trial run: only the next dev-close window is configured below. Once we # confirm the workflow behaves as expected, we'll extend the list (and @@ -20,6 +29,8 @@ on: # Run daily so PRs opened before dev-close still get notified once we enter # the window, and so the comment lands even if a PR has had no activity. + # (Daily sweep uses title regex only — building CEM for every open PR every + # day would be too expensive.) schedule: - cron: "17 7 * * *" # 07:17 UTC daily — odd minute on purpose @@ -31,11 +42,129 @@ permissions: pull-requests: write jobs: + # Job 1: build CEM on base+head, produce diff JSON. Only on pull_request, + # only when src changed. Soft-fails — if it can't produce a diff, the notify + # job falls back to the title regex. + cem-diff: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + outputs: + diff_status: ${{ steps.diff.outputs.status }} # ok | failed | no-changes + diff_md: ${{ steps.diff.outputs.md }} + steps: + - name: Checkout PR head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + path: head + + - name: Detect changed src paths + id: changed + working-directory: head + run: | + base_sha="${{ github.event.pull_request.base.sha }}" + if git diff --name-only "$base_sha" HEAD -- 'packages/*/src/**' | grep -q .; then + echo "src_changed=true" >> "$GITHUB_OUTPUT" + else + echo "src_changed=false" >> "$GITHUB_OUTPUT" + echo "No src changes — skipping CEM diff." + fi + + - name: Setup Node + if: steps.changed.outputs.src_changed == 'true' + uses: actions/setup-node@v4.1.0 + with: + node-version: 22 + cache: 'yarn' + cache-dependency-path: head/yarn.lock + + - name: Build CEM on PR head + id: head_build + if: steps.changed.outputs.src_changed == 'true' + working-directory: head + # Soft-fail: any failure here just means we'll have no head manifests + # and the diff step will exit with status=failed → notify falls back. + continue-on-error: true + run: | + set -e + yarn install --immutable + yarn generate + mkdir -p ../cem-head + for f in packages/*/dist/custom-elements-internal.json; do + [ -f "$f" ] || continue + pkg=$(basename "$(dirname "$(dirname "$f")")") + cp "$f" "../cem-head/${pkg}.json" + done + echo "Captured $(ls ../cem-head | wc -l) head manifests." + + - name: Checkout PR base + if: steps.changed.outputs.src_changed == 'true' && steps.head_build.outcome == 'success' + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.sha }} + fetch-depth: 1 + path: base + + - name: Build CEM on PR base + id: base_build + if: steps.changed.outputs.src_changed == 'true' && steps.head_build.outcome == 'success' + working-directory: base + continue-on-error: true + run: | + set -e + yarn install --immutable + yarn generate + mkdir -p ../cem-base + for f in packages/*/dist/custom-elements-internal.json; do + [ -f "$f" ] || continue + pkg=$(basename "$(dirname "$(dirname "$f")")") + cp "$f" "../cem-base/${pkg}.json" + done + echo "Captured $(ls ../cem-base | wc -l) base manifests." + + - name: Diff manifests + id: diff + # Always run so we can emit a status, even when earlier steps were + # skipped or failed. + if: always() + run: | + if [ "${{ steps.changed.outputs.src_changed }}" != "true" ]; then + echo "status=no-changes" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "${{ steps.head_build.outcome }}" != "success" ] || [ "${{ steps.base_build.outcome }}" != "success" ]; then + echo "CEM build failed on at least one side — falling back to title regex." + echo "status=failed" >> "$GITHUB_OUTPUT" + exit 0 + fi + + diff_json=$(node head/.github/scripts/dev-close/diff-cem.mjs cem-base cem-head) + has_changes=$(echo "$diff_json" | node -e "let s=''; process.stdin.on('data',d=>s+=d).on('end',()=>{const j=JSON.parse(s);process.stdout.write(j.hasChanges?'true':'false')})") + if [ "$has_changes" = "true" ]; then + md=$(echo "$diff_json" | node head/.github/scripts/dev-close/format-diff.mjs) + { + echo 'md<> "$GITHUB_OUTPUT" + echo "status=ok" >> "$GITHUB_OUTPUT" + else + echo "status=no-changes" >> "$GITHUB_OUTPUT" + fi + + # Job 2: decide whether to comment, then comment. Always runs. notify: runs-on: ubuntu-latest + needs: [cem-diff] + # Run even if cem-diff was skipped (schedule/workflow_dispatch) or failed. + if: always() steps: - name: Comment on PRs during dev close uses: actions/github-script@v7 + env: + CEM_DIFF_STATUS: ${{ needs.cem-diff.outputs.diff_status }} + CEM_DIFF_MD: ${{ needs.cem-diff.outputs.diff_md }} with: script: | // ----- Configuration ------------------------------------------------- @@ -49,17 +178,12 @@ jobs: // Marker so we never post the same comment twice on a PR. const MARKER = ""; - // Match Conventional Commit "feat" type (with optional scope and "!"), - // OR ANY type marked with "!" (breaking change), e.g. fix!:, refactor!:. - // Both patterns indicate a change that should NOT land during dev close: + // Title-regex fallback (used when CEM diff couldn't run). // feat: add foo ✅ feature // feat(ui5-button): bar ✅ feature // feat!: drop legacy api ✅ feature + breaking - // feat(ui5-table)!: bar ✅ feature + breaking - // fix(ui5-button)!: rename event ✅ breaking (public API change) - // refactor!: drop public method ✅ breaking + // fix(ui5-button)!: rename event ✅ breaking // fix(ui5-button): typo ❌ - // chore: bump deps ❌ const FEAT_TITLE_RE = /^feat(\([^)]+\))?!?:/i; const BREAKING_TITLE_RE = /^[a-z]+(\([^)]+\))?!:/i; const isBlockedTitle = (title) => @@ -82,18 +206,42 @@ jobs: core.info(`In dev-close window for release "${activePeriod.release}" (${activePeriod.devCloseStart} → ${activePeriod.releaseDate}).`); - const body = [ - MARKER, - `### ⏸️ Dev close in effect`, - ``, - `This repository is currently in **dev close** ahead of release \`${activePeriod.release}\` (scheduled **${activePeriod.releaseDate}**, UTC).`, - ``, - `This PR is detected as a **feature or a breaking change** (Conventional Commit title \`feat:\` / \`feat(...)\` / any type with \`!\`). Please **do not merge it into \`main\`** until the release ships — features and public API changes should land in the next dev cycle.`, - ``, - `Bugfixes and chores without API impact are unaffected. If you believe this PR was flagged in error (or should bypass dev close), please coordinate with the release captain.`, - ``, - `_This notice is posted automatically by the [Dev Close Notice](../blob/main/.github/workflows/dev-close-notice.yaml) workflow. Public-API changes that don't use a Conventional Commit \`!\` marker may not be detected — please double-check this PR's impact._`, - ].join("\n"); + const cemStatus = process.env.CEM_DIFF_STATUS || "skipped"; + const cemMd = process.env.CEM_DIFF_MD || ""; + core.info(`CEM diff status: ${cemStatus}`); + + // Build the comment body. The shape depends on detection source: + // - cem-ok: list the public-API changes inline + // - title-fallback: explain the title pattern matched + const buildBody = ({ source, cemMd, pr }) => { + const lines = [ + MARKER, + `### ⏸️ Dev close in effect`, + ``, + `This repository is currently in **dev close** ahead of release \`${activePeriod.release}\` (scheduled **${activePeriod.releaseDate}**, UTC).`, + ``, + ]; + if (source === "cem") { + lines.push( + `This PR introduces **public-API changes** detected by diffing the Custom Elements Manifest:`, + ``, + cemMd, + ``, + `Please **do not merge it into \`main\`** until the release ships — public API changes should land in the next dev cycle.`, + ); + } else { + lines.push( + `This PR is detected as a **feature or a breaking change** (title pattern: \`feat:\` / \`feat(...)\` / any type with \`!\`). Please **do not merge it into \`main\`** until the release ships.`, + ); + } + lines.push( + ``, + `Bugfixes and chores without API impact are unaffected. If you believe this PR was flagged in error, please coordinate with the release captain.`, + ``, + `_Posted automatically by the [Dev Close Notice](../blob/main/.github/workflows/dev-close-notice.yaml) workflow._`, + ); + return lines.join("\n"); + }; // Pick the PR list based on event: // - pull_request: just the one PR that triggered the run @@ -127,10 +275,25 @@ jobs: continue; } - // Only flag features and breaking changes. - if (!isBlockedTitle(pr.title)) { - core.info(`PR #${pr.number}: not a feat / breaking PR ("${pr.title}"), skipping.`); + // Decide source of detection for this PR: + // - For pull_request events: prefer CEM diff if it ran successfully. + // If CEM says "no-changes", trust it (don't fall back to title). + // If CEM was skipped (no src changes) or failed, fall back to title. + // - For schedule/dispatch: title regex only. + let source = null; + if (context.eventName === "pull_request" && cemStatus === "ok") { + source = "cem"; + } else if (cemStatus === "no-changes") { + core.info(`PR #${pr.number}: CEM diff is empty — no public-API changes, skipping.`); continue; + } else { + // cemStatus is "failed", "skipped", or this is a daily sweep. + if (isBlockedTitle(pr.title)) { + source = "title"; + } else { + core.info(`PR #${pr.number}: title doesn't match feat/breaking ("${pr.title}"), skipping.`); + continue; + } } // Has the marker already been posted? @@ -150,7 +313,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: pr.number, - body, + body: buildBody({ source, cemMd, pr }), }); - core.info(`PR #${pr.number}: posted dev-close notice.`); + core.info(`PR #${pr.number}: posted dev-close notice (source=${source}).`); } From 6aafc0c53ef23a0044bb31db0a2769b93c590a01 Mon Sep 17 00:00:00 2001 From: ilhan007 Date: Wed, 3 Jun 2026 19:44:34 +0300 Subject: [PATCH 3/3] ci(dev-close-notice): drop title regex, CEM diff is the only signal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: a single detection path (CEM diff) is clearer than two layers. The title regex fallback is removed — if CEM can't run (no src changes or build fails), the workflow exits without commenting. Also drops the daily schedule trigger: that path was title-only because building CEM for every open PR every day is too expensive, and removing the title regex makes the schedule pointless. Diff script (diff-cem.mjs) gains coverage borrowed from nnaydenow/version-compare's per-category logic, kept inside our flat-Map technique: - properties (kind=field) and methods (kind=method) tracked separately - events: bubbles, cancelable, parameter add/remove/type changes - enums: enum-member adds/removals - interfaces: add/remove/deprecation transitions - cssStates (in addition to cssParts/cssProperties) - deprecation transitions on any tracked entity (newly deprecated, un-deprecated, or message changed) Triggers: pull_request only (opened/reopened/ready_for_review/sync). --- .github/scripts/dev-close/diff-cem.mjs | 141 ++++++++-- .github/scripts/dev-close/format-diff.mjs | 10 +- .github/workflows/dev-close-notice.yaml | 308 +++++++--------------- 3 files changed, 229 insertions(+), 230 deletions(-) diff --git a/.github/scripts/dev-close/diff-cem.mjs b/.github/scripts/dev-close/diff-cem.mjs index 7c2e8b8f74b2..dd70571a9677 100644 --- a/.github/scripts/dev-close/diff-cem.mjs +++ b/.github/scripts/dev-close/diff-cem.mjs @@ -10,6 +10,19 @@ * `privacy: "public"`) ourselves keeps the workflow honest about what counts * as a public-API change. * + * Coverage (informed by nnaydenow/version-compare's process-manifest.js, which + * solves a similar problem for release-prep — we kept the categories that + * matter, dropped the HTML rendering and signature-noise on additions): + * - custom-element declarations (added / removed) + * - properties (kind=field) and methods (kind=method) — separated + * - events, including event parameters, _ui5Bubbles, _ui5Cancelable + * - slots + * - cssProperties, cssParts, cssStates, attributes + * - enums, including individual enum-member additions/removals + * - interfaces (added / removed / deprecation transitions) + * - deprecation transitions on any tracked entity (newly deprecated, + * un-deprecated, deprecation-message-changed) + * * Usage: * node diff-cem.mjs * @@ -62,39 +75,86 @@ function isPublic(node, { topLevel = false } = {}) { return topLevel && node?.customElement === true; } -/** Collapse a manifest into a flat lookup of public entries: - * "::" → declaration node - * ":::::" - * → member node - */ +/** True iff this top-level declaration is in scope for our diff. We track + * custom elements (the bulk of the public API), enums and interfaces — those + * three are the public-typed surface of UI5 packages. */ +function isTrackedDeclaration(decl) { + if (!decl) return false; + if (decl.customElement) return isPublic(decl, { topLevel: true }); + if (decl.kind === "enum" || decl.kind === "interface") { + // Enums/interfaces don't have customElement=true; they're tracked iff + // they declare _ui5privacy === "public" or have no privacy at all + // (default-public for top-level type-system declarations). + const p = decl._ui5privacy ?? decl.privacy; + return !p || p === "public"; + } + return false; +} + +/** Distinguish properties (kind=field) vs methods (kind=method) inside the + * generic `members` array. Anything else (e.g. accessors that survive into + * the manifest) falls back to "members". */ +function memberSubKind(m) { + if (m?.kind === "field") return "properties"; + if (m?.kind === "method") return "methods"; + return "members"; +} + +/** Collapse a manifest into a flat lookup of public entries. Each value is + * `{ kind, node, parent? }` where kind is the category used in diffs. */ function flattenPublic(manifest) { const flat = new Map(); const modules = manifest?.modules ?? []; for (const mod of modules) { const path = mod.path ?? "(unknown)"; for (const decl of mod.declarations ?? []) { - if (!isPublic(decl, { topLevel: true })) continue; + if (!isTrackedDeclaration(decl)) continue; + const declKind = decl.customElement + ? "element" + : decl.kind === "enum" + ? "enum" + : "interface"; const declKey = `${path}::${decl.name}`; - flat.set(declKey, { kind: "declaration", node: decl }); + flat.set(declKey, { kind: declKind, node: decl }); + // Enum members: track each enum value as an entry of its own. Members + // of enums have no privacy field — they inherit the enum's. + if (declKind === "enum") { + for (const m of decl.members ?? []) { + const memberName = m?.name ?? m?.value ?? m?.id; + if (!memberName) continue; + flat.set(`${declKey}::enumMembers:${memberName}`, { + kind: "enumMembers", + node: m, + }); + } + continue; // enums don't have the property/event/slot groups below + } + + // Custom elements: walk every member group. const groups = [ - ["members", decl.members], + // `members` is a heterogeneous array — split by kind. + ["__members__", decl.members], ["events", decl.events], ["slots", decl.slots], ["cssProperties", decl.cssProperties], ["cssParts", decl.cssParts], + ["cssStates", decl.cssStates], ["attributes", decl.attributes], ]; for (const [groupName, arr] of groups) { if (!Array.isArray(arr)) continue; for (const m of arr) { - // CSS properties / parts / attributes don't carry privacy fields - // in the manifest schema — they're always public when present - // on a public declaration. Members/events/slots use _ui5privacy. - const alwaysPublic = groupName === "cssProperties" || groupName === "cssParts" || groupName === "attributes"; + // CSS properties / parts / states / attributes don't carry privacy + // fields — they're always public when present on a public element. + const alwaysPublic = groupName === "cssProperties" + || groupName === "cssParts" + || groupName === "cssStates" + || groupName === "attributes"; if (!alwaysPublic && !isPublic(m)) continue; - const memberKey = `${declKey}::${groupName}:${m.name}`; - flat.set(memberKey, { kind: groupName, node: m }); + const subKind = groupName === "__members__" ? memberSubKind(m) : groupName; + const memberKey = `${declKey}::${subKind}:${m.name}`; + flat.set(memberKey, { kind: subKind, node: m }); } } } @@ -102,16 +162,56 @@ function flattenPublic(manifest) { return flat; } -/** Compare two member nodes shallowly: returns a list of changed field names - * (only fields that affect API surface — type text, default value, deprecated). */ -function memberFieldDiff(a, b) { +/** Compare two nodes for surface-relevant fields. Returns a list of changed + * field names. We treat "deprecated" specially — any transition matters, + * including a message edit, so callers can render it explicitly. */ +function nodeFieldDiff(kind, a, b) { const changed = []; + + // Type text — present on properties, methods (return type), slots, events, + // cssProperties (sometimes), attributes. const aType = a?.type?.text ?? a?._ui5type?.text; const bType = b?.type?.text ?? b?._ui5type?.text; if (aType !== bType) changed.push("type"); + + // Defaults are relevant for properties and attributes; harmless on others. if ((a?.default ?? null) !== (b?.default ?? null)) changed.push("default"); - if (Boolean(a?.deprecated) !== Boolean(b?.deprecated)) changed.push("deprecated"); + + // Readonly transitions matter for properties. if ((a?.readonly ?? false) !== (b?.readonly ?? false)) changed.push("readonly"); + + // Deprecation transitions are a public-API signal in their own right. + const aDep = a?.deprecated; + const bDep = b?.deprecated; + if (Boolean(aDep) !== Boolean(bDep) || (aDep && bDep && aDep !== bDep)) { + changed.push("deprecated"); + } + + // Event-only fields. + if (kind === "events") { + if ((a?._ui5Bubbles ?? null) !== (b?._ui5Bubbles ?? null)) changed.push("bubbles"); + if ((a?._ui5Cancelable ?? null) !== (b?._ui5Cancelable ?? null)) changed.push("cancelable"); + + // Parameter set diff: any add/remove/type-change counts as a change. + const aParams = a?._ui5parameters ?? []; + const bParams = b?._ui5parameters ?? []; + const allNames = new Set([ + ...aParams.map(p => p.name).filter(Boolean), + ...bParams.map(p => p.name).filter(Boolean), + ]); + for (const name of allNames) { + const ap = aParams.find(p => p.name === name); + const bp = bParams.find(p => p.name === name); + if (!ap || !bp) { + changed.push(`param:${name}`); + continue; + } + if ((ap.type?.text ?? null) !== (bp.type?.text ?? null)) { + changed.push(`param:${name}`); + } + } + } + return changed; } @@ -124,10 +224,11 @@ function diffPackage(baseManifest, headManifest) { const changed = []; for (const [key, value] of headFlat) { - if (!baseFlat.has(key)) { + const baseEntry = baseFlat.get(key); + if (!baseEntry) { added.push({ key, kind: value.kind, name: value.node.name }); } else { - const fields = memberFieldDiff(baseFlat.get(key).node, value.node); + const fields = nodeFieldDiff(value.kind, baseEntry.node, value.node); if (fields.length) changed.push({ key, kind: value.kind, name: value.node.name, fields }); } } diff --git a/.github/scripts/dev-close/format-diff.mjs b/.github/scripts/dev-close/format-diff.mjs index c4c039f1b4f2..b66b2ce0e0df 100644 --- a/.github/scripts/dev-close/format-diff.mjs +++ b/.github/scripts/dev-close/format-diff.mjs @@ -23,13 +23,19 @@ if (!diff?.hasChanges) { const lines = []; const KIND_LABEL = { - declaration: "element", - members: "member", + element: "element", + properties: "property", + methods: "method", events: "event", slots: "slot", cssProperties: "CSS property", cssParts: "CSS part", + cssStates: "CSS state", attributes: "attribute", + enum: "enum", + enumMembers: "enum member", + interface: "interface", + members: "member", // fallback for unsplit members }; const packages = Object.keys(diff.byPackage).sort(); diff --git a/.github/workflows/dev-close-notice.yaml b/.github/workflows/dev-close-notice.yaml index d06ab0513155..b274687e09ea 100644 --- a/.github/workflows/dev-close-notice.yaml +++ b/.github/workflows/dev-close-notice.yaml @@ -1,39 +1,25 @@ name: Dev Close Notice -# Posts a comment on open PRs targeting `main` during the "dev close" period -# (the week leading up to a release) reminding contributors that the PR -# should not be merged until the release ships. +# Posts a comment on PRs targeting `main` during the "dev close" period +# (the week leading up to a release) reminding contributors not to merge +# until the release ships. # -# Detection has two layers: -# 1. CEM diff (preferred, source of truth): build the -# custom-elements-internal.json manifest on PR base AND PR head, diff for -# public-API additions/removals/changes. Catches additive API changes -# that don't use a Conventional Commit `!` marker (the 99% case in this -# repo). Runs only on pull_request events that touched packages/*/src/**. -# 2. Title regex (fallback): when the CEM diff couldn't run (path filter -# didn't match, build failed, or it's the daily schedule sweep), match -# Conventional Commit `feat:` / `feat(...)` / any `!` to flag the PR. +# Detection: build the custom-elements-internal.json manifest on PR base +# AND PR head, diff for public-API additions/removals/changes (filtered +# to `_ui5privacy: "public"` / `privacy: "public"`). Logic lives in +# `.github/scripts/dev-close/diff-cem.mjs` and `format-diff.mjs`. # -# Trial run: only the next dev-close window is configured below. Once we -# confirm the workflow behaves as expected, we'll extend the list (and -# probably move it to a JSON file). +# Trial run: only the next dev-close window is configured below. # # Dates are interpreted in UTC. devCloseStart is inclusive; releaseDate is # exclusive (i.e. on releaseDate itself the workflow stops commenting). on: pull_request: - types: [opened, reopened, ready_for_review, synchronize, edited] + types: [opened, reopened, ready_for_review, synchronize] branches: - main - # Run daily so PRs opened before dev-close still get notified once we enter - # the window, and so the comment lands even if a PR has had no activity. - # (Daily sweep uses title regex only — building CEM for every open PR every - # day would be too expensive.) - schedule: - - cron: "17 7 * * *" # 07:17 UTC daily — odd minute on purpose - # Manual trigger for testing. workflow_dispatch: @@ -42,17 +28,45 @@ permissions: pull-requests: write jobs: - # Job 1: build CEM on base+head, produce diff JSON. Only on pull_request, - # only when src changed. Soft-fails — if it can't produce a diff, the notify - # job falls back to the title regex. - cem-diff: - if: github.event_name == 'pull_request' + dev-close-notice: + # Only run when src files actually changed — no point building CEM otherwise. runs-on: ubuntu-latest - outputs: - diff_status: ${{ steps.diff.outputs.status }} # ok | failed | no-changes - diff_md: ${{ steps.diff.outputs.md }} steps: + - name: Check whether we are in a dev-close window + id: window + uses: actions/github-script@v7 + with: + script: | + // ----- Configuration ------------------------------------------------- + // Add new entries here when the release schedule is known. + // devCloseStart: first day PRs should NOT be merged (inclusive, UTC) + // releaseDate: day the release ships (exclusive — period ends here) + const DEV_CLOSE_PERIODS = [ + { release: "next", devCloseStart: "2026-06-29", releaseDate: "2026-07-06" }, + ]; + + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + const active = DEV_CLOSE_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) { + core.info(`Today (${today.toISOString().slice(0, 10)}) is not in a dev-close window — exiting.`); + core.setOutput("active", "false"); + return; + } + + core.info(`In dev-close window for release "${active.release}" (${active.devCloseStart} → ${active.releaseDate}).`); + core.setOutput("active", "true"); + core.setOutput("release", active.release); + core.setOutput("releaseDate", active.releaseDate); + - name: Checkout PR head + if: steps.window.outputs.active == 'true' uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} @@ -61,6 +75,7 @@ jobs: - name: Detect changed src paths id: changed + if: steps.window.outputs.active == 'true' working-directory: head run: | base_sha="${{ github.event.pull_request.base.sha }}" @@ -68,11 +83,11 @@ jobs: echo "src_changed=true" >> "$GITHUB_OUTPUT" else echo "src_changed=false" >> "$GITHUB_OUTPUT" - echo "No src changes — skipping CEM diff." + echo "No src changes — nothing to diff." fi - name: Setup Node - if: steps.changed.outputs.src_changed == 'true' + if: steps.window.outputs.active == 'true' && steps.changed.outputs.src_changed == 'true' uses: actions/setup-node@v4.1.0 with: node-version: 22 @@ -81,11 +96,11 @@ jobs: - name: Build CEM on PR head id: head_build - if: steps.changed.outputs.src_changed == 'true' - working-directory: head - # Soft-fail: any failure here just means we'll have no head manifests - # and the diff step will exit with status=failed → notify falls back. + if: steps.window.outputs.active == 'true' && steps.changed.outputs.src_changed == 'true' + # Soft-fail: a failure here just means we have no head manifests, the + # diff step will see that and exit without commenting. continue-on-error: true + working-directory: head run: | set -e yarn install --immutable @@ -99,7 +114,7 @@ jobs: echo "Captured $(ls ../cem-head | wc -l) head manifests." - name: Checkout PR base - if: steps.changed.outputs.src_changed == 'true' && steps.head_build.outcome == 'success' + if: steps.window.outputs.active == 'true' && steps.changed.outputs.src_changed == 'true' && steps.head_build.outcome == 'success' uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.base.sha }} @@ -108,9 +123,9 @@ jobs: - name: Build CEM on PR base id: base_build - if: steps.changed.outputs.src_changed == 'true' && steps.head_build.outcome == 'success' - working-directory: base + if: steps.window.outputs.active == 'true' && steps.changed.outputs.src_changed == 'true' && steps.head_build.outcome == 'success' continue-on-error: true + working-directory: base run: | set -e yarn install --immutable @@ -125,20 +140,8 @@ jobs: - name: Diff manifests id: diff - # Always run so we can emit a status, even when earlier steps were - # skipped or failed. - if: always() + if: steps.window.outputs.active == 'true' && steps.changed.outputs.src_changed == 'true' && steps.head_build.outcome == 'success' && steps.base_build.outcome == 'success' run: | - if [ "${{ steps.changed.outputs.src_changed }}" != "true" ]; then - echo "status=no-changes" >> "$GITHUB_OUTPUT" - exit 0 - fi - if [ "${{ steps.head_build.outcome }}" != "success" ] || [ "${{ steps.base_build.outcome }}" != "success" ]; then - echo "CEM build failed on at least one side — falling back to title regex." - echo "status=failed" >> "$GITHUB_OUTPUT" - exit 0 - fi - diff_json=$(node head/.github/scripts/dev-close/diff-cem.mjs cem-base cem-head) has_changes=$(echo "$diff_json" | node -e "let s=''; process.stdin.on('data',d=>s+=d).on('end',()=>{const j=JSON.parse(s);process.stdout.write(j.hasChanges?'true':'false')})") if [ "$has_changes" = "true" ]; then @@ -148,172 +151,61 @@ jobs: echo "$md" echo 'DEV_CLOSE_EOF' } >> "$GITHUB_OUTPUT" - echo "status=ok" >> "$GITHUB_OUTPUT" + echo "has_changes=true" >> "$GITHUB_OUTPUT" else - echo "status=no-changes" >> "$GITHUB_OUTPUT" + echo "has_changes=false" >> "$GITHUB_OUTPUT" + echo "No public-API changes detected." fi - # Job 2: decide whether to comment, then comment. Always runs. - notify: - runs-on: ubuntu-latest - needs: [cem-diff] - # Run even if cem-diff was skipped (schedule/workflow_dispatch) or failed. - if: always() - steps: - - name: Comment on PRs during dev close + - name: Comment on PR + if: steps.window.outputs.active == 'true' && steps.diff.outputs.has_changes == 'true' uses: actions/github-script@v7 env: - CEM_DIFF_STATUS: ${{ needs.cem-diff.outputs.diff_status }} - CEM_DIFF_MD: ${{ needs.cem-diff.outputs.diff_md }} + DIFF_MD: ${{ steps.diff.outputs.md }} + RELEASE: ${{ steps.window.outputs.release }} + RELEASE_DATE: ${{ steps.window.outputs.releaseDate }} with: script: | - // ----- Configuration ------------------------------------------------- - // Add new entries here when the release schedule is known. - // devCloseStart: first day PRs should NOT be merged (inclusive, UTC) - // releaseDate: day the release ships (exclusive — period ends here) - const DEV_CLOSE_PERIODS = [ - { release: "next", devCloseStart: "2026-06-29", releaseDate: "2026-07-06" }, - ]; - - // Marker so we never post the same comment twice on a PR. const MARKER = ""; + const prNumber = context.payload.pull_request.number; - // Title-regex fallback (used when CEM diff couldn't run). - // feat: add foo ✅ feature - // feat(ui5-button): bar ✅ feature - // feat!: drop legacy api ✅ feature + breaking - // fix(ui5-button)!: rename event ✅ breaking - // fix(ui5-button): typo ❌ - const FEAT_TITLE_RE = /^feat(\([^)]+\))?!?:/i; - const BREAKING_TITLE_RE = /^[a-z]+(\([^)]+\))?!:/i; - const isBlockedTitle = (title) => - FEAT_TITLE_RE.test(title || "") || BREAKING_TITLE_RE.test(title || ""); - - // -------------------------------------------------------------------- - const today = new Date(); - today.setUTCHours(0, 0, 0, 0); - - const activePeriod = DEV_CLOSE_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 (!activePeriod) { - core.info(`Today (${today.toISOString().slice(0, 10)}) is not in a dev-close window — nothing to do.`); + // Skip drafts — they aren't merge candidates yet. + if (context.payload.pull_request.draft) { + core.info(`PR #${prNumber}: draft, skipping.`); return; } - core.info(`In dev-close window for release "${activePeriod.release}" (${activePeriod.devCloseStart} → ${activePeriod.releaseDate}).`); - - const cemStatus = process.env.CEM_DIFF_STATUS || "skipped"; - const cemMd = process.env.CEM_DIFF_MD || ""; - core.info(`CEM diff status: ${cemStatus}`); - - // Build the comment body. The shape depends on detection source: - // - cem-ok: list the public-API changes inline - // - title-fallback: explain the title pattern matched - const buildBody = ({ source, cemMd, pr }) => { - const lines = [ - MARKER, - `### ⏸️ Dev close in effect`, - ``, - `This repository is currently in **dev close** ahead of release \`${activePeriod.release}\` (scheduled **${activePeriod.releaseDate}**, UTC).`, - ``, - ]; - if (source === "cem") { - lines.push( - `This PR introduces **public-API changes** detected by diffing the Custom Elements Manifest:`, - ``, - cemMd, - ``, - `Please **do not merge it into \`main\`** until the release ships — public API changes should land in the next dev cycle.`, - ); - } else { - lines.push( - `This PR is detected as a **feature or a breaking change** (title pattern: \`feat:\` / \`feat(...)\` / any type with \`!\`). Please **do not merge it into \`main\`** until the release ships.`, - ); - } - lines.push( - ``, - `Bugfixes and chores without API impact are unaffected. If you believe this PR was flagged in error, please coordinate with the release captain.`, - ``, - `_Posted automatically by the [Dev Close Notice](../blob/main/.github/workflows/dev-close-notice.yaml) workflow._`, - ); - return lines.join("\n"); - }; - - // Pick the PR list based on event: - // - pull_request: just the one PR that triggered the run - // - schedule / workflow_dispatch: every open PR targeting `main` - let prs; - if (context.eventName === "pull_request") { - prs = [{ - number: context.payload.pull_request.number, - title: context.payload.pull_request.title, - base: { ref: context.payload.pull_request.base.ref }, - draft: context.payload.pull_request.draft, - }]; - } else { - prs = await github.paginate(github.rest.pulls.list, { - owner: context.repo.owner, - repo: context.repo.repo, - state: "open", - base: "main", - per_page: 100, - }); + // Idempotent: don't post again if the marker is already there. + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + per_page: 100, + }); + if (comments.some(c => c.body && c.body.includes(MARKER))) { + core.info(`PR #${prNumber}: marker already present, skipping.`); + return; } - core.info(`Considering ${prs.length} PR(s).`); - - for (const pr of prs) { - if (pr.base.ref !== "main") continue; - - // Skip drafts — they aren't merge candidates yet. - if (pr.draft) { - core.info(`PR #${pr.number}: draft, skipping.`); - continue; - } - - // Decide source of detection for this PR: - // - For pull_request events: prefer CEM diff if it ran successfully. - // If CEM says "no-changes", trust it (don't fall back to title). - // If CEM was skipped (no src changes) or failed, fall back to title. - // - For schedule/dispatch: title regex only. - let source = null; - if (context.eventName === "pull_request" && cemStatus === "ok") { - source = "cem"; - } else if (cemStatus === "no-changes") { - core.info(`PR #${pr.number}: CEM diff is empty — no public-API changes, skipping.`); - continue; - } else { - // cemStatus is "failed", "skipped", or this is a daily sweep. - if (isBlockedTitle(pr.title)) { - source = "title"; - } else { - core.info(`PR #${pr.number}: title doesn't match feat/breaking ("${pr.title}"), skipping.`); - continue; - } - } - - // Has the marker already been posted? - const comments = await github.paginate(github.rest.issues.listComments, { - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - per_page: 100, - }); - - if (comments.some(c => c.body && c.body.includes(MARKER))) { - core.info(`PR #${pr.number}: marker already present, skipping.`); - continue; - } - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: pr.number, - body: buildBody({ source, cemMd, pr }), - }); - core.info(`PR #${pr.number}: posted dev-close notice (source=${source}).`); - } + const body = [ + MARKER, + `### ⏸️ Dev close in effect`, + ``, + `This repository is currently in **dev close** ahead of release \`${process.env.RELEASE}\` (scheduled **${process.env.RELEASE_DATE}**, UTC).`, + ``, + `This PR introduces **public-API changes** detected by diffing the Custom Elements Manifest:`, + ``, + process.env.DIFF_MD, + ``, + `Please **do not merge it into \`main\`** until the release ships — public API changes should land in the next dev cycle.`, + ``, + `_Posted automatically by the [Dev Close Notice](../blob/main/.github/workflows/dev-close-notice.yaml) workflow._`, + ].join("\n"); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + core.info(`PR #${prNumber}: posted dev-close notice.`);