Skip to content
Open
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
7 changes: 6 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
version: 2
updates:
- package-ecosystem: "pip" # See documentation for possible values
directory: "/" # Location of package manifests
# Location of package manifests: the project's own at the root, and the
# hash-locked tool set the publish jobs install. From the root Dependabot
# does not look as deep as .github/requirements, so that directory is named.
directories:
- "/"
- "/.github/requirements"
schedule:
interval: "daily"
12 changes: 12 additions & 0 deletions .github/requirements/publish.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# What the two jobs that hold the PyPI token install: `publish` in stable.yml and `publish-dev` in dev.yml.
# Their version scripts (the inline bump in stable.yml, scripts/dev_release.py) use only the standard library.
# publish.txt is generated from this file:
# uv pip compile .github/requirements/publish.in --generate-hashes --python-version 3.12 --python-platform x86_64-manylinux_2_28 --only-binary :all: --exclude-newer 2026-09-24 -o .github/requirements/publish.txt
# --python-version is the one both jobs set up. --exclude-newer leaves out the uploads of the last 7 days:
# move the date when regenerating.
build==1.5.0
twine==6.2.0
# The build backend. The jobs run `python -m build --no-isolation`, so the backend is this locked one and
# not whatever is newest on PyPI when the job runs. It must satisfy `build-system.requires` in pyproject.toml
# and dev.toml; test/unit_test/headless/test_publish_tooling_lock.py fails when it does not.
setuptools
472 changes: 472 additions & 0 deletions .github/requirements/publish.txt

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions .github/workflows/dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,19 @@ jobs:
with:
python-version: "3.12"

- name: Install build tooling
run: "pip install --only-binary :all: build==1.5.0 twine==6.2.0"
# This job holds the PyPI token, so it installs one file and nothing else:
# wheels only, at locked hashes. The command that regenerates the lock is
# at the top of .github/requirements/publish.in.
- name: Install the hash-locked build tooling
run: "python -m pip install --require-hashes --only-binary :all: -r .github/requirements/publish.txt"

- name: Write pyproject.toml from dev.toml with the next version
run: python scripts/dev_release.py prepare

# --no-isolation: the backend is the setuptools the lock pins, not the
# newest one downloaded into a fresh build environment.
- name: Build distribution
run: python -m build
run: python -m build --no-isolation

