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
151 changes: 151 additions & 0 deletions .github/workflows/breaking-changes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
name: Breaking changes

permissions:
contents: read

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like we could add concurrency here and only care about the last runs, right? So something like:

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

on:
pull_request:
branches: [main]
# Re-run when override labels are added or removed
types: [opened, synchronize, reopened, labeled, unlabeled]

jobs:
proto-breaking:
runs-on: ubuntu-latest
env:
SKIP: ${{ contains(github.event.pull_request.labels.*.name, 'breaking-change:proto') || contains(github.event.pull_request.labels.*.name, 'breaking-change:approved') }}
steps:
- name: Skipped — intentional breaking change (label override)
if: env.SKIP == 'true'
run: |
echo "::notice title=Proto breaking check skipped::This PR has label breaking-change:proto or breaking-change:approved. A maintainer acknowledged an intentional proto break. Remove the label to re-enable buf breaking."

- name: Checkout code
if: env.SKIP != 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Check warning on line 25 in .github/workflows/breaking-changes.yml

View workflow job for this annotation

GitHub Actions / Validate Workflow Changes

1. Trusted actions should use a major version tag, if available. (trusted-tag-ref / warning)
with:
fetch-depth: 0
submodules: recursive
token: ${{ secrets.GITHUB_TOKEN }}

- name: Setup Bun
if: env.SKIP != 'true'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.12

- name: Install dependencies
if: env.SKIP != 'true'
run: bun install --frozen-lockfile

# cre-sdk's buf.yaml points at ../../submodules/chainlink-protos/cre, which buf rejects when
# --against uses subdir=packages/cre-sdk (module path escapes the context). Run breaking on
# the chainlink-protos workspace instead, comparing HEAD to the submodule commit pinned on main.
- name: Buf breaking (proto)
if: env.SKIP != 'true'
run: |
set -euo pipefail
REPO="${{ github.workspace }}"
SUBMODULE="${REPO}/submodules/chainlink-protos"
BASE_COMMIT=$(git -C "$REPO" rev-parse origin/main:submodules/chainlink-protos)
BASE_DIR="${RUNNER_TEMP}/chainlink-protos-baseline"
if ! git -C "$SUBMODULE" cat-file -e "${BASE_COMMIT}^{commit}" 2>/dev/null; then
git -C "$SUBMODULE" fetch --no-tags origin "${BASE_COMMIT}"
fi
git -C "$SUBMODULE" worktree add "${BASE_DIR}" "${BASE_COMMIT}" --detach
cleanup() {
git -C "$SUBMODULE" worktree remove "${BASE_DIR}" --force || true
}
trap cleanup EXIT
(cd "$SUBMODULE" && bun x @bufbuild/buf breaking cre --against "${BASE_DIR}/cre" --error-format github-actions)

ts-api-surface:
runs-on: ubuntu-latest
env:
SKIP: ${{ contains(github.event.pull_request.labels.*.name, 'breaking-change:typescript-api') || contains(github.event.pull_request.labels.*.name, 'breaking-change:approved') }}
steps:
- name: Skipped — intentional breaking change (label override)
if: env.SKIP == 'true'
run: |
echo "::notice title=TypeScript API check skipped::This PR has label breaking-change:typescript-api or breaking-change:approved. Commit an updated api-baseline.d.ts when appropriate; this label only bypasses CI."

- name: Checkout code
if: env.SKIP != 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Check warning on line 74 in .github/workflows/breaking-changes.yml

View workflow job for this annotation

GitHub Actions / Validate Workflow Changes

1. Trusted actions should use a major version tag, if available. (trusted-tag-ref / warning)
with:
submodules: recursive
token: ${{ secrets.GITHUB_TOKEN }}

- name: Setup Bun
if: env.SKIP != 'true'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.12

- name: Install dependencies
if: env.SKIP != 'true'
run: bun install --frozen-lockfile

- name: Compile declaration emit
if: env.SKIP != 'true'
working-directory: packages/cre-sdk
run: bun run compile:build

- name: Diff TypeScript public API vs baseline
if: env.SKIP != 'true'
working-directory: packages/cre-sdk
run: |
cat dist/index.d.ts dist/pb.d.ts \
dist/sdk/index.d.ts dist/sdk/runtime.d.ts \
dist/sdk/workflow.d.ts dist/sdk/errors.d.ts \
dist/sdk/report.d.ts > /tmp/api-current.d.ts

if ! diff api-baseline.d.ts /tmp/api-current.d.ts; then
echo "::error::TypeScript public API surface changed. Run 'bun run update-api-baseline' locally and commit the updated api-baseline.d.ts."
exit 1
fi
Comment on lines +94 to +106
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I'm thinking is that we are exposing these in package.json:

