Skip to content
80 changes: 80 additions & 0 deletions .github/workflows/skill-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,35 @@ on:
required: false
type: string
default: latest
changelog:
description: Optional changelog text applied to every skill published by this run.
required: false
type: string
default: ""
categories:
description: Optional comma-separated category slugs applied to every skill published by this run.
required: false
type: string
default: ""
topics:
description: Optional comma-separated topics applied to every skill published by this run.
required: false
type: string
default: ""
clear_categories:
description: >-
Publish an empty category list, clearing the categories already set on every skill
published by this run. Cannot be combined with a non-empty categories input.
required: false
type: boolean
default: false
clear_topics:
description: >-
Publish an empty topic list, clearing the topics already set on every skill published
by this run. Cannot be combined with a non-empty topics input.
required: false
type: boolean
default: false
registry:
description: ClawHub registry URL.
required: false
Expand Down Expand Up @@ -175,6 +204,11 @@ jobs:
INPUT_DRY_RUN: ${{ inputs.dry_run }}
INPUT_OWNER: ${{ inputs.owner }}
INPUT_TAGS: ${{ inputs.tags }}
INPUT_CHANGELOG: ${{ inputs.changelog }}
INPUT_CATEGORIES: ${{ inputs.categories }}
INPUT_TOPICS: ${{ inputs.topics }}
INPUT_CLEAR_CATEGORIES: ${{ inputs.clear_categories }}
INPUT_CLEAR_TOPICS: ${{ inputs.clear_topics }}
INPUT_SITE: ${{ inputs.site }}
INPUT_REGISTRY: ${{ inputs.registry }}
INPUT_REF: ${{ inputs.ref }}
Expand All @@ -184,6 +218,7 @@ jobs:
python3 - <<'PY'
import json
import os
import shlex
import subprocess
import sys
from pathlib import Path
Expand All @@ -204,6 +239,15 @@ jobs:
def is_skill_folder(path):
return path.is_dir() and any((path / name).is_file() for name in ("SKILL.md", "skill.md"))

def quote_for_log(part):
# shlex.quote is shell quoting, not output escaping: it wraps a value holding a
# line break in single quotes and leaves the break itself intact. One newline in
# a caller's metadata would then open a second log line, which the runner reads
# as a ::workflow-command. json.dumps escapes every control character, so one
# publish stays one line however the caller fills changelog, categories or topics.
quoted = shlex.quote(part)
return quoted if quoted.isprintable() else json.dumps(part)

skill_path = os.environ["INPUT_SKILL_PATH"].strip()
root_input = os.environ["INPUT_ROOT"].strip() or "skills"
if skill_path:
Expand Down Expand Up @@ -233,6 +277,28 @@ jobs:
dry_run = os.environ["INPUT_DRY_RUN"] == "true"
owner = os.environ["INPUT_OWNER"].strip()
tags = os.environ["INPUT_TAGS"].strip()
# changelog is Markdown prose, not a structured value: leading indentation and
# trailing double spaces are meaningful there, and `skill publish --changelog`
# stores what it is given. It is forwarded verbatim; only the decision to forward
# at all looks past surrounding whitespace, so a blank input stays a no-op.
changelog = os.environ["INPUT_CHANGELOG"]
categories = os.environ["INPUT_CATEGORIES"].strip()
topics = os.environ["INPUT_TOPICS"].strip()
# A workflow_call string input cannot tell an omitted value from an explicitly empty
# one - both arrive as "". The CLI does distinguish them: --categories "" clears the
# stored slugs, while omitting the flag leaves them alone. These two booleans carry
# that difference across the workflow boundary.
clear_categories = os.environ["INPUT_CLEAR_CATEGORIES"] == "true"
clear_topics = os.environ["INPUT_CLEAR_TOPICS"] == "true"
for value, clearing, name in (
(categories, clear_categories, "categories"),
(topics, clear_topics, "topics"),
):
if value and clearing:
raise SystemExit(
f"clear_{name} cannot be combined with a non-empty {name} input; "
f"got {name}={value!r}."
)

