Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 229 additions & 0 deletions .github/workflows/build-python-active-directory.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
# SPDX-FileCopyrightText: 2026 The RISE Project
# SPDX-License-Identifier: MIT
---
name: Build python-active-directory wheels (riscv64)

on:
workflow_dispatch:
inputs:
version:
description: 'Version glob to (re)build; empty builds every version of docs/packages/python-active-directory.yaml not released yet'
required: false
default: ''
pull_request:
branches: [main]
paths:
- '.github/workflows/build-python-active-directory.yml'
- 'docs/packages/python-active-directory.yaml'
- 'patches/python-active-directory/**'
push:
branches: [main]
paths:
- '.github/workflows/build-python-active-directory.yml'
- 'docs/packages/python-active-directory.yaml'
- 'patches/python-active-directory/**'

concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true

permissions:
contents: read # to fetch code (actions/checkout)

env:
MANYLINUX_RISCV64_IMAGE: quay.io/pypa/manylinux_2_39_riscv64

jobs:
setup:
uses: $/.github/workflows/_setup.yml
with:
package: python-active-directory
version: ${{ inputs.version }}

build_wheels:
needs: [setup]
if: needs.setup.outputs.versions != '[]'
name: Build python-active-directory ${{ matrix.version }} ${{ matrix.python }}-manylinux_riscv64
runs-on: ubuntu-24.04-riscv
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
version: ${{ fromJSON(needs.setup.outputs.versions) }}
python:
- "cp312"
- "cp313"
- "cp314"
- "cp314t"

env:
PYTHON_ACTIVE_DIRECTORY_VERSION: ${{ matrix.version }}

steps:
- name: Checkout python-active-directory v${{ env.PYTHON_ACTIVE_DIRECTORY_VERSION }}
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: theatlantic/python-active-directory
ref: v${{ env.PYTHON_ACTIVE_DIRECTORY_VERSION }}
persist-credentials: false

- name: Checkout python-wheels
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
path: python-wheels
persist-credentials: false