"exports": {
	".": {
		"types": "./dist/index.d.ts",
		"import": "./dist/index.js"
	},
	"./restricted-apis": {
		"types": "./dist/sdk/types/restricted-apis.d.ts",
		"import": "./dist/sdk/types/restricted-apis.js"
	},
	"./pb": {
		"types": "./dist/pb.d.ts",
		"import": "./dist/pb.js"
	},
	"./test": {
		"types": "./dist/sdk/test/index.d.ts",
		"import": "./dist/sdk/test/index.js"
	}
},

So basically ./test and /restricted-apis are missing in the concat. Not 100% sure if restricted apis would make sense here but probably we should be warned if we change test public APIs. Note: test in this case is a package for customers to write their own tests, not our internal unit tests.


host-bindings:
runs-on: ubuntu-latest
env:
SKIP: ${{ contains(github.event.pull_request.labels.*.name, 'breaking-change:host-bindings') || contains(github.event.pull_request.labels.*.name, 'breaking-change:approved') }}
steps:
- name: Skipped — intentional breaking change (label override)
if: env.SKIP == 'true'
run: |
echo "::notice title=Host bindings check skipped::This PR has label breaking-change:host-bindings or breaking-change:approved. Update snapshots and host-imports-baseline.txt when appropriate; this label only bypasses CI."

- name: Checkout code
if: env.SKIP != 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

Check warning on line 120 in .github/workflows/breaking-changes.yml

View workflow job for this annotation

GitHub Actions / Validate Workflow Changes

1. Trusted actions should use a major version tag, if available. (trusted-tag-ref / warning)
with:
submodules: recursive
token: ${{ secrets.GITHUB_TOKEN }}

- name: Setup Bun
if: env.SKIP != 'true'
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.12

- name: Install dependencies
if: env.SKIP != 'true'
run: bun install --frozen-lockfile

- name: JS host bindings snapshot test
if: env.SKIP != 'true'
working-directory: packages/cre-sdk
run: bun test src/sdk/wasm/host-bindings-contract.test.ts

- name: Diff Rust host imports vs baseline
if: env.SKIP != 'true'
run: |
sed -n '/unsafe extern "C"/,/^}/p' \
packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/src/lib.rs \
> /tmp/current-imports.txt

if ! diff packages/cre-sdk-javy-plugin/src/javy_chainlink_sdk/host-imports-baseline.txt \
/tmp/current-imports.txt; then
echo "::error::Rust host import signatures changed. Update host-imports-baseline.txt if intentional."
exit 1
fi
Comment on lines +140 to +151
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is comment from my AI Agent (as my Rust sidekick):

