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
6 changes: 6 additions & 0 deletions .changeset/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,9 @@ with multi-package repos, or single-package repos to help you version and publis

We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

## Flagship SDK releases

This repo uses Changesets for every SDK language. Release automation opens one SDK version PR and expands any SDK changeset so all SDK packages are versioned together.

Use `pnpm changeset` for any published SDK change. You only need to select the SDK package you changed; the release workflow adds the other SDK packages during versioning.
1 change: 1 addition & 0 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [],
"privatePackages": { "version": true, "tag": true },
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
"onlyUpdatePeerDependentsWhenOutOfRange": true
}
Expand Down
5 changes: 5 additions & 0 deletions .changeset/monorepo-restructure.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/flagship": patch
---

Restructure repository as a multi-language SDK monorepo under `packages/<language>/`. No API changes.
204 changes: 194 additions & 10 deletions .github/changeset-version.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,196 @@
/**
* Release automation for the Flagship multi-language SDK monorepo.
*
* Modes:
* - `validate`: rejects malformed changesets and `none`-bumped SDK changesets. Run in PR CI.
* - `release`: validates, expands every changeset to all SDKs, runs `changeset version`,
* syncs native manifests, refreshes the lockfile. Run by changesets/action.
*
* An SDK is any direct subdirectory of `sdks/` containing a `package.json`.
*/

import { execSync } from 'node:child_process';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join, relative } from 'node:path';
import readChangesets from '@changesets/read';
import type { NewChangeset, Release, VersionType } from '@changesets/types';
import { parse as parseToml, patch as patchToml } from '@decimalturn/toml-patch';
import { getPackagesSync, type Package } from '@manypkg/get-packages';

const ROOT = process.cwd();
const BUMP_RANK: Record<VersionType, number> = { none: 0, patch: 1, minor: 2, major: 3 };

function readSdkPackages(): Package[] {
const { packages } = getPackagesSync(ROOT);
return packages.filter((pkg) => pkg.relativeDir.startsWith('sdks/') && !pkg.relativeDir.slice('sdks/'.length).includes('/'));
}

export async function validatePendingChangesets(): Promise<void> {
const sdkNames = new Set(readSdkPackages().map((pkg) => pkg.packageJson.name));
const changesets = await readChangesets(ROOT);
const errors: string[] = [];

for (const { id, releases } of changesets) {
const path = `.changeset/${id}.md`;

if (releases.length === 0) {
errors.push(`${path}: declares no package bumps.`);
continue;
}

const nonSdkNames = releases.filter((release) => !sdkNames.has(release.name)).map((release) => release.name);
if (nonSdkNames.length > 0) {
errors.push(`${path}: references non-SDK package(s): ${nonSdkNames.join(', ')}. Every changeset must target SDK packages only.`);
}

const sdkReleases = releases.filter((release) => sdkNames.has(release.name));
if (sdkReleases.length === 0) {
errors.push(`${path}: does not bump any SDK package.`);
}

const noneBumps = sdkReleases.filter((release) => release.type === 'none').map((release) => release.name);
if (noneBumps.length > 0) {
errors.push(`${path}: SDK package(s) bumped as 'none': ${noneBumps.join(', ')}. Use 'patch', 'minor', or 'major'.`);
}
}

if (errors.length > 0) {
throw new Error(`Changeset validation failed:\n - ${errors.join('\n - ')}`);
}

console.log(changesets.length === 0 ? 'No pending changesets to validate.' : `Validated ${changesets.length} pending changeset(s).`);
}

