Skip to content
Open
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/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,6 @@ We have a quick list of common questions to get you started engaging with this p

## 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.
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. Publishing is independent: npm, PyPI, and Go releases are produced only when publish-relevant source or package configuration changed since that SDK's previous release tag.

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.
16 changes: 16 additions & 0 deletions .changeset/quiet-pianos-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@cloudflare/flagship': minor
---

Add an injectable `fetch` transport and caller `AbortSignal` propagation to `FlagshipClient`

- `FlagshipProviderOptions.fetch?: typeof globalThis.fetch` sets the transport for a client. It defaults to `globalThis.fetch`, resolved at call time, and the SDK never assigns to the global — so routing evaluations through a Workers service binding or stubbing the transport in tests no longer requires mutating `globalThis.fetch` and exposing unrelated traffic in the same isolate.
- `evaluate(flagKey, context, { fetch?, signal? })` adds per-call overrides. `signal` is merged with the request timeout and with `fetchOptions.signal` (previously silently discarded), so a caller abort now aborts the in-flight HTTP request instead of only abandoning the promise. An already-aborted signal rejects without issuing a request.
- New `FlagshipErrorCode.ABORTED` distinguishes caller cancellation from `TIMEOUT_ERROR`. Caller aborts interrupt in-flight requests and retry delays and are never retried; timeout aborts are still retried as before.
- New `FlagshipError.retryable` reports whether a failure was transient. `408`, `425`, `429`, `5xx`, connection failures, timeouts, and malformed bodies are retryable; other non-2xx responses (`400`, `401`, `403`, `404`, `422`, …) and caller aborts are terminal. This lets consumers implement fail-closed-without-caching instead of guessing from `NETWORK_ERROR` alone.
- `FlagshipServerProvider` and `FlagshipClientProvider` accept and forward `fetch` in HTTP mode; combining it with `binding` throws like the other HTTP-only options.

Behaviour changes for existing callers, who are otherwise unaffected:

- Previously every non-2xx except `400` and `404` was retried. Definitively terminal statuses such as `401`, `403`, and `422` are now propagated immediately.
- The request timeout now also covers reading the response body, so a stalled body read no longer holds the request open past `timeout`.
184 changes: 184 additions & 0 deletions .github/release-sdks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import assert from 'node:assert/strict';
import test from 'node:test';
import { classifySdkChanges, detectSdkChanges, publishCommands } from './release-sdks.js';

test('classifies changes by SDK directory', () => {
assert.deepEqual(classifySdkChanges(['sdks/typescript/src/client.ts', 'sdks/go/client.go', 'README.md']), {
typescript: true,
python: false,
go: true,
});
});

test('ignores test-only changes', () => {
assert.deepEqual(
classifySdkChanges(['sdks/typescript/tests/client.test.ts', 'sdks/python/tests/test_client.py', 'sdks/go/client_test.go']),
{ typescript: false, python: false, go: false },
);
});

test('includes examples, documentation, and licenses', () => {
assert.deepEqual(classifySdkChanges(['sdks/typescript/README.md', 'sdks/python/LICENSE', 'sdks/go/examples/basic/main.go']), {
typescript: true,
python: true,
go: true,
});
});

test('excludes generated changelogs', () => {
assert.deepEqual(classifySdkChanges(['sdks/typescript/CHANGELOG.md', 'sdks/python/CHANGELOG.md', 'sdks/go/CHANGELOG.md']), {
typescript: false,
python: false,
go: false,
});
});

test('includes package and build configuration changes but excludes lockfiles', () => {
assert.deepEqual(classifySdkChanges(['sdks/typescript/package.json', 'sdks/python/pyproject.toml', 'sdks/go/go.mod']), {
typescript: true,
python: true,
go: true,
});
assert.deepEqual(classifySdkChanges(['sdks/python/uv.lock', 'sdks/go/go.sum']), {
typescript: false,
python: false,
go: false,
});
});

