1+ # Builds and publishes open-mle package to PyPI.
2+ #
3+ # Manually triggered workflow for releasing the open-mle CLI package.
4+ #
5+ # Handles version bumping, building, and publishing to PyPI with authentication.
6+
7+ name : " 🚀 Package Release"
8+ run-name : " Release open-mle v${{ needs.build.outputs.version }}"
9+
10+ on :
11+ workflow_dispatch :
12+ inputs :
13+ dangerous-nonmaster-release :
14+ required : false
15+ type : boolean
16+ default : false
17+ description : " Release from a non-master branch (danger!) - Only use for hotfixes"
18+
19+ env :
20+ PYTHON_VERSION : " 3.12"
21+ UV_FROZEN : " true"
22+ UV_NO_SYNC : " true"
23+ WORKING_DIR : " libs/openmle-cli"
24+
25+ permissions :
26+ contents : write # Required for creating GitHub releases
27+
28+ jobs :
29+ # Build the distribution package and extract version info
30+ # Runs in isolated environment with minimal permissions for security
31+ build :
32+ if : github.ref == 'refs/heads/master' || inputs.dangerous-nonmaster-release
33+ runs-on : ubuntu-latest
34+ permissions :
35+ contents : read
36+
37+ outputs :
38+ pkg-name : ${{ steps.check-version.outputs.pkg-name }}
39+ version : ${{ steps.check-version.outputs.version }}
40+
41+ steps :
42+ - uses : actions/checkout@v5
43+
44+ - name : Set up Python + uv
45+ uses : " ./.github/actions/uv_setup"
46+ with :
47+ python-version : ${{ env.PYTHON_VERSION }}
48+
49+ # We want to keep this build stage *separate* from the release stage,
50+ # so that there's no sharing of permissions between them.
51+ # (Release stage has trusted publishing and GitHub repo contents write access)
52+ #
53+ # Otherwise, a malicious `build` step (e.g. via a compromised dependency)
54+ # could get access to our GitHub or PyPI credentials.
55+ #
56+ # Per the trusted publishing GitHub Action:
57+ # > It is strongly advised to separate jobs for building [...]
58+ # > from the publish job.
59+ # https://github.com/pypa/gh-action-pypi-publish#non-goals
60+ - name : Build project for distribution
61+ run : uv build
62+ working-directory : ${{ env.WORKING_DIR }}
63+
64+ - name : Upload build
65+ uses : actions/upload-artifact@v5
66+ with :
67+ name : dist
68+ path : ${{ env.WORKING_DIR }}/dist/
69+
70+ - name : Check version
71+ id : check-version
72+ shell : python
73+ working-directory : ${{ env.WORKING_DIR }}
74+ run : |
75+ import os
76+ import tomllib
77+ with open("pyproject.toml", "rb") as f:
78+ data = tomllib.load(f)
79+ pkg_name = data["project"]["name"]
80+ version = data["project"]["version"]
81+ with open(os.environ["GITHUB_OUTPUT"], "a") as f:
82+ f.write(f"pkg-name={pkg_name}\n")
83+ f.write(f"version={version}\n")
84+
85+ release-notes :
86+ needs :
87+ - build
88+ runs-on : ubuntu-latest
89+ permissions :
90+ contents : read
91+ outputs :
92+ release-body : ${{ steps.generate-release-body.outputs.release-body }}
93+ tag : ${{ steps.check-tags.outputs.tag }}
94+ prev-tag : ${{ steps.check-tags.outputs.prev-tag }}
95+ steps :
96+ - uses : actions/checkout@v5
97+ with :
98+ path : open-mle
99+ sparse-checkout : |
100+ ${{ env.WORKING_DIR }}
101+ ref : ${{ github.ref }}
102+ fetch-depth : 0 # this fetches entire commit history
103+
104+ - name : Check tags
105+ id : check-tags
106+ shell : bash
107+ working-directory : open-mle/${{ env.WORKING_DIR }}
108+ env :
109+ PKG_NAME : ${{ needs.build.outputs.pkg-name }}
110+ VERSION : ${{ needs.build.outputs.version }}
111+ run : |
112+ # Handle regular versions and pre-release versions differently
113+ if [[ "$VERSION" == *"-"* ]]; then
114+ # This is a pre-release version (contains a hyphen)
115+ BASE_VERSION=${VERSION%%-*}
116+ REGEX="^$PKG_NAME==$BASE_VERSION\$"
117+ PREV_TAG=$(git tag --sort=-creatordate | (grep -P "$REGEX" || true) | head -1)
118+
119+ if [ -z "$PREV_TAG" ]; then
120+ REGEX="^$PKG_NAME==\\d+\\.\\d+\\.\\d+\$"
121+ PREV_TAG=$(git tag --sort=-creatordate | (grep -P "$REGEX" || true) | head -1)
122+ fi
123+ else
124+ # Regular version handling
125+ PREV_TAG="$PKG_NAME==${VERSION%.*}.$(( ${VERSION##*.} - 1 ))"; [[ "${VERSION##*.}" -eq 0 ]] && PREV_TAG=""
126+
127+ if [ -z "$PREV_TAG" ]; then
128+ REGEX="^$PKG_NAME==\\d+\\.\\d+\\.\\d+\$"
129+ PREV_TAG=$(git tag --sort=-creatordate | (grep -P "$REGEX" || true) | head -1)
130+ fi
131+ fi
132+
133+ if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "$PKG_NAME==0.0.0" ]; then
134+ echo "No previous tag found - first release"
135+ else
136+ GIT_TAG_RESULT=$(git tag -l "$PREV_TAG")
137+ if [ -z "$GIT_TAG_RESULT" ]; then
138+ echo "Previous tag $PREV_TAG not found in git repo"
139+ exit 1
140+ fi
141+ fi
142+
143+ TAG="${PKG_NAME}==${VERSION}"
144+ if [ "$TAG" == "$PREV_TAG" ]; then
145+ echo "No new version to release"
146+ exit 1
147+ fi
148+ echo tag="$TAG" >> $GITHUB_OUTPUT
149+ echo prev-tag="$PREV_TAG" >> $GITHUB_OUTPUT
150+
151+ - name : Generate release body
152+ id : generate-release-body
153+ working-directory : open-mle
154+ env :
155+ PKG_NAME : ${{ needs.build.outputs.pkg-name }}
156+ TAG : ${{ steps.check-tags.outputs.tag }}
157+ PREV_TAG : ${{ steps.check-tags.outputs.prev-tag }}
158+ WORKING_DIR : ${{ env.WORKING_DIR }}
159+ run : |
160+ PREAMBLE="Changes since $PREV_TAG"
161+ if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "$PKG_NAME==0.0.0" ]; then
162+ PREAMBLE="Initial release"
163+ PREV_TAG=$(git rev-list --max-parents=0 HEAD)
164+ fi
165+ {
166+ echo 'release-body<<EOF'
167+ echo $PREAMBLE
168+ echo
169+ git log --format="%s" "$PREV_TAG"..HEAD -- "$WORKING_DIR"
170+ echo EOF
171+ } >> "$GITHUB_OUTPUT"
172+
173+ test-pypi-publish :
174+ needs :
175+ - build
176+ - release-notes
177+ runs-on : ubuntu-latest
178+ permissions :
179+ # This permission is used for trusted publishing:
180+ # https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/
181+ id-token : write
182+
183+ steps :
184+ - uses : actions/checkout@v5
185+
186+ - uses : actions/download-artifact@v6
187+ with :
188+ name : dist
189+ path : ${{ env.WORKING_DIR }}/dist/
190+
191+ - name : Publish to test PyPI
192+ uses : pypa/gh-action-pypi-publish@release/v1
193+ with :
194+ packages-dir : ${{ env.WORKING_DIR }}/dist/
195+ verbose : true
196+ print-hash : true
197+ repository-url : https://test.pypi.org/legacy/
198+ skip-existing : true
199+ attestations : false
200+
201+ pre-release-checks :
202+ needs :
203+ - build
204+ - release-notes
205+ - test-pypi-publish
206+ runs-on : ubuntu-latest
207+ permissions :
208+ contents : read
209+ timeout-minutes : 20
210+ steps :
211+ - uses : actions/checkout@v5
212+
213+ - name : Set up Python + uv
214+ uses : " ./.github/actions/uv_setup"
215+ id : setup-python
216+ with :
217+ python-version : ${{ env.PYTHON_VERSION }}
218+
219+ - uses : actions/download-artifact@v6
220+ with :
221+ name : dist
222+ path : ${{ env.WORKING_DIR }}/dist/
223+
224+ - name : Import dist package
225+ shell : bash
226+ working-directory : ${{ env.WORKING_DIR }}
227+ env :
228+ PKG_NAME : ${{ needs.build.outputs.pkg-name }}
229+ VERSION : ${{ needs.build.outputs.version }}
230+ run : |
231+ uv venv
232+ VIRTUAL_ENV=.venv uv pip install dist/*.whl
233+
234+ # Replace all dashes in the package name with underscores
235+ IMPORT_NAME="$(echo "$PKG_NAME" | sed s/-/_/g)"
236+
237+ uv run python -c "import $IMPORT_NAME; print(dir($IMPORT_NAME))"
238+
239+ - name : Import test dependencies
240+ run : uv sync --group test
241+ working-directory : ${{ env.WORKING_DIR }}
242+
243+ - name : Import published package (again)
244+ working-directory : ${{ env.WORKING_DIR }}
245+ shell : bash
246+ env :
247+ PKG_NAME : ${{ needs.build.outputs.pkg-name }}
248+ VERSION : ${{ needs.build.outputs.version }}
249+ run : |
250+ VIRTUAL_ENV=.venv uv pip install dist/*.whl
251+
252+ - name : Run unit tests
253+ run : make test || echo "No tests found, skipping..."
254+ working-directory : ${{ env.WORKING_DIR }}
255+
256+ publish :
257+ # Publishes the package to PyPI
258+ needs :
259+ - build
260+ - release-notes
261+ - test-pypi-publish
262+ - pre-release-checks
263+ runs-on : ubuntu-latest
264+ permissions :
265+ # This permission is used for trusted publishing:
266+ # https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/
267+ id-token : write
268+
269+ defaults :
270+ run :
271+ working-directory : ${{ env.WORKING_DIR }}
272+
273+ steps :
274+ - uses : actions/checkout@v5
275+
276+ - name : Set up Python + uv
277+ uses : " ./.github/actions/uv_setup"
278+ with :
279+ python-version : ${{ env.PYTHON_VERSION }}
280+
281+ - uses : actions/download-artifact@v6
282+ with :
283+ name : dist
284+ path : ${{ env.WORKING_DIR }}/dist/
285+
286+ - name : Publish package distributions to PyPI
287+ uses : pypa/gh-action-pypi-publish@release/v1
288+ with :
289+ packages-dir : ${{ env.WORKING_DIR }}/dist/
290+ verbose : true
291+ print-hash : true
292+ attestations : false
293+
294+ mark-release :
295+ # Marks the GitHub release with the new version tag
296+ needs :
297+ - build
298+ - release-notes
299+ - test-pypi-publish
300+ - pre-release-checks
301+ - publish
302+ runs-on : ubuntu-latest
303+ permissions :
304+ contents : write
305+
306+ defaults :
307+ run :
308+ working-directory : ${{ env.WORKING_DIR }}
309+
310+ steps :
311+ - uses : actions/checkout@v5
312+
313+ - name : Set up Python + uv
314+ uses : " ./.github/actions/uv_setup"
315+ with :
316+ python-version : ${{ env.PYTHON_VERSION }}
317+
318+ - uses : actions/download-artifact@v6
319+ with :
320+ name : dist
321+ path : ${{ env.WORKING_DIR }}/dist/
322+
323+ - name : Create Tag
324+ uses : ncipollo/release-action@v1
325+ with :
326+ artifacts : " ${{ env.WORKING_DIR }}/dist/*"
327+ token : ${{ secrets.GITHUB_TOKEN }}
328+ generateReleaseNotes : false
329+ tag : ${{ needs.build.outputs.pkg-name }}==${{ needs.build.outputs.version }}
330+ body : ${{ needs.release-notes.outputs.release-body }}
331+ commit : ${{ github.sha }}
332+ makeLatest : true
0 commit comments