/** Every release-bound changeset is rewritten to bump every SDK at the highest SDK bump present. */
export async function expandChangesetsToAllSdks(): Promise<void> {
const allSdkNames = readSdkPackages()
.map((pkg) => pkg.packageJson.name)
.sort();
const sdkNames = new Set(allSdkNames);

for (const changeset of await readChangesets(ROOT)) {
const sdkReleases = changeset.releases.filter((release) => sdkNames.has(release.name));
if (sdkReleases.length === 0) continue;

const present = new Set(changeset.releases.map((release) => release.name));
const missing = allSdkNames.filter((name) => !present.has(name));
if (missing.length === 0) continue;

const bump = highestBump(sdkReleases.map((release) => release.type));
const expanded: Release[] = [...changeset.releases, ...missing.map((name) => ({ name, type: bump }))];

writeChangesetFile({ id: changeset.id, summary: changeset.summary, releases: expanded });
console.log(`Expanded .changeset/${changeset.id}.md to all SDKs at bump '${bump}'.`);
}
}

function highestBump(bumps: VersionType[]): VersionType {
return bumps.reduce<VersionType>((highest, bump) => (BUMP_RANK[bump] > BUMP_RANK[highest] ? bump : highest), 'none');
}

function writeChangesetFile({ id, summary, releases }: NewChangeset): void {
const frontmatter = releases.map((release) => `"${release.name}": ${release.type}`).join('\n');
writeFileSync(join(ROOT, '.changeset', `${id}.md`), `---\n${frontmatter}\n---\n\n${summary}\n`);
}

/** Mirrors each SDK's `package.json` version into the native manifest beside it, if present. */
export function syncNativeManifests(): void {
const errors: string[] = [];

for (const pkg of readSdkPackages()) {
const targetVersion = pkg.packageJson.version;
syncPythonPackageVersion(pkg.dir, targetVersion, errors);
syncRustPackageVersion(pkg.dir, targetVersion, errors);
}

if (errors.length > 0) {
throw new Error(`Native manifest sync failed:\n - ${errors.join('\n - ')}`);
}
}

type PyProjectToml = { project?: { version?: string }; tool?: { poetry?: { version?: string } } };
type CargoToml = { package?: { version?: string } };

/**
* Syncs the version for any PEP 621 pyproject (uv, hatch, flit, pdm, setuptools, Poetry 2.0+)
* via `[project].version`, and also `[tool.poetry].version` for legacy Poetry 1.x layouts.
*/
function syncPythonPackageVersion(packageDir: string, targetVersion: string, errors: string[]): void {
patchTomlField(join(packageDir, 'pyproject.toml'), targetVersion, errors, (parsed: PyProjectToml) => {
const hasProjectVersion = parsed.project?.version !== undefined;
const hasPoetryVersion = parsed.tool?.poetry?.version !== undefined;
if (!hasProjectVersion && !hasPoetryVersion) {
return '[project].version (PEP 621) or [tool.poetry].version (legacy Poetry)';
}
if (parsed.project?.version !== undefined) parsed.project.version = targetVersion;
if (parsed.tool?.poetry?.version !== undefined) parsed.tool.poetry.version = targetVersion;
return undefined;
});
}

function syncRustPackageVersion(packageDir: string, targetVersion: string, errors: string[]): void {
patchTomlField(join(packageDir, 'Cargo.toml'), targetVersion, errors, (parsed: CargoToml) => {
if (parsed.package?.version === undefined) return '[package].version';
parsed.package.version = targetVersion;
return undefined;
});
}

/** Mutates parsed TOML and patches the file in place, preserving comments and formatting. */
function patchTomlField<T>(manifestPath: string, targetVersion: string, errors: string[], mutate: (parsed: T) => string | undefined): void {
if (!existsSync(manifestPath)) return;
const relativePath = relative(ROOT, manifestPath);
const original = readFileSync(manifestPath, 'utf8');
const parsed = parseToml(original) as T;

const missingField = mutate(parsed);
if (missingField !== undefined) {
errors.push(`${relativePath}: no ${missingField} field found. Add one so release automation can sync it.`);
return;
}

const updated = patchToml(original, parsed);
if (updated === original) {
console.log(`${relativePath} already at ${targetVersion}.`);
return;
}

writeFileSync(manifestPath, updated);
console.log(`Synced ${relativePath} -> ${targetVersion}.`);
}

