From 7c27156648c15819764dd5f8685c75dde74d4ec1 Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Fri, 12 Jun 2026 12:13:02 -0700 Subject: [PATCH 1/9] strings.linux --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 05774ed..a0818b7 100644 --- a/README.md +++ b/README.md @@ -177,7 +177,7 @@ Simply run it before submitting a Pull Request! # Linux tar xzvf strings.linux.tar.gz # unzip -./strings src/ +./strings.linux src/ ``` - **MacOS**: Monterey or newer - **Linux** and **WSL** From 71d3bd535485f18e1daf07d571442dd1b82dfbff Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Fri, 12 Jun 2026 12:26:49 -0700 Subject: [PATCH 2/9] Glob Homebrew Cellar paths when locating libomp.a Hardcoded libomp versions broke the macOS build whenever Homebrew bumped the package; globbing makes the dune rule version-agnostic. --- src/quickjs/dune | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/quickjs/dune b/src/quickjs/dune index 228298b..f370ccb 100644 --- a/src/quickjs/dune +++ b/src/quickjs/dune @@ -57,8 +57,8 @@ (action (bash " OUT=\"libomp.a\" PATHS=\" - /usr/local/Cellar/libomp/17.0.6/lib/libomp.a - /opt/homebrew/Cellar/libomp/20.1.6/lib/libomp.a + /usr/local/Cellar/libomp/*/lib/libomp.a + /opt/homebrew/Cellar/libomp/*/lib/libomp.a /usr/lib/x86_64-linux-gnu/libomp.a /usr/lib/x86_64-linux-gnu/libgomp.a /usr/lib/libgomp.a From 4d5a4738e1d81f8ae4ca36239499e0b0038f4de7 Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Fri, 12 Jun 2026 12:30:34 -0700 Subject: [PATCH 3/9] Add automated release workflow On every v* tag (or manual dispatch), builds strings.linux.tar.gz via Docker and an arm64-only strings.mac, then publishes a GitHub Release with SHA256 checksums, install instructions, and a Changes section generated by Z.ai (GLM-5.1 by default). --- .github/workflows/release.yml | 350 ++++++++++++++++++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..22bdf25 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,350 @@ +name: Release + +on: + push: + tags: + - v* + workflow_dispatch: + inputs: + tag: + description: Release tag to build, such as v1.2.3 + required: true + type: string + +permissions: + contents: write + +concurrency: + group: release-${{ github.ref_name || inputs.tag }} + cancel-in-progress: false + +jobs: + build-linux: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Build Linux binary (Docker) + run: | + set -euo pipefail + docker build . -t strings:latest + STRINGS_CID="$(docker create strings:latest)" + rm -rf strings.linux strings.linux.tar.gz lib + docker cp "$STRINGS_CID":/app/strings.exe strings.linux + docker cp "$STRINGS_CID":/app/lib lib + docker rm "$STRINGS_CID" + tar czvf strings.linux.tar.gz strings.linux lib + + - name: Smoke test + run: | + set -euo pipefail + tar tzf strings.linux.tar.gz | grep -q '^strings.linux$' + tar tzf strings.linux.tar.gz | grep -q '^lib/' + docker run --rm -v "$(pwd):/work" -w /work ubuntu:22.04 \ + sh -c 'apt-get update >/dev/null && apt-get install -y musl >/dev/null && mkdir -p strings && ./strings.linux -version' + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: strings.linux.tar.gz + path: strings.linux.tar.gz + if-no-files-found: error + + build-macos: + # arm64 (Apple silicon) only; Intel Macs are not supported + runs-on: macos-latest + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + + - name: Install Homebrew dependencies + run: brew install libomp + + - name: Set up OCaml + uses: ocaml/setup-ocaml@e32b06a3e831ff2fbc6f08cf35be2085e3918014 # v3.6.1 + with: + ocaml-compiler: 5.1.1 + dune-cache: true + opam-repositories: | + default: https://github.com/ocaml/opam-repository.git + + - name: Install dependencies + run: | + opam install . --deps-only --update-invariant + npm install --no-save typescript browserify pug-lexer pug-parser pug-walk + + - name: Cache QuickJS + id: cache-quickjs + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: quickjs + key: quickjs-2021-03-27-${{ runner.os }}-${{ runner.arch }} + + - name: Install QuickJS + if: steps.cache-quickjs.outputs.cache-hit != 'true' + run: | + curl -fsSL https://bellard.org/quickjs/quickjs-2021-03-27.tar.xz -o quickjs.tar.xz + tar xvf quickjs.tar.xz && rm quickjs.tar.xz + mv quickjs-2021-03-27 quickjs + cd quickjs && make + + - name: Cache Flow + id: cache-flow + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: flow + key: flow-v0.183.1-${{ runner.os }} + + - name: Install Flow + run: | + if [ ! -d flow ]; then + git clone --branch v0.183.1 --depth 1 https://github.com/facebook/flow.git flow + fi + ln -s "$(pwd)/flow/src/parser" src/flow_parser + ln -s "$(pwd)/flow/src/third-party/sedlex" src/sedlex + ln -s "$(pwd)/flow/src/hack_forked/utils/collections" src/collections + + - name: Build macOS binary + run: | + set -euo pipefail + DUNE_PROFILE=release opam exec -- dune build src/cli/strings.exe + cp _build/default/src/cli/strings.exe strings.mac + chmod 755 strings.mac + strip strings.mac + + - name: Smoke test + run: | + mkdir -p strings + ./strings.mac -version + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: strings.mac + path: strings.mac + if-no-files-found: error + + release: + needs: [build-linux, build-macos] + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + ref: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} + fetch-depth: 0 + + - name: Resolve release tag + env: + DISPATCH_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + + if [ "${GITHUB_REF_TYPE}" = "tag" ]; then + TAG="${GITHUB_REF_NAME}" + else + TAG="${DISPATCH_TAG}" + fi + + if [[ ! "${TAG}" =~ ^v[0-9]+(\.[0-9]+)*([-+][0-9A-Za-z.-]+)?$ ]]; then + echo "Invalid release tag: ${TAG}" >&2 + exit 1 + fi + + echo "TAG=${TAG}" >> "${GITHUB_ENV}" + + SOURCE_VERSION="$(sed -n 's/^let version = "\(.*\)"$/\1/p' src/cli/strings.ml)" + if [ "v${SOURCE_VERSION}" != "${TAG}" ]; then + echo "::warning::Tag ${TAG} does not match version v${SOURCE_VERSION} in src/cli/strings.ml" + fi + + - name: Download artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + merge-multiple: true + + - name: Generate changes section + env: + GH_TOKEN: ${{ github.token }} + ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }} + RELEASE_MODEL: ${{ vars.RELEASE_MODEL }} + run: | + set -euo pipefail + + node --input-type=module <<'NODE' + import { execFileSync } from "node:child_process"; + import { mkdirSync, writeFileSync } from "node:fs"; + + const apiKey = process.env.ZAI_API_KEY; + const model = process.env.RELEASE_MODEL || "glm-5.1"; + const repo = process.env.GITHUB_REPOSITORY; + const currentTag = process.env.TAG; + + const run = (command, args) => execFileSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); + + const exists = (ref) => { + try { + execFileSync("git", ["rev-parse", "--verify", "--quiet", ref], { + stdio: "ignore", + }); + return true; + } catch { + return false; + } + }; + + const isAncestor = (ref) => { + try { + execFileSync("git", ["merge-base", "--is-ancestor", ref, currentTag], { + stdio: "ignore", + }); + return true; + } catch { + return false; + } + }; + + const writeChanges = (notes) => { + mkdirSync("dist", { recursive: true }); + writeFileSync("dist/changes.md", notes ? `${notes}\n` : ""); + }; + + try { + if (!apiKey) throw new Error("ZAI_API_KEY is required"); + if (!repo) throw new Error("GITHUB_REPOSITORY is required"); + if (!currentTag) throw new Error("TAG is required"); + + execFileSync("git", ["fetch", "--force", "--tags"], { stdio: "inherit" }); + + let previousTag = ""; + try { + const releases = JSON.parse(run("gh", [ + "release", + "list", + "--repo", + repo, + "--exclude-drafts", + "--limit", + "100", + "--json", + "tagName", + ])); + previousTag = releases + .map((release) => release.tagName) + .find((tag) => tag !== currentTag && exists(`refs/tags/${tag}`) && isAncestor(tag)) ?? ""; + } catch (error) { + console.error(`Failed to find previous GitHub release: ${error.message}`); + } + + if (!previousTag) { + previousTag = run("git", ["tag", "--merged", currentTag, "--sort=-creatordate", "--list", "v*"]) + .split("\n") + .find((tag) => tag && tag !== currentTag) ?? ""; + } + + const range = previousTag ? `${previousTag}..${currentTag}` : currentTag; + const commitMessages = run("git", ["log", "--format=%B%n---END COMMIT---", range]); + const previousRelease = previousTag || "the beginning of the repository"; + + const response = await fetch("https://api.z.ai/api/coding/paas/v4/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + temperature: 0.2, + max_tokens: 2000, + messages: [ + { + role: "system", + content: "You write concise GitHub release notes from git commit messages. Return only Markdown. Use level 4 headings only for non-empty sections named Features, Improvements, and Bugfixes. Each bullet must be one brief sentence in the form '- Short change - extremely short description'. Mention only important user-visible changes. Omit chores, tests, docs, refactors, and CI unless they materially affect users. Do not include code fences, introductions, conclusions, or empty sections. If there are no important changes, return an empty string.", + }, + { + role: "user", + content: `Summarize important changes in ${currentTag} since ${previousRelease}.\n\nCommit messages:\n${commitMessages}`, + }, + ], + }), + }); + + if (!response.ok) { + throw new Error(`Z.ai release notes request failed with ${response.status}: ${await response.text()}`); + } + + const payload = await response.json(); + const notes = (payload.choices?.[0]?.message?.content ?? "") + .replace(/^```(?:markdown)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim(); + const generatedNote = `Release notes generated by ${model}.`; + writeChanges(notes ? `${notes}\n\n${generatedNote}` : generatedNote); + } catch (error) { + // Release notes are best-effort: never fail the release over them. + console.error(`::warning::Failed to generate release notes: ${error.message}`); + writeChanges(""); + } + NODE + + - name: Compose release body + run: | + set -euo pipefail + + SHA_MAC="$(sha256sum dist/strings.mac | awk '{print $1}')" + SHA_LINUX="$(sha256sum dist/strings.linux.tar.gz | awk '{print $1}')" + + { + echo '```' + echo "${SHA_MAC} strings.mac" + echo "${SHA_LINUX} strings.linux.tar.gz" + echo '```' + echo + echo '**MacOS**' + echo 'First time setup: `chmod +x strings.mac && xattr -d com.apple.quarantine strings.mac`' + echo 'Usage: `./strings.mac src/`' + echo + echo '**Linux**' + echo 'First time setup: `tar xzvf strings.linux.tar.gz`' + echo 'Usage: `./strings.linux src/`' + if [ -s dist/changes.md ]; then + echo + echo '## Changes' + echo + cat dist/changes.md + fi + } > dist/release-notes.md + + cat dist/release-notes.md + + - name: Create or update GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + if gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" >/dev/null 2>&1; then + gh release edit "${TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --notes-file dist/release-notes.md + gh release upload "${TAG}" dist/strings.mac dist/strings.linux.tar.gz \ + --repo "${GITHUB_REPOSITORY}" \ + --clobber + else + gh release create "${TAG}" dist/strings.mac dist/strings.linux.tar.gz \ + --repo "${GITHUB_REPOSITORY}" \ + --title "${TAG}" \ + --notes-file dist/release-notes.md + fi From fe143a74378c9474cf89ff124e07ce64e2dbcb61 Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Fri, 12 Jun 2026 12:33:52 -0700 Subject: [PATCH 4/9] Extract release notes generation into scripts/ Moves the inline node heredoc out of release.yml for readability and local testability; validated with actionlint and a no-credentials dry run of the fallback path. --- .github/workflows/release.yml | 122 +--------------------------- scripts/generate-release-notes.mjs | 123 +++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+), 121 deletions(-) create mode 100644 scripts/generate-release-notes.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22bdf25..ffc3616 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -177,127 +177,7 @@ jobs: GH_TOKEN: ${{ github.token }} ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }} RELEASE_MODEL: ${{ vars.RELEASE_MODEL }} - run: | - set -euo pipefail - - node --input-type=module <<'NODE' - import { execFileSync } from "node:child_process"; - import { mkdirSync, writeFileSync } from "node:fs"; - - const apiKey = process.env.ZAI_API_KEY; - const model = process.env.RELEASE_MODEL || "glm-5.1"; - const repo = process.env.GITHUB_REPOSITORY; - const currentTag = process.env.TAG; - - const run = (command, args) => execFileSync(command, args, { - encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], - }).trim(); - - const exists = (ref) => { - try { - execFileSync("git", ["rev-parse", "--verify", "--quiet", ref], { - stdio: "ignore", - }); - return true; - } catch { - return false; - } - }; - - const isAncestor = (ref) => { - try { - execFileSync("git", ["merge-base", "--is-ancestor", ref, currentTag], { - stdio: "ignore", - }); - return true; - } catch { - return false; - } - }; - - const writeChanges = (notes) => { - mkdirSync("dist", { recursive: true }); - writeFileSync("dist/changes.md", notes ? `${notes}\n` : ""); - }; - - try { - if (!apiKey) throw new Error("ZAI_API_KEY is required"); - if (!repo) throw new Error("GITHUB_REPOSITORY is required"); - if (!currentTag) throw new Error("TAG is required"); - - execFileSync("git", ["fetch", "--force", "--tags"], { stdio: "inherit" }); - - let previousTag = ""; - try { - const releases = JSON.parse(run("gh", [ - "release", - "list", - "--repo", - repo, - "--exclude-drafts", - "--limit", - "100", - "--json", - "tagName", - ])); - previousTag = releases - .map((release) => release.tagName) - .find((tag) => tag !== currentTag && exists(`refs/tags/${tag}`) && isAncestor(tag)) ?? ""; - } catch (error) { - console.error(`Failed to find previous GitHub release: ${error.message}`); - } - - if (!previousTag) { - previousTag = run("git", ["tag", "--merged", currentTag, "--sort=-creatordate", "--list", "v*"]) - .split("\n") - .find((tag) => tag && tag !== currentTag) ?? ""; - } - - const range = previousTag ? `${previousTag}..${currentTag}` : currentTag; - const commitMessages = run("git", ["log", "--format=%B%n---END COMMIT---", range]); - const previousRelease = previousTag || "the beginning of the repository"; - - const response = await fetch("https://api.z.ai/api/coding/paas/v4/chat/completions", { - method: "POST", - headers: { - "Authorization": `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model, - temperature: 0.2, - max_tokens: 2000, - messages: [ - { - role: "system", - content: "You write concise GitHub release notes from git commit messages. Return only Markdown. Use level 4 headings only for non-empty sections named Features, Improvements, and Bugfixes. Each bullet must be one brief sentence in the form '- Short change - extremely short description'. Mention only important user-visible changes. Omit chores, tests, docs, refactors, and CI unless they materially affect users. Do not include code fences, introductions, conclusions, or empty sections. If there are no important changes, return an empty string.", - }, - { - role: "user", - content: `Summarize important changes in ${currentTag} since ${previousRelease}.\n\nCommit messages:\n${commitMessages}`, - }, - ], - }), - }); - - if (!response.ok) { - throw new Error(`Z.ai release notes request failed with ${response.status}: ${await response.text()}`); - } - - const payload = await response.json(); - const notes = (payload.choices?.[0]?.message?.content ?? "") - .replace(/^```(?:markdown)?\s*/i, "") - .replace(/\s*```$/i, "") - .trim(); - const generatedNote = `Release notes generated by ${model}.`; - writeChanges(notes ? `${notes}\n\n${generatedNote}` : generatedNote); - } catch (error) { - // Release notes are best-effort: never fail the release over them. - console.error(`::warning::Failed to generate release notes: ${error.message}`); - writeChanges(""); - } - NODE + run: node scripts/generate-release-notes.mjs - name: Compose release body run: | diff --git a/scripts/generate-release-notes.mjs b/scripts/generate-release-notes.mjs new file mode 100644 index 0000000..667335f --- /dev/null +++ b/scripts/generate-release-notes.mjs @@ -0,0 +1,123 @@ +// Generates the "## Changes" section content for a GitHub Release by +// summarizing commit messages since the previous release with Z.ai. +// Writes the result to dist/changes.md (empty file on failure — release +// notes are best-effort and must never fail the release). +// +// Required environment: TAG, GITHUB_REPOSITORY, GH_TOKEN, ZAI_API_KEY. +// Optional: RELEASE_MODEL (defaults to glm-5.1). +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; + +const apiKey = process.env.ZAI_API_KEY; +const model = process.env.RELEASE_MODEL || "glm-5.1"; +const repo = process.env.GITHUB_REPOSITORY; +const currentTag = process.env.TAG; + +const run = (command, args) => execFileSync(command, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], +}).trim(); + +const exists = (ref) => { + try { + execFileSync("git", ["rev-parse", "--verify", "--quiet", ref], { + stdio: "ignore", + }); + return true; + } catch { + return false; + } +}; + +const isAncestor = (ref) => { + try { + execFileSync("git", ["merge-base", "--is-ancestor", ref, currentTag], { + stdio: "ignore", + }); + return true; + } catch { + return false; + } +}; + +const writeChanges = (notes) => { + mkdirSync("dist", { recursive: true }); + writeFileSync("dist/changes.md", notes ? `${notes}\n` : ""); +}; + +try { + if (!apiKey) throw new Error("ZAI_API_KEY is required"); + if (!repo) throw new Error("GITHUB_REPOSITORY is required"); + if (!currentTag) throw new Error("TAG is required"); + + execFileSync("git", ["fetch", "--force", "--tags"], { stdio: "inherit" }); + + let previousTag = ""; + try { + const releases = JSON.parse(run("gh", [ + "release", + "list", + "--repo", + repo, + "--exclude-drafts", + "--limit", + "100", + "--json", + "tagName", + ])); + previousTag = releases + .map((release) => release.tagName) + .find((tag) => tag !== currentTag && exists(`refs/tags/${tag}`) && isAncestor(tag)) ?? ""; + } catch (error) { + console.error(`Failed to find previous GitHub release: ${error.message}`); + } + + if (!previousTag) { + previousTag = run("git", ["tag", "--merged", currentTag, "--sort=-creatordate", "--list", "v*"]) + .split("\n") + .find((tag) => tag && tag !== currentTag) ?? ""; + } + + const range = previousTag ? `${previousTag}..${currentTag}` : currentTag; + const commitMessages = run("git", ["log", "--format=%B%n---END COMMIT---", range]); + const previousRelease = previousTag || "the beginning of the repository"; + + const response = await fetch("https://api.z.ai/api/coding/paas/v4/chat/completions", { + method: "POST", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + temperature: 0.2, + max_tokens: 2000, + messages: [ + { + role: "system", + content: "You write concise GitHub release notes from git commit messages. Return only Markdown. Use level 4 headings only for non-empty sections named Features, Improvements, and Bugfixes. Each bullet must be one brief sentence in the form '- Short change - extremely short description'. Mention only important user-visible changes. Omit chores, tests, docs, refactors, and CI unless they materially affect users. Do not include code fences, introductions, conclusions, or empty sections. If there are no important changes, return an empty string.", + }, + { + role: "user", + content: `Summarize important changes in ${currentTag} since ${previousRelease}.\n\nCommit messages:\n${commitMessages}`, + }, + ], + }), + }); + + if (!response.ok) { + throw new Error(`Z.ai release notes request failed with ${response.status}: ${await response.text()}`); + } + + const payload = await response.json(); + const notes = (payload.choices?.[0]?.message?.content ?? "") + .replace(/^```(?:markdown)?\s*/i, "") + .replace(/\s*```$/i, "") + .trim(); + const generatedNote = `Release notes generated by ${model}.`; + writeChanges(notes ? `${notes}\n\n${generatedNote}` : generatedNote); +} catch (error) { + // Release notes are best-effort: never fail the release over them. + console.error(`::warning::Failed to generate release notes: ${error.message}`); + writeChanges(""); +} From ae92f7b6990fdfe3c83c0b57b7f2f3d86eb6b3b7 Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Fri, 12 Jun 2026 12:35:28 -0700 Subject: [PATCH 5/9] Document the automated release process Adds a Releases section to DEVELOPMENT.md (tag-push flow, required ZAI_API_KEY secret, arm64-only macOS support) and updates AGENTS.md release and libomp notes accordingly. --- AGENTS.md | 5 +++-- DEVELOPMENT.md | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0a963ee..547b3cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,9 +60,10 @@ This runs both the inline unit tests (`tests/test_runner.ml`) and an integration - **Flow symlinks**: `src/flow_parser`, `src/sedlex`, and `src/collections` are symlinks into a cloned `flow` repo (v0.183.1) at the project root. If they're missing or dangling, builds fail with module errors. Recreate per `DEVELOPMENT.md`. - **QuickJS dependency**: Requires a compiled `quickjs` directory (quickjs-2021-03-27, `make` run) at the project root. `dune` rules in `src/quickjs/dune` copy `quickjs.h`, `libquickjs.a`, and invoke `quickjs/qjsc` from there. - **Generated runtime**: `src/quickjs/runtime.h` is generated at build time from `src/quickjs/parsers.js` via `npx browserify` then `qjsc`. Requires `npm install --no-save typescript browserify pug-lexer pug-parser pug-walk` at the repo root. -- **libomp**: `src/quickjs/dune` searches a hardcoded list of paths for `libomp.a`/`libgomp.a` (Homebrew Cellar paths on macOS, `/usr/lib/...` on Linux). If your system has it elsewhere, the build fails with "Could not find libomp.a" — add your path to the list in `src/quickjs/dune`. +- **libomp**: `src/quickjs/dune` searches a fixed list of paths (with globs) for `libomp.a`/`libgomp.a` (Homebrew Cellar globs on macOS, `/usr/lib/...` on Linux). If your system has it elsewhere, the build fails with "Could not find libomp.a" — add your path to the list in `src/quickjs/dune`. - **Link flags**: Platform/profile-specific link flags live in `src/cli/link_flags.{system}.{dev,release}.dune` (the Linux dev one is just `()`). A missing file for your platform/profile combination breaks the build. -- **Version number**: `let version = "x.y.z"` in `src/cli/strings.ml` must be bumped manually for releases. +- **Version number**: `let version = "x.y.z"` in `src/cli/strings.ml` must be bumped manually for releases (the release workflow warns if the tag doesn't match). +- **Releases are automated**: pushing a `v*` tag triggers `.github/workflows/release.yml`, which builds `strings.linux.tar.gz` (Docker) and `strings.mac` (arm64-only, `macos-latest`), then publishes a GitHub Release with SHA256 checksums and a `## Changes` section generated by `scripts/generate-release-notes.mjs` (Z.ai; needs `ZAI_API_KEY` secret, optional `RELEASE_MODEL` var, defaults to `glm-5.1`). Notes generation failures never fail the release. See "Releases" in `DEVELOPMENT.md`. ## Code Conventions & Patterns diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index bca2660..e34cef6 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -79,3 +79,26 @@ dune runtest tests/ ``` This builds the project, runs the inline unit tests, and executes the integration test (which verifies extraction and translation preservation in an isolated temporary directory). + +## Releases + +Releases are automated by [`.github/workflows/release.yml`](.github/workflows/release.yml). Pushing any tag starting with `v` (e.g. `v2.3.0`) triggers the workflow, which: + +1. Builds `strings.linux.tar.gz` (via the `Dockerfile`, same as the manual Docker flow above). +2. Builds `strings.mac` on a `macos-latest` runner. **The macOS binary is arm64-only (Apple silicon); Intel Macs are not supported.** +3. Publishes a GitHub Release titled after the tag, with both binaries attached. The release body contains SHA256 checksums, setup/usage instructions, and a `## Changes` section with release notes generated from the commit messages since the previous release. + +To cut a release: + +```sh +# 1. Update `let version = "x.y.z"` in src/cli/strings.ml (the workflow warns on mismatch) +# 2. Commit, then: +git tag vx.y.z && git push origin vx.y.z +``` + +The workflow can also be run manually from the Actions tab (`workflow_dispatch`) by entering an existing tag — useful for retrying a failed release; it updates the existing release idempotently. + +Repository configuration: + +- **Secret `ZAI_API_KEY`** (required for release notes): a [Z.ai](https://z.ai) API key used by `scripts/generate-release-notes.mjs`. If missing or the API call fails, the release still succeeds, just without the `## Changes` section. +- **Variable `RELEASE_MODEL`** (optional): the Z.ai model used for release notes; defaults to `glm-5.1`. From 8935610c8f54279a0e06b86f4e7ecd42d44aa9c3 Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Fri, 12 Jun 2026 12:41:41 -0700 Subject: [PATCH 6/9] bump version to 2.3.0 --- src/cli/strings.ml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/strings.ml b/src/cli/strings.ml index 3c8c088..00417d7 100644 --- a/src/cli/strings.ml +++ b/src/cli/strings.ml @@ -2,7 +2,7 @@ open! Core open Lwt.Infix open Lwt.Syntax -let version = "2.2.2" +let version = "2.3.0" let header = sprintf "/* Generated by okTurtles/strings v%s */\n\n" version From fa48c590200fd1d096d27e9f7ecfdb5796afc5f7 Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Fri, 12 Jun 2026 12:56:45 -0700 Subject: [PATCH 7/9] AI section to readme --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index a0818b7..637548d 100644 --- a/README.md +++ b/README.md @@ -185,3 +185,11 @@ tar xzvf strings.linux.tar.gz # unzip - Debian 11 (Bullseye) or newer - You'll need to install your Linux distribution's `musl` package: `apt-get install musl` - Make sure the `lib` directory stays in the same directory as `strings.linux` + +## Historical AI Usage + +This project is almost entirely hand-written by @SGrondin. + +Recent features have been done by @taoeffect under Opus 4.6 (tests), and Fable 5 (astro support + automated releases). + +Future releases may continue to involve AI usage, and all such usage must be disclosed in PR descriptions. From b35a7006ca1caa6ae37ea78e98270f1dafc0f4ec Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Fri, 12 Jun 2026 13:05:17 -0700 Subject: [PATCH 8/9] Harden release workflow per code review - Key concurrency group off inputs.tag on workflow_dispatch so retries for a tag share a group with its push run instead of racing it - Skip the Changes section (with a warning) when dispatching on a tag that predates scripts/generate-release-notes.mjs, instead of failing the release - Omit the Changes section entirely when the model reports no important changes, rather than emitting an attribution-only section - Cap release-notes input at 200 commits to bound the API payload when no previous tag is found - Smoke test: escape/anchor the tar grep pattern, add DEBIAN_FRONTEND=noninteractive, drop unneeded mkdir (-version runs before the project-root check) --- .github/workflows/release.yml | 14 ++++++++++---- scripts/generate-release-notes.mjs | 4 ++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ffc3616..dac007d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ permissions: contents: write concurrency: - group: release-${{ github.ref_name || inputs.tag }} + group: release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} cancel-in-progress: false jobs: @@ -42,10 +42,10 @@ jobs: - name: Smoke test run: | set -euo pipefail - tar tzf strings.linux.tar.gz | grep -q '^strings.linux$' + tar tzf strings.linux.tar.gz | grep -qx 'strings\.linux' tar tzf strings.linux.tar.gz | grep -q '^lib/' docker run --rm -v "$(pwd):/work" -w /work ubuntu:22.04 \ - sh -c 'apt-get update >/dev/null && apt-get install -y musl >/dev/null && mkdir -p strings && ./strings.linux -version' + sh -c 'apt-get update >/dev/null && DEBIAN_FRONTEND=noninteractive apt-get install -y musl >/dev/null && ./strings.linux -version' - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -177,7 +177,13 @@ jobs: GH_TOKEN: ${{ github.token }} ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }} RELEASE_MODEL: ${{ vars.RELEASE_MODEL }} - run: node scripts/generate-release-notes.mjs + run: | + if [ -f scripts/generate-release-notes.mjs ]; then + node scripts/generate-release-notes.mjs + else + echo "::warning::generate-release-notes.mjs not present at this ref; skipping Changes section" + mkdir -p dist && : > dist/changes.md + fi - name: Compose release body run: | diff --git a/scripts/generate-release-notes.mjs b/scripts/generate-release-notes.mjs index 667335f..343a663 100644 --- a/scripts/generate-release-notes.mjs +++ b/scripts/generate-release-notes.mjs @@ -79,7 +79,7 @@ try { } const range = previousTag ? `${previousTag}..${currentTag}` : currentTag; - const commitMessages = run("git", ["log", "--format=%B%n---END COMMIT---", range]); + const commitMessages = run("git", ["log", "--max-count=200", "--format=%B%n---END COMMIT---", range]); const previousRelease = previousTag || "the beginning of the repository"; const response = await fetch("https://api.z.ai/api/coding/paas/v4/chat/completions", { @@ -115,7 +115,7 @@ try { .replace(/\s*```$/i, "") .trim(); const generatedNote = `Release notes generated by ${model}.`; - writeChanges(notes ? `${notes}\n\n${generatedNote}` : generatedNote); + writeChanges(notes ? `${notes}\n\n${generatedNote}` : ""); } catch (error) { // Release notes are best-effort: never fail the release over them. console.error(`::warning::Failed to generate release notes: ${error.message}`); From b60edd61215720343ea05c46b7d6ececba6a8425 Mon Sep 17 00:00:00 2001 From: Greg Slepak Date: Fri, 12 Jun 2026 13:14:02 -0700 Subject: [PATCH 9/9] Emit ::warning:: annotation to stdout GitHub Actions only processes workflow commands from stdout, so the release-notes failure warning sent via console.error was not guaranteed to surface as an annotation. --- scripts/generate-release-notes.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/generate-release-notes.mjs b/scripts/generate-release-notes.mjs index 343a663..0c45c25 100644 --- a/scripts/generate-release-notes.mjs +++ b/scripts/generate-release-notes.mjs @@ -118,6 +118,7 @@ try { writeChanges(notes ? `${notes}\n\n${generatedNote}` : ""); } catch (error) { // Release notes are best-effort: never fail the release over them. - console.error(`::warning::Failed to generate release notes: ${error.message}`); + // Workflow commands are only processed from stdout, hence console.log. + console.log(`::warning::Failed to generate release notes: ${error.message}`); writeChanges(""); }