Initial public khodo snapshot
ci / checks (push) Successful in 38m58s

This commit is contained in:
khodowany
2026-09-04 19:30:11 -05:00
commit 4f9a269f85
99 changed files with 59585 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 7 ]; then
echo "usage: build_pico_firmware.sh BUILD_DIR OUTPUT_UF2 SDK_VERSION WIFI_SSID WIFI_PASSPHRASE ZERO_HOST ZERO_PORT" >&2
exit 2
fi
source_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
build_dir=$1
output_uf2=$2
sdk_version=$3
wifi_ssid=$4
wifi_passphrase=$5
zero_host=$6
zero_port=$7
pico_sdk_path=${VAMP_PICO_SDK_PATH:-${PICO_SDK_PATH:-}}
sdk_args=(
-DPICO_SDK_FETCH_FROM_GIT=ON
"-DPICO_SDK_FETCH_FROM_GIT_TAG=${sdk_version}"
)
if [ -n "$pico_sdk_path" ]; then
pico_sdk_path=$(cd "$pico_sdk_path" && pwd)
sdk_args=(
"-DPICO_SDK_PATH=${pico_sdk_path}"
-DPICO_SDK_FETCH_FROM_GIT=OFF
)
fi
mkdir -p "$(dirname "$output_uf2")"
unset CC CXX CPP AS AR LD RANLIB STRIP CFLAGS CXXFLAGS CPPFLAGS LDFLAGS
for tool in cmake ninja; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "missing required tool: $tool" >&2
exit 1
fi
done
if [ -z "${PICO_TOOLCHAIN_PATH:-}" ]; then
for tool in arm-none-eabi-gcc arm-none-eabi-g++; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "missing required tool: $tool, or set PICO_TOOLCHAIN_PATH" >&2
exit 1
fi
done
fi
cmake -E rm -f "$build_dir/CMakeCache.txt"
cmake -E rm -rf "$build_dir/CMakeFiles"
cmake \
-S "$source_dir" \
-B "$build_dir" \
-G Ninja \
-DVAMP_PICO_FIRMWARE_PROJECT=ON \
-DPICO_BOARD=pico2_w \
-DPICO_GCC_TRIPLE=arm-none-eabi \
"${sdk_args[@]}" \
"-DVAMP_PICO_WIFI_SSID=${wifi_ssid}" \
"-DVAMP_PICO_WIFI_PASSPHRASE=${wifi_passphrase}" \
"-DVAMP_ZERO_HOST=${zero_host}" \
"-DVAMP_ZERO_PORT=${zero_port}"
cmake --build "$build_dir" --target product-vamp-pico2w --parallel "${VAMP_BUILD_JOBS:-2}"
cmake -E copy "$build_dir/product-vamp-pico2w.uf2" "$output_uf2"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 3 ]; then
echo "usage: build_zero_image.sh CONTEXT_DIR OUTPUT_TAR IMAGE_TAG" >&2
exit 2
fi
context_dir=$1
output_tar=$2
image_tag=$3
mkdir -p "$(dirname "$output_tar")"
rm -f "$output_tar"
if docker buildx version >/dev/null 2>&1; then
docker buildx build \
--platform linux/arm64 \
--tag "$image_tag" \
--load \
"$context_dir"
else
docker build --platform linux/arm64 --tag "$image_tag" "$context_dir"
fi
docker run --rm --platform linux/arm64 "$image_tag" /usr/bin/vamp-zero --version
docker save "$image_tag" --output "$output_tar"
+218
View File
@@ -0,0 +1,218 @@
#!/usr/bin/env python3
import argparse
import base64
import json
import os
import pathlib
import sys
import urllib.error
import urllib.parse
import urllib.request
DEFAULT_REGISTRY_URL = "https://git.thyme-labs.com/api/packages/khodo/generic"
DEFAULT_VERSION = "latest"
PAGE_LIMIT = 50
def read_config(path):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise SystemExit(f"failed to read {path}: {error}") from error
package = data.get("package", {})
if not isinstance(package, dict):
raise SystemExit(f"{path}: package must be an object")
name = package.get("name")
if not isinstance(name, str) or not name:
raise SystemExit(f"{path}: package.name must be a non-empty string")
artifact = package.get("artifact", {})
if not isinstance(artifact, dict):
artifact = {}
registry_url = artifact.get("registry_url")
if not isinstance(registry_url, str) or not registry_url:
registry_url = DEFAULT_REGISTRY_URL
return name, registry_url
def parse_registry_url(registry_url):
parsed = urllib.parse.urlparse(registry_url.rstrip("/"))
parts = [urllib.parse.unquote(part) for part in parsed.path.split("/") if part]
if len(parts) < 4 or parts[-4] != "api" or parts[-3] != "packages" or parts[-1] != "generic":
raise SystemExit("package artifact registry_url must end with /api/packages/{owner}/generic")
owner = parts[-2]
prefix = "/" + "/".join(urllib.parse.quote(part, safe="") for part in parts[:-4]) if parts[:-4] else ""
return parsed, prefix, owner
def build_url(parsed, path, query=""):
return urllib.parse.urlunparse((
parsed.scheme,
parsed.netloc,
path,
"",
query,
"",
))
def quote_path(*parts):
return "/".join(urllib.parse.quote(part, safe="") for part in parts)
def version_list_url(parsed, prefix, owner, package_name, page):
path = f"{prefix}/api/v1/packages/{quote_path(owner, 'generic', package_name)}"
query = urllib.parse.urlencode({
"page": page,
"limit": PAGE_LIMIT,
})
return build_url(parsed, path, query)
def version_delete_url(parsed, prefix, owner, package_name, version):
path = f"{prefix}/api/packages/{quote_path(owner, 'generic', package_name, version)}"
return build_url(parsed, path)
def file_upload_url(registry_url, package_name, version, path):
return "/".join((
registry_url.rstrip("/"),
urllib.parse.quote(package_name, safe=""),
urllib.parse.quote(version, safe=""),
urllib.parse.quote(path.name, safe=""),
))
def authorization(user, token):
encoded = base64.b64encode(f"{user}:{token}".encode("utf-8")).decode("ascii")
return f"Basic {encoded}"
def request(method, url, auth, data=None, headers=None, missing_ok=False):
request_headers = {
"Authorization": auth,
**(headers or {}),
}
request = urllib.request.Request(url, data=data, method=method, headers=request_headers)
try:
return urllib.request.urlopen(request)
except urllib.error.HTTPError as error:
if missing_ok and error.code == 404:
return None
body = error.read().decode("utf-8", errors="replace")
message = f"{method} {url} failed with HTTP {error.code}"
if body:
message += f": {body}"
raise SystemExit(message) from error
except urllib.error.URLError as error:
raise SystemExit(f"{method} {url} failed: {error.reason}") from error
def list_versions(parsed, prefix, owner, package_name, auth):
versions = []
page = 1
while True:
response = request("GET", version_list_url(parsed, prefix, owner, package_name, page), auth, missing_ok=True)
if response is None:
return []
with response:
data = json.loads(response.read().decode("utf-8"))
if not isinstance(data, list):
raise SystemExit("Gitea package version list response was not an array")
if not data:
break
for item in data:
if isinstance(item, dict) and isinstance(item.get("version"), str):
versions.append(item["version"])
if len(data) < PAGE_LIMIT:
break
page += 1
return sorted(set(versions))
def delete_version(parsed, prefix, owner, package_name, version, auth):
url = version_delete_url(parsed, prefix, owner, package_name, version)
response = request("DELETE", url, auth, missing_ok=True)
if response is None:
print(f"Package version already absent: {package_name}/{version}")
return
with response:
if response.status != 204:
raise SystemExit(f"DELETE {url} returned HTTP {response.status}")
print(f"Deleted package version: {package_name}/{version}")
def discover_artifacts(dist_dir, package_name, version):
archives = sorted(dist_dir.glob(f"{package_name}-{version}-*.tar.gz"))
artifacts = []
for archive in archives:
artifacts.append(archive)
checksum = archive.with_name(archive.name + ".sha256")
if checksum.is_file():
artifacts.append(checksum)
if not artifacts:
raise SystemExit(f"no artifacts found matching {dist_dir}/{package_name}-{version}-*.tar.gz")
missing = [path for path in artifacts if not path.is_file()]
if missing:
raise SystemExit("missing artifact files: " + ", ".join(str(path) for path in missing))
return artifacts
def upload_file(registry_url, package_name, version, path, auth):
url = file_upload_url(registry_url, package_name, version, path)
headers = {
"Content-Length": str(path.stat().st_size),
"Content-Type": "application/octet-stream",
}
with path.open("rb") as input_file:
response = request("PUT", url, auth, data=input_file, headers=headers)
with response:
if response.status != 201:
raise SystemExit(f"PUT {url} returned HTTP {response.status}")
print(f"Uploaded package file: {package_name}/{version}/{path.name}")
def parse_args(argv):
parser = argparse.ArgumentParser(description="Replace Product Vamp's rolling Gitea generic package.")
parser.add_argument("--config", type=pathlib.Path, default=pathlib.Path("modulus.json"))
parser.add_argument("--dist-dir", type=pathlib.Path, default=pathlib.Path("build/packages"))
parser.add_argument("--version", default=DEFAULT_VERSION)
parser.add_argument("--user", default=None)
parser.add_argument("--token", default=None)
return parser.parse_args(argv)
def main(argv):
args = parse_args(argv)
package_name, registry_url = read_config(args.config)
user = args.user or os_environ("PACKAGE_REGISTRY_USER")
token = args.token or os_environ("PACKAGE_REGISTRY_TOKEN")
auth = authorization(user, token)
parsed, prefix, owner = parse_registry_url(registry_url)
artifacts = discover_artifacts(args.dist_dir, package_name, args.version)
existing_versions = list_versions(parsed, prefix, owner, package_name, auth)
# Gitea rejects duplicate file names within a generic package version, so
# replacing the rolling release requires deleting the old version first.
if args.version in existing_versions:
delete_version(parsed, prefix, owner, package_name, args.version, auth)
for artifact in artifacts:
upload_file(registry_url, package_name, args.version, artifact, auth)
for version in existing_versions:
if version != args.version:
delete_version(parsed, prefix, owner, package_name, version, auth)
return 0
def os_environ(name):
value = os.environ.get(name)
if not value:
raise SystemExit(f"{name} is required")
return value
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))