diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..646863c --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1f99646..18bdc09 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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=`). + +To try unreleased changes, install from the repository: ```shell pip install "openshell @ git+https://github.com/ai-openshell/sdk-python@main" diff --git a/mise.toml b/mise.toml index 0a99ef1..fb43799 100644 --- a/mise.toml +++ b/mise.toml @@ -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/" diff --git a/scripts/release/tag-release.sh b/scripts/release/tag-release.sh new file mode 100755 index 0000000..2cf45f3 --- /dev/null +++ b/scripts/release/tag-release.sh @@ -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 ] [--since ] +# +# Reads the pin, OPENSHELL_CORE_REF in mise.toml, at (default: +# $GITHUB_SHA, else HEAD). A pre-release pin creates nothing. Without --force, +# nothing is created either when the pin at equals the pin at --since +# (default: the parent of ) 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 ; 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 ] [--since ]" >&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 : 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"