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
5 changes: 5 additions & 0 deletions .changeset/imported-set-license-draft.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@selftune/desktop": patch
---

Fix license draft review for imported Skill Sets. Show draft errors in the dialog and save approved license terms as a new Set revision without changing the original package.
5 changes: 5 additions & 0 deletions .changeset/publication-preflight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@selftune/desktop": patch
---

Check the packed npm candidate and its validated dependency inventory before merge using the same SBOM generator as publication.
11 changes: 11 additions & 0 deletions .github/release-notes.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
"generatedFrom": "sites/docs/changelog.mdx",
"docsUrl": "https://docs.selftune.dev/changelog",
"entries": [
{
"label": "2026-09-03",
"description": "Skill Set license draft recovery",
"tags": ["Dashboard", "OSS"],
"rss": {
"title": null,
"description": null
},
"bullets": [],
"versionRange": null
},
{
"label": "2026-09-02",
"description": "Signed macOS runtime startup validation",
Expand Down
70 changes: 70 additions & 0 deletions .github/scripts/generate-sbom.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
: "${TARBALL_PATH:?Set TARBALL_PATH to the packed candidate}"
: "${SBOM_TOOLCHAIN_DIR:?Set SBOM_TOOLCHAIN_DIR to the installed locked toolchain}"
: "${SBOM_OUTPUT:?Set SBOM_OUTPUT to an absolute output path}"
TARBALL_PATH="$(cd "$(dirname "$TARBALL_PATH")" && pwd)/$(basename "$TARBALL_PATH")"
SBOM_TOOLCHAIN_DIR="$(cd "$SBOM_TOOLCHAIN_DIR" && pwd)"
SBOM_OUTPUT="$(cd "$(dirname "$SBOM_OUTPUT")" && pwd)/$(basename "$SBOM_OUTPUT")"
SBOM_TMP="$(mktemp -d)"
trap 'rm -rf "$SBOM_TMP"' EXIT
tar -xzf "$TARBALL_PATH" -C "$SBOM_TMP"
cd "$SBOM_TMP/package"
node -e "const fs=require('node:fs'); const pkg=JSON.parse(fs.readFileSync('package.json','utf8')); delete pkg.workspaces; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');"

# The SBOM is generated from the packed candidate before publishing to npm so
# an unresolvable bundled dependency tree fails the release while npm,
# the GitHub release, and the self-host image are still in sync.
npm install --package-lock-only --ignore-scripts --omit=dev --no-audit --no-fund >/dev/null

SBOM_LOG="$SBOM_TMP/cyclonedx.log"
if ! node "$SBOM_TOOLCHAIN_DIR/node_modules/@cyclonedx/cyclonedx-npm/bin/cyclonedx-npm-cli.js" \
--package-lock-only \
--omit dev \
--no-workspaces \
--validate \
-v -v \
--output-file "$SBOM_OUTPUT" \
>"$SBOM_LOG" 2>&1; then
cat "$SBOM_LOG" >&2
exit 1
fi
cat "$SBOM_LOG"
if grep --fixed-strings --quiet "skipped validating BOM" "$SBOM_LOG"; then
echo "CycloneDX skipped SBOM validation; refusing to publish." >&2
exit 1
fi
if ! grep --fixed-strings --line-regexp --quiet "INFO | BOM result appears valid" "$SBOM_LOG"; then
echo "CycloneDX did not confirm successful SBOM validation; refusing to publish." >&2
exit 1
fi

node - "$SBOM_TMP/package/package.json" "$SBOM_OUTPUT" <<'NODE'
const fs = require("node:fs");
const [, , packagePath, bomPath] = process.argv;
const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8"));
const bom = JSON.parse(fs.readFileSync(bomPath, "utf8"));
const rootRef = bom.metadata?.component?.["bom-ref"];
const root = bom.dependencies?.find((entry) => entry.ref === rootRef);
if (!root) throw new Error(`Missing SBOM root dependency node ${rootRef}`);
const namesByRef = new Map(
(bom.components ?? []).map((component) => [
component["bom-ref"],
`${component.group ? `${component.group}/` : ""}${component.name}`,
]),
);
const actual = (root.dependsOn ?? [])
.map((ref) => {
const name = namesByRef.get(ref);
if (!name) throw new Error(`Unresolved SBOM root dependency ${ref}`);
return name;
})
.sort();
const expected = Object.keys(pkg.dependencies ?? {}).sort();
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`Incomplete SBOM root dependencies: expected ${expected.length} [${expected.join(", ")}], got ${actual.length} [${actual.join(", ")}]`,
);
}
console.log(`SBOM root dependency coverage: ${actual.length}/${expected.length}`);
NODE
22 changes: 22 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
build-dashboard:
needs: [lint]
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
steps:
Expand All @@ -48,6 +49,27 @@ jobs:
run: bunx tsc --noEmit
- run: bun run build:dashboard

- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version: "24"
- name: Pin npm to the release toolchain
run: npm install --global npm@11
- name: Pack and smoke the publication candidate
shell: bash
run: |
set -euo pipefail
bun run pack:release >/dev/null
tarball="$(node -p "const pkg=require('./package.json'); pkg.name.replace(/^@/,'').replace(/\//g,'-') + '-' + pkg.version + '.tgz'")"
bun run scripts/smoke-packed-package.ts "$tarball"
echo "TARBALL_PATH=$tarball" >> "$GITHUB_ENV"
- name: Install locked SBOM toolchain
run: npm ci --prefix .github/sbom-toolchain --ignore-scripts --omit=dev --no-audit --no-fund
- name: Generate SBOM
env:
SBOM_TOOLCHAIN_DIR: ${{ github.workspace }}/.github/sbom-toolchain
SBOM_OUTPUT: ${{ github.workspace }}/sbom.cdx.json
run: bash .github/scripts/generate-sbom.sh

test:
needs: [lint]
runs-on: ubuntu-latest
Expand Down
70 changes: 6 additions & 64 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,9 @@ jobs:
with:
persist-credentials: false
ref: ${{ github.sha }}
sparse-checkout: .github/sbom-toolchain
sparse-checkout: |
.github/sbom-toolchain
.github/scripts
path: .release-workflow

- name: Install locked SBOM toolchain
Expand All @@ -381,70 +383,10 @@ jobs:
shell: bash
env:
TARBALL_PATH: ${{ steps.pack.outputs.tarball }}
SBOM_TOOLCHAIN_DIR: ${{ github.workspace }}/.release-workflow/.github/sbom-toolchain
SBOM_OUTPUT: ${{ github.workspace }}/sbom.cdx.json
run: |
set -euo pipefail
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
tar -xzf "$TARBALL_PATH" -C "$TMPDIR"
cd "$TMPDIR/package"
node -e "const fs=require('node:fs'); const pkg=JSON.parse(fs.readFileSync('package.json','utf8')); delete pkg.workspaces; fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');"

# The SBOM is generated from the packed candidate before publishing to npm so
# an unresolvable bundled dependency tree fails the release while npm,
# the GitHub release, and the self-host image are still in sync.
npm install --package-lock-only --ignore-scripts --omit=dev --no-audit --no-fund >/dev/null

SBOM_LOG="$TMPDIR/cyclonedx.log"
if ! node "$GITHUB_WORKSPACE/.release-workflow/.github/sbom-toolchain/node_modules/@cyclonedx/cyclonedx-npm/bin/cyclonedx-npm-cli.js" \
--package-lock-only \
--omit dev \
--no-workspaces \
--validate \
-v -v \
--output-file "$GITHUB_WORKSPACE/sbom.cdx.json" \
>"$SBOM_LOG" 2>&1; then
cat "$SBOM_LOG" >&2
exit 1
fi
cat "$SBOM_LOG"
if grep --fixed-strings --quiet "skipped validating BOM" "$SBOM_LOG"; then
echo "CycloneDX skipped SBOM validation; refusing to publish." >&2
exit 1
fi
if ! grep --fixed-strings --line-regexp --quiet "INFO | BOM result appears valid" "$SBOM_LOG"; then
echo "CycloneDX did not confirm successful SBOM validation; refusing to publish." >&2
exit 1
fi

node - "$TMPDIR/package/package.json" "$GITHUB_WORKSPACE/sbom.cdx.json" <<'NODE'
const fs = require("node:fs");
const [, , packagePath, bomPath] = process.argv;
const pkg = JSON.parse(fs.readFileSync(packagePath, "utf8"));
const bom = JSON.parse(fs.readFileSync(bomPath, "utf8"));
const rootRef = bom.metadata?.component?.["bom-ref"];
const root = bom.dependencies?.find((entry) => entry.ref === rootRef);
if (!root) throw new Error(`Missing SBOM root dependency node ${rootRef}`);
const namesByRef = new Map(
(bom.components ?? []).map((component) => [
component["bom-ref"],
`${component.group ? `${component.group}/` : ""}${component.name}`,
]),
);
const actual = (root.dependsOn ?? [])
.map((ref) => {
const name = namesByRef.get(ref);
if (!name) throw new Error(`Unresolved SBOM root dependency ${ref}`);
return name;
})
.sort();
const expected = Object.keys(pkg.dependencies ?? {}).sort();
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(
`Incomplete SBOM root dependencies: expected ${expected.length} [${expected.join(", ")}], got ${actual.length} [${actual.join(", ")}]`,
);
}
console.log(`SBOM root dependency coverage: ${actual.length}/${expected.length}`);
NODE
bash "$GITHUB_WORKSPACE/.release-workflow/.github/scripts/generate-sbom.sh"

