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`.
158 changes: 158 additions & 0 deletions .github/release-sdks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
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('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');

for (const sdk of ['typescript', 'python', 'go']) write(repo, `sdks/${sdk}/package.json`, '{"version":"0.2.0"}\n');
commit(repo, 'version SDKs');

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');
write(repo, 'sdks/go/package.json', '{"version":"0.2.0"}\n');
commit(repo, 'version SDKs');
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');
for (const sdk of ['typescript', 'python', 'go']) write(repo, `sdks/${sdk}/package.json`, '{"version":"0.2.0"}\n');
commit(repo, 'version SDKs');
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');
write(repo, 'sdks/go/package.json', '{"version":"0.2.0"}\n');
commit(repo, 'version SDKs');
git(repo, 'tag', '@cloudflare/flagship@0.2.0');
write(repo, 'README.md', 'next release\n');
commit(repo, 'prepare next release');
write(repo, 'sdks/typescript/package.json', '{"version":"0.3.0"}\n');
commit(repo, 'version SDKs again');

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

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');
write(repo, 'sdks/python/package.json', '{"version":"0.2.0"}\n');
commit(repo, 'version SDKs');
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;
}

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();
}
115 changes: 115 additions & 0 deletions .github/release-sdks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { pathToFileURL } from 'node:url';

const SDKS = ['typescript', 'python', 'go'] as const;
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');
const isDocumentation = (path: string): boolean =>
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) => {
const paths = git(cwd, 'diff', '--name-only', `${baselines[sdk]}..${releaseParent}`).split('\n').filter(Boolean);
return [sdk, classifySdkChanges(paths)[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 [];
}

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