Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .changeset/config.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/validate-deps.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -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'
Expand DownExpand Up@@ -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: |
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
167 changes: 167 additions & 0 deletions scripts/check-changeset-fixed.mjs
Original file line numberDiff line numberDiff line change
@@ -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();