PR #4534
Sections
Review

feat!: publish versioned plugin SDKs with compatibility checks ↗

main ← feature/plan-work-for-issue-46a ? files +? −? PR #4534 ↗

Plugin authors get a versioned Go module and npm package with one shared compatibility record, a read-only checker, and a Stable release path that retains the exact tested bytes, signs an SDK tag, publishes through npm OIDC, and verifies both public consumers before the release can complete.

Why this PR exists

Plugin authors had to import SDK code from a Kandev backend checkout. The Go SDK lived in `apps/backend/pkg/pluginsdk`, the frontend package was the private `@kandev/plugin-sdk`, and neither shipped a compatibility record. A clean external build therefore had no pinned artifact, no declared host floor, and no way to prove the SDK version it used matched the host it ran on. Release automation only published the host, so SDK bytes could drift from the bytes that were tested.

Who is affected: External plugin authors working in Go, TypeScript, or both; Kandev release maintainers; and backend maintainers who build in-tree plugins.

Result: The Go module `github.com/kdlbs/kandev/pluginsdk` and the npm package `@kdlbs/plugin-sdk` share an independent version starting at 0.1.0. One canonical record declares the API versions and the SDK-wide host floor, `kandev-sdk-check` verifies the SDK actually installed in a plugin project, and a Stable release publishes only a retained, signed candidate after both public consumers install and verify it. Stable completion, contributor notices, and Discord announcements now require that verification.

What it does

Impact at a glance

Breaking changes

Affected users / triggerBeforeAfterRequired actionSource
External Go plugin authorsPlugins imported `github.com/kdlbs/kandev/apps/backend/pkg/pluginsdk` from a Kandev source checkout.Plugins require the published module `github.com/kdlbs/kandev/pluginsdk`; only the in-tree backend gets a repository-local replace.Change imports, require the published module version, and remove any replace that points at a Kandev checkout.apps/backend/pkg/pluginsdk/serve.go
Frontend and combined plugin authorsThe frontend contract package was named `@kandev/plugin-sdk` and was not published as a standalone artifact.It is published as `@kdlbs/plugin-sdk` 0.1.0 with `sdk-release.json`, README, and license in the tarball.Change the dependency name, install it from the public registry, and update imports.apps/packages/plugin-sdk/package.json
Plugin authors choosing a compatible Kandev hostCompatibility was implicit in the host version; there was no published record and no checked minimum.One canonical `sdk-release.json` declares the shared SDK version, API versions 1 and 2, and an SDK-wide minimum host version stamped at Stable preparation.Read `min_kandev_version` from the installed SDK and declare at least that value in `manifest.yaml`.pluginsdk/sdk-release.json
Kandev release maintainersA Stable release completed after GitHub Release, npm, Homebrew, and Scoop.`stable-complete`, contributor notices, and Discord announcements also require the signed SDK tag and both public SDK consumers to verify.Confirm ownership of the `@kdlbs` scope and package, configure the trusted publisher, and set the release environment variables before the first Stable run..github/workflows/release.yml

Kandev plugin interfaces

API / interfaceChangeBefore / after contractCompatibility / actionSource
Go plugin SDK modulechangedBefore: a package inside the backend module at `apps/backend/pkg/pluginsdk`. After: a standalone `github.com/kdlbs/kandev/pluginsdk` module with its own `go.mod`, canonical protobuf, and checker command.breaking: the import path and module requirement change; callers must require the published version and drop the source-checkout replace.pluginsdk/go.mod
Frontend plugin SDK packagechangedBefore: the private `@kandev/plugin-sdk` package. After: `@kdlbs/plugin-sdk` 0.1.0 with source exports and an exported `./sdk-release.json`.breaking: the package name changes; callers must update the dependency and imports.apps/packages/plugin-sdk/package.json
SDK release recordaddedNew `pluginsdk/sdk-release.json` and a byte-identical npm copy declare `schema_version`, `sdk_version`, `host_release`, `min_kandev_version`, `api_versions`, and `change_kind`.compatible: additive metadata; the host still validates the plugin manifest with its own constants.pluginsdk/sdk-release.json
kandev-sdk-checkaddedNew read-only `pluginsdk/cmd/kandev-sdk-check` resolves the selected Go module and installed npm package, then checks the manifest API version and minimum host.compatible: new author tooling; it does not modify the plugin project.pluginsdk/cmd/kandev-sdk-check/main.go

