Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# SPDX-License-Identifier: Apache-2.0

# Releases the SDK when a stable core pin lands on main: tag and draft release,
# build, publish to PyPI, then attach the files and publish the GitHub release.
# PyPI trusts this file, release.yml, in the pypi environment (trusted
# publishing), so the publish job needs no token. Pushed tags never trigger it.
name: release

on:
push:
branches: [main]
workflow_dispatch:
inputs:
sha:
description: Commit on main to release (default the tip); the pin there must be stable
required: false
default: ""

permissions:
contents: read

concurrency:
group: release
cancel-in-progress: false

jobs:
tag:
name: tag
# Only main releases, also when the workflow is started by hand.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write # create the tag and the draft release
pull-requests: read # read the merged PR's body for the release notes
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false

- id: tag
name: Tag the release and create a draft
env:
GH_TOKEN: ${{ github.token }}
EVENT: ${{ github.event_name }}
INPUT_SHA: ${{ inputs.sha }}
BEFORE: ${{ github.event.before }}
run: |
set -euo pipefail
if [ "$EVENT" = workflow_dispatch ]; then
sha=${INPUT_SHA:-$GITHUB_SHA}
git merge-base --is-ancestor "$sha" "$GITHUB_SHA" || {
echo "::error::$sha is not on main"
exit 1
}
scripts/release/tag-release.sh --force --sha "$sha" >>"$GITHUB_OUTPUT"
else
# Compare against the commit before the push, so a pin change
# that isn't the pushed tip still counts.
scripts/release/tag-release.sh --since "$BEFORE" >>"$GITHUB_OUTPUT"
fi

build:
name: build
needs: tag
if: needs.tag.outputs.tag != ''
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.tag.outputs.tag }}
persist-credentials: false

# No tool cache: what gets published is built from fresh downloads.
- uses: jdx/mise-action@e6a8b3978addb5a52f2b4cd9d91eafa7f0ab959d # v4.2.0
with:
cache: false

# Builds the wheel and sdist at the tag's version, verifies the wheel,
# and installs both on the oldest supported Python.
- name: Build
env:
TAG: ${{ needs.tag.outputs.tag }}
run: OPENSHELL_SDK_VERSION="${TAG#v}" mise run build

- name: Write checksums
run: (cd dist && sha256sum -- *) >openshell-checksums-sha256.txt

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: release
path: |
dist/
openshell-checksums-sha256.txt
if-no-files-found: error

publish-pypi:
name: publish-pypi
needs: [tag, build]
runs-on: ubuntu-latest
timeout-minutes: 10
environment:
name: pypi
url: https://pypi.org/project/openshell/
permissions:
id-token: write # trusted publishing to PyPI
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release
path: release

- uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # v1.14.3
with:
packages-dir: release/dist/

# Publishing comes last, so the release never shows without its files. If
# this job fails after PyPI accepted the upload, re-run only the failed jobs.
github-release:
name: github-release
needs: [tag, publish-pypi]
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write # attach the files and publish the release
steps:
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: release
path: release

