Skip to content

docs: Update projects #4

docs: Update projects

docs: Update projects #4

# SPDX-FileCopyrightText: 2026 The RISE Project
# SPDX-License-Identifier: MIT
---
# This workflow is based on the `linux` job of
# https://github.com/pytogo/portforward/blob/v0.7.6/.github/workflows/release.yml
name: Build portforward wheels (riscv64)
on:
workflow_dispatch:
inputs:
version:
description: 'Version glob to (re)build; empty builds every version of docs/packages/portforward.yaml not released yet'
required: false
default: ''
pull_request:
branches: [main]
paths:
- '.github/workflows/build-portforward.yml'
- 'docs/packages/portforward.yaml'
push:
branches: [main]
paths:
- '.github/workflows/build-portforward.yml'
- 'docs/packages/portforward.yaml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
setup:
uses: $/.github/workflows/_setup.yml
with:
package: portforward
version: ${{ inputs.version }}
build_wheels:
needs: [setup]
if: needs.setup.outputs.versions != '[]'
name: Build portforward ${{ matrix.version }} ${{ matrix.python }}-manylinux_riscv64
runs-on: ubuntu-24.04-riscv
timeout-minutes: 180
strategy:
fail-fast: false
matrix:
version: ${{ fromJSON(needs.setup.outputs.versions) }}
python: ['cp312', 'cp313', 'cp314', 'cp314t']
env:
PORTFORWARD_VERSION: ${{ matrix.version }}
steps:
- name: Checkout portforward v${{ env.PORTFORWARD_VERSION }}
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: pytogo/portforward
ref: v${{ env.PORTFORWARD_VERSION }}
persist-credentials: false
- name: Determine target interpreter
id: interp
run: |
v="${{ matrix.python }}"
v="${v#cp}"
echo "value=python${v:0:1}.${v:1}" >> "$GITHUB_OUTPUT"
- name: Build wheel
uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
target: riscv64gc-unknown-linux-gnu
args: --release --out dist -i ${{ steps.interp.outputs.value }}
manylinux: '2_39'
before-script-linux: yum install -y perl-core openssl openssl-devel pkgconfig libatomic
- name: Verify the wheel ships the compiled extension
run: |
set -euo pipefail
python3 - dist/*.whl <<'EOF'
import sys, zipfile
names = zipfile.ZipFile(sys.argv[1]).namelist()
sos = [n for n in names if n.endswith(".so")]
assert any(n.startswith("portforward/_portforward.") for n in sos), sos
assert any(n.endswith(".dist-info/licenses/LICENSE") for n in names), names
print(sys.argv[1], "->", sos)
EOF
- name: Install Python
uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
python-version: ${{ steps.interp.outputs.value }}
activate-environment: true
enable-cache: false
# Upstream's tests/test_portforward.py needs a pytest-kind cluster; this
# drives the extension against a local TLS fake of the Kubernetes API instead.
- name: Stage the riscv64 tests
run: |
mkdir -p riscv64-tests
cat > riscv64-tests/test_riscv64_smoke.py <<'EOF'
import json
import socket
import ssl
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlsplit
import pytest
import portforward
NAMESPACE = "pftest"
POD = "test-pod"
SERVICE = "web-svc"
POD_OBJ = {
"apiVersion": "v1",
"kind": "Pod",
"metadata": {"name": POD, "namespace": NAMESPACE, "resourceVersion": "1", "labels": {"app": "web"}},
"spec": {"containers": [{"name": "web", "image": "web"}]},
"status": {"phase": "Running"},
}
SERVICE_OBJ = {
"apiVersion": "v1",
"kind": "Service",
"metadata": {"name": SERVICE, "namespace": NAMESPACE, "resourceVersion": "1"},
"spec": {"selector": {"app": "web"}, "ports": [{"port": 80}]},
}
NOT_FOUND = {"apiVersion": "v1", "kind": "Status", "metadata": {}, "status": "Failure",
"message": "not found", "reason": "NotFound", "code": 404}
class FakeApiServer(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def _send(self, code, body):
data = json.dumps(body).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
url = urlsplit(self.path)
query = parse_qs(url.query)
prefix = f"/api/v1/namespaces/{NAMESPACE}"
if url.path == f"{prefix}/pods/{POD}":
return self._send(200, POD_OBJ)
if url.path == f"{prefix}/services/{SERVICE}":
return self._send(200, SERVICE_OBJ)
if url.path == f"{prefix}/pods" and "watch" not in query:
selector = query.get("labelSelector", []) + query.get("fieldSelector", [])
if selector in (["app=web"], [f"metadata.name={POD}"]):
return self._send(200, {"apiVersion": "v1", "kind": "PodList",
"metadata": {"resourceVersion": "1"}, "items": [POD_OBJ]})
return self._send(404, NOT_FOUND)
def _openssl(*args, cwd):
subprocess.run(["openssl", *args], cwd=cwd, check=True, capture_output=True)
def _make_ca(tmp, name):
_openssl("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", f"/CN={name}",
"-keyout", f"{name}.key", "-out", f"{name}.crt", cwd=tmp)
return tmp / f"{name}.crt"
@pytest.fixture(autouse=True)
def no_proxy(monkeypatch):
for var in ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"):
monkeypatch.delenv(var, raising=False)
@pytest.fixture(scope="module")
def pki(tmp_path_factory):
tmp = tmp_path_factory.mktemp("pki")
ca = _make_ca(tmp, "ca")
other_ca = _make_ca(tmp, "other-ca")
_openssl("req", "-newkey", "rsa:2048", "-nodes", "-subj", "/CN=127.0.0.1",
"-keyout", "server.key", "-out", "server.csr", cwd=tmp)
(tmp / "ext.cnf").write_text(
"basicConstraints=CA:FALSE\nkeyUsage=digitalSignature,keyEncipherment\n"
"extendedKeyUsage=serverAuth\nsubjectAltName=IP:127.0.0.1\n"
)
_openssl("x509", "-req", "-in", "server.csr", "-CA", "ca.crt", "-CAkey", "ca.key",
"-CAcreateserial", "-days", "1", "-extfile", "ext.cnf", "-out", "server.crt", cwd=tmp)
return {"dir": tmp, "ca": ca, "other_ca": other_ca}
@pytest.fixture(scope="module")
def api_server(pki):
httpd = ThreadingHTTPServer(("127.0.0.1", 0), FakeApiServer)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain(pki["dir"] / "server.crt", pki["dir"] / "server.key")
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
thread.start()
yield f"https://127.0.0.1:{httpd.server_address[1]}"
httpd.shutdown()
def _kubeconfig(tmp_path, server, ca):
config = {
"apiVersion": "v1",
"kind": "Config",
"clusters": [{"name": "fake", "cluster": {"server": server, "certificate-authority": str(ca)}}],
"users": [{"name": "fake", "user": {"token": "fake-token"}}],
"contexts": [{"name": "fake", "context": {"cluster": "fake", "user": "fake", "namespace": NAMESPACE}}],
"current-context": "fake",
}
path = tmp_path / "kubeconfig"
path.write_text(json.dumps(config))
return str(path)
def _listening(port):
try:
socket.create_connection(("127.0.0.1", port), timeout=1).close()
return True
except OSError:
return False
def test_extension_is_compiled():
from portforward import _portforward
assert _portforward.__file__.endswith(".so"), _portforward.__file__
@pytest.mark.parametrize("target", [POD, SERVICE])
def test_forward_binds_and_stops(tmp_path, pki, api_server, target):
config = _kubeconfig(tmp_path, api_server, pki["ca"])
with portforward.forward(NAMESPACE, target, 0, 3000, config_path=config,
log_level=portforward.LogLevel.OFF) as forwarder:
assert forwarder._async_forwarder.actual_pod_name == POD
port = forwarder.from_port
assert port > 0
assert _listening(port)
for _ in range(50):
if not _listening(port):
break
time.sleep(0.1)
assert not _listening(port)
def test_unknown_target_raises(tmp_path, pki, api_server):
config = _kubeconfig(tmp_path, api_server, pki["ca"])
with pytest.raises(portforward.PortforwardError, match="NotFound"):
with portforward.forward(NAMESPACE, "missing", 0, 3000, config_path=config,
log_level=portforward.LogLevel.OFF):
pass
def test_untrusted_server_certificate_is_rejected(tmp_path, pki, api_server):
config = _kubeconfig(tmp_path, api_server, pki["other_ca"])
with pytest.raises(portforward.PortforwardError, match="(?i)certificate|UnknownIssuer"):
with portforward.forward(NAMESPACE, POD, 0, 3000, config_path=config,
log_level=portforward.LogLevel.OFF):
pass
EOF
- name: Test wheel
run: |
uv pip install --reinstall dist/*.whl
uv pip install pytest
python -m pytest -v riscv64-tests
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: portforward-${{ env.PORTFORWARD_VERSION }}-${{ matrix.python }}-manylinux_riscv64
path: dist/*.whl
if-no-files-found: error
publish:
name: Publish portforward ${{ matrix.version }}
needs: [setup, build_wheels]
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: portforward-${{ matrix.version }}-*-manylinux_riscv64