mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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();
|
||||
}
|
||||
@@ -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"]]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user