diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..dac007d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,236 @@ +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.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + 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 -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 && DEBIAN_FRONTEND=noninteractive apt-get install -y musl >/dev/null && ./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: | + 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: | + 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 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`. diff --git a/README.md b/README.md index 05774ed..637548d 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** @@ -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. diff --git a/scripts/generate-release-notes.mjs b/scripts/generate-release-notes.mjs new file mode 100644 index 0000000..0c45c25 --- /dev/null +++ b/scripts/generate-release-notes.mjs @@ -0,0 +1,124 @@ +// 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", "--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", { + 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}` : ""); +} catch (error) { + // Release notes are best-effort: never fail the release over them. + // Workflow commands are only processed from stdout, hence console.log. + console.log(`::warning::Failed to generate release notes: ${error.message}`); + writeChanges(""); +} 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 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