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
14 changes: 13 additions & 1 deletion .github/workflows/manage-npm-dist-tags.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ permissions:
contents: read

concurrency:
group: manage-npm-dist-tags
group: npm-call-e-calle
cancel-in-progress: false

jobs:
Expand Down Expand Up @@ -70,13 +70,25 @@ jobs:
echo "::error::version must be empty when action is remove."
exit 1
fi
if [[ "$INPUT_TAG" == "latest" ]]; then
echo "::error::The latest dist-tag cannot be removed."
exit 1
fi
exit 0
fi

if [[ ! "$INPUT_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then
echo "::error::version must be an exact semantic version without a leading v."
exit 1
fi
if [[ "$INPUT_TAG" == "latest" && "$INPUT_VERSION" == *-* ]]; then
echo "::error::The latest dist-tag must point to a stable version."
exit 1
fi
if [[ "$INPUT_TAG" == "beta" && "$INPUT_VERSION" != *-* ]]; then
echo "::error::The beta dist-tag must point to a prerelease version."
exit 1
fi

- name: Setup Node.js
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
Expand Down
12 changes: 10 additions & 2 deletions .github/workflows/publish-npm.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ permissions:
contents: read

concurrency:
group: publish-npm-${{ github.event.release.tag_name || github.ref }}
group: npm-call-e-calle
cancel-in-progress: false

jobs:
Expand Down Expand Up @@ -90,6 +90,12 @@ jobs:
exit 1
fi

if ! current_latest="$(npm view '@call-e/calle' dist-tags.latest --registry https://registry.npmjs.org 2>/dev/null)"; then
echo "::error::Could not read the current npm latest version."
exit 1
fi
node scripts/require-newer-stable-version.mjs "$package_version" "$current_latest"

set +e
version_lookup="$(npm view "@call-e/calle@${package_version}" version --registry https://registry.npmjs.org 2>&1)"
lookup_status=$?
Expand Down Expand Up @@ -262,7 +268,9 @@ jobs:
set -euo pipefail

for attempt in {1..10}; do
if [[ "$(npm view "@call-e/calle@${PACKAGE_VERSION}" version 2>/dev/null || true)" == "$PACKAGE_VERSION" ]]; then
visible_version="$(npm view "@call-e/calle@${PACKAGE_VERSION}" version 2>/dev/null || true)"
latest_version="$(npm view '@call-e/calle' dist-tags.latest 2>/dev/null || true)"
if [[ "$visible_version" == "$PACKAGE_VERSION" && "$latest_version" == "$PACKAGE_VERSION" ]]; then
exit 0
fi
echo "Published package metadata is not visible yet; retrying in 10 seconds."
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Stable publishing is initiated by a versioned GitHub Release and uses npm
Trusted Publishing.
- npm publishing and dist-tag changes are serialized, and stable releases must
advance the current `latest` version.
- The webhook receiver example bounds request bodies and safely handles
interrupted uploads.
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export CALLE_BASE_URL="https://api.heycall-e.com"
export CALLE_EXAMPLE_PHONE="+14155550100"
pnpm run example:create-and-wait

export CALLE_BASE_URL="https://test-api.heycall-e.com"
export CALLE_BASE_URL="https://api.heycall-e.com"
export CALLE_GOAL_ID="<PUBLISHED_GOAL_ID>"
export CALLE_EXAMPLE_PHONE="<E164_PHONE>"
export CALLE_GOAL_VARIABLES='{"name":"Alex"}'
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ pnpm run example:create-and-wait
Run a published Goal and wait for its structured result:

```bash
export CALLE_BASE_URL="https://test-api.heycall-e.com"
export CALLE_BASE_URL="https://api.heycall-e.com"
export CALLE_GOAL_ID="<PUBLISHED_GOAL_ID>"
export CALLE_EXAMPLE_PHONE="<E164_PHONE>"
export CALLE_GOAL_VARIABLES='{"name":"Alex"}'
Expand Down Expand Up @@ -123,7 +123,9 @@ event JSON without a webhook secret or signature headers. CALL-E sends the
event only after the post-call outcome and requested structured results are
finalized. Deduplicate side effects with the event `id` or
`CALL-E-Event-Id`, and reject events when the required header does not match
the body `id`.
the body `id`. The example defaults to a 10 MiB request-body limit and returns
`413` for larger payloads. Set `CALLE_WEBHOOK_MAX_BODY_BYTES` to match your
provider and ingress limits.

The `client.webhooks.verify` and signed `client.webhooks.unwrap` methods
implement the legacy SDK `0.2` contract. They remain available for source
Expand Down Expand Up @@ -264,4 +266,6 @@ in a public issue. Follow [SECURITY.md](./SECURITY.md) for private reporting.

## License

This project is licensed under the [MIT License](./LICENSE).
This project is licensed under the [MIT License](./LICENSE). The same license
applies to the published npm packages `@call-e/calle@0.6.0` and
`@call-e/calle@0.7.0`.
26 changes: 14 additions & 12 deletions RELEASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ workflow. Configure the trusted publisher on npm with:
- Environment name: `npm`
- Allowed action: `npm publish`

The GitHub `npm` environment should require maintainer approval and restrict
deployments to protected release tags. The publish job uses OIDC and does not
read a long-lived npm token. `NPM_TOKEN` is retained only for the separate,
manually invoked dist-tag management workflow.
The GitHub `npm` environment should require maintainer approval and allow
deployments only from `main` and `v*` release tags. The publish job uses OIDC
and does not read a long-lived npm token. `NPM_TOKEN` is retained only for the
separate, manually invoked dist-tag management workflow.

## Release gates

Expand Down Expand Up @@ -46,11 +46,13 @@ For a stable release:
corresponding GitHub Release.

The release workflow rejects prereleases, tags that do not exactly match the
`package.json` version, and tag commits that are not contained in `origin/main`.
Before dependency installation and again immediately before publication, it
checks npm for the exact version. Only an explicit not-found response is
treated as an available version; registry, network, and permission failures
stop the release.
`package.json` version, tag commits that are not contained in `origin/main`,
and versions that do not advance npm's current `latest` version. Before
dependency installation and again immediately before publication, it checks
npm for the exact version. Only an explicit not-found response is treated as
an available version; registry, network, and permission failures stop the
release. Package publication and manual dist-tag changes share one concurrency
lock.

The build job validates and packs once, then uploads exactly one tarball and a
SHA-256 manifest. After environment approval, the publish job downloads that
Expand All @@ -64,7 +66,7 @@ against a published Goal in the test environment:

```bash
export CALLE_API_KEY="<TEST_API_KEY>"
export CALLE_BASE_URL="https://test-api.heycall-e.com"
export CALLE_BASE_URL="<APPROVED_TEST_API_BASE_URL>"
export CALLE_GOAL_ID="<PUBLISHED_TEST_GOAL_ID>"
export CALLE_EXAMPLE_PHONE="<AUTHORIZED_TEST_E164_PHONE>"
export CALLE_GOAL_VARIABLES='{"name":"Alex"}'
Expand Down Expand Up @@ -108,8 +110,8 @@ version and a validated lowercase tag; `remove` requires a tag; `list` accepts
neither. Mutating actions require the `NPM_TOKEN` repository secret and the
`npm` environment.

Keep `latest` on the intended stable version. Reserve `beta` for prerelease
versions and do not move it to a stable version.
The workflow prevents removing `latest`, requires `latest` to target a stable
version, and requires `beta` to target a prerelease version.

## Version rules

Expand Down
38 changes: 31 additions & 7 deletions examples/webhook-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,26 @@ import type { IncomingMessage } from "node:http";
import type { WebhookEvent } from "../src/index.js";

const port = Number(process.env.PORT ?? "3000");
const maxRequestBodyBytes = Number(
process.env.CALLE_WEBHOOK_MAX_BODY_BYTES ?? "10485760"
);
const processedEventIds = new Set<string>();

class RequestBodyTooLargeError extends Error {}

if (!Number.isSafeInteger(maxRequestBodyBytes) || maxRequestBodyBytes <= 0) {
throw new Error("CALLE_WEBHOOK_MAX_BODY_BYTES must be a positive integer.");
}

const server = createServer(async (request, response) => {
if (request.method !== "POST" || request.url !== "/calle/webhook") {
response.writeHead(404, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "not_found" }));
return;
}

const rawBody = await readRequestBody(request);

try {
const rawBody = await readRequestBody(request);
const event = JSON.parse(rawBody.toString("utf8")) as WebhookEvent;
const eventId = request.headers["call-e-event-id"];
if (typeof eventId !== "string" || eventId !== event.id) {
Expand Down Expand Up @@ -52,9 +60,18 @@ const server = createServer(async (request, response) => {

response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ received: true }));
} catch {
response.writeHead(400, { "content-type": "application/json" });
response.end(JSON.stringify({ error: "invalid_json" }));
} catch (error) {
if (response.destroyed) {
return;
}

const bodyTooLarge = error instanceof RequestBodyTooLargeError;
response.writeHead(bodyTooLarge ? 413 : 400, {
"content-type": "application/json"
});
response.end(
JSON.stringify({ error: bodyTooLarge ? "payload_too_large" : "invalid_json" })
);
}
});

