Skip to content

Commit 4f70481

Browse files
authored
fix(release): include bundled skill in prebuilt archives (#302)
1 parent 9ada110 commit 4f70481

6 files changed

Lines changed: 150 additions & 38 deletions

File tree

.github/workflows/release-prebuilt-npm.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,19 @@ jobs:
191191
mkdir -p dist/release/github
192192
while IFS= read -r -d '' directory; do
193193
package_name="$(basename "$directory")"
194+
binary="$directory/hunk"
195+
if [ ! -f "$binary" ] && [ -f "$directory/hunk.exe" ]; then
196+
binary="$directory/hunk.exe"
197+
fi
198+
if [ ! -f "$binary" ]; then
199+
echo "Missing release binary in $directory" >&2
200+
exit 1
201+
fi
202+
if [ ! -f "$directory/skills/hunk-review/SKILL.md" ]; then
203+
echo "Missing bundled Hunk review skill in $directory" >&2
204+
exit 1
205+
fi
206+
chmod 0755 "$binary"
194207
tar -C "$(dirname "$directory")" -czf "dist/release/github/${package_name}.tar.gz" "$package_name"
195208
done < <(find dist/release/artifacts -mindepth 1 -maxdepth 1 -type d -print0 | sort -z)
196209
find dist/release/github -maxdepth 1 -type f | sort

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ All notable user-visible changes to Hunk are documented in this file.
1010

1111
### Fixed
1212

13+
- Included the bundled Hunk review skill in standalone prebuilt release archives so `hunk skill path` works after extracting a tarball or installing via Homebrew.
14+
1315
## [0.12.0] - 2026-05-12
1416

1517
### Added
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { existsSync, mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import { afterEach, describe, expect, test } from "bun:test";
5+
import { stagePrebuiltArtifact } from "./build-prebuilt-artifact";
6+
import { binaryFilenameForSpec, getHostPlatformPackageSpec } from "./prebuilt-package-helpers";
7+
8+
let tempRoot: string | undefined;
9+
10+
/** Create a disposable repository shape for release artifact staging tests. */
11+
function createTestRepo() {
12+
tempRoot = mkdtempSync(path.join(os.tmpdir(), "hunk-prebuilt-artifact-"));
13+
const repoRoot = path.join(tempRoot, "repo");
14+
const spec = getHostPlatformPackageSpec();
15+
const binaryName = binaryFilenameForSpec(spec);
16+
17+
mkdirSync(path.join(repoRoot, "dist"), { recursive: true });
18+
mkdirSync(path.join(repoRoot, "skills", "hunk-review"), { recursive: true });
19+
writeFileSync(path.join(repoRoot, "dist", binaryName), "#!/bin/sh\necho hunk\n", {
20+
mode: 0o600,
21+
});
22+
writeFileSync(path.join(repoRoot, "skills", "hunk-review", "SKILL.md"), "# Hunk review\n");
23+
24+
return { repoRoot, spec, binaryName };
25+
}
26+
27+
afterEach(() => {
28+
if (tempRoot) {
29+
rmSync(tempRoot, { recursive: true, force: true });
30+
tempRoot = undefined;
31+
}
32+
});
33+
34+
describe("stagePrebuiltArtifact", () => {
35+
test("rejects missing skills directory with an actionable error", () => {
36+
const { repoRoot } = createTestRepo();
37+
rmSync(path.join(repoRoot, "skills"), { recursive: true, force: true });
38+
39+
expect(() => stagePrebuiltArtifact({ repoRoot })).toThrow("Missing skills directory");
40+
});
41+
42+
test("rejects missing bundled Hunk review skill with an actionable error", () => {
43+
const { repoRoot } = createTestRepo();
44+
rmSync(path.join(repoRoot, "skills", "hunk-review", "SKILL.md"), { force: true });
45+
46+
expect(() => stagePrebuiltArtifact({ repoRoot })).toThrow("Missing bundled Hunk review skill");
47+
});
48+
49+
test("includes the bundled skill next to standalone release binaries", () => {
50+
const { repoRoot, spec, binaryName } = createTestRepo();
51+
const outputRoot = path.join(tempRoot!, "artifacts");
52+
53+
const outputDir = stagePrebuiltArtifact({ repoRoot, outputRoot });
54+
55+
expect(outputDir).toBe(path.join(outputRoot, spec.packageName));
56+
expect(existsSync(path.join(outputDir, binaryName))).toBe(true);
57+
expect(existsSync(path.join(outputDir, "metadata.json"))).toBe(true);
58+
expect(existsSync(path.join(outputDir, "skills", "hunk-review", "SKILL.md"))).toBe(true);
59+
60+
if (process.platform !== "win32") {
61+
expect(statSync(path.join(outputDir, binaryName)).mode & 0o111).not.toBe(0);
62+
}
63+
});
64+
});

scripts/build-prebuilt-artifact.ts

Lines changed: 69 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/usr/bin/env bun
22

3-
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
3+
import { chmodSync, cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
44
import path from "node:path";
55
import {
66
binaryFilenameForSpec,
@@ -32,45 +32,76 @@ function parseArgs(argv: string[]) {
3232
return { outputRoot, expectedPackage };
3333
}
3434

35-
const repoRoot = path.resolve(import.meta.dir, "..");
36-
const options = parseArgs(process.argv.slice(2));
37-
const spec = getHostPlatformPackageSpec();
38-
const binaryName = binaryFilenameForSpec(spec);
39-
const compiledBinaryCandidates = [
40-
path.join(repoRoot, "dist", binaryName),
41-
path.join(repoRoot, "dist", "hunk"),
42-
];
43-
const compiledBinary = compiledBinaryCandidates.find((candidate) => existsSync(candidate));
44-
const outputRoot = path.resolve(options.outputRoot ?? releaseArtifactsDir(repoRoot));
45-
const outputDir = path.join(outputRoot, spec.packageName);
46-
47-
if (options.expectedPackage && options.expectedPackage !== spec.packageName) {
48-
throw new Error(
49-
`Host build resolved to ${spec.packageName}, but the workflow expected ${options.expectedPackage}.`,
50-
);
35+
export interface StagePrebuiltArtifactOptions {
36+
repoRoot?: string;
37+
outputRoot?: string;
38+
expectedPackage?: string;
5139
}
5240

53-
if (!compiledBinary) {
54-
throw new Error(
55-
`Missing compiled binary at ${compiledBinaryCandidates.join(" or ")}. Run \`bun run build:bin\` first.`,
41+
/** Stage one standalone prebuilt release artifact for the current host. */
42+
export function stagePrebuiltArtifact(options: StagePrebuiltArtifactOptions = {}) {
43+
const repoRoot = path.resolve(options.repoRoot ?? path.resolve(import.meta.dir, ".."));
44+
const spec = getHostPlatformPackageSpec();
45+
const binaryName = binaryFilenameForSpec(spec);
46+
const compiledBinaryCandidates = [
47+
path.join(repoRoot, "dist", binaryName),
48+
path.join(repoRoot, "dist", "hunk"),
49+
];
50+
const compiledBinary = compiledBinaryCandidates.find((candidate) => existsSync(candidate));
51+
const outputRoot = path.resolve(options.outputRoot ?? releaseArtifactsDir(repoRoot));
52+
const outputDir = path.join(outputRoot, spec.packageName);
53+
54+
if (options.expectedPackage && options.expectedPackage !== spec.packageName) {
55+
throw new Error(
56+
`Host build resolved to ${spec.packageName}, but the workflow expected ${options.expectedPackage}.`,
57+
);
58+
}
59+
60+
if (!compiledBinary) {
61+
throw new Error(
62+
`Missing compiled binary at ${compiledBinaryCandidates.join(" or ")}. Run \`bun run build:bin\` first.`,
63+
);
64+
}
65+
66+
rmSync(outputDir, { recursive: true, force: true });
67+
mkdirSync(outputDir, { recursive: true });
68+
69+
const stagedBinary = path.join(outputDir, binaryName);
70+
cpSync(compiledBinary, stagedBinary);
71+
if (spec.os !== "windows") {
72+
chmodSync(stagedBinary, 0o755);
73+
}
74+
75+
const skillsSource = path.join(repoRoot, "skills");
76+
if (!existsSync(skillsSource)) {
77+
throw new Error(`Missing skills directory at ${skillsSource}.`);
78+
}
79+
80+
const hunkReviewSkill = path.join(skillsSource, "hunk-review", "SKILL.md");
81+
if (!existsSync(hunkReviewSkill)) {
82+
throw new Error(`Missing bundled Hunk review skill at ${hunkReviewSkill}.`);
83+
}
84+
85+
cpSync(skillsSource, path.join(outputDir, "skills"), { recursive: true });
86+
writeFileSync(
87+
path.join(outputDir, "metadata.json"),
88+
`${JSON.stringify(
89+
{
90+
packageName: spec.packageName,
91+
os: spec.os,
92+
cpu: spec.cpu,
93+
binaryName,
94+
},
95+
null,
96+
2,
97+
)}\n`,
5698
);
99+
100+
return outputDir;
57101
}
58102

59-
rmSync(outputDir, { recursive: true, force: true });
60-
mkdirSync(outputDir, { recursive: true });
61-
cpSync(compiledBinary, path.join(outputDir, binaryName));
62-
writeFileSync(
63-
path.join(outputDir, "metadata.json"),
64-
`${JSON.stringify(
65-
{
66-
packageName: spec.packageName,
67-
os: spec.os,
68-
cpu: spec.cpu,
69-
binaryName,
70-
},
71-
null,
72-
2,
73-
)}\n`,
74-
);
75-
76-
console.log(`Prepared prebuilt artifact in ${outputDir}`);
103+
if (import.meta.main) {
104+
const options = parseArgs(process.argv.slice(2));
105+
const outputDir = stagePrebuiltArtifact(options);
106+
console.log(`Prepared prebuilt artifact in ${outputDir}`);
107+
}

scripts/update-homebrew-formula.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ describe("update-homebrew-formula", () => {
8080
expect(formula).toContain("hunkdiff-linux-x64.tar.gz");
8181
expect(formula).toContain('chmod 0755, "hunk"');
8282
expect(formula).toContain('libexec.install "hunk"');
83+
expect(formula).toContain('libexec.install "skills"');
8384
expect(formula).toContain(
8485
'(bin/"hunk").write_env_script libexec/"hunk", HUNK_INSTALL_SOURCE: "homebrew"',
8586
);

scripts/update-homebrew-formula.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ function formulaContent(options: Options) {
147147
def install
148148
chmod 0755, "hunk"
149149
libexec.install "hunk"
150+
libexec.install "skills"
150151
(bin/"hunk").write_env_script libexec/"hunk", HUNK_INSTALL_SOURCE: "homebrew"
151152
end
152153

0 commit comments

Comments
 (0)