async function runRelease(): Promise<void> {
await validatePendingChangesets();

// Snapshot changeset files so they can be restored if expansion or versioning fails,
// leaving the working tree clean for a retry.
const changesets = await readChangesets(ROOT);
const snapshot = new Map(
changesets.map(({ id }) => {
const file = join(ROOT, '.changeset', `${id}.md`);
return [file, readFileSync(file, 'utf8')];
}),
);

try {
await expandChangesetsToAllSdks();
execSync('pnpm changeset version', { stdio: 'inherit' });
} catch (error) {
for (const [file, content] of snapshot) {
writeFileSync(file, content);
}
throw error;
}

syncNativeManifests();
execSync('pnpm install --no-frozen-lockfile', { stdio: 'inherit' });
}

// This script is used by the release workflow to update package versions.
// The standard step is only to run `changeset version` but this does not
// update the lockfile. So we also run `pnpm install` to keep it in sync.
// See https://github.com/changesets/changesets/issues/421.
execSync('pnpm changeset version', {
stdio: 'inherit',
});
execSync('pnpm install --no-frozen-lockfile', {
stdio: 'inherit',
});
const mode = process.argv[2] ?? 'release';
if (mode === 'release') {
await runRelease();
} else if (mode === 'validate') {
await validatePendingChangesets();
} else {
throw new Error(`Unknown mode: ${mode}. Expected 'release' or 'validate'.`);
}
25 changes: 22 additions & 3 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ name: Pull Request
on:
pull_request:
paths-ignore:
- 'docs/**'
- '*.md'
- '.changeset/**'

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true

permissions:
contents: read

jobs:
format:
name: Format
Expand Down Expand Up @@ -45,6 +46,24 @@ jobs:
- run: pnpm dlx sherif
- run: pnpm run lint

changesets:
name: Changesets
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

- uses: pnpm/action-setup@v5

- uses: actions/setup-node@v6
with:
node-version: 24

- run: pnpm install --frozen-lockfile
- run: pnpm run changeset:validate

build:
name: Build
runs-on: ubuntu-latest
Expand Down Expand Up @@ -112,4 +131,4 @@ jobs:

- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: pnpm dlx pkg-pr-new publish './packages/*'
- run: pnpm dlx pkg-pr-new publish './sdks/typescript'
7 changes: 6 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false

permissions:
contents: read

jobs:
check:
name: Pre-release Checks
Expand Down Expand Up @@ -40,7 +43,7 @@ jobs:
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-depth: 0

- uses: pnpm/action-setup@v5

Expand All @@ -56,6 +59,8 @@ jobs:
with:
version: pnpm tsx .github/changeset-version.ts
publish: pnpm changeset publish
title: 'chore(release): version SDK packages'
commit: 'chore(release): version SDK packages'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Expand Down
46 changes: 38 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

## Project Overview

`@cloudflare/flagship` — a TypeScript SDK for Cloudflare's Flagship feature flag platform. Provides OpenFeature-compatible providers for both server and browser environments.
`@cloudflare/flagship` — the TypeScript SDK for Cloudflare's Flagship feature flag platform. Provides OpenFeature-compatible providers for both server and browser environments.

This is a **pnpm monorepo** with a single published package today. More packages may be added under `packages/`.
This is a **pnpm monorepo** with SDKs organized by implementation language under `sdks/<language>/`. The current published SDK is the TypeScript package in `sdks/typescript`.

## Repository Structure

