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: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": [],
"privatePackages": { "version": true, "tag": true },
"privatePackages": { "version": true, "tag": false },
"___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": {
"onlyUpdatePeerDependentsWhenOutOfRange": true
}
Expand Down
5 changes: 5 additions & 0 deletions .changeset/vast-kids-wait.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@cloudflare/flagship": patch
---

Fix release pipeline: Python checks on push to main, canonical single-package release notes, no private SDK tags, and reliable PyPI publish trigger.
101 changes: 83 additions & 18 deletions .github/changeset-version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,23 @@
*
* 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.
* - `release`: validates, rewrites SDK changesets to the canonical npm package,
* runs `changeset version`, syncs private SDK versions and native
* manifests, refreshes native and pnpm lockfiles. 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 { existsSync, readFileSync, rmSync, 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 CANONICAL_PACKAGE_NAME = '@cloudflare/flagship';
const BUMP_RANK: Record<VersionType, number> = { none: 0, patch: 1, minor: 2, major: 3 };

function readSdkPackages(): Package[] {
Expand Down Expand Up @@ -61,26 +63,36 @@ export async function validatePendingChangesets(): Promise<void> {
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);
function readCanonicalPackage(): Package {
const canonical = readSdkPackages().find((pkg) => pkg.packageJson.name === CANONICAL_PACKAGE_NAME);
if (canonical === undefined) {
throw new Error(`Canonical SDK package not found: ${CANONICAL_PACKAGE_NAME}`);
}
return canonical;
}

/**
* The repo uses a single public release package for Changesets output. Private SDKs
* still get version-synced, but they are removed from release entries so the release
* PR body and changelog don't duplicate the same notes for every language.
*/
export async function rewriteChangesetsToCanonicalPackage(): Promise<void> {
const sdkNames = new Set(readSdkPackages().map((pkg) => pkg.packageJson.name));

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}'.`);
const rewritten: Release[] = [{ name: CANONICAL_PACKAGE_NAME, type: bump }];
const alreadyCanonical =
changeset.releases.length === rewritten.length &&
changeset.releases[0]?.name === CANONICAL_PACKAGE_NAME &&
changeset.releases[0]?.type === bump;
if (alreadyCanonical) continue;

writeChangesetFile({ id: changeset.id, summary: changeset.summary, releases: rewritten });
console.log(`Rewrote .changeset/${changeset.id}.md to ${CANONICAL_PACKAGE_NAME} at bump '${bump}'.`);
}
}

Expand All @@ -93,6 +105,21 @@ function writeChangesetFile({ id, summary, releases }: NewChangeset): void {
writeFileSync(join(ROOT, '.changeset', `${id}.md`), `---\n${frontmatter}\n---\n\n${summary}\n`);
}

/** Syncs private SDK package.json versions to the canonical public package version. */
export function syncPrivateSdkPackageVersions(): void {
const targetVersion = readCanonicalPackage().packageJson.version;

for (const pkg of readSdkPackages()) {
if (pkg.packageJson.private !== true) continue;
if (pkg.packageJson.version === targetVersion) continue;

const packageJsonPath = join(pkg.dir, 'package.json');
const packageJson = `${JSON.stringify({ ...pkg.packageJson, version: targetVersion }, null, '\t')}\n`;
writeFileSync(packageJsonPath, packageJson);
console.log(`Synced ${relative(ROOT, packageJsonPath)} -> ${targetVersion}.`);
}
}

/** Mirrors each SDK's `package.json` version into the native manifest beside it, if present. */
export function syncNativeManifests(): void {
const errors: string[] = [];
Expand All @@ -108,6 +135,26 @@ export function syncNativeManifests(): void {
}
}

