diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 20d74436c6..b2b5bcb747 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -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: @@ -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 @@ -187,7 +199,7 @@ 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 @@ -195,22 +207,27 @@ jobs: - 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: @@ -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 }} @@ -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 \ @@ -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: | @@ -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 }} @@ -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: @@ -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: @@ -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: @@ -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: diff --git a/.github/workflows/snap-package.yml b/.github/workflows/snap-package.yml index 6de2a587f2..7eb78d3a27 100644 --- a/.github/workflows/snap-package.yml +++ b/.github/workflows/snap-package.yml @@ -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: @@ -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 }} diff --git a/AGENTS.md b/AGENTS.md index 5fdaa63d02..2c11b4c4ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8b33c58fae..b1a789496f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/python/release_tooling_test.py b/python/release_tooling_test.py index ccc97453fc..0846705cb6 100644 --- a/python/release_tooling_test.py +++ b/python/release_tooling_test.py @@ -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 diff --git a/tasks/scripts/release.py b/tasks/scripts/release.py index dcf1c50119..feeaa84b91 100644 --- a/tasks/scripts/release.py +++ b/tasks/scripts/release.py @@ -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\d+)\.(?P\d+)\.(?P\d+)$") +PRERELEASE_TAG_RE = re.compile( + r"^v?(?P\d+)\.(?P\d+)\.(?P\d+)-pre\.(?P[1-9]\d*)$" +) @dataclass(frozen=True) @@ -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]}" @@ -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( @@ -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"]))