test('publishes npm only for TypeScript changes', () => {
assert.deepEqual(publishCommands({ typescript: true, python: false, go: false }), [
['changeset', 'publish'],
['changeset', 'tag'],
]);
assert.deepEqual(publishCommands({ typescript: false, python: true, go: false }), [['changeset', 'tag']]);
assert.deepEqual(publishCommands({ typescript: false, python: false, go: true }), [['changeset', 'tag']]);
assert.deepEqual(publishCommands({ typescript: false, python: false, go: false }), []);
});

test('ignores mechanical SDK version changes in the release commit', () => {
const repo = createRepository();
write(repo, 'sdks/python/src/client.py', 'changed\n');
commit(repo, 'change python');
releaseCommit(repo, '0.2.0');

assert.deepEqual(detectSdkChanges('HEAD', repo), { typescript: false, python: true, go: false });
});

test('reports no SDK changes after the release is tagged', () => {
const repo = createRepository();
write(repo, 'sdks/go/client.go', 'changed\n');
commit(repo, 'change go');
releaseCommit(repo, '0.2.0');
git(repo, 'tag', '@cloudflare/flagship@0.2.0');
write(repo, 'README.md', 'docs\n');
commit(repo, 'update docs');

assert.deepEqual(detectSdkChanges('HEAD', repo), { typescript: false, python: false, go: false });
});

test('uses the first parent of a merged release PR', () => {
const repo = createRepository();
const mainBranch = git(repo, 'branch', '--show-current');
write(repo, 'sdks/go/client.go', 'changed\n');
commit(repo, 'change go');
git(repo, 'checkout', '-b', 'release');
releaseCommit(repo, '0.2.0');
git(repo, 'checkout', mainBranch);
git(repo, 'merge', '--no-ff', 'release', '-m', 'merge release PR');

assert.deepEqual(detectSdkChanges('HEAD', repo), { typescript: false, python: false, go: true });
});

test('fails safely when the canonical baseline tag is missing', () => {
const repo = createRepository();
git(repo, 'tag', '-d', '@cloudflare/flagship@0.1.0');
write(repo, 'README.md', 'changed\n');
commit(repo, 'change docs');

assert.throws(() => detectSdkChanges('HEAD', repo));
});

test('retains unpublished SDK changes across canonical releases', () => {
const repo = createRepository();
git(repo, 'tag', 'sdks/go/v0.1.0');
write(repo, 'sdks/go/client.go', 'changed\n');
commit(repo, 'change go');
releaseCommit(repo, '0.2.0');
git(repo, 'tag', '@cloudflare/flagship@0.2.0');
write(repo, 'README.md', 'next release\n');
commit(repo, 'prepare next release');
releaseCommit(repo, '0.3.0');

assert.deepEqual(detectSdkChanges('HEAD', repo), { typescript: false, python: false, go: true });
});

test('ignores release commits inside a stale SDK baseline window', () => {
const repo = createRepository();
git(repo, 'tag', 'sdks/go/v0.1.0');
write(repo, 'sdks/typescript/src/client.ts', 'changed\n');
commit(repo, 'change typescript');
releaseCommit(repo, '0.2.0');
git(repo, 'tag', '@cloudflare/flagship@0.2.0');
write(repo, 'sdks/typescript/src/client.ts', 'changed again\n');
commit(repo, 'change typescript again');
releaseCommit(repo, '0.3.0');

assert.deepEqual(detectSdkChanges('HEAD', repo), { typescript: true, python: false, go: false });
});

test('ignores an SDK tag that is not reachable from the release parent', () => {
const repo = createRepository();
write(repo, 'sdks/python/src/client.py', 'changed\n');
commit(repo, 'change python');
releaseCommit(repo, '0.2.0');
git(repo, 'tag', '@cloudflare/flagship@0.2.0');
git(repo, 'tag', 'sdks/python/v0.2.0');

assert.deepEqual(detectSdkChanges('HEAD', repo), { typescript: false, python: true, go: false });
});

