ci(release): infer optional workflow tags (#4707)

* ci(release): infer optional workflow tags

Signed-off-by: Simon Scatton <sscatton@nvidia.com>

* fix(release): use resolved tag in success notifications

Signed-off-by: Simon Scatton <sscatton@nvidia.com>

* fix(release): pin prerelease notes to resolved candidate

Signed-off-by: Simon Scatton <sscatton@nvidia.com>

---------

Signed-off-by: Simon Scatton <sscatton@nvidia.com>
This commit is contained in:
Simon Scatton
2026-10-26 12:50:45 +00:00
committed by GitHub
parent c4688aa871
commit 72bc3d3668
9 changed files with 270 additions and 68 deletions
+34 -3
View File
@@ -12,11 +12,17 @@ prerelease and changes since the previous stable release. Present the candidate
to the maintainer. Once approved, dispatch Release from that branch:
```shell
gh workflow run release.yml --ref main -f tag=vX.Y.Z
gh workflow run release.yml --ref main
```
Use the support branch instead of `main` for a previous minor version. Release
selects the highest-numbered `vX.Y.Z-pre.N` reachable from that branch with a
Without `tag`, Release detects the highest prerelease version reachable from the
selected branch, then selects its highest-numbered passing candidate. It stops
if that version has no qualified candidate or already has a stable tag. Supply
`-f tag=vX.Y.Z` to select a version explicitly or retry a stable release.
Use the support branch instead of `main` for a previous minor version. That
branch must contain the updated release workflows. Release selects the
highest-numbered `vX.Y.Z-pre.N` reachable from that branch with a
successful Pre-release run triggered by that tag or dispatched on the same branch,
and matching passing `release-tag-v1` qualification evidence. Missing or expired evidence blocks
promotion. Inspect the selected candidate in the promotion job summary.
@@ -39,6 +45,31 @@ created using `GITHUB_TOKEN`, dispatch Pre-release explicitly on the intended br
gh workflow run release-prerelease.yml --ref main -f tag=vX.Y.Z-pre.1
```
Once the first tag seeds a series, omit `tag` to create the next candidate at
the selected branch's dispatch commit:
```shell
gh workflow run release-prerelease.yml --ref main
```
This selects the highest release series reachable from that branch and requires
its `-pre.1` seed there. It stops if that version is already stable. New version
series always need an explicit seed. Manual runs create a new candidate even
without new commits; pass an existing tag explicitly to retry that candidate.
Candidate creation is serialized with Release Auto-Tag, which still skips
unchanged commits and inactive series on `main`.
For example, start a support patch series by tagging its intended branch commit
as `v0.4.4-pre.1`. Subsequent candidates and promotion use the support branch:
```shell
gh workflow run release-prerelease.yml --ref release/0.4
gh workflow run release.yml --ref release/0.4
```
Replace `release/0.4` with the actual support branch. Use explicit tag inputs if
its history also contains a higher version series.
Wait for the entire Pre-release run to pass before promoting it. Stable tag pushes do not
start Release. Release Auto-Tag creates and dispatches prereleases on `main`.
@@ -176,6 +176,13 @@ selects the latest passing prerelease for the requested version and branch,
verifies its qualification evidence, creates the stable tag at its exact commit,
then rebuilds and publishes without rerunning qualification. The promotion job
summary and `stable-promotion-*` artifact record the candidate and evidence.
Both workflows accept optional tags. Tagless Pre-release creates the next
candidate in the selected branch's seeded series; inspect Resolve Prerelease Tag
for the actual tag and commit. Tagless Release infers the highest reachable
prerelease version and stops if it is already stable. Explicit tags select an
existing candidate or stable version for retries. Release Auto-Tag and manual
candidate creation share a concurrency group; pending tag-resolution jobs can
be waiting for that lock.
Failed, cancelled, or skipped required suites fail
qualification; stable publication is blocked, while published pre-release
artifacts remain available. The Pre-release failure notification includes the
+17 -43
View File
@@ -21,69 +21,43 @@ jobs:
create-tag:
runs-on: ubuntu-latest
timeout-minutes: 5
concurrency:
group: release-prerelease-tags
cancel-in-progress: false
queue: max
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
fetch-depth: 0
persist-credentials: false
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: "0.10.12"
python-version: "3.13"
enable-cache: false
- name: Determine next pre-release version
id: version
run: |
set -euo pipefail
tags=$(git tag -l)
latest=$(printf '%s\n' "$tags" | \
{ grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-pre\.[1-9][0-9]*)?$' || true; } | \
sort -V | tail -1)
if [[ ! "$latest" =~ ^(v[0-9]+\.[0-9]+\.[0-9]+)-pre\.([1-9][0-9]*)$ ]]; then
echo "No active pre-release series — skipping tag creation"
next=$(uv run --no-project python tasks/scripts/next-prerelease-tag.py --scheduled)
if [[ -z "${next}" ]]; then
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
base=${BASH_REMATCH[1]}
number=${BASH_REMATCH[2]}
if ! git show-ref --verify --quiet "refs/tags/${base}-pre.1"; then
echo "No ${base}-pre.1 seed — skipping tag creation"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if git show-ref --verify --quiet "refs/tags/${base}"; then
echo "$base is already stable — skipping tag creation"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git merge-base --is-ancestor "$latest" HEAD; then
echo "::error::Latest pre-release $latest is not an ancestor of main"
exit 1
fi
echo "Latest tag: $latest"
# Skip if no new commits since the latest tag
commit_count=$(git rev-list "${latest}..HEAD" --count)
echo "Commits since $latest: $commit_count"
if [ "$commit_count" -eq 0 ]; then
echo "No new commits since $latest — skipping tag creation"
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
next="${base}-pre.$((number + 1))"
if git tag -l "$next" | grep -q .; then
echo "::error::Tag $next already exists"
exit 1
fi
echo "next=$next" >> "$GITHUB_OUTPUT"
echo "Next tag: $next"
- name: Create and push tag
- name: Create tag
if: steps.version.outputs.skip != 'true'
env:
GH_TOKEN: ${{ github.token }}
NEXT_TAG: ${{ steps.version.outputs.next }}
run: |
git tag "$NEXT_TAG"
git push origin "refs/tags/$NEXT_TAG"
gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \
-f "ref=refs/tags/${NEXT_TAG}" -f "sha=$(git rev-parse HEAD)"
- name: Trigger Pre-release workflow
if: steps.version.outputs.skip != 'true'
+2 -2
View File
@@ -11,7 +11,7 @@ on:
required: true
type: string
expected-source-sha:
description: Qualified source commit that the release tag must resolve to
description: Expected source commit that the release tag must resolve to
required: false
default: ""
type: string
@@ -89,7 +89,7 @@ jobs:
run: |
set -euo pipefail
if [[ -n "${EXPECTED_SOURCE_SHA}" && "$(git rev-parse HEAD)" != "${EXPECTED_SOURCE_SHA}" ]]; then
echo "Release tag ${RELEASE_TAG} does not match the qualified commit ${EXPECTED_SOURCE_SHA}." >&2
echo "Release tag ${RELEASE_TAG} does not match the expected commit ${EXPECTED_SOURCE_SHA}." >&2
exit 1
fi
if [[ "${RELEASE_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-pre\.[1-9][0-9]*$ ]]; then
+78 -14
View File
@@ -10,43 +10,107 @@ on:
workflow_dispatch:
inputs:
tag:
description: "Pre-release tag to publish and qualify (e.g. v0.0.4-pre.1)"
required: true
description: "Existing prerelease tag (e.g. v0.0.4-pre.1); omit to create the next candidate on this branch"
required: false
type: string
permissions:
contents: read
jobs:
resolve:
name: Resolve Prerelease Tag
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
concurrency:
group: release-prerelease-tags
cancel-in-progress: false
queue: max
outputs:
tag: ${{ steps.tag.outputs.tag }}
source-sha: ${{ steps.tag.outputs.source_sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
persist-credentials: false
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
with:
version: "0.10.12"
python-version: "3.13"
enable-cache: false
- name: Resolve or create candidate tag
id: tag
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
EVENT_NAME: ${{ github.event_name }}
REF_TYPE: ${{ github.ref_type }}
REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
tag="${RELEASE_TAG}"
if [[ "${EVENT_NAME}" == "push" ]]; then
tag="${REF_NAME}"
fi
if [[ -z "${tag}" ]]; then
test "${REF_TYPE}" = branch
tag=$(uv run --no-project python tasks/scripts/next-prerelease-tag.py)
source_sha=$(git rev-parse HEAD)
gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \
-f "ref=refs/tags/${tag}" -f "sha=${source_sha}"
else
if [[ ! "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-pre\.[1-9][0-9]*$ ]]; then
echo "Unsupported prerelease tag: ${tag}" >&2
exit 1
fi
source_sha=$(git rev-parse "refs/tags/${tag}^{commit}")
fi
{
echo "tag=${tag}"
echo "source_sha=${source_sha}"
} >> "${GITHUB_OUTPUT}"
{
echo "## Prerelease candidate"
echo "- Tag: \`${tag}\`"
echo "- Source commit: \`${source_sha}\`"
} >> "${GITHUB_STEP_SUMMARY}"
build:
name: Build
needs: resolve
permissions:
actions: read
contents: read
packages: write
uses: ./.github/workflows/release-build.yml
with:
release-tag: ${{ inputs.tag || github.ref_name }}
release-tag: ${{ needs.resolve.outputs.tag }}
expected-source-sha: ${{ needs.resolve.outputs.source-sha }}
is-prerelease: true
secrets:
CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }}
release-notes:
name: Preview Release Notes
needs: build
needs: [resolve, build]
permissions:
contents: read
pull-requests: read
uses: ./.github/workflows/release-notes.yml
with:
release-tag: ${{ inputs.tag || github.ref_name }}
release-tag: ${{ needs.resolve.outputs.tag }}
advisory: true
secrets:
LLM_INFERENCE_API_KEY: ${{ secrets.LLM_INFERENCE_API_KEY }}
publish:
name: Publish
needs: build
needs: [resolve, build]
permissions:
actions: read
contents: write
@@ -57,7 +121,7 @@ jobs:
artifact-metadata: write
uses: ./.github/workflows/release-publish.yml
with:
release-tag: ${{ inputs.tag || github.ref_name }}
release-tag: ${{ needs.resolve.outputs.tag }}
source-sha: ${{ needs.build.outputs.source_sha }}
semver: ${{ needs.build.outputs.semver }}
python-version: ${{ needs.build.outputs.python_version }}
@@ -68,7 +132,7 @@ jobs:
qualify:
name: Qualify
needs: build
needs: [resolve, build]
permissions:
actions: read
contents: read
@@ -77,7 +141,7 @@ jobs:
pull-requests: read
uses: ./.github/workflows/release-qualification.yml
with:
release-tag: ${{ inputs.tag || github.ref_name }}
release-tag: ${{ needs.resolve.outputs.tag }}
source-sha: ${{ needs.build.outputs.source_sha }}
is-prerelease: true
secrets:
@@ -86,7 +150,7 @@ jobs:
notify-prerelease-success:
name: Notify Prerelease Success
needs: [build, publish, qualify]
needs: [resolve, build, publish, qualify]
if: success()
runs-on: ubuntu-latest
timeout-minutes: 2
@@ -95,10 +159,10 @@ jobs:
- name: Send Slack notification
continue-on-error: true
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_OPENSHELL_TRIAGE_WEBHOOK_URL }}
RUN_URL: ${{ format('{0}/{1}/actions/runs/{2}/attempts/{3}', github.server_url, github.repository, github.run_id, github.run_attempt) }}
TAG_URL: ${{ format('{0}/{1}/tree/{2}', github.server_url, github.repository, inputs.tag || github.ref_name) }}
TAG_URL: ${{ format('{0}/{1}/tree/{2}', github.server_url, github.repository, needs.resolve.outputs.tag) }}
run: |
set -euo pipefail
if [[ -z "${SLACK_WEBHOOK_URL}" ]]; then
@@ -114,7 +178,7 @@ jobs:
notify-prerelease-failure:
name: Notify Prerelease Failure
needs: [build, publish, qualify]
needs: [resolve, build, publish, qualify]
if: failure()
runs-on: ubuntu-latest
timeout-minutes: 2
@@ -127,7 +191,7 @@ jobs:
- name: Send Slack notification
continue-on-error: true
env:
RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
RELEASE_TAG: ${{ needs.resolve.outputs.tag || inputs.tag || github.ref_name }}
PUBLICATION_RESULT: ${{ needs.publish.result }}
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_OPENSHELL_TRIAGE_WEBHOOK_URL }}
SLACK_MENTION: ${{ secrets.SLACK_OPENSHELL_TRIAGE_MENTION }}
+3 -3
View File
@@ -7,8 +7,8 @@ on:
workflow_dispatch:
inputs:
tag:
description: "Stable version to promote from its latest passing prerelease (e.g. v0.0.4)"
required: true
description: "Stable version (e.g. v0.0.4); omit to detect the highest prerelease version on this branch"
required: false
type: string
notes-only:
description: "Generate a release notes artifact without promoting or publishing"
@@ -87,7 +87,7 @@ jobs:
- name: Retain promotion record
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: stable-promotion-${{ inputs.tag }}-${{ github.run_id }}-${{ github.run_attempt }}
name: stable-promotion-${{ steps.candidate.outputs.tag }}-${{ github.run_id }}-${{ github.run_attempt }}
path: promotion.json
retention-days: 90
if-no-files-found: error
+34 -2
View File
@@ -355,7 +355,34 @@ gh workflow run release.yml --ref main \
-f tag=v0.1.4 -f notes-only=true
```
Dispatch Release from the intended release branch with a stable version:
Both release workflows accept an optional `tag` input. After seeding a version
series with a `vMAJOR.MINOR.PATCH-pre.1` tag on the intended branch, dispatch
Pre-release without a tag to create its next candidate:
```shell
gh workflow run release-prerelease.yml --ref main
```
Pre-release chooses the highest release series reachable from the selected
branch and requires that series' `-pre.1` seed in its history. It stops if the
version is already stable or no series exists. Candidate numbers account for
all tags in the same series across the repository, and tag creation is serialized
with Release Auto-Tag. The new tag points to the branch's dispatch commit;
build, publication, qualification, and notifications use the resolved tag.
An explicit tag continues to build that existing candidate. Manual tagless runs
create a candidate even without new commits, so use an explicit tag for retries.
Release Auto-Tag continues to skip unchanged commits and inactive series on `main`.
Dispatch Release from the intended release branch:
```shell
gh workflow run release.yml --ref main
```
Without `tag`, Release detects the highest prerelease version reachable from
that branch, then selects a qualified candidate within that version. It stops
if none qualifies or that version is already stable. Supply a stable version
to select it explicitly or retry a stable release:
```shell
gh workflow run release.yml --ref main -f tag=v0.4.3
@@ -364,6 +391,11 @@ gh workflow run release.yml --ref main -f tag=v0.4.3
For a support release, replace `main` with its support branch. Release chooses
the highest-numbered `v0.4.3-pre.N` reachable from the selected branch with a
successful Pre-release run triggered by the tag or dispatched on that branch.
The support branch must contain these workflow definitions. Start each new
support patch series with an explicit seed such as `v0.4.4-pre.1`; subsequent
Pre-release and Release dispatches can omit `tag`. If the branch's history also
contains a higher version series, supply `tag` explicitly to choose the intended
support version.
The entire Pre-release run must succeed, including publication. If qualification
passes but publication fails, rerun the failed jobs before promoting the candidate.
It verifies the qualification summary's
@@ -641,7 +673,7 @@ These workflows run after merge to publish dev/tagged artifacts and verify them.
| `.github/workflows/release-dev.yml` | Publishes the rolling `dev` build on every push to `main`. Builds gateway, sandbox, and supervisor images and binaries, packages, wheels, and pushes the Helm chart as `oci://ghcr.io/nvidia/openshell/helm-chart:0.0.0-dev` (plus an immutable `0.0.0-dev.<sha>` pin). Also dispatchable manually. |
| `.github/workflows/release.yml` | Manually promote the latest qualified prerelease for a version and branch, create its stable tag, then rebuild and publish. Support releases preserve shared latest channels. Publishes the prerelease's notes preview, retagged, or generates notes when it is unavailable. The notes-only mode generates a release notes preview artifact. |
| `.github/workflows/release-auto-tag.yml` | Weekdays at 14:00 Europe/Zurich, tags the next `-pre.N` on `main` when commits landed since the latest one, then dispatches Pre-release. It skips when the train has no `-pre.1` or its version is already stable, so push each train's `-pre.1` tag by hand. |
| `.github/workflows/release-prerelease.yml` | Pre-release orchestrator, triggered by prerelease tag pushes or dispatched by Release Auto-Tag or manually: build, then publish and qualify in parallel. Qualification failure fails the run and sends the failure notification; published artifacts remain available. |
| `.github/workflows/release-prerelease.yml` | Pre-release orchestrator, triggered by prerelease tag pushes or dispatched by Release Auto-Tag or manually. A tagless dispatch creates the next candidate on its branch; explicit tags select existing candidates. Builds, then publishes and qualifies in parallel. Qualification failure fails the run and sends the failure notification; published artifacts remain available. |
| `.github/workflows/release-build.yml` | Shared version computation, binary and image builds, and package builds. Validates that the tag matches the caller's release kind. |
| `.github/workflows/release-publish.yml` | Shared artifact assembly, OCI image tags and Helm chart publication, and package publication. Stable releases also publish to GitHub Releases, snap, npm, PyPI, and Fern. |
| `.github/workflows/release-notes.yml` | Shared prompt-driven release notes generation through Inference Hub, used by prerelease previews, notes-only previews, and stable publication when no preview is reused. |
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
"""Choose the next candidate in the checked-out branch's seeded prerelease series."""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
def git(*args: str) -> str:
return subprocess.check_output(["git", *args], text=True).strip()
def next_tag(*, scheduled: bool = False) -> str:
versions = {}
for tag in git("tag", "--merged", "HEAD", "--list", "v*").splitlines():
match = re.fullmatch(
r"v([0-9]+)\.([0-9]+)\.([0-9]+)(?:-pre\.([1-9][0-9]*))?", tag
)
if match:
versions[tag] = (
*map(int, match.groups()[:3]),
match[4] is None,
int(match[4] or 0),
)
if not versions:
raise ValueError(
"No release series on this branch; create a vMAJOR.MINOR.PATCH-pre.1 seed"
)
latest = max(versions, key=versions.__getitem__)
base, separator, _ = latest.partition("-pre.")
if not separator or git("tag", "--list", base):
raise ValueError(
f"{base} is already stable; seed the next prerelease version explicitly"
)
if f"{base}-pre.1" not in versions:
raise ValueError(f"No {base}-pre.1 seed is reachable from this branch")
if scheduled and git("rev-list", f"{latest}..HEAD", "--count") == "0":
return ""
# Tag names are repository-wide even when branches share the same series.
numbers = [
int(tag.rsplit(".", 1)[1])
for tag in git("tag", "--list", f"{base}-pre.*").splitlines()
if re.fullmatch(re.escape(base) + r"-pre\.[1-9][0-9]*", tag)
]
return f"{base}-pre.{max(numbers) + 1}"
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--scheduled",
action="store_true",
help="Skip inactive series and unchanged commits",
)
args = parser.parse_args()
try:
tag = next_tag(scheduled=args.scheduled)
except ValueError as error:
if not args.scheduled:
parser.error(str(error))
print(error, file=sys.stderr)
return
if tag:
print(tag)
if __name__ == "__main__":
main()
+19 -1
View File
@@ -61,6 +61,24 @@ def is_latest(tag: str) -> bool:
def select_candidate(tag: str, branch: str) -> dict:
if not tag:
versions = {}
for candidate in git(
"tag", "--merged", "HEAD", "--list", "v*-pre.*"
).splitlines():
match = re.fullmatch(
r"(v([0-9]+)\.([0-9]+)\.([0-9]+))-pre\.[1-9][0-9]*", candidate
)
if match:
versions[match[1]] = tuple(map(int, match.groups()[1:]))
if not versions:
raise ValueError(f"No prerelease versions are reachable from {branch}")
tag = max(versions, key=versions.__getitem__)
if git("tag", "--list", tag):
raise ValueError(
f"{tag} is already stable; supply its tag explicitly to retry"
)
if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", tag):
raise ValueError("Stable tag must match vMAJOR.MINOR.PATCH")
@@ -147,7 +165,7 @@ def select_candidate(tag: str, branch: str) -> dict:
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("tag")
parser.add_argument("tag", nargs="?", default="")
parser.add_argument("--branch", required=True)
args = parser.parse_args()
print(json.dumps(select_candidate(args.tag, args.branch)))