diff --git a/.github/configuration.json b/.github/configuration.json new file mode 100644 index 0000000..98951d0 --- /dev/null +++ b/.github/configuration.json @@ -0,0 +1,18 @@ +{ + "categories": [ + { "title": "### ๐Ÿš€ Features", "labels": ["feature"] }, + { "title": "### ๐Ÿ› Fixes", "labels": ["fix"] }, + { "title": "### โ™ป๏ธ Refactoring", "labels": ["refactor"] }, + { "title": "### ๐Ÿ“ Documentation", "labels": ["docs"] }, + { "title": "### ๐Ÿงช Tests", "labels": ["test"] }, + { "title": "### ๐Ÿ”ง Maintenance", "labels": ["ci", "chore"] }, + { "title": "### ๐Ÿ“ฆ Other", "labels": [] } + ], + "ignore_labels": ["ignore-for-release"], + "sort": { "order": "ASC", "on_property": "mergedAt" }, + "template": "#{{CHANGELOG}}", + "pr_template": "- #{{TITLE}} (##{{NUMBER}}) @#{{AUTHOR}}", + "empty_template": "- _No user-facing changes._", + "max_pull_requests": 200, + "max_back_track_time_days": 365 +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..f9252c3 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,347 @@ +name: Release (binaries + Python wheels) + +# Builds the self-contained caniac binary for every supported platform and +# publishes: +# 1. the raw binaries, with a SHA256SUMS manifest, as GitHub Release assets +# (for analysis_backend_path / download-on-first-use use cases), and +# 2. platform-tagged Python wheels to PyPI as `codeanalyzer-iac`. +# +# The analyzer is CGO-free, so one Linux host cross-compiles every target with +# the stock Go toolchain and a single job suffices. + +on: + push: + tags: + - "v*.*.*" + workflow_dispatch: {} + +jobs: + release: + runs-on: ubuntu-latest + # Scoped to this job: the homebrew job below needs neither, and nothing here + # needs `discussions: write` -- the only discussion this workflow writes is + # the org-level one, and that uses its own token on another repository. + permissions: + contents: write # create GitHub Release + delete tag on failure + id-token: write # PyPI Trusted Publishing (OIDC) -- no API token needed + steps: + - name: Check out code + uses: actions/checkout@v5 + + # The tag is the single source of the release version (vX.Y.Z -> X.Y.Z): + # it becomes the wheel version, the -X main.version stamped into the + # binary, and therefore analyzer.version in every emitted document. There + # is no second place to keep in sync -- but the tag must be a version both + # PEP 440 and Homebrew accept, so reject anything else before building. + - name: Determine and verify version + id: ver + run: | + if [[ "${GITHUB_REF}" == refs/tags/* ]]; then + version="${GITHUB_REF#refs/tags/v}" + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?$ ]]; then + echo "::error::Tag v$version is not a PEP 440 release version (X.Y.Z, optionally aN/bN/rcN)." + exit 1 + fi + else + # workflow_dispatch is a dry run: every publishing step below is gated + # on refs/tags/, so this version never reaches PyPI or a Release. + version="0.0.0.dev0" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + echo ">>> Releasing version $version" + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Download dependencies + run: go mod download + + # ----- test gate: a broken build must not produce a release ----- + # The offline gates only. The parity gate needs a Neo4j service and the + # live gate needs a network, Helm and a schema checkout; both run in + # ci.yml and live.yml, and neither belongs on the release path. + # No continue-on-error: a failure here must fail the job. The tag is then + # removed by the cleanup step at the end of the pre-publish path below, + # which `if: failure()` reaches even though every ordinary step is skipped. + - name: Vet, test and schema drift + run: | + make vet + make test + make schema-check + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Python build tooling + # hatchling is the build backend; build_wheels.sh runs `python -m build + # --no-isolation`, so it must be present in this env (no auto-install). + run: python -m pip install --upgrade build wheel hatchling auditwheel + + - name: Build platform wheels (cross-compiles every target with Go) + working-directory: packaging/python + env: + PKG_VERSION: ${{ steps.ver.outputs.version }} + run: ./build_wheels.sh + + # The bundled binaries are statically linked (CGO_ENABLED=0), so the Linux + # wheels reference no external shared library at all and `auditwheel repair` + # would have nothing to bundle -- the manylinux_2_17 tag build_wheels.sh + # applies is already correct, and auditwheel confirms it. `show` is therefore + # the check, and it fails the release if a future dependency reintroduces cgo. + - name: Check Linux wheels are manylinux-clean + working-directory: packaging/python + run: | + for whl in dist/*manylinux*.whl; do + echo "----- auditwheel show $whl" + auditwheel show "$whl" + done + + # Only the wheel matching the runner (x86-64 Linux) can actually execute, so + # it gets the full install-and-run smoke test; the other four are checked + # structurally (the platform-tagged binary is present under _bin/). + - name: Smoke-test the wheels + working-directory: packaging/python + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + set -euo pipefail + host_whl="$(ls dist/*manylinux_2_17_x86_64.whl)" + python -m venv "$RUNNER_TEMP/smoke" + "$RUNNER_TEMP/smoke/bin/pip" install --quiet "$host_whl" + got="$("$RUNNER_TEMP/smoke/bin/caniac" --version)" + echo ">>> $got" + [[ "$got" == "caniac version $VERSION" ]] || { + echo "::error::$host_whl reports '$got', expected 'caniac version $VERSION'"; exit 1; } + ( cd "$GITHUB_WORKSPACE" && "$RUNNER_TEMP/smoke/bin/caniac" testdata/helm/profiles \ + --app-name payments -a 1 --emit json > /dev/null ) \ + || { echo "::error::the installed binary failed to analyze the fixture chart"; exit 1; } + for whl in dist/*.whl; do + [[ "$whl" == "$host_whl" ]] && continue + echo "----- structure check $whl" + unzip -l "$whl" | grep -E 'codeanalyzer_iac/_bin/caniac(\.exe)?$' \ + || { echo "::error::$whl carries no caniac binary"; exit 1; } + done + + - name: Extract raw binaries from wheels (for GitHub Release) + working-directory: packaging/python + run: | + mkdir -p ../../release-bins + for whl in dist/*.whl; do + plat="$(basename "$whl" .whl | sed 's/.*-py3-none-//')" + tmp="$(mktemp -d)" + python -m zipfile -e "$whl" "$tmp" + bin="$(find "$tmp/codeanalyzer_iac/_bin" -type f -name 'caniac*')" + ext=""; [[ "$bin" == *.exe ]] && ext=".exe" + cp "$bin" "../../release-bins/caniac-${plat}${ext}" + done + ls -lh ../../release-bins + + # The Neo4j schema contract is platform-independent and version-locked to this + # build: `make schema-check` (the gate above) proves this file is byte-identical + # to the copy the binary embeds and emits with `caniac --emit schema`. + - name: Stage the Neo4j schema contract (release asset) + run: cp schema.neo4j.json release-bins/schema.neo4j.json + + # Publish the cargo-dist-style install script so users can: + # curl --proto '=https' --tlsv1.2 -LsSf .../releases/latest/download/caniac-installer.sh | sh + - name: Stage the install script (release asset) + run: cp packaging/install/caniac-installer.sh release-bins/caniac-installer.sh + + # One manifest over every asset, so a download can be verified without + # trusting the release page. Generated last, so it covers everything. + - name: Checksum the release assets + working-directory: release-bins + run: | + sha256sum ./* > SHA256SUMS + cat SHA256SUMS + + - name: Build changelog (auto-generated from commits/PRs) + id: changelog + if: startsWith(github.ref, 'refs/tags/') + uses: mikepenz/release-changelog-builder-action@v5 + with: + configuration: ".github/configuration.json" + failOnError: "false" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # cargo-dist-style notes: install one-liners + a download table, then the auto-generated + # changelog from the step above (kept). Indented code blocks avoid backticks in the heredoc. + - name: Compose release notes + id: notes + if: startsWith(github.ref, 'refs/tags/') + env: + VERSION: ${{ steps.ver.outputs.version }} + CHANGELOG: ${{ steps.changelog.outputs.changelog }} + run: | + REPO="codellm-devkit/codeanalyzer-iac" + BASE="https://github.com/$REPO/releases/download/v$VERSION" + cat > "$RUNNER_TEMP/RELEASE_BODY.md" <> "$GITHUB_OUTPUT" + + - name: Download release binaries + uses: actions/download-artifact@v4 + with: + name: release-bins + path: release-bins + + - name: Generate Homebrew formula + env: + REPO: ${{ github.repository }} + VERSION: ${{ steps.ver.outputs.version }} + run: | + ./packaging/homebrew/generate_formula.sh release-bins > codeanalyzer-iac.rb + cat codeanalyzer-iac.rb + + - name: Push formula to codellm-devkit/homebrew-tap + env: + TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} # PAT with write access to homebrew-tap + VERSION: ${{ steps.ver.outputs.version }} + run: | + git clone "https://x-access-token:${TAP_TOKEN}@github.com/codellm-devkit/homebrew-tap.git" tap + mkdir -p tap/Formula + cp codeanalyzer-iac.rb tap/Formula/codeanalyzer-iac.rb + cd tap + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/codeanalyzer-iac.rb + git commit -m "codeanalyzer-iac ${VERSION}" || { echo "no formula change"; exit 0; } + git push diff --git a/.gitignore b/.gitignore index b70904e..db7a540 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,15 @@ /analysis.json /graph.cypher /coverage.out +/release-bins/ + +# Packaging build outputs. The per-platform binary under _bin/ and the README +# copied in as the wheel's long description are both produced by +# packaging/python/build_wheels.sh and never committed. +/packaging/python/dist/ +/packaging/python/README.md +/packaging/python/src/codeanalyzer_iac/_bin/* +!/packaging/python/src/codeanalyzer_iac/_bin/.gitignore .DS_Store # SCHEMA_DECISIONS.md is repository content. A global ignore of `.claude` diff --git a/Makefile b/Makefile index 3afeefc..8e492d3 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build sync-schema test race vet schema-check fuzz-smoke test-live test-live-head +.PHONY: build wheels sync-schema test race vet schema-check fuzz-smoke test-live test-live-head # VERSION is the one version the binary reports: --version prints it and every # analysis document is stamped with it. Override it for a release build. @@ -7,6 +7,13 @@ VERSION ?= 0.1.0-dev build: go build -ldflags "-X main.version=$(VERSION)" -o caniac ./cmd/codeanalyzer-iac +# The five platform-tagged PyPI wheels, cross-compiled from this host. VERSION +# must be a PEP 440 version here, not the 0.1.0-dev default, because it is the +# wheel version as well as the stamped main.version. Release CI sets it from the +# git tag; locally: `make wheels VERSION=0.1.0rc0`. +wheels: + PKG_VERSION=$(VERSION) packaging/python/build_wheels.sh + sync-schema: cp schema.json internal/contract/schema.json cp schema.neo4j.json internal/contract/schema.neo4j.json diff --git a/README.md b/README.md index 6c48ea7..1cdaefd 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,26 @@ The root `schema.json` (2.0.0) and `schema.neo4j.json` (1.0.0) are copied byte-for-byte from that revision. Run `make sync-schema` before testing after a contract update. -## Install and build +## Install + +Every release publishes the same self-contained `caniac` binary three ways. It +has no runtime dependency of any kind. + +```sh +pip install codeanalyzer-iac # the platform wheel; puts `caniac` on PATH +brew install codellm-devkit/tap/codeanalyzer-iac +curl --proto '=https' --tlsv1.2 -LsSf \ + https://github.com/codellm-devkit/codeanalyzer-iac/releases/latest/download/caniac-installer.sh | sh +``` + +The PyPI package is the one CLDK's Python SDK depends on: +`codeanalyzer_iac.bin_path()` returns the bundled executable. The raw +per-platform binaries, a `SHA256SUMS` manifest and the version-locked +`schema.neo4j.json` are also attached to each [GitHub +Release](https://github.com/codellm-devkit/codeanalyzer-iac/releases) for direct +download. + +## Build ```sh make build # -o caniac, version stamped from VERSION (0.1.0-dev) @@ -32,6 +51,23 @@ binary named `codeanalyzer-iac`, and the two behave identically. Building needs the Go version declared in `go.mod` and nothing else. Running needs nothing at all: the Helm renderer is the pinned `helm.sh/helm/v4` SDK, compiled in. +Nothing in the dependency tree needs cgo, so one host cross-compiles every +released target โ€” `linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64` +and `windows/amd64` โ€” with `CGO_ENABLED=0` and the stock toolchain. That is why +the release pipeline is a single job and why the Linux binaries are static +enough to carry a `manylinux_2_17` wheel tag. `make wheels VERSION=X.Y.Z` +reproduces all five wheels locally. + +One version reaches every artifact: the git tag `vX.Y.Z` is the only source, and +it becomes `codeanalyzer_iac.__version__`, the `-X main.version` linker stamp, +what `caniac --version` prints, and `analyzer.version` in every emitted analysis +document. The release workflow fails before publishing if they disagree. + +The tag must be an already normalized PEP 440 release version โ€” `v0.1.0` or +`v0.1.0rc1`, never `v0.1.0-rc1` or `v0.1.0-dev`. The workflow rejects anything +else before it builds, and `make wheels` applies the same rule to `VERSION`, so +the wheel filename, the Homebrew `version` field and the tag cannot drift apart. + ## Filesystem analysis ```sh @@ -421,6 +457,7 @@ make test # go test ./... โ€” offline, no cluster, no database required make race # go test -race ./... make schema-check # contract files, embedded copies and generated catalog agree make fuzz-smoke # 10s over each parser fuzz target +make wheels VERSION=0.1.0rc0 # the five platform wheels the release publishes ``` `go test ./...` is network-independent and needs no services. The graph parity diff --git a/packaging/homebrew/generate_formula.sh b/packaging/homebrew/generate_formula.sh new file mode 100755 index 0000000..7bedc4c --- /dev/null +++ b/packaging/homebrew/generate_formula.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# +# Generate the Homebrew formula for the `caniac` (codeanalyzer-iac) binary. +# +# This is the non-Rust equivalent of what cargo-dist does automatically: it takes +# the per-platform binaries that the release already publishes as GitHub Release +# assets, computes their sha256, and emits a formula that downloads + installs the +# matching binary for the user's platform. +# +# It reads the binaries straight from the release-bins/ dir built by release.yml, +# so the checksums are guaranteed to match the bytes that were uploaded. The +# download URLs point at the GitHub Release assets of the SAME tag. +# +# Usage: +# REPO=codellm-devkit/codeanalyzer-iac VERSION=0.2.0 \ +# ./generate_formula.sh ../../release-bins > codeanalyzer-iac.rb +# +set -euo pipefail + +BINS_DIR="${1:?usage: generate_formula.sh }" +REPO="${REPO:?set REPO, e.g. codellm-devkit/codeanalyzer-iac}" +VERSION="${VERSION:?set VERSION, e.g. 0.2.0}" +BASE_URL="https://github.com/${REPO}/releases/download/v${VERSION}" + +# shasum on macOS, sha256sum on the Linux runner; both print " ". +sha() { + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | cut -d' ' -f1 + else + sha256sum "$1" | cut -d' ' -f1 + fi +} + +# Map each release asset (named by Python wheel platform tag) to its Homebrew +# os/arch block. Windows is intentionally omitted -- Homebrew is macOS/Linux only. +asset_macos_arm="caniac-macosx_11_0_arm64" +asset_macos_intel="caniac-macosx_11_0_x86_64" +asset_linux_intel="caniac-manylinux_2_17_x86_64" +asset_linux_arm="caniac-manylinux_2_17_aarch64" + +for a in "$asset_macos_arm" "$asset_macos_intel" "$asset_linux_intel" "$asset_linux_arm"; do + [[ -f "$BINS_DIR/$a" ]] || { echo "missing expected binary: $BINS_DIR/$a" >&2; exit 1; } +done + +cat < "caniac" + end + + test do + # caniac --version prints "caniac version ", the same string it stamps on + # analyzer.version in every analysis document. + assert_match "caniac version #{version}", shell_output("#{bin}/caniac --version") + end +end +FORMULA diff --git a/packaging/install/caniac-installer.sh b/packaging/install/caniac-installer.sh new file mode 100755 index 0000000..2c973e2 --- /dev/null +++ b/packaging/install/caniac-installer.sh @@ -0,0 +1,71 @@ +#!/bin/sh +# caniac installer โ€” downloads the prebuilt codeanalyzer-iac (`caniac`) binary for your +# platform from the GitHub Release and installs it. Mirrors the cargo-dist installer pattern. +# +# Usage: +# curl --proto '=https' --tlsv1.2 -LsSf https://github.com/codellm-devkit/codeanalyzer-iac/releases/latest/download/caniac-installer.sh | sh +# +# Environment overrides: +# CANIAC_INSTALL_DIR install location (default: ~/.local/bin) +# CANIAC_VERSION release tag, e.g. v0.3.0 (default: latest) +set -eu + +REPO="codellm-devkit/codeanalyzer-iac" +INSTALL_DIR="${CANIAC_INSTALL_DIR:-$HOME/.local/bin}" +VERSION="${CANIAC_VERSION:-latest}" + +os="$(uname -s)" +arch="$(uname -m)" + +# Map the host platform to the published Release asset name (see packaging/python/build_wheels.sh +# targets and packaging/homebrew/generate_formula.sh). +case "$os" in + Darwin) + case "$arch" in + arm64 | aarch64) asset="caniac-macosx_11_0_arm64" ;; + x86_64) asset="caniac-macosx_11_0_x86_64" ;; + *) echo "caniac: unsupported macOS architecture: $arch" >&2; exit 1 ;; + esac + ;; + Linux) + case "$arch" in + x86_64) asset="caniac-manylinux_2_17_x86_64" ;; + aarch64 | arm64) asset="caniac-manylinux_2_17_aarch64" ;; + *) echo "caniac: unsupported Linux architecture: $arch" >&2; exit 1 ;; + esac + ;; + *) + echo "caniac: unsupported OS '$os'. Try: pip install codeanalyzer-iac" >&2 + exit 1 + ;; +esac + +if [ "$VERSION" = "latest" ]; then + url="https://github.com/$REPO/releases/latest/download/$asset" +else + url="https://github.com/$REPO/releases/download/$VERSION/$asset" +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT + +echo "caniac: downloading $asset ($VERSION)..." +if command -v curl >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -fLsS "$url" -o "$tmp/caniac" +elif command -v wget >/dev/null 2>&1; then + wget -q "$url" -O "$tmp/caniac" +else + echo "caniac: need curl or wget to download" >&2 + exit 1 +fi + +chmod +x "$tmp/caniac" +mkdir -p "$INSTALL_DIR" +mv "$tmp/caniac" "$INSTALL_DIR/caniac" +echo "caniac: installed to $INSTALL_DIR/caniac" + +# PATH hint when the install dir isn't already on PATH. +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) echo "caniac: add it to your PATH: export PATH=\"$INSTALL_DIR:\$PATH\"" ;; +esac diff --git a/packaging/python/build_wheels.sh b/packaging/python/build_wheels.sh new file mode 100755 index 0000000..38ff0e6 --- /dev/null +++ b/packaging/python/build_wheels.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# +# Build platform-tagged Python wheels for the caniac (codeanalyzer-iac) binary. +# +# For each target: cross-compile the binary with the Go toolchain, build a (pure) +# wheel with hatchling, then retag it from `py3-none-any` to the matching platform +# tag with `wheel tags`. The binary is python-agnostic, so each platform needs +# exactly one wheel (py3-none-), not one per Python version. +# +# Requirements on the build host: +# - go (the version in go.mod) -- cross-compiles all targets from +# one host because the analyzer is CGO-free (CGO_ENABLED=0) +# - python -m pip install build wheel hatchling twine +# (hatchling is the build backend; --no-isolation means it must be installed) +# +# Usage: +# ./build_wheels.sh # build all targets into ./dist +# twine upload dist/*.whl # publish +# +set -euo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$HERE/../.." && pwd)" # codeanalyzer-iac repo root (has go.mod) +# Version comes from the environment (the release workflow sets it from the git +# tag); the literal is only a local-dev fallback. It is written into __init__.py, +# which is hatch's single source of truth for the wheel version, and stamped into +# the binary as main.version, which is what `caniac --version` prints and what +# every analysis document carries as analyzer.version. +PKG_VERSION="${PKG_VERSION:-0.1.0}" + +# The same shape the release workflow requires of a tag. It has to be an already +# normalized PEP 440 version, because the wheel filename below is spelled out +# rather than read back from hatchling -- 0.1.0-dev or 0.1.0-rc1 would be +# normalized to something else and the `wheel tags` call would miss the file. +if [[ ! "$PKG_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+((a|b|rc)[0-9]+)?$ ]]; then + echo "PKG_VERSION='$PKG_VERSION' is not a normalized PEP 440 release version." >&2 + echo "Use X.Y.Z, optionally with aN, bN or rcN: 0.1.0, 0.1.0rc1." >&2 + echo "Hint: 'make wheels' needs VERSION set, e.g. make wheels VERSION=0.1.0rc1." >&2 + exit 1 +fi + +WHEEL_STEM="codeanalyzer_iac-${PKG_VERSION}-py3-none-any.whl" +BIN_DIR="$HERE/src/codeanalyzer_iac/_bin" +INIT_PY="$HERE/src/codeanalyzer_iac/__init__.py" + +# Remove built binaries from _bin/ but keep the tracked .gitignore (and the dir), +# so a local build leaves the working tree pristine. +clean_bin() { mkdir -p "$BIN_DIR"; find "$BIN_DIR" -mindepth 1 ! -name '.gitignore' -delete; } + +# Stamp $PKG_VERSION into __init__.py for the build, restoring the original on +# exit so the working tree stays pristine (mirrors the _bin cleanup below). +ORIG_INIT="$(cat "$INIT_PY")" # $(...) strips the trailing newline; restore re-adds it +restore_init() { printf '%s\n' "$ORIG_INIT" > "$INIT_PY"; } +trap restore_init EXIT +python - "$INIT_PY" "$PKG_VERSION" <<'PY' +import re, sys +path, version = sys.argv[1], sys.argv[2] +text = open(path).read() +new, n = re.subn(r'__version__ = "[^"]*"', f'__version__ = "{version}"', text) +if n != 1: + raise SystemExit(f"expected exactly one __version__ assignment in {path}, found {n}") +open(path, "w").write(new) +print(f">>> stamped __version__ = {version}") +PY + +# "GOOS/GOARCH" : "wheel platform tag". The Go binaries are static (CGO_ENABLED=0), +# so the Linux ones satisfy manylinux_2_17 with no shared-library dependencies at all. +TARGETS=( + "darwin/arm64:macosx_11_0_arm64" + "darwin/amd64:macosx_11_0_x86_64" + "linux/amd64:manylinux_2_17_x86_64" + "linux/arm64:manylinux_2_17_aarch64" + "windows/amd64:win_amd64" +) + +rm -rf "$HERE/dist" +mkdir -p "$HERE/dist" + +# The wheel's long description (the PyPI page) is the repo root README โ€” copy it in so there is a +# single source of truth. It is gitignored and removed on exit (see cleanup) to keep the tree pristine. +cp "$REPO_ROOT/README.md" "$HERE/README.md" + +for entry in "${TARGETS[@]}"; do + target="${entry%%:*}" + plat="${entry##*:}" + goos="${target%%/*}" + goarch="${target##*/}" + ext="" + [[ "$goos" == windows ]] && ext=".exe" + + echo ">>> [$target] compiling -> wheel ($plat)" + + clean_bin + + # -trimpath keeps the build reproducible; -s -w drops the symbol table and DWARF, + # which is most of the binary size and nothing a released analyzer needs. + ( cd "$REPO_ROOT" && CGO_ENABLED=0 GOOS="$goos" GOARCH="$goarch" \ + go build -trimpath -ldflags "-s -w -X main.version=$PKG_VERSION" \ + -o "$BIN_DIR/caniac$ext" ./cmd/codeanalyzer-iac ) + + # Build a pure wheel (py3-none-any), then retag to the platform. + python -m build --wheel --no-isolation -o "$HERE/dist" "$HERE" + python -m wheel tags --remove --platform-tag "$plat" "$HERE/dist/$WHEEL_STEM" +done + +# Clean the working binary + copied README so the tree stays pristine. +clean_bin +rm -f "$HERE/README.md" + +echo +echo ">>> Built wheels:" +ls -lh "$HERE/dist"/*.whl +echo +echo "Publish with: twine upload $HERE/dist/*.whl" diff --git a/packaging/python/pyproject.toml b/packaging/python/pyproject.toml new file mode 100644 index 0000000..2c8629b --- /dev/null +++ b/packaging/python/pyproject.toml @@ -0,0 +1,60 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "codeanalyzer-iac" +dynamic = ["version"] +description = "Prebuilt codeanalyzer-iac (caniac) backend binary for CLDK (codellm-devkit)." +# The PyPI long description is the repo root README, copied in by build_wheels.sh at build time +# (single source of truth; the copy is gitignored). See that script for the cp + cleanup. +readme = "README.md" +# 3.9 is the oldest interpreter this package is tested against; 3.8 is end of +# life and pip on it would happily install a wheel nothing verifies. +requires-python = ">=3.9" +license = { text = "Apache-2.0" } +authors = [ + { name = "Rahul Krishna", email = "i.m.ralk@gmail.com" }, +] +keywords = ["cldk", "codellm-devkit", "iac", "helm", "code analysis"] +classifiers = [ + "Development Status :: 4 - Beta", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", +] + +[project.urls] +Homepage = "https://codellm-devkit.info" +Repository = "https://github.com/codellm-devkit/codeanalyzer-iac" + +# Installs a `caniac` launcher on PATH that exec()s the bundled binary +# (see src/codeanalyzer_iac/__main__.py). CLDK still locates the binary +# directly via bin_path(); this is purely for direct shell use. The +# `codeanalyzer-iac` alias mirrors the name `go install` produces. +[project.scripts] +caniac = "codeanalyzer_iac.__main__:main" +codeanalyzer-iac = "codeanalyzer_iac.__main__:main" + +# Single source of truth for the package version: __init__.py's __version__. +# build_wheels.sh overwrites it from $PKG_VERSION (set to the git tag in CI), so a +# tag of vX.Y.Z always produces a codeanalyzer-iac==X.Y.Z wheel. +[tool.hatch.version] +path = "src/codeanalyzer_iac/__init__.py" + +# The compiled binary lives under _bin/ and is gitignored (it is produced per +# platform at build time). `artifacts` re-includes it in the wheel anyway, so the +# gitignored binary still ships inside the built wheel. +[tool.hatch.build.targets.wheel] +packages = ["src/codeanalyzer_iac"] +artifacts = ["src/codeanalyzer_iac/_bin/*"] + +[tool.hatch.build.targets.sdist] +# No meaningful sdist: the binary cannot be built without a Go toolchain and the +# analyzer sources, so we ship platform wheels only. Keep the sdist minimal for +# metadata completeness. +include = [ + "src/codeanalyzer_iac/__init__.py", + "README.md", + "build_wheels.sh", +] diff --git a/packaging/python/src/codeanalyzer_iac/__init__.py b/packaging/python/src/codeanalyzer_iac/__init__.py new file mode 100644 index 0000000..872d866 --- /dev/null +++ b/packaging/python/src/codeanalyzer_iac/__init__.py @@ -0,0 +1,53 @@ +"""Prebuilt ``caniac`` (codeanalyzer-iac) backend binary for CLDK. + +This package carries the platform-specific, self-contained ``caniac`` +executable (cross-compiled from this repo with CGO disabled) and exposes its +filesystem path. CLDK's Python SDK depends on this package and calls +:func:`bin_path` to locate the analyzer, exactly as it imports +``codeanalyzer-python`` for the Python backend. + +Each published wheel is platform-tagged and contains the single binary for that +platform; pip resolves the correct wheel at install time. +""" + +from __future__ import annotations + +import os +import stat +import sys +from pathlib import Path + +__version__ = "0.1.0" + +__all__ = ["bin_path", "__version__"] + +_BINARY_NAME = "caniac" + (".exe" if sys.platform == "win32" else "") + + +def bin_path() -> Path: + """Return the absolute path to the bundled ``caniac`` binary. + + Raises: + FileNotFoundError: if the wheel for this platform did not include a binary + (e.g. an unsupported platform, or a source/dev install with no build run). + """ + # The wheel is always installed unzipped, so the binary sits next to this + # module. importlib.resources.as_file() would be the general answer, but it + # deletes an extracted temp copy when its context exits -- and this function + # returns a path the caller executes later. + path = Path(__file__).resolve().parent / "_bin" / _BINARY_NAME + + if not path.exists(): + raise FileNotFoundError( + f"Bundled caniac binary not found at {path}. " + "This usually means there is no prebuilt wheel for your platform; " + "build the binary with `make build` and point CLDK at it via " + "analysis_backend_path." + ) + + # Wheels may not preserve the executable bit on POSIX; restore it best-effort. + if os.name == "posix": + mode = path.stat().st_mode + path.chmod(mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + + return path diff --git a/packaging/python/src/codeanalyzer_iac/__main__.py b/packaging/python/src/codeanalyzer_iac/__main__.py new file mode 100644 index 0000000..76dc629 --- /dev/null +++ b/packaging/python/src/codeanalyzer_iac/__main__.py @@ -0,0 +1,34 @@ +"""Console-script entry point: run the bundled ``caniac`` binary. + +``pip install codeanalyzer-iac`` installs a ``caniac`` launcher (see +``[project.scripts]`` in ``pyproject.toml``) that calls :func:`main`. We locate +the platform binary via :func:`bin_path` and hand off the process to it, passing +through every CLI argument and the exit code unchanged. +""" + +from __future__ import annotations + +import os +import subprocess +import sys + +from . import bin_path + + +def main() -> "int | None": + """Exec the bundled ``caniac`` binary with the current argv (minus argv[0]).""" + binary = str(bin_path()) + args = [binary, *sys.argv[1:]] + + if os.name == "posix": + # Replace this process with the binary: no extra Python process lingers, + # and signals/exit codes are handled by caniac directly. + os.execv(binary, args) + # os.execv never returns on success. + + # Windows has no execv that behaves like POSIX; spawn and propagate the code. + return subprocess.call(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/packaging/python/src/codeanalyzer_iac/_bin/.gitignore b/packaging/python/src/codeanalyzer_iac/_bin/.gitignore new file mode 100644 index 0000000..d6a9147 --- /dev/null +++ b/packaging/python/src/codeanalyzer_iac/_bin/.gitignore @@ -0,0 +1,5 @@ +# The compiled binary is produced per-platform at build time and bundled into +# the wheel via `artifacts` in pyproject.toml. Never commit it (platform-specific, +# ~30 MB). Keep this directory present so the package is importable in dev. +* +!.gitignore