results = {"wouldPublish": [], "published": [], "alreadySynced": [], "skipped": [], "failed": []}
status_keys = {
Expand Down Expand Up @@ -260,9 +326,23 @@ jobs:
command += ["--owner", owner]
if tags:
command += ["--tags", tags]
if changelog.strip():
command += ["--changelog", changelog]
if categories:
command += ["--categories", categories]
elif clear_categories:
command += ["--categories", ""]
if topics:
command += ["--topics", topics]
elif clear_topics:
command += ["--topics", ""]
if source_ref:
command += ["--source-ref", source_ref]

# Log-only: the list above is what actually runs, so the quoting here never
# reaches a shell. Without it a forwarded flag is invisible in the run logs.
print(f"Resolved publish command: {' '.join(quote_for_log(part) for part in command)}", flush=True)

completed = subprocess.run(command, cwd=workspace, capture_output=True, text=True)
if completed.returncode != 0:
message = completed.stderr.strip() or completed.stdout.strip() or f"exit {completed.returncode}"
Expand Down
39 changes: 37 additions & 2 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,12 @@ same automatic patch-version behavior.
Set `dry_run: true` to preview without a token. Real publishes require the
`clawhub_token` secret.

The workflow has no `categories` or `topics` input, so skills first published
through it are stored as `other`, the same as `sync`.
Optional `changelog`, `categories`, and `topics` inputs map to the matching
`skill publish` flags, and `clear_categories` / `clear_topics` remove metadata a
skill already carries. A skill first published without `categories` is stored as
`other`, the same as `sync`. Because catalog metadata applies to every skill the
run publishes and suspends the unchanged-skill skip described above, see the
notes under [GitHub Actions](#github-actions-1) before setting it catalog-wide.

### `sync`

Expand Down Expand Up @@ -312,6 +316,9 @@ jobs:
with:
owner: nvidia
dry_run: false
changelog: "Describe the changes in this release."
categories: "automation"
topics: "code-review,linting"
secrets:
clawhub_token: ${{ secrets.CLAWHUB_TOKEN }}
```
Expand All @@ -321,6 +328,34 @@ Notes:
- `root` defaults to `skills` for catalog repos.
- Pass `skill_path: skills/review-helper` to process one skill folder.
- `owner` maps to the CLI `--owner` flag; omit it to publish as the authenticated user.
- `changelog`, `categories`, and `topics` map to the matching `skill publish`
flags and are optional. Omitting them leaves the published metadata untouched.
- Like `tags`, these three apply to **every** skill the run publishes. Pass
`skill_path` when the values describe one skill rather than the whole catalog.
- **`categories` and `topics` suspend the "skips unchanged skills" behavior
described above.** The CLI treats supplied catalog metadata as authoritative
and bypasses its already-published short-circuit, so a catalog-wide run
publishes a **new patch version of every selected skill**, including skills
whose files did not change. The `clear_categories` and `clear_topics` flags
count as supplied metadata and do the same. `changelog` does not: a run that
passes only `changelog` still reports unchanged skills as `alreadySynced`.
Use `skill_path` to keep a metadata edit from releasing a whole catalog.
- `changelog` reaches the CLI verbatim, the way `skill publish --changelog`
stores it, so Markdown indentation and trailing hard-break spaces survive the
workflow; a value that is only whitespace counts as omitted. `categories` and
`topics` are trimmed instead, being slug lists.
- Categories and topics are comma-separated and validated server-side, so an
unknown category slug or a topic over the per-skill limit fails the publish
after the run has already built and validated the skill.
- To remove categories or topics already on a skill, set `clear_categories: true`
or `clear_topics: true`. A workflow input cannot distinguish `categories: ""`
from an omitted `categories`, so the empty string keeps meaning "leave them
alone" and the boolean is what sends the CLI's `--categories ""`. Setting a
non-empty value and its `clear_` flag together fails the run rather than
picking one silently. `changelog` has no such flag: the CLI already reads an
omitted `--changelog` as empty.
- The run logs echo the resolved `skill publish` command for each target, so a
forwarded flag is visible in CI output. Keep the values non-sensitive.
- V1 skill publishing uses `clawhub_token`; GitHub OIDC trusted publishing is package-only for now.

### `delete <skill>`
Expand Down
16 changes: 11 additions & 5 deletions docs/publishing.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,17 @@ jobs:

Use `dry_run: true` to preview new and changed skills without publishing.

The workflow has no `categories` or `topics` input. It calls `skill publish`
with `--owner` and `--tags` only, so skills first published through it are
stored as `other`, the same as [`clawhub sync`](./cli.md#sync). Set catalog
metadata on those skills from the skill's settings page, or publish once from
the CLI with `--categories`.
The workflow forwards optional `changelog`, `categories`, and `topics` inputs to
`skill publish`, plus `clear_categories` and `clear_topics` for removing metadata
a skill already carries. A skill first published without `categories` is stored
as `other`, the same as [`clawhub sync`](./cli.md#sync); you can also set catalog
metadata later from the skill's settings page.

Like `tags`, `categories` and `topics` apply to **every** skill the run
publishes, and supplying them suspends the unchanged-skill skip — the run
releases a new patch version of each selected skill, including skills whose files
did not change. Pass `skill_path` to bound that to one skill. See the
[workflow notes](./cli.md#github-actions-1) for the full behavior.

## Plugins

Expand Down
24 changes: 24 additions & 0 deletions packages/clawhub/src/cli/commands/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,30 @@ describe("cmdPublish", () => {
}
});

it("still skips an unchanged skill when only a changelog is supplied", async () => {
const workdir = await makeTmpWorkdir();
try {
const folder = join(workdir, "changelog-only");
await mkdir(folder, { recursive: true });
await writeFile(join(folder, "SKILL.md"), "# Skill\n", "utf8");
httpMocks.apiRequest.mockResolvedValueOnce({
match: { version: "1.2.3" },
latestVersion: { version: "1.2.3" },
});

const result = await cmdPublish(makeOpts(workdir), "changelog-only", {
changelog: "Describe the changes in this release.",
});

// changelog is not part of hasExplicitCatalogMetadata, so unlike categories and
// topics it does not turn a catalog-wide run into a release of every skill.
expect(result).toMatchObject({ status: "unchanged", version: "1.2.3" });
expect(httpMocks.apiRequestForm).not.toHaveBeenCalled();
} finally {
await rm(workdir, { recursive: true, force: true });
}
});

it("publishes explicit catalog metadata when the local skill content is unchanged", async () => {
const workdir = await makeTmpWorkdir();
try {
Expand Down
106 changes: 106 additions & 0 deletions src/__tests__/skill-publish-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,112 @@ describe("skill publish workflow", () => {
expect(workflow).not.toContain("--bump");
});

it("forwards optional catalog metadata through the argument list", () => {
const workflow = readFileSync(resolve(".github/workflows/skill-publish.yml"), "utf8");
const parsed = parseYaml(workflow) as {
on: {
workflow_call: {
inputs: Record<string, { type: string; required: boolean; default: string }>;
};
};
};
const inputs = parsed.on.workflow_call.inputs;

for (const name of ["changelog", "categories", "topics"] as const) {
const envName = `INPUT_${name.toUpperCase()}`;

expect(inputs[name]).toMatchObject({ type: "string", required: false, default: "" });
expect(workflow).toContain(` ${envName}: \${{ inputs.${name} }}`);
}

// categories and topics are slug lists, where surrounding whitespace is noise.
for (const name of ["categories", "topics"] as const) {
expect(workflow).toContain(
` ${name} = os.environ["INPUT_${name.toUpperCase()}"].strip()`,
);
expect(workflow).toContain(
` if ${name}:\n command += ["--${name}", ${name}]`,
);
}
});

it("forwards changelog Markdown exactly as the caller wrote it", () => {
const workflow = readFileSync(resolve(".github/workflows/skill-publish.yml"), "utf8");

// `skill publish --changelog` stores its argument untouched, and in Markdown both
// leading indentation and a trailing double space carry meaning, so the workflow
// must not trim the value on its way to that same CLI.
expect(workflow).toContain(' changelog = os.environ["INPUT_CHANGELOG"]\n');
expect(workflow).not.toContain('os.environ["INPUT_CHANGELOG"].strip()');
// Only the decision to forward looks past whitespace: a blank input stays a no-op.
expect(workflow).toContain(
' if changelog.strip():\n command += ["--changelog", changelog]',
);
});

it("can clear catalog metadata the CLI already treats as an explicit empty value", () => {
const workflow = readFileSync(resolve(".github/workflows/skill-publish.yml"), "utf8");
const parsed = parseYaml(workflow) as {
on: {
workflow_call: {
inputs: Record<string, { type: string; required: boolean; default: boolean }>;
};
};
};
const inputs = parsed.on.workflow_call.inputs;

for (const name of ["categories", "topics"] as const) {
const clearName = `clear_${name}` as const;

// Omitting the input must stay the no-op it is today, so the clear is its own signal.
expect(inputs[clearName]).toMatchObject({
type: "boolean",
required: false,
default: false,
});
expect(workflow).toContain(
` INPUT_CLEAR_${name.toUpperCase()}: \${{ inputs.${clearName} }}`,
);
expect(workflow).toContain(
` ${clearName} = os.environ["INPUT_CLEAR_${name.toUpperCase()}"] == "true"`,
);
// A non-empty value wins nothing silently - the two are mutually exclusive.
expect(workflow).toContain(` (${name}, ${clearName}, "${name}"),`);
expect(workflow).toContain(
` elif ${clearName}:\n command += ["--${name}", ""]`,
);
}

expect(workflow).toContain("if value and clearing:\n raise SystemExit(");
// changelog has no clear counterpart: the CLI reads an omitted --changelog as "".
expect(inputs.clear_changelog).toBeUndefined();
});

it("logs the resolved command without routing it through a shell", () => {
const workflow = readFileSync(resolve(".github/workflows/skill-publish.yml"), "utf8");

expect(workflow).toContain(
"print(f\"Resolved publish command: {' '.join(quote_for_log(part) for part in command)}\", flush=True)",
);
expect(workflow).toContain("completed = subprocess.run(command, cwd=workspace");
expect(workflow).not.toContain("shell=True");
});

it("keeps a metadata value carrying CR or LF from opening a second log line", () => {
const workflow = readFileSync(resolve(".github/workflows/skill-publish.yml"), "utf8");

// shlex.quote is shell quoting, not output escaping: it wraps a value in single quotes
// and leaves an embedded line break intact, so a changelog, categories or topics value
// holding "\n::error::" would reach the runner as its own ::workflow-command line.
expect(workflow).toContain(
[
" quoted = shlex.quote(part)",
" return quoted if quoted.isprintable() else json.dumps(part)",
].join("\n"),
);
expect(workflow).not.toContain("' '.join(shlex.quote(part) for part in command)");
});

it("preserves publish output when a target fails", () => {
const workflow = readFileSync(resolve(".github/workflows/skill-publish.yml"), "utf8");

Expand Down
Loading