Feature flags and rollout

No runtime feature flag gates the SDK packages or the Stable release jobs. The SDK version is pinned in `pluginsdk/sdk-release.json`; release behavior is controlled by workflow inputs and protected environment variables, not by a runtime toggle.

No UX changes with flags off

The feature changes no application UI and ships no runtime toggle, so there is no flag-off screen or behavior to compare. The checker runs only when an author invokes it, and the release jobs run only on a non-dry-run Stable dispatch.

Architecture, end to end

The published Go and npm artifacts are generated from the same checked-in record. Authors run the read-only checker against whatever versions their project actually installed. During a Stable release, the workflow reuses the original tested source, retains a signed candidate, creates the SDK tag at the original host commit, publishes through npm OIDC, and verifies clean public installs before the release is marked complete.

flowchart LR
  Go["pluginsdk Go module<br/>+ release record"] --> Check["kandev-sdk-check<br/>in plugin project"]
  Npm["@kdlbs/plugin-sdk<br/>npm tarball"] --> Check
  Host["Stable host release"] --> Prepare["prepare-plugin-sdk<br/>retain candidate + sign tag"]
  Go --> Prepare
  Npm --> Prepare
  Prepare --> Publish["publish-plugin-sdk<br/>OIDC + provenance"]
  Publish --> Verify["fresh public Go + npm consumers"]
  Verify --> Complete["stable-complete"]
  Complete --> Notices["contributor notices<br/>Discord announcement"]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

Published packages
Author compatibility check
Stable release
Go SDK becomes its own modulepluginsdk/go.mod ↗
module github.com/kdlbs/kandev/pluginsdk
Click for details →

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 publishedapps/packages/plugin-sdk/package.json ↗
@kdlbs/plugin-sdk at version 0.1.0
Click for details →

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 recordpluginsdk/sdk-release.json ↗
0.1.0, APIs 1-2, floor stamped at release
Click for details →

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 validationpluginsdk/internal/compatibility/release.go ↗
exact Stable versions, bounded reads, closed change kinds
Click for details →

`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 SDKspluginsdk/internal/compatibility/resolve.go ↗
go list with GOWORK off; local substitutions rejected
Click for details →

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"))
}
manifest plus installed SDKs, no writes
Click for details →

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 releasescripts/release/plugin-sdk.mjs ↗
shared version rules and pending record stamping
Click for details →

`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, verifyscripts/release/sdk-publication-state.mjs ↗
idempotent state machine with bounded rereads
Click for details →

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 ↗
prepare then publish then stable-complete
Click for details →

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
Read the changes as a list

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

Risk

7 / 10 High
1 low5 medium10 high

Why this score

  • Public npm versions and signed Git tags are immutable, so a wrong candidate or identity conflict cannot be repaired by re-publishing the same version.
  • The first publish depends on npm scope ownership, a trusted publisher, and OIDC; a misconfiguration deliberately blocks the whole Stable completion instead of falling back to a token.
  • The Go extraction moves a large in-tree package and the canonical protobuf, touching backend imports and build paths as well as external consumers.
  • The release gate can now fail Stable after every host channel succeeded, which changes maintainer recovery expectations.
  • Local-substitution rejection and lockfile parsing are security-sensitive: a false acceptance would let a plugin pass the checker while running code that was never published.

Trade-offs and review notes

Where to look first

  1. In `scripts/release/plugin-sdk.mjs`, check the reuse branch and `validateProgression`: a same-version release is allowed only when no contract input changed and the record is unchanged.
  2. In `scripts/release/sdk-publication-state.mjs`, follow the `bootstrap_required` and provenance branches to confirm an absent package never publishes without a signed receipt and approved bootstrap.
  3. In `.github/workflows/release.yml`, confirm `prepare-plugin-sdk` and `publish-plugin-sdk` require the host release, use the protected environment, and that `stable-complete` includes both the SDK result and every existing channel.
  4. In `pluginsdk/internal/compatibility/resolve.go`, verify that Go replacements and frontend links outside the installed package or pnpm store are rejected before the record is read.
  5. In `pluginsdk/cmd/kandev-sdk-check/main.go`, check the flag contract for frontend-only projects and that failures exit non-zero without writing to the project.