From 4d74f26e2d2704d245b751d52ac045e505af91a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 25 May 2026 09:21:55 +0000 Subject: [PATCH] fix(release): cover all public packages in changeset fixed group + add CI guard --- .changeset/config.json | 2 + .github/workflows/release.yml | 3 + .github/workflows/validate-deps.yml | 6 + CHANGELOG.md | 19 ++++ package.json | 1 + scripts/check-changeset-fixed.mjs | 167 ++++++++++++++++++++++++++++ 6 files changed, 198 insertions(+) create mode 100644 scripts/check-changeset-fixed.mjs diff --git a/.changeset/config.json b/.changeset/config.json index 933c00c81a..eafd80d447 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -47,6 +47,8 @@ "@objectstack/service-analytics", "@objectstack/service-automation", "@objectstack/service-cache", + "@objectstack/service-cluster", + "@objectstack/service-cluster-redis", "@objectstack/service-feed", "@objectstack/service-i18n", "@objectstack/service-job", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa6cbbae60..8a860a8786 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Verify Changesets "fixed" group covers every public package + run: node scripts/check-changeset-fixed.mjs + - name: Build run: pnpm run build diff --git a/.github/workflows/validate-deps.yml b/.github/workflows/validate-deps.yml index 24f7c0c6e2..7657a897ad 100644 --- a/.github/workflows/validate-deps.yml +++ b/.github/workflows/validate-deps.yml @@ -5,6 +5,9 @@ on: paths: - '**/package.json' - 'pnpm-lock.yaml' + - '.changeset/config.json' + - 'pnpm-workspace.yaml' + - 'scripts/check-changeset-fixed.mjs' schedule: # Run weekly on Monday at 03:00 UTC - cron: '0 3 * * 1' @@ -49,6 +52,9 @@ jobs: - name: Verify lockfile is up to date run: | pnpm install --frozen-lockfile --prefer-offline + + - name: Verify Changesets "fixed" group covers every public package + run: node scripts/check-changeset-fixed.mjs - name: Check for dependency issues run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 79b32a548b..d6e6e2ada7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed — Changesets `fixed` group now covers every public package + +Two newly added services — `@objectstack/service-cluster` and +`@objectstack/service-cluster-redis` — were missing from the `fixed` +group in `.changeset/config.json`, so they were not bumped in lockstep +with the rest of `@objectstack/*` during the last release. Both names +have been added back to the group. + +To prevent this class of drift from recurring, a new validator +(`scripts/check-changeset-fixed.mjs`) compares every public workspace +package against the `fixed` group and fails CI if any public package is +missing (or if a stale name lingers in the group). It runs: + +- On every PR that touches `**/package.json`, `pnpm-workspace.yaml`, + `.changeset/config.json`, or the script itself (`validate-deps.yml`). +- Before `changeset publish` in the `Release` workflow, so a release + cannot ship while the `fixed` group is out of sync. +- Locally via `pnpm run lint:changeset`. + ### Changed — `@object-ui/*` upgraded to v6.0 Bundled UI assets (`@object-ui/console`, `@object-ui/studio`, diff --git a/package.json b/package.json index e2e77ef9da..5091b46ccd 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "setup": "pnpm install && pnpm --filter @objectstack/spec build", "version": "changeset version", "release": "pnpm run build && changeset publish", + "lint:changeset": "node scripts/check-changeset-fixed.mjs", "docs:dev": "pnpm --filter @objectstack/docs dev", "docs:build": "pnpm --filter @objectstack/docs build", "docs:start": "pnpm --filter @objectstack/docs start", diff --git a/scripts/check-changeset-fixed.mjs b/scripts/check-changeset-fixed.mjs new file mode 100644 index 0000000000..0d1de01f3d --- /dev/null +++ b/scripts/check-changeset-fixed.mjs @@ -0,0 +1,167 @@ +#!/usr/bin/env node +/** + * Validates that every publishable workspace package is enumerated in the + * Changesets `fixed` group, so a new public package can never silently be + * released out of lockstep with the rest of the monorepo. + * + * Run: node scripts/check-changeset-fixed.mjs + * + * Exits with code 1 (and a clear diff) if: + * - A public (non-private) workspace package is missing from the + * `fixed` group in .changeset/config.json + * - A name listed in the `fixed` group no longer exists in the workspace + * + * The script intentionally has zero third-party dependencies so it can run + * in minimal CI environments before `pnpm install`. It reads + * pnpm-workspace.yaml directly and walks the workspace globs itself. + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, '..'); + +/** + * Minimal pnpm-workspace.yaml parser: extracts entries under the top-level + * `packages:` key. Supports the `- pattern` list form used by this repo and + * tolerates comments / blank lines. Avoids pulling in a YAML dependency. + * + * @returns {string[]} + */ +function readWorkspacePatterns() { + const text = readFileSync(resolve(repoRoot, 'pnpm-workspace.yaml'), 'utf8'); + const lines = text.split(/\r?\n/); + const patterns = []; + let inPackages = false; + for (const raw of lines) { + const line = raw.replace(/#.*$/, '').replace(/\s+$/, ''); + if (!line.trim()) continue; + if (/^packages\s*:\s*$/.test(line)) { + inPackages = true; + continue; + } + if (inPackages) { + const m = /^\s+-\s+["']?([^"'\s]+)["']?\s*$/.exec(line); + if (m) { + patterns.push(m[1]); + continue; + } + // Any other non-indented key ends the packages block. + if (/^\S/.test(line)) inPackages = false; + } + } + return patterns; +} + +/** + * Expand a single `pattern` like `packages/*` or `packages/services/*` into + * concrete directory paths. Only supports the `*` wildcard at any single + * path segment, which is what the repo uses. + * + * @param {string} pattern + * @returns {string[]} + */ +function expandPattern(pattern) { + const segments = pattern.split('/'); + /** @type {string[]} */ + let dirs = [repoRoot]; + for (const seg of segments) { + /** @type {string[]} */ + const next = []; + for (const dir of dirs) { + if (seg === '*') { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (entry.isDirectory() && !entry.name.startsWith('.')) { + next.push(join(dir, entry.name)); + } + } + } else { + const candidate = join(dir, seg); + try { + if (statSync(candidate).isDirectory()) next.push(candidate); + } catch { + /* missing - skip */ + } + } + } + dirs = next; + } + return dirs; +} + +/** @returns {string[]} names of all non-private workspace packages */ +function listPublicPackageNames() { + const patterns = readWorkspacePatterns(); + const seen = new Set(); + const names = []; + for (const pattern of patterns) { + for (const dir of expandPattern(pattern)) { + const pkgPath = join(dir, 'package.json'); + let pkg; + try { + pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + } catch { + continue; + } + if (!pkg.name || pkg.private === true) continue; + if (seen.has(pkg.name)) continue; + seen.add(pkg.name); + names.push(pkg.name); + } + } + return names.sort(); +} + +function readFixedGroups() { + const configPath = resolve(repoRoot, '.changeset/config.json'); + const config = JSON.parse(readFileSync(configPath, 'utf8')); + if (!Array.isArray(config.fixed)) return []; + return config.fixed; +} + +function main() { + const publicPackages = listPublicPackageNames(); + const fixedGroups = readFixedGroups(); + const fixed = new Set(fixedGroups.flat()); + + const missing = publicPackages.filter((name) => !fixed.has(name)); + const stale = [...fixed] + .filter((name) => !publicPackages.includes(name)) + .sort(); + + if (missing.length === 0 && stale.length === 0) { + console.log( + `✓ .changeset/config.json "fixed" group is in sync with ${publicPackages.length} public workspace packages.`, + ); + return; + } + + console.error('✗ .changeset/config.json "fixed" group is out of sync.'); + if (missing.length > 0) { + console.error( + '\nPublic packages missing from "fixed" (add them to keep versions in lockstep):', + ); + for (const name of missing) console.error(` - ${name}`); + } + if (stale.length > 0) { + console.error( + '\nNames in "fixed" that no longer exist in the workspace (remove them):', + ); + for (const name of stale) console.error(` - ${name}`); + } + console.error( + '\nEdit .changeset/config.json so the "fixed" group matches the public workspace, then re-run this script.', + ); + process.exit(1); +} + +main(); +