Skip to content
Merged
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
70 changes: 52 additions & 18 deletions .github/workflows/release-tag.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jobs:
semver: ${{ steps.v.outputs.semver }}
# Commit resolved from RELEASE_TAG, used for image tags and downstream metadata
source_sha: ${{ steps.v.outputs.source_sha }}
is_prerelease: ${{ steps.v.outputs.is_prerelease }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -65,14 +66,25 @@ jobs:
id: v
run: |
set -euo pipefail
echo "python=$(uv run python tasks/scripts/release.py get-version --python)" >> "$GITHUB_OUTPUT"
echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT"
echo "npm=$(uv run python tasks/scripts/release.py get-version --npm)" >> "$GITHUB_OUTPUT"
echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)" >> "$GITHUB_OUTPUT"
echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --rpm-version)" >> "$GITHUB_OUTPUT"
echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --rpm-release)" >> "$GITHUB_OUTPUT"
echo "semver=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT"
echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
if [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-pre\.[1-9][0-9]*$ ]]; then
is_prerelease=true
elif [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
is_prerelease=false
else
echo "Unsupported release tag: ${RELEASE_TAG}" >&2
exit 1
fi
{
echo "is_prerelease=${is_prerelease}"
echo "python=$(uv run python tasks/scripts/release.py get-version --python)"
echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)"
echo "npm=$(uv run python tasks/scripts/release.py get-version --npm)"
echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)"
echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --rpm-version)"
echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --rpm-release)"
echo "semver=${RELEASE_TAG#v}"
echo "source_sha=$(git rev-parse HEAD)"
} >> "$GITHUB_OUTPUT"

build-cli:
needs: compute-versions
Expand Down Expand Up @@ -187,30 +199,35 @@ jobs:
checkout-ref: ${{ inputs.tag || github.ref }}

tag-ghcr-release:
name: Tag GHCR Images for Release
name: Tag GHCR Images
needs: [compute-versions, build-gateway-image, build-supervisor-image, release]
runs-on: linux-amd64-cpu8
timeout-minutes: 10
steps:
- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${GITHUB_ACTOR}" --password-stdin

- name: Tag images with version and latest
- name: Tag images with release version
env:
IS_PRERELEASE: ${{ needs.compute-versions.outputs.is_prerelease }}
run: |
set -euo pipefail
REGISTRY="ghcr.io/nvidia/openshell"
VERSION="${{ needs.compute-versions.outputs.semver }}"
SOURCE_TAG="${{ needs.compute-versions.outputs.source_sha }}"
for component in gateway supervisor; do
echo "Tagging ${REGISTRY}/${component}:${SOURCE_TAG} as ${VERSION} and latest..."
echo "Tagging ${REGISTRY}/${component}:${SOURCE_TAG} as ${VERSION}..."
docker buildx imagetools create \
--prefer-index=false \
-t "${REGISTRY}/${component}:${VERSION}" \
"${REGISTRY}/${component}:${SOURCE_TAG}"
docker buildx imagetools create \
--prefer-index=false \
-t "${REGISTRY}/${component}:latest" \
"${REGISTRY}/${component}:${SOURCE_TAG}"
if [[ "${IS_PRERELEASE}" != "true" ]]; then
echo "Tagging ${REGISTRY}/${component}:${SOURCE_TAG} as latest..."
docker buildx imagetools create \
--prefer-index=false \
-t "${REGISTRY}/${component}:latest" \
"${REGISTRY}/${component}:${SOURCE_TAG}"
fi
done

build-python-wheel:
Expand Down Expand Up @@ -266,8 +283,9 @@ jobs:
uses: ./.github/workflows/snap-package.yml
with:
checkout-ref: ${{ inputs.tag || github.ref }}
upload-channel: latest/stable
github-environment: latest/stable
upload-channel: ${{ needs.compute-versions.outputs.is_prerelease == 'true' && 'latest/edge' || 'latest/stable' }}
github-environment: ${{ needs.compute-versions.outputs.is_prerelease == 'true' && 'latest/edge' || 'latest/stable' }}
publish: ${{ needs.compute-versions.outputs.is_prerelease != 'true' }}
secrets:
publish-credentials: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}

Expand Down Expand Up @@ -530,6 +548,7 @@ jobs:
cat openshell-sandbox-checksums-sha256.txt

