argsbarg 5.1.9 → 5.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [5.1.10] - 2026-07-05
11
+
12
+ ### Added
13
+
14
+ - **Zip release assets** — Homebrew formulae download `{key}.zip` from GitHub Releases; `buildReleaseArchive` in `formula-shared.ts`; `just release --purge` to delete stale releases.
15
+
16
+ ### Changed
17
+
18
+ - **Release workflow** — `scripts/release.ts` uploads a zip archive (smaller download) instead of a bare Mach-O binary; formula `sha256` pins the archive.
19
+
10
20
  ## [5.1.9] - 2026-07-05
11
21
 
12
22
  ### Added
@@ -646,7 +656,8 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
646
656
  - Migrate schemas: rename every `children` property to **`commands`**; move positional definitions to **`CliPositional`** objects on `positionals` and strip `positional` / `argMin` / `argMax` from flag definitions under `options` (flags only carry `name`, `description`, `kind`, and optional `shortName`).
647
657
  - Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
648
658
 
649
- [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v5.1.9...HEAD
659
+ [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v5.1.10...HEAD
660
+ [5.1.10]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.10
650
661
  [5.1.9]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.9
651
662
  [5.1.8]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.8
652
663
  [5.1.7]: https://github.com/bdombro/bun-argsbarg/releases/tag/v5.1.7
@@ -118,9 +118,22 @@ Template source: [`examples/full-example/`](../examples/full-example/) in the ar
118
118
  ## Release workflow
119
119
 
120
120
  1. `just build` → `dist/{key}`
121
- 2. `scripts/release.ts` → writes `Formula/{key}.rb` (GitHub URL + sha256), commits, tags, uploads `dist/{key}` to GitHub Releases
121
+ 2. `scripts/release.ts` → zips `dist/{key}` to `dist/{key}.zip`, writes `Formula/{key}.rb` (GitHub zip URL + archive sha256), commits, tags, uploads `dist/{key}.zip` to GitHub Releases
122
122
  3. Users `brew upgrade {key}` from the tap
123
123
 
124
+ Requires the `zip` CLI on the release machine. `just install-local` still stages the bare binary via `file://` (no zip).
125
+
126
+ ### Stale release cleanup
127
+
128
+ ```bash
129
+ just release --purge # delete all GitHub releases except the newest (confirm on TTY)
130
+ just release --purge --yes # skip confirmation
131
+ just release --purge --dry-run # list tags that would be deleted
132
+ just release patch --purge # release, then purge older releases
133
+ ```
134
+
135
+ `--purge` removes GitHub Release records and attached assets only — git tags remain on the remote. Old formula pins that reference deleted release URLs will fail until users upgrade.
136
+
124
137
  ## Removed (breaking)
125
138
 
126
139
  - Self-install to `~/.local/bin`
@@ -88,6 +88,10 @@ setup:
88
88
  test: check
89
89
  bun test .
90
90
 
91
+ # Bump version, build, publish; or pass --purge to delete stale GitHub releases
92
+ release *ARGS:
93
+ bun scripts/release.ts {{ARGS}}
94
+
91
95
  # Install release formula from tap and run formula test
92
96
  test-release:
93
97
  brew untap {{tap}} 2>/dev/null || true
@@ -0,0 +1,68 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { execFileSync } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import {
8
+ buildReleaseArchive,
9
+ releaseArchiveName,
10
+ releaseFormulaUrl,
11
+ selectStaleReleaseTags,
12
+ } from "./formula-shared.ts";
13
+
14
+ describe("releaseArchiveName", () => {
15
+ test("returns key.zip", () => {
16
+ expect(releaseArchiveName()).toBe("full-example.zip");
17
+ });
18
+ });
19
+
20
+ describe("releaseFormulaUrl", () => {
21
+ test("points at zip asset on GitHub releases", () => {
22
+ expect(releaseFormulaUrl("1.2.3")).toBe(
23
+ "https://github.com/bdombro/bun-argsbarg/releases/download/v1.2.3/full-example.zip",
24
+ );
25
+ });
26
+ });
27
+
28
+ describe("selectStaleReleaseTags", () => {
29
+ test("returns empty for zero or one release", () => {
30
+ expect(selectStaleReleaseTags([])).toEqual([]);
31
+ expect(selectStaleReleaseTags([{ tagName: "v1.0.0", publishedAt: "2026-01-01T00:00:00Z" }])).toEqual([]);
32
+ });
33
+
34
+ test("keeps newest by publishedAt and returns the rest", () => {
35
+ const releases = [
36
+ { tagName: "v1.0.0", publishedAt: "2026-01-01T00:00:00Z" },
37
+ { tagName: "v1.1.0", publishedAt: "2026-02-01T00:00:00Z" },
38
+ { tagName: "v1.0.1", publishedAt: "2026-01-15T00:00:00Z" },
39
+ ];
40
+ expect(selectStaleReleaseTags(releases)).toEqual(["v1.0.1", "v1.0.0"]);
41
+ });
42
+ });
43
+
44
+ describe("buildReleaseArchive", () => {
45
+ const workDirs: string[] = [];
46
+
47
+ afterEach(() => {
48
+ for (const dir of workDirs.splice(0)) {
49
+ rmSync(dir, { recursive: true, force: true });
50
+ }
51
+ });
52
+
53
+ test("creates zip with binary at archive root and matching sha256", async () => {
54
+ const work = mkdtempSync(join(tmpdir(), "argsbarg-release-zip-"));
55
+ workDirs.push(work);
56
+ const binaryPath = join(work, "full-example");
57
+ writeFileSync(binaryPath, "#!/bin/sh\necho hi\n", { mode: 0o755 });
58
+
59
+ const { archivePath, sha256 } = await buildReleaseArchive(binaryPath);
60
+
61
+ expect(archivePath).toBe(`${binaryPath}.zip`);
62
+ const onDisk = createHash("sha256").update(readFileSync(archivePath)).digest("hex");
63
+ expect(sha256).toBe(onDisk);
64
+
65
+ const listing = execFileSync("unzip", ["-l", archivePath], { encoding: "utf8" });
66
+ expect(listing).toContain("full-example");
67
+ });
68
+ });
@@ -1,9 +1,18 @@
1
1
  /** Shared Ruby fragments embedded in Homebrew formulae. */
2
2
 
3
+ import { createHash } from "node:crypto";
4
+ import { readFileSync } from "node:fs";
5
+ import { $ } from "bun";
3
6
  import { createIdentity } from "./create-identity.ts";
4
7
 
5
8
  const { key, className, desc, homepage, releaseRepo } = createIdentity;
6
9
 
10
+ /** GitHub release row used by {@link selectStaleReleaseTags}. */
11
+ export interface ReleaseTag {
12
+ tagName: string;
13
+ publishedAt: string;
14
+ }
15
+
7
16
  export const formulaInstallRuby = `def install
8
17
  bin.install "${key}"
9
18
  chmod 0755, bin/"${key}"
@@ -36,11 +45,40 @@ export interface FormulaCoords {
36
45
  url: string;
37
46
  urlStanza: string;
38
47
  version: string;
48
+ /** SHA-256 hex digest of the release archive at `url` (zip for GitHub releases). */
39
49
  sha256: string;
40
50
  /** When true, embed {@link githubPrivateReleaseDownloadStrategyRuby} in the formula class. */
41
51
  privateRelease?: boolean;
42
52
  }
43
53
 
54
+ /** Release asset filename on GitHub (zip containing the bare binary at archive root). */
55
+ export function releaseArchiveName(): string {
56
+ return `${key}.zip`;
57
+ }
58
+
59
+ /** Build `{binaryPath}.zip` from a compiled binary; return archive path and sha256 of the zip. */
60
+ export async function buildReleaseArchive(binaryPath: string): Promise<{ archivePath: string; sha256: string }> {
61
+ const archivePath = `${binaryPath}.zip`;
62
+ const result = await $`zip -j -9 ${archivePath} ${binaryPath}`.nothrow();
63
+ if (result.exitCode !== 0) {
64
+ throw new Error(`zip failed: ${result.stderr}`);
65
+ }
66
+ const sha256 = createHash("sha256").update(readFileSync(archivePath)).digest("hex");
67
+ return { archivePath, sha256 };
68
+ }
69
+
70
+ /** Tags to delete when keeping only the newest release (sorted by `publishedAt` desc). */
71
+ export function selectStaleReleaseTags(releases: ReleaseTag[]): string[] {
72
+ if (releases.length <= 1) {
73
+ return [];
74
+ }
75
+ const sorted = [...releases].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
76
+ return sorted.slice(1).map((r) => r.tagName);
77
+ }
78
+
79
+ /** GitHub `org/repo` slug for release and purge commands. */
80
+ export const releaseRepoSlug = releaseRepo;
81
+
44
82
  /** Resolves private GitHub release assets via the API at download time. */
45
83
  export const githubPrivateReleaseDownloadStrategyRuby = ` class GitHubPrivateReleaseDownloadStrategy < CurlDownloadStrategy
46
84
  def initialize(url, name, version, **meta)
@@ -82,7 +120,7 @@ export function releaseUrlStanza(url: string): string {
82
120
  }
83
121
 
84
122
  export function releaseFormulaUrl(version: string): string {
85
- return `https://github.com/${releaseRepo}/releases/download/v${version}/${key}`;
123
+ return `https://github.com/${releaseRepo}/releases/download/v${version}/${releaseArchiveName()}`;
86
124
  }
87
125
 
88
126
  export function renderFormula(coords: FormulaCoords): string {
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env bun
2
+ /** Bump version, build, publish zip release; optional `--purge` to drop stale GitHub releases. */
3
+
4
+ import * as fs from "node:fs";
5
+ import { stdin as input, stdout as output } from "node:process";
6
+ import * as readline from "node:readline/promises";
7
+ import { $ } from "bun";
8
+ import { createIdentity } from "./create-identity.ts";
9
+ import {
10
+ buildReleaseArchive,
11
+ type ReleaseTag,
12
+ releaseRepoSlug,
13
+ renderReleaseFormula,
14
+ selectStaleReleaseTags,
15
+ } from "./formula-shared.ts";
16
+
17
+ const { key } = createIdentity;
18
+ const formulaPath = `Formula/${key}.rb`;
19
+ const binaryPath = `dist/${key}`;
20
+ const programPath = "src/program.ts";
21
+
22
+ /** Allowed semver bump kinds for `scripts/release.ts`. */
23
+ type Bump = "major" | "minor" | "patch";
24
+
25
+ interface ReleaseOptions {
26
+ bump?: Bump;
27
+ purge: boolean;
28
+ yes: boolean;
29
+ dryRun: boolean;
30
+ }
31
+
32
+ /** Entry point: release bump, purge-only, or release then purge. */
33
+ async function main(): Promise<void> {
34
+ const options = parseOptions(process.argv.slice(2));
35
+ if (options.purge && !options.bump) {
36
+ await purgeStaleReleases(options);
37
+ return;
38
+ }
39
+ if (!options.bump) {
40
+ usage();
41
+ }
42
+ await runRelease(options.bump, options);
43
+ }
44
+
45
+ /** Prints usage and exits. */
46
+ function usage(): never {
47
+ process.stderr.write(
48
+ "Usage:\n" +
49
+ " bun scripts/release.ts <major|minor|patch> [--purge]\n" +
50
+ " bun scripts/release.ts --purge [--yes] [--dry-run]\n",
51
+ );
52
+ process.exit(1);
53
+ }
54
+
55
+ /** Parses argv into release options. */
56
+ function parseOptions(argv: string[]): ReleaseOptions {
57
+ const yes = argv.includes("--yes");
58
+ const dryRun = argv.includes("--dry-run");
59
+ const purge = argv.includes("--purge");
60
+ const bump = argv.find((a): a is Bump => a === "major" || a === "minor" || a === "patch");
61
+ for (const arg of argv) {
62
+ if (arg.startsWith("--") && arg !== "--purge" && arg !== "--yes" && arg !== "--dry-run") {
63
+ usage();
64
+ }
65
+ }
66
+ if (!purge && !bump) {
67
+ usage();
68
+ }
69
+ return { bump, purge, yes, dryRun };
70
+ }
71
+
72
+ /** Full release pipeline for a semver bump. */
73
+ async function runRelease(bump: Bump, options: ReleaseOptions): Promise<void> {
74
+ const testResult = await $`just test`.nothrow();
75
+ if (testResult.exitCode !== 0) process.exit(testResult.exitCode);
76
+
77
+ const currentVersion = readCurrentVersion();
78
+ const newVersion = applyBump(currentVersion, bump);
79
+ console.log(`Releasing ${currentVersion} → ${newVersion}`);
80
+
81
+ updateVersion(newVersion);
82
+ updateChangelog(newVersion);
83
+
84
+ const buildResult = await $`just build`.nothrow();
85
+ if (buildResult.exitCode !== 0) process.exit(buildResult.exitCode);
86
+
87
+ const archivePath = await updateReleaseFormula(newVersion);
88
+
89
+ const docgenResult = await $`just docgen`.nothrow();
90
+ if (docgenResult.exitCode !== 0) process.exit(docgenResult.exitCode);
91
+
92
+ await commitAndTag(newVersion);
93
+ await createGithubRelease(`v${newVersion}`, archivePath);
94
+
95
+ console.log(`Released v${newVersion}`);
96
+
97
+ if (options.purge) {
98
+ await purgeStaleReleases(options);
99
+ }
100
+ }
101
+
102
+ /** Applies a semver bump to `current` and returns the new version string. */
103
+ function applyBump(current: string, bump: Bump): string {
104
+ const [major, minor, patch] = current.split(".").map(Number) as [number, number, number];
105
+ if (bump === "major") return `${major + 1}.0.0`;
106
+ if (bump === "minor") return `${major}.${minor + 1}.0`;
107
+ return `${major}.${minor}.${patch + 1}`;
108
+ }
109
+
110
+ /** Commits all staged changes, creates an annotated tag, and pushes both to origin. */
111
+ async function commitAndTag(newVersion: string): Promise<void> {
112
+ await $`git add -A`;
113
+ await $`git commit -m ${`chore: release v${newVersion}`}`;
114
+ await $`git tag v${newVersion}`;
115
+ await $`git push`;
116
+ await $`git push origin v${newVersion}`;
117
+ }
118
+
119
+ /** Creates a GitHub release for `tag` with the zip archive attached. */
120
+ async function createGithubRelease(tag: string, archivePath: string): Promise<void> {
121
+ await $`gh release create ${tag} ${archivePath} --title ${tag} --generate-notes`;
122
+ }
123
+
124
+ /** Reads the CLI version from `src/program.ts`. */
125
+ function readCurrentVersion(): string {
126
+ const content = fs.readFileSync(programPath, "utf-8");
127
+ const match = /version:\s*"([^"]+)"/.exec(content);
128
+ if (!match) {
129
+ process.stderr.write(`Could not read version from ${programPath}\n`);
130
+ process.exit(1);
131
+ }
132
+ const version = match[1];
133
+ if (!version) {
134
+ process.stderr.write(`Could not read version from ${programPath}\n`);
135
+ process.exit(1);
136
+ }
137
+ const parts = version.split(".").map(Number);
138
+ if (parts.length !== 3 || parts.some(Number.isNaN)) {
139
+ process.stderr.write(`Invalid semver in ${programPath}: ${version}\n`);
140
+ process.exit(1);
141
+ }
142
+ return version;
143
+ }
144
+
145
+ /** Promotes `[Unreleased]` to a dated version section in `CHANGELOG.md`. */
146
+ function updateChangelog(newVersion: string): void {
147
+ const changelogPath = "CHANGELOG.md";
148
+ const content = fs.readFileSync(changelogPath, "utf-8");
149
+ const date = new Date().toISOString().slice(0, 10);
150
+ fs.writeFileSync(
151
+ changelogPath,
152
+ content.replace(/^## \[Unreleased\]/m, `## [Unreleased]\n\n## [${newVersion}] - ${date}`),
153
+ );
154
+ }
155
+
156
+ /** Overwrites the version literal in `src/program.ts`. */
157
+ function updateVersion(newVersion: string): void {
158
+ const content = fs.readFileSync(programPath, "utf-8");
159
+ fs.writeFileSync(programPath, content.replace(/version:\s*"[^"]+"/, `version: "${newVersion}"`));
160
+ }
161
+
162
+ /** Writes the release formula with zip URL and archive sha256; returns the archive path. */
163
+ async function updateReleaseFormula(version: string): Promise<string> {
164
+ const { archivePath, sha256 } = await buildReleaseArchive(binaryPath);
165
+ fs.writeFileSync(formulaPath, renderReleaseFormula(version, sha256));
166
+ return archivePath;
167
+ }
168
+
169
+ /** Deletes all GitHub releases except the most recent. */
170
+ async function purgeStaleReleases(options: ReleaseOptions): Promise<void> {
171
+ const list = await $`gh release list -R ${releaseRepoSlug} --json tagName,publishedAt`.nothrow();
172
+ if (list.exitCode !== 0) process.exit(list.exitCode);
173
+
174
+ const releases = JSON.parse(list.stdout.toString()) as ReleaseTag[];
175
+ const toDelete = selectStaleReleaseTags(releases);
176
+
177
+ if (toDelete.length === 0) {
178
+ console.log("No stale releases to delete.");
179
+ return;
180
+ }
181
+
182
+ console.log(`Will delete ${toDelete.length} release(s):`);
183
+ for (const tag of toDelete) {
184
+ console.log(` ${tag}`);
185
+ }
186
+
187
+ if (options.dryRun) {
188
+ return;
189
+ }
190
+
191
+ if (!options.yes) {
192
+ if (!input.isTTY) {
193
+ process.stderr.write("Not a TTY; pass --yes to confirm purge.\n");
194
+ process.exit(1);
195
+ }
196
+ const rl = readline.createInterface({ input, output });
197
+ const answer = await rl.question("Delete these releases? [y/N] ");
198
+ rl.close();
199
+ if (answer.trim().toLowerCase() !== "y") {
200
+ console.log("Aborted.");
201
+ return;
202
+ }
203
+ }
204
+
205
+ for (const tag of toDelete) {
206
+ const del = await $`gh release delete ${tag} -R ${releaseRepoSlug} --yes`.nothrow();
207
+ if (del.exitCode !== 0) process.exit(del.exitCode);
208
+ console.log(`Deleted ${tag}`);
209
+ }
210
+ }
211
+
212
+ await main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "5.1.9",
3
+ "version": "5.1.10",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.3"
@@ -32,6 +32,7 @@
32
32
  "devDependencies": {
33
33
  "@biomejs/biome": "^2.5.0",
34
34
  "@types/bun": "^1.3.12",
35
+ "dts-bundle-generator": "^9.5.1",
35
36
  "typescript": "^5.9.3"
36
37
  }
37
38
  }