rhiza-task 0.1.1__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.
Files changed (35) hide show
  1. rhiza_task-0.1.1/.github/workflows/ci.yml +72 -0
  2. rhiza_task-0.1.1/.github/workflows/rhiza_release.yml +528 -0
  3. rhiza_task-0.1.1/.gitignore +11 -0
  4. rhiza_task-0.1.1/.python-version +1 -0
  5. rhiza_task-0.1.1/CHANGELOG.md +32 -0
  6. rhiza_task-0.1.1/LICENSE +21 -0
  7. rhiza_task-0.1.1/PKG-INFO +209 -0
  8. rhiza_task-0.1.1/README.md +185 -0
  9. rhiza_task-0.1.1/pyproject.toml +85 -0
  10. rhiza_task-0.1.1/pytest.ini +5 -0
  11. rhiza_task-0.1.1/ruff.toml +175 -0
  12. rhiza_task-0.1.1/src/rhiza_task/__init__.py +26 -0
  13. rhiza_task-0.1.1/src/rhiza_task/__main__.py +6 -0
  14. rhiza_task-0.1.1/src/rhiza_task/cli.py +163 -0
  15. rhiza_task-0.1.1/src/rhiza_task/config.py +248 -0
  16. rhiza_task-0.1.1/src/rhiza_task/runner.py +149 -0
  17. rhiza_task-0.1.1/src/rhiza_task/spec.py +185 -0
  18. rhiza_task-0.1.1/src/rhiza_task/tasks/__init__.py +7 -0
  19. rhiza_task-0.1.1/src/rhiza_task/tasks/book.py +195 -0
  20. rhiza_task-0.1.1/src/rhiza_task/tasks/doctor.py +148 -0
  21. rhiza_task-0.1.1/src/rhiza_task/tasks/extras.py +164 -0
  22. rhiza_task-0.1.1/src/rhiza_task/tasks/python.py +315 -0
  23. rhiza_task-0.1.1/src/rhiza_task/tasks/quality.py +236 -0
  24. rhiza_task-0.1.1/src/rhiza_task/templates/Makefile +36 -0
  25. rhiza_task-0.1.1/src/rhiza_task/uv.py +178 -0
  26. rhiza_task-0.1.1/tests/__init__.py +1 -0
  27. rhiza_task-0.1.1/tests/conftest.py +197 -0
  28. rhiza_task-0.1.1/tests/test_cli.py +235 -0
  29. rhiza_task-0.1.1/tests/test_config.py +151 -0
  30. rhiza_task-0.1.1/tests/test_dev_tasks.py +418 -0
  31. rhiza_task-0.1.1/tests/test_runner.py +219 -0
  32. rhiza_task-0.1.1/tests/test_spec.py +100 -0
  33. rhiza_task-0.1.1/tests/test_tasks.py +466 -0
  34. rhiza_task-0.1.1/tests/test_uv.py +169 -0
  35. rhiza_task-0.1.1/uv.lock +356 -0
