Skip to content

Commit c8f1320

Browse files
authored
ci(release): publish prerelease artifacts (#3093)
Signed-off-by: Simon Scatton <sscatton@nvidia.com>
1 parent b143500 commit c8f1320

6 files changed

Lines changed: 150 additions & 32 deletions

File tree

‎.github/workflows/release-tag.yml‎

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ jobs:
4949
semver: ${{ steps.v.outputs.semver }}
5050
# Commit resolved from RELEASE_TAG, used for image tags and downstream metadata
5151
source_sha: ${{ steps.v.outputs.source_sha }}
52+
is_prerelease: ${{ steps.v.outputs.is_prerelease }}
5253
steps:
5354
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
5455
with:
@@ -65,14 +66,25 @@ jobs:
6566
id: v
6667
run: |
6768
set -euo pipefail
68-
echo "python=$(uv run python tasks/scripts/release.py get-version --python)" >> "$GITHUB_OUTPUT"
69-
echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)" >> "$GITHUB_OUTPUT"
70-
echo "npm=$(uv run python tasks/scripts/release.py get-version --npm)" >> "$GITHUB_OUTPUT"
71-
echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)" >> "$GITHUB_OUTPUT"
72-
echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --rpm-version)" >> "$GITHUB_OUTPUT"
73-
echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --rpm-release)" >> "$GITHUB_OUTPUT"
74-
echo "semver=${RELEASE_TAG#v}" >> "$GITHUB_OUTPUT"
75-
echo "source_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
69+
if [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-pre\.[1-9][0-9]*$ ]]; then
70+
is_prerelease=true
71+
elif [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
72+
is_prerelease=false
73+
else
74+
echo "Unsupported release tag: ${RELEASE_TAG}" >&2
75+
exit 1
76+
fi
77+
{
78+
echo "is_prerelease=${is_prerelease}"
79+
echo "python=$(uv run python tasks/scripts/release.py get-version --python)"
80+
echo "cargo=$(uv run python tasks/scripts/release.py get-version --cargo)"
81+
echo "npm=$(uv run python tasks/scripts/release.py get-version --npm)"
82+
echo "deb=$(uv run python tasks/scripts/release.py get-version --deb)"
83+
echo "rpm_version=$(uv run python tasks/scripts/release.py get-version --rpm-version)"
84+
echo "rpm_release=$(uv run python tasks/scripts/release.py get-version --rpm-release)"
85+
echo "semver=${RELEASE_TAG#v}"
86+
echo "source_sha=$(git rev-parse HEAD)"
87+
} >> "$GITHUB_OUTPUT"
7688
7789
build-cli:
7890
needs: compute-versions
@@ -187,30 +199,35 @@ jobs:
187199
checkout-ref: ${{ inputs.tag || github.ref }}
188200

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

198-
- name: Tag images with version and latest
210+
- name: Tag images with release version
211+
env:
212+
IS_PRERELEASE: ${{ needs.compute-versions.outputs.is_prerelease }}
199213
run: |
200214
set -euo pipefail
201215
REGISTRY="ghcr.io/nvidia/openshell"
202216
VERSION="${{ needs.compute-versions.outputs.semver }}"
203217
SOURCE_TAG="${{ needs.compute-versions.outputs.source_sha }}"
204218
for component in gateway supervisor; do
205-
echo "Tagging ${REGISTRY}/${component}:${SOURCE_TAG} as ${VERSION} and latest..."
219+
echo "Tagging ${REGISTRY}/${component}:${SOURCE_TAG} as ${VERSION}..."
206220
docker buildx imagetools create \
207221
--prefer-index=false \
208222
-t "${REGISTRY}/${component}:${VERSION}" \
209223
"${REGISTRY}/${component}:${SOURCE_TAG}"
210-
docker buildx imagetools create \
211-
--prefer-index=false \
212-
-t "${REGISTRY}/${component}:latest" \
213-
"${REGISTRY}/${component}:${SOURCE_TAG}"
224+
if [[ "${IS_PRERELEASE}" != "true" ]]; then
225+
echo "Tagging ${REGISTRY}/${component}:${SOURCE_TAG} as latest..."
226+
docker buildx imagetools create \
227+
--prefer-index=false \
228+
-t "${REGISTRY}/${component}:latest" \
229+
"${REGISTRY}/${component}:${SOURCE_TAG}"
230+
fi
214231
done
215232
216233
build-python-wheel:
@@ -266,8 +283,9 @@ jobs:
266283
uses: ./.github/workflows/snap-package.yml
267284
with:
268285
checkout-ref: ${{ inputs.tag || github.ref }}
269-
upload-channel: latest/stable
270-
github-environment: latest/stable
286+
upload-channel: ${{ needs.compute-versions.outputs.is_prerelease == 'true' && 'latest/edge' || 'latest/stable' }}
287+
github-environment: ${{ needs.compute-versions.outputs.is_prerelease == 'true' && 'latest/edge' || 'latest/stable' }}
288+
publish: ${{ needs.compute-versions.outputs.is_prerelease != 'true' }}
271289
secrets:
272290
publish-credentials: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}
273291

