diff --git a/.github/scripts/dev-close/diff-cem.mjs b/.github/scripts/dev-close/diff-cem.mjs new file mode 100644 index 000000000000..dd70571a9677 --- /dev/null +++ b/.github/scripts/dev-close/diff-cem.mjs @@ -0,0 +1,261 @@ +#!/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. + * + * 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 + * + * 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; +} + +/** 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 (!isTrackedDeclaration(decl)) continue; + const declKind = decl.customElement + ? "element" + : decl.kind === "enum" + ? "enum" + : "interface"; + const declKey = `${path}::${decl.name}`; + 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` 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 / 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 subKind = groupName === "__members__" ? memberSubKind(m) : groupName; + const memberKey = `${declKey}::${subKind}:${m.name}`; + flat.set(memberKey, { kind: subKind, node: m }); + } + } + } + } + return flat; +} + +/** 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"); + + // 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; +} + +function diffPackage(baseManifest, headManifest) { + const baseFlat = flattenPublic(baseManifest); + const headFlat = flattenPublic(headManifest); + + const added = []; + const removed = []; + const changed = []; + + for (const [key, value] of headFlat) { + const baseEntry = baseFlat.get(key); + if (!baseEntry) { + added.push({ key, kind: value.kind, name: value.node.name }); + } else { + const fields = nodeFieldDiff(value.kind, baseEntry.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..b66b2ce0e0df --- /dev/null +++ b/.github/scripts/dev-close/format-diff.mjs @@ -0,0 +1,57 @@ +#!/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 = { + 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(); +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 new file mode 100644 index 000000000000..b274687e09ea --- /dev/null +++ b/.github/workflows/dev-close-notice.yaml @@ -0,0 +1,211 @@ +name: Dev Close Notice + +# 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: 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. +# +# 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] + branches: + - main + + # Manual trigger for testing. + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +jobs: + dev-close-notice: + # Only run when src files actually changed — no point building CEM otherwise. + runs-on: ubuntu-latest + 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 }} + fetch-depth: 0 + path: head + + - 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 }}" + 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 — nothing to diff." + fi + + - name: Setup Node + if: steps.window.outputs.active == 'true' && 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.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 + 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.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 }} + fetch-depth: 1 + path: base + + - name: Build CEM on PR base + id: base_build + 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 + 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 + if: steps.window.outputs.active == 'true' && steps.changed.outputs.src_changed == 'true' && steps.head_build.outcome == 'success' && steps.base_build.outcome == 'success' + run: | + 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 "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + echo "No public-API changes detected." + fi + + - name: Comment on PR + if: steps.window.outputs.active == 'true' && steps.diff.outputs.has_changes == 'true' + uses: actions/github-script@v7 + env: + DIFF_MD: ${{ steps.diff.outputs.md }} + RELEASE: ${{ steps.window.outputs.release }} + RELEASE_DATE: ${{ steps.window.outputs.releaseDate }} + with: + script: | + const MARKER = ""; + const prNumber = context.payload.pull_request.number; + + // Skip drafts — they aren't merge candidates yet. + if (context.payload.pull_request.draft) { + core.info(`PR #${prNumber}: draft, skipping.`); + return; + } + + // 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; + } + + 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.`);