@@ -0,0 +1,72 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ # Least privilege: the gates only need to read the code.
9
+ permissions:
10
+ contents: read
11
+
12
+ # A new push to a PR makes the in-flight run irrelevant.
13
+ concurrency:
14
+ group: ${{ github.workflow }}-${{ github.ref }}
15
+ cancel-in-progress: true
16
+
17
+ jobs:
18
+ test:
19
+ name: pytest (${{ matrix.os }}, ${{ matrix.python-version }})
20
+ runs-on: ${{ matrix.os }}
21
+ strategy:
22
+ fail-fast: false
23
+ matrix:
24
+ os: [ubuntu-latest, macos-latest, windows-latest]
25
+ # Mirrors the Programming Language :: Python :: X.Y classifiers in pyproject.toml.
26
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
27
+
28
+ steps:
29
+ - uses: actions/checkout@v7
30
+
31
+ # Exact version, not a floating major: astral-sh/setup-uv publishes no `v8`, `v9` or
32
+ # `v10` alias tag, so `@v10` fails to resolve at job start.
33
+ #
34
+ # This step is also what replaces `install-uv`. bootstrap.mk curls the uv installer
35
+ # into ./bin because make cannot assume uv exists; a workflow can just say so.
36
+ #
37
+ # `version` pins the uv binary itself, not just the action, to the same uv that
38
+ # rhiza_release.yml builds and publishes with. A gate that passes here on one uv and
39
+ # a release built on another is a resolver difference nothing would catch.
40
+ - uses: astral-sh/setup-uv@v10.0.1
41
+ with:
42
+ version: "0.11.16"
43
+ python-version: ${{ matrix.python-version }}
44
+ enable-cache: true
45
+
46
+ # Windows is in the matrix deliberately. The make layer needed a 40-line probe to
47
+ # detect make falling back to cmd.exe, because its recipes were POSIX shell; every
48
+ # command here is an argument vector, so this job is what asserts that the shell
49
+ # dependency is really gone.
50
+ - name: Run tests
51
+ run: uv run --group test --python ${{ matrix.python-version }} pytest -ra
52
+
53
+ gates:
54
+ name: the gates this package applies to others
55
+ runs-on: ubuntu-latest
56
+ steps:
57
+ - uses: actions/checkout@v7
58
+ - uses: astral-sh/setup-uv@v10.0.1
59
+ with:
60
+ version: "0.11.16"
61
+ enable-cache: true
62
+
63
+ # Dogfooding, and not for its own sake: `all` is the aggregate consumers run, so a
64
+ # break in prerequisite resolution or outcome reporting fails here first.
65
+ #
66
+ # Deliberately without --strict. This repository is not rhiza-managed -- it ships no
67
+ # .pre-commit-config.yaml and no .rhiza/semgrep.yml, exactly as pytest-rhiza does not
68
+ # -- so `fmt` and `semgrep` have genuinely nothing to measure here and skip. --strict
69
+ # is the right setting in a *consumer* repository, where a skip means a gate lost its
70
+ # subject; asserting it here would only assert that this repo is something it is not.
71
+ - name: rhiza-task all
72
+ run: uv run rhiza-task all
@@ -0,0 +1,528 @@
1
+ # This file is part of the jebel-quant/rhiza repository
2
+ # (https://github.com/jebel-quant/rhiza).
3
+ #
4
+ # ⚠️ WHY THIS WORKFLOW CANNOT USE A STUB
5
+ #
6
+ # Rhiza normally distributes workflows as thin stubs that delegate to a reusable
7
+ # workflow in this repository via `workflow_call`. That pattern cannot be used here
8
+ # because PyPI Trusted Publishing (OIDC) validates the *exact* workflow file path
9
+ # and repository that invokes `pypa/gh-action-pypi-publish`. When a stub delegates
10
+ # to a reusable workflow in jebel-quant/rhiza, PyPI sees rhiza's repository as the
11
+ # publisher — not the downstream project's — and rejects the OIDC token. This file
12
+ # must therefore be synced directly into each downstream project so that the publish
13
+ # step runs under the correct repository identity.
14
+ #
15
+ # Release Workflow for Python Packages
16
+ #
17
+ # This workflow implements a secure, maintainable release pipeline with distinct phases:
18
+ #
19
+ # 📋 Pipeline Phases:
20
+ # 1. 🔍 Validate Tag - Check tag format and ensure release doesn't already exist
21
+ # 2. 🏗️ Build - Build Python package with Hatch (if [build-system] is defined in pyproject.toml)
22
+ # 3. 📦 Generate SBOM - Create Software Bill of Materials (CycloneDX format)
23
+ # 4. 📝 Draft Release - Create draft GitHub release with build artifacts and SBOM
24
+ # 5. 🚀 Publish to PyPI - Publish package using OIDC or custom feed
25
+ # 6. ✅ Finalize Release - Publish the GitHub release with links
26
+ #
27
+ # 📄 CHANGELOG: not updated here. The release process (rhiza-claude `/release`)
28
+ # folds a freshly generated CHANGELOG.md into the version-bump commit before the
29
+ # tag is pushed, so the tagged commit already carries the changelog — no separate
30
+ # post-tag commit.
31
+ #
32
+ # 📦 SBOM Generation:
33
+ # - Generated using CycloneDX format (industry standard for software supply chain security)
34
+ # - Creates both JSON and XML formats for maximum compatibility
35
+ # - SBOM attestations are created and stored (public repos only)
36
+ # - Attached to GitHub releases for transparency and compliance
37
+ # - Skipped if pyproject.toml doesn't exist
38
+ #
39
+ # 🚀 PyPI Publishing:
40
+ # - Skipped if no dist/ artifacts exist
41
+ # - Skipped if pyproject.toml contains "Private :: Do Not Upload"
42
+ # - Uses Trusted Publishing (OIDC) for PyPI (no stored credentials)
43
+ # - For custom feeds, use PYPI_REPOSITORY_URL and PYPI_TOKEN secrets
44
+ # - Adds PyPI/custom feed link to GitHub release notes
45
+ #
46
+ # 🔐 Security:
47
+ # - No PyPI credentials stored; relies on Trusted Publishing via GitHub OIDC
48
+ # - For custom feeds, PYPI_TOKEN secret is used with default username __token__
49
+ # - SLSA provenance attestations generated for build artifacts (public repos only)
50
+ # - SBOM attestations generated for supply chain transparency (public repos only)
51
+ #
52
+ # 📄 Requirements:
53
+ # - pyproject.toml with top-level version field (for Python packages)
54
+ # - Package registered on PyPI as Trusted Publisher (for PyPI publishing)
55
+ #
56
+ # ✅ To Trigger:
57
+ # Create and push a version tag:
58
+ # git tag v1.2.3
59
+ # git push origin v1.2.3
60
+ #
61
+ # 🎯 For repos without Python packages:
62
+ # The workflow gracefully skips Python-related steps if pyproject.toml doesn't exist.
63
+
64
+
65
+ name: (RHIZA) RELEASE
66
+
67
+ on:
68
+ push:
69
+ tags:
70
+ - 'v*'
71
+ workflow_call:
72
+ inputs:
73
+ tag:
74
+ description: 'Version tag to release (e.g. v1.2.3). Required when called via workflow_call.'
75
+ required: true
76
+ type: string
77
+ secrets:
78
+ GH_PAT:
79
+ required: false
80
+ UV_EXTRA_INDEX_URL:
81
+ required: false
82
+ PYPI_TOKEN:
83
+ required: false
84
+
85
+ # Queue runs instead of cancelling: a release or sync must never be
86
+ # interrupted mid-publish or mid-push.
87
+ concurrency:
88
+ group: ${{ github.workflow }}-${{ github.ref }}
89
+ cancel-in-progress: false
90
+
91
+ # Least-privilege default: every job below re-declares exactly the write
92
+ # scopes it needs (Scorecard Token-Permissions). Top-level stays read-only.
93
+ permissions:
94
+ contents: read
95
+
96
+ jobs:
97
+ tag:
98
+ name: Validate Tag
99
+ runs-on: ubuntu-latest
100
+ permissions:
101
+ contents: read # Validation only: reads tags and release state
102
+ outputs:
103
+ tag: ${{ steps.set_tag.outputs.tag }}
104
+ steps:
105
+ - name: Checkout Code
106
+ uses: actions/checkout@v6.1.0
107
+ with:
108
+ fetch-depth: 0
109
+
110
+ - name: Set Tag Variable
111
+ id: set_tag
112
+ run: |
113
+ if [[ -n "${{ inputs.tag }}" ]]; then
114
+ TAG="${{ inputs.tag }}"
115
+ else
116
+ TAG="${GITHUB_REF#refs/tags/}"
117
+ fi
118
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
119
+
120
+ - name: Validate Release
121
+ env:
122
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
123
+ TAG: ${{ steps.set_tag.outputs.tag }}
124
+ run: |
125
+ if gh release view "$TAG" >/dev/null 2>&1; then
126
+ DRAFT_STATUS=$(gh release view "$TAG" --json isDraft --jq '.isDraft')
127
+ if [ "$DRAFT_STATUS" != "true" ]; then
128
+ echo "::error::Release '$TAG' already exists and is not a draft. Please use a new tag."
129
+ exit 1
130
+ else
131
+ echo "::warning::Release '$TAG' exists as a draft. Will proceed to update it."
132
+ fi
133
+ fi
134
+
135
+ - name: Ensure the tagged commit is reachable from a branch
136
+ env:
137
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
138
+ TAG: ${{ steps.set_tag.outputs.tag }}
139
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
140
+ run: |
141
+ # Backstop for issue #1454: a release cut on a branch that is then
142
+ # squash-merged leaves the tag on the pre-squash commit, and the squash
143
+ # puts the same content on the default branch under a new SHA. The tag is
144
+ # then permanently orphaned — no branch contains it, `git describe` skips
145
+ # the release, and a git-cliff regeneration silently deletes that version's
146
+ # CHANGELOG section because it cannot place a boundary at an unreachable
147
+ # tag. Publishing from such a tag is never intended, so refuse it.
148
+ # The checkout above uses fetch-depth: 0; this fetch only adds the remote
149
+ # branch refs, which a tag-push checkout does not need otherwise.
150
+ git fetch --no-tags --quiet origin '+refs/heads/*:refs/remotes/origin/*'
151
+
152
+ if [ -z "$DEFAULT_BRANCH" ]; then
153
+ DEFAULT_BRANCH=$(gh repo view --json defaultBranchRef --jq '.defaultBranchRef.name')
154
+ fi
155
+ if ! git rev-parse --verify --quiet "refs/remotes/origin/$DEFAULT_BRANCH" >/dev/null; then
156
+ echo "::error::Cannot resolve the default branch 'origin/$DEFAULT_BRANCH' — refusing to release without a reachability check."
157
+ exit 1
158
+ fi
159
+
160
+ COMMIT=$(git rev-parse "$TAG^{commit}")
161
+ if git merge-base --is-ancestor "$COMMIT" "refs/remotes/origin/$DEFAULT_BRANCH"; then
162
+ echo "✅ $TAG ($COMMIT) is an ancestor of $DEFAULT_BRANCH"
163
+ exit 0
164
+ fi
165
+
166
+ # Reachable from some other branch: a maintenance/hotfix release. Legitimate,
167
+ # but a changelog regenerated from the default branch still cannot see it.
168
+ BRANCHES=$(git branch -r --contains "$COMMIT" --format='%(refname:short)')
169
+ if [ -n "$BRANCHES" ]; then
170
+ echo "::warning::Tag $TAG is not an ancestor of $DEFAULT_BRANCH; it is contained in: $(echo "$BRANCHES" | tr '\n' ' '). A CHANGELOG regenerated from $DEFAULT_BRANCH will not include this release."
171
+ exit 0
172
+ fi
173
+
174
+ echo "::error::Tag $TAG points at $COMMIT, which no branch contains. It is most likely a pre-squash commit from a squash-merged release branch: re-tag the merged commit on $DEFAULT_BRANCH and delete this tag (issue #1454)."
175
+ exit 1
176
+
177
+ - name: Install uv
178
+ uses: astral-sh/setup-uv@v7.6.0
179
+
180
+ - name: Ensure release version is newer than latest published
181
+ env:
182
+ TAG: ${{ steps.set_tag.outputs.tag }}
183
+ run: |
184
+ # Backstop for issue #1126: refuse a tag that is not strictly newer than the
185
+ # highest version already released. A diverged branch with a stale pyproject.toml
186
+ # can otherwise publish an older version (e.g. v0.3.3 after v0.4.0). The bump
187
+ # tooling enforces the same rule; this guards manually-pushed tags too.
188
+ git tag -l 'v*' | uv run --with packaging --no-project python3 -c '
189
+ import sys
190
+ from packaging.version import Version, InvalidVersion
191
+
192
+ new = sys.argv[1].lstrip("v")
193
+ try:
194
+ new_v = Version(new)
195
+ except InvalidVersion:
196
+ print(f"::error::Tag {sys.argv[1]} is not a valid version")
197
+ sys.exit(1)
198
+
199
+ latest = None
200
+ for line in sys.stdin:
201
+ tag = line.strip().lstrip("v")
202
+ if not tag:
203
+ continue
204
+ try:
205
+ candidate = Version(tag)
206
+ except InvalidVersion:
207
+ continue
208
+ if candidate == new_v:
209
+ continue
210
+ if latest is None or candidate > latest:
211
+ latest = candidate
212
+
213
+ if latest is not None and new_v <= latest:
214
+ print(
215
+ f"::error::Refusing to release v{new_v}: it is not newer than the latest "
216
+ f"released version v{latest} (issue #1126). Sync with the default branch and bump again."
217
+ )
218
+ sys.exit(1)
219
+ print(f"v{new_v} is newer than the latest released version v{latest}" if latest
220
+ else f"v{new_v} is the first release")
221
+ ' "$TAG"
222
+
223
+ build:
224
+ name: Build
225
+ runs-on: ubuntu-latest
226
+ needs: tag
227
+ permissions:
228
+ contents: read
229
+ id-token: write # OIDC for attestation signing
230
+ attestations: write # SLSA provenance + SBOM attestations (public repos only)
231
+ steps:
232
+ - name: Checkout Code
233
+ uses: actions/checkout@v6.1.0
234
+ with:
235
+ fetch-depth: 0
236
+
237
+ - name: Install uv
238
+ uses: astral-sh/setup-uv@v7.6.0
239
+ with:
240
+ version: "0.11.16"
241
+
242
+ - name: Configure git auth for private packages
243
+ uses: jebel-quant/actions/configure-git-auth@6d52725ca371d609489c5b50236965ad70bbe528 # v1
244
+ with:
245
+ token: ${{ secrets.GH_PAT }}
246
+
247
+ - name: Verify version matches tag
248
+ if: hashFiles('pyproject.toml') != ''
249
+ run: |
250
+ TAG_VERSION="${{ needs.tag.outputs.tag }}"
251
+ TAG_VERSION=${TAG_VERSION#v}
252
+ PROJECT_VERSION=$(uv version --short)
253
+
254
+ # Normalize tag version to PEP 440 format for comparison.
255
+ # Tags use semver format (e.g., 0.11.1-beta.1) while uv version --short
256
+ # returns PEP 440 normalized format (e.g., 0.11.1b1).
257
+ NORMALIZED_TAG=$(uv run --with packaging --no-project python3 -c "from packaging.version import Version; print(Version('$TAG_VERSION'))")
258
+
259
+ if [[ "$PROJECT_VERSION" != "$NORMALIZED_TAG" ]]; then
260
+ echo "::error::Version mismatch: pyproject.toml has '$PROJECT_VERSION' but tag is '$NORMALIZED_TAG' (from tag '$TAG_VERSION')"
261
+ exit 1
262
+ fi
263
+ echo "Version verified: $PROJECT_VERSION matches tag (normalized: $NORMALIZED_TAG)"
264
+
265
+ - name: Detect buildable Python package
266
+ id: buildable
267
+ run: |
268
+ if [[ -f pyproject.toml ]] && grep -q '^\[build-system\]' pyproject.toml; then
269
+ echo "buildable=true" >> "$GITHUB_OUTPUT"
270
+ else
271
+ echo "buildable=false" >> "$GITHUB_OUTPUT"
272
+ fi
273
+
274
+ - name: Build
275
+ if: steps.buildable.outputs.buildable == 'true'
276
+ run: |
277
+ printf "[INFO] Building package...\n"
278
+ uv build
279
+
280
+ - name: Install Python for SBOM generation
281
+ if: hashFiles('pyproject.toml') != ''
282
+ uses: actions/setup-python@v6.3.0
283
+ with:
284
+ python-version-file: .python-version
285
+
286
+ - name: Sync environment for SBOM generation
287
+ if: hashFiles('pyproject.toml') != ''
288
+ run: |
289
+ export UV_EXTRA_INDEX_URL="${{ secrets.UV_EXTRA_INDEX_URL }}"
290
+ uv sync --all-extras --all-groups --frozen
291
+
292
+ - name: Generate SBOM (CycloneDX)
293
+ if: hashFiles('pyproject.toml') != ''
294
+ run: |
295
+ printf "[INFO] Generating SBOM in CycloneDX format...\n"
296
+ # Note: uvx caches the tool environment, so the second call is fast
297
+ uvx --from 'cyclonedx-bom>=7.0.0' cyclonedx-py environment --pyproject pyproject.toml --of JSON -o sbom.cdx.json
298
+ uvx --from 'cyclonedx-bom>=7.0.0' cyclonedx-py environment --pyproject pyproject.toml --of XML -o sbom.cdx.xml
299
+ printf "[INFO] SBOM generation complete\n"
300
+ printf "Generated files:\n"
301
+ ls -lh sbom.cdx.*
302
+
303
+ - name: Attest SBOM
304
+ # Attest only the JSON format as it's the canonical machine-readable format.
305
+ # The XML format is provided for compatibility but doesn't need separate attestation.
306
+ id: attest-sbom
307
+ if: hashFiles('pyproject.toml') != '' && github.event.repository.private == false
308
+ uses: actions/attest@v4.2.2
309
+ with:
310
+ subject-path: sbom.cdx.json
311
+ sbom-path: sbom.cdx.json
312
+
313
+ - name: Stage SBOM attestation for the GitHub release
314
+ # Attach the SBOM's Sigstore attestation bundle to the release as a
315
+ # recognised signature asset (*.sigstore.json). Non-buildable repos
316
+ # (no [build-system], so no dist/*.intoto.jsonl provenance) would
317
+ # otherwise ship releases without any signature asset, which fails
318
+ # OpenSSF Scorecard's Signed-Releases check.
319
+ if: hashFiles('pyproject.toml') != '' && github.event.repository.private == false
320
+ run: cp "${{ steps.attest-sbom.outputs.bundle-path }}" sbom.cdx.json.sigstore.json
321
+
322
+ - name: Upload SBOM artifacts
323
+ if: hashFiles('pyproject.toml') != ''
324
+ uses: actions/upload-artifact@v7.0.1
325
+ with:
326
+ name: sbom
327
+ path: |
328
+ sbom.cdx.json
329
+ sbom.cdx.xml
330
+ sbom.cdx.json.sigstore.json
331
+
332
+ - name: Generate SLSA provenance attestations
333
+ id: provenance
334
+ if: steps.buildable.outputs.buildable == 'true' && github.event.repository.private == false
335
+ uses: actions/attest-build-provenance@v4.2.2
336
+ with:
337
+ subject-path: dist/*
338
+
339
+ - name: Stage provenance bundle for the GitHub release
340
+ # Attach the SLSA provenance bundle to the release as a recognised
341
+ # signature asset (*.intoto.jsonl) so consumers — and OpenSSF
342
+ # Scorecard's Signed-Releases check — can verify the artifacts.
343
+ if: steps.buildable.outputs.buildable == 'true' && github.event.repository.private == false
344
+ run: cp "${{ steps.provenance.outputs.bundle-path }}" dist/provenance.intoto.jsonl
345
+
346
+ - name: Upload dist artifact
347
+ if: steps.buildable.outputs.buildable == 'true'
348
+ uses: actions/upload-artifact@v7.0.1
349
+ with:
350
+ name: dist
351
+ path: dist
352
+
353
+ # Don't try to upload artifacts to GitHub Release at all
354
+ draft-release:
355
+ name: Draft GitHub Release
356
+ runs-on: ubuntu-latest
357
+ needs: [tag, build]
358
+ permissions:
359
+ contents: write # Needed to create the GitHub release and upload artifacts
360
+
361
+ steps:
362
+ - name: Checkout Code
363
+ uses: actions/checkout@v6.1.0
364
+ with:
365
+ fetch-depth: 0
366
+
367
+ - name: Install uv
368
+ uses: astral-sh/setup-uv@v7.6.0
369
+
370
+ - name: Generate release notes with git-cliff
371
+ run: uvx git-cliff --latest --output RELEASE_NOTES.md
372
+
373
+ - name: Download SBOM artifact
374
+ # Downloads sbom.cdx.json and sbom.cdx.xml into sbom/ directory
375
+ uses: actions/download-artifact@v8.0.1
376
+ with:
377
+ name: sbom
378
+ path: sbom
379
+ continue-on-error: true
380
+
381
+ - name: Download dist artifact
382
+ # Brings in provenance.intoto.jsonl (and the built distributions) staged
383
+ # by the build job, so the SLSA provenance ships as a release asset.
384
+ uses: actions/download-artifact@v8.0.1
385
+ with:
386
+ name: dist
387
+ path: dist
388
+ continue-on-error: true
389
+
390
+ - name: Create GitHub Release with artifacts
391
+ uses: ncipollo/release-action@v1.21.0
392
+ with:
393
+ tag: ${{ needs.tag.outputs.tag }}
394
+ name: ${{ needs.tag.outputs.tag }}
395
+ bodyFile: RELEASE_NOTES.md
396
+ draft: true
397
+ allowUpdates: true
398
+ artifacts: "sbom/*,dist/provenance.intoto.jsonl"
399
+
400
+ # Decide at step-level whether to publish
401
+ pypi:
402
+ name: Publish to PyPI
403
+ runs-on: ubuntu-latest
404
+ environment: release
405
+ needs: [tag, build, draft-release]
406
+ permissions:
407
+ contents: read
408
+ id-token: write # OIDC Trusted Publishing to PyPI (no stored credentials)
409
+ outputs:
410
+ should_publish: ${{ steps.check_dist.outputs.should_publish }}
411
+
412
+ steps:
413
+ - name: Checkout Code
414
+ uses: actions/checkout@v6.1.0
415
+ with:
416
+ fetch-depth: 0
417
+
418
+ - name: Download dist artifact
419
+ uses: actions/download-artifact@v8.0.1
420
+ with:
421
+ name: dist
422
+ path: dist
423
+ # Continue if dist folder is not found. Don't fail the job.
424
+ continue-on-error: true
425
+
426
+ - name: Check if dist contains artifacts and not marked as private
427
+ id: check_dist
428
+ run: |
429
+ if [[ ! -d dist ]]; then
430
+ echo "::warning::No folder dist/. Skipping PyPI publish."
431
+ echo "should_publish=false" >> "$GITHUB_OUTPUT"
432
+ else
433
+ if grep -R "Private :: Do Not Upload" pyproject.toml; then
434
+ echo "should_publish=false" >> "$GITHUB_OUTPUT"
435
+ else
436
+ echo "should_publish=true" >> "$GITHUB_OUTPUT"
437
+ fi
438
+ fi
439
+ cat "$GITHUB_OUTPUT"
440
+
441
+ - name: Remove non-distribution files before publish
442
+ # The dist artifact also carries the SLSA provenance bundle (staged for
443
+ # the GitHub release). twine/pypi-publish rejects it as an unknown
444
+ # distribution format, so strip it here before publishing.
445
+ if: ${{ steps.check_dist.outputs.should_publish == 'true' }}
446
+ run: rm -f dist/*.intoto.jsonl
447
+
448
+ # this should not take place, as "Private :: Do Not Upload" set in pyproject.toml
449
+ # repository-url and password only used for custom feeds, not for PyPI with OIDC
450
+ - name: Publish to PyPI
451
+ if: ${{ steps.check_dist.outputs.should_publish == 'true' }}
452
+ uses: pypa/gh-action-pypi-publish@v1.14.2
453
+ with:
454
+ packages-dir: dist/
455
+ skip-existing: true
456
+ repository-url: ${{ vars.PYPI_REPOSITORY_URL }}
457
+ password: ${{ secrets.PYPI_TOKEN }}
458
+
459
+
460
+ finalise-release:
461
+ name: Finalise Release
462
+ runs-on: ubuntu-latest
463
+ needs: [tag, pypi]
464
+ if: needs.pypi.result == 'success'
465
+ permissions:
466
+ contents: write # Needed to undraft/publish the GitHub release
467
+ steps:
468
+ - name: Checkout Code
469
+ uses: actions/checkout@v6.1.0
470
+ with:
471
+ fetch-depth: 0
472
+
473
+ - name: Install uv
474
+ uses: astral-sh/setup-uv@v7.6.0
475
+ with:
476
+ version: "0.11.16"
477
+
478
+ - name: "Sync the virtual environment for ${{ github.repository }}"
479
+ shell: bash
480
+ run: |
481
+ export UV_EXTRA_INDEX_URL="${{ secrets.uv_extra_index_url }}"
482
+ # will just use .python-version?
483
+ uv sync --all-extras --all-groups --frozen
484
+
485
+ - name: Set up Python
486
+ uses: actions/setup-python@v6.3.0
487
+
488
+ - name: Generate PyPI Link
489
+ id: pypi_link
490
+ if: needs.pypi.outputs.should_publish == 'true' && needs.pypi.result == 'success'
491
+ run: |
492
+ PACKAGE_NAME=$(uv run python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['name'])")
493
+ VERSION="${{ needs.tag.outputs.tag }}"
494
+ VERSION=${VERSION#v}
495
+
496
+ REPO_URL="${{ vars.PYPI_REPOSITORY_URL || '' }}"
497
+ if [ -z "$REPO_URL" ]; then
498
+ LINK="https://pypi.org/project/$PACKAGE_NAME/$VERSION/"
499
+ NAME="PyPI Package"
500
+ else
501
+ LINK="$REPO_URL"
502
+ NAME="Custom Feed Package"
503
+ fi
504
+
505
+ {
506
+ echo "message<<EOF"
507
+ echo "### $NAME"
508
+ echo ""
509
+ echo "[$PACKAGE_NAME]($LINK)"
510
+ echo "EOF"
511
+ } >> "$GITHUB_OUTPUT"
512
+
513
+ - name: Publish Release
514
+ env:
515
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
516
+ TAG: ${{ needs.tag.outputs.tag }}
517
+ PYPI_MSG: ${{ steps.pypi_link.outputs.message }}
518
+ run: |
519
+ # Get existing auto-generated release notes (gracefully handle missing release)
520
+ EXISTING=$(gh release view "$TAG" --json body --jq '.body // ""' 2>/dev/null || echo "")
521
+
522
+ # Append links to release body
523
+ {
524
+ printf '%s' "$EXISTING"
525
+ [ -n "$PYPI_MSG" ] && printf '\n\n%s' "$PYPI_MSG"
526
+ } > /tmp/notes.md
527
+
528
+ gh release edit "$TAG" --draft=false --notes-file /tmp/notes.md
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .coverage
9
+ htmlcov/
10
+ _tests/
11
+ local.mk
@@ -0,0 +1 @@
1
+ 3.11
@@ -0,0 +1,32 @@
1
+ # Changelog
2
+
3
+ ## [0.1.1] - 2026-08-18
4
+
5
+ ## [0.1.0] - unreleased
6
+
7
+ Initial extraction. Replaces `.rhiza/rhiza.mk` and `.rhiza/make.d/*.mk` (1023 synced
8
+ lines) with a pinned CLI, on the same principle as `pytest-rhiza` replacing
9
+ `.rhiza/tests`.
10
+
11
+ - `core` + `python-core` gate set: install, test, typecheck, security, deps, license,
12
+ docs-coverage, fmt, semgrep, rhiza-test, todos, clean, doctor, all
13
+ - `tests` extras: benchmark, hypothesis-test, stress, mutation
14
+ - `book` / `marimo`: book, serve, marimo, marimo-validate
15
+ - Not ported: `github.mk`'s seven `gh` wrappers (use `gh` directly), `install-uv`
16
+ (running under `uvx` means uv is already present)
17
+
18
+ ### Deliberate differences from the make layer
19
+
20
+ Four places where the port does not reproduce the recipe, each found by running the gates
21
+ against this package:
22
+
23
+ - **`security` passes `--ini .bandit` only when that file exists.** python.mk passes it
24
+ unconditionally, and bandit treats a missing ini as a usage error — so a project without
25
+ one got a red security gate reporting a configuration problem.
26
+ - **`doctor` treats GNU make as optional.** doctor.mk requires it, which was honest while
27
+ the task layer *was* make. Only the shim needs it now.
28
+ - **The shim declares `local.mk: ;`.** An included makefile is a target make tries to remake
29
+ first, and the catch-all routed that attempt to the CLI — so every invocation began with
30
+ `unknown task: local.mk`.
31
+ - **`shim` writes to stdout, not through rich.** Console output word-wraps and expands
32
+ tabs; a Makefile survives neither, so `rhiza-task shim > Makefile` produced a broken file.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Jebel Quant Research
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.