fix(ci): skills sync publishes hyperframes-creative past its hidden 1.0.13 and checks it went live (#5081)

* fix(ci): skills sync publishes past a version the registry holds unpublished

* fix(ci): skills sync fails unless a republished skill is the registry's latest

* fix(ci): skills sync republishes one patch past a hidden version and checks it as owner

* test(ci): skills sync fails the job on an error and stops after a clean sync

Two mutants survived review: dropping the line that sets the exit code (the job would print its ::error:: lines and still pass) and dropping the clean-sync return. One test runs the script with a failing fake clawhub on PATH and expects exit 1; another pins that a clean sync makes no further call.

* test(ci): the exit-code test can only reach its fake clawhub

The child's PATH was the fake's folder plus the inherited PATH, so on a machine where the fake cannot execute, a real logged-in clawhub further down would run sync for real. PATH is now the fake's folder alone.

* fix(ci): skills sync skips only hyperframes-creative's hidden 1.0.13

Republishing one patch past any taken version would publish past every future hidden version with no one looking. Only the acknowledged case (hyperframes-creative, 1.0.13 taken) is published at 1.0.14; any other taken version fails the sync as before.

* test(ci): the skip allowlist matches the skill, not just the version

A taken 1.0.13 on any other skill must fail the sync too; without this case, accepting 1.0.13 for every skill passed all tests.

* refactor(ci): skills sync picks the republish version in its own helper

The allowlist check took republishTaken to cyclomatic 5, which the Fallow audit scores at its 30.0 CRAP threshold; deciding the version in versionPastSkippable keeps each function under it. Behaviour unchanged.
This commit is contained in:
Miguel Ángel
2026-10-07 01:46:15 +00:00
committed by GitHub
parent 4cf5cf9b53
commit 0ca99b30da
4 changed files with 267 additions and 15 deletions
+2 -14
View File
@@ -10,6 +10,7 @@ on:
branches: [main]
paths:
- "skills/**"
- "scripts/clawhub-sync.mjs"
workflow_dispatch:
inputs:
dry_run:
@@ -50,17 +51,4 @@ jobs:
- name: Sync skills
env:
DRY_RUN: ${{ github.event.inputs.dry_run }}
run: |
FLAGS=(
--all
--owner heygen-com
--bump patch
--changelog "Synced from ${GITHUB_SHA:0:7} (${GITHUB_REF_NAME})"
--source-repo "$GITHUB_REPOSITORY"
--source-commit "$GITHUB_SHA"
--source-ref "$GITHUB_REF"
)
if [ "$DRY_RUN" = "true" ]; then
FLAGS+=(--dry-run)
fi
clawhub sync "${FLAGS[@]}"
run: node scripts/clawhub-sync.mjs
+1 -1
View File
@@ -52,7 +52,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/prune-fallow-audit-caches.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-comment-citations.test.mjs scripts/comments-workflow.test.mjs scripts/comment-ratchet.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-media-use-svg-sanitize-generated.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/prune-fallow-audit-caches.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/clawhub-sync.test.mjs scripts/publish-workflow.test.mjs scripts/pr-edit-concurrency.test.mjs scripts/merge-queue-workflows.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/backfill-block-previews.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-publication.test.mjs scripts/ci/resolve-workflow-pr.test.mjs scripts/ci/base-file.test.mjs scripts/check-catalog-source-pr.test.mjs scripts/generate-registry-items.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts",
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
"test:skills": "HYPERFRAMES_MEDIA_HOME_REQUIRED=1 node --import ./packages/cli/scripts/test-home.mjs --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env node
// `clawhub sync` bumps from the registry's `latest` tag, so a version the registry has hidden
// blocks the skill for good; an acknowledged one is published one patch past, then checked live.
import { spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const TAKEN_VERSION = /Version (\d+)\.(\d+)\.(\d+) already exists/;
// The registry hid this one version; any other taken version still fails the sync.
const SKIPPABLE_TAKEN = new Map([["hyperframes-creative", "1.0.13"]]);
const OWNER_HANDLE = "heygen-com";
const OWNER = ["--owner", OWNER_HANDLE];
function takenVersion(message) {
const match = TAKEN_VERSION.exec(message);
return match ? match.slice(1, 4).map(Number) : null;
}
function parsedOutput(result) {
try {
return JSON.parse(result.output);
} catch {
return null;
}
}
function latestTag(run, slug) {
return parsedOutput(run(["inspect", `${OWNER_HANDLE}/${slug}`, "--json"]))?.skill?.tags?.latest;
}
function failedSkills(sync) {
return parsedOutput(sync)?.failed ?? [];
}
const syncArgs = (provenance, dryRun) =>
["sync", "--all", "--json", "--bump", "patch", ...OWNER, ...provenance].concat(
dryRun ? ["--dry-run"] : [],
);
function versionPastSkippable(slug, message) {
const taken = takenVersion(message);
if (!taken || SKIPPABLE_TAKEN.get(slug) !== taken.join(".")) return null;
const [major, minor, patch] = taken;
return `${major}.${minor}.${patch + 1}`;
}
function republishTaken(run, provenance, { slug, message }) {
const version = versionPastSkippable(slug, message);
if (!version) return `${slug}: ${message}`;
const folder = `skills/${slug}`;
const published = run(
["publish", folder, "--slug", slug, "--version", version, "--source-path", folder].concat(
OWNER,
provenance,
),
);
if (!published.ok) return `${slug}: ${published.message}`;
const latest = latestTag(run, slug);
if (latest !== version)
return `${slug}: published ${version}, but the registry's latest is ${latest}`;
console.log(`${slug}: ${SKIPPABLE_TAKEN.get(slug)} is taken, published ${version}`);
return null;
}
export function syncSkills({ run, provenance, dryRun }) {
const sync = run(syncArgs(provenance, dryRun));
if (sync.ok) return [];
const failed = dryRun ? [] : failedSkills(sync);
if (failed.length === 0) return [sync.message];
return failed.map((skill) => republishTaken(run, provenance, skill)).filter(Boolean);
}
function runClawhub(args) {
const result = spawnSync("clawhub", args, { encoding: "utf8" });
process.stdout.write(result.stdout);
process.stderr.write(result.stderr);
return { ok: result.status === 0, output: result.stdout, message: result.stdout + result.stderr };
}
function main() {
const env = process.env;
const errors = syncSkills({
run: runClawhub,
dryRun: env.DRY_RUN === "true",
provenance: [
"--changelog",
`Synced from ${env.GITHUB_SHA.slice(0, 7)} (${env.GITHUB_REF_NAME})`,
"--source-repo",
env.GITHUB_REPOSITORY,
"--source-commit",
env.GITHUB_SHA,
"--source-ref",
env.GITHUB_REF,
],
});
for (const error of errors) console.error(`::error::${error}`);
if (errors.length > 0) process.exitCode = 1;
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
+163
View File
@@ -0,0 +1,163 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { syncSkills } from "./clawhub-sync.mjs";
const TAKEN = (version) =>
`Version ${version} already exists. Increment the version number and try again. (reset in 24s)`;
function fakeClawhub({
syncOk = false,
syncFailed,
syncOutput,
takenVersions = [],
refuse = null,
hidden = false,
}) {
const calls = [];
let published = "1.0.12";
const publish = (args) => {
const version = args[args.indexOf("--version") + 1];
if (refuse) return { ok: false, output: "", message: refuse };
if (takenVersions.includes(version)) return { ok: false, output: "", message: TAKEN(version) };
if (!hidden) published = version;
return { ok: true, output: "{}", message: "" };
};
const syncResult = () => {
const output = syncOutput ?? JSON.stringify({ ok: syncOk, published: [], failed: syncFailed });
return { ok: syncOk, output, message: output };
};
const inspect = () => ({
ok: true,
output: JSON.stringify({ skill: { tags: { latest: published } } }),
});
const commands = { sync: syncResult, inspect, publish };
const run = (args) => {
calls.push(args);
return commands[args[0]](args);
};
return { run, calls };
}
const provenance = ["--changelog", "Synced from 6c353d8 (main)", "--source-commit", "6c353d8"];
const sync = (clawhub, dryRun = false) => syncSkills({ run: clawhub.run, provenance, dryRun });
const SYNC_ARGS = ["sync", "--all", "--json", "--bump", "patch", "--owner", "heygen-com"];
const creativeTaken = { slug: "hyperframes-creative", message: TAKEN("1.0.13") };
describe("ClawHub skill sync", () => {
it("publishes a skill whose bumped version is taken one patch past it, then reads it back", () => {
const clawhub = fakeClawhub({ syncFailed: [creativeTaken] });
assert.deepEqual(sync(clawhub), []);
assert.deepEqual(clawhub.calls, [
[...SYNC_ARGS, ...provenance],
[
"publish",
"skills/hyperframes-creative",
"--slug",
"hyperframes-creative",
"--version",
"1.0.14",
"--source-path",
"skills/hyperframes-creative",
"--owner",
"heygen-com",
...provenance,
],
["inspect", "heygen-com/hyperframes-creative", "--json"],
]);
});
it("fails when the registry does not show the version it accepted", () => {
const clawhub = fakeClawhub({ syncFailed: [creativeTaken], hidden: true });
assert.deepEqual(sync(clawhub), [
"hyperframes-creative: published 1.0.14, but the registry's latest is 1.0.12",
]);
});
it("fails rather than skipping further when the next patch is taken too", () => {
const clawhub = fakeClawhub({ syncFailed: [creativeTaken], takenVersions: ["1.0.14"] });
assert.deepEqual(sync(clawhub), [`hyperframes-creative: ${TAKEN("1.0.14")}`]);
assert.equal(clawhub.calls.filter((args) => args[0] === "publish").length, 1);
});
it("still fails on any other refusal, without retrying it", () => {
const clawhub = fakeClawhub({ syncFailed: [{ slug: "figma", message: "Unauthorized" }] });
assert.deepEqual(sync(clawhub), ["figma: Unauthorized"]);
assert.equal(clawhub.calls.filter((args) => args[0] === "publish").length, 0);
});
it("reports a republish the registry refuses for another reason", () => {
const clawhub = fakeClawhub({ syncFailed: [creativeTaken], refuse: "Rate limited" });
assert.deepEqual(sync(clawhub), ["hyperframes-creative: Rate limited"]);
});
it("fails on any taken version other than the one acknowledged, without publishing", () => {
const others = [
{ slug: "figma", message: TAKEN("1.0.17") },
{ slug: "figma", message: TAKEN("1.0.13") },
{ slug: "hyperframes-creative", message: TAKEN("1.0.15") },
];
const clawhub = fakeClawhub({ syncFailed: others });
assert.deepEqual(
sync(clawhub),
others.map(({ slug, message }) => `${slug}: ${message}`),
);
assert.equal(clawhub.calls.filter((args) => args[0] === "publish").length, 0);
});
it("fails a sync that reports no failed skill or no readable result", () => {
assert.deepEqual(sync(fakeClawhub({ syncFailed: [] })), [
JSON.stringify({ ok: false, published: [], failed: [] }),
]);
assert.deepEqual(sync(fakeClawhub({ syncOutput: "Error: not logged in" })), [
"Error: not logged in",
]);
});
it("stops after a sync that succeeds", () => {
const clawhub = fakeClawhub({ syncOk: true, syncFailed: [] });
assert.deepEqual(sync(clawhub), []);
assert.deepEqual(clawhub.calls, [[...SYNC_ARGS, ...provenance]]);
});
it("exits non-zero when the sync fails, so the workflow goes red", () => {
const bin = mkdtempSync(join(tmpdir(), "clawhub-fake-"));
try {
writeFileSync(join(bin, "clawhub"), "#!/bin/sh\necho 'Error: not logged in' >&2\nexit 1\n");
chmodSync(join(bin, "clawhub"), 0o755);
const script = new URL("./clawhub-sync.mjs", import.meta.url).pathname;
const result = spawnSync(process.execPath, [script], {
encoding: "utf8",
env: {
PATH: bin,
GITHUB_SHA: "6c353d8aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
GITHUB_REF_NAME: "main",
GITHUB_REF: "refs/heads/main",
GITHUB_REPOSITORY: "heygen-com/hyperframes",
},
});
assert.equal(result.status, 1);
assert.match(result.stderr, /::error::Error: not logged in/);
} finally {
rmSync(bin, { recursive: true, force: true });
}
});
it("only previews in a dry run, even when a version is taken", () => {
const clawhub = fakeClawhub({ syncFailed: [creativeTaken] });
assert.equal(sync(clawhub, true).length, 1);
assert.deepEqual(clawhub.calls, [[...SYNC_ARGS, ...provenance, "--dry-run"]]);
});
});