diff --git a/.github/workflows/release-tags.yml b/.github/workflows/release-tags.yml index fd7348bef..457b18d23 100644 --- a/.github/workflows/release-tags.yml +++ b/.github/workflows/release-tags.yml @@ -7,13 +7,30 @@ on: push: branches: - main + # Maintenance branches for the subprojects that release from a branch + # rather than from main -- currently the three deploy/stacks subprojects. + # A push here cuts the next patch on that branch's train. + - 'release-**/v*' tags: - '*-v*' - '**/v*' workflow_dispatch: inputs: + operation: + description: Release operation to run. + required: false + default: auto + type: choice + options: + - auto + - branch-cut + - release-candidate service: - description: Optional service id or service_name to scope the run to. Defaults to every registered service. + description: >- + Service id or service_name. Optional for operation=auto, where it + scopes the run and otherwise defaults to every registered service. + Required for branch-cut and release-candidate, which act on one + subproject. required: false type: string inventory_tag: @@ -56,10 +73,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, 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 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. # # 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 @@ -67,7 +84,7 @@ jobs: # work. service-release: name: service release automation - if: github.ref_type != 'tag' && (github.event_name != 'workflow_dispatch' || (inputs.inventory_tag == '' && inputs.release_tag == '')) + if: github.ref_type != 'tag' && (github.event_name != 'workflow_dispatch' || (inputs.operation == 'auto' && inputs.inventory_tag == '' && inputs.release_tag == '')) runs-on: linux-amd64-cpu4 permissions: contents: write @@ -116,6 +133,84 @@ jobs: fi ./tools/ci/github-release auto + # Opens the next train for a subproject that releases from a branch: creates + # release-X.Y holding the default branch's content, and opens the + # pull request advancing that subproject's VERSION to X.Y+1.0 on the default + # branch. Dispatch from any ref; the cut always uses the default branch. + release-branch-cut: + name: 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 + fetch-tags: true + ref: ${{ github.event.repository.default_branch }} + token: ${{ secrets.NV_GITHUB_TOKEN || github.token }} + + - name: Cut release branch + env: + NVCF_GITHUB_RELEASE_SERVICE: ${{ inputs.service }} + 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}" + + # Cuts X.Y.Z-rc.N at the dispatched ref. This is how an unreleased tree gets a + # real artifact now that the stacks do not tag on main: the internal publish + # lanes accept -rc.N, so selecting a pull request's branch in the run dialog + # builds and publishes that tree without putting a version on the stack's + # stable line. + # + # Same-repository branches only, which is what workflow_dispatch offers. A + # fork's branch cannot be selected, so release credentials never run against + # unreviewed code. + release-candidate: + name: release candidate + if: github.event_name == 'workflow_dispatch' && inputs.operation == 'release-candidate' + runs-on: linux-amd64-cpu4 + permissions: + contents: write + steps: + # fetch-tags because the rc counter is derived from the rc tags already + # published for this base version. Without them it restarts at rc.0 and + # the push fails on a tag name that is already taken. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + ref: ${{ github.ref_name }} + token: ${{ secrets.NV_GITHUB_TOKEN || github.token }} + + - name: Cut release candidate + env: + NVCF_GITHUB_RELEASE_SERVICE: ${{ inputs.service }} + run: | + set -euo pipefail + if [ "${NV_GITHUB_TOKEN_CONFIGURED}" != "true" ]; then + echo "ERROR: set secret NV_GITHUB_TOKEN before cutting a release candidate." >&2 + echo "The default GITHUB_TOKEN cannot trigger the tag workflow that publishes release notes." >&2 + exit 1 + fi + if [ -z "${NVCF_GITHUB_RELEASE_SERVICE}" ]; then + echo "ERROR: service is required when operation=release-candidate." >&2 + exit 1 + fi + ./tools/ci/github-release release-candidate --service "${NVCF_GITHUB_RELEASE_SERVICE}" + inventory-preflight: name: stack inventory preflight if: github.event_name == 'workflow_dispatch' && inputs.inventory_tag != '' diff --git a/deploy/stacks/nvcf-compute-plane/VERSION b/deploy/stacks/nvcf-compute-plane/VERSION new file mode 100644 index 000000000..3eefcb9dd --- /dev/null +++ b/deploy/stacks/nvcf-compute-plane/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/deploy/stacks/observability/VERSION b/deploy/stacks/observability/VERSION new file mode 100644 index 000000000..3eefcb9dd --- /dev/null +++ b/deploy/stacks/observability/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/deploy/stacks/self-managed/Makefile b/deploy/stacks/self-managed/Makefile index ed014a117..c3ae1f41a 100644 --- a/deploy/stacks/self-managed/Makefile +++ b/deploy/stacks/self-managed/Makefile @@ -15,6 +15,7 @@ test: @tests/grpc-proxy-nats-endpoint.sh @tests/llm-pki-openbao-migration.sh @tests/api-keys-startup-probe.sh + @tests/upgrade-receipt-wiring.sh @tests/cassandra-openbao-credential-wiring.sh @tests/llm-pki-release.sh @tests/check-llm-pki-issuer.sh diff --git a/deploy/stacks/self-managed/VERSION b/deploy/stacks/self-managed/VERSION new file mode 100644 index 000000000..3eefcb9dd --- /dev/null +++ b/deploy/stacks/self-managed/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/Chart.yaml b/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/Chart.yaml new file mode 100644 index 000000000..9abbe3869 --- /dev/null +++ b/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/Chart.yaml @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +apiVersion: v2 +name: nvcf-upgrade-receipt +description: Records the installed NVCF stack version in-cluster so an upgrade can tell where it is starting from. +type: application +version: 0.1.0 +appVersion: "0.1.0" diff --git a/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/job.yaml b/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/job.yaml new file mode 100644 index 000000000..3b2e6848b --- /dev/null +++ b/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/job.yaml @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +{{- $version := required "stackVersion is required: a receipt that names the wrong version is worse than no receipt" .Values.stackVersion }} +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ .Release.Name }} + namespace: {{ .Release.Namespace }} + annotations: + # post-* rather than pre-*: the receipt states what the cluster is running, + # so it must not be written until the release it describes has been applied. + # + # Both install and upgrade, because the first cluster to receive this chart + # has no prior release of it, and Helm runs post-install there rather than + # post-upgrade. Omitting post-install would leave exactly the clusters this + # exists for without a receipt. + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-weight": "0" + "helm.sh/hook-delete-policy": before-hook-creation +spec: + backoffLimit: 3 + ttlSecondsAfterFinished: 600 + template: + metadata: + name: {{ .Release.Name }} + spec: + restartPolicy: Never + serviceAccountName: {{ .Release.Name }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: +{{ toYaml . | indent 8 }} + {{- end }} + containers: + - name: receipt + image: "{{ with .Values.image.registry }}{{ . }}/{{ end }}{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + env: + - name: RECEIPT_CONFIGMAP + value: {{ .Values.configMapName | quote }} + - name: INSTALLED_STACK_VERSION + value: {{ $version | quote }} + command: + - /bin/sh + - -c + - | + set -eu + # Rendered through apply rather than create so that the first + # install and every later upgrade take the same path. A receipt + # that only appears on a fresh install would be absent from + # exactly the clusters that are upgrading. + kubectl create configmap "${RECEIPT_CONFIGMAP}" \ + --from-literal=installed_stack_version="${INSTALLED_STACK_VERSION}" \ + --from-literal=recorded_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --dry-run=client -o yaml \ + | kubectl apply -f - + echo "recorded installed_stack_version=${INSTALLED_STACK_VERSION} in ${RECEIPT_CONFIGMAP}" diff --git a/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/role.yaml b/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/role.yaml new file mode 100644 index 000000000..469367eea --- /dev/null +++ b/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/role.yaml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ .Release.Name }} + namespace: {{ .Release.Namespace }} + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": before-hook-creation +rules: + # Scoped to the receipt itself. get and patch cover the upgrade case where a + # receipt already exists, and naming the resource keeps this identity from + # reaching any other ConfigMap in the namespace. + - apiGroups: [""] + resources: ["configmaps"] + resourceNames: [{{ .Values.configMapName | quote }}] + verbs: ["get", "patch"] + # create cannot be scoped: RBAC matches resourceNames against an object that + # does not exist yet, so a create rule naming one is never satisfied. It is + # kept in its own rule so the unscoped verb is visible rather than buried + # alongside the scoped ones, and it is only reachable on a first install. + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["create"] diff --git a/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/rolebinding.yaml b/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/rolebinding.yaml new file mode 100644 index 000000000..4139a6c87 --- /dev/null +++ b/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/rolebinding.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ .Release.Name }} + namespace: {{ .Release.Namespace }} + annotations: + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": before-hook-creation +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ .Release.Name }} +subjects: + - kind: ServiceAccount + name: {{ .Release.Name }} + namespace: {{ .Release.Namespace }} diff --git a/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/serviceaccount.yaml b/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/serviceaccount.yaml new file mode 100644 index 000000000..dcdf2d5ef --- /dev/null +++ b/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/serviceaccount.yaml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ .Release.Name }} + namespace: {{ .Release.Namespace }} + annotations: + # Must exist before the Job that uses it, and survive long enough to be + # bound. A lower weight than the Job is what orders them. + "helm.sh/hook": post-install,post-upgrade + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": before-hook-creation +{{- with .Values.imagePullSecrets }} +imagePullSecrets: +{{ toYaml . | indent 2 }} +{{- end }} diff --git a/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/values.yaml b/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/values.yaml new file mode 100644 index 000000000..87537031e --- /dev/null +++ b/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/values.yaml @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# The stack version this bundle installs. The Helmfile supplies it from the +# bundle's own VERSION file; there is no sensible default, so rendering fails +# rather than recording a version the cluster is not running. +stackVersion: "" + +image: + registry: "" + repository: alpine-k8s + tag: "1.33.1" + pullPolicy: IfNotPresent + +imagePullSecrets: [] + +# Name of the ConfigMap holding the receipt. An upgrade reads this to decide +# whether the jump it has been asked to make is one it can make safely. +configMapName: nvcf-upgrade-receipt diff --git a/deploy/stacks/self-managed/helmfile.d/04-upgrade-receipt.yaml.gotmpl b/deploy/stacks/self-managed/helmfile.d/04-upgrade-receipt.yaml.gotmpl new file mode 100644 index 000000000..a42e8f9fd --- /dev/null +++ b/deploy/stacks/self-managed/helmfile.d/04-upgrade-receipt.yaml.gotmpl @@ -0,0 +1,30 @@ +environments: + default: + values: + - ../environments/base.yaml + - ../environments/{{ requiredEnv "HELMFILE_ENV" }}.yaml + +--- + +{{- /* + A stage of its own, and the last one, so the receipt is written only after + every other release has been applied. Ordering is a stage boundary rather + than a needs: edge on purpose: under the helmfile version this stack pins, + needs: places a release in a later DAG layer where it waits on every peer in + the previous one, and a single slow or failed peer silently skips it. See the + admin-issuer-proxy comment in 02-core.yaml.gotmpl. +*/}} + +releases: + - name: upgrade-receipt + chart: ../charts/nvcf-upgrade-receipt + namespace: nvcf + values: + - stackVersion: {{ readFile "../VERSION" | trim | quote }} + image: + registry: {{ .Values.global.image.registry | quote }} + repository: {{ .Values.global.image.repository }}/alpine-k8s + {{- with .Values.global.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 10 }} + {{- end }} diff --git a/deploy/stacks/self-managed/tests/upgrade-receipt-wiring.sh b/deploy/stacks/self-managed/tests/upgrade-receipt-wiring.sh new file mode 100755 index 000000000..f59ec3bc8 --- /dev/null +++ b/deploy/stacks/self-managed/tests/upgrade-receipt-wiring.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Test that the stack records the version it installed. +# +# An upgrade has to know where it is starting from, and nothing else in a +# cluster carries that: Helm tracks chart versions per release, and helmfile has +# no concept of the bundle's own version. Without this receipt every cluster +# looks identical to every other one at upgrade time. +# +# The assertions that matter are the hook kinds and the recorded version. A +# pre-* hook would claim a version before it was applied, and a post-upgrade +# hook alone would skip the first install of this chart, which is precisely the +# set of clusters that need a receipt written. +set -euo pipefail + +stack_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +expected_version="$(tr -d '[:space:]' < "$stack_dir/VERSION")" + +rendered="$(cd "$stack_dir" && HELMFILE_ENV=base helmfile \ + --file helmfile.d/04-upgrade-receipt.yaml.gotmpl template)" + +fail() { echo "FAIL: $1" >&2; exit 1; } + +grep -q 'kind: Job' <<<"$rendered" || fail "no Job rendered" +grep -q '"helm.sh/hook": post-install,post-upgrade' <<<"$rendered" \ + || fail "receipt must run on both install and upgrade, after the release it describes" +grep -q "value: \"${expected_version}\"" <<<"$rendered" \ + || fail "recorded version does not match VERSION (${expected_version})" +# The ConfigMap is created by the Job at run time, not rendered, so its name +# reaches the cluster as the env var the script reads. +grep -q 'value: "nvcf-upgrade-receipt"' <<<"$rendered" \ + || fail "receipt ConfigMap name is not the one an upgrade will read" + +for kind in ServiceAccount Role RoleBinding; do + grep -q "kind: ${kind}" <<<"$rendered" || fail "missing ${kind}; the Job cannot write the ConfigMap without it" +done +# get and patch are scoped to the receipt by name so this identity cannot +# reach any other ConfigMap. create cannot be scoped -- RBAC matches +# resourceNames against an object that does not exist yet. +grep -qE '^\s+resourceNames: \["nvcf-upgrade-receipt"\]' <<<"$rendered" \ + || fail "get/patch are not scoped to the receipt ConfigMap by name" +grep -qE '^\s+verbs: \["get", "patch"\]' <<<"$rendered" \ + || fail "scoped rule should carry only get and patch" +grep -qE '^\s+verbs: \["create"\]' <<<"$rendered" \ + || fail "create must remain, in its own rule, for the first install" + +# The stage number is the ordering guarantee. needs: is deliberately not used +# here; see the comment in the stage file. +last_stage="$(ls "$stack_dir"/helmfile.d/*.gotmpl | sort | tail -1)" +[[ "$(basename "$last_stage")" == "04-upgrade-receipt.yaml.gotmpl" ]] \ + || fail "receipt is not the last stage; it would record a version before the stack finished applying" + +echo "PASS: upgrade-receipt-wiring" diff --git a/tools/ci/github-release b/tools/ci/github-release index 93b81ac2a..5c0e1b600 100755 --- a/tools/ci/github-release +++ b/tools/ci/github-release @@ -241,10 +241,39 @@ 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"] @@ -252,6 +281,118 @@ def current_branch(root): return "" if branch == "HEAD" else branch +def service_release_branch_prefix(service, root=None): + return f"release-{tag_prefix(service, root)}" + + +def service_release_branch(service, version, root=None): + train = ".".join(version.split(".")[:2]) + return f"{service_release_branch_prefix(service, root)}{train}" + + +def release_branch_train(service, branch, root=None): + """The X.Y this branch is the maintenance branch for, or "" if it is not one.""" + prefix = service_release_branch_prefix(service, root) + if not branch.startswith(prefix): + return "" + train = branch[len(prefix) :] + if not re.fullmatch(r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)", train): + return "" + return 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): + """A commit carrying base_sha's tree whose history has no merges. + + The release ruleset requires a linear commit graph, so a release branch + cannot simply point at the default branch head. The consequence is that a + release branch is a parallel history: its tags are not ancestors of the + default branch, which is why `release_baseline_version` filters on + reachability. + """ + 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, "*") @@ -655,6 +796,24 @@ def stale_checkout_output(output): return "is behind the remote" in output +def validate_version_file(root, service): + """The stable X.Y.Z this service's VERSION file holds. + + Stable only. A prerelease here would be cut verbatim as the train's first + release, and the rc counter derives its base version from this value. + """ + 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(STABLE_SEMVER_PATTERN, version): + raise SystemExit(f"{service['id']}: {version_file} does not contain a stable X.Y.Z SemVer: {version}") + 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: @@ -1100,8 +1259,8 @@ def publish_tag_for_version(root, service, version, dry_run, draft, reason): run(["git", "push", "origin", f"refs/tags/{tag}"], cwd=root) created = create_release(tag, tag, notes, draft, dry_run=False) # Only for a release this run actually created, so a re-run does not comment - # twice, and only for a stable version: dev prereleases are internal - # checkpoints, not something to announce on a pull request. + # twice, and only for a stable version: a prerelease is an internal + # checkpoint, not something to announce on a pull request. if created and not draft and re.fullmatch(STABLE_SEMVER_PATTERN, version): comment_release_on_pull_requests(root, service, tag, version, since_tag) @@ -1320,19 +1479,301 @@ def finish_semantic_release(root, service, components, exit_code, output, dry_ru return outcome +def next_prerelease_version(root, service, base_version, channel): + """The next `base_version-.N` this service has not published yet.""" + last = -1 + pattern = re.compile(rf"^{re.escape(base_version)}-{channel}\.(\d+)$") + for tag in service_tags_matching_suffix(service, f"{base_version}-{channel}.*", root): + match = pattern.fullmatch(version_from_tag(service, tag, root)) + if match: + last = max(last, int(match.group(1))) + return f"{base_version}-{channel}.{last + 1}" + + +def stack_content_changed_since(root, service, tag): + """True when the service's own tree differs from what `tag` shipped. + + Compares trees rather than walking commits. A release branch is rooted at a + synthetic commit (see `linear_release_branch_base`), so the train's own tags + are usually not ancestors of HEAD and a `..HEAD` log would report every + commit or none depending on which side of the graft it landed on. Two trees + at one path can always be compared. + + The version file is excluded. It is release bookkeeping, not part of what + the stack deploys, so a commit that only seeds or bumps it is not a reason + to publish a version whose content nobody changed. + + A diff that does not run is fatal rather than a "yes". This decides whether + to publish, so the failure has to be loud: `run(capture=True)` folds stderr + into stdout, and an unreadable tag object would otherwise come back as + `fatal: bad revision ...`, parse as one changed path, and cut a release off + an error message. + """ + service_path = service["path"].rstrip("/") + result = subprocess.run( + ["git", "diff", "--name-only", tag, "HEAD", "--", service_path], + cwd=root, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + raise SystemExit( + f"{service['id']}: cannot compare {service_path} against {tag} " + f"(git diff exited {result.returncode}): {(result.stderr or '').strip()}" + ) + changed = {line.strip() for line in result.stdout.splitlines() if line.strip()} + version_file = service.get("version_file") + if version_file: + changed.discard(f"{service_path}/{version_file}") + return bool(changed) + + +def publish_release_branch_release(root, service, dry_run, draft): + """Cut the next patch on this service's maintenance branch. + + The branch names the train and `VERSION` states it; they must agree, or the + branch is cutting versions for a train it does not hold. The first release + on a branch is `X.Y.0` and each later push advances the patch. + + A push that leaves the stack's own tree untouched cuts nothing. Pushes to a + maintenance branch are not all backports: bootstrapping the branch, bumping + its VERSION, and backporting CI or tooling all land here, and none of them + change what the stack deploys. + """ + base_version = validate_version_file(root, service) + branch = current_branch(root) + train = release_branch_train(service, branch, root) + if not train: + print( + f"[github-release] {service['id']}: releases only from " + f"{service_release_branch_prefix(service, root)}X.Y; branch={branch or ''}; skipping" + ) + return + if ".".join(base_version.split(".")[:2]) != train: + raise SystemExit( + f"{service['id']}: {service.get('version_file')}={base_version} " + f"does not match release branch train {train}" + ) + + head = run(["git", "rev-parse", "--verify", "HEAD^{commit}"], cwd=root, capture=True).strip() + existing_at_head = existing_matching_tag_at_sha(root, service, f"{train}.*", head) + if existing_at_head and re.fullmatch(STABLE_SEMVER_PATTERN, version_from_tag(service, existing_at_head, root)): + print(f"[github-release] {service['id']}: {existing_at_head} already points at HEAD; skipping") + return + + last_patch = -1 + last_tag = "" + pattern = re.compile(rf"^{re.escape(train)}\.(\d+)$") + for tag in service_tags_matching_suffix(service, f"{train}.*", root): + match = pattern.fullmatch(version_from_tag(service, tag, root)) + if match and int(match.group(1)) > last_patch: + last_patch, last_tag = int(match.group(1)), tag + + if last_tag and not stack_content_changed_since(root, service, last_tag): + print( + f"[github-release] {service['id']}: nothing changed under {service['path']} " + f"since {last_tag}; skipping" + ) + return + + 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" + ) + + +def release_candidate(args): + """Cut an `X.Y.Z-rc.N` for the checked-out ref. + + This is how a stack that has not been branch-cut yet gets an installable + artifact: the internal publish lanes accept `-rc.N`, so tagging a pull + request's head builds and publishes that tree without putting a version on + the stack's stable line. `main` itself cuts nothing. + """ + root = repo_root() + metadata = load_metadata(root, args.metadata) + service = find_service(metadata, args.service) + if not service.get("version_file"): + raise SystemExit(f"{service['id']}: release-candidate requires release.version_file") + + _auto_tagging_enabled, dry_run = github_release_mode() + dry_run = dry_run or args.dry_run + draft = bool_env("NVCF_GITHUB_RELEASE_DRAFT", False) + + base_version = validate_version_file(root, service) + branch = current_branch(root) + train = release_branch_train(service, branch, root) + if train and ".".join(base_version.split(".")[:2]) != train: + raise SystemExit( + f"{service['id']}: {service.get('version_file')}={base_version} " + f"does not match release branch train {train}" + ) + + version = next_prerelease_version(root, service, base_version, "rc") + print( + f"[github-release] {service['id']}: release candidate {version} " + f"from {branch or ''} at " + f"{run(['git', 'rev-parse', '--verify', 'HEAD^{commit}'], cwd=root, capture=True).strip()}" + ) + publish_tag_for_version( + root, service, version, dry_run, draft, f"{service.get('version_file') or 'version file'} release candidate" + ) + + def service_matches_filter(service, service_filter): return not service_filter or service_filter in {service["id"], service["service_name"]} -def should_process_auto_service(service, service_filter, branch, default_branch): +def should_process_auto_service(service, service_filter, branch, default_branch, root=None): if not service_matches_filter(service, service_filter): return False - # 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. + if service.get("release_branch_only"): + # A stack version is a statement about a composition that somebody + # qualified, so it comes from its maintenance branch and nowhere else. + # On the default branch this service releases nothing at all; an + # unreleased tree is reached through `release-candidate`. + return bool(branch) and bool(release_branch_train(service, branch, root)) + # Everything else releases 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 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): + """Open the next release train: the maintenance branch, and the VERSION bump. + + Two outputs, both required. The release branch is where the train releases + from; the pull request advances VERSION on the default branch so the next + cut opens the following train rather than this one again. + + Re-running is safe. A release branch already holding the selected tree is + reused, one holding a different tree is an error rather than a silent + reset, and an open bump pull request is reported rather than replaced. + """ + root = repo_root() + metadata = load_metadata(root, args.metadata) + service = find_service(metadata, args.service) + if not service.get("release_branch_only"): + raise SystemExit(f"{service['id']}: branch-cut requires release.release_branch_only") + if not service.get("version_file"): + raise SystemExit(f"{service['id']}: branch-cut requires release.version_file") + + current_version = validate_version_file(root, service) + 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, root) + 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, + ) + + def auto_release(args): root = repo_root() metadata = load_metadata(root, args.metadata) @@ -1362,7 +1803,7 @@ def auto_release(args): stale_checkouts = [] for service in metadata.get("services", []): - if not should_process_auto_service(service, service_filter, branch, default_branch): + if not should_process_auto_service(service, service_filter, branch, default_branch, root): continue service_dir = root / service["path"] if not service_dir.is_dir(): @@ -1371,6 +1812,13 @@ def auto_release(args): print(f"::group::github-release {service['id']}") try: + if service.get("release_branch_only"): + # The version comes from the branch's VERSION file, not from + # semantic-release: a maintenance branch ships the train it was + # cut for, whatever commit types land on it. + publish_release_branch_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 @@ -1673,6 +2121,18 @@ 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="release-branch-only 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) + + rc = subparsers.add_parser("release-candidate", help="cut an rc tag for the checked-out ref") + rc.add_argument("--service", required=True, help="version-file service id, service_name, or path") + rc.add_argument("--dry-run", action="store_true", help="show the tag that would be created") + rc.set_defaults(func=release_candidate) + args = parser.parse_args() args.func(args) diff --git a/tools/ci/github-release-subprojects.json b/tools/ci/github-release-subprojects.json index 9fe957ab1..19a647bf2 100644 --- a/tools/ci/github-release-subprojects.json +++ b/tools/ci/github-release-subprojects.json @@ -35,7 +35,8 @@ "service_name": "nvcf-compute-plane-stack", "legacy_tag_prefix": "nvcf-compute-plane-stack-v", "tag_format": "deploy/stacks/nvcf-compute-plane/v${version}", - "initial_version": "0.2.0", + "version_file": "VERSION", + "release_branch_only": true, "resolved_inventory_asset": "nvcf-compute-plane-stack-inventory.json" }, { @@ -43,7 +44,8 @@ "path": "deploy/stacks/self-managed", "service_name": "nvcf-self-managed-stack", "tag_format": "deploy/stacks/self-managed/v${version}", - "initial_version": "0.8.0", + "version_file": "VERSION", + "release_branch_only": true, "resolved_inventory_asset": "nvcf-self-managed-stack-inventory.json" }, { @@ -51,7 +53,8 @@ "path": "deploy/stacks/observability", "service_name": "nvcf-observability-stack", "tag_format": "deploy/stacks/observability/v${version}", - "initial_version": "0.0.0", + "version_file": "VERSION", + "release_branch_only": true, "resolved_inventory_asset": "nvcf-observability-stack-inventory.json" }, { diff --git a/tools/ci/test-github-release.py b/tools/ci/test-github-release.py index e8bf73b9b..29e845221 100644 --- a/tools/ci/test-github-release.py +++ b/tools/ci/test-github-release.py @@ -63,6 +63,16 @@ def chdir(path): class GithubReleaseTest(unittest.TestCase): def setUp(self): self.github_release = load_github_release() + # `current_branch` prefers GITHUB_REF_TYPE/GITHUB_REF_NAME over the + # checked-out branch, and both are set on every Actions runner. A test + # that builds a repo in a temp directory would otherwise be told it is + # on the pull request's merge ref, which is how this suite passed + # locally and failed in CI. Any test that wants them sets them itself. + env = mock.patch.dict(os.environ, {}, clear=False) + env.start() + self.addCleanup(env.stop) + os.environ.pop("GITHUB_REF_TYPE", None) + os.environ.pop("GITHUB_REF_NAME", None) def init_repo(self, root): git(root, "init") @@ -600,19 +610,14 @@ def test_floor_anchor_lands_on_the_newest_tag_not_the_start_of_history(self): anchored = self.github_release.tag_sha(root, "src/compute-plane-services/nvca/v3.3.0") self.assertEqual(anchored, newest) - def test_the_migrated_services_all_resolve_a_floor_above_their_baseline(self): - # Guards the cutover itself: if any of the four stopped needing its floor, - # its first automatic release would silently restart or regress the line. + def test_nvca_resolves_a_floor_above_its_baseline(self): + # Guards nvca's cutover onto semantic-release: if it stopped needing its + # floor, its next automatic release would silently restart the line. The + # three stacks migrated with it and have since moved back to the VERSION + # file, so they have no floor to check. metadata = json.loads(SCRIPT_PATH.with_name("github-release-subprojects.json").read_text()) by_id = {s["id"]: s for s in metadata["services"]} - for service_id, floor in ( - ("nvca", "3.3.0"), - ("nvcf-compute-plane-stack", "0.2.0"), - ("nvcf-self-managed-stack", "0.8.0"), - ("nvcf-observability-stack", "0.0.0"), - ): - with self.subTest(service=service_id): - self.assertEqual(self.github_release.initial_floor_version(by_id[service_id]), floor) + self.assertEqual(self.github_release.initial_floor_version(by_id["nvca"]), "3.3.0") def test_initial_version_anchor_honors_metadata(self): service = { @@ -869,25 +874,34 @@ def test_http_invocation_chart_uses_its_published_lineage(self): "deploy/helm/http-invocation/v1.5.6", ) - 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", - "initial_version": "3.3.0", - } - grpc_proxy = { - "id": "grpc-proxy", - "path": "src/invocation-plane-services/grpc-proxy", - "service_name": "nvcf-grpc-proxy", - "legacy_tag_prefix": "nvcf-grpc-proxy-v", - } + NVCA_MAIN_SERVICE = { + "id": "nvca", + "path": "src/compute-plane-services/nvca", + "service_name": "nvca", + "legacy_tag_prefix": "nvca-v", + "initial_version": "3.3.0", + } + GRPC_PROXY_SERVICE = { + "id": "grpc-proxy", + "path": "src/invocation-plane-services/grpc-proxy", + "service_name": "nvcf-grpc-proxy", + "legacy_tag_prefix": "nvcf-grpc-proxy-v", + } + SELF_MANAGED_STACK_SERVICE = { + "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", + "release_branch_only": True, + } + + def test_only_the_default_branch_releases_a_semantic_release_service(self): release_branch = "release-src/compute-plane-services/nvca/v3.1" - # 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): + # Maintenance branches for these still build and test but do not + # release: a tag on one of them is cut by hand. + for service in (self.NVCA_MAIN_SERVICE, self.GRPC_PROXY_SERVICE): with self.subTest(service=service["id"]): self.assertTrue(self.github_release.should_process_auto_service(service, "", "main", "main")) self.assertFalse( @@ -895,8 +909,378 @@ def test_only_the_default_branch_releases(self): ) # 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")) + self.assertFalse( + self.github_release.should_process_auto_service(self.NVCA_MAIN_SERVICE, "grpc-proxy", "main", "main") + ) + self.assertTrue( + self.github_release.should_process_auto_service(self.GRPC_PROXY_SERVICE, "grpc-proxy", "main", "main") + ) + + def test_a_release_branch_only_service_never_releases_from_main(self): + stack = self.SELF_MANAGED_STACK_SERVICE + own_branch = "release-deploy/stacks/self-managed/v0.21" + + self.assertTrue(self.github_release.should_process_auto_service(stack, "", own_branch, "main")) + # Including a detached checkout, which `auto` treats as the default + # branch for every other service. + for branch in ("main", ""): + with self.subTest(branch=branch or ""): + self.assertFalse(self.github_release.should_process_auto_service(stack, "", branch, "main")) + + # Another subproject's maintenance branch, and a branch whose suffix is + # not an X.Y train, are both somebody else's push. + for branch in ( + "release-src/compute-plane-services/nvca/v3.1", + "release-deploy/stacks/observability/v0.3", + "release-deploy/stacks/self-managed/v0.21.4", + "release-deploy/stacks/self-managed/vnext", + ): + with self.subTest(branch=branch): + self.assertFalse(self.github_release.should_process_auto_service(stack, "", branch, "main")) + + # A push to a stack's maintenance branch must not release anything else. + for service in (self.NVCA_MAIN_SERVICE, self.GRPC_PROXY_SERVICE): + with self.subTest(service=service["id"]): + self.assertFalse( + self.github_release.should_process_auto_service(service, "", own_branch, "main") + ) + + def _seed_stack_release_branch(self, root, version, branch): + """A stack repo checked out on `branch` with VERSION set to `version`.""" + self.init_repo(root) + stack_dir = root / "deploy/stacks/self-managed" + stack_dir.mkdir(parents=True, exist_ok=True) + (stack_dir / "VERSION").write_text(f"{version}\n") + (stack_dir / "README.md").write_text("stack\n") + self.commit_all(root, "chore: seed the self-managed stack") + if branch: + git(root, "switch", "-c", branch) + + def test_release_branch_cuts_the_trains_first_version_then_patches(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + branch = "release-deploy/stacks/self-managed/v0.21" + self._seed_stack_release_branch(root, "0.21.0", branch) + + output = io.StringIO() + with chdir(root), contextlib.redirect_stdout(output): + self.github_release.publish_release_branch_release( + root, self.SELF_MANAGED_STACK_SERVICE, dry_run=True, draft=False + ) + self.assertIn("would create deploy/stacks/self-managed/v0.21.0", output.getvalue()) + + git(root, "tag", "deploy/stacks/self-managed/v0.21.0") + (root / "deploy/stacks/self-managed/README.md").write_text("backport\n") + self.commit_all(root, "fix(self-managed): backport") + + output = io.StringIO() + with chdir(root), contextlib.redirect_stdout(output): + self.github_release.publish_release_branch_release( + root, self.SELF_MANAGED_STACK_SERVICE, dry_run=True, draft=False + ) + self.assertIn("would create deploy/stacks/self-managed/v0.21.1", output.getvalue()) + + def _run_auto(self, root, service, expected_branch, default_branch): + """`auto` as the workflow runs it, on the checked-out branch.""" + metadata_path = root / "metadata.json" + metadata_path.write_text(json.dumps({"version": 1, "services": [service]})) + env = { + "NVCF_GITHUB_AUTO_TAGGING_ENABLED": "true", + "NVCF_GITHUB_RELEASE_DRY_RUN": "true", + "GITHUB_DEFAULT_BRANCH": default_branch, + } + args = types.SimpleNamespace(metadata=str(metadata_path), service=service["id"]) + output = io.StringIO() + with mock.patch.dict(os.environ, env, clear=False): + with chdir(root), contextlib.redirect_stdout(output): + self.github_release.auto_release(args) + self.assertIn(f"branch={expected_branch}", output.getvalue()) + return output.getvalue() + + def test_auto_releases_a_stack_from_its_branch_and_not_from_the_default_branch(self): + # The whole point of the model: a merge to the default branch must leave + # the stack's version alone, and the maintenance branch is what moves it. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._seed_stack_release_branch(root, "0.21.0", "") + # Read rather than assume: `git init` names the first branch from + # the machine's init.defaultBranch, which is not `main` everywhere. + default_branch = self.github_release.run( + ["git", "branch", "--show-current"], cwd=root, capture=True + ).strip() + (root / "deploy/stacks/self-managed/README.md").write_text("a customer fix\n") + self.commit_all(root, "fix(self-managed): a change that would release anywhere else") + + on_default = self._run_auto( + root, self.SELF_MANAGED_STACK_SERVICE, default_branch, default_branch + ) + self.assertNotIn("would create", on_default) + + release_branch = "release-deploy/stacks/self-managed/v0.21" + git(root, "switch", "-c", release_branch) + on_branch = self._run_auto( + root, self.SELF_MANAGED_STACK_SERVICE, release_branch, default_branch + ) + self.assertIn("would create deploy/stacks/self-managed/v0.21.0", on_branch) + + def test_release_branch_skips_a_push_that_does_not_touch_the_stack(self): + # Backporting CI, tooling, or anything outside the stack's own tree is + # not a reason to publish a stack version. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._seed_stack_release_branch(root, "1.0.0", "release-deploy/stacks/self-managed/v1.0") + git(root, "tag", "deploy/stacks/self-managed/v1.0.0") + (root / "tools").mkdir(parents=True, exist_ok=True) + (root / "tools" / "ci-helper").write_text("backported tooling\n") + self.commit_all(root, "ci(self-managed): backport the release tooling") + + output = io.StringIO() + with chdir(root), contextlib.redirect_stdout(output): + self.github_release.publish_release_branch_release( + root, self.SELF_MANAGED_STACK_SERVICE, dry_run=True, draft=False + ) + self.assertIn("nothing changed under deploy/stacks/self-managed", output.getvalue()) + self.assertNotIn("would create", output.getvalue()) + + def test_release_branch_skips_a_version_file_only_change(self): + # The exact bootstrap case: a branch cut from a commit that predates the + # VERSION file needs one commit to add it, and that commit ships nothing. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.init_repo(root) + stack_dir = root / "deploy/stacks/self-managed" + stack_dir.mkdir(parents=True, exist_ok=True) + (stack_dir / "helmfile.yaml").write_text("qualified content\n") + self.commit_all(root, "chore: the qualified commit, with no VERSION file") + git(root, "tag", "deploy/stacks/self-managed/v1.0.0") + + git(root, "switch", "-c", "release-deploy/stacks/self-managed/v1.0") + (stack_dir / "VERSION").write_text("1.0.0\n") + self.commit_all(root, "chore(self-managed): seed VERSION for the 1.0 train") + + output = io.StringIO() + with chdir(root), contextlib.redirect_stdout(output): + self.github_release.publish_release_branch_release( + root, self.SELF_MANAGED_STACK_SERVICE, dry_run=True, draft=False + ) + self.assertNotIn("would create", output.getvalue()) + + # A real backport on top still releases, and it is the next patch. + (stack_dir / "helmfile.yaml").write_text("qualified content plus a backported fix\n") + self.commit_all(root, "fix(self-managed): backport the fix") + output = io.StringIO() + with chdir(root), contextlib.redirect_stdout(output): + self.github_release.publish_release_branch_release( + root, self.SELF_MANAGED_STACK_SERVICE, dry_run=True, draft=False + ) + self.assertIn("would create deploy/stacks/self-managed/v1.0.1", output.getvalue()) + + def test_stack_change_detection_fails_closed_when_the_diff_cannot_run(self): + # A diff that does not run must not read as "the stack changed". + # run(capture=True) folds stderr into stdout, so an unreadable tag would + # come back as `fatal: bad revision ...`, parse as one changed path, and + # publish a version off an error message. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._seed_stack_release_branch(root, "1.0.0", "release-deploy/stacks/self-managed/v1.0") + + with self.assertRaisesRegex(SystemExit, "cannot compare deploy/stacks/self-managed"): + self.github_release.stack_content_changed_since( + root, self.SELF_MANAGED_STACK_SERVICE, "deploy/stacks/self-managed/v9.9.9" + ) + + def test_stack_change_detection_survives_a_synthetic_branch_root(self): + # A release branch is rooted at a synthetic commit, so the train's tags + # are not ancestors of HEAD. Comparison must be by tree, not by ancestry. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.init_repo(root) + stack_dir = root / "deploy/stacks/self-managed" + stack_dir.mkdir(parents=True, exist_ok=True) + (stack_dir / "helmfile.yaml").write_text("qualified content\n") + (stack_dir / "VERSION").write_text("1.0.0\n") + self.commit_all(root, "chore: qualified") + git(root, "tag", "deploy/stacks/self-managed/v1.0.0") + qualified = self.github_release.run( + ["git", "rev-parse", "HEAD"], cwd=root, capture=True + ).strip() + + # Graft: same tree, unrelated parentage. + base = self.github_release.linear_release_branch_base(root, qualified) + orphan = self.github_release.run( + ["git", "commit-tree", self.github_release.commit_tree(root, qualified), "-m", "snapshot"], + cwd=root, + capture=True, + ).strip() + git(root, "switch", "-c", "release-deploy/stacks/self-managed/v1.0", orphan) + + self.assertFalse( + self.github_release.tag_is_reachable(root, "deploy/stacks/self-managed/v1.0.0"), + "the tag must not be an ancestor, or this test is not exercising the graft", + ) + self.assertFalse( + self.github_release.stack_content_changed_since( + root, self.SELF_MANAGED_STACK_SERVICE, "deploy/stacks/self-managed/v1.0.0" + ), + "identical trees across a graft must read as unchanged", + ) + self.assertEqual(self.github_release.commit_tree(root, base), self.github_release.commit_tree(root, qualified)) + + def test_release_branch_release_is_idempotent_at_head(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._seed_stack_release_branch(root, "0.21.0", "release-deploy/stacks/self-managed/v0.21") + git(root, "tag", "deploy/stacks/self-managed/v0.21.0") + + output = io.StringIO() + with chdir(root), contextlib.redirect_stdout(output): + self.github_release.publish_release_branch_release( + root, self.SELF_MANAGED_STACK_SERVICE, dry_run=True, draft=False + ) + self.assertIn("already points at HEAD", output.getvalue()) + + def test_release_branch_release_refuses_a_version_from_another_train(self): + # The branch names the train and VERSION states it. Disagreeing means + # the branch would publish a version for a train it does not hold. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._seed_stack_release_branch(root, "0.22.0", "release-deploy/stacks/self-managed/v0.21") + with chdir(root), self.assertRaisesRegex(SystemExit, "does not match release branch train 0.21"): + self.github_release.publish_release_branch_release( + root, self.SELF_MANAGED_STACK_SERVICE, dry_run=True, draft=False + ) + + def test_release_candidate_counts_up_from_the_published_rcs(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._seed_stack_release_branch(root, "0.21.0", "kpathak/some-pull-request") + git(root, "tag", "deploy/stacks/self-managed/v0.21.0-rc.0") + git(root, "tag", "deploy/stacks/self-managed/v0.21.0-rc.1") + metadata_path = root / "metadata.json" + metadata_path.write_text( + json.dumps({"version": 1, "services": [self.SELF_MANAGED_STACK_SERVICE]}) + ) + + args = types.SimpleNamespace( + metadata=str(metadata_path), service="nvcf-self-managed-stack", dry_run=True + ) + output = io.StringIO() + with chdir(root), contextlib.redirect_stdout(output): + self.github_release.release_candidate(args) + + text = output.getvalue() + self.assertIn("would create deploy/stacks/self-managed/v0.21.0-rc.2", text) + # Cutting an rc must not advance the stable line. + self.assertNotIn("deploy/stacks/self-managed/v0.21.0 ", text) + + def test_release_candidate_requires_a_version_file_service(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._seed_stack_release_branch(root, "0.21.0", "") + metadata_path = root / "metadata.json" + metadata_path.write_text( + json.dumps({"version": 1, "services": [self.NVCA_MAIN_SERVICE]}) + ) + + args = types.SimpleNamespace(metadata=str(metadata_path), service="nvca", dry_run=True) + with chdir(root), self.assertRaisesRegex(SystemExit, "requires release.version_file"): + self.github_release.release_candidate(args) + + def test_branch_cut_dry_run_reports_release_branch_and_bump_pr(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._seed_stack_release_branch(root, "0.21.0", "") + metadata_path = root / "metadata.json" + metadata_path.write_text( + json.dumps({"version": 1, "services": [self.SELF_MANAGED_STACK_SERVICE]}) + ) + + args = types.SimpleNamespace( + metadata=str(metadata_path), + service="nvcf-self-managed-stack", + ref="HEAD", + target_branch="main", + dry_run=True, + ) + output = io.StringIO() + with chdir(root), contextlib.redirect_stdout(output): + self.github_release.branch_cut(args) + + text = output.getvalue() + self.assertIn("release-deploy/stacks/self-managed/v0.21", text) + self.assertIn("release-bump/nvcf-self-managed-stack/v0.21-to-v0.22", text) + self.assertIn("deploy/stacks/self-managed/VERSION=0.21.0->0.22.0", text) + + def test_branch_cut_requires_a_release_branch_only_service(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._seed_stack_release_branch(root, "0.21.0", "") + service = dict(self.SELF_MANAGED_STACK_SERVICE) + del service["release_branch_only"] + metadata_path = root / "metadata.json" + metadata_path.write_text(json.dumps({"version": 1, "services": [service]})) + + args = types.SimpleNamespace( + metadata=str(metadata_path), + service="nvcf-self-managed-stack", + ref="HEAD", + target_branch="main", + dry_run=True, + ) + with chdir(root), self.assertRaisesRegex(SystemExit, "requires release.release_branch_only"): + self.github_release.branch_cut(args) + + def test_version_file_must_hold_a_stable_version(self): + # A prerelease here would be cut verbatim as the train's first release. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._seed_stack_release_branch(root, "0.21.0-rc.1", "") + with self.assertRaisesRegex(SystemExit, "does not contain a stable"): + self.github_release.validate_version_file(root, self.SELF_MANAGED_STACK_SERVICE) + + def test_linear_release_branch_base_preserves_the_selected_tree(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._seed_stack_release_branch(root, "0.21.0", "") + 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") + + 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(), + "", + ) + + def test_linear_release_branch_base_keeps_an_already_linear_base(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self._seed_stack_release_branch(root, "0.21.0", "") + 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) # Image sources that live beside a chart of the same name. Each pair # releases independently: the chart from deploy/helm/, the image @@ -950,34 +1334,61 @@ def test_release_worthy_infra_commit_touches_the_image_stream_only(self): self.github_release.latest_service_tag(image, root), "infra/openbao/v1.3.1" ) - def test_no_service_uses_the_retired_version_file_model(self): + # The subprojects that release from a maintenance branch instead of from + # main, and the VERSION each one's next branch cut will open. + RELEASE_BRANCH_STACKS = ( + ("nvcf-self-managed-stack", "deploy/stacks/self-managed"), + ("nvcf-compute-plane-stack", "deploy/stacks/nvcf-compute-plane"), + ("nvcf-observability-stack", "deploy/stacks/observability"), + ) + + def test_the_stacks_release_from_a_branch_and_nothing_else_does(self): + 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"]} + expected = {stack_id for stack_id, _path in self.RELEASE_BRANCH_STACKS} + + declared = {s["id"] for s in metadata["services"] if s.get("release_branch_only")} + self.assertEqual(declared, expected) + + # version_file and release_branch_only are one model, not two settings. + # A version_file without the branch rule releases from main off a file + # nothing advances; the branch rule without a file has no version to cut. for service in metadata["services"]: with self.subTest(service=service["id"]): - self.assertNotIn("version_file", service) + self.assertEqual( + bool(service.get("version_file")), bool(service.get("release_branch_only")) + ) + # The retired spelling. A `dev_prerelease` entry would be read by + # nothing and the subproject would silently stop releasing. 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", - } + for stack_id, path in self.RELEASE_BRANCH_STACKS: + with self.subTest(service=stack_id): + service = by_id[stack_id] + self.assertEqual(service["path"], path) + self.assertEqual(service["version_file"], "VERSION") + # A floor is for a semantic-release subproject. Leaving one here + # would state a second, contradictory source for the version. + self.assertNotIn("initial_version", service) + version = self.github_release.validate_version_file(root, service) + self.assertTrue( + self.github_release.service_release_branch(service, version, root).startswith("release-") + ) + self.assertTrue( + self.github_release.service_version_bump_branch(service, version).startswith("release-bump/") + ) + + def test_nvca_still_releases_from_main(self): + # nvca migrated to semantic-release alongside the stacks and stays there. 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"]} + nvca = {service["id"]: service for service in metadata["services"]}["nvca"] - 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()) + self.assertEqual(nvca.get("initial_version"), "3.3.0") + self.assertNotIn("version_file", nvca) + self.assertNotIn("release_branch_only", nvca) + self.assertFalse((root / nvca["path"] / "VERSION").exists()) NVCA_SERVICE = { "id": "nvca",