Expand All @@ -64,8 +81,15 @@ server.listen(port, () => {

async function readRequestBody(request: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
let totalBytes = 0;

for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
const bodyChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
totalBytes += bodyChunk.length;
if (totalBytes > maxRequestBodyBytes) {
throw new RequestBodyTooLargeError();
}
chunks.push(bodyChunk);
}
return Buffer.concat(chunks);
return Buffer.concat(chunks, totalBytes);
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,9 @@
"test:package": "pnpm run build && node --input-type=module -e \"import('./dist/index.js').then((mod) => { const client = new mod.CalleClient({ apiKey: 'smoke' }); if (typeof client.goals?.runAndWait !== 'function') process.exit(1); })\"",
"pack": "rm -f call-e-calle-*.tgz && npm pack --silent",
"test:tarball": "pnpm run build && pnpm run pack && tarball=$(find . -maxdepth 1 -name 'call-e-calle-*.tgz' -print -quit) && test -n \"$tarball\" && tar -tzf \"$tarball\" | grep -qx 'package/LICENSE' && tarball=\"$(pwd)/${tarball#./}\" && smoke_dir=$(mktemp -d) && trap 'rm -rf \"$smoke_dir\"' EXIT && cd \"$smoke_dir\" && npm init -y >/dev/null && npm install \"$tarball\" >/dev/null && node --input-type=module -e \"import('@call-e/calle').then((mod) => { const client = new mod.CalleClient({ apiKey: 'smoke' }); if (typeof client.goals?.runAndWait !== 'function') process.exit(1); })\" && ./node_modules/.bin/calle --help | grep \"Usage:\" >/dev/null",
"test:release-version": "node scripts/require-newer-stable-version.mjs --self-test",
"check:public-repo": "node scripts/check-public-repo-hygiene.mjs",
"validate": "pnpm run verify:openapi && pnpm run test && pnpm run typecheck && pnpm run typecheck:examples && pnpm run check:public-repo && pnpm run test:package && pnpm run test:tarball",
"validate": "pnpm run verify:openapi && pnpm run test && pnpm run typecheck && pnpm run typecheck:examples && pnpm run test:release-version && pnpm run check:public-repo && pnpm run test:package && pnpm run test:tarball",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"publishConfig": {
Expand Down
44 changes: 44 additions & 0 deletions scripts/require-newer-stable-version.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";

const stableVersion = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;

function compare(left, right) {
const leftParts = parse(left);
const rightParts = parse(right);
for (let index = 0; index < leftParts.length; index += 1) {
if (leftParts[index] !== rightParts[index]) {
return leftParts[index] > rightParts[index] ? 1 : -1;
}
}
return 0;
}

function parse(version) {
const match = stableVersion.exec(version);
if (!match) {
throw new Error(`Invalid stable version: ${version}`);
}
return match.slice(1).map(BigInt);
}

const [candidate, current] = process.argv.slice(2);

try {
if (candidate === "--self-test") {
assert.equal(compare("0.7.1", "0.7.0"), 1);
assert.equal(compare("1.0.0", "1.0.0"), 0);
assert.equal(compare("2.0.0", "10.0.0"), -1);
assert.equal(compare("100000000000000000000.0.0", "9.0.0"), 1);
assert.throws(() => compare("1.0.0-beta.1", "1.0.0"));
} else {
if (!candidate || !current) {
throw new Error("Usage: require-newer-stable-version <candidate> <current>");
}
if (compare(candidate, current) <= 0) {
throw new Error(`Release version ${candidate} must be newer than npm latest ${current}.`);
}
}
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
4 changes: 2 additions & 2 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ describe("calle CLI", () => {
"--api-key",
"cli_key",
"--base-url",
"https://test-api.heycall-e.com",
"https://api.example.test",
"--idempotency-key",
"idem_123",
"--interval-ms",
Expand All @@ -141,7 +141,7 @@ describe("calle CLI", () => {
expect(exitCode).toBe(0);
expect(createClient).toHaveBeenCalledWith({
apiKey: "cli_key",
baseUrl: "https://test-api.heycall-e.com"
baseUrl: "https://api.example.test"
});
expect(create).toHaveBeenCalledWith(
{
Expand Down
71 changes: 71 additions & 0 deletions tests/webhook-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { describe, expect, it, vi } from "vitest";

type RequestListener = (
request: IncomingMessage,
response: ServerResponse
) => Promise<void>;

const state = vi.hoisted(() => ({ listener: undefined as RequestListener | undefined }));

vi.mock("node:http", () => ({
createServer: vi.fn((listener: RequestListener) => {
state.listener = listener;
return { listen: vi.fn() };
})
}));

process.env.CALLE_WEBHOOK_MAX_BODY_BYTES = "8";
await import("../examples/webhook-server.js");
delete process.env.CALLE_WEBHOOK_MAX_BODY_BYTES;

function response(destroyed = false): ServerResponse {
return {
destroyed,
writeHead: vi.fn(),
end: vi.fn()
} as unknown as ServerResponse;
}

describe("webhook server example", () => {
it("bounds request bodies and handles aborted streams", async () => {
const listener = state.listener;
expect(listener).toBeDefined();

const oversizedRequest = Object.assign(
Readable.from([Buffer.alloc(9)]),
{
method: "POST",
url: "/calle/webhook",
headers: { "call-e-event-id": "evt_large" }
}
) as IncomingMessage;
const oversizedResponse = response();

await listener!(oversizedRequest, oversizedResponse);

expect(oversizedResponse.writeHead).toHaveBeenCalledWith(413, {
"content-type": "application/json"
});
expect(oversizedResponse.end).toHaveBeenCalledWith(
JSON.stringify({ error: "payload_too_large" })
);

const abortedRequest = {
method: "POST",
url: "/calle/webhook",
headers: { "call-e-event-id": "evt_abort" },
async *[Symbol.asyncIterator]() {
yield Buffer.from("{");
throw Object.assign(new Error("aborted"), { code: "ECONNRESET" });
}
} as unknown as IncomingMessage;
const abortedResponse = response(true);

await listener!(abortedRequest, abortedResponse);

expect(abortedResponse.writeHead).not.toHaveBeenCalled();
expect(abortedResponse.end).not.toHaveBeenCalled();
});
});