Go SDK becomes its own module
pluginsdk/go.mod ↗The SDK moves out of `apps/backend/pkg/pluginsdk` into a root Go module, so external plugins consume a real module version instead of importing a package from a Kandev checkout. The gRPC and go-plugin wire contract stays the same, and the backend keeps a local replace for unreleased in-tree work.
New module boundary
module github.com/kdlbs/kandev/pluginsdk
go 1.26.0
require (
github.com/Masterminds/semver/v3 v3.4.0
github.com/hashicorp/go-plugin v1.8.0
github.com/stretchr/testify v1.11.1
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
Frontend package renamed and published
apps/packages/plugin-sdk/package.json ↗The npm artifact carries the new scope, explicit public publish configuration, the exported release record, and the AGPL license. The published tarball is limited to `src`, `sdk-release.json`, `README.md`, and `LICENSE`, so consumers do not get workspace-only files.
Package metadata
{
"name": "@kdlbs/plugin-sdk",
"version": "0.1.0",
"description": "Versioned frontend contracts for Kandev runtime plugins",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./sdk-release.json": "./sdk-release.json"
},
"files": [
"src",
"sdk-release.json",
"README.md",
"LICENSE"
],
"publishConfig": {
"access": "public"
},
One canonical compatibility record
pluginsdk/sdk-release.json ↗The record is the single source of truth shared by Go and npm. `host_release` and `min_kandev_version` stay null until Stable preparation stamps the first shipping host, and the npm copy must remain byte-identical to the canonical file.
Canonical record
{
"schema_version": 1,
"sdk_version": "0.1.0",
"host_release": null,
"min_kandev_version": null,
"api_versions": [1, 2],
"change_kind": "initial"
}
Strict release-record validation
pluginsdk/internal/compatibility/release.go ↗`ReadRecord` rejects unknown fields, trailing data, and oversized files. It also rejects prerelease or metadata versions, a minimum that exceeds the originating host, empty or duplicate API versions, and change kinds outside the closed set.
Validation rules
func stableVersion(field, raw string) (*semver.Version, error) {
version, err := semver.StrictNewVersion(raw)
if err != nil || version.Prerelease() != "" || version.Metadata() != "" {
return nil, fmt.Errorf("%s must be an exact published Stable version, got %q", field, raw)
}
return version, nil
}
func (r Record) validate(selected string) error {
if r.SchemaVersion != 1 {
return fmt.Errorf("unsupported SDK record schema_version %d", r.SchemaVersion)
}
if _, err := stableVersion("sdk_version", r.SDKVersion); err != nil {
return err
}
if r.SDKVersion != selected {
return fmt.Errorf("SDK record version %s differs from selected version %s", r.SDKVersion, selected)
}
host, err := stableVersion("host_release", r.HostRelease)
if err != nil {
return err
}
floor, err := stableVersion("min_kandev_version", r.Minimum)
if err != nil {
return err
}
if floor.GreaterThan(host) {
return fmt.Errorf("SDK min_kandev_version exceeds host_release")
}
if !slices.Contains([]string{"initial", "patch", "additive", "breaking"}, r.ChangeKind) {
return fmt.Errorf("invalid SDK change_kind %q", r.ChangeKind)
}
seen := map[int]bool{}
for _, api := range r.APIVersions {
if api < 1 || seen[api] {
return fmt.Errorf("invalid SDK api_versions")
}
seen[api] = true
}
if len(seen) == 0 {
return fmt.Errorf("SDK api_versions is empty")
}
return nil
}
Resolve only real installed SDKs
pluginsdk/internal/compatibility/resolve.go ↗Go evidence comes from `go list -mod=readonly` with workspaces and ambient flags cleared, and a `replace` is rejected outright. Frontend evidence must be the installed `node_modules/@kdlbs/plugin-sdk`, must match the lockfile, and cannot be a link outside the package directory or pnpm store.
Go resolution
func ResolveGo(directory string) (Record, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "list", "-mod=readonly", "-m", "-json", ModulePath)
cmd.Dir = directory
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
if key != "GOWORK" && key != "GOFLAGS" {
cmd.Env = append(cmd.Env, entry)
}
}
cmd.Env = append(cmd.Env, "GOWORK=off", "GOFLAGS=")
data, err := cmd.Output()
if err != nil {
return Record{}, fmt.Errorf("resolve selected Go SDK (install dependencies first): %w", err)
}
var module struct {
Path, Version, Dir string
Replace *json.RawMessage
}
if err = json.Unmarshal(data, &module); err != nil {
return Record{}, fmt.Errorf("selected Go module evidence: %w", err)
}
if module.Replace != nil {
return Record{}, fmt.Errorf("selected Go SDK replacement is not published dependency evidence")
}
if module.Path != ModulePath || module.Dir == "" || !strings.HasPrefix(module.Version, "v") {
return Record{}, fmt.Errorf("missing selected Go SDK version or downloaded directory")
}
return ReadRecord(filepath.Join(module.Dir, "sdk-release.json"), strings.TrimPrefix(module.Version, "v"))
}
Read-only author checker
pluginsdk/cmd/kandev-sdk-check/main.go ↗The command wires flags for the manifest path, an installed frontend SDK, and frontend-only projects. It then prints each compatible SDK version and its minimum host. Any resolution or compatibility failure exits non-zero without editing the project.
Command surface
func run(args []string, directory string, out, errOut io.Writer) int {
options := compatibility.Options{Directory: directory}
flags := flag.NewFlagSet("kandev-sdk-check", flag.ContinueOnError)
flags.SetOutput(errOut)
flags.StringVar(&options.Manifest, "manifest", "manifest.yaml", "plugin manifest path")
flags.StringVar(&options.FrontendSDK, "frontend-sdk", "", "installed node_modules/@kdlbs/plugin-sdk path")
flags.BoolVar(&options.FrontendOnly, "frontend-only", false, "check a frontend-only plugin without a Go dependency")
if err := flags.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if flags.NArg() != 0 || (options.FrontendOnly && options.FrontendSDK == "") {
_, _ = fmt.Fprintln(errOut, "unexpected arguments or --frontend-only without --frontend-sdk")
return 2
}
records, err := compatibility.CheckProject(options)
if err != nil {
_, _ = fmt.Fprintln(errOut, "SDK compatibility:", err)
return 1
}
for _, record := range records {
_, _ = fmt.Fprintf(out, "SDK %s: compatible (minimum Kandev %s)\n", record.SDKVersion, record.Minimum)
}
return 0
}
Gate SDK versions before release
scripts/release/plugin-sdk.mjs ↗`planSDKRelease` reuses a version only when no contract input changed and the record is identical. Otherwise it enforces the reviewed change kind: additive changes need a minor bump, breaking changes need a minor before 1.0 or a major afterward, and a v2 SDK needs a migrated Go import path. Stamping writes the host version and default floor into both record copies.
Version plan and stamping
export function planSDKRelease(current, previous, changedPaths, hostVersion) {
validateRecord(current, true);
if (hostVersion !== null) stableParts(hostVersion);
if (previous) {
validateRecord(previous);
if (previous.sdk_version === current.sdk_version) {
if (changedPaths.some(isContractInput))
throw new Error("changed SDK contract requires a new shared version");
if (!isDeepStrictEqual(previous, current))
throw new Error("changed SDK release record requires a new version");
if (
hostVersion !== null &&
compareVersions(current.host_release, hostVersion) > 0
)
throw new Error("SDK originates after the selected host release");
return { record: structuredClone(previous), reused: true };
}
validateProgression(current, previous);
} else if (
current.sdk_version !== "0.1.0" ||
current.change_kind !== "initial"
) {
throw new Error("first SDK must be version 0.1.0 with initial change_kind");
}
if (hostVersion === null)
return { record: structuredClone(current), reused: false };
if (current.host_release !== null && current.host_release !== hostVersion)
throw new Error(
"new SDK host_release differs from the release being prepared",
);
const record = {
...current,
host_release: hostVersion,
min_kandev_version: current.min_kandev_version ?? hostVersion,
};
validateRecord(record);
return { record, reused: false };
}
Prepare, publish, bootstrap, verify
scripts/release/sdk-publication-state.mjs ↗Publication verifies the retained candidate and registry bytes against the signed receipt before uploading. It returns `bootstrap_required` instead of using a token fallback, requires provenance for automated uploads, repairs `latest` only after verification, and runs both public consumers before reporting success.
Publication path
export async function publishPublication(io, context) {
validateContext(context);
const { source } = context;
let { tag, registry } = await readState(io, context);
if (!tag)
throw new Error("signed SDK tag is missing; run SDK preparation first");
const candidate = await io.obtainCandidate(source, {
allowCreate: false,
bootstrapAllowed: false,
});
verifyCandidate(candidate, context);
verifyRegistry(registry, candidate, source);
let uploaded = false;
if (!registry.version) {
if (!registry.packageExists && !context.firstPublishOIDCReady) {
if (!candidate.receipt.bootstrap_allowed)
throw new Error("absent package is not an approved initial bootstrap");
return { status: "bootstrap_required", candidate, source };
}
let writeError;
try {
await io.publish(candidate);
} catch (error) {
writeError = error;
}
registry = await observe(
io,
() => io.readRegistry(source.record.sdk_version),
(value) => {
verifyRegistry(value, candidate, source);
return value.version !== null;
},
"npm publication",
writeError,
);
uploaded = true;
}
if (
!registry.version.provenance &&
(uploaded || !candidate.receipt.bootstrap_allowed)
)
throw new Error("npm upload has no required workflow provenance");
const origin = registry.version.provenance ? "oidc" : "manual_bootstrap";
await ensureLatest(io, context);
await io.verifyConsumers(candidate, origin);
return {
status: "verified",
origin,
sdk_version: source.record.sdk_version,
host_release: source.record.host_release,
};
}
Stable waits for SDK verification
.github/workflows/release.yml ↗Two new jobs run only for a non-dry-run Stable dispatch after the host release succeeds. `stable-complete` fails unless every publication including the SDK job succeeds, and contributor notices plus Discord announcements now depend on that gate.
Prepare job
prepare-plugin-sdk:
name: Prepare signed plugin SDK
needs: [prepare, publish-release]
if: >-
${{ !cancelled() && github.event_name == 'workflow_dispatch' &&
inputs.channel == 'stable' && !inputs.dry_run && !inputs.desktop_validation_only &&
needs.prepare.result == 'success' && needs.publish-release.result == 'success'
}}
runs-on: ubuntu-latest
timeout-minutes: 20
environment: release
permissions:
contents: write
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_GPG_FINGERPRINT: ${{ vars.RELEASE_GPG_FINGERPRINT }}
SDK_SCOPE_OWNERSHIP_CONFIRMED: ${{ vars.SDK_SCOPE_OWNERSHIP_CONFIRMED }}
SDK_FIRST_PUBLISH_OIDC_READY: ${{ vars.SDK_FIRST_PUBLISH_OIDC_READY }}
SDK_HOST_TAG: ${{ needs.prepare.outputs.tag }}
SDK_WORKFLOW_SHA: ${{ github.workflow_sha }}
Completion gate
stable-complete:
name: Verify Stable publication completion
needs: [prepare, publish-release, publish-npm, update-homebrew-tap, update-scoop-bucket, publish-plugin-sdk]
if: >-
${{ !cancelled() && github.event_name == 'workflow_dispatch' &&
inputs.channel == 'stable' && !inputs.dry_run && !inputs.desktop_validation_only }}
runs-on: ubuntu-latest
permissions: {}
steps:
- name: Require every Stable publication
env:
COMPLETE: ${{ needs.prepare.result == 'success' && needs.publish-release.result == 'success' && needs.publish-npm.result == 'success' && needs.update-homebrew-tap.result == 'success' && needs.update-scoop-bucket.result == 'success' && needs.publish-plugin-sdk.result == 'success' }}
run: test "$COMPLETE" = true