From 6a03f0ef2f5a45dd7ffa3b46075e32c6601f997a Mon Sep 17 00:00:00 2001 From: Alex Wilkerson John Date: Fri, 3 Jul 2026 13:38:35 -0400 Subject: [PATCH] feat(packaging): publish codeguard to npm and PyPI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add thin wrapper packages that ship the prebuilt GoReleaser binaries to npm (@devr-tools/codeguard launcher + per-platform optionalDependencies) and PyPI (per-platform wheels). No Go toolchain or network access at install time. - packaging/extract-binaries.sh stages the 4 release archives (darwin/linux x amd64/arm64; no Windows, tar.gz, codeguard_v__). - npm: build.sh assembles the launcher + platform packages; launcher shim resolves the host optionalDependency and execs the binary. - PyPI project is `devr-codeguard` (plain `codeguard` is taken); the installed command is still `codeguard` — distribution name and data-script name are decoupled in build_wheels.py. - release.yml gains publish-npm and publish-pypi jobs (stable-only, OIDC trusted publishing). Because release.yml is a reusable workflow called by cd.yml, the trusted publishers must be configured against cd.yml. Verified locally against v0.7.0: npm dry-run + install-simulation, twine check on all 6 wheels, and `pip install devr-codeguard` yielding a working `codeguard` command. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/knowledge/deployment-release.md | 16 +++ .github/workflows/release.yml | 122 ++++++++++++++++++++ .gitignore | 1 + README.md | 15 +++ packaging/README.md | 133 +++++++++++++++++++++ packaging/extract-binaries.sh | 52 +++++++++ packaging/npm/bootstrap-publish.sh | 54 +++++++++ packaging/npm/build.sh | 91 +++++++++++++++ packaging/npm/launcher/README.md | 16 +++ packaging/npm/launcher/bin/codeguard | 49 ++++++++ packaging/pypi/build_wheels.py | 146 ++++++++++++++++++++++++ 11 files changed, 695 insertions(+) create mode 100644 .claude/knowledge/deployment-release.md create mode 100644 packaging/README.md create mode 100755 packaging/extract-binaries.sh create mode 100755 packaging/npm/bootstrap-publish.sh create mode 100755 packaging/npm/build.sh create mode 100644 packaging/npm/launcher/README.md create mode 100755 packaging/npm/launcher/bin/codeguard create mode 100644 packaging/pypi/build_wheels.py diff --git a/.claude/knowledge/deployment-release.md b/.claude/knowledge/deployment-release.md new file mode 100644 index 0000000..a612839 --- /dev/null +++ b/.claude/knowledge/deployment-release.md @@ -0,0 +1,16 @@ +# Deployment & Release + +How code gets to production. Release processes, environment promotion, rollback procedures, gotchas. + +## Release flow + +- Stable releases: push to `main` → `cd.yml` runs `release-please` → when a release PR merges, the `stable-release` job calls the **reusable** `release.yml` (`workflow_call`) with `prerelease: false`. Prereleases go through `cd.yml`'s `prepare-prerelease` → `release.yml` with `prerelease: true`. +- `release.yml` has **no `on: push: tags` trigger** — this is deliberate. Tags are minted only through the release-please approval flow (or a `create_missing_tag: true` call from the trusted `cd.yml` caller). Do not add a tag-push trigger; it would let anyone pushing a tag bypass the approval flow. +- GoReleaser (`.goreleaser.yaml`) builds only **darwin + linux on amd64/arm64** — no Windows. Archives are `tar.gz` named `codeguard_v__.tar.gz` (note the literal `v` before the version). + +## npm + PyPI packaging (packaging/) + +- npm/PyPI ship thin wrappers around the prebuilt GoReleaser binaries — no Go toolchain at install time. `packaging/extract-binaries.sh` downloads release assets, `npm/build.sh` and `pypi/build_wheels.py` assemble artifacts. `publish-npm`/`publish-pypi` jobs in `release.yml` run for stable releases only. See `packaging/README.md`. +- **GOTCHA — trusted publishing + reusable workflow:** the publish jobs live in `release.yml`, but npm and PyPI OIDC trusted publishing match the **top-level *calling* workflow**, not the reusable one. So the trusted publisher on npmjs.com / PyPI must be configured with workflow filename **`cd.yml`**, NOT `release.yml`. PyPI additionally forbids naming a reusable workflow as the publisher outright (warehouse#11096). `id-token: write` must be present on both the `cd.yml` caller jobs (it is) and the `release.yml` publish jobs. +- First automated publish needs one-time manual bootstrap: `packaging/npm/bootstrap-publish.sh` (npm requires a package to exist before a trusted publisher can be added) and a PyPI *pending publisher* for project `devr-codeguard`. Both are documented step-by-step in `packaging/README.md`. +- **PyPI project name is `devr-codeguard`, NOT `codeguard`** (the plain name was already taken on PyPI). The npm scope `@devr-tools/codeguard` is unaffected. The installed command is still `codeguard` regardless — in `build_wheels.py`, `PROJECT`/`DIST` (distribution name, hyphen vs. PEP 427 escaped `devr_codeguard`) are decoupled from `BIN`/the data-script name (`codeguard`), so `pip install devr-codeguard` yields a `codeguard` command. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e5c1768..d9adf95 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -409,3 +409,125 @@ jobs: echo "- SHA256: `$FORMULA_SHA256`" echo "- Suggested branch: `$FORMULA_BRANCH`" } >>"$GITHUB_STEP_SUMMARY" + + publish-npm: + needs: build-release + if: needs.build-release.outputs.prerelease != 'true' + runs-on: ubuntu-latest + permissions: + contents: read + # Required for npm trusted publishing (OIDC) — no NPM_TOKEN needed. + # Matches the top-level caller (cd.yml) configured as the trusted + # publisher; the id-token: write on cd.yml's stable-release job propagates + # here because release.yml is called from it. + id-token: write + steps: + - name: Check out code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "22" + + - name: Upgrade npm for trusted publishing + # Trusted publishing (OIDC) requires npm >= 11.5.1; the runner ships an + # older npm by default. + run: npm install -g npm@latest + + - name: Resolve package version + id: version + shell: bash + env: + RELEASE_TAG: ${{ needs.build-release.outputs.tag }} + run: | + set -euo pipefail + if [[ "$RELEASE_TAG" == *-v* ]]; then + version="${RELEASE_TAG##*-v}" + else + version="${RELEASE_TAG#v}" + fi + echo "version=$version" >>"$GITHUB_OUTPUT" + + - name: Stage release binaries + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + ./packaging/extract-binaries.sh "${{ needs.build-release.outputs.tag }}" "${{ steps.version.outputs.version }}" + + - name: Build npm packages + run: ./packaging/npm/build.sh "${{ steps.version.outputs.version }}" + + - name: Publish to npm + # Authentication is via OIDC trusted publishing (id-token: write above); + # no NODE_AUTH_TOKEN/.npmrc. Provenance is attached automatically. + shell: bash + run: | + set -euo pipefail + + publish_dir() { + local dir="$1" + local name ver + name="$(node -p "require('./$dir/package.json').name")" + ver="$(node -p "require('./$dir/package.json').version")" + if npm view "$name@$ver" version >/dev/null 2>&1; then + echo "$name@$ver already published, skipping" + else + npm publish "$dir" --access public + fi + } + + # Platform packages first so the launcher's optional deps resolve. + for dir in packaging/npm/dist/codeguard-*/package; do + publish_dir "$dir" + done + publish_dir packaging/npm/dist/codeguard/package + + publish-pypi: + needs: build-release + if: needs.build-release.outputs.prerelease != 'true' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # PyPI trusted publishing (OIDC) — no PYPI_API_TOKEN needed. + steps: + - name: Check out code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Resolve package version + id: version + shell: bash + env: + RELEASE_TAG: ${{ needs.build-release.outputs.tag }} + run: | + set -euo pipefail + if [[ "$RELEASE_TAG" == *-v* ]]; then + version="${RELEASE_TAG##*-v}" + else + version="${RELEASE_TAG#v}" + fi + echo "version=$version" >>"$GITHUB_OUTPUT" + + - name: Stage release binaries + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + ./packaging/extract-binaries.sh "${{ needs.build-release.outputs.tag }}" "${{ steps.version.outputs.version }}" + + - name: Build wheels + run: | + set -euo pipefail + python3 packaging/pypi/build_wheels.py "${{ steps.version.outputs.version }}" packaging/.staging packaging/pypi/dist + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1 + with: + packages-dir: packaging/pypi/dist + skip-existing: true diff --git a/.gitignore b/.gitignore index 821f67d..251ce32 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ coverage.* *.coverprofile profile.cov dist/ +packaging/.staging/ .gocache/ .gomodcache/ .codeguard/cache.json diff --git a/README.md b/README.md index 88b300e..482fe18 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,21 @@ Other install paths: - Homebrew: `brew install devr-tools/tap/codeguard` - GitHub Marketplace Action: `Devr Codeguard` +npm (installs a prebuilt binary, no Go toolchain required): + +```bash +npm install -g @devr-tools/codeguard +codeguard version +``` + +pip (installs a prebuilt binary per platform; the project is `devr-codeguard` +because the plain `codeguard` name is taken, but the command is still `codeguard`): + +```bash +pip install devr-codeguard +codeguard version +``` + ```yaml - name: Devr Codeguard uses: devr-tools/codeguard@v0.2.0 diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..6baa887 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,133 @@ +# Packaging: npm and PyPI + +`codeguard` is a Go binary released by GoReleaser to GitHub Releases. The npm and +PyPI packages here are thin wrappers that ship those **prebuilt binaries** — no +Go toolchain, and no network access, at install time. + +| Registry | Install | Package(s) | +| --- | --- | --- | +| npm | `npm install -g @devr-tools/codeguard` | `@devr-tools/codeguard` (launcher) + one `@devr-tools/codeguard--` per platform | +| PyPI | `pip install devr-codeguard` | one `devr_codeguard--py3-none-.whl` per platform | + +Both are published automatically by `.github/workflows/release.yml` for **stable** +releases only (prereleases are skipped, matching the Homebrew job). + +## How it works + +- **npm** uses esbuild-style `optionalDependencies`. The `@devr-tools/codeguard` + launcher declares one optional dependency per platform (each constrained by + `os`/`cpu`). npm installs only the package matching the host, and the + `bin/codeguard` launcher `require.resolve`s that package's binary and execs it. +- **PyPI** ships one wheel per platform. The project is named **`devr-codeguard`** + (the plain `codeguard` name is already taken), but the installed command is + still `codeguard`: each wheel carries the binary as a *data script* + (`devr_codeguard-.data/scripts/codeguard`), so pip drops `codeguard` + straight onto PATH. There is no Python module and no shim. + +## Layout + +``` +packaging/ + extract-binaries.sh # download + extract release binaries into .staging/ + npm/ + launcher/bin/codeguard # the launcher shim (committed source of truth) + launcher/README.md # README published with the main npm package + build.sh # generate dist/ from .staging/ + version + bootstrap-publish.sh # one-time manual publish to seed trusted publishing + pypi/ + build_wheels.py # generate dist/*.whl from .staging/ + version +``` + +## Build and test locally + +```bash +# 1. Stage binaries from a published release (needs an authenticated gh). +./packaging/extract-binaries.sh v0.7.0 0.7.0 + +# 2. npm packages -> packaging/npm/dist/ +./packaging/npm/build.sh 0.7.0 + +# 3. wheels -> packaging/pypi/dist/ +python3 packaging/pypi/build_wheels.py 0.7.0 packaging/.staging packaging/pypi/dist +``` + +`npm publish --dry-run ` and `pipx run twine check packaging/pypi/dist/*.whl` +validate the artifacts without publishing. + +## Release flow and the reusable-workflow caveat + +Unlike a repo that triggers `release.yml` directly on a tag push, codeguard's +`release.yml` is a **reusable workflow** (`workflow_call` only) invoked by +`cd.yml`: + +``` +push to main -> cd.yml (release-please) -> stable-release job -> release.yml +``` + +The `publish-npm` and `publish-pypi` jobs live inside `release.yml` (where the +release binaries are built), but **npm and PyPI trusted publishing match the +top-level *calling* workflow, not the reusable one.** So the trusted publisher +must be configured against **`cd.yml`**, and `id-token: write` is set on both the +`cd.yml` caller jobs and the `release.yml` publish jobs. + +> PyPI does not allow a *reusable* workflow to be named as the trusted publisher +> (warehouse#11096); naming the `cd.yml` caller is the supported path. + +## One-time prerequisites (before the first automated release) + +These are external-registry setup steps the CI cannot perform for you. Both +registries use OIDC trusted publishing — no long-lived tokens live in CI. + +1. **npm org + scope.** Create/own the `@devr-tools` npm org (or org scope) so + the scoped packages can be published publicly. + +2. **npm trusted publishing (OIDC).** npm requires each package to *already + exist* before you can add a trusted publisher, and trusted publishers are + configured **per package**. So: + + a. **Bootstrap once** — publish all five packages from your machine (needs + `npm login` or a token in `~/.npmrc` with publish rights to + `@devr-tools`): + + ```bash + ./packaging/npm/bootstrap-publish.sh v0.7.0 0.7.0 + ``` + + b. **Configure a trusted publisher** for each package on npmjs.com + (package → Settings → Trusted Publisher → GitHub Actions): + - Organization/user: `devr-tools` + - Repository: `codeguard` + - Workflow filename: `cd.yml` ← the caller, not release.yml + - Environment: *(leave blank)* + - Allowed actions: `npm publish` + + Packages: `@devr-tools/codeguard` plus + `@devr-tools/codeguard-{darwin-x64,darwin-arm64,linux-x64,linux-arm64}`. + + After that, the `publish-npm` job publishes via OIDC (`id-token: write`, + npm ≥ 11.5.1 which the job installs). No `NPM_TOKEN` needed, and provenance + is attached automatically. + +3. **PyPI trusted publisher (OIDC).** On PyPI, add a *pending publisher* for + project `devr-codeguard` (the plain `codeguard` name is taken; the installed + command is still `codeguard`): + - Owner / repo: `devr-tools/codeguard` + - Workflow filename: `cd.yml` ← the caller, not release.yml + - Environment: *(leave blank — the job sets none)* + + This lets the `publish-pypi` job authenticate via `id-token: write` with no + long-lived token. (Alternatively, set a `PYPI_API_TOKEN` secret and pass it + to `pypa/gh-action-pypi-publish` with `password:`.) + +## Platform matrix + +Keep this in sync with the `.goreleaser.yaml` build matrix. Currently: + +| GOOS/GOARCH | npm package | wheel platform tag(s) | +| --- | --- | --- | +| darwin/amd64 | `@devr-tools/codeguard-darwin-x64` | `macosx_10_9_x86_64` | +| darwin/arm64 | `@devr-tools/codeguard-darwin-arm64` | `macosx_11_0_arm64` | +| linux/amd64 | `@devr-tools/codeguard-linux-x64` | `manylinux2014_x86_64`, `musllinux_1_1_x86_64` | +| linux/arm64 | `@devr-tools/codeguard-linux-arm64` | `manylinux2014_aarch64`, `musllinux_1_1_aarch64` | + +GoReleaser builds no Windows target, so there is no Windows wrapper. diff --git a/packaging/extract-binaries.sh b/packaging/extract-binaries.sh new file mode 100755 index 0000000..00fa0fe --- /dev/null +++ b/packaging/extract-binaries.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# Download the GoReleaser archives for a release tag and extract each +# `codeguard` binary into a stable staging layout that the npm and PyPI +# builders consume: +# +# /_/codeguard +# +# Usage: extract-binaries.sh [staging_dir] +# +# tag release tag, e.g. v0.7.0 or codeguard-v0.7.0 +# version bare semver used in asset names, e.g. 0.7.0 +# staging_dir output dir (default: packaging/.staging) +# +# Requires: gh (authenticated), tar. +set -euo pipefail + +tag="${1:?tag required}" +version="${2:?version required}" +staging="${3:-"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.staging"}" + +# goos goarch -> archive suffix. Keep in sync with .goreleaser.yaml build matrix. +# codeguard builds darwin/linux on amd64/arm64 only (no windows), all tar.gz. +targets=( + "darwin amd64 tar.gz" + "darwin arm64 tar.gz" + "linux amd64 tar.gz" + "linux arm64 tar.gz" +) + +workdir="$(mktemp -d)" +trap 'rm -rf "$workdir"' EXIT + +rm -rf "$staging" +mkdir -p "$staging" + +for target in "${targets[@]}"; do + read -r goos goarch ext <<<"$target" + # Matches .goreleaser.yaml name_template: + # "{{ .ProjectName }}_v{{ .Version }}_{{ .Os }}_{{ .Arch }}" + asset="codeguard_v${version}_${goos}_${goarch}.${ext}" + dest="$staging/${goos}_${goarch}" + mkdir -p "$dest" + + echo "==> $asset" + gh release download "$tag" --repo devr-tools/codeguard --pattern "$asset" --dir "$workdir" --clobber + + tar -xzf "$workdir/$asset" -C "$dest" codeguard + chmod +x "$dest/codeguard" +done + +echo "Binaries staged in $staging" diff --git a/packaging/npm/bootstrap-publish.sh b/packaging/npm/bootstrap-publish.sh new file mode 100755 index 0000000..aa86889 --- /dev/null +++ b/packaging/npm/bootstrap-publish.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# +# ONE-TIME bootstrap for npm trusted publishing. +# +# npm requires a package to already exist before you can configure a trusted +# publisher for it. This script publishes all five packages once from your +# machine using your local npm auth (run `npm login`, or set an npm token in +# ~/.npmrc, first). After it finishes, configure a trusted publisher for each +# package on npmjs.com (see the printed instructions); from then on the CI +# `publish-npm` job publishes automatically via OIDC — no token. +# +# Usage: bootstrap-publish.sh +# e.g. bootstrap-publish.sh v0.7.0 0.7.0 +set -euo pipefail + +tag="${1:?tag required, e.g. v0.7.0}" +version="${2:?version required, e.g. 0.7.0}" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +"$here/../extract-binaries.sh" "$tag" "$version" +"$here/build.sh" "$version" + +# Platform packages first so the launcher's optional deps resolve on install. +for dir in "$here"/dist/codeguard-*/package "$here"/dist/codeguard/package; do + name="$(node -p "require('$dir/package.json').name")" + echo "==> publishing $name@$version" + npm publish "$dir" --access public +done + +cat <<'EOF' + +Bootstrap publish complete. + +Next: on npmjs.com, open each package's Settings -> Trusted Publisher and add: + Publisher: GitHub Actions + Organization/user: devr-tools + Repository: codeguard + Workflow filename: cd.yml + Environment: (leave blank) + Allowed actions: npm publish + +Use cd.yml (NOT release.yml): the publish-npm job lives in the reusable +release.yml, but npm/PyPI trusted publishing matches the *top-level calling* +workflow, which is cd.yml (release-please -> stable-release -> release.yml). + +Packages to configure: + @devr-tools/codeguard + @devr-tools/codeguard-darwin-x64 + @devr-tools/codeguard-darwin-arm64 + @devr-tools/codeguard-linux-x64 + @devr-tools/codeguard-linux-arm64 + +After that, remove NPM_TOKEN (if set) — CI publishes via OIDC. +EOF diff --git a/packaging/npm/build.sh b/packaging/npm/build.sh new file mode 100755 index 0000000..33f4c82 --- /dev/null +++ b/packaging/npm/build.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# +# Assemble the publishable npm packages from staged binaries. +# +# Layout produced under packaging/npm/dist/: +# @devr-tools/codeguard main launcher package (optionalDependencies) +# @devr-tools/codeguard-darwin-x64 platform package (binary payload) +# @devr-tools/codeguard-darwin-arm64 +# @devr-tools/codeguard-linux-x64 +# @devr-tools/codeguard-linux-arm64 +# +# Usage: build.sh [staging_dir] +set -euo pipefail + +version="${1:?version required}" +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +staging="${2:-"$here/../.staging"}" +dist="$here/dist" + +# npm-platform-key npm-os npm-cpu staging-subdir +platforms=( + "darwin-x64 darwin x64 darwin_amd64" + "darwin-arm64 darwin arm64 darwin_arm64" + "linux-x64 linux x64 linux_amd64" + "linux-arm64 linux arm64 linux_arm64" +) + +rm -rf "$dist" +mkdir -p "$dist" + +optional_deps="" + +for entry in "${platforms[@]}"; do + read -r key npm_os npm_cpu subdir <<<"$entry" + pkgname="@devr-tools/codeguard-${key}" + pkgdir="$dist/codeguard-${key}/package" + mkdir -p "$pkgdir/bin" + + src="$staging/$subdir/codeguard" + if [ ! -f "$src" ]; then + echo "missing staged binary: $src" >&2 + exit 1 + fi + cp "$src" "$pkgdir/bin/codeguard" + chmod +x "$pkgdir/bin/codeguard" + + cat >"$pkgdir/package.json" <"$maindir/package.json" < + +The staging layout matches packaging/extract-binaries.sh: + /_/codeguard +""" +import base64 +import hashlib +import os +import stat +import sys +import zipfile + +# PyPI project name (canonical, with hyphen) vs. the escaped form used in wheel +# filenames and dist-info/.data directory names (PEP 427: non-alphanumeric runs +# -> "_"). The installed executable stays `codeguard` (see BIN / TARGETS). +PROJECT = "devr-codeguard" +DIST = "devr_codeguard" +BIN = "codeguard" +SUMMARY = ( + "Repository checks across code quality, design boundaries, security, " + "CI/CD hygiene, and AI prompt governance." +) +HOMEPAGE = "https://github.com/devr-tools/codeguard" +DESCRIPTION = ( + "# codeguard\n\n" + "Repository checks across code quality, design boundaries, security, " + "CI/CD hygiene, and AI prompt governance.\n\n" + "```bash\n" + "pip install devr-codeguard\n" + "codeguard version\n" + "```\n\n" + "This wheel bundles the prebuilt `codeguard` binary for your platform and " + "installs it onto your PATH as `codeguard` — no build step and no Go " + "toolchain required. The PyPI project is `devr-codeguard`; the command is " + "`codeguard`.\n\n" + "Full documentation: https://github.com/devr-tools/codeguard\n" +) + +# staging-subdir -> (binary-name, [wheel platform tags]) +# Linux ships manylinux2014 + musllinux_1_1 (same static binary) so Alpine works. +TARGETS = { + "darwin_amd64": ("codeguard", ["macosx_10_9_x86_64"]), + "darwin_arm64": ("codeguard", ["macosx_11_0_arm64"]), + "linux_amd64": ("codeguard", ["manylinux2014_x86_64", "musllinux_1_1_x86_64"]), + "linux_arm64": ("codeguard", ["manylinux2014_aarch64", "musllinux_1_1_aarch64"]), +} + + +def record_hash(data: bytes) -> str: + digest = hashlib.sha256(data).digest() + b64 = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii") + return f"sha256={b64}" + + +def build_wheel(version: str, binary: bytes, binname: str, plat_tag: str, out_dir: str) -> str: + tag = f"py3-none-{plat_tag}" + dist_info = f"{DIST}-{version}.dist-info" + data_dir = f"{DIST}-{version}.data/scripts" + + metadata = ( + "Metadata-Version: 2.1\n" + f"Name: {PROJECT}\n" + f"Version: {version}\n" + f"Summary: {SUMMARY}\n" + f"Home-page: {HOMEPAGE}\n" + "License: Apache-2.0\n" + "Classifier: License :: OSI Approved :: Apache Software License\n" + "Requires-Python: >=3.7\n" + "Description-Content-Type: text/markdown\n" + "\n" + f"{DESCRIPTION}" + ) + wheel_meta = ( + "Wheel-Version: 1.0\n" + "Generator: codeguard build_wheels.py\n" + "Root-Is-Purelib: false\n" + f"Tag: {tag}\n" + ) + + script_path = f"{data_dir}/{binname}" + records = [] + files = [ + (script_path, binary, True), + (f"{dist_info}/METADATA", metadata.encode(), False), + (f"{dist_info}/WHEEL", wheel_meta.encode(), False), + ] + + os.makedirs(out_dir, exist_ok=True) + wheel_name = f"{DIST}-{version}-{tag}.whl" + wheel_path = os.path.join(out_dir, wheel_name) + + with zipfile.ZipFile(wheel_path, "w", zipfile.ZIP_DEFLATED) as zf: + for arcname, data, executable in files: + info = zipfile.ZipInfo(arcname) + # Store a full Unix mode (regular-file type bits + permissions) so + # pip installs the bundled binary with its executable bit intact. + mode = stat.S_IFREG | (0o755 if executable else 0o644) + info.external_attr = mode << 16 + info.compress_type = zipfile.ZIP_DEFLATED + zf.writestr(info, data) + records.append(f"{arcname},{record_hash(data)},{len(data)}") + + record_arc = f"{dist_info}/RECORD" + records.append(f"{record_arc},,") + zf.writestr(record_arc, "\n".join(records) + "\n") + + return wheel_path + + +def main() -> int: + if len(sys.argv) != 4: + print(__doc__) + return 2 + version, staging, out_dir = sys.argv[1], sys.argv[2], sys.argv[3] + + built = [] + for subdir, (binname, plat_tags) in TARGETS.items(): + src = os.path.join(staging, subdir, binname) + if not os.path.isfile(src): + print(f"missing staged binary: {src}", file=sys.stderr) + return 1 + with open(src, "rb") as fh: + binary = fh.read() + for plat_tag in plat_tags: + path = build_wheel(version, binary, binname, plat_tag, out_dir) + built.append(path) + print(f"built {os.path.basename(path)}") + + print(f"{len(built)} wheels written to {out_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())