- name: Publish npm candidate after artifact gates
shell: bash
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@selftune/desktop",
"version": "0.4.8",
"version": "0.4.9",
"private": true,
"description": "Native desktop control plane for SelfTune skill observability and improvement",
"homepage": "https://selftune.dev",
Expand Down
10 changes: 9 additions & 1 deletion apps/local-dashboard/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,16 @@ export interface LicenseDraftPreviewResponse {

function licenseDraftRequest(
path: "/api/v2/library/license/preview" | "/api/v2/library/license/apply",
input: { skillId: string; terms: LicenseDraftTermsInput; previewId?: string },
input: {
skillId: string;
skillSetId?: string;
terms: LicenseDraftTermsInput;
previewId?: string;
},
): Promise<LicenseDraftPreviewResponse> {
return portfolioRequest(path, {
skill_id: input.skillId,
...(input.skillSetId ? { set_id: input.skillSetId } : {}),
...(input.previewId ? { preview_id: input.previewId } : {}),
terms: {
copyright_holder: input.terms.copyrightHolder,
Expand All @@ -262,13 +268,15 @@ function licenseDraftRequest(

export function previewLibrarySkillLicense(input: {
skillId: string;
skillSetId?: string;
terms: LicenseDraftTermsInput;
}): Promise<LicenseDraftPreviewResponse> {
return licenseDraftRequest("/api/v2/library/license/preview", input);
}

export function applyLibrarySkillLicense(input: {
skillId: string;
skillSetId?: string;
previewId: string;
terms: LicenseDraftTermsInput;
}): Promise<LicenseDraftPreviewResponse> {
Expand Down
68 changes: 44 additions & 24 deletions apps/local/src/dashboard-operations.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
previewSkillSetLicenseDraft,
applySkillSetLicenseDraft,
} from "@selftune/runtime/skill-set-license-draft";
import { join, resolve } from "node:path";
import type { Database } from "bun:sqlite";

Expand Down Expand Up @@ -667,11 +671,13 @@ export class DashboardOperations extends Context.Service<
readonly previewLicenseDraft: (
skillId: string,
terms: LicenseDraftTerms,
skillSetId?: string,
) => Effect.Effect<LicenseDraftPreview, DashboardOperationError>;
readonly applyLicenseDraft: (
skillId: string,
previewId: string,
terms: LicenseDraftTerms,
skillSetId?: string,
) => Effect.Effect<LicenseDraftPreview, DashboardOperationError>;
readonly remoteLibraryShare: (
action: RemoteLibraryShareAction,
Expand Down Expand Up @@ -1493,30 +1499,44 @@ export function makeDashboardOperationsLayer(options: DashboardOperationOverride
},
),
),
previewLicenseDraft: (skillId, terms) =>
Effect.flatMap(libraryReport.read, (snapshot) =>
attempt("library.license.preview", () => {
const skill = snapshot.skills.find((candidate) => candidate.skillId === skillId);
const location =
skill?.locations.find((candidate) => candidate.active) ?? skill?.locations[0];
if (!location) throw new Error("Refresh the Library and select this skill again.");
return previewLocalLicenseDraft(location.skillPath, terms);
}),
),
applyLicenseDraft: (skillId, previewId, terms) =>
Effect.flatMap(libraryReport.read, (snapshot) =>
invalidatingAttempt("library.license.apply", () => {
const skill = snapshot.skills.find((candidate) => candidate.skillId === skillId);
const location =
skill?.locations.find((candidate) => candidate.active) ?? skill?.locations[0];
if (!location) throw new Error("Refresh the Library and select this skill again.");
return applyLocalLicenseDraft({
skillPath: location.skillPath,
previewId,
terms,
});
}),
),
previewLicenseDraft: (skillId, terms, skillSetId) =>
skillSetId !== undefined
? attempt("library.license.preview", () =>
previewSkillSetLicenseDraft(skillSetId, skillId, terms, {
configRoot: options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR,
}),
)
: Effect.flatMap(libraryReport.read, (snapshot) =>
attempt("library.license.preview", () => {
const skill = snapshot.skills.find((candidate) => candidate.skillId === skillId);
const location =
skill?.locations.find((candidate) => candidate.active) ?? skill?.locations[0];
if (!location)
throw new Error("Refresh the Library and select this skill again.");
return previewLocalLicenseDraft(location.packagePath, terms);
}),
),
applyLicenseDraft: (skillId, previewId, terms, skillSetId) =>
skillSetId !== undefined
? invalidatingAttempt("library.license.apply", () =>
applySkillSetLicenseDraft(skillSetId, skillId, previewId, terms, {
configRoot: options.skillSetConfigRoot ?? SELFTUNE_CONFIG_DIR,
}),
)
: Effect.flatMap(libraryReport.read, (snapshot) =>
invalidatingAttempt("library.license.apply", () => {
const skill = snapshot.skills.find((candidate) => candidate.skillId === skillId);
const location =
skill?.locations.find((candidate) => candidate.active) ?? skill?.locations[0];
if (!location)
throw new Error("Refresh the Library and select this skill again.");
return applyLocalLicenseDraft({
skillPath: location.packagePath,
previewId,
terms,
});
}),
),
remoteLibraryShare: (action, input) =>
attempt(`remote_library.share.${action}`, () => runRemoteLibraryShare(action, input)),
workspace: (action, input) =>
Expand Down
9 changes: 8 additions & 1 deletion apps/local/src/routes/library-transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,12 @@ const LicenseDraftTermsBody = Schema.Struct({
year: Schema.Number,
});
const PreviewLicenseBody = Schema.Struct({
set_id: Schema.optional(Schema.String),
skill_id: Schema.String,
terms: LicenseDraftTermsBody,
});
const ApplyLicenseBody = Schema.Struct({
set_id: Schema.optional(Schema.String),
skill_id: Schema.String,
preview_id: Schema.String,
terms: LicenseDraftTermsBody,
Expand Down Expand Up @@ -141,7 +143,11 @@ export const routeLibraryTransfer = Effect.fn("DashboardApplication.routeLibrary
);
return {
response: Response.json(
yield* operations.previewLicenseDraft(body.skill_id, licenseTerms(body.terms)),
yield* operations.previewLicenseDraft(
body.skill_id,
licenseTerms(body.terms),
body.set_id,
),
{ headers: dashboardCorsHeaders() },
),
installed: false,
Expand All @@ -160,6 +166,7 @@ export const routeLibraryTransfer = Effect.fn("DashboardApplication.routeLibrary
body.skill_id,
body.preview_id,
licenseTerms(body.terms),
body.set_id,
),
{ headers: dashboardCorsHeaders() },
),
Expand Down
Loading
Loading