feat: forward skill catalog metadata in the reusable publish workflow - #3414
feat: forward skill catalog metadata in the reusable publish workflow#3414Yigtwxx wants to merge 8 commits into
Conversation
|
@Yigtwxx is attempting to deploy a commit to the OpenClaw Foundation Team on Vercel. A member of the Team first needs to authorize it. |
|
Codex review: needs maintainer review before merge. Reviewed August 15, 2026, 6:58 PM ET / 22:58 UTC. ClawSweeper reviewWhat this changesAdds optional changelog, category, topic, and per-field metadata-clearing inputs to the reusable skill publishing workflow, with documentation and regression coverage. Merge readinessKeep open: current main and v0.23.3 still lack the skill-workflow metadata inputs, while this PR has an additive, source-aligned implementation with real reusable-workflow proof and green required checks. Likely related people: Patrick Erichsen (original skill workflow/CLI) and Sergio Peschiera (package-workflow counterpart). Priority: P2 Review scores
Verification
How this fits togetherClawHub’s reusable GitHub Actions workflow converts catalog-repository inputs into skill-publish CLI arguments. The CLI validates the metadata and creates or updates published skill versions in the catalog. flowchart LR
A[Catalog repository] --> B[Reusable publish workflow]
B --> C[Input validation]
C --> D[Skill publish CLI]
D --> E[Catalog metadata]
E --> F[Published skills]
Before merge
Agent review detailsSecurityNone. Review metrics
Merge-risk optionsMaintainer options:
Technical reviewBest possible solution: Land the additive input-to-CLI mapping with blank defaults, explicit per-field clear flags, and the documented catalog-wide versioning behavior. Do we have a high-confidence way to reproduce the issue? Not applicable: this PR adds an optional reusable-workflow capability rather than repairing a reported runtime failure. Is this the best way to solve the issue? Yes: it forwards the established CLI contract through the existing workflow argument-list path, preserving omitted-input behavior and using the merged package workflow as an adjacent pattern. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 4117154ecac4. LabelsLabel justifications:
EvidenceWhat I checked:
Likely related people:
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
HistoryReview history (127 earlier review cycles; latest 8 shown)
|
|
Fixed in The finding holds, and the CLI side is more deliberate than my first version assumed:
Real behavior, exact revision All three shapes the CLI distinguishes — absent, valued, explicitly empty — come out of a The guard has its own dispatch, It is a separate run because Before spending the Actions runs I extracted the workflow's input-parsing and One note for #3074, which is the same change on the package side: it forwards |
|
Documented in The finding is right and the contrast is sharper than the note said, because One thing the finding did not mention, which I have documented too: the clear flags The new paragraph sits in the notes next to The republish half was already pinned by Verification, both against this branch's own merge base
I did not re-run the Actions proof for this commit: |
pacocartones
left a comment
There was a problem hiding this comment.
Verify pass on ba6aa78 — no blocking findings.
Checked the claims against main (82313c2b) rather than the description:
- The CLI side is as stated:
skill publishdeclares--changelog/--categories/--topics(packages/clawhub/src/cli.ts:505-507),hasExplicitCatalogMetadatatests!== undefined(publish.ts:84), and the unchanged short-circuit skips only on explicit catalog metadata (publish.ts:134). The new CLI test pins that--changelogalone keeps the skip — good, that is the surprising corner of this design. quote_for_logdoes what the evidence claims:str.isprintable()is false for any value carrying\n,\r,\tor C0/C1 controls, so those route tojson.dumps, which escapes every control character and non-ASCII — one argument stays one log line regardless of input. Values that are already printable keepshlex.quote(readable logs for the common case). The execution path is untouched (subprocess.run(command, …), noshell=True), and the print happens before the run per target, so a failed publish still shows its resolved command.- The mutual-exclusion guard (
clear_x+ non-emptyx→SystemExit) fails the step before any publish starts, and the boolean env parsing (== "true") matches how GitHub rendersworkflow_callbooleans. - Docs match behavior: main promises "skips unchanged skills" in
docs/cli.md, and the PR now states next to it thatcategories/topics(and the clear flags) suspend that skip catalog-wide whilechangelogalone does not.
Ran at ba6aa78 (bun, Windows):
bunx vitest run src/__tests__/skill-publish-workflow.test.ts→ 6/6 pass.- Negative control: with main's
skill-publish.ymlrestored, the same file fails 4/6 — exactly the new forwarding/clear/log-line tests — so they exercise the change, not pass vacuously. - CLI: the new "changelog alone keeps the skip" test passes; the file is 20/21, and the one failure ("uploads each skill file separately…") fails identically on main (pre-existing, environmental).
Non-blocking notes:
- The adjacent hole you flagged is real and still on
main:.github/workflows/package-publish.yml(~:572) buildsshell_linewith the same" ".join(shlex.quote(...))andprint()s it with caller-controlledchangelog/categories/topics. There the quoted line is also written to the executed.sh, whereshlex.quoteis the right thing — the log print is the only injectable half. Agree it should stay out of this PR's file set; suggest a maintainer-tracked follow-up issue rather than an author offer in-thread. - Nit:
changelog = os.environ["INPUT_CHANGELOG"].strip()trims leading/trailing whitespace from user prose; consistent with the neighboring inputs and harmless for markdown, but the CLI itself does not strip — a multi-paragraph changelog loses its leading blank line. - The literal-content workflow tests are brittle to any reindent of the YAML heredoc; that matches the existing tests in this file, so no change requested — flagging only so a future reformat doesn't read as a regression.
The reusable skill-publish workflow forwarded only owner and tags, so catalog repos could not set changelog, category, or topic metadata through the supported path even though `clawhub skill publish` has accepted the matching flags all along. The three values travel the existing environment-variable to argument-list route, which keeps parsing and validation in the CLI and server and leaves callers that omit the inputs on exactly their current behavior. The step now also echoes the resolved command for each target, mirroring the package workflow. Without it a forwarded flag is invisible in the run logs, because a dry run reports only the publish JSON and that payload carries no catalog metadata.
The CLI distinguishes an omitted `--categories` from `--categories ""`: the first leaves the stored slugs alone, the second clears them, and `publish.test.ts` pins that difference. A `workflow_call` string input collapses both to `""`, so the truthiness guards forwarding these values made catalog metadata settable but never clearable through the supported workflow. Two false-default booleans carry the distinction across that boundary, one per field so the CLI's per-flag independence survives. Setting a non-empty value and its clear flag together fails the run rather than silently picking one. `changelog` needs no counterpart: the CLI already reads an omitted `--changelog` as empty.
`docs/cli.md:212` promises the reusable workflow skips unchanged skills, and the new metadata inputs quietly suspend that. `publish.ts:134` skips the already-published short-circuit whenever catalog metadata is supplied, so a catalog-wide `categories` or `topics` value - or either clear flag, which counts as supplied - releases a new patch version of every selected skill. A caller editing one field can release a whole catalog without meaning to. `changelog` is not part of that condition and leaves the skip intact. The republish half was already pinned by an existing CLI test; the changelog half was not, so it gets one now.
docs/cli.md and docs/publishing.md both still said the reusable skill workflow has no categories or topics input, which contradicts the inputs this branch adds a few sections later. Both paragraphs now describe the metadata inputs, the clear flags, and the unchanged-skill skip they suspend.
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, so a changelog, categories or topics input containing a newline made the resolved command log emit a second line, which the runner parses as a workflow command. Escape the parts that are not printable so one publish stays one log line regardless of what the caller sends.
ba6aa78 to
f47107d
Compare
|
Thanks for the verify pass, @pacocartones — especially for running the negative control rather than 1. The adjacent 2. The 3. Agreed on the literal-content tests. They are brittle to a reindent of the YAML heredoc, and The branch is rebased onto What the rebase did and did not change:
The body is updated for all of the above. |
The reusable skill workflow read INPUT_CHANGELOG through .strip() before appending it to the publish command, while `skill publish --changelog` stores whatever text it is handed. Markdown carries meaning in exactly the whitespace that trimming removes: leading indentation nests a list item, and two trailing spaces are a hard line break. A caller's changelog reached the catalog altered, and only through this workflow. Read the value verbatim and keep the trimming only where it decides whether to forward at all, so a blank input stays the no-op it is today. `categories` and `topics` remain trimmed; they are slug lists rather than prose.
|
Fixed in My last comment defended the The repair is two lines and a comment: changelog = os.environ["INPUT_CHANGELOG"] # was .strip()
...
if changelog.strip(): # was: if changelog
command += ["--changelog", changelog]The value is forwarded verbatim; only the decision whether to forward looks past whitespace, so a Real behavior proof, recorded at the new head. Two jobs in one dispatch, same changelog: " - indented bullet \n - bullet ending in a hard break \n"Both jobs are green, which is the shape of this bug: the difference is in the argument, not in the On the re-proof cost I cited. It was smaller than I claimed, because it is per changed line Tests. Also documented in @clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
|
The six red checks on One shard failed — The same failure mode is on — one failing to start, mine failing to stop, both after the same This diff is a workflow YAML file, a docs paragraph, and a contract test. It cannot reach a Convex backend. I cannot re-run a shard from a fork ( |
|
Refreshed against current main.
I held the head at The merge is clean and disjoint. The six red checks on the previous head were a single |
|
Refreshed again; the red on the previous head is identified and already fixed on main. It was not a repeat of The cause is repo-wide: Nothing to do with this branch: the diff is one workflow file, two docs and two test files, with no dependency or lockfile change. The merge is clean and disjoint again. The two new main commits touch Local re-run of this PR's own tests on the merged tree: |
|
Closing the loop from the review side: every finding is fixed and I verified the fixes against
|
Related: #3074
What Problem This Solves
Catalog repos that publish skills through ClawHub's supported reusable workflow cannot set release or catalog metadata.
.github/workflows/skill-publish.ymlforwards onlyownerandtags, so a publisher who wants a changelog, a category, or topics on a skill has to abandon the reusable workflow and call the CLI directly.The CLI side has been ready the whole time:
clawhub skill publishdeclares--changelog,--categories, and--topics(packages/clawhub/src/cli.ts:494), and the package workflow is gaining the same three inputs in #3074. This closes the equivalent gap on the skill side, which I noticed while reviewing that PR.Why This Change Was Made
The three values are optional
workflow_callstring inputs that travel the route the existingownerandtagsinputs already use: workflow input, then anINPUT_*environment variable, then a guard that treats a blank value as absent, then a conditional append onto the Python argument list thatsubprocess.runexecutes.categoriesandtopicsare trimmed on the way through, being slug lists;changelogis forwarded verbatim, for the reason in the fourth bullet below. Parsing and validation stay in the CLI and the server, so nothing is duplicated in YAML and no permission, secret, or publish path changes.Four deliberate decisions worth reviewer attention:
They apply to every skill the run publishes, exactly like the existing
tagsinput, because one call can process a wholerootdirectory. This matters more than it does fortags, anddocs/cli.mdnow says so in its own paragraph rather than leaving it to this description:publish.ts:134skips theunchangedshort-circuit whenever catalog metadata is supplied, so a catalog-widecategoriesortopicsvalue — or either clear flag, which counts as supplied — releases a new patch version of every selected skill, including ones whose files did not change.docs/cli.md:212promises the workflow "skips unchanged skills", so that suspension needed to be stated where callers read it, next toskill_pathas the way to bound the blast radius.changelogis not part of that condition and leaves the skip intact.Clearing metadata needs its own signal. The CLI separates an omitted
--categoriesfrom--categories "":hasExplicitCatalogMetadatatestsoptions.categories !== undefined(publish.ts:84),parseCsv("")returns[], andpublish.test.ts:482pins that{ categories: "", topics: "" }produces{ categories: [], topics: [] }in the payload. Aworkflow_callstring input cannot express that difference — an omitted input and an explicitly empty one both arrive as""— so a truthiness guard alone would leave callers able to set catalog metadata through the supported workflow and never able to clear it.clear_categoriesandclear_topicsare false-default booleans, following thedry_run/json/wait_for_publicationconvention already in these workflows. They are per field rather than one combined flag because the CLI applies--categories ""independently of--topics. Passing a non-empty value together with its own clear flag stops the run instead of silently preferring one.changeloggets no counterpart:publish.ts:78already reads an omitted--changelogas"".The step now echoes the resolved command per target, mirroring
package-publish.yml:526-529. Without it this change is unverifiable: a dry run prints only the publish JSON, andSkillPublishResultcarries no catalog metadata, so a forwarded flag left no trace in the logs. The quoting is log-only — the argument list is what runs,shell=Trueappears nowhere, and a test pins that.changelogis forwarded verbatim.skill publish --changelogstores the text it is handed (publish.ts:78), and Markdown carries meaning in exactly the whitespace a.strip()removes: leading indentation nests a list item, and two trailing spaces are a hard line break. Trimming it here would make the supported workflow alter a caller's changelog where the direct CLI does not, so only the decision whether to forward looks past surrounding whitespace — a blank input stays the no-op it is today.categoriesandtopicskeep their.strip(); they are slug lists, not prose. This was ClawSweeper's P2 blocker on the previous head and is fixed in78111513, with its own dispatch below.User Impact
Skill catalog repos can set changelog text, category slugs, and topics through the supported workflow instead of replacing it with a hand-rolled CLI job. Callers that omit the three inputs get byte-identical behavior: an unset
workflow_callstring input is"", every guard reads a blank value as absent, and no flag is appended.Evidence
Three dispatches, each pinning the ClawHub branch by full commit SHA: the metadata-forwarding one below, recorded at
ade5dc42; the control-character one further down, recorded atba6aa780; and the changelog-whitespace one, recorded at the current head78111513.ba6aa780was the head when the first two dispatches were recorded, and two things have happened since. The branch was rebased ontofaab45bato pick up thebun auditfix (#3446, merged as8b31a7e6); the rebase replayed the same five commits with no conflicts and changed no file in this diff, and the only drift underdocs/cli.mdcame frommainitself (#3359, in the ClawPack section, four sections away from this PR's text). Then78111513stopped trimming thechangeloginput, which is the one finding the last review left open. That commit is the only change to.github/workflows/skill-publish.ymlsinceba6aa780, and it is two lines plus their comment: the input parsing forcategoriesandtopics, the mutual-exclusion guard, the clear branches, andquote_for_logare untouched. So the two earlier dispatches still describe the current head for everything they assert, and the changed lines have their own current-head dispatch below.Real behavior proof: metadata forwarding. Recorded at
ade5dc4268c352c267db0a4fbbd0c2143a848988. Two commits have touched.github/workflows/skill-publish.ymlsince:ba6aa780, which replacesshlex.quote(part)withquote_for_log(part)in the log line and adds that helper, and78111513, which stops trimmingchangelog. Neither touches the input parsing for the other inputs, the conditional appends, the mutual-exclusion guard, or the executed argument list, so the four jobs below still describe the current head's forwarding behavior; each changed area is covered by its own current-head run.A throwaway caller repo runs the reusable workflow four times in one dispatch, every job hardcoding
dry_run: true, passing no repository secrets, and pinning this branch by full commit SHA: run 31038898583, all four green. Each line below is theResolved publish commandits job logged, trimmed to the flags under test:with-metadata
without-metadata — the control, every new input omitted
clear-both —
clear_categories: true,clear_topics: trueset-one-clear-other —
categories: "automation",clear_topics: trueThe three shapes the CLI distinguishes — flag absent, flag with a value, flag with an empty value — each come out of a real run, and the fourth job shows they are independent per field.
The conflict guard, proven by a failing run. run 31038901710 passes
categories: "automation"together withclear_categories: trueand is expected to fail; the failure is the assertion. It lives in its own dispatch becausecontinue-on-erroris not accepted on a job that calls a reusable workflow, and I would rather have one red run that means something than a green one that hides it. The log:Nothing is published:
dry_runis hardcoded and the run stops before the firstskill publish.The changelog value deliberately carries a single quote and a semicolon; both survive as one argument, which is the property the list-based
subprocess.runcall guarantees.Real behavior proof: control characters in metadata, at the current head.
shlex.quoteis shell quoting, not output escaping — given"a\n::notice::x"it returns'a\n::notice::x', single quotes around a line break that is still a line break. The runner reads step stdout line by line, so a caller's newline opened a second log line that the runner parsed as a workflow command.quote_for_log(skill-publish.yml:242-249) keepsshlex.quotefor the copy-pasteable common case and falls back tojson.dumpswhen the quoted form is not printable.str.isprintable()rather than an explicit\r\ncheck, because it is false for every C0/C1 control character and for the Unicode line/paragraph separators, so nothing has to enumerate them and--changelog 'Adds 日本語 notes'stays on the readable path;json.dumpsrather thanrepr, becauseensure_asciidefaults to true and the fallback cannot smuggle a separator back in.Two jobs in one dispatch, same multi-line
changelog, differing only in the pinned ClawHub SHA —cc70d190(the previously reviewed head) andba6aa780(the current head's workflow file, byte for byte). Bothdry_run: true, nosecrets:(run 31313037515, workflow source):before-fix — the command log breaks in two and the runner consumes the second line:
after-fix — one line, and the payload is inert text:
The annotation API is the unambiguous half, because a line the runner accepts as a command is removed from the log rather than printed:
One thing the run showed that is worth a maintainer's eye: the raw second line is also in both jobs' logs before the publish step runs, in the runner's own
##[group] Inputsecho of the reusable-workflow inputs and in theenv:block printed above each step. Those are the runner writing its own log, not step stdout, so they are never parsed — zero annotations onafter-fixis the proof of that. They cannot be suppressed from inside a workflow. A multi-line input stays visible in logs either way; what this fix removes is the part where it becomes executable.Real behavior proof: changelog whitespace, at the current head. This is the finding the last review left open.
skill publish --changelogstores the text it is handed, while this workflow readINPUT_CHANGELOGthrough.strip(). What that removes is not decoration: two leading spaces nest a Markdown list item, and two trailing spaces are a hard line break. A changelog published through the supported workflow therefore reached the catalog altered, and only through this workflow.Two jobs in one dispatch, same
changeloginput, differing only in the pinned ClawHub SHA —f47107d1(the head ClawSweeper reviewed) and78111513(the current head). Bothdry_run: true, nosecrets:(run 31674101726, workflow source). The input is a double-quoted scalar rather than a block scalar, so no indentation-stripping happens in YAML and both jobs receive the same bytes:before-fix — the leading indentation and the trailing hard break are gone:
after-fix — the value reaches the CLI as written:
Both jobs are green: the difference is in the argument, not in the outcome, which is what makes this a fidelity bug rather than a failure anyone would have noticed. The JSON escaping in both lines is
quote_for_logfrom the section above doing its job — the value holds newlines, so one publish still prints as one log line, and that is what makes the whitespace legible at all.Tests.
bunx vitest run src/__tests__/skill-publish-workflow.test.ts— 7 passed at the current head, the seventh being the changelog-fidelity case added in78111513. Two negative controls, each restoring only.github/workflows/skill-publish.yml: fromf47107d1, the head ClawSweeper reviewed,1 failed | 6 passed, the failure being the new test; fromcc70d190,3 failed | 4 passed, the two log tests and the new one. It pins the fallback itself rather than only the absence of the old join, so replacingquote_for_logwith anything that leaves a control character intact fails it. The contract tests pin the whole chain per input: the parsedworkflow_callinput declaration, the exactINPUT_*environment expression, the Python read, and the conditional append, plus theelifclear branch, the mutual-exclusion guard, and the absence of aclear_changeloginput. Reverting only the workflow file turns the clear test red, so it is not vacuous.bunx vitest run src/__tests__/package-publish-workflow.test.ts scripts/security/package-publish-workflow.test.ts— 8 passed together with the above; the sibling workflow contracts are untouched.bunx tsc --noEmit,bun run lint,bun run llms:check,bun run deadcode:ci,bun run check:release-workflow-action-pins— all clean.exec'd verbatim across six input combinations, which produced exactly the command lines the real run later logged, including the rejection:bunx vitest run src/cli/commands/publish.test.tsinpackages/clawhub— 20 passed, 1 failed. The failure,uploads each skill file separately before sending the publish metadata, is a pre-existing file-ordering assertion that expectsSKILL.mdbeforeassets/payload.bin; it fails identically with my change stashed (1 failed | 19 passed), so this branch adds one passing test and no failure. The twoclearcases in that file are the existing CLI contract this depends on, unmodified here. The new case pins the other half of the docs paragraph: achangelog-only publish of unchanged content still returnsunchangedat1.2.3, while the neighbouring test already pinned thatcategories/topicsbump it to1.2.4.Gates with pre-existing failures on my machine. Both were confirmed against a clean
origin/maincheckout of the same tree, so neither is caused by this branch:bun run ci:unit, re-measured atade5dc42against this branch's own merge basef9ea25e1so the only difference is this PR. Branch:15 failed | 436 passed | 1 skipped (452)files,25 failed | 5687 passed | 9 skippedtests. Basef9ea25e1:15 failed | 436 passed | 1 skipped (452)files,25 failed | 5684 passed | 9 skippedtests. The sorted failing-file lists are byte-identical; the delta is the three contract tests added here. The failures are Windows-local (mkdtempon paths containing:underscripts/, plus jsdom cases) and unrelated to workflows, docs, or tests touched here.bun run ci:packages, same base comparison, needed because this branch now touches a file underpackages/. Branch:7 failed | 20 passed (28)files,69 failed | 317 passedtests. Basef9ea25e1:7 failed | 20 passed (28)files,69 failed | 316 passedtests. Identical failing-file lists; the one-test delta is thechangelog-only case added here. The failures are the same Windows-local class (curlmultipart, restricted file modes, temp-directory paths).bun run ci:static—bun auditnow reportsNo vulnerabilities foundon the rebased base, so the step that failed for most of this PR's life is gone. It stops one step later atformat:check, onCLAUDE.mdand.agents/skills/autoreview/CLAUDE.md;src/styles.css, the third file in the earlier reading, was fixed onmainin the meantime. Neither remaining file, and no dependency, is touched by this branch.The six red checks this PR carried were repo-wide, and their cause is now fixed on
main.pr-gateswas failing atbun auditonmainitself — run 31332116595 at82313c2b,8 vulnerabilities (2 high, 5 moderate, 1 low)— andstatic,unit,packages,types-build, ande2e-httpare mirror jobs whose only step istest "$PR_GATES_RESULT" = "success", so that one failure painted six checks red on every open PR. #3446 bumped the four advisory-hit packages and merged as8b31a7e6; this branch is now rebased ontofaab45ba, which contains it, so the checks on this head are discriminating for the first time.Screenshots: N/A, workflow and documentation change with no UI surface.
Adjacent, now merged
package-publish.ymlonmainforwards the same caller-controlledchangelog/categories/topics(merged in #3074) and joined them with the same" ".join(shlex.quote(part) for part in cmd)beforeprinting that string — the quoting is load-bearing for the.shfile it also writes, but the echo carried the property this PR fixes here. #3447 applied the same helper there and merged as8bf424cf, soquote_for_logis now the shipped shape on the package side and this PR brings the skill side to match it. The two were independent; neither needed the other to merge.