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:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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