vnc-remote-control 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vnc_remote_control-0.1.0/.claude/skills/vnc-remote-control/SKILL.md +109 -0
- vnc_remote_control-0.1.0/.devcontainer/devcontainer.json +22 -0
- vnc_remote_control-0.1.0/.devcontainer/settings.json +6 -0
- vnc_remote_control-0.1.0/.github/actions/extract-metadata/action.yml +92 -0
- vnc_remote_control-0.1.0/.github/dependabot.yml +22 -0
- vnc_remote_control-0.1.0/.github/workflows/codeql.yml +39 -0
- vnc_remote_control-0.1.0/.github/workflows/default_cicd_public.yml +327 -0
- vnc_remote_control-0.1.0/.github/workflows/default_release_public.yml +69 -0
- vnc_remote_control-0.1.0/.gitignore +188 -0
- vnc_remote_control-0.1.0/.qlty/qlty.toml +2 -0
- vnc_remote_control-0.1.0/.snyk +5 -0
- vnc_remote_control-0.1.0/CHANGELOG.md +31 -0
- vnc_remote_control-0.1.0/CONFIG.md +512 -0
- vnc_remote_control-0.1.0/CONTRIBUTING.md +46 -0
- vnc_remote_control-0.1.0/DEVELOPMENT.md +104 -0
- vnc_remote_control-0.1.0/INSTALL.md +157 -0
- vnc_remote_control-0.1.0/LICENSE +22 -0
- vnc_remote_control-0.1.0/Makefile +266 -0
- vnc_remote_control-0.1.0/PKG-INFO +295 -0
- vnc_remote_control-0.1.0/README.md +240 -0
- vnc_remote_control-0.1.0/SECURITY.md +37 -0
- vnc_remote_control-0.1.0/ai-disclosure.md +58 -0
- vnc_remote_control-0.1.0/ai-stance.md +90 -0
- vnc_remote_control-0.1.0/codecov.yml +27 -0
- vnc_remote_control-0.1.0/docs/systemdesign/module_reference.md +108 -0
- vnc_remote_control-0.1.0/pyproject.toml +275 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/__init__.py +40 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/__init__conf__.py +90 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/__main__.py +9 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/__init__.py +15 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/__init__.py +66 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/__init__.py +44 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/config.py +335 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/email/__init__.py +16 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/email/_common.py +285 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/email/send_email.py +139 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/email/send_notification.py +98 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/info.py +74 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/logging.py +33 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/commands/vnc.py +217 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/constants.py +28 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/context.py +169 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/exit_codes.py +52 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/main.py +120 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/py.typed +0 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/root.py +200 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/cli/typed_click.py +42 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/__init__.py +25 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/defaultconfig.d/40-layered-config.toml +71 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/defaultconfig.d/50-mail.toml +222 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/defaultconfig.d/90-logging.toml +432 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/defaultconfig.toml +46 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/deploy.py +118 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/display.py +56 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/loader.py +188 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/overrides.py +189 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/permissions.py +185 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/config/py.typed +0 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/email/__init__.py +28 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/email/config.py +311 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/email/py.typed +0 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/email/sender.py +26 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/email/transport.py +274 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/email/validation.py +62 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/logging/__init__.py +13 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/logging/py.typed +0 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/logging/setup.py +132 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/memory/__init__.py +50 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/memory/config.py +62 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/memory/email.py +179 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/memory/logging.py +15 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/ocr.py +178 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/py.typed +0 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/adapters/rfb.py +329 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/application/__init__.py +32 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/application/ports.py +115 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/application/py.typed +0 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/composition/__init__.py +128 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/composition/py.typed +0 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/domain/__init__.py +32 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/domain/behaviors.py +28 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/domain/enums.py +55 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/domain/errors.py +56 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/domain/keymap.py +69 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/domain/py.typed +0 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/entry.py +26 -0
- vnc_remote_control-0.1.0/src/vnc_remote_control/py.typed +0 -0
- vnc_remote_control-0.1.0/tests/conftest.py +644 -0
- vnc_remote_control-0.1.0/tests/test_behaviors.py +13 -0
- vnc_remote_control-0.1.0/tests/test_cache_effectiveness.py +155 -0
- vnc_remote_control-0.1.0/tests/test_cli_config.py +802 -0
- vnc_remote_control-0.1.0/tests/test_cli_core.py +222 -0
- vnc_remote_control-0.1.0/tests/test_cli_email.py +781 -0
- vnc_remote_control-0.1.0/tests/test_cli_env_file.py +177 -0
- vnc_remote_control-0.1.0/tests/test_cli_exit_codes.py +158 -0
- vnc_remote_control-0.1.0/tests/test_cli_overrides.py +164 -0
- vnc_remote_control-0.1.0/tests/test_cli_validation.py +132 -0
- vnc_remote_control-0.1.0/tests/test_cli_vnc.py +215 -0
- vnc_remote_control-0.1.0/tests/test_config_overrides.py +327 -0
- vnc_remote_control-0.1.0/tests/test_deploy_permissions.py +520 -0
- vnc_remote_control-0.1.0/tests/test_display.py +121 -0
- vnc_remote_control-0.1.0/tests/test_drawing.py +97 -0
- vnc_remote_control-0.1.0/tests/test_enums.py +78 -0
- vnc_remote_control-0.1.0/tests/test_errors.py +39 -0
- vnc_remote_control-0.1.0/tests/test_keymap.py +31 -0
- vnc_remote_control-0.1.0/tests/test_logging.py +31 -0
- vnc_remote_control-0.1.0/tests/test_mail.py +1228 -0
- vnc_remote_control-0.1.0/tests/test_metadata.py +84 -0
- vnc_remote_control-0.1.0/tests/test_metadata_sync.py +80 -0
- vnc_remote_control-0.1.0/tests/test_module_entry.py +179 -0
- vnc_remote_control-0.1.0/tests/test_ocr.py +104 -0
- vnc_remote_control-0.1.0/tests/test_ports.py +188 -0
- vnc_remote_control-0.1.0/tests/test_property_email.py +284 -0
- vnc_remote_control-0.1.0/tests/test_property_overrides.py +165 -0
- vnc_remote_control-0.1.0/tests/test_rfb.py +312 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: vnc-remote-control
|
|
3
|
+
description: Use when you need to observe and control a machine over VNC/RFB - take a screenshot, read the screen (including OCR), click a pixel or a labeled UI element, type text, or press keys - using the vnc-remote-control CLI. Triggers on driving or automating a guest GUI through VNC (a VM console, a boot screen, a login prompt, a dialog).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Driving a machine over VNC with vnc-remote-control
|
|
7
|
+
|
|
8
|
+
`vnc-remote-control` is a small CLI that drives a VNC/RFB server: screenshot,
|
|
9
|
+
click, type, press keys, and OCR the screen. This skill explains how to use it
|
|
10
|
+
to reliably control a guest, including how to click the right pixel every time.
|
|
11
|
+
|
|
12
|
+
## Prerequisites
|
|
13
|
+
|
|
14
|
+
- The `vnc-remote-control` command on PATH (`pip install vnc-remote-control` or
|
|
15
|
+
`uv tool install vnc-remote-control`).
|
|
16
|
+
- `tesseract` installed (required for `ocr` and `click-text`):
|
|
17
|
+
`apt-get install tesseract-ocr`, `brew install tesseract`, or
|
|
18
|
+
`choco install tesseract`.
|
|
19
|
+
- The VNC server's host and port. Every command takes `--port`; `--host`
|
|
20
|
+
defaults to `127.0.0.1`. Examples below use `--port 5901`.
|
|
21
|
+
|
|
22
|
+
## The one rule that makes clicking reliable
|
|
23
|
+
|
|
24
|
+
RFB pointer coordinates are absolute framebuffer pixels, and a screenshot is
|
|
25
|
+
taken at the native framebuffer resolution. So a coordinate you read off the
|
|
26
|
+
screenshot is the exact coordinate to click. There is no scale factor.
|
|
27
|
+
|
|
28
|
+
The usual cause of "I clicked the wrong place" is reading coordinates off a
|
|
29
|
+
scaled image. Do not let your image viewer downscale the screenshot, and do not
|
|
30
|
+
guess at coordinates from a resized view.
|
|
31
|
+
|
|
32
|
+
## The control loop
|
|
33
|
+
|
|
34
|
+
1. Capture and look. Run `screenshot out.png`. It prints `resolution: WxH` on
|
|
35
|
+
stdout. View `out.png` at native size.
|
|
36
|
+
2. Decide where to act. Read the (x, y) directly off the screenshot, or use OCR
|
|
37
|
+
to locate UI text (see below).
|
|
38
|
+
3. Verify a coordinate before committing (optional but cheap). Run
|
|
39
|
+
`screenshot out.png --mark X,Y` and confirm the crosshair sits on the target.
|
|
40
|
+
`--grid 50` overlays a coordinate grid if you want a ruler.
|
|
41
|
+
4. Act: `click X Y`, or click by label with `click-text`. Before you `type`,
|
|
42
|
+
click into the target text field: a window you just opened or launched
|
|
43
|
+
usually does NOT have keyboard focus yet, so typing goes nowhere. Click the
|
|
44
|
+
field first, then `type` / `key`.
|
|
45
|
+
5. Confirm. Take a fresh screenshot and check the result before the next step.
|
|
46
|
+
Always re-screenshot; never assume an action landed.
|
|
47
|
+
|
|
48
|
+
## Commands
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# see the screen (prints "resolution: WxH")
|
|
52
|
+
vnc-remote-control --port 5901 screenshot /tmp/s.png
|
|
53
|
+
|
|
54
|
+
# verify a candidate click point with a crosshair
|
|
55
|
+
vnc-remote-control --port 5901 screenshot /tmp/s.png --mark 640,480
|
|
56
|
+
|
|
57
|
+
# read the screen: each line is "<cx> <cy> <w>x<h> conf=<n> <text>"
|
|
58
|
+
# cx,cy is the center, i.e. the point to click
|
|
59
|
+
vnc-remote-control --port 5901 ocr
|
|
60
|
+
vnc-remote-control --port 5901 ocr --grep "Sign in"
|
|
61
|
+
|
|
62
|
+
# click: by pixel, or (more reliable) by on-screen label
|
|
63
|
+
vnc-remote-control --port 5901 click 640 480
|
|
64
|
+
vnc-remote-control --port 5901 click-text "Next"
|
|
65
|
+
|
|
66
|
+
# type text (literal keysyms; the server maps the layout) and press keys
|
|
67
|
+
vnc-remote-control --port 5901 type "chkdsk c: /f" --enter
|
|
68
|
+
vnc-remote-control --port 5901 type "user@host"
|
|
69
|
+
vnc-remote-control --port 5901 key enter
|
|
70
|
+
vnc-remote-control --port 5901 key esc
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Clicking: prefer labels over pixels
|
|
74
|
+
|
|
75
|
+
When the target has visible text, `click-text "Label"` (or `ocr --grep` then
|
|
76
|
+
`click <cx> <cy>`) is more reliable than reading a pixel, because it clicks the
|
|
77
|
+
center of the recognized word and does not depend on your coordinate estimate.
|
|
78
|
+
Fall back to pixel clicks for icons and controls with no text, and use
|
|
79
|
+
`--mark X,Y` to confirm the pixel first.
|
|
80
|
+
|
|
81
|
+
## Typing and keyboard layout
|
|
82
|
+
|
|
83
|
+
`type` sends literal keysyms: each character's keysym is its code point, exactly
|
|
84
|
+
like a standard VNC client. It does no client-side translation. The guest
|
|
85
|
+
keyboard layout is a server setting: openvmm is layout-aware (its
|
|
86
|
+
`--vnc-keyboard-layout` flag, which on Proxmox the ovm shim sets from the VM's
|
|
87
|
+
`keyboard:` key) and maps keysyms into the guest layout. When the server layout
|
|
88
|
+
matches the guest, every character types correctly, including symbols.
|
|
89
|
+
|
|
90
|
+
A text field must have keyboard focus before you `type` (see the focus tip
|
|
91
|
+
below).
|
|
92
|
+
|
|
93
|
+
## Focus before typing
|
|
94
|
+
|
|
95
|
+
This is the most common typing failure. After you open or launch a window
|
|
96
|
+
(taskbar search, the Run box, an application), it often does NOT have keyboard
|
|
97
|
+
focus, so `type` goes nowhere. Concrete lesson: a freshly-opened editor showed
|
|
98
|
+
nothing typed until it was clicked to focus. Always click into the actual text
|
|
99
|
+
field first, then `type`. If text seems to vanish, re-screenshot, click the
|
|
100
|
+
field, and type again.
|
|
101
|
+
|
|
102
|
+
## Practical tips
|
|
103
|
+
|
|
104
|
+
- Focus before typing: click the target field, then `type` (see above).
|
|
105
|
+
- One observable step at a time: act, then screenshot to confirm.
|
|
106
|
+
- Boot screens and dialogs: screenshot first to see the current state (spinner,
|
|
107
|
+
login, BSOD, dialog) before deciding what to send.
|
|
108
|
+
- If OCR misreads small text, take the screenshot and read the coordinates
|
|
109
|
+
yourself off the native image.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"image": "mcr.microsoft.com/devcontainers/python:3.13",
|
|
3
|
+
|
|
4
|
+
"postCreateCommand": "bash -lc '\nPY=/usr/local/bin/python;\n$PY -m pip install -U pip && \\\n$PY -m pip install ipykernel && \\\n$PY -m pip install -e . && \\\n# register kernel using this exact interpreter\n$PY -m ipykernel install --name=python3 --display-name=\"Python 3 (3.13)\" --user && \\\n# patch the notebook to point to the python3 kernelspec (VS Code will bind to /usr/local/bin/python)\n$PY - <<\"PY\"\nimport json, pathlib\np = pathlib.Path(\"notebooks/Quickstart.ipynb\")\nif p.exists():\n nb = json.loads(p.read_text(encoding=\"utf-8\"))\n md = nb.setdefault(\"metadata\", {})\n md[\"kernelspec\"] = {\n \"name\": \"python3\",\n \"display_name\": \"Python 3 (3.13)\",\n \"language\": \"python\"\n }\n md[\"language_info\"] = {\"name\": \"python\"}\n p.write_text(json.dumps(nb, ensure_ascii=False, indent=1), encoding=\"utf-8\")\n print(\"Pinned kernelspec to python3 in\", p)\nelse:\n print(\"Notebook not found:\", p)\nPY'\n",
|
|
5
|
+
|
|
6
|
+
"customizations": {
|
|
7
|
+
"vscode": {
|
|
8
|
+
"extensions": ["ms-toolsai.jupyter", "ms-python.python"],
|
|
9
|
+
"settings": {
|
|
10
|
+
"workbench.startupEditor": "none",
|
|
11
|
+
"jupyter.alwaysTrustNotebooks": true,
|
|
12
|
+
"jupyter.kernelPickerType": "OnlyRecommended",
|
|
13
|
+
"python.defaultInterpreterPath": "/usr/local/bin/python"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"codespaces": {
|
|
17
|
+
"openFiles": ["README.md"]
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
"postAttachCommand": "bash -lc 'if [ -f notebooks/Quickstart.ipynb ]; then code -r notebooks/Quickstart.ipynb; fi'"
|
|
22
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
name: Extract project metadata
|
|
2
|
+
description: >-
|
|
3
|
+
Parse pyproject.toml and expose project metadata as step outputs.
|
|
4
|
+
Requires Python to be available in the runner environment.
|
|
5
|
+
Uses the standard-library tomllib (with a tomli fallback on Python 3.10).
|
|
6
|
+
|
|
7
|
+
outputs:
|
|
8
|
+
PACKAGE_MODULE:
|
|
9
|
+
description: Python-importable module name (underscored)
|
|
10
|
+
value: ${{ steps.meta.outputs.PACKAGE_MODULE }}
|
|
11
|
+
PROJECT_NAME:
|
|
12
|
+
description: Project name from pyproject.toml
|
|
13
|
+
value: ${{ steps.meta.outputs.PROJECT_NAME }}
|
|
14
|
+
CLI_BIN:
|
|
15
|
+
description: CLI entry-point binary name
|
|
16
|
+
value: ${{ steps.meta.outputs.CLI_BIN }}
|
|
17
|
+
SRC_PATH:
|
|
18
|
+
description: Source directory path (e.g. "src")
|
|
19
|
+
value: ${{ steps.meta.outputs.SRC_PATH }}
|
|
20
|
+
COV_FAIL_UNDER:
|
|
21
|
+
description: Minimum coverage percentage from tool.coverage.report.fail_under
|
|
22
|
+
value: ${{ steps.meta.outputs.COV_FAIL_UNDER }}
|
|
23
|
+
COV_REPORT_FILE:
|
|
24
|
+
description: Coverage report filename (e.g. "coverage.xml")
|
|
25
|
+
value: ${{ steps.meta.outputs.COV_REPORT_FILE }}
|
|
26
|
+
PYTEST_VERBOSITY:
|
|
27
|
+
description: Pytest verbosity flag (e.g. "-vv")
|
|
28
|
+
value: ${{ steps.meta.outputs.PYTEST_VERBOSITY }}
|
|
29
|
+
BANDIT_SKIP_FLAG:
|
|
30
|
+
description: Bandit skip flag string (e.g. "-s B101,B601") or empty
|
|
31
|
+
value: ${{ steps.meta.outputs.BANDIT_SKIP_FLAG }}
|
|
32
|
+
|
|
33
|
+
runs:
|
|
34
|
+
using: composite
|
|
35
|
+
steps:
|
|
36
|
+
- name: Extract project metadata from pyproject.toml
|
|
37
|
+
id: meta
|
|
38
|
+
shell: python
|
|
39
|
+
run: |
|
|
40
|
+
import os, subprocess, sys
|
|
41
|
+
try:
|
|
42
|
+
import tomllib
|
|
43
|
+
except ModuleNotFoundError:
|
|
44
|
+
subprocess.check_call([sys.executable, "-m", "pip", "install", "tomli", "-q"])
|
|
45
|
+
import tomli as tomllib
|
|
46
|
+
|
|
47
|
+
with open('pyproject.toml', 'rb') as fh:
|
|
48
|
+
data = tomllib.load(fh)
|
|
49
|
+
|
|
50
|
+
# --- [project] section ---
|
|
51
|
+
project = data['project']['name']
|
|
52
|
+
module = project.replace('-', '_')
|
|
53
|
+
dash = project.replace('_', '-')
|
|
54
|
+
scripts = list(data['project'].get('scripts', {}).keys())
|
|
55
|
+
cli_bin = scripts[0] if scripts else dash
|
|
56
|
+
|
|
57
|
+
# --- [tool.scripts.test] section ---
|
|
58
|
+
scripts_test = data.get('tool', {}).get('scripts', {}).get('test', {})
|
|
59
|
+
src_path = scripts_test.get('src-path', 'src')
|
|
60
|
+
pytest_verbosity = scripts_test.get('pytest-verbosity', '-vv')
|
|
61
|
+
cov_report_file = scripts_test.get('coverage-report-file', 'coverage.xml')
|
|
62
|
+
|
|
63
|
+
# --- [tool.coverage.report] section ---
|
|
64
|
+
cov_fail_under = (
|
|
65
|
+
data.get('tool', {}).get('coverage', {}).get('report', {}).get('fail_under', 90)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# --- [tool.bandit] section ---
|
|
69
|
+
bandit_skips = data.get('tool', {}).get('bandit', {}).get('skips', [])
|
|
70
|
+
bandit_skip_flag = f"-s {','.join(bandit_skips)}" if bandit_skips else ""
|
|
71
|
+
|
|
72
|
+
# Write to GITHUB_OUTPUT for composite action outputs
|
|
73
|
+
with open(os.environ['GITHUB_OUTPUT'], 'a', encoding='utf-8') as out:
|
|
74
|
+
out.write(f"PROJECT_NAME={project}\n")
|
|
75
|
+
out.write(f"PACKAGE_MODULE={module}\n")
|
|
76
|
+
out.write(f"CLI_BIN={cli_bin}\n")
|
|
77
|
+
out.write(f"SRC_PATH={src_path}\n")
|
|
78
|
+
out.write(f"PYTEST_VERBOSITY={pytest_verbosity}\n")
|
|
79
|
+
out.write(f"COV_REPORT_FILE={cov_report_file}\n")
|
|
80
|
+
out.write(f"COV_FAIL_UNDER={cov_fail_under}\n")
|
|
81
|
+
out.write(f"BANDIT_SKIP_FLAG={bandit_skip_flag}\n")
|
|
82
|
+
|
|
83
|
+
# Also write to GITHUB_ENV so subsequent steps can use ${{ env.VAR }}
|
|
84
|
+
with open(os.environ['GITHUB_ENV'], 'a', encoding='utf-8') as env:
|
|
85
|
+
env.write(f"PROJECT_NAME={project}\n")
|
|
86
|
+
env.write(f"PACKAGE_MODULE={module}\n")
|
|
87
|
+
env.write(f"CLI_BIN={cli_bin}\n")
|
|
88
|
+
env.write(f"SRC_PATH={src_path}\n")
|
|
89
|
+
env.write(f"PYTEST_VERBOSITY={pytest_verbosity}\n")
|
|
90
|
+
env.write(f"COV_REPORT_FILE={cov_report_file}\n")
|
|
91
|
+
env.write(f"COV_FAIL_UNDER={cov_fail_under}\n")
|
|
92
|
+
env.write(f"BANDIT_SKIP_FLAG={bandit_skip_flag}\n")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
version: 2
|
|
2
|
+
updates:
|
|
3
|
+
- package-ecosystem: "pip"
|
|
4
|
+
directory: "/"
|
|
5
|
+
schedule:
|
|
6
|
+
interval: "weekly"
|
|
7
|
+
allow:
|
|
8
|
+
- dependency-type: "direct"
|
|
9
|
+
labels:
|
|
10
|
+
- "dependencies"
|
|
11
|
+
commit-message:
|
|
12
|
+
prefix: "deps"
|
|
13
|
+
include: "scope"
|
|
14
|
+
- package-ecosystem: "github-actions"
|
|
15
|
+
directory: "/"
|
|
16
|
+
schedule:
|
|
17
|
+
interval: "weekly"
|
|
18
|
+
labels:
|
|
19
|
+
- "dependencies"
|
|
20
|
+
commit-message:
|
|
21
|
+
prefix: "deps"
|
|
22
|
+
include: "scope"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
name: CodeQL
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main, master ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main, master ]
|
|
8
|
+
schedule:
|
|
9
|
+
- cron: '0 8 * * 1'
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
analyze:
|
|
13
|
+
name: CodeQL Analyze
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
permissions:
|
|
16
|
+
actions: read
|
|
17
|
+
contents: read
|
|
18
|
+
security-events: write
|
|
19
|
+
strategy:
|
|
20
|
+
fail-fast: false
|
|
21
|
+
matrix:
|
|
22
|
+
language: [ 'python' ]
|
|
23
|
+
|
|
24
|
+
steps:
|
|
25
|
+
- name: Checkout repository
|
|
26
|
+
uses: actions/checkout@v7
|
|
27
|
+
|
|
28
|
+
- name: Initialize CodeQL
|
|
29
|
+
uses: github/codeql-action/init@v4
|
|
30
|
+
with:
|
|
31
|
+
languages: ${{ matrix.language }}
|
|
32
|
+
|
|
33
|
+
- name: Autobuild
|
|
34
|
+
uses: github/codeql-action/autobuild@v4
|
|
35
|
+
|
|
36
|
+
- name: Perform CodeQL Analysis
|
|
37
|
+
uses: github/codeql-action/analyze@v4
|
|
38
|
+
with:
|
|
39
|
+
category: "/language:${{ matrix.language }}"
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [ main, master ]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: [ main, master ]
|
|
8
|
+
schedule:
|
|
9
|
+
# Run daily at 3:17 AM UTC (off-peak hours, odd minute to avoid congestion)
|
|
10
|
+
- cron: '17 3 * * *'
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
setup:
|
|
14
|
+
name: Extract Python versions and OS matrix from pyproject.toml
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
outputs:
|
|
17
|
+
python-versions: ${{ steps.metadata.outputs.python-versions }}
|
|
18
|
+
python-latest: ${{ steps.metadata.outputs.python-latest }}
|
|
19
|
+
os-matrix: ${{ steps.metadata.outputs.os-matrix }}
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
|
+
- uses: actions/setup-python@v6
|
|
23
|
+
with:
|
|
24
|
+
python-version: "3.x"
|
|
25
|
+
- name: Parse pyproject.toml for Python versions and OS matrix
|
|
26
|
+
id: metadata
|
|
27
|
+
shell: python
|
|
28
|
+
run: |
|
|
29
|
+
import json, os, tomllib
|
|
30
|
+
|
|
31
|
+
with open("pyproject.toml", "rb") as f:
|
|
32
|
+
data = tomllib.load(f)
|
|
33
|
+
|
|
34
|
+
# Python versions from classifiers
|
|
35
|
+
classifiers = data.get("project", {}).get("classifiers", [])
|
|
36
|
+
prefix = "Programming Language :: Python :: "
|
|
37
|
+
versions = [
|
|
38
|
+
c[len(prefix):]
|
|
39
|
+
for c in classifiers
|
|
40
|
+
if c.startswith(prefix) and "." in c[len(prefix):]
|
|
41
|
+
]
|
|
42
|
+
if not versions:
|
|
43
|
+
raise SystemExit("No Python X.Y versions found in classifiers")
|
|
44
|
+
|
|
45
|
+
# OS matrix from [tool.ci]
|
|
46
|
+
ci_config = data.get("tool", {}).get("ci", {})
|
|
47
|
+
os_list = ci_config.get("os", ["ubuntu-latest"])
|
|
48
|
+
|
|
49
|
+
with open(os.environ["GITHUB_OUTPUT"], "a") as fh:
|
|
50
|
+
fh.write(f"python-versions={json.dumps(versions)}\n")
|
|
51
|
+
fh.write(f"python-latest={versions[-1]}\n")
|
|
52
|
+
fh.write(f"os-matrix={json.dumps(os_list)}\n")
|
|
53
|
+
|
|
54
|
+
test:
|
|
55
|
+
name: Tests (Python ${{ matrix.python }}, ${{ matrix.os }})
|
|
56
|
+
needs: setup
|
|
57
|
+
runs-on: ${{ matrix.os }}
|
|
58
|
+
strategy:
|
|
59
|
+
fail-fast: false
|
|
60
|
+
matrix:
|
|
61
|
+
os: ${{ fromJSON(needs.setup.outputs.os-matrix) }}
|
|
62
|
+
python: ${{ fromJSON(needs.setup.outputs.python-versions) }}
|
|
63
|
+
env:
|
|
64
|
+
PYO3_USE_ABI3_FORWARD_COMPATIBILITY: "1"
|
|
65
|
+
PYTHONIOENCODING: "utf-8"
|
|
66
|
+
UV_LINK_MODE: "copy"
|
|
67
|
+
UV_BREAK_SYSTEM_PACKAGES: "1"
|
|
68
|
+
steps:
|
|
69
|
+
- uses: actions/checkout@v7
|
|
70
|
+
- uses: actions/setup-python@v6
|
|
71
|
+
with:
|
|
72
|
+
python-version: ${{ matrix.python }}
|
|
73
|
+
- name: Install uv
|
|
74
|
+
uses: astral-sh/setup-uv@v7
|
|
75
|
+
with:
|
|
76
|
+
enable-cache: true
|
|
77
|
+
prune-cache: false
|
|
78
|
+
|
|
79
|
+
- name: Install bash 4+ on macOS
|
|
80
|
+
if: runner.os == 'macOS'
|
|
81
|
+
run: |
|
|
82
|
+
brew install bash
|
|
83
|
+
echo "$(brew --prefix)/bin" >> "$GITHUB_PATH"
|
|
84
|
+
|
|
85
|
+
- name: Extract project metadata
|
|
86
|
+
id: metadata
|
|
87
|
+
uses: ./.github/actions/extract-metadata
|
|
88
|
+
|
|
89
|
+
- name: Install CI system dependencies (Ubuntu)
|
|
90
|
+
if: runner.os == 'Linux'
|
|
91
|
+
shell: python
|
|
92
|
+
run: |
|
|
93
|
+
import subprocess
|
|
94
|
+
try:
|
|
95
|
+
import tomllib
|
|
96
|
+
except ModuleNotFoundError:
|
|
97
|
+
import pip._vendor.tomli as tomllib
|
|
98
|
+
with open("pyproject.toml", "rb") as f:
|
|
99
|
+
data = tomllib.load(f)
|
|
100
|
+
deps = data.get("tool", {}).get("ci", {}).get("system-dependencies-ubuntu", [])
|
|
101
|
+
if deps:
|
|
102
|
+
print(f"Installing system packages: {' '.join(deps)}")
|
|
103
|
+
subprocess.check_call(["sudo", "apt-get", "update"])
|
|
104
|
+
subprocess.check_call(["sudo", "apt-get", "install", "-y", *deps])
|
|
105
|
+
else:
|
|
106
|
+
print("No CI system dependencies configured for Ubuntu.")
|
|
107
|
+
|
|
108
|
+
- name: Install CI system dependencies (macOS)
|
|
109
|
+
if: runner.os == 'macOS'
|
|
110
|
+
shell: python
|
|
111
|
+
run: |
|
|
112
|
+
import subprocess
|
|
113
|
+
try:
|
|
114
|
+
import tomllib
|
|
115
|
+
except ModuleNotFoundError:
|
|
116
|
+
import pip._vendor.tomli as tomllib
|
|
117
|
+
with open("pyproject.toml", "rb") as f:
|
|
118
|
+
data = tomllib.load(f)
|
|
119
|
+
deps = data.get("tool", {}).get("ci", {}).get("system-dependencies-macos", [])
|
|
120
|
+
if deps:
|
|
121
|
+
print(f"Installing system packages: {' '.join(deps)}")
|
|
122
|
+
subprocess.check_call(["brew", "install", *deps])
|
|
123
|
+
else:
|
|
124
|
+
print("No CI system dependencies configured for macOS.")
|
|
125
|
+
|
|
126
|
+
- name: Install dev deps
|
|
127
|
+
shell: bash
|
|
128
|
+
run: |
|
|
129
|
+
uv pip install -e .[dev] --system
|
|
130
|
+
|
|
131
|
+
- name: Install CI system dependencies (Windows/choco)
|
|
132
|
+
if: runner.os == 'Windows'
|
|
133
|
+
shell: python
|
|
134
|
+
run: |
|
|
135
|
+
import subprocess
|
|
136
|
+
try:
|
|
137
|
+
import tomllib
|
|
138
|
+
except ModuleNotFoundError:
|
|
139
|
+
import pip._vendor.tomli as tomllib
|
|
140
|
+
with open("pyproject.toml", "rb") as f:
|
|
141
|
+
data = tomllib.load(f)
|
|
142
|
+
deps = data.get("tool", {}).get("ci", {}).get("system-dependencies-windows-choco", [])
|
|
143
|
+
if deps:
|
|
144
|
+
print(f"Installing choco packages: {' '.join(deps)}")
|
|
145
|
+
subprocess.check_call(["choco", "install", "-y", *deps])
|
|
146
|
+
else:
|
|
147
|
+
print("No CI choco dependencies configured for Windows.")
|
|
148
|
+
|
|
149
|
+
- name: Install CI pip dependencies (Windows)
|
|
150
|
+
if: runner.os == 'Windows'
|
|
151
|
+
shell: python
|
|
152
|
+
run: |
|
|
153
|
+
import subprocess
|
|
154
|
+
try:
|
|
155
|
+
import tomllib
|
|
156
|
+
except ModuleNotFoundError:
|
|
157
|
+
import pip._vendor.tomli as tomllib
|
|
158
|
+
with open("pyproject.toml", "rb") as f:
|
|
159
|
+
data = tomllib.load(f)
|
|
160
|
+
deps = data.get("tool", {}).get("ci", {}).get("system-dependencies-windows-pip", [])
|
|
161
|
+
if deps:
|
|
162
|
+
print(f"Installing pip packages: {' '.join(deps)}")
|
|
163
|
+
subprocess.check_call(["uv", "pip", "install", *deps, "--system"])
|
|
164
|
+
else:
|
|
165
|
+
print("No CI pip dependencies configured for Windows.")
|
|
166
|
+
|
|
167
|
+
# Self-hosted runners persist ruff cache via RUFF_CACHE_DIR volume mount,
|
|
168
|
+
# so actions/cache is only needed on GitHub-hosted runners.
|
|
169
|
+
- name: Restore ruff cache
|
|
170
|
+
if: runner.environment == 'github-hosted'
|
|
171
|
+
id: ruff-cache
|
|
172
|
+
uses: actions/cache/restore@v5
|
|
173
|
+
with:
|
|
174
|
+
path: .ruff_cache
|
|
175
|
+
key: ruff-${{ runner.os }}-py${{ matrix.python }}-${{ github.sha }}
|
|
176
|
+
restore-keys: |
|
|
177
|
+
ruff-${{ runner.os }}-py${{ matrix.python }}-
|
|
178
|
+
ruff-${{ runner.os }}-
|
|
179
|
+
|
|
180
|
+
- name: Run ruff (lint + format)
|
|
181
|
+
id: ruff
|
|
182
|
+
continue-on-error: true
|
|
183
|
+
shell: bash
|
|
184
|
+
run: |
|
|
185
|
+
ruff check .
|
|
186
|
+
ruff format --check .
|
|
187
|
+
|
|
188
|
+
- name: Run pyright (type check)
|
|
189
|
+
id: pyright
|
|
190
|
+
continue-on-error: true
|
|
191
|
+
shell: bash
|
|
192
|
+
run: pyright
|
|
193
|
+
|
|
194
|
+
- name: Run pytest (tests + coverage)
|
|
195
|
+
id: pytest
|
|
196
|
+
continue-on-error: true
|
|
197
|
+
shell: bash
|
|
198
|
+
env:
|
|
199
|
+
TEST_VERBOSE: "1"
|
|
200
|
+
# Write coverage SQLite database to local disk. Network mounts
|
|
201
|
+
# and stale journal files cause "database is locked" errors.
|
|
202
|
+
COVERAGE_FILE: "${{ runner.temp }}/.coverage"
|
|
203
|
+
run: >-
|
|
204
|
+
pytest -m "not local_only"
|
|
205
|
+
--cov=${{ env.SRC_PATH }}/${{ env.PACKAGE_MODULE }}
|
|
206
|
+
--cov-report=term-missing
|
|
207
|
+
--cov-report=xml:${{ env.COV_REPORT_FILE }}
|
|
208
|
+
--cov-fail-under=${{ env.COV_FAIL_UNDER }}
|
|
209
|
+
${{ env.PYTEST_VERBOSITY }}
|
|
210
|
+
|
|
211
|
+
- name: Upload coverage to Codecov
|
|
212
|
+
if: >-
|
|
213
|
+
always()
|
|
214
|
+
&& steps.pytest.outcome == 'success'
|
|
215
|
+
&& matrix.os == 'ubuntu-latest'
|
|
216
|
+
&& matrix.python == needs.setup.outputs.python-latest
|
|
217
|
+
uses: codecov/codecov-action@v7
|
|
218
|
+
with:
|
|
219
|
+
files: ${{ env.COV_REPORT_FILE }}
|
|
220
|
+
fail_ci_if_error: false
|
|
221
|
+
|
|
222
|
+
- name: Run bandit (security scan)
|
|
223
|
+
id: bandit
|
|
224
|
+
continue-on-error: true
|
|
225
|
+
shell: bash
|
|
226
|
+
run: bandit -q -c pyproject.toml -r ${{ env.SRC_PATH }}/${{ env.PACKAGE_MODULE }} ${{ env.BANDIT_SKIP_FLAG }}
|
|
227
|
+
|
|
228
|
+
- name: Run pip-audit (vulnerability scan)
|
|
229
|
+
id: pipaudit
|
|
230
|
+
continue-on-error: true
|
|
231
|
+
shell: python
|
|
232
|
+
run: |
|
|
233
|
+
import subprocess, sys, tomllib
|
|
234
|
+
with open("pyproject.toml", "rb") as fh:
|
|
235
|
+
data = tomllib.load(fh)
|
|
236
|
+
vulns = data.get("tool", {}).get("pip-audit", {}).get("ignore-vulns", [])
|
|
237
|
+
cmd = ["pip-audit", "--skip-editable"]
|
|
238
|
+
for v in vulns:
|
|
239
|
+
cmd.extend(["--ignore-vuln", v])
|
|
240
|
+
sys.exit(subprocess.call(cmd))
|
|
241
|
+
|
|
242
|
+
- name: Check all steps passed
|
|
243
|
+
shell: bash
|
|
244
|
+
run: |
|
|
245
|
+
if [[ "${{ steps.ruff.outcome }}" != "success" ]]; then
|
|
246
|
+
echo "::error::Ruff check failed"
|
|
247
|
+
exit 1
|
|
248
|
+
fi
|
|
249
|
+
if [[ "${{ steps.pyright.outcome }}" != "success" ]]; then
|
|
250
|
+
echo "::error::Pyright check failed"
|
|
251
|
+
exit 1
|
|
252
|
+
fi
|
|
253
|
+
if [[ "${{ steps.pytest.outcome }}" != "success" ]]; then
|
|
254
|
+
echo "::error::Pytest failed"
|
|
255
|
+
exit 1
|
|
256
|
+
fi
|
|
257
|
+
if [[ "${{ steps.bandit.outcome }}" != "success" ]]; then
|
|
258
|
+
echo "::error::Bandit security scan failed"
|
|
259
|
+
exit 1
|
|
260
|
+
fi
|
|
261
|
+
if [[ "${{ steps.pipaudit.outcome }}" != "success" ]]; then
|
|
262
|
+
echo "::warning::pip-audit vulnerability scan failed"
|
|
263
|
+
fi
|
|
264
|
+
echo "All checks passed!"
|
|
265
|
+
|
|
266
|
+
- name: Build wheel/sdist
|
|
267
|
+
shell: bash
|
|
268
|
+
run: uv build
|
|
269
|
+
|
|
270
|
+
- name: Verify wheel install in clean env
|
|
271
|
+
shell: bash
|
|
272
|
+
run: |
|
|
273
|
+
uv venv .venv_wheel
|
|
274
|
+
. .venv_wheel/bin/activate 2>/dev/null || . .venv_wheel/Scripts/activate 2>/dev/null
|
|
275
|
+
uv pip install dist/*.whl
|
|
276
|
+
"$CLI_BIN" --version 2>/dev/null || python -m "$PACKAGE_MODULE" --version
|
|
277
|
+
|
|
278
|
+
- name: Prune uv cache
|
|
279
|
+
continue-on-error: true
|
|
280
|
+
shell: bash
|
|
281
|
+
run: uv cache prune --ci
|
|
282
|
+
|
|
283
|
+
- name: Save ruff cache
|
|
284
|
+
if: always() && runner.environment == 'github-hosted' && steps.ruff-cache.outputs.cache-hit != 'true'
|
|
285
|
+
uses: actions/cache/save@v5
|
|
286
|
+
with:
|
|
287
|
+
path: .ruff_cache
|
|
288
|
+
key: ruff-${{ runner.os }}-py${{ matrix.python }}-${{ github.sha }}
|
|
289
|
+
|
|
290
|
+
pipx-uv:
|
|
291
|
+
name: pipx/uv verification (ubuntu)
|
|
292
|
+
runs-on: ubuntu-latest
|
|
293
|
+
steps:
|
|
294
|
+
- uses: actions/checkout@v7
|
|
295
|
+
- uses: actions/setup-python@v6
|
|
296
|
+
with:
|
|
297
|
+
python-version: "3.14"
|
|
298
|
+
|
|
299
|
+
- name: Install uv
|
|
300
|
+
uses: astral-sh/setup-uv@v7
|
|
301
|
+
with:
|
|
302
|
+
enable-cache: true
|
|
303
|
+
prune-cache: false
|
|
304
|
+
|
|
305
|
+
- name: Extract project metadata
|
|
306
|
+
id: metadata
|
|
307
|
+
uses: ./.github/actions/extract-metadata
|
|
308
|
+
|
|
309
|
+
- name: Build wheel
|
|
310
|
+
run: uv build
|
|
311
|
+
- name: pipx install from wheel
|
|
312
|
+
run: |
|
|
313
|
+
uv pip install --system pipx
|
|
314
|
+
pipx ensurepath
|
|
315
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
316
|
+
pipx install dist/*.whl
|
|
317
|
+
"$CLI_BIN" --version
|
|
318
|
+
|
|
319
|
+
- name: uv tool install
|
|
320
|
+
run: |
|
|
321
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
322
|
+
uv tool install --force .
|
|
323
|
+
"$CLI_BIN" --version
|
|
324
|
+
|
|
325
|
+
- name: Prune uv cache
|
|
326
|
+
continue-on-error: true
|
|
327
|
+
run: uv cache prune --ci
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
push:
|
|
7
|
+
tags:
|
|
8
|
+
- 'v*'
|
|
9
|
+
workflow_dispatch: {}
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
build:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
permissions:
|
|
15
|
+
contents: read
|
|
16
|
+
env:
|
|
17
|
+
UV_LINK_MODE: "copy"
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v7
|
|
20
|
+
- uses: actions/setup-python@v6
|
|
21
|
+
with:
|
|
22
|
+
python-version: '3.14'
|
|
23
|
+
|
|
24
|
+
- name: Install uv
|
|
25
|
+
uses: astral-sh/setup-uv@v7
|
|
26
|
+
with:
|
|
27
|
+
enable-cache: true
|
|
28
|
+
prune-cache: false
|
|
29
|
+
|
|
30
|
+
- name: Build artifacts
|
|
31
|
+
run: uv build
|
|
32
|
+
|
|
33
|
+
- name: Upload artifacts
|
|
34
|
+
uses: actions/upload-artifact@v7
|
|
35
|
+
with:
|
|
36
|
+
name: dist
|
|
37
|
+
path: dist/*
|
|
38
|
+
|
|
39
|
+
- name: Prune uv cache
|
|
40
|
+
continue-on-error: true
|
|
41
|
+
run: uv cache prune --ci
|
|
42
|
+
|
|
43
|
+
publish:
|
|
44
|
+
needs: build
|
|
45
|
+
runs-on: ubuntu-latest
|
|
46
|
+
environment: pypi
|
|
47
|
+
permissions:
|
|
48
|
+
contents: read
|
|
49
|
+
steps:
|
|
50
|
+
- name: Verify PyPI token is set
|
|
51
|
+
run: |
|
|
52
|
+
if [ -z "${{ secrets.PYPI_API_TOKEN }}" ]; then
|
|
53
|
+
echo "::error title=Missing PyPI token::Set the PYPI_API_TOKEN secret in GitHub repository settings to enable PyPI publishing on releases."
|
|
54
|
+
echo "See: https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions"
|
|
55
|
+
exit 1
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
- name: Download artifacts
|
|
59
|
+
uses: actions/download-artifact@v8
|
|
60
|
+
with:
|
|
61
|
+
name: dist
|
|
62
|
+
path: dist/
|
|
63
|
+
|
|
64
|
+
- name: Publish to PyPI
|
|
65
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
66
|
+
with:
|
|
67
|
+
password: ${{ secrets.PYPI_API_TOKEN }}
|
|
68
|
+
attestations: false
|
|
69
|
+
skip-existing: true
|