Rust extern extraction fragile.

  sed -n '/unsafe extern "C"/,/^}/p (breaking-changes.yml:144-146) match first ^} at column 0. Works today (only one extern block at lib.rs:23-54). Break silently if:
  - Someone add 2nd extern block — only first captured.
  - Closing } indented (rustfmt config change, cfg-gated block) — sed run past it to next col-0 }, capture wrong content.
  - Attribute macro (#[link]) inserted before unsafe extern "C" — still ok actually.
  Fix: use syn parse, or at minimum grep all extern "C" blocks. Cheaper alt: extract each fn <name>(...) line individually instead of block-range.

6 changes: 6 additions & 0 deletions PUBLISHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ There are 2 possible scenarios that you might encounter:

Below ale the steps for two scenarios.

> **Breaking changes:** If the release includes any intentional breaking changes to the
> TypeScript API surface, JS host bindings, or Rust host imports, the corresponding baseline
> files (`api-baseline.d.ts`, `__snapshots__/host-bindings-contract.test.ts.snap`,
> `host-imports-baseline.txt`) must already be committed on the release branch before
> tagging. The `breaking-changes` CI job must be green before publishing.

### 1. Both packages need an update

1. Create a new branch from `main` with the name `release-candidate-vx.y.z` (for example `release-candidate-v1.0.8`).
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,32 @@ bun generate:chain-selectors # Update chain selector types
bun generate:sdk # Generate all SDK types and code
```

### Breaking Changes
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the readme section 🙌


The [`breaking-changes`](./.github/workflows/breaking-changes.yml) workflow blocks PRs that alter any of the three protected contracts. If your change is intentional, update the relevant baseline before pushing:

| Contract | What triggers the failure | How to update |
|---|---|---|
| Proto fields | Field deleted, renamed, renumbered, or type changed | No baseline file — CI runs `buf breaking` on `submodules/chainlink-protos` (`cre` module) against the submodule commit pinned on `main` |
| TypeScript public API | An exported type/interface was removed or changed | Run `bun run update-api-baseline` inside `packages/cre-sdk` and commit `api-baseline.d.ts` |
| JS host binding names | A binding was added, removed, or renamed in `host-bindings.ts` | Run `bun test --update-snapshots` inside `packages/cre-sdk` and commit the updated `__snapshots__` file |
| Rust host imports | An `extern "C"` import was added or removed in `lib.rs` | Re-run the sed extraction (see `breaking-changes.yml`) and commit `host-imports-baseline.txt` |

#### CI override labels

When a change is **intentionally** breaking and cannot be fixed by updating a baseline (e.g. a coordinated proto break before `main` catches up), a **maintainer** adds the matching label on the PR (this re-runs the workflow):

| Label | Skips |
|-------|--------|
| `breaking-change:proto` | `proto-breaking` (`buf breaking`) |
| `breaking-change:typescript-api` | `ts-api-surface` |
| `breaking-change:host-bindings` | `host-bindings` |
| `breaking-change:approved` | All three jobs |

Labels are an audit trail in the PR timeline, not a substitute for review. Prefer updating baselines (`api-baseline.d.ts`, snapshots, `host-imports-baseline.txt`) when the new contract is the new source of truth. For proto breaks, coordinate the `chainlink-protos` submodule bump and document migration notes in the PR.

Restrict who can add these labels in the repo’s **Labels** settings (e.g. maintainers only).

For detailed development setup, see individual package READMEs:

- [CRE SDK Development](./packages/cre-sdk/README.md#building-from-source)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
unsafe extern "C" {
fn call_capability(req_ptr: *const u8, req_len: i32) -> i64;
fn await_capabilities(
await_request_ptr: *const u8,
await_request_len: i32,
response_buffer_ptr: *mut u8,
max_response_len: i32,
) -> i64;

fn get_secrets(
req_ptr: *const u8,
req_len: i32,
response_buffer_ptr: *mut u8,
max_response_len: i32,
) -> i64;
fn await_secrets(
await_request_ptr: *const u8,
await_request_len: i32,
response_buffer_ptr: *mut u8,
max_response_len: i32,
) -> i64;

fn log(message_ptr: *const u8, message_len: i32);
fn send_response(response_ptr: *const u8, response_len: i32) -> i32;

fn switch_modes(mode: i32);
fn version_v2_typescript();

fn random_seed(mode: i32) -> i64;

fn now(result_timestamp: *mut u8) -> i32;
}
167 changes: 167 additions & 0 deletions packages/cre-sdk/api-baseline.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
export * from './sdk';
export * as EVM_PB from '@cre/generated/capabilities/blockchain/evm/v1alpha/client_pb';
export * as CONFIDENTIAL_HTTP_CLIENT_PB from '@cre/generated/capabilities/networking/confidentialhttp/v1alpha/client_pb';
export * as HTTP_CLIENT_PB from '@cre/generated/capabilities/networking/http/v1alpha/client_pb';
export * as HTTP_TRIGGER_PB from '@cre/generated/capabilities/networking/http/v1alpha/trigger_pb';
export * as CRON_TRIGGER_PB from '@cre/generated/capabilities/scheduler/cron/v1/trigger_pb';
export * as SDK_PB from '@cre/generated/sdk/v1alpha/sdk_pb';
export * as VALUES_PB from '@cre/generated/values/v1/values_pb';
export * as BUFBUILD_TYPES from '@cre/sdk/types/bufbuild-types';
export * from './cre';
export * from './don-info';
export * from './errors';
export * from './report';
export type * from './runtime';
export * from './runtime';
export * from './types/bufbuild-types';
export * from './utils';
export * from './utils/capabilities/http/http-helpers';
export * from './wasm';
export * from './workflow';
import type { Message } from '@bufbuild/protobuf';
import type { GenMessage } from '@bufbuild/protobuf/codegenv2';
import type { ReportRequest, ReportRequestJson } from '@cre/generated/sdk/v1alpha/sdk_pb';
import type { Report } from '@cre/sdk/report';
import type { ConsensusAggregation, PrimitiveTypes, UnwrapOptions } from '@cre/sdk/utils';
import type { SecretsProvider } from '.';
export type { ReportRequest, ReportRequestJson };
export type CallCapabilityParams<I extends Message, O extends Message> = {
capabilityId: string;
method: string;
payload: I;
inputSchema: GenMessage<I>;
outputSchema: GenMessage<O>;
};
/**
* Base runtime available in both DON and Node execution modes.
* Provides core functionality for calling capabilities and logging.
*/
export interface BaseRuntime<C> {
config: C;
callCapability<I extends Message, O extends Message>(params: CallCapabilityParams<I, O>): {
result: () => O;
};
now(): Date;
log(message: string): void;
}
/**
* Runtime for Node mode execution.
*/
export interface NodeRuntime<C> extends BaseRuntime<C> {
readonly _isNodeRuntime: true;
}
/**
* Runtime for DON mode execution.
*/
export interface Runtime<C> extends BaseRuntime<C>, SecretsProvider {
/**
* Executes a function in Node mode and aggregates results via consensus.
*
* @param fn - Function to execute in each node (receives NodeRuntime)
* @param consensusAggregation - How to aggregate results across nodes
* @param unwrapOptions - Optional unwrapping config for complex return types
* @returns Wrapped function that returns aggregated result
*/
runInNodeMode<TArgs extends unknown[], TOutput>(fn: (nodeRuntime: NodeRuntime<C>, ...args: TArgs) => TOutput, consensusAggregation: ConsensusAggregation<TOutput, true>, unwrapOptions?: TOutput extends PrimitiveTypes ? never : UnwrapOptions<TOutput>): (...args: TArgs) => {
result: () => TOutput;
};
report(input: ReportRequest | ReportRequestJson): {
result: () => Report;
};
}
import type { Message } from '@bufbuild/protobuf';
import type { Secret, SecretRequest, SecretRequestJson } from '@cre/generated/sdk/v1alpha/sdk_pb';
import { type Runtime } from '@cre/sdk/runtime';
import type { Trigger } from '@cre/sdk/utils/triggers/trigger-interface';
import type { CreSerializable } from './utils';
export type HandlerFn<TConfig, TTriggerOutput, TResult> = (runtime: Runtime<TConfig>, triggerOutput: TTriggerOutput) => Promise<CreSerializable<TResult>> | CreSerializable<TResult>;
export interface HandlerEntry<TConfig, TRawTriggerOutput extends Message<string>, TTriggerOutput, TResult> {
trigger: Trigger<TRawTriggerOutput, TTriggerOutput>;
fn: HandlerFn<TConfig, TTriggerOutput, TResult>;
}
export type Workflow<TConfig> = ReadonlyArray<HandlerEntry<TConfig, any, any, any>>;
export declare const handler: <TRawTriggerOutput extends Message<string>, TTriggerOutput, TConfig, TResult>(trigger: Trigger<TRawTriggerOutput, TTriggerOutput>, fn: HandlerFn<TConfig, TTriggerOutput, TResult>) => HandlerEntry<TConfig, TRawTriggerOutput, TTriggerOutput, TResult>;
export type SecretsProvider = {
getSecret(request: SecretRequest | SecretRequestJson): {
result: () => Secret;
};
};
import type { SecretRequest } from '@cre/generated/sdk/v1alpha/sdk_pb';
export declare class DonModeError extends Error {
constructor();
}
export declare class NodeModeError extends Error {
constructor();
}
export declare class SecretsError extends Error {
secretRequest: SecretRequest;
error: string;
constructor(secretRequest: SecretRequest, error: string);
}
export declare class NullReportError extends Error {
constructor();
}
export declare class WrongSignatureCountError extends Error {
constructor();
}
export declare class ParseSignatureError extends Error {
constructor();
}
export declare class RecoverSignerError extends Error {
constructor();
}
export declare class UnknownSignerError extends Error {
constructor();
}
export declare class DuplicateSignerError extends Error {
constructor();
}
export declare class RawReportTooShortError extends Error {
readonly need: number;
readonly got: number;
constructor(need: number, got: number);
}
import { type ReportResponse, type ReportResponseJson } from '@cre/generated/sdk/v1alpha/sdk_pb';
import { type Environment, type Zone } from './don-info';
import type { BaseRuntime } from './runtime';
export type ReportParseConfig = {
acceptedZones?: Zone[];
acceptedEnvironments?: Environment[];
skipSignatureVerification?: boolean;
};
export declare const REPORT_METADATA_HEADER_LENGTH = 109;
export type ReportMetadataHeader = {
version: number;
executionId: string;
timestamp: number;
donId: number;
donConfigVersion: number;
workflowId: string;
workflowName: string;
workflowOwner: string;
reportId: string;
body: Uint8Array;
};
export declare class Report {
private readonly report;
private cachedHeader;
constructor(report: ReportResponse | ReportResponseJson);
static parse(runtime: BaseRuntime<unknown>, rawReport: Uint8Array, signatures: Uint8Array[], reportContext: Uint8Array, config?: ReportParseConfig): Promise<Report>;
private parseHeader;
private verifySignaturesWithConfig;
seqNr(): bigint;
configDigest(): Uint8Array;
reportContext(): Uint8Array;
rawReport(): Uint8Array;
version(): number;
executionId(): string;
timestamp(): number;
donId(): number;
donConfigVersion(): number;
workflowId(): string;
workflowName(): string;
workflowOwner(): string;
reportId(): string;
body(): Uint8Array;
x_generatedCodeOnly_unwrap(): ReportResponse;
}
Loading
Loading