diff --git a/.github/workflows/release-tags.yml b/.github/workflows/release-tags.yml index bffafd26b..da3d2a198 100644 --- a/.github/workflows/release-tags.yml +++ b/.github/workflows/release-tags.yml @@ -7,23 +7,13 @@ on: push: branches: - main - - 'release-**/v*' tags: - '*-v*' - '**/v*' workflow_dispatch: inputs: - operation: - description: Release operation to run. - required: false - default: branch-cut - type: choice - options: - - branch-cut - - self-managed-branch-cut - - auto service: - description: Optional service id or service_name for operation=auto. Branch-cut operations select a fixed service. + description: Optional service id or service_name to scope the run to. Defaults to every registered service. required: false type: string @@ -47,10 +37,10 @@ env: jobs: # NVIDIA self-hosted runners. Eligible without a conditional because this - # workflow has no pull_request trigger: it fires on pushes to main and to - # release branches, on tags, and on manual dispatch, all of which run trusted - # reviewed code. The bazel matrix needs an event-conditional runs-on for - # exactly that reason; this does not. + # workflow has no pull_request trigger: it fires on pushes to main, on tags, + # and on manual dispatch, all of which run trusted reviewed code. The bazel + # matrix needs an event-conditional runs-on for exactly that reason; this + # does not. # # cpu4 rather than cpu16: these jobs shell out to the GitHub API to cut tags # and create releases. They are latency-bound, not CPU-bound, and were sitting @@ -58,7 +48,7 @@ jobs: # work. service-release: name: service release automation - if: github.ref_type != 'tag' && (github.event_name != 'workflow_dispatch' || inputs.operation == 'auto') + if: github.ref_type != 'tag' runs-on: linux-amd64-cpu4 permissions: contents: write @@ -95,78 +85,18 @@ jobs: - name: Run service release automation env: - NVCF_GITHUB_RELEASE_SERVICE: ${{ github.event_name == 'workflow_dispatch' && inputs.service || '' }} + NVCF_GITHUB_RELEASE_SERVICE: ${{ inputs.service || '' }} run: | set -euo pipefail if [ "${NVCF_GITHUB_AUTO_TAGGING_ENABLED}" = "true" ] && \ [ "${NVCF_GITHUB_RELEASE_DRY_RUN}" = "false" ] && \ [ "${NV_GITHUB_TOKEN_CONFIGURED}" != "true" ]; then echo "ERROR: set secret NV_GITHUB_TOKEN before enabling GitHub auto-tag publish mode." >&2 - echo "The default GITHUB_TOKEN cannot trigger the release-branch/tag workflows that publish release artifacts." >&2 + echo "The default GITHUB_TOKEN cannot trigger the tag workflow that publishes release notes." >&2 exit 1 fi ./tools/ci/github-release auto - release-branch-cut: - name: NVCA release branch cut - if: github.event_name == 'workflow_dispatch' && inputs.operation == 'branch-cut' - runs-on: linux-amd64-cpu4 - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.event.repository.default_branch }} - token: ${{ secrets.NV_GITHUB_TOKEN || github.token }} - - - name: Cut release branch - env: - NVCF_GITHUB_RELEASE_SERVICE: nvca - NV_GITHUB_TOKEN_CONFIGURED: ${{ secrets.NV_GITHUB_TOKEN != '' }} - GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - set -euo pipefail - if [ "${NV_GITHUB_TOKEN_CONFIGURED}" != "true" ]; then - echo "ERROR: set secret NV_GITHUB_TOKEN before cutting a GitHub release branch." >&2 - echo "The default GITHUB_TOKEN cannot trigger CI for the generated VERSION bump PR." >&2 - exit 1 - fi - if [ -z "${NVCF_GITHUB_RELEASE_SERVICE}" ]; then - echo "ERROR: service is required when operation=branch-cut." >&2 - exit 1 - fi - ./tools/ci/github-release branch-cut --service "${NVCF_GITHUB_RELEASE_SERVICE}" - - self-managed-release-branch-cut: - name: self-managed stack release branch cut - if: github.event_name == 'workflow_dispatch' && inputs.operation == 'self-managed-branch-cut' - runs-on: linux-amd64-cpu4 - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.event.repository.default_branch }} - token: ${{ secrets.NV_GITHUB_TOKEN || github.token }} - - - name: Cut release branch - env: - NVCF_GITHUB_RELEASE_SERVICE: nvcf-self-managed-stack - NV_GITHUB_TOKEN_CONFIGURED: ${{ secrets.NV_GITHUB_TOKEN != '' }} - GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - run: | - set -euo pipefail - if [ "${NV_GITHUB_TOKEN_CONFIGURED}" != "true" ]; then - echo "ERROR: set secret NV_GITHUB_TOKEN before cutting a GitHub release branch." >&2 - echo "The default GITHUB_TOKEN cannot trigger CI for the generated VERSION bump PR." >&2 - exit 1 - fi - ./tools/ci/github-release branch-cut --service "${NVCF_GITHUB_RELEASE_SERVICE}" - tag-release-notes: name: tag release notes if: github.ref_type == 'tag' diff --git a/RELEASE.md b/RELEASE.md index d1e3dff47..997280d92 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -14,16 +14,10 @@ authoritative behavior; this document summarizes it for contributors. Releases are commit-triggered, not calendar-triggered. There is no fixed weekly or monthly cadence. On every push to `main`, the `service-release` job -runs `./tools/ci/github-release auto`. Each subproject registered in +runs `./tools/ci/github-release auto`. Every subproject registered in [`tools/ci/github-release-subprojects.json`](tools/ci/github-release-subprojects.json) -uses exactly one of two release models. They are mutually exclusive: the -automation branches on a subproject's `dev_prerelease` flag and only ever -runs one path for it, never both. - -### Semantic-release model - -Most subprojects, for example `src/clis/nvcf-cli`. Walks the commits since -the subproject's last release tag and decides whether to cut a new version. +uses the same model: semantic-release walks the commits since the +subproject's last release tag and decides whether to cut a new version. - Commits typed `feat`, `fix`, or `perf` (the "customer" commit types defined in [`CONTRIBUTING.md`](CONTRIBUTING.md#how-to-select-a-commit-type)) trigger @@ -31,35 +25,31 @@ the subproject's last release tag and decides whether to cut a new version. - Commits typed `docs`, `build`, `test`, `refactor`, `ci`, `chore`, `style`, or `revert` (the "foundational" types) do not trigger a release on their own. +- A commit whose type carries a `!` breaking marker triggers a release + regardless of type. + +Only commits that touch a subproject's own path count toward its version. +`semantic-release-monorepo` scopes the commit analysis to the subtree, so a +`fix(grpc-proxy):` commit cannot release `nvcf-cli` and vice versa. -### Dev-prerelease model - -Only `nvca` and the three Helm stacks under `deploy/stacks/`: -`nvcf-compute-plane`, `self-managed`, and `observability`. A push to `main` -never cuts a stable release, regardless of commit type. It only bumps a -`-dev.N` prerelease tag off the stable base version recorded in -the subproject's `VERSION` file. In practice this means dev prereleases land -many times per day during active development; for example, at the time of -writing, `src/compute-plane-services/nvca` had cut five `-dev.N` prereleases -in a single day. A stable release for one of these four subprojects is cut -only when a commit lands on that subproject's release branch (see Branch -Naming), which bumps the patch version automatically. Until a release branch -exists for one of these subprojects, only dev prereleases accumulate; there -is no stable-release path on `main` for them. +`nvca` and the three Helm stacks under `deploy/stacks/` previously used a +separate model: a push to `main` only bumped a `-dev.N` prerelease read from +a `VERSION` file, and a stable version was cut only on a release branch. +Those subprojects now release from `main` like every other one, the `VERSION` +files are gone, and the `-dev.N` tags they already published remain in the +repository as history. Release notes are generated from commit messages (semantic-release -conventions) and attached to the GitHub Release for each tag, dev -prereleases included. +conventions) and attached to the GitHub Release for each tag. ## Branch Naming - `main`: the active development branch. All pull requests target `main`, except hotfixes (see [`CONTRIBUTING.md`](CONTRIBUTING.md#step-2-create-a-branch)). - `release-/vMAJOR.MINOR`: a maintenance branch for one - subproject's release train, used by the dev-prerelease release model (see - Release Cadence). Pushes to a branch matching `release-**/v*` run the same - release automation, scoped to that subproject, so patch fixes on a - maintenance train publish their own stable tags. + subproject's release train. These branches still run the build, test, lint, + and scan workflows, but they no longer cut release tags. Release automation + runs on `main` only. Real examples from this repository: @@ -70,61 +60,37 @@ Real examples from this repository: The separator between the service id and the version can vary by how a subproject registers its release metadata; for example `release-nvcf-cassandra-migrations-v0.10` uses a flattened id instead of a -path segment. That branch also predates this subproject's current -configuration, which does not use the dev-prerelease model, so treat it as -historical evidence of the naming pattern rather than a live example. Check -a subproject's entry in +path segment. Check a subproject's entry in [`tools/ci/github-release-subprojects.json`](tools/ci/github-release-subprojects.json) -for its current release model and exact branch name. +for its exact tag prefix and branch name. Tags follow the matching format `/vMAJOR.MINOR.PATCH`, for -example `src/clis/nvcf-cli/v1.15.11` (semantic-release model) and -`src/compute-plane-services/nvca/v3.3.0-dev.184` (dev-prerelease model). +example `src/clis/nvcf-cli/v1.15.11` and +`src/compute-plane-services/nvca/v3.4.0`. ## Who Can Trigger a Release -Automatic, semantic-release subprojects: any contributor whose reviewed pull -request merges to `main` has triggered a release for the subprojects their -commits touch, as long as at least one commit is a `feat`, `fix`, or `perf` -type. No separate release action is needed after merge. - -Automatic, dev-prerelease subprojects (`nvca` and the three stacks under -`deploy/stacks/`): merging any pull request to `main` only produces a dev -prerelease tag, regardless of commit type. Triggering an actual stable -release for one of these subprojects requires a commit to land on that -subproject's release branch instead of `main`. See Manual below for who can -create that branch in the first place. +Any contributor whose reviewed pull request merges to `main` has triggered a +release for the subprojects their commits touch, as long as at least one +commit is a `feat`, `fix`, or `perf` type. No separate release action is +needed after merge. Manual: `.github/workflows/release-tags.yml` also accepts a -`workflow_dispatch` trigger with three operations: - -- `auto`: re-run the same automatic logic on demand, optionally scoped to one - service. -- `branch-cut`: cut a new maintenance release branch and open the follow-up - version-bump pull request for the `nvca` service. -- `self-managed-branch-cut`: the same branch-cut flow for the - `nvcf-self-managed-stack` service. - -Both branch-cut operations only work for a dev-prerelease subproject; the -underlying `branch-cut` command refuses to run against a subproject that -does not use that release model. +`workflow_dispatch` trigger that re-runs the same automatic logic on demand, +optionally scoped to a single service through the `service` input. This is a +recovery path for a run that failed or was cancelled, not a way to force a +version that the commits do not justify. `workflow_dispatch` requires GitHub write access to the repository. In this repository that access is granted through organization team membership: -maintainers (`NVIDIA/nvcf-dev` and `NVIDIA/nvcf-admin`) can cut a release -branch. Only `nvca` and `nvcf-self-managed-stack` have a dedicated -`workflow_dispatch` operation; for `nvcf-compute-plane-stack` and -`nvcf-observability-stack`, a maintainer runs -`tools/ci/github-release branch-cut --service ` directly, since no CI -job wires up that operation for them yet. An external contributor with -a fix for an already-cut release branch needs a maintainer to either create -the branch or merge a -pull request that targets an existing one. Branch-cut operations -additionally require the `NV_GITHUB_TOKEN` repository secret to be -configured, because the default `GITHUB_TOKEN` cannot trigger CI on the -generated version-bump pull request. Area ownership for review is defined in +maintainers (`NVIDIA/nvcf-dev` and `NVIDIA/nvcf-admin`) can run it. Area +ownership for review is defined in [`.github/CODEOWNERS`](.github/CODEOWNERS). +Publishing additionally requires the `NV_GITHUB_TOKEN` repository secret, +because a tag pushed with the default `GITHUB_TOKEN` does not start the +follow-up tag workflow that creates release notes. + ## Artifact Destinations GitHub tags and GitHub Releases are the primary release artifact, one per @@ -145,19 +111,29 @@ step tied to a version tag. ## Backport Policy -Applies only to the four dev-prerelease subprojects (`nvca` and the three -stacks under `deploy/stacks/`), since those are the only subprojects that -cut release branches at all (see Release Cadence). Semantic-release -subprojects have no maintenance branch to backport to; a fix for one of -them ships by merging to `main` like any other change. - -Support window: for each of the four dev-prerelease subprojects, only the -latest minor release train and the one before it (N and N-1) are -maintained. A release branch older than N-1 is effectively end of life and -does not receive further backports. - -Mechanism: a fix lands on `main` first. To reach a supported release -branch, apply it there directly, for example by cherry-picking the commit -to the `release-*` branch, following the same commit and review -conventions as `main`. There is no automation that backports a commit for -you. +A fix lands on `main` first, where it releases normally for every subproject +it touches. + +Maintenance branches are the exception, not the default. Most subprojects do +not have one: a fix ships by merging to `main` like any other change. Open a +maintenance branch only when a released train needs a fix that cannot wait +for, or must not carry, the current state of `main`. + +Support window: where a subproject does maintain release branches, only the +latest minor release train and the one before it (N and N-1) are maintained. +A release branch older than N-1 is effectively end of life and does not +receive further backports. + +Mechanism: cherry-pick the commit from `main` onto the `release-*` branch, +following the same commit and review conventions as `main`. Because release +automation runs on `main` only, a maintainer then creates the patch tag by +hand: + +```sh +git tag /vMAJOR.MINOR.PATCH +git push origin /vMAJOR.MINOR.PATCH +``` + +Push the tag with a token that can start workflows, so the tag workflow +creates the matching GitHub Release. There is no automation that backports a +commit or cuts a maintenance release for you. diff --git a/deploy/stacks/nvcf-compute-plane/VERSION b/deploy/stacks/nvcf-compute-plane/VERSION deleted file mode 100644 index 0ea3a944b..000000000 --- a/deploy/stacks/nvcf-compute-plane/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.2.0 diff --git a/deploy/stacks/observability/VERSION b/deploy/stacks/observability/VERSION deleted file mode 100644 index 77d6f4ca2..000000000 --- a/deploy/stacks/observability/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.0.0 diff --git a/deploy/stacks/self-managed/VERSION b/deploy/stacks/self-managed/VERSION deleted file mode 100644 index a3df0a695..000000000 --- a/deploy/stacks/self-managed/VERSION +++ /dev/null @@ -1 +0,0 @@ -0.8.0 diff --git a/docs/dev/github-release-process.md b/docs/dev/github-release-process.md index c7c3b79fd..89655c0f9 100644 --- a/docs/dev/github-release-process.md +++ b/docs/dev/github-release-process.md @@ -52,6 +52,10 @@ image release pipeline. ## Service auto-tags +Releases are cut from the default branch only. A push to a `release-*` +maintenance branch still runs the build, test, lint, and scan +workflows, but cuts no tag; a patch on such a branch is tagged by hand. + On `main` branch pushes, the workflow runs: ```bash @@ -68,7 +72,6 @@ intentionally contains only public release metadata: yet - legacy service tag prefix, when a release line still needs old-tag compatibility -- version-file hints for services that do not use semantic-release - generated/mechanical file basenames to ignore for release decisions It does not contain internal runner tags, Vault paths, NGC registry @@ -95,40 +98,29 @@ uses those old tags as version anchors but creates any new tags with the path-scoped tag derived from the service path, unless the metadata declares an explicit `tag_format` override. -Services that declare both `version_file` and `dev_prerelease`, such -as NVCA and `nvcf-compute-plane-stack`, do not use semantic-release -for the next version. On `main`, the GitHub workflow reads the stable -base version from the version file and creates the next path-format dev -prerelease tag: - -```text -src/compute-plane-services/nvca/v-dev.N -``` - -On a matching release branch, the workflow creates the next stable -patch tag for that train. +NVCA and the three stacks under `deploy/stacks/` used to opt out of +this, reading a stable base version from a `VERSION` file and cutting +`-dev.N` prereleases on `main`. They no longer do. Every registered +service now takes its next version from semantic-release, the `VERSION` +files are gone, and the `-dev.N` tags already published stay in the +repository as history. See "Retiring the version-file model" below for +the anchors that carried those version lines across. -Every stable release the workflow creates from a version file comments -the version it shipped on the pull requests that release covers, which -is the note `@semantic-release/github` posts for the services it -manages: +Every release the workflow creates comments the version it shipped on +the pull requests that release covers, which is the note +`@semantic-release/github` posts for the services it manages: ```text This PR is included in version 3.2.14. ``` -Dev prerelease tags stay silent, and a re-run over a release that -already exists does not comment again. The commented range starts at -the closest release tag reachable from the branch rather than the -highest-sorting tag, because a release branch is cut with a synthetic -root and never contained most default-branch tags. It covers every -commit since that tag rather than only the tagged commit, because the -workflow's concurrency group cancels queued runs and a superseded push -is first tagged by the next run to finish. - -The self-managed stack is not in this auto-tag set until it has a -monorepo version source. Its release config currently keeps default -branch release tagging disabled. +A re-run over a release that already exists does not comment again. The +commented range starts at the closest release tag reachable from the +branch rather than the highest-sorting tag, because the highest tag can +sit on a maintenance branch this history never contained. It covers +every commit since that tag rather than only the tagged commit, because +the workflow's concurrency group cancels queued runs and a superseded +push is first tagged by the next run to finish. For `nvcf-compute-plane-stack`, GitHub-created `deploy/stacks/nvcf-compute-plane/v*` tags are mirrored. The scheduled @@ -237,6 +229,11 @@ Package metadata uses SemVer without the leading `v`: ## Release branches +Release automation does not cut or tag these branches; it runs on the +default branch only. The convention below is what the `tag` command +reports in release notes, and what a maintainer follows when creating a +maintenance branch or tagging a patch on one. + Release branch names use: ```text @@ -253,6 +250,51 @@ Examples: Slashes remain branch namespace separators. +## Retiring the version-file model + +NVCA, `nvcf-compute-plane-stack`, `nvcf-self-managed-stack`, and +`nvcf-observability-stack` used to declare `version_file` and +`dev_prerelease`. On `main` they cut `/v-dev.N` from a +`VERSION` file, and a stable version only appeared on a release branch. +They now use semantic-release like every other service. + +`initial_version` alone could not carry those version lines across. +The floor is only synthesized when a service has no tags at all, and +each of the four had hundreds of `-dev.N` tags. Their stable tags were +also unreachable from `main`: the NVCA 3.2 line lives on +`release-src/compute-plane-services/nvca/v3.2`, and two of the stacks +had no stable tag at all. Left alone, semantic-release would have +restarted each line at `0.1.0`. + +Each line was carried across with one `anchor` per service, at the +version its `VERSION` file last held: + +| Service | Anchored version | +| --- | --- | +| `nvca` | `3.3.0` | +| `nvcf-compute-plane-stack` | `0.2.0` | +| `nvcf-self-managed-stack` | `0.8.0` | +| `nvcf-observability-stack` | `0.0.0` | + +Anchor each one on the newest commit that touched its own subtree, not +on the commit its newest `-dev.N` tag points at. All four services +received a dev tag on every default-branch push, so their newest dev +tags share one commit, and `refs/notes/semantic-release` holds one note +per commit. Anchoring them all on that shared commit would fail on the +second service. + +```bash +sha="$(git log -n1 --format=%H origin/main -- src/compute-plane-services/nvca)" +./tools/ci/github-release anchor --service nvca --version 3.3.0 --ref "${sha}" --dry-run +./tools/ci/github-release anchor --service nvca --version 3.3.0 --ref "${sha}" --push +``` + +`anchor` writes a tag and a note; it does not create a GitHub Release. +The internal release dispatcher reacts to Releases, so an anchor starts +no image pipeline. The first real release for each service is the next +bump above its anchor, for example `3.3.1` for an NVCA `fix` or `3.4.0` +for a `feat`. + ## Cutover anchors GitHub release publishing needs both the latest service tag and the diff --git a/docs/dev/release-process.md b/docs/dev/release-process.md index 0c033378c..1f889e338 100644 --- a/docs/dev/release-process.md +++ b/docs/dev/release-process.md @@ -515,7 +515,7 @@ and we accept its outcome silently. - `tools/ci/generated-release-jobs.yml`: generated output the umbrella `.gitlab-ci.yml` includes; do not hand-edit. - `BAZEL.md` at the repo root: Bazel-build-related conventions. -- `deploy/stacks/self-managed/.gitlab-ci.yml`: the self-managed - stack's release flow (helmfile-based bundle, not individual chart - push). Different shape from service releases; same semantic-release - driver. +- `deploy/stacks/self-managed/`: the self-managed stack publishes a + helmfile-based bundle rather than an individual chart, so its + packaging differs from a service release. Its version comes from the + same semantic-release driver as every other subproject. diff --git a/src/compute-plane-services/nvca/AGENTS.md b/src/compute-plane-services/nvca/AGENTS.md index e89e759dc..ee0c44e0c 100644 --- a/src/compute-plane-services/nvca/AGENTS.md +++ b/src/compute-plane-services/nvca/AGENTS.md @@ -421,30 +421,39 @@ Useful local test variables: ## Versioning & Tag Formats -This project uses [Semantic Versioning](https://semver.org/) with a `v` prefix for git tags. +NVCA uses [Semantic Versioning](https://semver.org/). Tags are path-scoped, +because every subproject in this monorepo shares one tag namespace: -**Supported tag formats:** +```text +src/compute-plane-services/nvca/vMAJOR.MINOR.PATCH +``` -| Format | Description | Example | Audience | -|--------|-------------|---------|----------| -| `vMAJOR.MINOR.PATCH` | Release version | `v1.20.0` | QA / Production | -| `vMAJOR.MINOR.PATCH-dev.N` | Dev/prerelease build | `v1.20.0-dev.0` | Dev | -| `vMAJOR.MINOR.PATCH-rc.N` | Release candidate (stage) | `v1.20.0-rc.1` | QA | +Do not create a bare `vMAJOR.MINOR.PATCH` tag. That was the convention in the +standalone NVCA repository and it collides with every other subproject here. -**Version precedence (lowest to highest):** -- `v1.20.0-dev.0` < `v1.20.0-dev.1` < `v1.20.0-rc.1` < `v1.20.0` +Supported tag formats: -**Creating tags:** -```bash -# Release tag -git tag v1.20.0 +| Format | Description | Example | +|--------|-------------|---------| +| `/vMAJOR.MINOR.PATCH` | Release version | `src/compute-plane-services/nvca/v3.4.0` | +| `/vMAJOR.MINOR.PATCH-rc.N` | Release candidate | `src/compute-plane-services/nvca/v3.4.0-rc.1` | -# Dev build tag -git tag v1.20.0-dev.0 -``` +Version precedence, lowest to highest: `3.4.0-rc.1` < `3.4.0`. + +Creating tags on `main`: do not. Release automation cuts the tag when a +`feat`, `fix`, or `perf` commit touching this subtree merges to `main`. + +On a `release-*` maintenance branch there is no automation, so a maintainer +does create and push the patch tag by hand. See +[RELEASE.md](../../../RELEASE.md) at the repository root for that procedure +and the full model. + +NVCA previously cut `-dev.N` prereleases from a `VERSION` file on every push +to `main`. That model is retired: the `VERSION` file is gone and the existing +`-dev.N` tags remain only as history. -**CI behavior:** -- Tags should trigger release validation in the hosting environment. +CI behavior: +- Tags trigger release validation in the hosting environment. - Keep GitHub-facing documentation free of internal CI pipeline commands and generated pipeline artifacts. ## Observability diff --git a/src/compute-plane-services/nvca/VERSION b/src/compute-plane-services/nvca/VERSION deleted file mode 100644 index 15a279981..000000000 --- a/src/compute-plane-services/nvca/VERSION +++ /dev/null @@ -1 +0,0 @@ -3.3.0 diff --git a/tools/ci/github-release b/tools/ci/github-release index 8b3645fbc..57dfc0ca7 100755 --- a/tools/ci/github-release +++ b/tools/ci/github-release @@ -237,39 +237,10 @@ def tag_exists_for_version(service, version, root=None): return "" -def existing_tag_for_version(root, service, version): - for prefix in tag_prefixes(service, root): - tag = f"{prefix}{version}" - existing = run( - ["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag}"], - cwd=root, - capture=True, - check=False, - ).strip() - if existing: - return tag - return "" - - def tag_sha(root, tag): return run(["git", "rev-list", "-n", "1", tag], cwd=root, capture=True).strip() -def service_tags_matching_suffix(service, suffix, root=None): - tags = [] - for prefix in tag_prefixes(service, root): - raw = run(["git", "tag", "-l", f"{prefix}{suffix}"], cwd=root, capture=True) - tags.extend(line.strip() for line in raw.splitlines() if line.strip()) - return sorted(set(tags)) - - -def existing_matching_tag_at_sha(root, service, suffix, sha): - for tag in service_tags_matching_suffix(service, suffix, root): - if tag_sha(root, tag) == sha: - return tag - return "" - - def current_branch(root): if os.environ.get("GITHUB_REF_TYPE") == "branch" and os.environ.get("GITHUB_REF_NAME"): return os.environ["GITHUB_REF_NAME"] @@ -277,95 +248,6 @@ def current_branch(root): return "" if branch == "HEAD" else branch -def service_release_branch(service, version): - train = ".".join(version.split(".")[:2]) - return f"release-{tag_prefix(service)}{train}" - - -def service_version_bump_branch(service, version): - major, minor, _patch = [int(part) for part in version.split(".")] - return f"release-bump/{service['id']}/v{major}.{minor}-to-v{major}.{minor + 1}" - - -def next_release_train_version(version): - major, minor, _patch = [int(part) for part in version.split(".")] - return f"{major}.{minor + 1}.0" - - -def remote_branch_sha(root, branch): - raw = run(["git", "ls-remote", "--heads", "origin", branch], cwd=root, capture=True).strip() - if not raw: - return "" - return raw.split()[0] - - -def commit_tree(root, ref): - return run(["git", "rev-parse", "--verify", f"{ref}^{{tree}}"], cwd=root, capture=True).strip() - - -def linear_release_branch_base(root, base_sha): - merges = run( - ["git", "rev-list", "--topo-order", "--reverse", "--merges", base_sha], - cwd=root, - capture=True, - ).splitlines() - if not merges: - return base_sha - - # Keep the selected tree but root the release branch before the first merge - # in its history so the release ruleset sees only a linear commit graph. - first_merge = merges[0] - linear_parent = run( - ["git", "rev-parse", "--verify", f"{first_merge}^1"], cwd=root, capture=True - ).strip() - return run( - [ - "git", - "commit-tree", - commit_tree(root, base_sha), - "-p", - linear_parent, - "-m", - "chore(release): snapshot default branch for linear history", - ], - cwd=root, - capture=True, - ).strip() - - -def open_pr_url(root, source_branch, target_branch): - raw = run( - [ - "gh", - "pr", - "list", - "--state", - "open", - "--head", - source_branch, - "--base", - target_branch, - "--json", - "url", - ], - cwd=root, - capture=True, - ) - prs = json.loads(raw or "[]") - if prs: - return prs[0].get("url", "") - return "" - - -def ensure_git_identity(root): - name = run(["git", "config", "--get", "user.name"], cwd=root, capture=True, check=False).strip() - email = run(["git", "config", "--get", "user.email"], cwd=root, capture=True, check=False).strip() - if not name: - run(["git", "config", "user.name", "github-actions[bot]"], cwd=root) - if not email: - run(["git", "config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"], cwd=root) - - def current_service_tags(root, service): template = tag_format_template(service) tag_glob = template.replace(UPSTREAM_VERSION_PLACEHOLDER, "*").replace(VERSION_PLACEHOLDER, "*") @@ -595,33 +477,6 @@ def stale_checkout_output(output): return "is behind the remote" in output -def validate_version_file(root, service): - service_dir = root / service["path"] - version_file = service.get("version_file") or "" - version_path = service_dir / version_file - if not version_path.is_file(): - raise SystemExit(f"{service['id']}: version_file {version_file} not found") - - version = version_path.read_text().strip().lstrip("v") - if not re.fullmatch(SEMVER_PATTERN, version): - raise SystemExit(f"{service['id']}: {version_file} does not contain a supported SemVer: {version}") - - source_file = service.get("version_major_minor_source_file") or "" - if source_file: - source_path = service_dir / source_file - if not source_path.is_file(): - raise SystemExit(f"{service['id']}: version_major_minor_source_file {source_file} not found") - source = re.search(r"v?(\d+\.\d+\.\d+)", source_path.read_text()) - if not source: - raise SystemExit(f"{service['id']}: no semantic version found in {source_file}") - if ".".join(source.group(1).split(".")[:2]) != ".".join(version.split("-")[0].split(".")[:2]): - raise SystemExit( - f"{service['id']}: {version_file}={version} does not match " - f"{source_file} major/minor {source.group(1)}" - ) - return version - - def create_release(tag, title, notes, draft, dry_run): """Create the GitHub release. True when this call created it, False otherwise.""" if dry_run: @@ -658,9 +513,9 @@ def release_url(slug, tag): def ancestor_service_tag(root, service): """Closest service tag reachable from HEAD, by ancestry rather than by version sort. - Release branches are cut with a synthetic root (see linear_release_branch_base), - so the highest-sorting tag for a service can be a main-line tag that this branch - never contained. Ancestry is what actually bounds the commits a release ships. + The highest-sorting tag for a service can sit on a maintenance branch this one + never contained, so a version sort can name a tag that is not in this history. + Ancestry is what actually bounds the commits a release ships. Every supported prefix is matched in a single describe so the closest tag wins. Taking the first prefix to match instead would return a more distant tag whenever @@ -1009,64 +864,6 @@ def finish_semantic_release(root, service, components, exit_code, output, dry_ru return outcome -def publish_version_file_release(root, service, dry_run, draft): - version = validate_version_file(root, service) - existing = existing_tag_for_version(root, service, version) - if existing: - existing_sha = tag_sha(root, existing) - print(f"[github-release] {service['id']}: {existing} already exists at {existing_sha}; skipping") - return - publish_tag_for_version(root, service, version, dry_run, draft, f"{service.get('version_file') or 'version file'}") - - -def publish_dev_prerelease(root, service, dry_run, draft): - base_version = validate_version_file(root, service) - if "-" in base_version: - raise SystemExit(f"{service['id']}: dev_prerelease version_file must contain a stable X.Y.Z base, got {base_version}") - - branch = current_branch(root) - default_branch = os.environ.get("GITHUB_DEFAULT_BRANCH", "main") - head = run(["git", "rev-parse", "--verify", "HEAD^{commit}"], cwd=root, capture=True).strip() - if branch == default_branch: - existing_at_head = existing_matching_tag_at_sha(root, service, f"{base_version}-dev.*", head) - if existing_at_head: - print(f"[github-release] {service['id']}: {existing_at_head} already points at HEAD; skipping") - return - last_dev = -1 - pattern = re.compile(rf"^{re.escape(base_version)}-dev\.(\d+)$") - for tag in service_tags_matching_suffix(service, f"{base_version}-dev.*"): - match = pattern.fullmatch(version_from_tag(service, tag)) - if match: - last_dev = max(last_dev, int(match.group(1))) - version = f"{base_version}-dev.{last_dev + 1}" - publish_tag_for_version(root, service, version, dry_run, draft, f"{service.get('version_file') or 'version file'} dev prerelease") - return - - branch_prefix = f"release-{tag_prefix(service)}" - if branch.startswith(branch_prefix): - train = branch[len(branch_prefix) :] - if not re.fullmatch(r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)", train): - raise SystemExit(f"{service['id']}: release branch {branch} must end with X.Y") - if ".".join(base_version.split(".")[:2]) != train: - raise SystemExit(f"{service['id']}: {service.get('version_file')}={base_version} does not match release branch train {train}") - - existing_at_head = existing_matching_tag_at_sha(root, service, f"{train}.*", head) - if existing_at_head and "-" not in version_from_tag(service, existing_at_head): - print(f"[github-release] {service['id']}: {existing_at_head} already points at HEAD; skipping") - return - last_patch = -1 - pattern = re.compile(rf"^{re.escape(train)}\.(\d+)$") - for tag in service_tags_matching_suffix(service, f"{train}.*"): - match = pattern.fullmatch(version_from_tag(service, tag)) - if match: - last_patch = max(last_patch, int(match.group(1))) - version = base_version if last_patch < 0 else f"{train}.{last_patch + 1}" - publish_tag_for_version(root, service, version, dry_run, draft, f"{service.get('version_file') or 'version file'} release branch") - return - - print(f"[github-release] {service['id']}: dev prerelease tags only publish on {default_branch} or release branches; branch={branch or ''}; skipping") - - def service_matches_filter(service, service_filter): return not service_filter or service_filter in {service["id"], service["service_name"]} @@ -1074,135 +871,10 @@ def service_matches_filter(service, service_filter): def should_process_auto_service(service, service_filter, branch, default_branch): if not service_matches_filter(service, service_filter): return False - if not branch or branch == default_branch: - return True - return bool(service.get("dev_prerelease")) and branch.startswith(f"release-{tag_prefix(service)}") - - -def release_branch_pr_body(service, release_branch, base_sha, version_file_path, current_version, next_version, target_branch): - service_name = service.get("service_name") or service["id"] - return "\n".join( - [ - "## TL;DR", - "", - f"Cuts the {service_name} release train.", - "", - "## Additional Details", - "", - f"- Created release branch `{release_branch}` from the selected default branch content at `{base_sha}`.", - f"- Advances `{version_file_path}` from `{current_version}` to `{next_version}` on `{target_branch}`.", - "- The release branch push runs release automation for the first stable tag from the branch VERSION file.", - "", - "## For QA", - "", - "- Generated by `tools/ci/github-release branch-cut`.", - "", - "## Issues", - "", - "NO-REF", - "", - "## Checklist", - "- [x] I am familiar with the [Contributing Guidelines](../CONTRIBUTING.md).", - "- [x] I have signed off my commits for Developer Certificate of Origin (DCO) compliance.", - "- [x] New or existing tests cover these changes.", - "- [x] The documentation is up to date with these changes.", - "", - ] - ) - - -def branch_cut(args): - root = repo_root() - metadata = load_metadata(root, args.metadata) - service = find_service(metadata, args.service) - if not service.get("dev_prerelease"): - raise SystemExit(f"{service['id']}: branch-cut requires release.dev_prerelease") - if not service.get("version_file"): - raise SystemExit(f"{service['id']}: branch-cut requires release.version_file") - - current_version = validate_version_file(root, service) - if not re.fullmatch(STABLE_SEMVER_PATTERN, current_version): - raise SystemExit(f"{service['id']}: branch-cut requires a stable X.Y.Z version_file, got {current_version}") - - target_branch = args.target_branch or os.environ.get("GITHUB_DEFAULT_BRANCH", "main") - base_sha = run(["git", "rev-parse", "--verify", f"{args.ref}^{{commit}}"], cwd=root, capture=True).strip() - release_branch = service_release_branch(service, current_version) - bump_branch = service_version_bump_branch(service, current_version) - next_version = next_release_train_version(current_version) - version_file_path = f"{service['path'].rstrip('/')}/{service['version_file']}" - base_tree = commit_tree(root, base_sha) - - print( - f"[github-release] {service['id']}: branch-cut base={base_sha} " - f"release_branch={release_branch} bump_branch={bump_branch} " - f"{version_file_path}={current_version}->{next_version}" - ) - - if args.dry_run: - print(f"[github-release] dry-run: would create {release_branch} from {base_sha}'s tree") - print(f"[github-release] dry-run: would create {bump_branch} with {version_file_path}={next_version}") - print(f"[github-release] dry-run: would open PR from {bump_branch} to {target_branch}") - return - - existing_release_sha = remote_branch_sha(root, release_branch) - if existing_release_sha: - existing_release_tree = commit_tree(root, existing_release_sha) - if existing_release_tree != base_tree: - raise SystemExit( - f"{release_branch} already exists with tree {existing_release_tree}, " - f"not selected base tree {base_tree}" - ) - print(f"[github-release] {release_branch} already has the selected base tree") - bump_base = existing_release_sha - else: - ensure_git_identity(root) - release_base = linear_release_branch_base(root, base_sha) - run(["git", "push", "origin", f"{release_base}:refs/heads/{release_branch}"], cwd=root) - print(f"[github-release] created {release_branch} from selected base {base_sha}") - bump_base = release_base - - existing_pr = open_pr_url(root, bump_branch, target_branch) - if existing_pr: - print(f"[github-release] existing VERSION bump PR: {existing_pr}") - return - - if remote_branch_sha(root, bump_branch): - raise SystemExit(f"{bump_branch} already exists but has no open PR to {target_branch}") - - ensure_git_identity(root) - run(["git", "switch", "-C", bump_branch, bump_base], cwd=root) - version_path = root / version_file_path - version_path.write_text(f"{next_version}\n") - run(["git", "add", version_file_path], cwd=root) - run(["git", "commit", "-s", "-m", f"chore({service['id']}): advance release train to v{next_version}"], cwd=root) - run(["git", "push", "origin", f"HEAD:refs/heads/{bump_branch}"], cwd=root) - print(f"[github-release] created {bump_branch} with {version_file_path}={next_version}") - - body = release_branch_pr_body( - service, - release_branch, - base_sha, - version_file_path, - current_version, - next_version, - target_branch, - ) - run( - [ - "gh", - "pr", - "create", - "--head", - bump_branch, - "--base", - target_branch, - "--title", - f"chore({service['id']}): advance release train to v{next_version.rsplit('.', 1)[0]}", - "--body", - body, - ], - cwd=root, - ) + # Releases are cut from the default branch only. Pushes to a maintenance + # `release-*` branch still build and test, but the tag on such a branch is + # cut by hand. + return not branch or branch == default_branch def auto_release(args): @@ -1243,14 +915,6 @@ def auto_release(args): print(f"::group::github-release {service['id']}") try: - if service.get("dev_prerelease"): - publish_dev_prerelease(root, service, dry_run=dry_run, draft=draft) - continue - - if service.get("version_file"): - publish_version_file_release(root, service, dry_run=dry_run, draft=draft) - continue - if only_generated_changes(root, service, generated_paths): # A shared Java framework change lands outside every service # directory, so semantic-release (scoped to the service path by @@ -1523,13 +1187,6 @@ def main(): anchor.add_argument("--no-fetch-notes", action="store_true", help="do not fetch refs/notes/semantic-release before adding a note") anchor.set_defaults(func=anchor_release) - branch = subparsers.add_parser("branch-cut", help="cut a release branch and open the next-train VERSION bump PR") - branch.add_argument("--service", required=True, help="dev-prerelease service id, service_name, or path") - branch.add_argument("--ref", default="HEAD", help="GitHub commit/ref to cut from, defaults to HEAD") - branch.add_argument("--target-branch", default="", help="default branch for the VERSION bump PR, defaults to GITHUB_DEFAULT_BRANCH or main") - branch.add_argument("--dry-run", action="store_true", help="show the branches and PR that would be created") - branch.set_defaults(func=branch_cut) - args = parser.parse_args() args.func(args) diff --git a/tools/ci/github-release-subprojects.json b/tools/ci/github-release-subprojects.json index 77364a125..15d6a2f72 100644 --- a/tools/ci/github-release-subprojects.json +++ b/tools/ci/github-release-subprojects.json @@ -34,24 +34,21 @@ "path": "deploy/stacks/nvcf-compute-plane", "service_name": "nvcf-compute-plane-stack", "legacy_tag_prefix": "nvcf-compute-plane-stack-v", - "version_file": "VERSION", - "dev_prerelease": true + "initial_version": "0.2.0" }, { "id": "nvcf-self-managed-stack", "path": "deploy/stacks/self-managed", "service_name": "nvcf-self-managed-stack", "tag_format": "deploy/stacks/self-managed/v${version}", - "version_file": "VERSION", - "dev_prerelease": true + "initial_version": "0.8.0" }, { "id": "nvcf-observability-stack", "path": "deploy/stacks/observability", "service_name": "nvcf-observability-stack", "tag_format": "deploy/stacks/observability/v${version}", - "version_file": "VERSION", - "dev_prerelease": true + "initial_version": "0.0.0" }, { "id": "grpc-proxy", @@ -174,8 +171,7 @@ "path": "src/compute-plane-services/nvca", "service_name": "nvca", "legacy_tag_prefix": "nvca-v", - "version_file": "VERSION", - "dev_prerelease": true + "initial_version": "3.3.0" }, { "id": "container-cache", diff --git a/tools/ci/test-github-release.py b/tools/ci/test-github-release.py index 524160e0e..788ba0595 100644 --- a/tools/ci/test-github-release.py +++ b/tools/ci/test-github-release.py @@ -67,17 +67,9 @@ def init_repo(self, root): git(root, "config", "user.email", "test@example.com") git(root, "config", "user.name", "Test User") - def write_service_version(self, root, version): - service_dir = root / "src/compute-plane-services/byoo-otel-collector" - service_dir.mkdir(parents=True, exist_ok=True) - (service_dir / "VERSION").write_text(f"{version}\n") - (service_dir / "otel-collector-build.yaml").write_text(f"version: v{version}\n") - (service_dir / "README.md").write_text("test\n") - - def write_nvca_version(self, root, version): + def seed_nvca_service(self, root): service_dir = root / "src/compute-plane-services/nvca" service_dir.mkdir(parents=True, exist_ok=True) - (service_dir / "VERSION").write_text(f"{version}\n") (service_dir / "README.md").write_text("test\n") def commit_all(self, root, message): @@ -479,55 +471,6 @@ def list_tags(self, root): ) return sorted(line.strip() for line in result.stdout.splitlines() if line.strip()) - def test_version_file_release_skips_existing_current_tag_on_previous_commit(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - self.init_repo(root) - self.write_service_version(root, "0.153.6") - self.commit_all(root, "release byoo") - git(root, "tag", "src/compute-plane-services/byoo-otel-collector/v0.153.6") - (root / "src/compute-plane-services/byoo-otel-collector" / "README.md").write_text("later change\n") - self.commit_all(root, "later byoo change") - - service = { - "id": "byoo-otel-collector", - "path": "src/compute-plane-services/byoo-otel-collector", - "service_name": "byoo-otel-collector", - "legacy_tag_prefix": "byoo-otel-collector-v", - "version_file": "VERSION", - "version_major_minor_source_file": "otel-collector-build.yaml", - } - - output = io.StringIO() - with contextlib.redirect_stdout(output): - self.github_release.publish_version_file_release(root, service, dry_run=True, draft=False) - - self.assertIn("src/compute-plane-services/byoo-otel-collector/v0.153.6 already exists", output.getvalue()) - self.assertIn("skipping", output.getvalue()) - - def test_version_file_release_skips_existing_legacy_tag(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - self.init_repo(root) - self.write_service_version(root, "0.153.6") - self.commit_all(root, "release byoo") - git(root, "tag", "byoo-otel-collector-v0.153.6") - - service = { - "id": "byoo-otel-collector", - "path": "src/compute-plane-services/byoo-otel-collector", - "service_name": "byoo-otel-collector", - "legacy_tag_prefix": "byoo-otel-collector-v", - "version_file": "VERSION", - } - - output = io.StringIO() - with contextlib.redirect_stdout(output): - self.github_release.publish_version_file_release(root, service, dry_run=True, draft=False) - - self.assertIn("byoo-otel-collector-v0.153.6 already exists", output.getvalue()) - self.assertIn("skipping", output.getvalue()) - def _make_service_repo(self, root): self.init_repo(root) (root / "README.md").write_text("root\n") @@ -782,137 +725,13 @@ def test_cloud_tasks_chart_continues_its_published_lineage(self): "deploy/helm/cloud-tasks/v1.4.4", ) - def test_nvca_branch_cut_uses_path_scoped_release_branch(self): - service = { - "id": "nvca", - "path": "src/compute-plane-services/nvca", - "service_name": "nvca", - "legacy_tag_prefix": "nvca-v", - "version_file": "VERSION", - "dev_prerelease": True, - } - - self.assertEqual( - self.github_release.service_release_branch(service, "3.1.0"), - "release-src/compute-plane-services/nvca/v3.1", - ) - self.assertEqual( - self.github_release.service_version_bump_branch(service, "3.1.0"), - "release-bump/nvca/v3.1-to-v3.2", - ) - self.assertEqual(self.github_release.next_release_train_version("3.1.0"), "3.2.0") - - def test_linear_release_branch_base_preserves_the_selected_tree(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - self.init_repo(root) - self.write_nvca_version(root, "3.2.0") - self.commit_all(root, "seed nvca") - main_branch = self.github_release.run( - ["git", "branch", "--show-current"], cwd=root, capture=True - ).strip() - - git(root, "switch", "-c", "merged-change") - (root / "merged.txt").write_text("merged change\n") - self.commit_all(root, "fix: merged change") - - git(root, "switch", main_branch) - (root / "main.txt").write_text("main change\n") - self.commit_all(root, "fix: main change") - git(root, "merge", "--no-ff", "merged-change", "-m", "Merge merged-change") - (root / "src/compute-plane-services/nvca" / "README.md").write_text("release head\n") - self.commit_all(root, "fix(nvca): prepare release") - - base_sha = self.github_release.run( - ["git", "rev-parse", "HEAD"], cwd=root, capture=True - ).strip() - release_base = self.github_release.linear_release_branch_base(root, base_sha) - - self.assertNotEqual(release_base, base_sha) - self.assertEqual( - self.github_release.commit_tree(root, release_base), - self.github_release.commit_tree(root, base_sha), - ) - self.assertEqual( - self.github_release.run( - ["git", "rev-list", "--merges", release_base], cwd=root, capture=True - ).strip(), - "", - ) - - git(root, "switch", "-c", "release-bump/nvca/v3.2-to-v3.3", release_base) - (root / "src/compute-plane-services/nvca" / "VERSION").write_text("3.3.0\n") - self.commit_all(root, "chore(nvca): advance release train to v3.3.0") - bump_head = self.github_release.run( - ["git", "rev-parse", "HEAD"], cwd=root, capture=True - ).strip() - - self.assertEqual( - self.github_release.run( - ["git", "rev-parse", f"{bump_head}^"], cwd=root, capture=True - ).strip(), - release_base, - ) - self.assertEqual( - self.github_release.run( - ["git", "diff", "--name-only", base_sha, bump_head], cwd=root, capture=True - ).strip(), - "src/compute-plane-services/nvca/VERSION", - ) - self.assertEqual( - self.github_release.run( - ["git", "diff", "--name-only", f"{release_base}...{bump_head}"], cwd=root, capture=True - ).strip(), - "src/compute-plane-services/nvca/VERSION", - ) - self.assertEqual( - self.github_release.run( - ["git", "rev-list", "--merges", bump_head], cwd=root, capture=True - ).strip(), - "", - ) - - def test_linear_release_branch_base_keeps_a_linear_base(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - self.init_repo(root) - self.write_nvca_version(root, "3.2.0") - self.commit_all(root, "seed nvca") - base_sha = self.github_release.run( - ["git", "rev-parse", "HEAD"], cwd=root, capture=True - ).strip() - - self.assertEqual(self.github_release.linear_release_branch_base(root, base_sha), base_sha) - - def test_dev_prerelease_metadata_supports_branch_cut(self): - root = SCRIPT_PATH.parents[2] - metadata = json.loads(SCRIPT_PATH.with_name("github-release-subprojects.json").read_text()) - services = [service for service in metadata["services"] if service.get("dev_prerelease")] - self.assertGreater(len(services), 0) - - for service in services: - with self.subTest(service=service["id"]): - self.assertTrue(service.get("version_file")) - version = self.github_release.validate_version_file(root, service) - self.assertNotIn("-", version) - self.assertTrue(self.github_release.service_release_branch(service, version).startswith("release-")) - self.assertTrue(self.github_release.service_version_bump_branch(service, version).startswith("release-bump/")) - - def test_release_branch_push_only_processes_matching_dev_prerelease_service(self): + def test_only_the_default_branch_releases(self): nvca = { "id": "nvca", "path": "src/compute-plane-services/nvca", "service_name": "nvca", "legacy_tag_prefix": "nvca-v", - "version_file": "VERSION", - "dev_prerelease": True, - } - compute_stack = { - "id": "nvcf-compute-plane-stack", - "path": "deploy/stacks/nvcf-compute-plane", - "service_name": "nvcf-compute-plane-stack", - "version_file": "VERSION", - "dev_prerelease": True, + "initial_version": "3.3.0", } grpc_proxy = { "id": "grpc-proxy", @@ -920,90 +739,56 @@ def test_release_branch_push_only_processes_matching_dev_prerelease_service(self "service_name": "nvcf-grpc-proxy", "legacy_tag_prefix": "nvcf-grpc-proxy-v", } - branch = "release-src/compute-plane-services/nvca/v3.1" - - self.assertTrue(self.github_release.should_process_auto_service(nvca, "", branch, "main")) - self.assertFalse(self.github_release.should_process_auto_service(compute_stack, "", branch, "main")) - self.assertFalse(self.github_release.should_process_auto_service(grpc_proxy, "", branch, "main")) - self.assertTrue(self.github_release.should_process_auto_service(grpc_proxy, "", "main", "main")) - self.assertFalse(self.github_release.should_process_auto_service(nvca, "grpc-proxy", branch, "main")) + release_branch = "release-src/compute-plane-services/nvca/v3.1" - def test_branch_cut_dry_run_reports_release_branch_and_bump_pr(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - self.init_repo(root) - self.write_nvca_version(root, "3.1.0") - metadata = { - "version": 1, - "services": [ - { - "id": "nvca", - "path": "src/compute-plane-services/nvca", - "service_name": "nvca", - "legacy_tag_prefix": "nvca-v", - "version_file": "VERSION", - "dev_prerelease": True, - } - ], - } - metadata_path = root / "metadata.json" - metadata_path.write_text(json.dumps(metadata)) - self.commit_all(root, "seed nvca") - - args = types.SimpleNamespace( - metadata=str(metadata_path), - service="nvca", - ref="HEAD", - target_branch="main", - dry_run=True, - ) - output = io.StringIO() - with chdir(root), contextlib.redirect_stdout(output): - self.github_release.branch_cut(args) + # Maintenance branches still build and test, but no longer release: + # a tag on one of them is cut by hand. + for service in (nvca, grpc_proxy): + with self.subTest(service=service["id"]): + self.assertTrue(self.github_release.should_process_auto_service(service, "", "main", "main")) + self.assertFalse( + self.github_release.should_process_auto_service(service, "", release_branch, "main") + ) - text = output.getvalue() - self.assertIn("release-src/compute-plane-services/nvca/v3.1", text) - self.assertIn("release-bump/nvca/v3.1-to-v3.2", text) - self.assertIn("src/compute-plane-services/nvca/VERSION=3.1.0->3.2.0", text) + # The service filter still scopes a run to one service. + self.assertFalse(self.github_release.should_process_auto_service(nvca, "grpc-proxy", "main", "main")) + self.assertTrue(self.github_release.should_process_auto_service(grpc_proxy, "grpc-proxy", "main", "main")) - def test_branch_cut_requires_dev_prerelease_service(self): - with tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - self.init_repo(root) - self.write_nvca_version(root, "3.1.0") - metadata = { - "version": 1, - "services": [ - { - "id": "nvca", - "path": "src/compute-plane-services/nvca", - "service_name": "nvca", - "version_file": "VERSION", - } - ], - } - metadata_path = root / "metadata.json" - metadata_path.write_text(json.dumps(metadata)) - self.commit_all(root, "seed nvca") - - args = types.SimpleNamespace( - metadata=str(metadata_path), - service="nvca", - ref="HEAD", - target_branch="main", - dry_run=True, - ) - with chdir(root), self.assertRaisesRegex(SystemExit, "branch-cut requires release.dev_prerelease"): - self.github_release.branch_cut(args) + def test_no_service_uses_the_retired_version_file_model(self): + metadata = json.loads(SCRIPT_PATH.with_name("github-release-subprojects.json").read_text()) + for service in metadata["services"]: + with self.subTest(service=service["id"]): + self.assertNotIn("version_file", service) + self.assertNotIn("dev_prerelease", service) + + def test_migrated_services_declare_their_version_floor(self): + # nvca and the three stacks moved off the VERSION file onto + # semantic-release. Their stable lines resume from these floors, which + # are anchored on the GitHub commit graph at cutover. + expected = { + "nvca": "3.3.0", + "nvcf-compute-plane-stack": "0.2.0", + "nvcf-self-managed-stack": "0.8.0", + "nvcf-observability-stack": "0.0.0", + } + root = SCRIPT_PATH.parents[2] + metadata = json.loads(SCRIPT_PATH.with_name("github-release-subprojects.json").read_text()) + by_id = {service["id"]: service for service in metadata["services"]} + for service_id, floor in expected.items(): + with self.subTest(service=service_id): + service = by_id[service_id] + self.assertEqual(service.get("initial_version"), floor) + # The VERSION file these floors came from is gone; nothing may + # reintroduce it, or the service would silently stop releasing. + self.assertFalse((root / service["path"] / "VERSION").exists()) NVCA_SERVICE = { "id": "nvca", "path": "src/compute-plane-services/nvca", "service_name": "nvca", "legacy_tag_prefix": "nvca-v", - "version_file": "VERSION", - "dev_prerelease": True, + "initial_version": "3.3.0", } def stub_gh_comments(self, pull_requests, failing=()): @@ -1032,7 +817,7 @@ def fake_run(args, *rest, **kwargs): def nvca_repo_with_tag(self, root, version="3.2.0"): """Seed an nvca repo whose HEAD carries the service tag for `version`.""" self.init_repo(root) - self.write_nvca_version(root, version) + self.seed_nvca_service(root) self.commit_all(root, "seed nvca") git(root, "tag", f"src/compute-plane-services/nvca/v{version}") @@ -1098,7 +883,7 @@ def test_released_commits_without_a_previous_tag_resolves_only_head(self): with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) self.init_repo(root) - self.write_nvca_version(root, "3.2.0") + self.seed_nvca_service(root) self.commit_all(root, "seed nvca") head = self.commit_backport(root, "fix(nvca): first ever release") @@ -1218,7 +1003,7 @@ def publish_and_capture_comments(self, version): ) with chdir(root), contextlib.redirect_stdout(io.StringIO()): self.github_release.publish_tag_for_version( - root, self.NVCA_SERVICE, version, dry_run=False, draft=False, reason="VERSION" + root, self.NVCA_SERVICE, version, dry_run=False, draft=False, reason="test" ) return comments @@ -1230,10 +1015,12 @@ def test_publish_tag_comments_on_a_stable_release(self): "the range must be bounded by the newest tag on this branch, not the highest tag overall", ) - def test_publish_tag_stays_quiet_for_a_dev_prerelease(self): - # Dev prereleases are internal checkpoints on the default branch, not - # something to announce on a pull request. - self.assertEqual(self.publish_and_capture_comments("3.3.0-dev.4"), []) + def test_publish_tag_stays_quiet_for_a_prerelease(self): + # A prerelease is an internal checkpoint, not something to announce on + # a pull request. Nothing publishes one automatically now that the + # dev-prerelease model is retired, but a hand-cut rc still reaches + # publish_tag_for_version through the release-candidate path. + self.assertEqual(self.publish_and_capture_comments("3.4.0-rc.1"), []) if __name__ == "__main__":