function createRepository(): string {
const repo = mkdtempSync(join(tmpdir(), 'flagship-release-'));
git(repo, 'init');
git(repo, 'config', 'user.email', 'test@example.com');
git(repo, 'config', 'user.name', 'Test');

for (const sdk of ['typescript', 'python', 'go']) {
write(repo, `sdks/${sdk}/package.json`, '{"version":"0.1.0"}\n');
write(repo, `sdks/${sdk}/src/initial`, 'initial\n');
}
commit(repo, 'initial release');
git(repo, 'tag', '@cloudflare/flagship@0.1.0');
return repo;
}

/** Mirrors what `.github/changeset-version.ts` writes, including the release commit message. */
function releaseCommit(repo: string, version: string): void {
for (const sdk of ['typescript', 'python', 'go']) {
write(repo, `sdks/${sdk}/package.json`, `{"version":"${version}"}\n`);
write(repo, `sdks/${sdk}/CHANGELOG.md`, `## ${version}\n`);
}
write(repo, 'sdks/python/pyproject.toml', `version = "${version}"\n`);
write(repo, 'sdks/python/uv.lock', `version = "${version}"\n`);
commit(repo, `chore(release): version SDK packages (#${version.replaceAll('.', '')})`);
}

function write(repo: string, path: string, content: string): void {
const file = join(repo, path);
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, content);
}

function commit(repo: string, message: string): void {
git(repo, 'add', '.');
git(repo, 'commit', '-m', message);
}