@@ -530,6 +548,7 @@ jobs:
530548
cat openshell-sandbox-checksums-sha256.txt
531549
532550
- name: Generate Homebrew formula
551+
if: needs.compute-versions.outputs.is_prerelease != 'true'
533552
run: |
534553
set -euo pipefail
535554
python3 tasks/scripts/release.py generate-homebrew-formula \
@@ -548,6 +567,7 @@ jobs:
548567
release/*.whl
549568
550569
- name: Prune removed VM checksum asset
570+
if: needs.compute-versions.outputs.is_prerelease != 'true'
551571
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
552572
with:
553573
script: |
@@ -570,6 +590,7 @@ jobs:
570590
}
571591
572592
- name: Create GitHub Release
593+
if: needs.compute-versions.outputs.is_prerelease != 'true'
573594
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
574595
with:
575596
name: OpenShell ${{ env.RELEASE_TAG }}
@@ -606,9 +627,19 @@ jobs:
606627
release/openshell-gateway-checksums-sha256.txt
607628
release/openshell-sandbox-checksums-sha256.txt
608629
630+
- name: Upload pre-release artifacts
631+
if: needs.compute-versions.outputs.is_prerelease == 'true'
632+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
633+
with:
634+
name: openshell-${{ env.RELEASE_TAG }}
635+
path: release/
636+
retention-days: 90
637+
if-no-files-found: error
638+
609639
publish-fern-docs:
610640
name: Publish Fern Docs
611-
needs: [release]
641+
needs: [compute-versions, release]
642+
if: needs.compute-versions.outputs.is_prerelease != 'true'
612643
runs-on: ubuntu-latest
613644
timeout-minutes: 15
614645
steps:
@@ -635,6 +666,7 @@ jobs:
635666
publish-sdk-typescript:
636667
name: Publish TypeScript SDK
637668
needs: [compute-versions, release]
669+
if: needs.compute-versions.outputs.is_prerelease != 'true'
638670
runs-on: linux-amd64-cpu8
639671
timeout-minutes: 15
640672
permissions:
@@ -673,6 +705,7 @@ jobs:
673705
release-helm:
674706
name: Release Helm Chart (OCI)
675707
needs: [compute-versions, release, tag-ghcr-release]
708+
if: needs.compute-versions.outputs.is_prerelease != 'true'
676709
runs-on: ubuntu-latest
677710
timeout-minutes: 10
678711
permissions:
@@ -691,6 +724,7 @@ jobs:
691724
trigger-wheel-publish:
692725
name: Trigger Wheel Publish
693726
needs: [compute-versions, release]
727+
if: needs.compute-versions.outputs.is_prerelease != 'true'
694728
runs-on: [self-hosted, nv]
695729
timeout-minutes: 10
696730
steps:

‎.github/workflows/snap-package.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ on:
1717
required: true
1818
type: string
1919
description: "GitHub deployment environment for approval gates (e.g., latest/edge, latest/stable)"
20+
publish:
21+
required: false
22+
type: boolean
23+
default: true
24+
description: "Whether to upload the built snap to the Snap Store"
2025

2126
secrets:
2227
publish-credentials:
@@ -161,6 +166,7 @@ jobs:
161166
retention-days: 5
162167

163168
- name: Upload snap to Snap Store
169+
if: inputs.publish
164170
env:
165171
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.publish-credentials }}
166172
INPUTS_UPLOAD_CHANNEL: ${{ inputs.upload-channel }}

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ When behavior, commands, or development workflows change, review the related age
250250
- 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.
251251
- `fern/` contains the Fern site config, components, preview workflow inputs, and publish settings.
252252
- 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.
253-
- 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`.
253+
- 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.
254254
- Use the `update-docs` skill to scan recent commits and draft doc updates.
255255

256256
### Architecture Docs

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ mise run docs
456456

457457
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.
458458

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

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

‎python/release_tooling_test.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,32 @@ def test_dev_versions_share_one_build_identity() -> None:
4444
assert versions.rpm_release == "0.dev.108.g152d05940"
4545

4646

47+
def test_prerelease_versions_share_tag_identity() -> None:
48+
versions = release._versions_from_prerelease(
49+
(0, 1, 0), 1, "152d05940", "v0.1.0-pre.1"
50+
)
51+
52+
assert versions.python == "0.1.0-pre.1"
53+
assert versions.cargo == "0.1.0-pre.1"
54+
assert versions.npm == "0.1.0-pre.1"
55+
assert versions.docker == "0.1.0-pre.1"
56+
assert versions.deb == "0.1.0~pre.1-1"
57+
assert versions.snap == "0.1.0-pre.1"
58+
assert versions.rpm_version == "0.1.0"
59+
assert versions.rpm_release == "0.pre.1"
60+
61+
4762
def test_semver_tag_parser_excludes_vm_tags() -> None:
4863
assert release._parse_semver_tag("v0.0.37") == (0, 0, 37)
4964
assert release._parse_semver_tag("0.0.37") == (0, 0, 37)
65+
assert release._parse_semver_tag("v0.1.0-pre.1") is None
5066
assert release._parse_semver_tag("vm-runtime") is None
5167
assert release._parse_semver_tag("vm-dev") is None
68+
69+
70+
def test_prerelease_tag_parser_requires_positive_sequence() -> None:
71+
assert release._parse_prerelease_tag("v0.1.0-pre.1") == (0, 1, 0, 1)
72+
assert release._parse_prerelease_tag("0.1.0-pre.12") == (0, 1, 0, 12)
73+
assert release._parse_prerelease_tag("v0.1.0") is None
74+
assert release._parse_prerelease_tag("v0.1.0-pre.0") is None
75+
assert release._parse_prerelease_tag("v0.1.0-rc.1") is None

‎tasks/scripts/release.py‎

Lines changed: 66 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@
1212
from dataclasses import asdict, dataclass
1313
from pathlib import Path
1414

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

1820

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

6870

71+
def _parse_prerelease_tag(tag: str) -> tuple[int, int, int, int] | None:
72+
match = PRERELEASE_TAG_RE.match(tag)
73+
if match is None:
74+
return None
75+
return (
76+
int(match.group("major")),
77+
int(match.group("minor")),
78+
int(match.group("patch")),
79+
int(match.group("sequence")),
80+
)
81+
82+
6983
def _format_semver(version: tuple[int, int, int]) -> str:
7084
return f"{version[0]}.{version[1]}.{version[2]}"
7185

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

7690

77-
def _latest_semver_tag() -> str | None:
78-
try:
79-
tag = _git(
80-
["describe", "--tags", "--match", SEMVER_TAG_GLOB, "--abbrev=0", "HEAD"]
81-
)
82-
except subprocess.CalledProcessError:
83-
return None
91+
def _exact_release_tag() -> str | None:
92+
tags = _git(["tag", "--points-at", "HEAD"]).splitlines()
93+
stable = [(version, tag) for tag in tags if (version := _parse_semver_tag(tag))]
94+
if stable:
95+
return max(stable)[1]
96+
97+
prereleases = [
98+
(version, tag) for tag in tags if (version := _parse_prerelease_tag(tag))
99+
]
100+
return max(prereleases)[1] if prereleases else None
84101

85-
if _parse_semver_tag(tag) is None:
86-
raise RuntimeError(f"git describe returned non-semver release tag: {tag}")
87-
return tag
102+
103+
def _latest_stable_tag() -> str | None:
104+
tags = _git(["tag", "--merged", "HEAD", "--list", "v*.*.*"]).splitlines()
105+
stable = [(version, tag) for tag in tags if (version := _parse_semver_tag(tag))]
106+
return max(stable)[1] if stable else None
88107

89108

90109
def _versions_from_parts(
@@ -142,10 +161,45 @@ def _versions_from_parts(
142161
)
143162

144163

164+
def _versions_from_prerelease(
165+
base_version: tuple[int, int, int],
166+
sequence: int,
167+
git_sha: str,
168+
git_tag: str,
169+
) -> Versions:
170+
version = f"{_format_semver(base_version)}-pre.{sequence}"
171+
return Versions(
172+
python=version,
173+
cargo=version,
174+
npm=version,
175+
docker=version,
176+
deb=f"{_format_semver(base_version)}~pre.{sequence}-1",
177+
snap=version,
178+
rpm_version=_format_semver(base_version),
179+
rpm_release=f"0.pre.{sequence}",
180+
git_tag=git_tag,
181+
git_sha=git_sha,
182+
git_distance=0,
183+
)
184+
185+
145186
def _compute_versions() -> Versions:
146-
git_tag = _latest_semver_tag()
147187
git_sha = _git(["rev-parse", "--short=9", "HEAD"])
188+
exact_tag = _exact_release_tag()
189+
190+
if exact_tag is not None:
191+
stable = _parse_semver_tag(exact_tag)
192+
if stable is not None:
193+
return _versions_from_parts(stable, 0, git_sha, exact_tag)
194+
195+
prerelease = _parse_prerelease_tag(exact_tag)
196+
if prerelease is None:
197+
raise RuntimeError(f"invalid semantic release tag: {exact_tag}")
198+
return _versions_from_prerelease(
199+
prerelease[:3], prerelease[3], git_sha, exact_tag
200+
)
148201

202+
git_tag = _latest_stable_tag()
149203
if git_tag is None:
150204
base_version = (0, 0, 0)
151205
git_distance = int(_git(["rev-list", "--count", "HEAD"]))

0 commit comments

Comments
 (0)