- name: Generate Homebrew formula
if: needs.compute-versions.outputs.is_prerelease != 'true'
run: |
set -euo pipefail
python3 tasks/scripts/release.py generate-homebrew-formula \
Expand All @@ -548,6 +567,7 @@ jobs:
release/*.whl

- name: Prune removed VM checksum asset
if: needs.compute-versions.outputs.is_prerelease != 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
Expand All @@ -570,6 +590,7 @@ jobs:
}

- name: Create GitHub Release
if: needs.compute-versions.outputs.is_prerelease != 'true'
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
name: OpenShell ${{ env.RELEASE_TAG }}
Expand Down Expand Up @@ -606,9 +627,19 @@ jobs:
release/openshell-gateway-checksums-sha256.txt
release/openshell-sandbox-checksums-sha256.txt

- name: Upload pre-release artifacts
if: needs.compute-versions.outputs.is_prerelease == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: openshell-${{ env.RELEASE_TAG }}
path: release/
retention-days: 90
if-no-files-found: error

publish-fern-docs:
name: Publish Fern Docs
needs: [release]
needs: [compute-versions, release]
if: needs.compute-versions.outputs.is_prerelease != 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
Expand All @@ -635,6 +666,7 @@ jobs:
publish-sdk-typescript:
name: Publish TypeScript SDK
needs: [compute-versions, release]
if: needs.compute-versions.outputs.is_prerelease != 'true'
runs-on: linux-amd64-cpu8
timeout-minutes: 15
permissions:
Expand Down Expand Up @@ -673,6 +705,7 @@ jobs:
release-helm:
name: Release Helm Chart (OCI)
needs: [compute-versions, release, tag-ghcr-release]
if: needs.compute-versions.outputs.is_prerelease != 'true'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
Expand All @@ -691,6 +724,7 @@ jobs:
trigger-wheel-publish:
name: Trigger Wheel Publish
needs: [compute-versions, release]
if: needs.compute-versions.outputs.is_prerelease != 'true'
runs-on: [self-hosted, nv]
timeout-minutes: 10
steps:
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/snap-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ on:
required: true
type: string
description: "GitHub deployment environment for approval gates (e.g., latest/edge, latest/stable)"
publish:
required: false
type: boolean
default: true
description: "Whether to upload the built snap to the Snap Store"

secrets:
publish-credentials:
Expand Down Expand Up @@ -161,6 +166,7 @@ jobs:
retention-days: 5

- name: Upload snap to Snap Store
if: inputs.publish
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.publish-credentials }}
INPUTS_UPLOAD_CHANNEL: ${{ inputs.upload-channel }}
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ When behavior, commands, or development workflows change, review the related age
- When changing gateway TOML fields, driver-specific config options, config defaults, or Helm rendering of `gateway.toml`, update `docs/reference/gateway-config.mdx` in the same branch.
- `fern/` contains the Fern site config, components, preview workflow inputs, and publish settings.
- Follow the docs style guide in [docs/CONTRIBUTING.mdx](docs/CONTRIBUTING.mdx): active voice, minimal formatting, no filler introductions, `shell` fences for copyable commands, and no duplicate body H1.
- Fern PR previews run through `.github/workflows/branch-docs.yml`, and production publish runs through the `publish-fern-docs` job in `.github/workflows/release-tag.yml`.
- Fern PR previews run through `.github/workflows/branch-docs.yml`, and production publish runs through the `publish-fern-docs` job in `.github/workflows/release-tag.yml` for stable release tags.
- Use the `update-docs` skill to scan recent commits and draft doc updates.

### Architecture Docs
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,7 +456,7 @@ mise run docs

PRs that touch `docs/**` or `fern/**` are validated by `.github/workflows/branch-docs.yml`, and they get a preview when `FERN_TOKEN` is available to the workflow.

Fern docs publishing is handled by the `publish-fern-docs` job in `.github/workflows/release-tag.yml` when a release tag is created.
Fern docs publishing is handled by the `publish-fern-docs` job in `.github/workflows/release-tag.yml` when a stable release tag is created.

`docs/` is the source-of-truth docs tree. `fern/` contains the site config, components, and theme assets that publish those pages.

Expand Down
24 changes: 24 additions & 0 deletions python/release_tooling_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,32 @@ def test_dev_versions_share_one_build_identity() -> None:
assert versions.rpm_release == "0.dev.108.g152d05940"


def test_prerelease_versions_share_tag_identity() -> None:
versions = release._versions_from_prerelease(
(0, 1, 0), 1, "152d05940", "v0.1.0-pre.1"
)

assert versions.python == "0.1.0-pre.1"
assert versions.cargo == "0.1.0-pre.1"
assert versions.npm == "0.1.0-pre.1"
assert versions.docker == "0.1.0-pre.1"
assert versions.deb == "0.1.0~pre.1-1"
assert versions.snap == "0.1.0-pre.1"
assert versions.rpm_version == "0.1.0"
assert versions.rpm_release == "0.pre.1"


def test_semver_tag_parser_excludes_vm_tags() -> None:
assert release._parse_semver_tag("v0.0.37") == (0, 0, 37)
assert release._parse_semver_tag("0.0.37") == (0, 0, 37)
assert release._parse_semver_tag("v0.1.0-pre.1") is None
assert release._parse_semver_tag("vm-runtime") is None
assert release._parse_semver_tag("vm-dev") is None


def test_prerelease_tag_parser_requires_positive_sequence() -> None:
assert release._parse_prerelease_tag("v0.1.0-pre.1") == (0, 1, 0, 1)
assert release._parse_prerelease_tag("0.1.0-pre.12") == (0, 1, 0, 12)
assert release._parse_prerelease_tag("v0.1.0") is None
assert release._parse_prerelease_tag("v0.1.0-pre.0") is None
assert release._parse_prerelease_tag("v0.1.0-rc.1") is None
78 changes: 66 additions & 12 deletions tasks/scripts/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@
from dataclasses import asdict, dataclass
from pathlib import Path

SEMVER_TAG_GLOB = "v[0-9]*.[0-9]*.[0-9]*"
SEMVER_TAG_RE = re.compile(r"^v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)$")
PRERELEASE_TAG_RE = re.compile(
r"^v?(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)-pre\.(?P<sequence>[1-9]\d*)$"
)


@dataclass(frozen=True)
Expand Down Expand Up @@ -66,6 +68,18 @@ def _parse_semver_tag(tag: str) -> tuple[int, int, int] | None:
)


def _parse_prerelease_tag(tag: str) -> tuple[int, int, int, int] | None:
match = PRERELEASE_TAG_RE.match(tag)
if match is None:
return None
return (
int(match.group("major")),
int(match.group("minor")),
int(match.group("patch")),
int(match.group("sequence")),
)


def _format_semver(version: tuple[int, int, int]) -> str:
return f"{version[0]}.{version[1]}.{version[2]}"

Expand All @@ -74,17 +88,22 @@ def _next_patch(version: tuple[int, int, int]) -> tuple[int, int, int]:
return version[0], version[1], version[2] + 1


def _latest_semver_tag() -> str | None:
try:
tag = _git(
["describe", "--tags", "--match", SEMVER_TAG_GLOB, "--abbrev=0", "HEAD"]
)
except subprocess.CalledProcessError:
return None
def _exact_release_tag() -> str | None:
tags = _git(["tag", "--points-at", "HEAD"]).splitlines()
stable = [(version, tag) for tag in tags if (version := _parse_semver_tag(tag))]
if stable:
return max(stable)[1]

prereleases = [
(version, tag) for tag in tags if (version := _parse_prerelease_tag(tag))
]
return max(prereleases)[1] if prereleases else None

if _parse_semver_tag(tag) is None:
raise RuntimeError(f"git describe returned non-semver release tag: {tag}")
return tag

def _latest_stable_tag() -> str | None:
tags = _git(["tag", "--merged", "HEAD", "--list", "v*.*.*"]).splitlines()
stable = [(version, tag) for tag in tags if (version := _parse_semver_tag(tag))]
return max(stable)[1] if stable else None


def _versions_from_parts(
Expand Down Expand Up @@ -142,10 +161,45 @@ def _versions_from_parts(
)


def _versions_from_prerelease(
base_version: tuple[int, int, int],
sequence: int,
git_sha: str,
git_tag: str,
) -> Versions:
version = f"{_format_semver(base_version)}-pre.{sequence}"
return Versions(
python=version,
cargo=version,
npm=version,
docker=version,
deb=f"{_format_semver(base_version)}~pre.{sequence}-1",
snap=version,
rpm_version=_format_semver(base_version),
rpm_release=f"0.pre.{sequence}",
git_tag=git_tag,
git_sha=git_sha,
git_distance=0,
)


def _compute_versions() -> Versions:
git_tag = _latest_semver_tag()
git_sha = _git(["rev-parse", "--short=9", "HEAD"])
exact_tag = _exact_release_tag()

if exact_tag is not None:
stable = _parse_semver_tag(exact_tag)
if stable is not None:
return _versions_from_parts(stable, 0, git_sha, exact_tag)

prerelease = _parse_prerelease_tag(exact_tag)
if prerelease is None:
raise RuntimeError(f"invalid semantic release tag: {exact_tag}")
return _versions_from_prerelease(
prerelease[:3], prerelease[3], git_sha, exact_tag
)

git_tag = _latest_stable_tag()
if git_tag is None:
base_version = (0, 0, 0)
git_distance = int(_git(["rev-list", "--count", "HEAD"]))
Expand Down
Loading