/**
* Regenerate native lockfiles after manifest version bumps so the release PR
* contains an up-to-date lockfile. Skipped silently when the relevant CLI is
* not on PATH (e.g. local dev environments without `uv` installed).
*/
export function refreshNativeLockfiles(): void {
for (const pkg of readSdkPackages()) {
const pyprojectPath = join(pkg.dir, 'pyproject.toml');
const uvLockPath = join(pkg.dir, 'uv.lock');
if (existsSync(pyprojectPath) && existsSync(uvLockPath)) {
try {
execSync('uv lock', { cwd: pkg.dir, stdio: 'inherit' });
console.log(`Refreshed ${relative(ROOT, uvLockPath)}.`);
} catch (error) {
throw new Error(`Failed to refresh ${relative(ROOT, uvLockPath)}: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
}

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

Expand Down Expand Up @@ -159,6 +206,21 @@ function patchTomlField<T>(manifestPath: string, targetVersion: string, errors:
console.log(`Synced ${relativePath} -> ${targetVersion}.`);
}

/**
* Changesets generates a CHANGELOG.md for every versioned package, including private ones.
* Private SDK changelogs are duplicates of the TypeScript SDK changelog — delete them so
* they don't appear in the release PR diff or clutter the repository.
*/
function deletePrivateSdkChangelogs(): void {
for (const pkg of readSdkPackages()) {
if (pkg.packageJson.private !== true) continue;
const changelog = join(pkg.dir, 'CHANGELOG.md');
if (!existsSync(changelog)) continue;
rmSync(changelog);
console.log(`Deleted ${relative(ROOT, changelog)} (private SDK — changelog not needed).`);
}
}

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

Expand All @@ -173,7 +235,7 @@ async function runRelease(): Promise<void> {
);

try {
await expandChangesetsToAllSdks();
await rewriteChangesetsToCanonicalPackage();
execSync('pnpm changeset version', { stdio: 'inherit' });
} catch (error) {
for (const [file, content] of snapshot) {
Expand All @@ -182,7 +244,10 @@ async function runRelease(): Promise<void> {
throw error;
}

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

Expand Down
25 changes: 1 addition & 24 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
@@ -1,37 +1,14 @@
name: Publish PyPI

on:
push:
tags:
- '@cloudflare/flagship-python@*'
workflow_call:

permissions:
contents: read

jobs:
check:
name: Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0

- run: uv run ruff format --check .
working-directory: sdks/python

- run: uv run ruff check .
working-directory: sdks/python

- run: uv run --group dev mypy
working-directory: sdks/python

- run: uv run --group dev pytest
working-directory: sdks/python

publish:
name: Publish
needs: check
runs-on: ubuntu-latest
environment:
name: pypi
Expand Down
31 changes: 19 additions & 12 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ jobs:
name: TypeScript Typecheck
runs-on: ubuntu-latest
timeout-minutes: 20
needs: typescript-build
steps:
- uses: actions/checkout@v6

Expand All @@ -99,14 +98,12 @@ jobs:
node-version: 24

- run: pnpm install --frozen-lockfile
- run: pnpm run build
- run: pnpm run typecheck

typescript-test:
name: TypeScript Test
runs-on: ubuntu-latest
timeout-minutes: 20
needs: typescript-build
steps:
- uses: actions/checkout@v6

Expand Down Expand Up @@ -146,10 +143,13 @@ jobs:
steps:
- uses: actions/checkout@v6

- uses: astral-sh/setup-uv@v8.1.0
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
working-directory: sdks/python

- run: uv sync --group dev --locked
working-directory: sdks/python

- run: uv run ruff format --check .
working-directory: sdks/python

Expand All @@ -160,10 +160,13 @@ jobs:
steps:
- uses: actions/checkout@v6

- uses: astral-sh/setup-uv@v8.1.0
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
working-directory: sdks/python

- run: uv sync --group dev --locked
working-directory: sdks/python

- run: uv run ruff check .
working-directory: sdks/python

Expand All @@ -174,7 +177,7 @@ jobs:
steps:
- uses: actions/checkout@v6

- uses: astral-sh/setup-uv@v8.1.0
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
working-directory: sdks/python

Expand All @@ -185,28 +188,32 @@ jobs:
name: Python Typecheck
runs-on: ubuntu-latest
timeout-minutes: 20
needs: python-build
steps:
- uses: actions/checkout@v6

- uses: astral-sh/setup-uv@v8.1.0
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
working-directory: sdks/python

- run: uv run --group dev mypy
- run: uv sync --group dev --locked
working-directory: sdks/python

- run: uv run mypy
working-directory: sdks/python

python-test:
name: Python Test
runs-on: ubuntu-latest
timeout-minutes: 20
needs: python-build
steps:
- uses: actions/checkout@v6

- uses: astral-sh/setup-uv@v8.1.0
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
working-directory: sdks/python

- run: uv run --group dev pytest
- run: uv sync --group dev --locked
working-directory: sdks/python

- run: uv run pytest
working-directory: sdks/python
Loading
Loading