function git(repo: string, ...args: string[]): string {
return execFileSync('git', args, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
}
130 changes: 130 additions & 0 deletions .github/release-sdks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';

const SDKS = ['typescript', 'python', 'go'] as const;
const RELEASE_COMMIT_PATTERN = '^chore(release): version SDK packages';
type Sdk = (typeof SDKS)[number];
export type SdkChanges = Record<Sdk, boolean>;

export function classifySdkChanges(paths: string[]): SdkChanges {
const relative = (sdk: Sdk): string[] =>
paths.filter((path) => path.startsWith(`sdks/${sdk}/`)).map((path) => path.slice(`sdks/${sdk}/`.length));
const typescript = relative('typescript');
const python = relative('python');
const go = relative('go');
// `CHANGELOG.md` is generated by the release itself, so it never justifies a publish.
const isDocumentation = (path: string): boolean =>
path !== 'CHANGELOG.md' &&
(path.startsWith('examples/') ||
path.startsWith('docs/') ||
path.endsWith('.md') ||
path.slice(path.lastIndexOf('/') + 1).startsWith('LICENSE'));

return {
typescript: typescript.some(
(path) => isDocumentation(path) || path.startsWith('src/') || ['package.json', 'tsconfig.json', 'tsdown.config.ts'].includes(path),
),
python: python.some((path) => isDocumentation(path) || path.startsWith('src/') || path === 'pyproject.toml'),
go: go.some(
(path) => isDocumentation(path) || (!path.includes('/') && path.endsWith('.go') && !path.endsWith('_test.go')) || path === 'go.mod',
),
};
}

export function detectSdkChanges(releaseCommit = 'HEAD', cwd = process.cwd()): SdkChanges {
const releaseParent = `${releaseCommit}^1`;
const canonicalTag = describeTag(cwd, '@cloudflare/flagship@*', releaseParent);
const baselines: Record<Sdk, string> = {
typescript: canonicalTag,
python: findSdkTag(cwd, 'sdks/python/v*', releaseParent) ?? canonicalTag,
go: findSdkTag(cwd, 'sdks/go/v*', releaseParent) ?? canonicalTag,
};

return Object.fromEntries(
SDKS.map((sdk) => [sdk, classifySdkChanges(changedPaths(cwd, baselines[sdk], releaseParent))[sdk]]),
) as SdkChanges;
}

type ChangesetCommand = ['changeset', 'publish' | 'tag'];

export function publishCommands(changes: SdkChanges): ChangesetCommand[] {
if (changes.typescript)
return [
['changeset', 'publish'],
['changeset', 'tag'],
];
if (changes.python || changes.go) return [['changeset', 'tag']];
return [];
}

/**
* Collects the files touched between two commits, skipping the mechanical version
* bumps produced by `.github/changeset-version.ts`. An SDK that is skipped by one
* release keeps a stale baseline tag, so later windows span earlier release commits
* and would otherwise republish on generated churn alone.
*
* The pattern must match the `commit` input of `changesets/action` in `release.yml`.
* This is a union of per-commit changes rather than a net diff, so a change that is
* later reverted still counts — deliberately conservative.
*/
function changedPaths(cwd: string, base: string, head: string): string[] {
const output = git(cwd, 'log', '--format=', '--name-only', '--invert-grep', '--grep', RELEASE_COMMIT_PATTERN, `${base}..${head}`);
return [...new Set(output.split('\n').filter(Boolean))];
}

function git(cwd: string, ...args: string[]): string {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
}

function describeTag(cwd: string, pattern: string, commit: string): string {
return git(cwd, 'describe', '--first-parent', '--tags', '--match', pattern, '--exclude', '*-*', '--abbrev=0', commit);
}

function findSdkTag(cwd: string, pattern: string, commit: string): string | undefined {
try {
return describeTag(cwd, pattern, commit);
} catch {
return undefined;
}
}

function writeChanges(changes: SdkChanges): void {
const lines = [...SDKS.map((sdk) => `${sdk}=${changes[sdk]}`), `any=${Object.values(changes).some(Boolean)}`];
const output = process.env.GITHUB_OUTPUT;

if (output) appendFileSync(output, `${lines.join('\n')}\n`);
console.log(lines.join('\n'));
}

function publish(): void {
const changes = Object.fromEntries(SDKS.map((sdk) => [sdk, process.env[`${sdk.toUpperCase()}_SDK_CHANGED`] === 'true'])) as SdkChanges;
const commands = publishCommands(changes);

if (commands.length === 0) {
console.log('No SDK source changes detected; skipping release.');
return;
}

if (!changes.typescript) console.log('TypeScript SDK unchanged; creating the canonical release tag without publishing to npm.');
for (const command of commands) {
const result = spawnSync('pnpm', command, { stdio: 'inherit' });
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
}

async function main(): Promise<void> {
switch (process.argv[2]) {
case 'detect':
writeChanges(detectSdkChanges(process.env.GITHUB_SHA));
break;
case 'publish':
publish();
break;
default:
throw new Error('Expected mode: detect or publish');
}
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main();
30 changes: 28 additions & 2 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,45 @@ jobs:
name: pypi
url: https://pypi.org/project/cloudflare-flagship/
permissions:
contents: read
contents: write
id-token: write
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0

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

- name: Bootstrap Python release baseline
run: |
if [ -z "$(git tag --list 'sdks/python/v*')" ]; then
BASE=$(git describe --first-parent --tags --match '@cloudflare/flagship@*' --exclude '*-*' --abbrev=0 "${GITHUB_SHA}^1")
TAG="sdks/python/v${BASE##*@}"
git tag "${TAG}" "${BASE}"
git push origin "${TAG}"
fi

- name: Build
working-directory: sdks/python
run: uv build

- name: Publish
working-directory: sdks/python
run: uv publish
run: uv publish --check-url https://pypi.org/simple

- name: Tag Python SDK release
run: |
VERSION=$(uv version --project sdks/python --short)
TAG="sdks/python/v${VERSION}"
if git rev-parse --verify --quiet "refs/tags/${TAG}"; then
test "$(git rev-list -n 1 "${TAG}")" = "${GITHUB_SHA}" || {
echo "${TAG} exists on a different commit." >&2
exit 1
}
echo "${TAG} already exists on this release; skipping."
else
git tag "${TAG}"
git push origin "${TAG}"
fi
Loading
Loading