- name: Attach the files and publish the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ needs.tag.outputs.tag }}
run: |
set -euo pipefail
gh release upload "$TAG" -R "$GITHUB_REPOSITORY" --clobber \
release/dist/* release/openshell-checksums-sha256.txt
gh release edit "$TAG" -R "$GITHUB_REPOSITORY" --draft=false --latest
21 changes: 19 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,25 @@ pinned ref.
## Releases

SDK releases follow core releases: SDK `X.Y.Z` is built from core `vX.Y.Z`, and
there are no SDK-only releases. To try unreleased changes, install from the
repository:
there are no SDK-only releases. Pre-release pins are never released.

Merging a pull request that moves the pin to a stable core tag releases the SDK.
`release.yml` runs on every push to `main` and does nothing unless the pin
changed to a stable tag. Then it:

1. tags `vX.Y.Z` at the merged commit and creates a draft GitHub release, with
the pull request's description as notes (`scripts/release/tag-release.sh`);
2. builds the wheel and sdist at version `X.Y.Z`, verifies them, and writes
their checksums;
3. publishes to PyPI from the `pypi` environment with trusted publishing;
4. attaches the files and the checksums to the draft and publishes it.

If a run fails, re-run only its failed jobs. The tag step skips a tag or draft
that exists already, and a published release is never released again.
To release a commit by hand, start the workflow on `main` with the commit's SHA
(`gh workflow run release.yml -f sha=<commit>`).

To try unreleased changes, install from the repository:

```shell
pip install "openshell @ git+https://github.com/ai-openshell/sdk-python@main"
Expand Down
4 changes: 3 additions & 1 deletion mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ uv = "0.10.12"
[env]
# The single core pin: a release tag (or a full commit SHA).
OPENSHELL_CORE_REF = "v0.1.7"
SETUPTOOLS_SCM_PRETEND_VERSION_FOR_OPENSHELL = "0.0.0"
# The package version for builds. CI and local builds use 0.0.0; the release
# workflow sets OPENSHELL_SDK_VERSION to the release's version.
SETUPTOOLS_SCM_PRETEND_VERSION_FOR_OPENSHELL = "{{ get_env(name='OPENSHELL_SDK_VERSION', default='0.0.0') }}"

[vars]
python_paths = "src/ tests/ scripts/"
Expand Down
127 changes: 127 additions & 0 deletions scripts/release/tag-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: Apache-2.0

# tag-release.sh: start the lockstep SDK release for a stable core pin.
#
# Usage: tag-release.sh [--dry-run] [--force] [--sha <commit>] [--since <commit>]
#
# Reads the pin, OPENSHELL_CORE_REF in mise.toml, at <commit> (default:
# $GITHUB_SHA, else HEAD). A pre-release pin creates nothing. Without --force,
# nothing is created either when the pin at <commit> equals the pin at --since
# (default: the parent of <commit>) or there is no pin there, so ordinary
# pushes to main and the push that adds mise.toml are no-ops.
# The release workflow passes the commit before the push as --since, which
# covers pushes of several commits. Then, each step only when missing:
# - tag vX.Y.Z (lightweight) at <commit>; a tag at another commit fails
# - a draft GitHub release with the merged PR's body plus
# "Matches OpenShell core vX.Y.Z"
# Prints "tag=vX.Y.Z" when the release still has to be built and published,
# and nothing when there is nothing to release or the release is published
# already. The workflow appends the output to $GITHUB_OUTPUT.
set -euo pipefail

sdk_repo=${SDK_REPO:-ai-openshell/sdk-python}
dry=0
force=0
sha=${GITHUB_SHA:-}
since=

usage() { echo "usage: $0 [--dry-run] [--force] [--sha <commit>] [--since <commit>]" >&2; exit 2; }
while [ $# -gt 0 ]; do
case $1 in
--dry-run) dry=1; shift ;;
--force) force=1; shift ;;
--sha) sha=${2:?--sha needs a commit}; shift 2 ;;
--since) since=${2:?--since needs a commit}; shift 2 ;;
*) usage ;;
esac
done
[ -n "$sha" ] || sha=$(git rev-parse HEAD)
sha=$(git rev-parse "$sha^{commit}")
short=$(git rev-parse --short "$sha")

pin_at() { git show "$1:mise.toml" 2>/dev/null | sed -n 's/^OPENSHELL_CORE_REF = "\(.*\)"$/\1/p'; }

pin=$(pin_at "$sha")
if ! [[ "$pin" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Pin $pin is not a stable release; no SDK release." >&2
exit 0
fi

if [ "$force" -eq 0 ]; then
# A new branch push reports an all-zero "before"; fall back to the parent.
if [ -z "$since" ] || ! git rev-parse --verify --quiet "$since^{commit}" >/dev/null; then
since="$sha^"
fi
old=$(pin_at "$since" || true)
if [ "$old" = "$pin" ]; then
echo "The pin is $pin both at $short and at $(git rev-parse --short "$since"); no SDK release." >&2
exit 0
fi
# No pin before means the push added mise.toml (the import), not a bump.
if [ -z "$old" ]; then
echo "No pin at $(git rev-parse --short "$since"); no SDK release." >&2
exit 0
fi
fi

# get_or_empty <api path> <jq>: the value, empty on 404, and fail on any other
# error. gh prints the error body on stdout for a 404, so stdout is only used
# after a successful call.
get_or_empty() {
local out err
if out=$(gh api "$1" --jq "$2" 2>"${tmp}/err"); then
printf '%s' "$out"
else
err=$(cat "${tmp}/err")
grep -q 'HTTP 404' <<<"$err" || { echo "ERROR: gh api $1 failed: $err" >&2; return 1; }
fi
}
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

existing=$(get_or_empty "repos/$sdk_repo/git/ref/tags/$pin" '.object.sha')
if [ -n "$existing" ] && [ "$existing" != "$sha" ]; then
echo "ERROR: tag $pin exists at $existing, not at $sha. SDK tags never move; fix this by hand." >&2
exit 1
fi
# The tag lookup doesn't return drafts, so search the release list.
release=$(gh api --paginate "repos/$sdk_repo/releases" \
--jq ".[] | select(.tag_name == \"$pin\") | if .draft then \"draft\" else \"published\" end" | head -1)
if [ "$release" = published ]; then
echo "Release $pin is published already; nothing to do." >&2
exit 0
fi

# Release notes: the body of the PR that merged this commit, then the match line.
notes="$tmp/notes.md"
pr_body=$(get_or_empty "repos/$sdk_repo/commits/$sha/pulls" '.[0].body // empty')
{
[ -z "$pr_body" ] || printf '%s\n\n' "$pr_body"
echo "Matches OpenShell core $pin."
} >"$notes"

if [ "$dry" -eq 1 ]; then
if [ -n "$existing" ]; then
echo "dry-run: tag $pin exists at $sha" >&2
else
echo "dry-run: would create tag $pin at $sha" >&2
fi
if [ "$release" = draft ]; then
echo "dry-run: draft release $pin exists" >&2
else
echo "dry-run: would create draft release $pin with notes:" >&2
sed 's/^/ | /' "$notes" >&2
fi
echo "tag=$pin"
exit 0
fi

if [ -z "$existing" ]; then
gh api -X POST "repos/$sdk_repo/git/refs" -f ref="refs/tags/$pin" -f sha="$sha" --silent
echo "Created tag $pin at $sha." >&2
fi
if [ -z "$release" ]; then
gh release create "$pin" -R "$sdk_repo" --draft --verify-tag --title "$pin" --notes-file "$notes" >&2
fi
echo "tag=$pin"
Loading