Files
hyperframes/scripts/clawhub-sync.mjs
T
Miguel Ángel 0ca99b30da 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.
2026-10-07 01:46:15 +00:00

102 lines
3.3 KiB
JavaScript

#!/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();
}