ferro-orm 0.2.1__tar.gz → 0.3.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 (125) hide show
  1. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/.github/PERMISSIONS.md +61 -32
  2. ferro_orm-0.3.1/.github/workflows/release.yml +552 -0
  3. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/.gitignore +1 -0
  4. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/CHANGELOG.md +82 -0
  5. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/Cargo.lock +1 -1
  6. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/Cargo.toml +1 -1
  7. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/PKG-INFO +3 -4
  8. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/README.md +2 -3
  9. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/TEST_RESULTS.md +4 -2
  10. ferro_orm-0.3.1/docs/api/fields.md +21 -0
  11. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/coming-soon.md +6 -2
  12. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/concepts/architecture.md +5 -3
  13. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/concepts/performance.md +7 -3
  14. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/getting-started/tutorial.md +13 -13
  15. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/guide/migrations.md +39 -7
  16. ferro_orm-0.3.1/docs/guide/models-and-fields.md +231 -0
  17. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/guide/queries.md +3 -3
  18. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/guide/relationships.md +13 -4
  19. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/howto/pagination.md +5 -3
  20. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/index.md +3 -3
  21. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/migration-sqlalchemy.md +9 -6
  22. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/pyproject.toml +6 -2
  23. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/ferro/__init__.py +2 -1
  24. ferro_orm-0.3.1/src/ferro/_annotation_utils.py +28 -0
  25. ferro_orm-0.3.1/src/ferro/_shadow_fk_types.py +137 -0
  26. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/ferro/base.py +55 -3
  27. ferro_orm-0.3.1/src/ferro/composite_uniques.py +98 -0
  28. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/ferro/fields.py +20 -1
  29. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/ferro/metaclass.py +22 -5
  30. ferro_orm-0.3.1/src/ferro/migrations/alembic.py +345 -0
  31. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/ferro/models.py +26 -2
  32. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/ferro/relations/__init__.py +14 -2
  33. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/schema.rs +51 -0
  34. ferro_orm-0.3.1/tests/test_alembic_autogenerate.py +190 -0
  35. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_alembic_bridge.py +42 -0
  36. ferro_orm-0.3.1/tests/test_alembic_nullability.py +270 -0
  37. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_alembic_type_mapping.py +3 -1
  38. ferro_orm-0.3.1/tests/test_composite_unique.py +324 -0
  39. ferro_orm-0.3.1/tests/test_field_wrapper.py +95 -0
  40. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_metaclass_internals.py +20 -1
  41. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_relationship_engine.py +2 -0
  42. ferro_orm-0.3.1/tests/test_shadow_fk_types.py +258 -0
  43. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/uv.lock +1 -1
  44. ferro_orm-0.2.1/.github/workflows/release.yml +0 -320
  45. ferro_orm-0.2.1/DOCS_COMPLETE.md +0 -226
  46. ferro_orm-0.2.1/DOCS_RESTRUCTURE_SUMMARY.md +0 -213
  47. ferro_orm-0.2.1/docs/api/fields.md +0 -21
  48. ferro_orm-0.2.1/docs/guide/models-and-fields.md +0 -184
  49. ferro_orm-0.2.1/src/ferro/migrations/alembic.py +0 -211
  50. ferro_orm-0.2.1/tests/test_alembic_autogenerate.py +0 -94
  51. ferro_orm-0.2.1/tests/test_field_wrapper.py +0 -47
  52. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  53. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  54. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/.github/PYPI_CHECKLIST.md +0 -0
  55. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/.github/PYPI_SETUP.md +0 -0
  56. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/.github/generated/wheels.generated.yml +0 -0
  57. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/.github/pull_request_template.md +0 -0
  58. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/.github/workflows/ci.yml +0 -0
  59. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/.github/workflows/packaging-smoke.yml +0 -0
  60. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/.github/workflows/publish-docs.yml +0 -0
  61. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/.github/workflows/publish.yml +0 -0
  62. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/.pre-commit-config.yaml +0 -0
  63. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/.python-version +0 -0
  64. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/CONTRIBUTING.md +0 -0
  65. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/LICENSE +0 -0
  66. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/api/model.md +0 -0
  67. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/api/query.md +0 -0
  68. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/api/relationships.md +0 -0
  69. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/api/transactions.md +0 -0
  70. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/api/utilities.md +0 -0
  71. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/changelog.md +0 -0
  72. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/concepts/identity-map.md +0 -0
  73. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/concepts/type-safety.md +0 -0
  74. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/contributing.md +0 -0
  75. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/faq.md +0 -0
  76. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/getting-started/installation.md +0 -0
  77. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/getting-started/next-steps.md +0 -0
  78. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/guide/database.md +0 -0
  79. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/guide/mutations.md +0 -0
  80. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/guide/transactions.md +0 -0
  81. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/howto/multiple-databases.md +0 -0
  82. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/howto/soft-deletes.md +0 -0
  83. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/howto/testing.md +0 -0
  84. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/howto/timestamps.md +0 -0
  85. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/stylesheets/extra.css +0 -0
  86. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/docs/why-ferro.md +0 -0
  87. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/justfile +0 -0
  88. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/mkdocs.yml +0 -0
  89. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/scripts/demo_queries.py +0 -0
  90. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/connection.rs +0 -0
  91. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/ferro/_core.pyi +0 -0
  92. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/ferro/migrations/__init__.py +0 -0
  93. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/ferro/py.typed +0 -0
  94. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/ferro/query/__init__.py +0 -0
  95. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/ferro/query/builder.py +0 -0
  96. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/ferro/query/nodes.py +0 -0
  97. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/ferro/relations/descriptors.py +0 -0
  98. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/ferro/state.py +0 -0
  99. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/lib.rs +0 -0
  100. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/operations.rs +0 -0
  101. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/query.rs +0 -0
  102. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/src/state.rs +0 -0
  103. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/conftest.py +0 -0
  104. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_aggregation.py +0 -0
  105. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_auto_migrate.py +0 -0
  106. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_bulk_update.py +0 -0
  107. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_connection.py +0 -0
  108. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_constraints.py +0 -0
  109. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_crud.py +0 -0
  110. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_deletion.py +0 -0
  111. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_docs_examples.py +0 -0
  112. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_documentation_features.py +0 -0
  113. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_helpers.py +0 -0
  114. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_hydration.py +0 -0
  115. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_metadata.py +0 -0
  116. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_models.py +0 -0
  117. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_one_to_one.py +0 -0
  118. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_query_builder.py +0 -0
  119. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_refresh.py +0 -0
  120. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_schema.py +0 -0
  121. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_schema_constraints.py +0 -0
  122. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_string_search.py +0 -0
  123. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_structural_types.py +0 -0
  124. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_temporal_types.py +0 -0
  125. {ferro_orm-0.2.1 → ferro_orm-0.3.1}/tests/test_transactions.py +0 -0
