From c09df63964de3872845a0c4803383b25d777faeb Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 9 Oct 2026 14:17:11 +0800 Subject: [PATCH 1/2] Release 1.0.0 and let the publish job release a version set by hand The publish job could only add one patch to the declared version, so no merge could ever produce 1.0.0. The version now comes from scripts/stable_release.py: a declared version that already has a tag is bumped one patch as before, an untagged one is released as written. docs/API_LIFECYCLE.md said the project stays pre-1.0 until three conditions hold; it now records what 1.0.0 promises (the stable API and the stable rows of the capability matrix) and how each condition stood on the day, including that macOS input is still unverified by CI. --- .github/workflows/stable.yml | 41 +++------ CHANGELOG.md | 21 ++++- CLAUDE.md | 2 +- architecture.md | 4 +- architecture_explore.md | 5 +- dev.toml | 2 +- docs/API_LIFECYCLE.md | 29 +++++- docs/source/conf.py | 2 +- docs/updates/2026-10.md | 9 ++ docs/updates/README.md | 1 + pyproject.toml | 4 +- scripts/stable_release.py | 81 ++++++++++++++++ .../unit_test/headless/test_stable_release.py | 92 +++++++++++++++++++ 13 files changed, 249 insertions(+), 44 deletions(-) create mode 100644 scripts/stable_release.py create mode 100644 test/unit_test/headless/test_stable_release.py diff --git a/.github/workflows/stable.yml b/.github/workflows/stable.yml index c1004c63..6c1534fb 100644 --- a/.github/workflows/stable.yml +++ b/.github/workflows/stable.yml @@ -123,9 +123,10 @@ jobs: publish: needs: test - # Releases ride on merges to main: this job bumps the patch version, - # uploads to PyPI with the PYPI_API_TOKEN secret, then pushes the bump - # commit (marked [skip ci]) and its v* tag. The guard is what keeps it + # Releases ride on merges to main: this job picks the version + # (scripts/stable_release.py: the next patch, or the untagged version + # pyproject.toml declares), uploads to PyPI with the PYPI_API_TOKEN + # secret, then pushes the bump commit (marked [skip ci]) and its v* tag. The guard is what keeps it # off pull requests and the nightly schedule, which the same workflow # also triggers on — without it every PR would cut a release. if: github.event_name == 'push' && github.ref == 'refs/heads/main' @@ -149,33 +150,12 @@ jobs: - 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 + # One patch above the declared version when that one is already tagged + # (every ordinary merge); the declared version itself when it is not, + # which is how a minor or major release is made. See the script. + - name: Choose the version to release id: bump - run: | - python <<'PY' - import os - import re - import pathlib - - path = pathlib.Path("pyproject.toml") - text = path.read_text(encoding="utf-8") - match = re.search(r'^version\s*=\s*"(\d+)\.(\d+)\.(\d+)"', text, flags=re.M) - if not match: - raise SystemExit("version line not found in pyproject.toml") - major, minor, patch = (int(part) for part in match.groups()) - new_version = f"{major}.{minor}.{patch + 1}" - text = re.sub( - r'^version\s*=\s*"\d+\.\d+\.\d+"', - f'version = "{new_version}"', - text, - count=1, - flags=re.M, - ) - path.write_text(text, encoding="utf-8") - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh: - fh.write(f"new_version={new_version}\n") - print(f"Bumped to {new_version}") - PY + run: python scripts/stable_release.py # --no-isolation: the backend is the setuptools the lock pins, not the # newest one downloaded into a fresh build environment. @@ -193,7 +173,8 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add pyproject.toml - git commit -m "Bump version to ${{ steps.bump.outputs.new_version }} [skip ci]" + # A version set by hand is already what main declares: nothing to commit. + git diff --cached --quiet || git commit -m "Bump version to ${{ steps.bump.outputs.new_version }} [skip ci]" git tag "v${{ steps.bump.outputs.new_version }}" git pull --rebase origin main git push origin main diff --git a/CHANGELOG.md b/CHANGELOG.md index 040ac351..c8ef489a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,10 @@ This file records user-visible compatibility changes. Detailed development notes are recorded in `docs/updates/` (index: `docs/updates/README.md`). -The format follows Keep a Changelog. Until 1.0, breaking changes are permitted -only when documented here with a migration path. +The format follows Keep a Changelog and, from 1.0.0, semantic versioning as +`docs/API_LIFECYCLE.md` scopes it: the stable surface is `je_auto_control.api`, +and a breaking change to it needs a deprecation here first. Beta and +experimental surfaces change with a release note in this file. New entries go under `## Unreleased`. The version bump on `main` is automated and does not touch this file, so after a release tag appears, move the entries @@ -13,6 +15,21 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ## Unreleased +## [1.0.0] - 2026-10-09 + +The first release under semantic versioning. `docs/API_LIFECYCLE.md` says what +the number promises: the stable surface is `je_auto_control.api` and the rows +`docs/CAPABILITY_MATRIX.md` marks stable; everything else keeps its beta or +experimental label. No name is removed or renamed by this release itself. This +section also holds what 0.0.223 to 0.0.234 shipped, which was never moved out +of `Unreleased`. + +- The package classifier is `Development Status :: 5 - Production/Stable` + (was `2 - Pre-Alpha`). +- Releases: a merge to `main` still releases the next patch; a minor or major + version is released by declaring it in `pyproject.toml` + (`scripts/stable_release.py`). + ### Added - Action journal: `start_action_journal` / `stop_action_journal` / diff --git a/CLAUDE.md b/CLAUDE.md index 295991e0..4cd16dac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,4 +221,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). 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). +- 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 patch version by hand: `scripts/stable_release.py` releases one patch above the version `pyproject.toml` declares whenever that version already has a `v*` tag. A minor or major release is the one exception — set the new version in `pyproject.toml` in the pull request that ships it, and the job releases that untagged version as written (`test/unit_test/headless/test_stable_release.py`). 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). diff --git a/architecture.md b/architecture.md index d5e112d3..e03de7db 100644 --- a/architecture.md +++ b/architecture.md @@ -36,7 +36,7 @@ entry points → execution core (`utils/executor/`) → headless capabilities (` | `examples/`, `benchmarks/` | Runnable example scripts; latency smoke benchmark. | | `docker/`, `k8s/helm/`, `ci_templates/` | Container images and backend verification harnesses, Helm chart, GitLab CI template. | | `browser-extension/`, `AutoControl/`, `exe/`, `autocontrol_driver/` | Manifest v3 companion extension, project-template sample, packaged GUI launcher, driver build script. | -| `scripts/` | Release tooling, not shipped: `dev_release.py` numbers and gates the dev-channel package (§3). | +| `scripts/` | Release tooling, not shipped: `dev_release.py` numbers and gates the dev-channel package, `stable_release.py` picks the version a push to `main` releases (§3). | ## 3. Entry points and public interfaces @@ -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`). 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`). | +| 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 takes the version from `scripts/stable_release.py` (one patch above the declared version when that one is tagged; the declared version itself when it is not, which is how a minor or major release such as 1.0.0 is made), 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 diff --git a/architecture_explore.md b/architecture_explore.md index 111f26ab..ec3742da 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -6,7 +6,7 @@ > 擷取每個模組的 docstring 與頂層公開名稱;統計數字取自實際檔案,非估算。 > 指令數與公開 API 數以 `executor.known_commands()` 與 `je_auto_control.__all__` 在工作樹上實測取得。 > -> **掃描時間**:2026-10-09 **版本**:`pyproject.toml` version `0.0.233` **分支**:`feat/decisions-followup` +> **掃描時間**:2026-10-09 **版本**:`pyproject.toml` version `1.0.0` **分支**:`release/1.0.0` --- @@ -1015,6 +1015,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,PySide6),且 | `docker/` | Dockerfile ×8 + compose + 9 支驗證/伺服器腳本 | 無頭容器(`Dockerfile`)、帶 XFCE 桌面的容器(`Dockerfile.xfce`),以及四個**驗證用**映像:`Dockerfile.wayland`(sway headless,擷取路徑 + `libei_verify.py` 對真的 libei.so 解析符號)、`Dockerfile.eis`(`eis_server.py` 用 ctypes 綁 libeis 起一個真的 EIS server,`eis_verify.py` 把 libei sender 對著它跑完整握手與發送)、`Dockerfile.portal`(`portal_server.py` 自己佔住 `org.freedesktop.portal.Desktop`,真的 `dbus-daemon` + 真的 liboeffis 跑完 RemoteDesktop 交握)、`Dockerfile.ydotool`(真的 uinput 裝置,`ydotool_verify.py` 直接讀回 `/dev/input/eventN`)、`Dockerfile.seat`(`headless,libinput` + builtin seat,合成器真的吃下 ydotool 裝置,`seat_verify.py` 從 `grim -c` 的像素讀回游標落點)、`Dockerfile.x11`(真的 Xvfb + openbox,`x11_verify.py` 用 `xev` 把注入的事件從真的客戶端讀回(含 `synthetic NO`,這是 XTest 跟 `XSendEvent` 的差別),另用 ImageMagick `import` 做獨立擷取對照,跑兩種螢幕版面)。全部接在 `.github/workflows/docker.yml`。 | | `k8s/helm/` | Helm chart | Kubernetes 部署。 | | `ci_templates/.gitlab-ci.yml` | — | 供使用者專案複製的 GitLab CI 範本。 | +| `scripts/stable_release.py` | 只用標準函式庫的發佈輔助腳本 | stable 通道(PyPI 的 `je_auto_control`)這次要發哪一版:`pyproject.toml` 宣告的版本已經有 `v*` tag 就加一個 patch(一般的合併),還沒有 tag 就照寫的發(minor/major 版在 PR 裡手動寫進 `pyproject.toml`),但不得低於最新的 tag。把版本寫回 `pyproject.toml` 並輸出 `new_version` 給 `stable.yml` 的 `publish` job。 | | `scripts/dev_release.py` | 只用標準函式庫的發佈輔助腳本 | dev 通道(PyPI 的 `je_auto_control_dev`)的版本與比對:`prepare` 把 `dev.toml` 寫成 `pyproject.toml`,版本取 PyPI 最新一版加一個 patch(`dev.toml` 的版本只是下限,不回寫 repo);`changed dist` 把建好的 wheel 跟 PyPI 最新一版逐檔比對,內容沒變就不上傳。只給 `dev.yml` 的 `publish-dev` job 與拋棄式的 checkout 用,因為 `prepare` 會蓋掉 `pyproject.toml`。 | | `docs/` | Sphinx(`API`/`Eng`/`Zh`/`getting_started`) | Read the Docs 文件。 | | `architecture_diagram/` | drawio + png | 既有的架構圖原始檔。 | @@ -1042,7 +1043,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,PySide6),且 | 檔案 | 用途 | | --- | --- | -| `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` 建置,所以建置後端也是鎖定的那一版。 | +| `stable.yml` | 每次 push/PR 到 `main` 與每日排程跑 Windows 五版本的示範腳本;合併到 main 後由 `scripts/stable_release.py` 決定版本(`pyproject.toml` 宣告的版本已有 tag 就加一個 patch,還沒有 tag 就照寫的發,minor/major 版就是這樣發的)並上傳 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。 | diff --git a/dev.toml b/dev.toml index e034a7e4..067aee5c 100644 --- a/dev.toml +++ b/dev.toml @@ -36,7 +36,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", - "Development Status :: 2 - Pre-Alpha", + "Development Status :: 5 - Production/Stable", "Environment :: Win32 (MS Windows)", "Environment :: MacOS X", "Environment :: X11 Applications", diff --git a/docs/API_LIFECYCLE.md b/docs/API_LIFECYCLE.md index 874388f6..2c177c00 100644 --- a/docs/API_LIFECYCLE.md +++ b/docs/API_LIFECYCLE.md @@ -12,9 +12,32 @@ available for compatibility but is not expanded with new integrations. replacement. Use `je_auto_control.utils.deprecation.deprecated`. - Breaking changes and migrations are recorded in `CHANGELOG.md`. -The project remains pre-1.0. A 1.0 release requires passing stable capability -tests on every claimed platform, documented recovery/diagnostic behavior, and -no unresolved critical security advisories. +## What 1.0.0 promises + +1.0.0 (2026-10-09) is the first release under semantic versioning. The +promise covers the **stable** surface only: `je_auto_control.api`, and the +capabilities `docs/CAPABILITY_MATRIX.md` marks stable. From here a breaking +change to that surface needs a new major version and the deprecation window +above. Beta and experimental surfaces keep the rules above and may change in a +minor release with a release note. + +The bar for 1.0 was: stable capability tests passing on every platform the +matrix claims, documented recovery and diagnostic behavior, and no unresolved +critical security advisory. As released: + +- The stable rows of the matrix are run by CI on Windows, Linux X11 and Linux + Wayland. On macOS the mouse / keyboard / screenshot row is still + "implementation": the stable-API suite passes on the macOS runner, but no + job drives a real pointer there. Treat macOS input as beta until that row + says CI. +- Recovery and diagnostics are `create_failure_bundle`, the trace and report + writers and the action journal, described in the README and `docs/source`. +- GitHub reported no open Dependabot alert and no open repository security + advisory on the day of the release. + +What has not been verified on real hardware (a Mac, a phone, a Wayland desktop +session, USB devices) is listed in `Progress.md`; none of it is in the stable +surface. ## Formats and wire versions diff --git a/docs/source/conf.py b/docs/source/conf.py index b16d0bf5..5cd26c5c 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -13,7 +13,7 @@ project = 'AutoControl' copyright = '2020 ~ Now, JE-Chen' # noqa: A001 # pylint: disable=redefined-builtin # reason: Sphinx-required name author = 'JE-Chen' -release = '0.0.179' +release = '1.0.0' # -- General configuration --------------------------------------------------- diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index 0d221bfb..e2e9dbeb 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -458,3 +458,12 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **What**: `execute_action(..., result_callback=fn)` (and `observe_results`) calls `fn(command, arguments, result, path)` after every command that returned, nested ones included; it is per run and per thread, changes neither the return value nor the log, and a callback that raises is logged by type and ignored (`utils/executor/result_hook.py`). The Script Builder collects its one-time values through it, so a token issued inside a loop or a try block is offered with its path; at most 200 per run. The result pane masks every secret-named field of every command's result at any depth, which closes the plain-text lease and REST bearer tokens; `AC_approval_request`'s request id stays visible through an explicit allow-list. The dialog can clear the clipboard when it still holds a value copied from it. - **Tests**: `test_executor_result_hook.py` (14), `test_result_redaction.py` (13), `test_script_builder_one_time_reveal.py` (33). - **Open items**: `AC_parallel` branches are not followed by the hook. `Progress.md` › GUI. + +## U-20261009-41 · 2026-10-09 · Release 1.0.0; the publish job releases a hand-set version as written · #release #ci + +- **What**: `pyproject.toml` declares `1.0.0`, the first release under semantic versioning. The `publish` job of `stable.yml` could only add one patch, so the inline bump is now `scripts/stable_release.py`: a declared version that already has a `v*` tag is bumped one patch (every ordinary merge, unchanged); one with no tag is released as written, and refused when it is below the newest tag. The commit step commits only when `pyproject.toml` changed. +- **What 1.0.0 promises**: `docs/API_LIFECYCLE.md` › "What 1.0.0 promises". The stable surface is `je_auto_control.api` plus the rows `docs/CAPABILITY_MATRIX.md` marks stable; beta and experimental surfaces keep their labels. The paragraph that said "the project remains pre-1.0" set three conditions, and the section records how each stood: stable rows run by CI on Windows, X11 and Wayland, with macOS input still "implementation" (stable-API suite green on `macos-14`, no job drives a real pointer); recovery and diagnostics documented; 0 open Dependabot alerts and 0 repository security advisories on the day. +- **Also**: classifier `Development Status :: 5 - Production/Stable` in `pyproject.toml` and `dev.toml`; `CHANGELOG.md` gains `## [1.0.0] - 2026-10-09`, which takes everything that was under `Unreleased` (0.0.223 to 0.0.234 were never moved out). The floor in `dev.toml` is unchanged, so `je_auto_control_dev` keeps its own 0.0.x numbering. +- **Tests**: `test_stable_release.py` (13). +- **Files**: `scripts/stable_release.py` (new), `.github/workflows/stable.yml`, `pyproject.toml`, `dev.toml`, `CHANGELOG.md`, `docs/API_LIFECYCLE.md`, `architecture.md`, `architecture_explore.md`, `CLAUDE.md`, `test/unit_test/headless/test_stable_release.py` (new). +- **Open items**: none new. Real-hardware verification stays in `Progress.md`. diff --git a/docs/updates/README.md b/docs/updates/README.md index 5bd3d800..2496c6e4 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -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-20261009-41 | 2026-10-09 | Release 1.0.0; the publish job releases a hand-set version as written | #release #ci | [2026-10](2026-10.md) | | U-20261009-40 | 2026-10-09 | A result hook reports nested command results; secret-named result fields are masked at any depth and offered once | #done #executor #gui #security | [2026-10](2026-10.md) | | U-20261009-39 | 2026-10-09 | REST and MCP resources follow the read_data rule; a Slack /stop no longer waits for the run it stops | #done #security #rbac #mcp #chatops | [2026-10](2026-10.md) | | U-20261009-38 | 2026-10-09 | Script Builder shows a freshly issued token once; command results are masked before they are logged | #done #gui #security | [2026-10](2026-10.md) | diff --git a/pyproject.toml b/pyproject.toml index ea7daff5..ae07ac00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "je_auto_control" -version = "0.0.234" +version = "1.0.0" authors = [ { name = "JE-Chen", email = "jechenmailman@gmail.com" }, ] @@ -61,7 +61,7 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", - "Development Status :: 2 - Pre-Alpha", + "Development Status :: 5 - Production/Stable", "Environment :: Win32 (MS Windows)", "Environment :: MacOS X", "Environment :: X11 Applications", diff --git a/scripts/stable_release.py b/scripts/stable_release.py new file mode 100644 index 00000000..1d927868 --- /dev/null +++ b/scripts/stable_release.py @@ -0,0 +1,81 @@ +"""Release helper for the stable channel, ``je_auto_control`` on PyPI. + +The ``publish`` job of ``.github/workflows/stable.yml`` runs ``python scripts/stable_release.py`` +before it builds. The script decides which version that push to ``main`` releases and writes it to +``pyproject.toml``: + +* the version ``pyproject.toml`` declares already has a ``v`` tag: one patch above it, + which is every ordinary merge; +* it has no tag yet: that version as written. This is how a minor or major release is made -- + set the version in ``pyproject.toml`` in the pull request that should ship it. It must be above + every tag, since PyPI never takes a number twice and a lower one would not be the newest. + +It prints ``new_version=X.Y.Z`` and appends the same line to ``$GITHUB_OUTPUT``. +""" +from __future__ import annotations + +import os +import re +import subprocess # nosec B404 # reason: runs the fixed command `git tag --list` and nothing else +import sys +from pathlib import Path + +Version = tuple[int, int, int] + +VERSION_LINE = re.compile(r'^(version\s*=\s*)"(\d+)\.(\d+)\.(\d+)"', re.MULTILINE) +TAG = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") + + +def as_version(parts: tuple[str, ...]) -> Version: + """Turn the three captured number strings of a version into a comparable tuple.""" + major, minor, patch = (int(part) for part in parts) + return major, minor, patch + + +def released_tags(root: Path) -> set[Version]: + """Return the versions that have a ``vX.Y.Z`` tag in the repository at ``root``.""" + listing = subprocess.run( # nosec B603 B607 # reason: fixed argv, git from the runner's PATH + ["git", "tag", "--list", "v*"], cwd=root, check=True, capture_output=True, text=True, timeout=60, + ).stdout + found = (TAG.match(line.strip()) for line in listing.splitlines()) + return {as_version(match.groups()) for match in found if match} + + +def release_version(declared: Version, tags: set[Version]) -> Version: + """Return the version to release: ``declared`` when it is untagged, else one patch above it.""" + if declared in tags: + major, minor, patch = declared + return major, minor, patch + 1 + newest = max(tags, default=None) + if newest is not None and declared < newest: + raise SystemExit( + "pyproject.toml declares {0}.{1}.{2}, which has no tag and is below the newest tag " + "v{3}.{4}.{5}".format(*declared, *newest)) + return declared + + +def prepare(root: Path) -> str: + """Write the version to release into ``pyproject.toml`` under ``root`` and return it.""" + path = root / "pyproject.toml" + text = path.read_text(encoding="utf-8") + line = VERSION_LINE.search(text) + if line is None: + raise SystemExit("version line not found in pyproject.toml") + version = "{0}.{1}.{2}".format(*release_version(as_version(line.groups()[1:]), released_tags(root))) + path.write_text(VERSION_LINE.sub(rf'\g<1>"{version}"', text, count=1), encoding="utf-8", newline="\n") + return version + + +def main() -> int: + """Prepare the checkout in the current directory; return the process exit code.""" + line = f"new_version={prepare(Path.cwd())}" + print(line) + output = os.environ.get("GITHUB_OUTPUT") + if output: + with open(output, "a", encoding="utf-8") as handle: + handle.write(line + "\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/unit_test/headless/test_stable_release.py b/test/unit_test/headless/test_stable_release.py new file mode 100644 index 00000000..3f31a352 --- /dev/null +++ b/test/unit_test/headless/test_stable_release.py @@ -0,0 +1,92 @@ +"""``scripts/stable_release.py`` picks the version a push to ``main`` releases. + +PyPI never takes a number twice, so the two ways of getting it wrong are both pinned here without +touching git or the network: bumping past a version that was set by hand (1.0.0 would have gone +out as 1.0.1), and releasing a version that is already tagged. The last tests read +``.github/workflows/stable.yml``, since the script only matters while the publish job runs it. +""" +import importlib.util +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPT = REPO_ROOT / "scripts" / "stable_release.py" +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "stable.yml" + + +def _load_script(): + spec = importlib.util.spec_from_file_location("stable_release", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +stable_release = _load_script() + + +@pytest.mark.parametrize("declared, tags, expected", [ + ((0, 0, 234), {(0, 0, 233), (0, 0, 234)}, (0, 0, 235)), # an ordinary merge + ((1, 0, 0), {(0, 0, 233), (0, 0, 234)}, (1, 0, 0)), # a version set by hand + ((1, 0, 0), {(0, 0, 234), (1, 0, 0)}, (1, 0, 1)), # the merge after it + ((1, 1, 0), {(1, 0, 0), (1, 0, 7)}, (1, 1, 0)), + ((0, 0, 1), set(), (0, 0, 1)), # no tag yet +]) +def test_an_untagged_version_is_released_as_written_and_a_tagged_one_is_bumped(declared, tags, expected): + assert stable_release.release_version(declared, tags) == expected + + +def test_an_untagged_version_below_the_newest_tag_is_refused(): + with pytest.raises(SystemExit, match="below the newest tag v1.0.3"): + stable_release.release_version((1, 0, 2), {(1, 0, 1), (1, 0, 3)}) + + +def test_prepare_rewrites_only_the_project_version(tmp_path, monkeypatch): + text = '[project]\nname = "je_auto_control"\nversion = "0.0.234"\n\n[tool.x]\nversion = "0.0.234"\n' + (tmp_path / "pyproject.toml").write_text(text, encoding="utf-8") + monkeypatch.setattr(stable_release, "released_tags", lambda root: {(0, 0, 234)}) + assert stable_release.prepare(tmp_path) == "0.0.235" + assert (tmp_path / "pyproject.toml").read_text(encoding="utf-8") == text.replace("0.0.234", "0.0.235", 1) + + +def test_prepare_leaves_a_hand_set_version_alone(tmp_path, monkeypatch): + text = '[project]\nversion = "1.0.0"\n' + (tmp_path / "pyproject.toml").write_text(text, encoding="utf-8") + monkeypatch.setattr(stable_release, "released_tags", lambda root: {(0, 0, 234)}) + assert stable_release.prepare(tmp_path) == "1.0.0" + assert (tmp_path / "pyproject.toml").read_text(encoding="utf-8") == text + + +def test_prepare_refuses_a_pyproject_without_a_plain_version(tmp_path): + (tmp_path / "pyproject.toml").write_text('[project]\nversion = "1.0.0rc1"\n', encoding="utf-8") + with pytest.raises(SystemExit, match="version line not found"): + stable_release.prepare(tmp_path) + + +def test_released_tags_reads_the_release_tags_of_this_repository(): + tags = stable_release.released_tags(REPO_ROOT) + assert all(len(tag) == 3 for tag in tags) + + +def test_main_reports_the_version_to_the_workflow(tmp_path, monkeypatch, capsys): + (tmp_path / "pyproject.toml").write_text('[project]\nversion = "1.0.0"\n', encoding="utf-8") + output = tmp_path / "github_output" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("GITHUB_OUTPUT", str(output)) + monkeypatch.setattr(stable_release, "released_tags", lambda root: {(1, 0, 0)}) + assert stable_release.main() == 0 + assert capsys.readouterr().out == "new_version=1.0.1\n" + assert output.read_text(encoding="utf-8") == "new_version=1.0.1\n" + + +def test_the_publish_job_takes_its_version_from_the_script_before_it_builds(): + job = WORKFLOW.read_text(encoding="utf-8").split("\n publish:\n", 1)[1] + assert "fetch-depth: 0" in job, "the script reads the tags, so the checkout has to fetch them" + assert job.index("python scripts/stable_release.py") < job.index("python -m build") < job.index("twine upload") + assert "steps.bump.outputs.new_version" in job + + +def test_the_publish_job_commits_only_when_the_version_changed(): + # A hand-set version leaves pyproject.toml as main has it; a bare `git commit` would fail there. + job = WORKFLOW.read_text(encoding="utf-8").split("\n publish:\n", 1)[1] + assert "git diff --cached --quiet || git commit" in job From 257f8c564aea14db61c7cbaf881f25f0aa1a6381 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 9 Oct 2026 14:32:03 +0800 Subject: [PATCH 2/2] Skip the tag-reading test where there is no git, as in the Docker image --- test/unit_test/headless/test_stable_release.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/unit_test/headless/test_stable_release.py b/test/unit_test/headless/test_stable_release.py index 3f31a352..f1de1b3a 100644 --- a/test/unit_test/headless/test_stable_release.py +++ b/test/unit_test/headless/test_stable_release.py @@ -6,6 +6,7 @@ ``.github/workflows/stable.yml``, since the script only matters while the publish job runs it. """ import importlib.util +import shutil from pathlib import Path import pytest @@ -63,6 +64,8 @@ def test_prepare_refuses_a_pyproject_without_a_plain_version(tmp_path): stable_release.prepare(tmp_path) +@pytest.mark.skipif(shutil.which("git") is None or not (REPO_ROOT / ".git").exists(), + reason="needs git and a checkout; the Docker image has neither") def test_released_tags_reads_the_release_tags_of_this_repository(): tags = stable_release.released_tags(REPO_ROOT) assert all(len(tag) == 3 for tag in tags)