From 29b463fda252df267af0f2c6fc903522e2664546 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Fri, 4 Sep 2026 18:29:14 +0530 Subject: [PATCH 1/4] ci: add fail-closed native release workflow Build tagged releases on native Linux, macOS, and Windows runners, smoke-test every binary, and publish only the verified asset set with SHA-256 checksums.\n\nReplace the legacy .env-bundling build path with a data-free PyInstaller invocation that rejects local .env files before release builds. --- .github/workflows/release.yml | 135 ++++++++++++++++++ build.py | 252 ++++++++++++++-------------------- docs/RELEASING.md | 52 +++++++ tests/test_release_build.py | 40 ++++++ 4 files changed, 329 insertions(+), 150 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docs/RELEASING.md create mode 100644 tests/test_release_build.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..375efb5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,135 @@ +name: Release native binaries + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build: + name: Build ${{ matrix.target }} + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - target: linux-x64 + runner: ubuntu-latest + asset: wild-linux-x64 + smoke: ./release/wild-linux-x64 --help + - target: linux-arm64 + runner: ubuntu-24.04-arm + asset: wild-linux-arm64 + smoke: ./release/wild-linux-arm64 --help + - target: macos-x64 + runner: macos-13 + asset: wild-macos-x64 + smoke: ./release/wild-macos-x64 --help + - target: macos-arm64 + runner: macos-14 + asset: wild-macos-arm64 + smoke: ./release/wild-macos-arm64 --help + - target: windows-x64 + runner: windows-latest + asset: wild-windows-x64.exe + smoke: ./release/wild-windows-x64.exe --help + + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + - name: Build native executable + run: python build.py --output-dir release --name wild + - name: Name release asset + shell: bash + run: | + if [ "${RUNNER_OS}" = "Windows" ]; then + mv release/wild.exe "release/${{ matrix.asset }}" + else + mv release/wild "release/${{ matrix.asset }}" + fi + - name: Smoke test native executable + shell: bash + run: ${{ matrix.smoke }} + - name: Upload release asset + uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.target }} + path: release/${{ matrix.asset }} + if-no-files-found: error + retention-days: 7 + + publish: + name: Verify assets and create release + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + - name: Download all native binaries + uses: actions/download-artifact@v4 + with: + pattern: release-* + path: release + merge-multiple: true + - name: Fail closed and write checksums + shell: bash + run: | + set -euo pipefail + expected=( + wild-linux-x64 + wild-linux-arm64 + wild-macos-x64 + wild-macos-arm64 + wild-windows-x64.exe + ) + + for asset in "${expected[@]}"; do + test -f "release/${asset}" || { + echo "Missing expected release asset: ${asset}" >&2 + exit 1 + } + done + + actual_count="$(find release -maxdepth 1 -type f | wc -l | tr -d ' ')" + test "${actual_count}" -eq "${#expected[@]}" || { + echo "Expected exactly ${#expected[@]} binaries, found ${actual_count}" >&2 + find release -maxdepth 1 -type f -printf '%f\n' >&2 + exit 1 + } + + ( + cd release + sha256sum "${expected[@]}" > checksums.txt + ) + - name: Create GitHub release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + gh release create "${GITHUB_REF_NAME}" release/* \ + --repo "${GITHUB_REPOSITORY}" \ + --title "${GITHUB_REF_NAME}" \ + --generate-notes diff --git a/build.py b/build.py index 60c8eaa..bb646e0 100644 --- a/build.py +++ b/build.py @@ -1,163 +1,115 @@ #!/usr/bin/env python3 -""" -Build script for DiffGraph CLI using PyInstaller +"""Build a native, single-file ``wild`` executable with PyInstaller. + +This builder deliberately never supplies PyInstaller with ``--add-data``. In +particular, a local ``.env`` is rejected before the build starts so credentials +cannot accidentally become part of a release executable. """ +from __future__ import annotations + +import argparse +import shutil import subprocess import sys -import os -import re from pathlib import Path +from typing import Sequence + +ROOT = Path(__file__).resolve().parent +ENTRYPOINT = ROOT / "diffgraph" / "cli.py" +FORBIDDEN_BUNDLE_FILE_NAMES = frozenset({".env"}) + + +def find_forbidden_files(root: Path) -> list[Path]: + """Return credential-like dotfiles that must not be present for a build.""" + return sorted( + path + for path in root.rglob("*") + if path.is_file() + and path.name in FORBIDDEN_BUNDLE_FILE_NAMES + and ".git" not in path.parts + and ".venv" not in path.parts + ) + + +def assert_release_workspace_safe(root: Path) -> None: + """Fail before PyInstaller runs if a local credential file is present.""" + forbidden_files = find_forbidden_files(root) + if forbidden_files: + rendered_paths = ", ".join(str(path.relative_to(root)) for path in forbidden_files) + raise SystemExit( + "refusing to build from a workspace containing forbidden file(s): " + f"{rendered_paths}. Release builds never package .env files; " + "use process environment variables instead." + ) -def create_spec_file(): - """Generate the wild.spec file using PyInstaller if it doesn't exist""" - spec_file = "wild.spec" - - if os.path.exists(spec_file): - print("โœ… Spec file already exists") - return True - - print("๐Ÿ“ Generating wild.spec file using PyInstaller...") - # Generate spec file using PyInstaller - result = subprocess.run([ - sys.executable, "-m", "PyInstaller", - "--name", "wild", +def build_command(*, output_dir: Path, work_dir: Path, spec_dir: Path, name: str) -> list[str]: + """Return the intentionally data-free native PyInstaller invocation.""" + return [ + sys.executable, + "-m", + "PyInstaller", + "--noconfirm", + "--clean", "--onefile", "--console", - "--specpath", ".", - "--distpath", "dist", - "--workpath", "build", - "--clean", - "--noconfirm", - "diffgraph/cli.py" - ], capture_output=True, text=True) - - if result.returncode != 0: - print(f"โŒ Failed to generate spec file: {result.stderr}") - return False - - print("โœ… Generated wild.spec file") - return True - -def ensure_env_in_spec(): - """Ensure .env file is included in the spec file if it exists""" - spec_file = "wild.spec" - - # Check if .env file exists - env_file_exists = os.path.exists(".env") - - if not env_file_exists: - print("โš ๏ธ .env file not found - skipping") - return True - - # Read the spec file - with open(spec_file, 'r') as f: - content = f.read() - - # Check if .env is already in datas - if "('.env', '.')" in content: - print("โœ… .env file already included in spec file") - return True - - print("๐Ÿ“ Adding .env file to spec file...") - - # Find the datas line and add .env - # Handle both empty datas array and non-empty datas array - if "datas=[]," in content: - # Empty datas array - content = content.replace("datas=[],", "datas=[('.env', '.')],") - elif "datas=[" in content: - # Non-empty datas array - add to existing items - content = re.sub( - r'datas=\[([^\]]*)\],', - r'datas=[\1, (\'.env\', \'.\')],', - content - ) - else: - print("โš ๏ธ Could not find datas array in spec file") - return False - - with open(spec_file, 'w') as f: - f.write(content) - print("โœ… Added .env file to spec file") - return True - -def verify_env_in_bundle(): - """Verify that the .env file is properly included in the built binary""" - print("๐Ÿ” Verifying .env file in bundle...") - - # Check if binary exists - binary_name = "wild.exe" if sys.platform == 'win32' else "wild" - binary_path = os.path.join("dist", binary_name) - - if not os.path.exists(binary_path): - print("โŒ Binary not found - cannot verify bundle") - return False - - # Check if .env file exists in the project directory - if os.path.exists(".env"): - print("โœ… .env file exists in project directory") - print(" The binary should be able to load it from the current directory") - return True - else: - print("โš ๏ธ .env file not found in project directory") - return False - -def main(): - """Build the DiffGraph CLI binary using PyInstaller""" - - # Check if PyInstaller is installed + "--name", + name, + "--distpath", + str(output_dir), + "--workpath", + str(work_dir), + "--specpath", + str(spec_dir), + str(ENTRYPOINT), + ] + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--name", default="wild", help="name of the generated executable") + parser.add_argument( + "--output-dir", + type=Path, + default=ROOT / "dist", + help="directory that receives the executable (default: %(default)s)", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + output_dir = args.output_dir.resolve() + work_dir = ROOT / "build" / "pyinstaller" + spec_dir = ROOT / "build" / "spec" + + assert_release_workspace_safe(ROOT) + if not ENTRYPOINT.is_file(): + raise SystemExit(f"build entrypoint is missing: {ENTRYPOINT}") + + for directory in (output_dir, work_dir, spec_dir): + shutil.rmtree(directory, ignore_errors=True) + try: - import PyInstaller - except ImportError: - print("โŒ PyInstaller not found. Installing...") - subprocess.run([sys.executable, "-m", "pip", "install", "pyinstaller"], check=True) - - # Create spec file if it doesn't exist - create_spec_file() - - # Ensure .env file is included in spec - ensure_env_in_spec() - - # Clean previous builds - print("๐Ÿงน Cleaning previous builds...") - for path in ["build", "dist"]: - if os.path.exists(path): - import shutil - try: - shutil.rmtree(path) - except PermissionError: - print(f"โš ๏ธ Could not remove {path} - permission denied. Continuing...") - except Exception as e: - print(f"โš ๏ธ Could not remove {path}: {e}. Continuing...") - - # Build using the spec file - print("๐Ÿ”จ Building DiffGraph CLI...") - result = subprocess.run([ - sys.executable, "-m", "PyInstaller", "wild.spec", "--clean" - ], check=True) - - if result.returncode == 0: - print("โœ… Build completed successfully!") - if sys.platform == 'win32': - print(f"๐Ÿ“ฆ Binary location: {os.path.join('dist', 'wild.exe')}") - else: - print(f"๐Ÿ“ฆ Binary location: {os.path.join('dist', 'wild')}") - - # Verify that .env file is properly included - verify_env_in_bundle() - - print("\n๐Ÿ’ก Environment Variable Loading Tips:") - print(" - The binary will look for .env file in multiple locations:") - print(" 1. Current working directory") - print(" 2. Next to the executable") - print(" 3. Inside the bundled resources") - print(" - You can also set OPENAI_API_KEY as an environment variable") - print(" - Or use the --api-key command line option") - else: - print("โŒ Build failed!") - sys.exit(1) + import PyInstaller # noqa: F401 + except ImportError as error: + raise SystemExit("PyInstaller is not installed; install requirements.txt first") from error + + command = build_command( + output_dir=output_dir, + work_dir=work_dir, + spec_dir=spec_dir, + name=args.name, + ) + subprocess.run(command, cwd=ROOT, check=True) + + executable = output_dir / (f"{args.name}.exe" if sys.platform == "win32" else args.name) + if not executable.is_file(): + raise SystemExit(f"build completed without expected executable: {executable}") + print(executable) + return 0 + if __name__ == "__main__": - main() \ No newline at end of file + raise SystemExit(main()) diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..25ab74d --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,52 @@ +# Releasing native DiffGraph CLI binaries + +Pushing a tag beginning with `v` (for example, `v1.1.0`) starts the **Release +native binaries** GitHub Actions workflow. The workflow is intentionally tag +only; a pull request or branch push cannot publish a release. + +## What the workflow produces + +Each target is built on a runner of the same operating system and CPU +architecture, then runs its own `wild --help` smoke test: + +- `wild-linux-x64` +- `wild-linux-arm64` +- `wild-macos-x64` +- `wild-macos-arm64` +- `wild-windows-x64.exe` + +The publish job downloads those five artifacts and fails before creating a +release unless every expected file is present and there are exactly five +binaries. It writes `checksums.txt` with SHA-256 checksums and uploads it with +the binaries to the GitHub release. + +## Credential safety + +`build.py` creates a one-file PyInstaller binary without `--add-data` or +`--add-binary`. It rejects a workspace containing any `.env` file before +PyInstaller runs. This replaces the legacy behavior that generated a spec file +and edited it to bundle `.env`. A binary may still read a user-provided `.env` +next to the executable at runtime; that file is never embedded in a release. +Use environment variables in CI and for releases. + +## Runner availability + +The ARM jobs require GitHub-hosted ARM labels (`ubuntu-24.04-arm` and +`macos-14`); the Intel macOS job uses `macos-13`. Availability and billing for +these labels depend on the repository visibility and the GitHub plan. If a +label is unavailable, the workflow fails at scheduling rather than silently +cross-compiling an incorrect binary. Update the matrix only after confirming an +alternative runner is native for the target architecture. + +## Local Linux check + +On Linux, the equivalent build and smoke test are: + +```bash +python -m pip install -r requirements.txt +python build.py --output-dir release --name wild +./release/wild --help +``` + +Do not leave a `.env` in the checkout when invoking `build.py`; it fails closed +by design. diff --git a/tests/test_release_build.py b/tests/test_release_build.py new file mode 100644 index 0000000..cc8dc32 --- /dev/null +++ b/tests/test_release_build.py @@ -0,0 +1,40 @@ +from pathlib import Path + +import pytest + +import build + + +def test_release_build_command_does_not_add_data_files(tmp_path): + command = build.build_command( + output_dir=tmp_path / "release", + work_dir=tmp_path / "work", + spec_dir=tmp_path / "spec", + name="wild", + ) + + assert "--onefile" in command + assert "--add-data" not in command + assert "--add-binary" not in command + assert all(".env" not in argument for argument in command) + + +def test_release_build_rejects_local_dotenv(tmp_path): + (tmp_path / ".env").write_text("OPENAI_API_KEY=must-not-bundle\n", encoding="utf-8") + + with pytest.raises(SystemExit, match="never package .env files"): + build.assert_release_workspace_safe(tmp_path) + + +def test_release_build_allows_env_example(tmp_path): + (tmp_path / ".env.example").write_text("OPENAI_API_KEY=\n", encoding="utf-8") + + assert build.find_forbidden_files(tmp_path) == [] + + +def test_release_build_rejects_nested_dotenv(tmp_path): + nested_env = tmp_path / "package" / ".env" + nested_env.parent.mkdir() + nested_env.write_text("OPENAI_API_KEY=must-not-bundle\n", encoding="utf-8") + + assert build.find_forbidden_files(tmp_path) == [nested_env] From 45ffb385926e2146013c18d68b51307c20b08630 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Fri, 4 Sep 2026 19:12:32 +0530 Subject: [PATCH 2/4] fix(ci): align CLI release asset contract --- .github/workflows/release.yml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 375efb5..e2c3411 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,8 +39,8 @@ jobs: smoke: ./release/wild-macos-arm64 --help - target: windows-x64 runner: windows-latest - asset: wild-windows-x64.exe - smoke: ./release/wild-windows-x64.exe --help + asset: wild-win.exe + smoke: ./release/wild-win.exe --help runs-on: ${{ matrix.runner }} steps: @@ -102,7 +102,7 @@ jobs: wild-linux-arm64 wild-macos-x64 wild-macos-arm64 - wild-windows-x64.exe + wild-win.exe ) for asset in "${expected[@]}"; do @@ -121,8 +121,24 @@ jobs: ( cd release - sha256sum "${expected[@]}" > checksums.txt + sha256sum "${expected[@]}" > SHA256SUMS ) + + node - <<'NODE' + const fs = require('node:fs'); + const assets = [ + 'wild-linux-x64', + 'wild-linux-arm64', + 'wild-macos-x64', + 'wild-macos-arm64', + 'wild-win.exe', + ]; + fs.writeFileSync('release/cli-manifest.json', JSON.stringify({ + schemaVersion: 1, + version: process.env.GITHUB_REF_NAME, + assets, + }, null, 2) + '\n'); + NODE - name: Create GitHub release env: GH_TOKEN: ${{ github.token }} From 183aee19cbac591c42f3c29c4b76df3154db42e5 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Fri, 4 Sep 2026 19:36:34 +0530 Subject: [PATCH 3/4] docs: match release asset names and checksum file --- docs/RELEASING.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 25ab74d..568e466 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -13,11 +13,11 @@ architecture, then runs its own `wild --help` smoke test: - `wild-linux-arm64` - `wild-macos-x64` - `wild-macos-arm64` -- `wild-windows-x64.exe` +- `wild-win.exe` The publish job downloads those five artifacts and fails before creating a release unless every expected file is present and there are exactly five -binaries. It writes `checksums.txt` with SHA-256 checksums and uploads it with +binaries. It writes `SHA256SUMS` with SHA-256 checksums and uploads it with the binaries to the GitHub release. ## Credential safety From 316167b276a644ce04c12f6e18a4fb0af27951b2 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Sat, 5 Sep 2026 02:27:14 +0530 Subject: [PATCH 4/4] fix(release): guard unsafe output cleanup Use current macOS 15 runner labels and document their availability.\n\nReject output paths at or above the checkout before build cleanup, with regression coverage for both cases. --- .github/workflows/release.yml | 4 ++-- build.py | 10 ++++++++++ docs/RELEASING.md | 11 ++++++----- tests/test_release_build.py | 13 +++++++++++++ 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e2c3411..2af3c92 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,11 +30,11 @@ jobs: asset: wild-linux-arm64 smoke: ./release/wild-linux-arm64 --help - target: macos-x64 - runner: macos-13 + runner: macos-15-intel asset: wild-macos-x64 smoke: ./release/wild-macos-x64 --help - target: macos-arm64 - runner: macos-14 + runner: macos-15 asset: wild-macos-arm64 smoke: ./release/wild-macos-arm64 --help - target: windows-x64 diff --git a/build.py b/build.py index bb646e0..7ea80e5 100644 --- a/build.py +++ b/build.py @@ -78,12 +78,22 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: return parser.parse_args(argv) +def assert_output_dir_safe(output_dir: Path) -> None: + """Reject an output directory that would remove the checkout during cleanup.""" + if ROOT.is_relative_to(output_dir): + raise SystemExit( + "refusing to use an output directory that is the repository root or an " + "ancestor of it" + ) + + def main(argv: Sequence[str] | None = None) -> int: args = parse_args(argv) output_dir = args.output_dir.resolve() work_dir = ROOT / "build" / "pyinstaller" spec_dir = ROOT / "build" / "spec" + assert_output_dir_safe(output_dir) assert_release_workspace_safe(ROOT) if not ENTRYPOINT.is_file(): raise SystemExit(f"build entrypoint is missing: {ENTRYPOINT}") diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 568e466..fca216a 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -32,11 +32,12 @@ Use environment variables in CI and for releases. ## Runner availability The ARM jobs require GitHub-hosted ARM labels (`ubuntu-24.04-arm` and -`macos-14`); the Intel macOS job uses `macos-13`. Availability and billing for -these labels depend on the repository visibility and the GitHub plan. If a -label is unavailable, the workflow fails at scheduling rather than silently -cross-compiling an incorrect binary. Update the matrix only after confirming an -alternative runner is native for the target architecture. +`macos-15`); the Intel macOS job uses `macos-15-intel`. Availability and +billing for these labels depend on the repository visibility and the GitHub +plan, so confirm both macOS 15 labels are available before creating a release +tag. If a label is unavailable, the workflow fails at scheduling rather than +silently cross-compiling an incorrect binary. Update the matrix only after +confirming an alternative runner is native for the target architecture. ## Local Linux check diff --git a/tests/test_release_build.py b/tests/test_release_build.py index cc8dc32..b532e30 100644 --- a/tests/test_release_build.py +++ b/tests/test_release_build.py @@ -38,3 +38,16 @@ def test_release_build_rejects_nested_dotenv(tmp_path): nested_env.write_text("OPENAI_API_KEY=must-not-bundle\n", encoding="utf-8") assert build.find_forbidden_files(tmp_path) == [nested_env] + +@pytest.mark.parametrize("output_dir", [build.ROOT, build.ROOT.parent]) +def test_release_build_rejects_checkout_or_ancestor_output_dir_before_cleanup( + monkeypatch, output_dir +): + monkeypatch.setattr( + build.shutil, + "rmtree", + lambda *args, **kwargs: pytest.fail("cleanup called for unsafe output directory"), + ) + + with pytest.raises(SystemExit, match="repository root or an ancestor"): + build.main(["--output-dir", str(output_dir)])