- name: Verify distribution metadata
run: python -m twine check dist/*
Expand Down
11 changes: 8 additions & 3 deletions .github/workflows/stable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,11 @@ jobs:
with:
python-version: "3.12"

- name: Install build tooling
run: "pip install --only-binary :all: build==1.5.0 twine==6.2.0"
# This job holds the PyPI token, so it installs one file and nothing else:
# wheels only, at locked hashes. The command that regenerates the lock is
# at the top of .github/requirements/publish.in.
- name: Install the hash-locked build tooling
run: "python -m pip install --require-hashes --only-binary :all: -r .github/requirements/publish.txt"

- name: Bump patch version in pyproject.toml
id: bump
Expand Down Expand Up @@ -174,8 +177,10 @@ jobs:
print(f"Bumped to {new_version}")
PY

# --no-isolation: the backend is the setuptools the lock pins, not the
# newest one downloaded into a fresh build environment.
- name: Build distribution
run: python -m build
run: python -m build --no-isolation

- name: Publish to PyPI
env:
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -213,4 +213,4 @@ Workspace rule shared by every repository under `D:\Codes` (full text: `D:\Codes
- JSON action command names use the `AC_` prefix (e.g. `AC_click_mouse`); MCP tools use `ac_`.
- Platform backends are named `{platform}_{function}.py` (e.g. `win32_ctype_mouse_control.py`).
- Virtual key mappings live in `core/utils/*_vk.py` per platform.
- Two PyPI packages, both published by CI: a push to `main` releases `je_auto_control` (the `publish` job of `stable.yml`), and a push to `dev` that passes the headless suite and changes what the package ships releases `je_auto_control_dev` (the `publish-dev` job of `dev.yml`, `scripts/dev_release.py`). Never bump a version by hand; the version in `dev.toml` is only a floor. `dev.toml` must declare what `pyproject.toml` declares, so a change to dependencies, extras, scripts, entry points or `[tool.setuptools]` goes into both files (`test/unit_test/headless/test_dev_toml_parity.py` fails otherwise).
- Two PyPI packages, both published by CI: a push to `main` releases `je_auto_control` (the `publish` job of `stable.yml`), and a push to `dev` that passes the headless suite and changes what the package ships releases `je_auto_control_dev` (the `publish-dev` job of `dev.yml`, `scripts/dev_release.py`). Never bump a version by hand; the version in `dev.toml` is only a floor. `dev.toml` must declare what `pyproject.toml` declares, so a change to dependencies, extras, scripts, entry points or `[tool.setuptools]` goes into both files (`test/unit_test/headless/test_dev_toml_parity.py` fails otherwise). Both publish jobs hold the PyPI token, so they install only the hash-locked `.github/requirements/publish.txt` and build with `python -m build --no-isolation`: a new tool, or a higher floor in `build-system.requires`, needs the lock regenerated with the command at the top of `.github/requirements/publish.in` (`test/unit_test/headless/test_publish_tooling_lock.py` fails otherwise).
2 changes: 1 addition & 1 deletion architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ entry points → execution core (`utils/executor/`) → headless capabilities (`
| LSP | `autocontrol-lsp` → `autocontrol_lsp.server.server:run`; `python -m autocontrol_lsp.server` | Command list is read from the live executor. |
| GUI | `start_autocontrol_gui()` in `gui/__init__.py`; `exe/start_autocontrol_gui.py` | Needs `pip install je_auto_control[gui]`; PySide6 is imported only under `gui/`. |
| Action lint | `python -m je_auto_control.utils.action_lint` | Used by `.github/workflows/action-json-lint.yml`. |
| PyPI packages | `je_auto_control` (stable), `je_auto_control_dev` (dev channel) | Both ship the same `je_auto_control` import package. Stable: a push to `main` runs the `publish` job of `stable.yml`, which bumps `pyproject.toml`, uploads and tags. Dev: the `publish-dev` job of `dev.yml` runs after the headless suite on a push to `dev`, builds from `dev.toml` and uploads when the commit is still the tip of `dev` and the wheel differs from the newest published one; `scripts/dev_release.py` takes the version from PyPI (newest release plus one patch), so nothing is committed back. `dev.toml` declares what `pyproject.toml` declares (`test_dev_toml_parity.py`). |
| PyPI packages | `je_auto_control` (stable), `je_auto_control_dev` (dev channel) | Both ship the same `je_auto_control` import package. Stable: a push to `main` runs the `publish` job of `stable.yml`, which bumps `pyproject.toml`, uploads and tags. Dev: the `publish-dev` job of `dev.yml` runs after the headless suite on a push to `dev`, builds from `dev.toml` and uploads when the commit is still the tip of `dev` and the wheel differs from the newest published one; `scripts/dev_release.py` takes the version from PyPI (newest release plus one patch), so nothing is committed back. `dev.toml` declares what `pyproject.toml` declares (`test_dev_toml_parity.py`). Both jobs hold the PyPI token and install nothing but the hash-locked `.github/requirements/publish.txt` (`build`, `twine` and the `setuptools` backend, generated from `publish.in` beside it), then build with `python -m build --no-isolation`, so the backend is the locked one (`test_publish_tooling_lock.py`). |

## 4. Main flows

Expand Down
4 changes: 2 additions & 2 deletions architecture_explore.md
Original file line number Diff line number Diff line change
Expand Up @@ -1018,8 +1018,8 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,PySide6 + qt-mate

| 檔案 | 用途 |
| --- | --- |
| `stable.yml` | 每次 push/PR 到 `main` 與每日排程跑 Windows 五版本的示範腳本;合併到 main 後版本遞增並上傳 PyPI(使用 `PYPI_API_TOKEN`)。 |
| `dev.yml` | 每次 push/PR 到 `dev` 跑 headless pytest(四格:Windows 的 3.10 與 3.14、Ubuntu 3.14、macOS 3.10,都是 `quality.yml` 九格裡的格子,不量 coverage)。push 通過後由 `publish-dev` job 用 `dev.toml` 建置並上傳 `je_auto_control_dev`(同一個 `PYPI_API_TOKEN`);只有這個 commit 仍是 `dev` 的最新一筆、而且 wheel 內容跟 PyPI 最新一版不同時才上傳,版本由 `scripts/dev_release.py` 向 PyPI 查,不回寫 repo。 |
| `stable.yml` | 每次 push/PR 到 `main` 與每日排程跑 Windows 五版本的示範腳本;合併到 main 後版本遞增並上傳 PyPI(使用 `PYPI_API_TOKEN`)。`publish` job 只安裝雜湊鎖定的 `.github/requirements/publish.txt`(`build`、`twine` 與建置後端 `setuptools`,由同目錄的 `publish.in` 用 `uv pip compile` 產生),並以 `python -m build --no-isolation` 建置,所以建置後端也是鎖定的那一版。 |
| `dev.yml` | 每次 push/PR 到 `dev` 跑 headless pytest(四格:Windows 的 3.10 與 3.14、Ubuntu 3.14、macOS 3.10,都是 `quality.yml` 九格裡的格子,不量 coverage)。push 通過後由 `publish-dev` job 用 `dev.toml` 建置並上傳 `je_auto_control_dev`(同一個 `PYPI_API_TOKEN`);只有這個 commit 仍是 `dev` 的最新一筆、而且 wheel 內容跟 PyPI 最新一版不同時才上傳,版本由 `scripts/dev_release.py` 向 PyPI 查,不回寫 repo。`publish-dev` 跟 `stable.yml` 的 `publish` 一樣只安裝 `.github/requirements/publish.txt` 並以 `python -m build --no-isolation` 建置;`test_publish_tooling_lock.py` 守住這兩個 job 的安裝行、建置指令,以及鎖定版本滿足 `pyproject.toml`/`dev.toml` 的 `build-system.requires`。 |
| `release.yml` | 發佈流程(上傳步驟目前關閉)。 |
| `quality.yml` | ruff、bandit、dependency review、九格矩陣的 headless pytest(含 coverage 地板)與 mypy。 |
| `platform-smoke.yml` | 跨平台煙霧測試。 |
Expand Down
28 changes: 28 additions & 0 deletions docs/updates/2026-10.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,31 @@ WebRunner's native commands (its U-20261001-28 and -29), `WR_ac_fill_native_file
- **Docs**: `architecture.md` (§2 `scripts/` row, §3 PyPI packages row), `architecture_explore.md` (§5.6 `scripts/dev_release.py` row, §7 `dev.yml` row), `CLAUDE.md` › Key Conventions, `CHANGELOG.md` (Changed). No README or installation page mentions the dev package, so none changed.
- **Files**: `.github/workflows/dev.yml`, `scripts/dev_release.py`, `dev.toml`, `test/unit_test/headless/{test_dev_release,test_dev_toml_parity}.py`, `architecture.md`, `architecture_explore.md`, `CLAUDE.md`, `CHANGELOG.md`.
- **Open items**: the points under **Not running yet**. `Progress.md` is not part of this change, so it does not list them.

## U-20261001-10 · 2026-10-01 · The publish jobs install hash-locked build tooling and build with the locked setuptools · #release #ci #security #X-13

- **What**: workspace X-13, decided by the owner. The two jobs that receive `secrets.PYPI_API_TOKEN`, `publish` in `stable.yml` and `publish-dev` in `dev.yml`, installed `build==1.5.0` and `twine==6.2.0` by version alone: no hashes, and their dependencies at whatever was newest that day. `python -m build` then created an isolated environment and downloaded the newest `setuptools` (`build-system.requires` is `setuptools>=82.0.1`). All of it runs in the job that is about to upload with the token.
- New `.github/requirements/publish.in` names `build==1.5.0` and `twine==6.2.0`, the versions the workflows named, and `setuptools`, the build backend. `publish.txt` is generated from it with `uv pip compile`: wheels only, with hashes, for Python 3.12 on `x86_64-manylinux_2_28`, `--exclude-newer 2026-09-24`. The command is at the top of `publish.in` and in the header of `publish.txt`.
- Both jobs now run one install, `python -m pip install --require-hashes --only-binary :all: -r .github/requirements/publish.txt`, and build with `python -m build --no-isolation`, so the backend is the locked `setuptools`. Triggers, the other steps and the secret are unchanged.
- `release.yml` is unchanged. Its `build` job has no PyPI token (it checks the tag, smoke-tests the wheel and attests provenance) and its `publish` job is switched off, so it keeps `build==1.5.0 twine==6.2.0` and an isolated build.
- `.github/dependabot.yml`: the `pip` entry names `/` and `/.github/requirements` (`directories`). From `/` Dependabot does not look that deep, so it would never have proposed an update to the lock. Nothing else in that file changed.
- **Guards**: new `test_publish_tooling_lock.py`, 11 cases read from the files as text:
- the jobs that read the token are exactly `dev.yml:publish-dev` and `stable.yml:publish`;
- each runs exactly one `pip install`, the locked one, so an unpinned install, a second install or a pip upgrade fails (2 cases);
- every `python -m build` in them carries `--no-isolation` (2 cases);
- `build-system.requires` in `pyproject.toml` and in `dev.toml` names only packages `publish.txt` pins, at a version inside the written specifier (2 cases). `--no-isolation` checks the requirement instead of installing it, so a floor raised without regenerating the lock fails in the suite, not in the publish job;
- `publish.in` names exactly what the jobs run with `python -m` or import in an inline script, less pip and the standard library, plus the build backend;
- every name in `publish.in` is pinned in `publish.txt`;
- the lock's header records `--generate-hashes`, `--only-binary :all:` and the `--python-version` both jobs set up;
- Dependabot's `pip` entry covers `/` and `/.github/requirements`.
`test_dev_release.py` compared the `build==` line of `dev.yml` with `stable.yml`'s; it now compares the locked install line and the `--no-isolation` build line of the two jobs.
- **Result / numbers**:
- 30 pins: `build` 1.5.0, `twine` 6.2.0, `setuptools` 84.0.0 and 27 dependencies (among them `packaging` 26.3, `pyproject-hooks` 1.3.3, `requests` 2.34.2, `urllib3` 2.8.0, `cryptography` 50.0.1, `keyring` 25.7.0).
- `pip download --require-hashes --only-binary :all: --no-deps` for CPython 3.12 on manylinux x86_64 fetches all 30 wheels with matching hashes.
- Built in a throwaway worktree, in a scratch Python 3.12 environment holding the 30 locked versions: the stable metadata (0.0.225) and the dev metadata (`scripts/dev_release.py prepare`, 0.0.137), each with `python -m build --no-isolation` and, for comparison, with an isolated build. Both sdist and wheel build, `twine check` passes on all four files, each wheel has 1,062 members with the same names and the same content as the isolated build's, and each sdist the same 1,067 files.
- Against the published `je_auto_control_dev` 0.0.136 the dev wheel differs, line endings aside, only in its `Version:` line and `RECORD`, so `publish-dev` has nothing to upload for this change.
- Twelve ways of breaking the rule (an unpinned or a second install, an isolated build in either job, a floor above the lock in either metadata file, a backend the lock does not pin, Dependabot on `/` alone, a tool not in `publish.in`, a name in `publish.in` not in the lock, another Python in a job, the token in `release.yml`) each fail at least one test.
- Full headless suite on this branch (`python -m pytest --timeout=120`, Windows, Python 3.14): 10673 passed, 46 skipped.
- **Docs**: `architecture.md` §3 (PyPI packages row), `architecture_explore.md` §7 (`stable.yml` and `dev.yml` rows) and `CLAUDE.md` › Key Conventions say what the two jobs install and how they build. No README or `docs/source/` page describes the publish jobs, and nothing a user of the package sees changes, so the READMEs and `CHANGELOG.md` are untouched.
- **Files**: `.github/requirements/publish.in` (new), `.github/requirements/publish.txt` (new, generated), `.github/workflows/dev.yml`, `.github/workflows/stable.yml`, `.github/dependabot.yml`, `test/unit_test/headless/test_publish_tooling_lock.py` (new), `test/unit_test/headless/test_dev_release.py`, `architecture.md`, `architecture_explore.md`, `CLAUDE.md`.
- **Open items**: none here. Neither publish job runs on a pull request: `publish-dev` first runs its install and build steps on the push that brings this to `dev`, and `stable.yml`'s `publish` once `dev` is merged into `main`. The rest of `.github/dependabot.yml` (no `github-actions` entry, no cooldown, no `target-branch`) is workspace X-21.
3 changes: 2 additions & 1 deletion docs/updates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here

| ID | Date | Title | Tags | Batch |
|---|---|---|---|---|
| U-20261001-10 | 2026-10-01 | The publish jobs install hash-locked build tooling and build with the locked setuptools | #release #ci #security #X-13 | [2026-10](2026-10.md) |
| U-20261001-09 | 2026-10-01 | CI publishes je_auto_control_dev from the dev branch; dev.toml says what pyproject.toml says | #release #ci #X-13 | [2026-10](2026-10.md) |
| U-20261001-08 | 2026-10-01 | Package gate in front of AC_add_package_to_executor | #security #X-12 | [2026-10](2026-10.md) |
| U-20261001-07 | 2026-10-01 | write_secret / AC_write_secret: type a password without logging, recording or returning it | #done #keyboard #security #webrunner | [2026-10](2026-10.md) |
Expand Down Expand Up @@ -341,7 +342,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here

| File | Period | Entries |
|---|---|---:|
| [2026-10.md](2026-10.md) | 2026-10 | 5 |
| [2026-10.md](2026-10.md) | 2026-10 | 6 |
| [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 |
| [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 |
| [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 |
Expand Down
8 changes: 6 additions & 2 deletions test/unit_test/headless/test_dev_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ def test_the_workflow_publishes_only_a_tested_push_to_dev():
def test_the_workflow_uploads_only_a_changed_build_and_keeps_no_credentials():
job = _publish_job()
upload = job.index("twine upload")
assert job.index("dev_release.py prepare") < job.index("python -m build") < upload
assert job.index("dev_release.py prepare") < job.index("python -m build --no-isolation") < upload
assert job.index("dev_release.py changed dist") < upload
assert job.index("git ls-remote origin refs/heads/dev") < upload
assert "if: steps.compare.outputs.changed == 'true' && steps.tip.outputs.current == 'true'" in job
Expand All @@ -177,4 +177,8 @@ def test_dev_is_tested_the_way_main_is():


def test_the_dev_package_is_built_with_the_tooling_the_stable_one_is():
assert _line_with(_publish_job(), "build==") == _line_with(_workflow("stable.yml"), "build==")
# Both jobs install the same hash-locked file and build with the backend it pins
# (test_publish_tooling_lock.py says what those two lines must be).
job, stable = _publish_job(), _workflow("stable.yml")
assert _line_with(job, "--require-hashes") == _line_with(stable, "--require-hashes")
assert _line_with(job, "python -m build") == _line_with(stable, "python -m build")
Loading
Loading