Skip to content

Commit f00325d

Browse files
authored
ci(release): tag, publish to PyPI and release when a stable pin lands (#14)
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
1 parent df083ca commit f00325d

4 files changed

Lines changed: 293 additions & 3 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
3+
# Releases the SDK when a stable core pin lands on main: tag and draft release,
4+
# build, publish to PyPI, then attach the files and publish the GitHub release.
5+
# PyPI trusts this file, release.yml, in the pypi environment (trusted
6+
# publishing), so the publish job needs no token. Pushed tags never trigger it.
7+
name: release
8+
9+
on:
10+
push:
11+
branches: [main]
12+
workflow_dispatch:
13+
inputs:
14+
sha:
15+
description: Commit on main to release (default the tip); the pin there must be stable
16+
required: false
17+
default: ""
18+
19+
permissions:
20+
contents: read
21+
22+
concurrency:
23+
group: release
24+
cancel-in-progress: false
25+
26+
jobs:
27+
tag:
28+
name: tag
29+
# Only main releases, also when the workflow is started by hand.
30+
if: github.ref == 'refs/heads/main'
31+
runs-on: ubuntu-latest
32+
timeout-minutes: 10
33+
permissions:
34+
contents: write # create the tag and the draft release
35+
pull-requests: read # read the merged PR's body for the release notes
36+
outputs:
37+
tag: ${{ steps.tag.outputs.tag }}
38+
steps:
39+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
40+
with:
41+
fetch-depth: 0
42+
persist-credentials: false
43+
44+
- id: tag
45+
name: Tag the release and create a draft
46+
env:
47+
GH_TOKEN: ${{ github.token }}
48+
EVENT: ${{ github.event_name }}
49+
INPUT_SHA: ${{ inputs.sha }}
50+
BEFORE: ${{ github.event.before }}
51+
run: |
52+
set -euo pipefail
53+
if [ "$EVENT" = workflow_dispatch ]; then
54+
sha=${INPUT_SHA:-$GITHUB_SHA}
55+
git merge-base --is-ancestor "$sha" "$GITHUB_SHA" || {
56+
echo "::error::$sha is not on main"
57+
exit 1
58+
}
59+
scripts/release/tag-release.sh --force --sha "$sha" >>"$GITHUB_OUTPUT"
60+
else
61+
# Compare against the commit before the push, so a pin change
62+
# that isn't the pushed tip still counts.
63+
scripts/release/tag-release.sh --since "$BEFORE" >>"$GITHUB_OUTPUT"
64+
fi
65+
66+
build:
67+
name: build
68+
needs: tag
69+
if: needs.tag.outputs.tag != ''
70+
runs-on: ubuntu-latest
71+
timeout-minutes: 15
72+
steps:
73+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
74+
with:
75+
ref: ${{ needs.tag.outputs.tag }}
76+
persist-credentials: false
77+
78+
# No tool cache: what gets published is built from fresh downloads.
79+
- uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
80+
with:
81+
cache: false
82+
83+
# Builds the wheel and sdist at the tag's version, verifies the wheel,
84+
# and installs both on the oldest supported Python.
85+
- name: Build
86+
env:
87+
TAG: ${{ needs.tag.outputs.tag }}
88+
run: OPENSHELL_SDK_VERSION="${TAG#v}" mise run build
89+
90+
- name: Write checksums
91+
run: (cd dist && sha256sum -- *) >openshell-checksums-sha256.txt
92+
93+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
94+
with:
95+
name: release
96+
path: |
97+
dist/
98+
openshell-checksums-sha256.txt
99+
if-no-files-found: error
100+
101+
publish-pypi:
102+
name: publish-pypi
103+
needs: [tag, build]
104+
runs-on: ubuntu-latest
105+
timeout-minutes: 10
106+
environment:
107+
name: pypi
108+
url: https://pypi.org/project/openshell/
109+
permissions:
110+
id-token: write # trusted publishing to PyPI
111+
steps:
112+
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
113+
with:
114+
name: release
115+
path: release
116+
117+
- uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
118+
with:
119+
packages-dir: release/dist/
120+
121+
# Publishing comes last, so the release never shows without its files. If
122+
# this job fails after PyPI accepted the upload, re-run only the failed jobs.
123+
github-release:
124+
name: github-release
125+
needs: [tag, publish-pypi]
126+
runs-on: ubuntu-latest
127+
timeout-minutes: 10
128+
permissions:
129+
contents: write # attach the files and publish the release
130+
steps:
131+
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
132+
with:
133+
name: release
134+
path: release
135+
136+
- name: Attach the files and publish the release
137+
env:
138+
GH_TOKEN: ${{ github.token }}
139+
TAG: ${{ needs.tag.outputs.tag }}
140+
run: |
141+
set -euo pipefail
142+
gh release upload "$TAG" -R "$GITHUB_REPOSITORY" --clobber \
143+
release/dist/* release/openshell-checksums-sha256.txt
144+
gh release edit "$TAG" -R "$GITHUB_REPOSITORY" --draft=false --latest

‎CONTRIBUTING.md‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,25 @@ doesn't cover yet, and keeps one issue labeled `core-drift` up to date. See
7979
## Releases
8080

8181
SDK releases follow core releases: SDK `X.Y.Z` is built from core `vX.Y.Z`, and
82-
there are no SDK-only releases. To try unreleased changes, install from the
83-
repository:
82+
there are no SDK-only releases. Pre-release pins are never released.
83+
84+
Merging a pull request that moves the pin to a stable core tag releases the SDK.
85+
`release.yml` runs on every push to `main` and does nothing unless the pin
86+
changed to a stable tag. Then it:
87+
88+
1. tags `vX.Y.Z` at the merged commit and creates a draft GitHub release, with
89+
the pull request's description as notes (`scripts/release/tag-release.sh`);
90+
2. builds the wheel and sdist at version `X.Y.Z`, verifies them, and writes
91+
their checksums;
92+
3. publishes to PyPI from the `pypi` environment with trusted publishing;
93+
4. attaches the files and the checksums to the draft and publishes it.
94+
95+
If a run fails, re-run only its failed jobs. The tag step skips a tag or draft
96+
that exists already, and a published release is never released again.
97+
To release a commit by hand, start the workflow on `main` with the commit's SHA
98+
(`gh workflow run release.yml -f sha=<commit>`).
99+
100+
To try unreleased changes, install from the repository:
84101

85102
```shell
86103
pip install "openshell @ git+https://github.com/ai-openshell/sdk-python@main"

‎mise.toml‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ uv = "0.10.12"
1919
[env]
2020
# The single core pin: a release tag. scripts/core-sync/ requires a tag.
2121
OPENSHELL_CORE_REF = "v0.1.7"
22-
SETUPTOOLS_SCM_PRETEND_VERSION_FOR_OPENSHELL = "0.0.0"
22+
# The package version for builds. CI and local builds use 0.0.0; the release
23+
# workflow sets OPENSHELL_SDK_VERSION to the release's version.
24+
SETUPTOOLS_SCM_PRETEND_VERSION_FOR_OPENSHELL = "{{ get_env(name='OPENSHELL_SDK_VERSION', default='0.0.0') }}"
2325

2426
[vars]
2527
python_paths = "src/ tests/ scripts/"

‎scripts/release/tag-release.sh‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
# tag-release.sh: start the lockstep SDK release for a stable core pin.
5+
#
6+
# Usage: tag-release.sh [--dry-run] [--force] [--sha <commit>] [--since <commit>]
7+
#
8+
# Reads the pin, OPENSHELL_CORE_REF in mise.toml, at <commit> (default:
9+
# $GITHUB_SHA, else HEAD). A pre-release pin creates nothing. Without --force,
10+
# nothing is created either when the pin at <commit> equals the pin at --since
11+
# (default: the parent of <commit>) or there is no pin there, so ordinary
12+
# pushes to main and the push that adds mise.toml are no-ops.
13+
# The release workflow passes the commit before the push as --since, which
14+
# covers pushes of several commits. Then, each step only when missing:
15+
# - tag vX.Y.Z (lightweight) at <commit>; a tag at another commit fails
16+
# - a draft GitHub release with the merged PR's body plus
17+
# "Matches OpenShell core vX.Y.Z"
18+
# Prints "tag=vX.Y.Z" when the release still has to be built and published,
19+
# and nothing when there is nothing to release or the release is published
20+
# already. The workflow appends the output to $GITHUB_OUTPUT.
21+
set -euo pipefail
22+
23+
sdk_repo=${SDK_REPO:-ai-openshell/sdk-python}
24+
dry=0
25+
force=0
26+
sha=${GITHUB_SHA:-}
27+
since=
28+
29+
usage() { echo "usage: $0 [--dry-run] [--force] [--sha <commit>] [--since <commit>]" >&2; exit 2; }
30+
while [ $# -gt 0 ]; do
31+
case $1 in
32+
--dry-run) dry=1; shift ;;
33+
--force) force=1; shift ;;
34+
--sha) sha=${2:?--sha needs a commit}; shift 2 ;;
35+
--since) since=${2:?--since needs a commit}; shift 2 ;;
36+
*) usage ;;
37+
esac
38+
done
39+
[ -n "$sha" ] || sha=$(git rev-parse HEAD)
40+
sha=$(git rev-parse "$sha^{commit}")
41+
short=$(git rev-parse --short "$sha")
42+
43+
pin_at() { git show "$1:mise.toml" 2>/dev/null | sed -n 's/^OPENSHELL_CORE_REF = "\(.*\)"$/\1/p'; }
44+
45+
pin=$(pin_at "$sha")
46+
if ! [[ "$pin" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
47+
echo "Pin $pin is not a stable release; no SDK release." >&2
48+
exit 0
49+
fi
50+
51+
if [ "$force" -eq 0 ]; then
52+
# A new branch push reports an all-zero "before"; fall back to the parent.
53+
if [ -z "$since" ] || ! git rev-parse --verify --quiet "$since^{commit}" >/dev/null; then
54+
since="$sha^"
55+
fi
56+
old=$(pin_at "$since" || true)
57+
if [ "$old" = "$pin" ]; then
58+
echo "The pin is $pin both at $short and at $(git rev-parse --short "$since"); no SDK release." >&2
59+
exit 0
60+
fi
61+
# No pin before means the push added mise.toml (the import), not a bump.
62+
if [ -z "$old" ]; then
63+
echo "No pin at $(git rev-parse --short "$since"); no SDK release." >&2
64+
exit 0
65+
fi
66+
fi
67+
68+
# get_or_empty <api path> <jq>: the value, empty on 404, and fail on any other
69+
# error. gh prints the error body on stdout for a 404, so stdout is only used
70+
# after a successful call.
71+
get_or_empty() {
72+
local out err
73+
if out=$(gh api "$1" --jq "$2" 2>"${tmp}/err"); then
74+
printf '%s' "$out"
75+
else
76+
err=$(cat "${tmp}/err")
77+
grep -q 'HTTP 404' <<<"$err" || { echo "ERROR: gh api $1 failed: $err" >&2; return 1; }
78+
fi
79+
}
80+
tmp=$(mktemp -d)
81+
trap 'rm -rf "$tmp"' EXIT
82+
83+
existing=$(get_or_empty "repos/$sdk_repo/git/ref/tags/$pin" '.object.sha')
84+
if [ -n "$existing" ] && [ "$existing" != "$sha" ]; then
85+
echo "ERROR: tag $pin exists at $existing, not at $sha. SDK tags never move; fix this by hand." >&2
86+
exit 1
87+
fi
88+
# The tag lookup doesn't return drafts, so search the release list.
89+
release=$(gh api --paginate "repos/$sdk_repo/releases" \
90+
--jq ".[] | select(.tag_name == \"$pin\") | if .draft then \"draft\" else \"published\" end" | head -1)
91+
if [ "$release" = published ]; then
92+
echo "Release $pin is published already; nothing to do." >&2
93+
exit 0
94+
fi
95+
96+
# Release notes: the body of the PR that merged this commit, then the match line.
97+
notes="$tmp/notes.md"
98+
pr_body=$(get_or_empty "repos/$sdk_repo/commits/$sha/pulls" '.[0].body // empty')
99+
{
100+
[ -z "$pr_body" ] || printf '%s\n\n' "$pr_body"
101+
echo "Matches OpenShell core $pin."
102+
} >"$notes"
103+
104+
if [ "$dry" -eq 1 ]; then
105+
if [ -n "$existing" ]; then
106+
echo "dry-run: tag $pin exists at $sha" >&2
107+
else
108+
echo "dry-run: would create tag $pin at $sha" >&2
109+
fi
110+
if [ "$release" = draft ]; then
111+
echo "dry-run: draft release $pin exists" >&2
112+
else
113+
echo "dry-run: would create draft release $pin with notes:" >&2
114+
sed 's/^/ | /' "$notes" >&2
115+
fi
116+
echo "tag=$pin"
117+
exit 0
118+
fi
119+
120+
if [ -z "$existing" ]; then
121+
gh api -X POST "repos/$sdk_repo/git/refs" -f ref="refs/tags/$pin" -f sha="$sha" --silent
122+
echo "Created tag $pin at $sha." >&2
123+
fi
124+
if [ -z "$release" ]; then
125+
gh release create "$pin" -R "$sdk_repo" --draft --verify-tag --title "$pin" --notes-file "$notes" >&2
126+
fi
127+
echo "tag=$pin"

0 commit comments

Comments
 (0)