From d526052aa0baf14632eb5ef5bda7a8695bcbdf6d Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 20 Aug 2026 10:40:56 +0100 Subject: [PATCH 1/5] ci: publish the binary to PyPI as flagsmith-cli Wheels ship the GoReleaser binary in .data/scripts, so uv/pip/pipx put flagsmith on PATH with no Python shim. Published from a separate job via PyPI trusted publishing, keeping third-party code out of the attesting job. --- .github/workflows/pull-request.yml | 21 +++ .github/workflows/release.yml | 27 ++++ .gitignore | 1 + README.md | 9 ++ packaging/pypi/build_wheels.py | 209 +++++++++++++++++++++++++++++ 5 files changed, 267 insertions(+) create mode 100644 packaging/pypi/build_wheels.py diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 993af2b..d98e009 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -64,6 +64,27 @@ jobs: - run: ./install.ps1 -DryRun shell: pwsh + pypi-wheel: + name: PyPI wheel + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 + - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + version: "~> v2" + args: build --snapshot --clean --single-target + - run: python3 packaging/pypi/build_wheels.py --dist dist + - run: uvx twine check dist/pypi/*.whl + # The wheel ships no Python: this proves the binary lands on PATH. + - run: uvx --from ./dist/pypi/*.whl flagsmith --version + cross-compile: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c39000f..de7f67e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,6 +55,17 @@ jobs: with: subject-checksums: ./dist/digests.txt + # Wraps the binaries GoReleaser just built into wheels. Publishing them + # is a separate job: nothing third-party runs while this one holds a + # token that can attest artifacts. + - name: Build PyPI wheels + run: python3 packaging/pypi/build_wheels.py --dist dist + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: pypi-wheels + path: dist/pypi/*.whl + if-no-files-found: error + # During public beta the newest beta is what people # should land on, so clear it. - if: contains(github.ref_name, '-beta') @@ -62,6 +73,22 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + pypi: + name: Publish to PyPI + needs: goreleaser + runs-on: ubuntu-latest + environment: pypi + permissions: + id-token: write # PyPI trusted publishing + PEP 740 attestations + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: pypi-wheels + path: dist + - uses: pypa/gh-action-pypi-publish@a892a5a61159132606e93a2fa6f4358831b04d26 # v1.14.2 + with: + packages-dir: dist + install-script: name: install.sh (${{ matrix.os }}) needs: goreleaser diff --git a/.gitignore b/.gitignore index a94b49e..e0e5434 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /flagsmith /dist/ *.test +__pycache__/ diff --git a/README.md b/README.md index 43335dc..b589a73 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,15 @@ curl -fsSL https://raw.githubusercontent.com/Flagsmith/flagsmith-cli/main/instal To pin the installer itself, fetch it at a commit you trust: `raw.githubusercontent.com/Flagsmith/flagsmith-cli//install.sh`. +With [uv](https://docs.astral.sh/uv/): + +```sh +uv tool install flagsmith-cli # installs the `flagsmith` command +uvx --from flagsmith-cli flagsmith --help +``` + +The wheels carry the binary itself, so no Python code runs at any point. (`pip install flagsmith-cli` works too; add `--pre` while the CLI is in beta. The `flagsmith` package on PyPI is the [Python SDK](https://github.com/Flagsmith/flagsmith-python-client), which is why the CLI needs `--from`.) + Alternatively, `go install github.com/Flagsmith/flagsmith-cli/v2@v2.0.0-beta.3` (installs as `flagsmith-cli`), or grab an archive from [Releases](https://github.com/Flagsmith/flagsmith-cli/releases). On Windows: diff --git a/packaging/pypi/build_wheels.py b/packaging/pypi/build_wheels.py new file mode 100644 index 0000000..7e302ed --- /dev/null +++ b/packaging/pypi/build_wheels.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Build PyPI wheels around the binaries from GoReleaser. + +The binary is shipped in the wheel's .data/scripts/ directory, +which every installer drops straight onto PATH. +This should make `uv tool install flagsmith-cli` (and pip, and pipx) hand +you a working `flagsmith` without a Python shim in the way. + +Reads dist/artifacts.json + dist/metadata.json, writes wheels to +dist/pypi/. + + python3 packaging/pypi/build_wheels.py --dist dist +""" + +from __future__ import annotations + +import argparse +import base64 +import csv +import hashlib +import io +import json +import re +import sys +import zipfile +from dataclasses import dataclass +from pathlib import Path + +PACKAGE = "flagsmith-cli" +# Wheel filenames and .dist-info/.data directories use the escaped name. +PACKAGE_ESCAPED = PACKAGE.replace("-", "_") +BINARY = "flagsmith" +SUMMARY = "The Flagsmith command-line interface" +HOMEPAGE = "https://github.com/Flagsmith/flagsmith-cli" +REQUIRES_PYTHON = ">=3.8" + +# GoReleaser target -> wheel platform tag(s). A wheel may claim several +# platforms; the compressed tag set is joined with "." in the filename. +# CGO is disabled, so the Linux binaries are static and run on musl too. +PLATFORM_TAGS: dict[tuple[str, str], list[str]] = { + ("darwin", "amd64"): ["macosx_10_13_x86_64"], + ("darwin", "arm64"): ["macosx_11_0_arm64"], + ("linux", "amd64"): ["manylinux2014_x86_64", "musllinux_1_1_x86_64"], + ("linux", "arm64"): ["manylinux2014_aarch64", "musllinux_1_1_aarch64"], + ("windows", "amd64"): ["win_amd64"], + ("windows", "arm64"): ["win_arm64"], +} + +PRERELEASE = {"alpha": "a", "beta": "b", "rc": "rc"} + + +def pep440_version(tag: str) -> str: + """v2.0.0-beta.3 -> 2.0.0b3. Anything unexpected is a hard error.""" + match = re.fullmatch(r"v?(\d+\.\d+\.\d+)(?:-([a-z]+)\.?(\d+))?", tag) + if not match: + raise SystemExit(f"cannot convert tag {tag!r} to a PEP 440 version") + release, kind, number = match.groups() + if kind is None: + return release + if kind not in PRERELEASE: + raise SystemExit(f"unknown prerelease segment {kind!r} in tag {tag!r}") + return f"{release}{PRERELEASE[kind]}{number}" + + +def metadata(version: str, readme: str) -> str: + return ( + "Metadata-Version: 2.4\n" + f"Name: {PACKAGE}\n" + f"Version: {version}\n" + f"Summary: {SUMMARY}\n" + f"Project-URL: Homepage, {HOMEPAGE}\n" + f"Project-URL: Source, {HOMEPAGE}\n" + f"Project-URL: Issues, {HOMEPAGE}/issues\n" + "License-Expression: MIT\n" + "License-File: LICENSE\n" + "Keywords: cli,feature-flags,flagsmith\n" + "Classifier: Development Status :: 4 - Beta\n" + "Classifier: Environment :: Console\n" + "Classifier: Intended Audience :: Developers\n" + "Classifier: Programming Language :: Go\n" + "Classifier: Topic :: Software Development\n" + f"Requires-Python: {REQUIRES_PYTHON}\n" + "Description-Content-Type: text/markdown\n" + "\n" + f"{readme}" + ) + + +@dataclass +class Record: + entries: list[tuple[str, str, int]] + + def add(self, name: str, data: bytes) -> None: + digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()) + self.entries.append((name, f"sha256={digest.rstrip(b'=').decode()}", len(data))) + + def render(self, record_name: str) -> bytes: + out = io.StringIO() + writer = csv.writer(out, lineterminator="\n") + writer.writerows(self.entries) + writer.writerow([record_name, "", ""]) + return out.getvalue().encode() + + +def build_wheel( + *, + binary: Path, + goos: str, + tags: list[str], + version: str, + readme: str, + license_text: str, + out_dir: Path, +) -> Path: + dist_info = f"{PACKAGE_ESCAPED}-{version}.dist-info" + data_scripts = f"{PACKAGE_ESCAPED}-{version}.data/scripts" + script_name = f"{BINARY}.exe" if goos == "windows" else BINARY + tag = ".".join(tags) + + files: list[tuple[str, bytes, int]] = [ + (f"{dist_info}/METADATA", metadata(version, readme).encode(), 0o644), + ( + f"{dist_info}/WHEEL", + ( + "Wheel-Version: 1.0\n" + f"Generator: {Path(__file__).name}\n" + "Root-Is-Purelib: false\n" + + "".join(f"Tag: py3-none-{t}\n" for t in tags) + ).encode(), + 0o644, + ), + (f"{dist_info}/licenses/LICENSE", license_text.encode(), 0o644), + (f"{data_scripts}/{script_name}", binary.read_bytes(), 0o755), + ] + + record = Record(entries=[]) + path = out_dir / f"{PACKAGE_ESCAPED}-{version}-py3-none-{tag}.whl" + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as wheel: + for name, data, mode in files: + record.add(name, data) + # Fixed timestamp: same inputs, same wheel, byte for byte. + info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + info.external_attr = (mode << 16) | 0o100000 + info.compress_type = zipfile.ZIP_DEFLATED + wheel.writestr(info, data) + record_name = f"{dist_info}/RECORD" + info = zipfile.ZipInfo(record_name, date_time=(1980, 1, 1, 0, 0, 0)) + info.external_attr = (0o644 << 16) | 0o100000 + info.compress_type = zipfile.ZIP_DEFLATED + wheel.writestr(info, record.render(record_name)) + return path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--dist", type=Path, default=Path("dist"), help="GoReleaser dist directory" + ) + parser.add_argument( + "--out", type=Path, default=None, help="output directory (default: /pypi)" + ) + parser.add_argument( + "--version", + default=None, + help="override the version (default: tag from metadata.json)", + ) + args = parser.parse_args() + + repo = Path(__file__).resolve().parents[2] + artifacts = json.loads((args.dist / "artifacts.json").read_text()) + meta = json.loads((args.dist / "metadata.json").read_text()) + version = args.version or pep440_version(meta["tag"]) + + out_dir = args.out or args.dist / "pypi" + out_dir.mkdir(parents=True, exist_ok=True) + readme = (repo / "README.md").read_text() + license_text = (repo / "LICENSE").read_text() + + built = 0 + for artifact in artifacts: + if artifact.get("type") != "Binary": + continue + target = (artifact["goos"], artifact["goarch"]) + tags = PLATFORM_TAGS.get(target) + if tags is None: + print( + f"skipping {target[0]}/{target[1]}: no wheel platform tag", + file=sys.stderr, + ) + continue + path = build_wheel( + binary=Path(artifact["path"]), + goos=target[0], + tags=tags, + version=version, + readme=readme, + license_text=license_text, + out_dir=out_dir, + ) + print(f"{target[0]}/{target[1]} -> {path}") + built += 1 + + if not built: + raise SystemExit("no binaries found in artifacts.json") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 748ef80848fc097091ca9dd6b2e8ec9d327256b8 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 20 Aug 2026 11:13:03 +0100 Subject: [PATCH 2/5] ci: Match the macOS wheel baseline to the Go toolchain Go 1.26, which go.mod pins and the release job builds with, requires macOS 12. A macOS wheel tag is a minimum, so 10.13/11 tags let pip install onto systems the binary cannot run on. --- packaging/pypi/build_wheels.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packaging/pypi/build_wheels.py b/packaging/pypi/build_wheels.py index 7e302ed..14f9ea9 100644 --- a/packaging/pypi/build_wheels.py +++ b/packaging/pypi/build_wheels.py @@ -37,9 +37,15 @@ # GoReleaser target -> wheel platform tag(s). A wheel may claim several # platforms; the compressed tag set is joined with "." in the filename. # CGO is disabled, so the Linux binaries are static and run on musl too. +# +# A macOS tag is a minimum: macosx_12_0 installs on 12 and newer. Keep it at +# the floor of the Go version in go.mod, which the release job builds with -- +# claiming more than the binary supports means installing onto a macOS that +# cannot run it. Go 1.26 needs macOS 12; 1.27 moves to 13. See +# https://go.dev/wiki/MinimumRequirements PLATFORM_TAGS: dict[tuple[str, str], list[str]] = { - ("darwin", "amd64"): ["macosx_10_13_x86_64"], - ("darwin", "arm64"): ["macosx_11_0_arm64"], + ("darwin", "amd64"): ["macosx_12_0_x86_64"], + ("darwin", "arm64"): ["macosx_12_0_arm64"], ("linux", "amd64"): ["manylinux2014_x86_64", "musllinux_1_1_x86_64"], ("linux", "arm64"): ["manylinux2014_aarch64", "musllinux_1_1_aarch64"], ("windows", "amd64"): ["win_amd64"], From f0436e6b036fb5a3843a78179fc275a90cb7fba8 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 20 Aug 2026 11:15:17 +0100 Subject: [PATCH 3/5] ci: Trim the macOS wheel tag comment to the Go requirements link --- packaging/pypi/build_wheels.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packaging/pypi/build_wheels.py b/packaging/pypi/build_wheels.py index 14f9ea9..c91d2e1 100644 --- a/packaging/pypi/build_wheels.py +++ b/packaging/pypi/build_wheels.py @@ -37,12 +37,7 @@ # GoReleaser target -> wheel platform tag(s). A wheel may claim several # platforms; the compressed tag set is joined with "." in the filename. # CGO is disabled, so the Linux binaries are static and run on musl too. -# -# A macOS tag is a minimum: macosx_12_0 installs on 12 and newer. Keep it at -# the floor of the Go version in go.mod, which the release job builds with -- -# claiming more than the binary supports means installing onto a macOS that -# cannot run it. Go 1.26 needs macOS 12; 1.27 moves to 13. See -# https://go.dev/wiki/MinimumRequirements +# The macOS tags are a minimum, kept at https://go.dev/wiki/MinimumRequirements PLATFORM_TAGS: dict[tuple[str, str], list[str]] = { ("darwin", "amd64"): ["macosx_12_0_x86_64"], ("darwin", "arm64"): ["macosx_12_0_arm64"], From 0b649ec23805ef74e7ba8b3cb9a761ea86eb8e38 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 20 Aug 2026 11:24:22 +0100 Subject: [PATCH 4/5] deslop --- README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/README.md b/README.md index b589a73..a26cb89 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,6 @@ uv tool install flagsmith-cli # installs the `flagsmith` command uvx --from flagsmith-cli flagsmith --help ``` -The wheels carry the binary itself, so no Python code runs at any point. (`pip install flagsmith-cli` works too; add `--pre` while the CLI is in beta. The `flagsmith` package on PyPI is the [Python SDK](https://github.com/Flagsmith/flagsmith-python-client), which is why the CLI needs `--from`.) - Alternatively, `go install github.com/Flagsmith/flagsmith-cli/v2@v2.0.0-beta.3` (installs as `flagsmith-cli`), or grab an archive from [Releases](https://github.com/Flagsmith/flagsmith-cli/releases). On Windows: From c3a03272f8c72cebacbf3f127bbc5ceb833a2892 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Thu, 20 Aug 2026 18:01:08 +0100 Subject: [PATCH 5/5] ci: Build the wheels with hatchling instead of our own script hatchling (via uv build) builds each wheel and `wheel tags` stamps the platform tag on it, which leaves nothing for us to maintain: no zip plumbing, no RECORD hashes, no METADATA, and no PEP 440 conversion either, since hatchling's env version source normalises the Go tag. --- .github/workflows/pull-request.yml | 2 +- .github/workflows/release.yml | 3 +- .gitignore | 5 +- packaging/pypi/build-wheels.sh | 58 ++++++++ packaging/pypi/build_wheels.py | 210 ----------------------------- packaging/pypi/pyproject.toml | 39 ++++++ 6 files changed, 104 insertions(+), 213 deletions(-) create mode 100755 packaging/pypi/build-wheels.sh delete mode 100644 packaging/pypi/build_wheels.py create mode 100644 packaging/pypi/pyproject.toml diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index d98e009..48a1b79 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -80,7 +80,7 @@ jobs: with: version: "~> v2" args: build --snapshot --clean --single-target - - run: python3 packaging/pypi/build_wheels.py --dist dist + - run: ./packaging/pypi/build-wheels.sh - run: uvx twine check dist/pypi/*.whl # The wheel ships no Python: this proves the binary lands on PATH. - run: uvx --from ./dist/pypi/*.whl flagsmith --version diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 027191e..6377b37 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,7 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod + - uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1 - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4.5.2 with: @@ -56,7 +57,7 @@ jobs: subject-checksums: ./dist/digests.txt - name: Build PyPI wheels - run: python3 packaging/pypi/build_wheels.py --dist dist + run: ./packaging/pypi/build-wheels.sh - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pypi-wheels diff --git a/.gitignore b/.gitignore index e0e5434..45bcda3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /flagsmith /dist/ *.test -__pycache__/ +# Staged by packaging/pypi/build-wheels.sh +/packaging/pypi/bin/ +/packaging/pypi/README.md +/packaging/pypi/LICENSE diff --git a/packaging/pypi/build-wheels.sh b/packaging/pypi/build-wheels.sh new file mode 100755 index 0000000..253dd13 --- /dev/null +++ b/packaging/pypi/build-wheels.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Build PyPI wheels around the binaries from GoReleaser. +# +# hatchling builds each wheel (see pyproject.toml) and `wheel tags` stamps the +# platform tag on it, so there is no packaging code of our own to maintain. +# +# Reads dist/artifacts.json + dist/metadata.json, writes wheels to dist/pypi/. +# +# packaging/pypi/build-wheels.sh [dist-dir] +set -euo pipefail + +dist=${1:-dist} +here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +repo=$(cd "$here/../.." && pwd) +out=$repo/$dist/pypi + +# CGO is disabled, so the Linux binaries are static and run on musl too. +# The macOS tags are a minimum, kept at https://go.dev/wiki/MinimumRequirements +platform_tag() { + case "$1/$2" in + darwin/amd64) echo macosx_12_0_x86_64 ;; + darwin/arm64) echo macosx_12_0_arm64 ;; + linux/amd64) echo manylinux2014_x86_64.musllinux_1_1_x86_64 ;; + linux/arm64) echo manylinux2014_aarch64.musllinux_1_1_aarch64 ;; + windows/amd64) echo win_amd64 ;; + windows/arm64) echo win_arm64 ;; + *) return 1 ;; + esac +} + +version=$(jq -re '.tag | ltrimstr("v")' "$dist/metadata.json") +rm -rf "$out" "${here:?}/bin" +mkdir -p "$out" "$here/bin" +cp "$repo/README.md" "$repo/LICENSE" "$here/" + +built=0 +while read -r goos goarch path; do + tag=$(platform_tag "$goos" "$goarch") || { + echo "skipping $goos/$goarch: no wheel platform tag" >&2 + continue + } + script=flagsmith + [ "$goos" = windows ] && script=flagsmith.exe + rm -f "$here"/bin/flagsmith* + install -m 755 "$path" "$here/bin/$script" + + FLAGSMITH_CLI_VERSION=$version uv build --quiet --wheel "$here" --out-dir "$out" + uvx --from wheel wheel tags --python-tag py3 --abi-tag none \ + --platform-tag "$tag" --remove "$out/flagsmith_cli-"*"-py3-none-any.whl" + echo "$goos/$goarch -> $tag" + built=$((built + 1)) +done < <(jq -re '.[] | select(.type == "Binary") | [.goos, .goarch, .path] | @tsv' "$dist/artifacts.json") + +rm -rf "${here:?}/bin" "$here/README.md" "$here/LICENSE" +[ "$built" -gt 0 ] || { + echo "no binaries found in $dist/artifacts.json" >&2 + exit 1 +} diff --git a/packaging/pypi/build_wheels.py b/packaging/pypi/build_wheels.py deleted file mode 100644 index c91d2e1..0000000 --- a/packaging/pypi/build_wheels.py +++ /dev/null @@ -1,210 +0,0 @@ -#!/usr/bin/env python3 -"""Build PyPI wheels around the binaries from GoReleaser. - -The binary is shipped in the wheel's .data/scripts/ directory, -which every installer drops straight onto PATH. -This should make `uv tool install flagsmith-cli` (and pip, and pipx) hand -you a working `flagsmith` without a Python shim in the way. - -Reads dist/artifacts.json + dist/metadata.json, writes wheels to -dist/pypi/. - - python3 packaging/pypi/build_wheels.py --dist dist -""" - -from __future__ import annotations - -import argparse -import base64 -import csv -import hashlib -import io -import json -import re -import sys -import zipfile -from dataclasses import dataclass -from pathlib import Path - -PACKAGE = "flagsmith-cli" -# Wheel filenames and .dist-info/.data directories use the escaped name. -PACKAGE_ESCAPED = PACKAGE.replace("-", "_") -BINARY = "flagsmith" -SUMMARY = "The Flagsmith command-line interface" -HOMEPAGE = "https://github.com/Flagsmith/flagsmith-cli" -REQUIRES_PYTHON = ">=3.8" - -# GoReleaser target -> wheel platform tag(s). A wheel may claim several -# platforms; the compressed tag set is joined with "." in the filename. -# CGO is disabled, so the Linux binaries are static and run on musl too. -# The macOS tags are a minimum, kept at https://go.dev/wiki/MinimumRequirements -PLATFORM_TAGS: dict[tuple[str, str], list[str]] = { - ("darwin", "amd64"): ["macosx_12_0_x86_64"], - ("darwin", "arm64"): ["macosx_12_0_arm64"], - ("linux", "amd64"): ["manylinux2014_x86_64", "musllinux_1_1_x86_64"], - ("linux", "arm64"): ["manylinux2014_aarch64", "musllinux_1_1_aarch64"], - ("windows", "amd64"): ["win_amd64"], - ("windows", "arm64"): ["win_arm64"], -} - -PRERELEASE = {"alpha": "a", "beta": "b", "rc": "rc"} - - -def pep440_version(tag: str) -> str: - """v2.0.0-beta.3 -> 2.0.0b3. Anything unexpected is a hard error.""" - match = re.fullmatch(r"v?(\d+\.\d+\.\d+)(?:-([a-z]+)\.?(\d+))?", tag) - if not match: - raise SystemExit(f"cannot convert tag {tag!r} to a PEP 440 version") - release, kind, number = match.groups() - if kind is None: - return release - if kind not in PRERELEASE: - raise SystemExit(f"unknown prerelease segment {kind!r} in tag {tag!r}") - return f"{release}{PRERELEASE[kind]}{number}" - - -def metadata(version: str, readme: str) -> str: - return ( - "Metadata-Version: 2.4\n" - f"Name: {PACKAGE}\n" - f"Version: {version}\n" - f"Summary: {SUMMARY}\n" - f"Project-URL: Homepage, {HOMEPAGE}\n" - f"Project-URL: Source, {HOMEPAGE}\n" - f"Project-URL: Issues, {HOMEPAGE}/issues\n" - "License-Expression: MIT\n" - "License-File: LICENSE\n" - "Keywords: cli,feature-flags,flagsmith\n" - "Classifier: Development Status :: 4 - Beta\n" - "Classifier: Environment :: Console\n" - "Classifier: Intended Audience :: Developers\n" - "Classifier: Programming Language :: Go\n" - "Classifier: Topic :: Software Development\n" - f"Requires-Python: {REQUIRES_PYTHON}\n" - "Description-Content-Type: text/markdown\n" - "\n" - f"{readme}" - ) - - -@dataclass -class Record: - entries: list[tuple[str, str, int]] - - def add(self, name: str, data: bytes) -> None: - digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()) - self.entries.append((name, f"sha256={digest.rstrip(b'=').decode()}", len(data))) - - def render(self, record_name: str) -> bytes: - out = io.StringIO() - writer = csv.writer(out, lineterminator="\n") - writer.writerows(self.entries) - writer.writerow([record_name, "", ""]) - return out.getvalue().encode() - - -def build_wheel( - *, - binary: Path, - goos: str, - tags: list[str], - version: str, - readme: str, - license_text: str, - out_dir: Path, -) -> Path: - dist_info = f"{PACKAGE_ESCAPED}-{version}.dist-info" - data_scripts = f"{PACKAGE_ESCAPED}-{version}.data/scripts" - script_name = f"{BINARY}.exe" if goos == "windows" else BINARY - tag = ".".join(tags) - - files: list[tuple[str, bytes, int]] = [ - (f"{dist_info}/METADATA", metadata(version, readme).encode(), 0o644), - ( - f"{dist_info}/WHEEL", - ( - "Wheel-Version: 1.0\n" - f"Generator: {Path(__file__).name}\n" - "Root-Is-Purelib: false\n" - + "".join(f"Tag: py3-none-{t}\n" for t in tags) - ).encode(), - 0o644, - ), - (f"{dist_info}/licenses/LICENSE", license_text.encode(), 0o644), - (f"{data_scripts}/{script_name}", binary.read_bytes(), 0o755), - ] - - record = Record(entries=[]) - path = out_dir / f"{PACKAGE_ESCAPED}-{version}-py3-none-{tag}.whl" - with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as wheel: - for name, data, mode in files: - record.add(name, data) - # Fixed timestamp: same inputs, same wheel, byte for byte. - info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) - info.external_attr = (mode << 16) | 0o100000 - info.compress_type = zipfile.ZIP_DEFLATED - wheel.writestr(info, data) - record_name = f"{dist_info}/RECORD" - info = zipfile.ZipInfo(record_name, date_time=(1980, 1, 1, 0, 0, 0)) - info.external_attr = (0o644 << 16) | 0o100000 - info.compress_type = zipfile.ZIP_DEFLATED - wheel.writestr(info, record.render(record_name)) - return path - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "--dist", type=Path, default=Path("dist"), help="GoReleaser dist directory" - ) - parser.add_argument( - "--out", type=Path, default=None, help="output directory (default: /pypi)" - ) - parser.add_argument( - "--version", - default=None, - help="override the version (default: tag from metadata.json)", - ) - args = parser.parse_args() - - repo = Path(__file__).resolve().parents[2] - artifacts = json.loads((args.dist / "artifacts.json").read_text()) - meta = json.loads((args.dist / "metadata.json").read_text()) - version = args.version or pep440_version(meta["tag"]) - - out_dir = args.out or args.dist / "pypi" - out_dir.mkdir(parents=True, exist_ok=True) - readme = (repo / "README.md").read_text() - license_text = (repo / "LICENSE").read_text() - - built = 0 - for artifact in artifacts: - if artifact.get("type") != "Binary": - continue - target = (artifact["goos"], artifact["goarch"]) - tags = PLATFORM_TAGS.get(target) - if tags is None: - print( - f"skipping {target[0]}/{target[1]}: no wheel platform tag", - file=sys.stderr, - ) - continue - path = build_wheel( - binary=Path(artifact["path"]), - goos=target[0], - tags=tags, - version=version, - readme=readme, - license_text=license_text, - out_dir=out_dir, - ) - print(f"{target[0]}/{target[1]} -> {path}") - built += 1 - - if not built: - raise SystemExit("no binaries found in artifacts.json") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/packaging/pypi/pyproject.toml b/packaging/pypi/pyproject.toml new file mode 100644 index 0000000..e12f0e7 --- /dev/null +++ b/packaging/pypi/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "flagsmith-cli" +dynamic = ["version"] +description = "The Flagsmith command-line interface" +readme = "README.md" # staged by build-wheels.sh +license = "MIT" +license-files = ["LICENSE"] # staged by build-wheels.sh +requires-python = ">=3.8" +keywords = ["cli", "feature-flags", "flagsmith"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: Developers", + "Programming Language :: Go", + "Topic :: Software Development", +] + +[project.urls] +Homepage = "https://github.com/Flagsmith/flagsmith-cli" +Source = "https://github.com/Flagsmith/flagsmith-cli" +Issues = "https://github.com/Flagsmith/flagsmith-cli/issues" + +# The Go tag, minus its "v"; hatchling normalises it to PEP 440 +# (2.0.0-beta.3 -> 2.0.0b3). +[tool.hatch.version] +source = "env" +variable = "FLAGSMITH_CLI_VERSION" + +# The binary ships in the wheel's .data/scripts/ directory, which every +# installer drops straight onto PATH: `uv tool install flagsmith-cli` (and pip, +# and pipx) hand you a working `flagsmith` with no Python shim in the way. +# Only one of these two exists per build; hatchling skips the other. +[tool.hatch.build.targets.wheel] +bypass-selection = true # no Python package to ship +shared-scripts = { "bin/flagsmith" = "flagsmith", "bin/flagsmith.exe" = "flagsmith.exe" }