- name: Apply patches
run: git apply python-wheels/patches/python-active-directory/${{ env.PYTHON_ACTIVE_DIRECTORY_VERSION }}/*.patch

- name: Stage the licence-collection script
run: |
cat > collect-licenses.sh <<'COLLECT_EOF'
#!/bin/bash
# SPDX-FileCopyrightText: 2026 The RISE Project
# SPDX-License-Identifier: MIT
#
# Stage, at the project root, the licence of every shared library
# auditwheel vendors out of the build image alongside the krb5.c
# extension (the MIT Kerberos stack). setuptools' default
# LICEN[CS]E* glob copies them into the wheel.
set -euo pipefail

project="${1:?usage: collect-licenses.sh <project-dir>}"

mapfile -t libs < <(
ldd /usr/lib64/libkrb5.so | tr ' ' '\n' | grep '^/'
readlink -f /usr/lib64/libkrb5.so
)
mapfile -t libs < <(printf '%s\n' "${libs[@]}" | sort -u)

mapfile -t pkgs < <(
rpm -qf --qf '%{NAME}\n' "${libs[@]}" 2>/dev/null |
grep -E '^[A-Za-z0-9._+-]+$' | sort -u |
grep -vE '^(glibc|libgcc|libstdc\+\+|gcc|zlib-ng-compat)$'
)

for pkg in "${pkgs[@]}"; do
rpm -q --qf '%{NAME}: %{LICENSE}\n' "$pkg"
mapfile -t files < <(rpm -q --licensefiles "$pkg" 2>/dev/null || true)

if [ -z "${files[0]:-}" ]; then
srpm=$(rpm -q --qf '%{SOURCERPM}\n' "$pkg")
mapfile -t files < <(
rpm -qa --qf '%{SOURCERPM} %{NAME}\n' |
awk -v s="$srpm" '$1 == s { print $2 }' |
xargs -r rpm -q --licensefiles 2>/dev/null | sort -u
)
fi

if [ -z "${files[0]:-}" ]; then
dnf -y --disablerepo=extras reinstall --setopt=tsflags= "$pkg" >/dev/null
mapfile -t files < <(rpm -qd "$pkg" | grep -iE '/(LICEN[CS]E|COPYING|NOTICE)')
fi

for f in "${files[@]}"; do
[ -f "$f" ] || continue
cp "$f" "$project/LICENSE.${pkg}.$(basename "$f")"
done
compgen -G "$project/LICENSE.$pkg.*" >/dev/null ||
{ echo "no licence file found for $pkg" >&2; exit 1; }
done

ls -1 "$project"/LICENSE.* | sed "s|$project/||"
COLLECT_EOF

- name: Build wheels
uses: pypa/cibuildwheel@1828c10ab37f080699c7b81cea34097c684a7074 # v4.2.0
with:
output-dir: wheelhouse/
only: ${{ matrix.python }}-manylinux_riscv64
env:
CIBW_MANYLINUX_RISCV64_IMAGE: ${{ env.MANYLINUX_RISCV64_IMAGE }}
# krb5-devel builds our own extension; openldap-devel/cyrus-sasl-devel
# are for python-ldap (install_requires, no riscv64 wheel anywhere -
# it builds from its sdist whenever pip resolves it, here and for
# every consumer on this registry).
CIBW_BEFORE_ALL_LINUX: >-
dnf -y install krb5-devel openldap-devel cyrus-sasl-devel &&
bash {project}/collect-licenses.sh {project}
CIBW_ENVIRONMENT: PIP_EXTRA_INDEX_URL=https://pypi.riseproject.dev/simple/
CIBW_TEST_REQUIRES: pytest pexpect
# tests/ is a package whose sibling lib/ source dir would shadow the
# installed wheel (gotcha 25).
CIBW_TEST_SOURCES: tests
# Default tests/test.conf.example disables readonly/intrusive AD
# tests (conf.require() skips them), so the suite needs no real
# Active Directory server - matches upstream's own tox.ini.
CIBW_TEST_COMMAND: python -m pytest -q -ra tests

- name: Check the wheel ships the extension and the licences
run: |
python3 - wheelhouse/*.whl <<'EOF'
import sys, zipfile
for whl in sys.argv[1:]:
names = zipfile.ZipFile(whl).namelist()
assert any(n.startswith("activedirectory/protocol/krb5") and n.endswith(".so") for n in names), (whl, names)
lic = {n.rsplit("/", 1)[1] for n in names if ".dist-info/licenses/" in n} - {""}
print(whl, sorted(lic))
assert "LICENSE" in lic, lic
pkgs = {f.split(".", 2)[1] for f in lic if f.startswith("LICENSE.") and f.count(".") >= 2}
assert "krb5-libs" in pkgs, pkgs
EOF

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: python-active-directory-${{ env.PYTHON_ACTIVE_DIRECTORY_VERSION }}-${{ matrix.python }}-manylinux_riscv64
path: wheelhouse/*.whl
if-no-files-found: error

gpl_sources:
needs: [setup]
if: needs.setup.outputs.versions != '[]'
strategy:
fail-fast: false
matrix:
version: ${{ fromJSON(needs.setup.outputs.versions) }}
name: Collect GPL sources for python-active-directory ${{ matrix.version }}
runs-on: ubuntu-24.04-riscv

env:
PYTHON_ACTIVE_DIRECTORY_VERSION: ${{ matrix.version }}

steps:
- name: Checkout python-wheels
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

# keyutils-libs is the one copyleft library auditwheel vendors out of
# the build image alongside libkrb5 (same krb5 stack as ssh-python).
- uses: ./actions/collect-gpl-sources
with:
image: ${{ env.MANYLINUX_RISCV64_IMAGE }}
packages: gcc keyutils-libs
output: gpl-sources.tar

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: python-active-directory-${{ env.PYTHON_ACTIVE_DIRECTORY_VERSION }}-gpl-sources
path: gpl-sources.tar
if-no-files-found: error

publish:
name: Publish python-active-directory ${{ matrix.version }}
needs: [setup, build_wheels, gpl_sources]
if: needs.setup.outputs.versions != '[]'
strategy:
fail-fast: false
matrix:
version: ${{ fromJSON(needs.setup.outputs.versions) }}
permissions:
contents: write
pull-requests: write
uses: $/.github/workflows/_publish-wheel.yml
secrets:
app-private-key: ${{ secrets.RISEPROJECT_APP_PRIVATE_KEY }}
with:
artifact-pattern: python-active-directory-${{ matrix.version }}-*-manylinux_riscv64
gpl-sources-artifact: python-active-directory-${{ matrix.version }}-gpl-sources
gpl-sources-description: gcc
5 changes: 5 additions & 0 deletions docs/packages/python-active-directory.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package-name: python-active-directory
source-code: https://github.com/theatlantic/python-active-directory
license: MIT
versions:
- version: 2.0.1
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Ludovic Henry <git@ludovic.dev>
Date: Thu, 22 Oct 2026 00:00:00 +0000
Subject: [PATCH] compat: drop distutils for Python 3.12+

activedirectory.util.compat imports distutils.version at module load
time, only to compare python-ldap's version against 2.4.0 with
StrictVersion. distutils was removed from the standard library in
Python 3.12 (PEP 632), so `import activedirectory` fails with
"ModuleNotFoundError: No module named 'distutils'" on every interpreter
this registry builds for (cp312, cp313, cp314, cp314t), unless
setuptools happens to be installed and injects its vendored copy.

Replace the four StrictVersion comparisons with one module-level
(major, minor) tuple comparison computed from ldap.__version__. The
behaviour is unchanged: install_requires already pins python-ldap>=3.0,
so the check is always true for a supported install.

Upstream-Status: To upstream [not yet submitted; upstream's tox.ini only covers py37/py38]

Signed-off-by: Ludovic Henry <git@ludovic.dev>
---
lib/activedirectory/util/compat.py | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)

diff --git a/lib/activedirectory/util/compat.py b/lib/activedirectory/util/compat.py
index eb6a68a..f4d3462 100644
--- a/lib/activedirectory/util/compat.py
+++ b/lib/activedirectory/util/compat.py
@@ -9,7 +9,9 @@
import ldap
import ldap.dn

-from distutils import version
+# distutils was removed from the standard library in Python 3.12 (PEP 632);
+# all it was used for here is comparing python-ldap's version against 2.4.
+_LDAP_2_4 = tuple(int(x) for x in ldap.__version__.split('.')[:2]) >= (2, 4)

# ldap.str2dn has been removed in python-ldap >= 2.3.6. We now need to use
# the version in ldap.dn.
@@ -22,7 +24,7 @@ def disable_reverse_dns():
# Possibly add in a Kerberos minimum version check as well...
return hasattr(ldap, 'OPT_X_SASL_NOCANON')

-if version.StrictVersion('2.4.0') <= version.StrictVersion(ldap.__version__):
+if _LDAP_2_4:
LDAP_CONTROL_PAGED_RESULTS = ldap.CONTROL_PAGEDRESULTS
else:
LDAP_CONTROL_PAGED_RESULTS = ldap.LDAP_CONTROL_PAGE_OID
@@ -36,7 +38,7 @@ class SimplePagedResultsControl(ldap.controls.SimplePagedResultsControl):
"""

def __init__(self, page_size=0, cookie=''):
- if version.StrictVersion('2.4.0') <= version.StrictVersion(ldap.__version__):
+ if _LDAP_2_4:
ldap.controls.SimplePagedResultsControl.__init__(
self,
size=page_size,
@@ -51,13 +53,13 @@ class SimplePagedResultsControl(ldap.controls.SimplePagedResultsControl):
)

def cookie(self):
- if version.StrictVersion('2.4.0') <= version.StrictVersion(ldap.__version__):
+ if _LDAP_2_4:
return self.cookie
else:
return self.controlValue[1]

def size(self):
- if version.StrictVersion('2.4.0') <= version.StrictVersion(ldap.__version__):
+ if _LDAP_2_4:
return self.size
else:
return self.controlValue[0]
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: Ludovic Henry <git@ludovic.dev>
Date: Fri, 23 Oct 2026 00:00:00 +0000
Subject: [PATCH] tests: netlogon: fix swapped site names in test_real_packet

netlogon.Decoder.parse() decodes DcSiteName before ClientSiteName, the
order of NETLOGON_SAM_LOGON_RESPONSE_EX in MS-ADTS 2.2.1.9; releases up
to 1.0.5 decoded them the other way round. The test still asserts the
old assignment, so it fails on every platform: tests/protocol/netlogon.bin
carries "Default-First-Site" as the DC site and "Test-Site" as the client
site.

Swap the two expected values to match the decoder and the packet.

Upstream-Status: To upstream [not yet submitted; upstream has no CI running the test suite]

Signed-off-by: Ludovic Henry <git@ludovic.dev>
---
tests/protocol/test_netlogon.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tests/protocol/test_netlogon.py b/tests/protocol/test_netlogon.py
index 711b5f6..519524b 100644
--- a/tests/protocol/test_netlogon.py
+++ b/tests/protocol/test_netlogon.py
@@ -191,8 +191,8 @@ class TestDecoder(object):
res = dec.parse()
assert res.forest == b'freeadi.org'
assert res.domain == b'freeadi.org'
- assert res.client_site == b'Default-First-Site'
- assert res.server_site == b'Test-Site'
+ assert res.server_site == b'Default-First-Site'
+ assert res.client_site == b'Test-Site'

def test_error_short_input(self):
buf = b'x' * 24
Loading