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..f1de1b3a --- /dev/null +++ b/test/unit_test/headless/test_stable_release.py @@ -0,0 +1,95 @@ +"""``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 +import shutil +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) + + +@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) + + +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