@@ -45,7 +45,37 @@ All workflows use explicit, fine-grained permissions (principle of least privile
45
45
 
46
46
  **Trigger:** Manual workflow dispatch
47
47
 
48
- **Permissions:**
48
+ **Job graph (top to bottom is execution order):**
49
+
50
+ ```
51
+ ci-gate packaging-smoke
52
+ \ /
53
+ prepare-release (computes bump, writes release.patch; no pushes)
54
+ / | | \
55
+ build-wheels build-sdist test-wheels verify-docs
56
+ \ | /
57
+ promote-pypi (PyPI Trusted Publishing; first irreversible step)
58
+ |
59
+ promote-git (commit bump, push main, tag, create GH Release + assets)
60
+ |
61
+ promote-docs (deploys MkDocs site pinned to the new tag)
62
+ ```
63
+
64
+ Nothing is pushed, tagged, or published until every validation job above
65
+ `promote-pypi` has succeeded. PyPI is published first so that if anything
66
+ fails, `main` is never advanced to an "orphan" version.
67
+
68
+ **Permissions (prepare-release):**
69
+ ```yaml
70
+ permissions:
71
+ contents: read
72
+ issues: read
73
+ pull-requests: read
74
+ ```
75
+
76
+ Prepare-release only needs read access because it just computes the bump and uploads a patch artifact — it does not push anything.
77
+
78
+ **Permissions (promote-git):**
49
79
  ```yaml
50
80
  permissions:
51
81
  contents: write
@@ -53,32 +83,23 @@ permissions:
53
83
  pull-requests: write
54
84
  ```
55
85
 
56
- **Why These Permissions:**
57
- - `contents: write` - Allows the workflow to:
58
- - Commit version bumps to pyproject.toml and Cargo.toml
86
+ - `contents: write` - Allows the job to:
87
+ - Commit version bumps to `pyproject.toml` and `Cargo.toml`
59
88
  - Push commits to the `main` branch
60
- - Create and push git tags (e.g., `v0.2.0`)
61
- - Create GitHub releases
89
+ - Create and push git tags (e.g., `v0.3.1`)
90
+ - Create GitHub Releases
62
91
 
63
- - `issues: write` - Allows the workflow to:
64
- - Update issue references in release notes
65
- - Close issues automatically via commit messages
66
- - Add labels or comments to issues
92
+ - `issues: write` / `pull-requests: write` - Allows semantic-release to
93
+ update issue/PR references in generated release notes.
67
94
 
68
- - `pull-requests: write` - Allows the workflow to:
69
- - Update PR references in release notes
70
- - Close PRs automatically via commit messages
71
- - Add labels or comments to PRs
72
-
73
- **What It Does:**
74
- - Analyzes conventional commits
75
- - Determines next version
76
- - Updates version in both Python and Rust files
77
- - Finalizes CHANGELOG.md
78
- - Creates git tag
79
- - Creates GitHub release
80
- - Triggers publish workflow
81
- - Reports whether release commits include `CHANGELOG.md`
95
+ **What the release flow does:**
96
+ - Analyzes conventional commits since the latest tag (fetched explicitly via `fetch-tags: true`)
97
+ - Fails fast if the computed version collides with an existing tag (indicates a tag-fetch bug)
98
+ - Bumps version in `pyproject.toml` and `Cargo.toml` and finalizes `CHANGELOG.md`
99
+ - Validates the bumped tree across wheel builds, sdist, install smoke tests, and `mkdocs build`
100
+ - Publishes to PyPI before touching `main`
101
+ - Commits bump to `main`, creates the tag, creates the GitHub Release with generated notes, attaches wheels + sdist
102
+ - Deploys the MkDocs site pinned to the new tag
82
103
 
83
104
  **Required Secrets:**
84
105
  - `RELEASE_DEPLOY_KEY` (private SSH key for a write-enabled deploy key)
@@ -88,15 +109,15 @@ permissions:
88
109
 
89
110
  ### 2. Build & Publish (jobs in `release.yml`)
90
111
 
91
- **Trigger:** Part of `release.yml` after the release job (no reusable workflow).
112
+ **Trigger:** Part of `release.yml` all build and publish jobs live in the same workflow file.
92
113
 
93
114
  Build, test, and publish jobs are defined directly in `release.yml` so PyPI Trusted Publishing receives a token with `workflow_ref: release.yml` (reusable workflows are not supported by PyPI).
94
115
 
95
116
  **Permissions:**
96
117
 
97
- **For build-wheels, build-sdist, test-wheels:** (default - read-only)
118
+ **For build-wheels, build-sdist, test-wheels, verify-docs:** (default - read-only)
98
119
 
99
- **For publish-pypi job:**
120
+ **For promote-pypi job:**
100
121
  ```yaml
101
122
  permissions:
102
123
  id-token: write
@@ -106,10 +127,10 @@ permissions:
106
127
  - `id-token: write` - Allows the job to request an OIDC token and authenticate with PyPI using Trusted Publishing.
107
128
 
108
129
  **What It Does:**
109
- - Builds wheels for multiple platforms from the release tag
110
- - Builds source distribution
111
- - Tests built packages
112
- - Publishes to PyPI using OIDC authentication
130
+ - Builds wheels for multiple platforms against the patched (bumped) tree
131
+ - Builds source distribution against the patched tree
132
+ - Installs wheels and runs a smoke import + in-memory SQLite check
133
+ - `promote-pypi` publishes the validated wheels + sdist to PyPI via OIDC
113
134
 
114
135
  ---
115
136
 
@@ -268,10 +289,18 @@ Permission to manage GitHub Pages deployments:
268
289
  **Cause:** Missing `id-token: write` permission
269
290
 
270
291
  **Solution:**
271
- - Ensure `publish-pypi` job has `id-token: write`
292
+ - Ensure `promote-pypi` job has `id-token: write`
272
293
  - Verify PyPI trusted publisher is configured correctly
273
294
  - Check environment name matches (`pypi`)
274
295
 
296
+ ### Release says "0.3.0 has already been released!" for a version that was already shipped
297
+
298
+ **Cause:** `actions/checkout@v4` runs `git fetch --no-tags` by default, even with `fetch-depth: 0`. Without tags, `semantic-release` picks an older baseline tag, computes a minor/patch bump, and lands on the same version number as a prior release.
299
+
300
+ **Solution:**
301
+ - The `prepare-release` job sets `fetch-tags: true` on its checkout step and then runs a `git describe` vs `semantic-release --print-last-released-tag` comparison as a guard. Both must be present.
302
+ - If this check fails in a run, confirm no one has removed `fetch-tags: true` from the checkout step.
303
+
275
304
  ---
276
305
 
277
306
  ## Verification
@@ -0,0 +1,552 @@
1
+ name: Release
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ inputs:
6
+ prerelease:
7
+ description: 'Create a pre-release'
8
+ required: false
9
+ type: boolean
10
+ default: false
11
+
12
+ concurrency:
13
+ group: release-${{ github.workflow }}-${{ github.ref }}
14
+ cancel-in-progress: true
15
+
16
+ jobs:
17
+ ci-gate:
18
+ name: CI Gate
19
+ uses: ./.github/workflows/ci.yml
20
+
21
+ packaging-smoke:
22
+ name: Packaging Smoke Gate
23
+ uses: ./.github/workflows/packaging-smoke.yml
24
+
25
+ # Bump versions and changelog locally only; export a patch so every later job
26
+ # validates the exact tree that would be released. Nothing is pushed until
27
+ # promote-* jobs run, after wheels, sdist, tests, and docs all pass.
28
+ prepare-release:
29
+ name: Prepare release (no push)
30
+ needs: [ci-gate, packaging-smoke]
31
+ runs-on: ubuntu-latest
32
+ outputs:
33
+ release_created: ${{ steps.verify_release.outputs.release_created }}
34
+ release_base_sha: ${{ steps.base_sha.outputs.release_base_sha }}
35
+ release_tag: ${{ steps.verify_release.outputs.release_tag }}
36
+ release_version: ${{ steps.verify_release.outputs.release_version }}
37
+ permissions:
38
+ contents: read
39
+ issues: read
40
+ pull-requests: read
41
+
42
+ steps:
43
+ - name: Record workflow base SHA
44
+ id: base_sha
45
+ run: echo "release_base_sha=${{ github.sha }}" >> "$GITHUB_OUTPUT"
46
+
47
+ - name: Checkout repository
48
+ uses: actions/checkout@v4
49
+ with:
50
+ fetch-depth: 0
51
+ # actions/checkout@v4 passes --no-tags by default, even with
52
+ # fetch-depth: 0. semantic-release needs tags to pick the right
53
+ # baseline version; without them it may bump from an older tag and
54
+ # land on a version that has already been released.
55
+ fetch-tags: true
56
+
57
+ - name: Set up Python
58
+ uses: actions/setup-python@v5
59
+ with:
60
+ python-version: '3.13'
61
+
62
+ - name: Install UV
63
+ uses: astral-sh/setup-uv@v5
64
+ with:
65
+ enable-cache: true
66
+
67
+ - name: Install dependencies
68
+ run: |
69
+ uv sync --only-group release --no-install-project --python 3.13
70
+
71
+ # Fail fast if the checkout did not materialize tags. Without this guard
72
+ # semantic-release silently picks an older baseline and can compute an
73
+ # already-released version (see PSR note: "No release will be made, X has
74
+ # already been released!").
75
+ - name: Verify tag visibility
76
+ env:
77
+ GH_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
78
+ run: |
79
+ set -euo pipefail
80
+ GIT_LATEST_TAG="$(git describe --tags --abbrev=0 2>/dev/null || echo '')"
81
+ PSR_LATEST_TAG="$(uv run semantic-release version --print-last-released-tag 2>/dev/null | tail -n1 || echo '')"
82
+
83
+ echo "git describe --tags --abbrev=0 -> '${GIT_LATEST_TAG}'"
84
+ echo "semantic-release --print-last-released-tag -> '${PSR_LATEST_TAG}'"
85
+
86
+ if [[ -n "${GIT_LATEST_TAG}" && "${GIT_LATEST_TAG}" != "${PSR_LATEST_TAG}" ]]; then
87
+ echo "ERROR: git and semantic-release disagree on the latest tag."
88
+ echo "This usually means actions/checkout did not fetch tags."
89
+ echo "Confirm 'fetch-tags: true' on the checkout step."
90
+ exit 1
91
+ fi
92
+
93
+ - name: Run semantic-release (local bump only)
94
+ env:
95
+ GH_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
96
+ run: |
97
+ PRERELEASE_FLAG=""
98
+ if [ "${{ github.event.inputs.prerelease }}" == "true" ]; then
99
+ PRERELEASE_FLAG="--prerelease"
100
+ fi
101
+
102
+ # Writes version files + CHANGELOG only; no commit, push, tag, or VCS
103
+ # release. --skip-build defers packaging to validation jobs.
104
+ uv run semantic-release version $PRERELEASE_FLAG \
105
+ --no-vcs-release --no-tag --no-commit --no-push --skip-build
106
+
107
+ - name: Verify release candidate and write patch
108
+ id: verify_release
109
+ shell: bash
110
+ run: |
111
+ set -euo pipefail
112
+ # Normalize to a single index-vs-HEAD patch (stable for git apply on runners).
113
+ git add -A
114
+ git diff --cached HEAD > release.patch
115
+
116
+ if [[ ! -s release.patch ]]; then
117
+ echo "No semantic version bump in this run (index matches HEAD)."
118
+ echo "release_created=false" >> "$GITHUB_OUTPUT"
119
+ echo "changelog_validated=skipped" >> "$GITHUB_OUTPUT"
120
+ echo "release_tag=" >> "$GITHUB_OUTPUT"
121
+ echo "release_version=" >> "$GITHUB_OUTPUT"
122
+ exit 0
123
+ fi
124
+
125
+ if ! grep -qE '^diff --git a/CHANGELOG\.md b/CHANGELOG\.md' release.patch; then
126
+ echo "WARNING: expected CHANGELOG.md to change for a release bump."
127
+ echo "release_created=false" >> "$GITHUB_OUTPUT"
128
+ echo "changelog_validated=missing" >> "$GITHUB_OUTPUT"
129
+ echo "release_tag=" >> "$GITHUB_OUTPUT"
130
+ echo "release_version=" >> "$GITHUB_OUTPUT"
131
+ exit 0
132
+ fi
133
+
134
+ VERSION="$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])")"
135
+ TAG="v${VERSION}"
136
+
137
+ # Final guard: if the tag already exists on the remote, bail before
138
+ # any irreversible promote-* step runs (PyPI, main push, GH Release).
139
+ if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then
140
+ echo "ERROR: tag ${TAG} already exists on the remote."
141
+ echo "This means semantic-release computed a version that is already released."
142
+ exit 1
143
+ fi
144
+
145
+ echo "release_created=true" >> "$GITHUB_OUTPUT"
146
+ echo "changelog_validated=passed" >> "$GITHUB_OUTPUT"
147
+ echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT"
148
+ echo "release_version=${VERSION}" >> "$GITHUB_OUTPUT"
149
+ echo "Prepared ${TAG} as patch against ${{ github.sha }}"
150
+
151
+ - name: Upload release patch
152
+ if: steps.verify_release.outputs.release_created == 'true'
153
+ uses: actions/upload-artifact@v4
154
+ with:
155
+ name: release-patch
156
+ path: release.patch
157
+
158
+ - name: Release summary
159
+ if: always()
160
+ shell: bash
161
+ run: |
162
+ {
163
+ echo "## Release workflow summary"
164
+ echo ""
165
+ echo "- Trigger: \`${{ github.event_name }}\`"
166
+ echo "- Base ref: \`${{ github.ref_name }}\` @ \`${{ github.sha }}\`"
167
+ echo "- Release candidate prepared: \`${{ steps.verify_release.outputs.release_created || 'unknown' }}\`"
168
+ echo "- Changelog validation: \`${{ steps.verify_release.outputs.changelog_validated || 'unknown' }}\`"
169
+ echo "- Tag if published: \`${{ steps.verify_release.outputs.release_tag || 'n/a' }}\`"
170
+ } >> "$GITHUB_STEP_SUMMARY"
171
+
172
+ # Build and publish jobs are inlined here (not in a reusable workflow) so that
173
+ # PyPI Trusted Publishing sees workflow_ref = release.yml. Reusable workflows
174
+ # are not supported by PyPI Trusted Publishing.
175
+ build-wheels:
176
+ name: Build wheels (${{ matrix.platform.name }})
177
+ needs: [prepare-release]
178
+ if: needs.prepare-release.outputs.release_created == 'true'
179
+ runs-on: ${{ matrix.platform.runner }}
180
+ strategy:
181
+ fail-fast: false
182
+ matrix:
183
+ platform:
184
+ - name: linux-x86_64
185
+ runner: ubuntu-latest
186
+ target: x86_64
187
+ manylinux: auto
188
+ - name: linux-aarch64
189
+ runner: ubuntu-latest
190
+ target: aarch64
191
+ manylinux: auto
192
+ - name: macos-aarch64
193
+ runner: macos-latest
194
+ target: aarch64
195
+ manylinux: ''
196
+ - name: windows-x64
197
+ runner: windows-latest
198
+ target: x64
199
+ manylinux: ''
200
+
201
+ steps:
202
+ - name: Disable Git CRLF conversion before checkout
203
+ shell: bash
204
+ run: git config --global core.autocrlf false
205
+ - name: Checkout repository
206
+ uses: actions/checkout@v4
207
+ with:
208
+ ref: ${{ needs.prepare-release.outputs.release_base_sha }}
209
+
210
+ - name: Download release patch
211
+ uses: actions/download-artifact@v4
212
+ with:
213
+ name: release-patch
214
+ path: release-patch
215
+
216
+ - name: Apply release patch
217
+ shell: bash
218
+ run: |
219
+ git config core.autocrlf false
220
+ git apply release-patch/release.patch
221
+ rm -rf release-patch
222
+
223
+ - name: Set up Python
224
+ uses: actions/setup-python@v5
225
+ with:
226
+ python-version: '3.13'
227
+
228
+ - name: Build wheels
229
+ uses: PyO3/maturin-action@v1.48.0
230
+ with:
231
+ target: ${{ matrix.platform.target }}
232
+ args: --release --out dist --find-interpreter
233
+ manylinux: ${{ matrix.platform.manylinux }}
234
+ sccache: 'true'
235
+
236
+ - name: Upload wheels
237
+ uses: actions/upload-artifact@v4
238
+ with:
239
+ name: wheels-${{ matrix.platform.name }}
240
+ path: dist/*.whl
241
+
242
+ build-sdist:
243
+ name: Build sdist
244
+ needs: [prepare-release]
245
+ if: needs.prepare-release.outputs.release_created == 'true'
246
+ runs-on: ubuntu-latest
247
+ steps:
248
+ - name: Disable Git CRLF conversion before checkout
249
+ shell: bash
250
+ run: git config --global core.autocrlf false
251
+ - name: Checkout repository
252
+ uses: actions/checkout@v4
253
+ with:
254
+ ref: ${{ needs.prepare-release.outputs.release_base_sha }}
255
+
256
+ - name: Download release patch
257
+ uses: actions/download-artifact@v4
258
+ with:
259
+ name: release-patch
260
+ path: release-patch
261
+
262
+ - name: Apply release patch
263
+ shell: bash
264
+ run: |
265
+ git config core.autocrlf false
266
+ git apply release-patch/release.patch
267
+ rm -rf release-patch
268
+
269
+ - name: Build sdist
270
+ uses: PyO3/maturin-action@v1.48.0
271
+ with:
272
+ command: sdist
273
+ args: --out dist
274
+
275
+ - name: Upload sdist
276
+ uses: actions/upload-artifact@v4
277
+ with:
278
+ name: sdist
279
+ path: dist/*.tar.gz
280
+
281
+ test-wheels:
282
+ name: Test wheels (${{ matrix.os }})
283
+ needs: [prepare-release, build-wheels]
284
+ if: needs.prepare-release.outputs.release_created == 'true'
285
+ runs-on: ${{ matrix.os }}
286
+ strategy:
287
+ fail-fast: false
288
+ matrix:
289
+ os: [ubuntu-latest, windows-latest, macos-latest]
290
+ steps:
291
+ - name: Disable Git CRLF conversion before checkout
292
+ shell: bash
293
+ run: git config --global core.autocrlf false
294
+ - name: Checkout repository
295
+ uses: actions/checkout@v4
296
+ with:
297
+ ref: ${{ needs.prepare-release.outputs.release_base_sha }}
298
+
299
+ - name: Download release patch
300
+ uses: actions/download-artifact@v4
301
+ with:
302
+ name: release-patch
303
+ path: release-patch
304
+
305
+ - name: Apply release patch
306
+ shell: bash
307
+ run: |
308
+ git config core.autocrlf false
309
+ git apply release-patch/release.patch
310
+ rm -rf release-patch
311
+
312
+ - name: Set up Python
313
+ uses: actions/setup-python@v5
314
+ with:
315
+ python-version: '3.13'
316
+
317
+ - name: Download wheels
318
+ uses: actions/download-artifact@v4
319
+ with:
320
+ path: dist
321
+ pattern: wheels-*
322
+ merge-multiple: true
323
+
324
+ - name: Install wheel
325
+ shell: bash
326
+ run: |
327
+ python -m pip install --upgrade pip
328
+ python -m pip install --find-links dist ferro-orm
329
+
330
+ - name: Test import
331
+ shell: bash
332
+ run: |
333
+ python -c "import ferro; print('Ferro imported successfully')"
334
+
335
+ - name: Run basic smoke test
336
+ shell: bash
337
+ run: |
338
+ python -c "
339
+ import asyncio
340
+ from ferro import Model, FerroField, connect
341
+ from typing import Annotated
342
+
343
+ class TestModel(Model):
344
+ id: Annotated[int, FerroField(primary_key=True)]
345
+ name: str
346
+
347
+ async def test():
348
+ await connect('sqlite::memory:')
349
+ print('Connection test passed')
350
+
351
+ asyncio.run(test())
352
+ "
353
+
354
+ verify-docs:
355
+ name: Verify documentation build
356
+ needs: [prepare-release]
357
+ if: needs.prepare-release.outputs.release_created == 'true'
358
+ runs-on: ubuntu-latest
359
+ steps:
360
+ - name: Disable Git CRLF conversion before checkout
361
+ shell: bash
362
+ run: git config --global core.autocrlf false
363
+ - name: Checkout repository
364
+ uses: actions/checkout@v4
365
+ with:
366
+ ref: ${{ needs.prepare-release.outputs.release_base_sha }}
367
+
368
+ - name: Download release patch
369
+ uses: actions/download-artifact@v4
370
+ with:
371
+ name: release-patch
372
+ path: release-patch
373
+
374
+ - name: Apply release patch
375
+ shell: bash
376
+ run: |
377
+ git config core.autocrlf false
378
+ git apply release-patch/release.patch
379
+ rm -rf release-patch
380
+
381
+ - name: Set up Python
382
+ uses: actions/setup-python@v5
383
+ with:
384
+ python-version: '3.13'
385
+
386
+ - name: Install UV
387
+ uses: astral-sh/setup-uv@v5
388
+ with:
389
+ enable-cache: true
390
+
391
+ - name: Install documentation dependencies
392
+ run: |
393
+ uv sync --only-group docs --no-install-project --python 3.13
394
+
395
+ - name: Build MkDocs site
396
+ run: |
397
+ uv run --no-sync mkdocs build
398
+
399
+ # ---------------------------------------------------------------------------
400
+ # Promote phase: every validation above has passed. Irreversible steps now
401
+ # run in a strict order:
402
+ # promote-pypi -> publishes to PyPI (first irreversible action)
403
+ # promote-git -> commits bump, pushes main, creates tag, creates GH Release
404
+ # promote-docs -> deploys MkDocs site pinned to the new tag
405
+ # If promote-pypi fails, main is untouched and the run can be re-triggered.
406
+ # ---------------------------------------------------------------------------
407
+
408
+ promote-pypi:
409
+ name: Publish to PyPI
410
+ needs: [prepare-release, build-wheels, build-sdist, test-wheels, verify-docs]
411
+ if: needs.prepare-release.outputs.release_created == 'true'
412
+ runs-on: ubuntu-latest
413
+ environment:
414
+ name: pypi
415
+ url: https://pypi.org/p/ferro-orm
416
+ permissions:
417
+ id-token: write
418
+ steps:
419
+ - name: Download sdist
420
+ uses: actions/download-artifact@v4
421
+ with:
422
+ name: sdist
423
+ path: dist
424
+
425
+ - name: Download wheels
426
+ uses: actions/download-artifact@v4
427
+ with:
428
+ pattern: wheels-*
429
+ path: dist
430
+ merge-multiple: true
431
+
432
+ - name: Publish to PyPI
433
+ uses: pypa/gh-action-pypi-publish@release/v1
434
+ with:
435
+ skip-existing: true
436
+ verbose: true
437
+
438
+ promote-git:
439
+ name: Commit, tag, and create GitHub Release
440
+ needs: [prepare-release, promote-pypi]
441
+ if: needs.prepare-release.outputs.release_created == 'true'
442
+ runs-on: ubuntu-latest
443
+ permissions:
444
+ contents: write
445
+ issues: write
446
+ pull-requests: write
447
+ steps:
448
+ - name: Disable Git CRLF conversion before checkout
449
+ shell: bash
450
+ run: git config --global core.autocrlf false
451
+
452
+ - name: Checkout repository
453
+ uses: actions/checkout@v4
454
+ with:
455
+ ref: ${{ needs.prepare-release.outputs.release_base_sha }}
456
+ fetch-depth: 0
457
+ fetch-tags: true
458
+ ssh-key: ${{ secrets.RELEASE_DEPLOY_KEY }}
459
+ # Required so the deploy key stays loaded for git push below.
460
+ persist-credentials: true
461
+
462
+ - name: Download release patch
463
+ uses: actions/download-artifact@v4
464
+ with:
465
+ name: release-patch
466
+ path: release-patch
467
+
468
+ - name: Apply release patch and push release commit
469
+ env:
470
+ VERSION: ${{ needs.prepare-release.outputs.release_version }}
471
+ TAG: ${{ needs.prepare-release.outputs.release_tag }}
472
+ run: |
473
+ set -euo pipefail
474
+ git config core.autocrlf false
475
+ git apply release-patch/release.patch
476
+ rm -rf release-patch
477
+
478
+ git config user.email "github-actions[bot]@users.noreply.github.com"
479
+ git config user.name "github-actions[bot]"
480
+ git remote set-url origin "git@github.com:${{ github.repository }}.git"
481
+
482
+ # Re-check remote (prepare-release already checked, but the window
483
+ # between jobs is non-zero).
484
+ if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q .; then
485
+ echo "ERROR: tag ${TAG} appeared on the remote between jobs."
486
+ exit 1
487
+ fi
488
+
489
+ git add -A
490
+ git commit --author="semantic-release <semantic-release@github.com>" \
491
+ -m "chore(release): ${VERSION}" \
492
+ -m "Automatically generated by python-semantic-release"
493
+ git push origin "HEAD:refs/heads/${{ github.ref_name }}"
494
+
495
+ git tag -a "${TAG}" -m "Release ${TAG}"
496
+ git push origin "refs/tags/${TAG}"
497
+
498
+ # semantic-release matches branch config (e.g. branches.main); detached HEAD fails with
499
+ # "Detached HEAD state cannot match any release groups".
500
+ - name: Attach HEAD to branch for semantic-release
501
+ run: git switch -C "${{ github.ref_name }}"
502
+
503
+ - name: Set up Python
504
+ uses: actions/setup-python@v5
505
+ with:
506
+ python-version: '3.13'
507
+
508
+ - name: Install UV
509
+ uses: astral-sh/setup-uv@v5
510
+ with:
511
+ enable-cache: true
512
+
513
+ - name: Install dependencies
514
+ run: |
515
+ uv sync --only-group release --no-install-project --python 3.13
516
+
517
+ # semantic-release publish only uploads assets to an existing GitHub
518
+ # Release. Create the release with generated notes first, then attach
519
+ # wheels + sdist from the validation jobs.
520
+ - name: Download sdist for GitHub Release assets
521
+ uses: actions/download-artifact@v4
522
+ with:
523
+ name: sdist
524
+ path: dist
525
+
526
+ - name: Download wheels for GitHub Release assets
527
+ uses: actions/download-artifact@v4
528
+ with:
529
+ pattern: wheels-*
530
+ path: dist
531
+ merge-multiple: true
532
+
533
+ - name: Create GitHub Release and upload assets
534
+ env:
535
+ GH_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
536
+ run: |
537
+ set -euo pipefail
538
+ TAG="${{ needs.prepare-release.outputs.release_tag }}"
539
+ uv run semantic-release changelog --post-to-release-tag "$TAG"
540
+ uv run semantic-release publish --tag "$TAG"
541
+
542
+ promote-docs:
543
+ name: Deploy Documentation
544
+ needs: [prepare-release, promote-git]
545
+ if: needs.prepare-release.outputs.release_created == 'true'
546
+ uses: ./.github/workflows/publish-docs.yml
547
+ permissions:
548
+ contents: read
549
+ pages: write
550
+ id-token: write
551
+ with:
552
+ ref: ${{ needs.prepare-release.outputs.release_tag }}
@@ -243,3 +243,4 @@ playground.ipynb
243
243
  IMPLEMENTATION.md
244
244
  Cargo.lock
245
245
  demo.db
246
+ docs/superpowers