```
packages/
flagship/ # @cloudflare/flagship — OpenFeature provider SDK
sdks/
typescript/ # @cloudflare/flagship — OpenFeature provider SDK
src/
index.ts # Core exports (FlagshipClient, types, errors)
server.ts # Re-exports core + FlagshipServerProvider + hooks
Expand All @@ -21,7 +21,7 @@ packages/
client-provider.ts # Sync cache-based provider (browser)
hooks/ # LoggingHook, TelemetryHook
types.ts # Shared types and error codes
test/ # Vitest unit and integration tests
tests/ # Vitest unit and integration tests

.changeset/ # Changeset config and pending changesets
.github/ # CI workflows (release, pull-request, bonk), issue templates
Expand All @@ -48,7 +48,7 @@ Run from the repo root:
| `pnpm run format` | Format all files with oxfmt |
| `pnpm run typecheck` | TypeScript type checking across packages |

Package-level (run from `packages/flagship/`):
Package-level (run from `sdks/typescript/`):

| Command | What it does |
| ---------------- | ----------------------------- |
Expand Down Expand Up @@ -96,7 +96,7 @@ Config in `.oxfmtrc.json`: tabs, single quotes, semicolons, 140 print width.

## Testing

Tests use **vitest** in Node environment. Test files live in `packages/flagship/test/` mirroring the source structure.
Tests use **vitest** in Node environment. Test files live in `sdks/typescript/tests/` mirroring the source structure.

```bash
pnpm run test # all tests
Expand All @@ -113,9 +113,39 @@ Changes to published packages need a changeset:
pnpm changeset # interactive prompt — pick packages, semver bump, description
```

Pick only the SDK package(s) you actually changed. The release workflow expands every release-bound changeset so all SDK packages are bumped to the same version. The highest bump in the changeset (`patch` < `minor` < `major`) becomes the bump for the rest.

The release pipeline runs `.github/changeset-version.ts`, which:

1. Validates every pending changeset — fails fast on unknown packages, non-SDK-only changesets, or SDK entries with a `none` bump.
2. Expands the changeset to include every SDK package so they share the bump.
3. Runs `pnpm changeset version`, producing one PR titled `chore(release): version SDK packages` with all SDK `package.json` and `CHANGELOG.md` updates.
4. Syncs the new version into native manifests beside each SDK:
- `pyproject.toml` — updates `[project].version` (PEP 621, used by uv/hatch/flit/pdm and Poetry 2.0+) and/or `[tool.poetry].version` (legacy Poetry 1.x), whichever fields are present. Fails if neither exists.
- `Cargo.toml` — updates `[package].version` only. Dependency `version` fields are left untouched.
- `go.mod` — no file sync. Go modules are versioned exclusively via git tags.
5. Re-runs `pnpm install` to refresh the lockfile.

After merge the same workflow runs `pnpm changeset publish` and:

- Publishes public npm SDKs (currently `@cloudflare/flagship`).
- Skips npm publish for `private: true` SDKs but still creates a git tag (`privatePackages.tag: true`). Language-specific publish workflows (PyPI, crates.io, etc.) should subscribe to those tags. For Go, the git tag is the version — no additional file sync is needed.

Every releasable SDK must have a `package.json` so Changesets can discover and version it, even if the actual package is published to PyPI, crates.io, Go modules, or another registry. Non-npm SDK packages should use `private: true` and keep their native manifest beside it:

| Language | Native manifest | Version sync |
| -------- | ---------------- | ------------------------------------------------------------------------- |
| Python | `pyproject.toml` | `[project].version` and/or `[tool.poetry].version`, whichever are present |
| Rust | `Cargo.toml` | `[package].version` only — dependency versions are not touched |
| Go | `go.mod` | No file sync — version is the git tag only |

PR CI runs `pnpm run changeset:validate` (the `Changesets` job) so malformed, non-SDK, or `none`-bumped SDK changesets fail before merge.

Changesets should remain the only release intent file. Do not add release-please, semantic-release, or language-specific release manifests unless the release workflow is explicitly changed to derive them from Changesets.

### Pull Request Process

CI runs on every PR: `pnpm install → build → check → test`. All checks must pass.
CI runs on every PR: `pnpm install → build → check → test → changeset:validate`. All checks must pass.

## Boundaries

Expand Down
Loading
Loading