From 4c95235d87e556e119d01ea21a1a47cc35150f9a Mon Sep 17 00:00:00 2001 From: Kristina Pathak Date: Mon, 21 Sep 2026 16:00:08 -0700 Subject: [PATCH 1/3] ci(release): give the 1.0 train the machinery to cut a patch The v1.0.0 tag this branch is rooted at predates release branching. At that commit there is no release_branch_only, no version_file, no publish_release_branch_release and no branch_cut, and test-github-release.py actively asserted that no stack may carry a VERSION file: the three stacks still released from main under semantic-release. A branch cut from that tag therefore has no code path that can publish 1.0.1 at all. This is a carve of two commits from main, reduced to what makes a release possible and nothing else: 729fcc5 ci(release): put the deploy/stacks subprojects back on release branching (#1959) a00ee1e fix(release): do not cut a stack patch for a push that changes nothing it ships (#1963) Their documentation changes are left out; a maintenance branch describing capabilities it does not carry is worse than one that says nothing. The second commit is load-bearing rather than incidental: it is what makes stack_content_changed_since discard the version file, so release bookkeeping on this branch does not cut versions. All three VERSION files are present because test_the_stacks_release_from_a_ branch_and_nothing_else_does validates one for every stack registered as release_branch_only, so the metadata and the files have to agree. They read 1.0.0 because all three stacks were tagged v1.0.0 together; the 0.21.0 seed #1959 carries is the pre-1.0 value #1964 later had to correct on main. Nothing here cuts a version. The only path under deploy/stacks that changes is the version file, which is excluded from the content comparison. Refs #1977 Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release-tags.yml | 107 ++++- deploy/stacks/nvcf-compute-plane/VERSION | 1 + deploy/stacks/observability/VERSION | 1 + deploy/stacks/self-managed/VERSION | 1 + tools/ci/github-release | 474 ++++++++++++++++++++- tools/ci/github-release-subprojects.json | 9 +- tools/ci/test-github-release.py | 511 ++++++++++++++++++++--- 7 files changed, 1038 insertions(+), 66 deletions(-) create mode 100644 deploy/stacks/nvcf-compute-plane/VERSION create mode 100644 deploy/stacks/observability/VERSION create mode 100644 deploy/stacks/self-managed/VERSION diff --git a/.github/workflows/release-tags.yml b/.github/workflows/release-tags.yml index fd7348bef5..457b18d232 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 0000000000..3eefcb9dd5 --- /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 0000000000..3eefcb9dd5 --- /dev/null +++ b/deploy/stacks/observability/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/deploy/stacks/self-managed/VERSION b/deploy/stacks/self-managed/VERSION new file mode 100644 index 0000000000..3eefcb9dd5 --- /dev/null +++ b/deploy/stacks/self-managed/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/tools/ci/github-release b/tools/ci/github-release index 93b81ac2af..5c0e1b6005 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 9fe957ab1a..19a647bf27 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 e8bf73b9ba..29e845221f 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", From ba800eb159e64c8dddb17a992b325d1f55bf2ad5 Mon Sep 17 00:00:00 2001 From: Kristina Pathak Date: Mon, 21 Sep 2026 16:10:44 -0700 Subject: [PATCH 2/3] feat(self-managed): record the installed stack version in-cluster An upgrade has to know where it is starting from, and nothing in a cluster carries that today. Helm tracks chart versions per release; helmfile has no concept of the bundle's own version. So every cluster looks identical at upgrade time, and a gate has no basis to decide whether the jump it has been asked to make is one it can make safely. This writes a nvcf-upgrade-receipt ConfigMap naming the stack version that was installed. Only the writer ships here. The validating hook belongs in the release that first has something to validate against, and a writer cannot block an upgrade whereas a checker that misjudges can break every existing customer. Shipping it on the 1.0 line is the point. A cluster that reaches 2.0.0 with no receipt is indistinguishable from a 0.x install, which leaves a gate choosing between refusing everybody and checking nothing. Once any 1.x records a version, absence becomes a refusal that names a version the customer can actually install. The hook is post-install and post-upgrade, not pre-*: a receipt must not claim a version before that version has been applied. post-upgrade alone would skip the first cluster to receive this chart, because Helm runs post-install where no prior release exists -- precisely the clusters this exists for. Ordering is a stage boundary rather than a needs: edge. 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 one slow or failed peer silently skips it; see the admin-issuer-proxy comment in 02-core.yaml.gotmpl. A new last-numbered stage gets the ordering without that failure mode. The chart is local to the bundle rather than pulled from the registry, so the receipt works the moment this merges instead of waiting on a chart publish. Closes #1977 Co-Authored-By: Claude Opus 5 (1M context) --- deploy/stacks/self-managed/Makefile | 1 + .../charts/nvcf-upgrade-receipt/Chart.yaml | 8 +++ .../nvcf-upgrade-receipt/templates/job.yaml | 56 +++++++++++++++++++ .../nvcf-upgrade-receipt/templates/role.yaml | 18 ++++++ .../templates/rolebinding.yaml | 19 +++++++ .../templates/serviceaccount.yaml | 17 ++++++ .../charts/nvcf-upgrade-receipt/values.yaml | 19 +++++++ .../helmfile.d/04-upgrade-receipt.yaml.gotmpl | 30 ++++++++++ .../tests/upgrade-receipt-wiring.sh | 45 +++++++++++++++ 9 files changed, 213 insertions(+) create mode 100644 deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/Chart.yaml create mode 100644 deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/job.yaml create mode 100644 deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/role.yaml create mode 100644 deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/rolebinding.yaml create mode 100644 deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/serviceaccount.yaml create mode 100644 deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/values.yaml create mode 100644 deploy/stacks/self-managed/helmfile.d/04-upgrade-receipt.yaml.gotmpl create mode 100755 deploy/stacks/self-managed/tests/upgrade-receipt-wiring.sh diff --git a/deploy/stacks/self-managed/Makefile b/deploy/stacks/self-managed/Makefile index ed014a1177..c3ae1f41ae 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/charts/nvcf-upgrade-receipt/Chart.yaml b/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/Chart.yaml new file mode 100644 index 0000000000..9abbe38694 --- /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 0000000000..3b2e6848b0 --- /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 0000000000..1ad660c895 --- /dev/null +++ b/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/role.yaml @@ -0,0 +1,18 @@ +# 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: + # get and patch are for the upgrade case where a receipt already exists; + # create is for the first install. Nothing here needs to read or write any + # other resource. + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "create", "patch"] 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 0000000000..4139a6c876 --- /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 0000000000..dcdf2d5ef7 --- /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 0000000000..87537031ef --- /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 0000000000..a42e8f9fdf --- /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 0000000000..6161eeb9ed --- /dev/null +++ b/deploy/stacks/self-managed/tests/upgrade-receipt-wiring.sh @@ -0,0 +1,45 @@ +#!/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 +grep -qE '^\s+verbs: \["get", "create", "patch"\]' <<<"$rendered" \ + || fail "Role must allow get/create/patch on configmaps and nothing more" + +# 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" From 77c6db7687a24c55a1f0e1b8cc8c3187a1191b14 Mon Sep 17 00:00:00 2001 From: Kristina Pathak Date: Mon, 21 Sep 2026 16:39:58 -0700 Subject: [PATCH 3/3] fix(self-managed): scope the receipt Role to the receipt ConfigMap The Role granted get, create and patch on every ConfigMap in the namespace, so anything able to run a pod under this ServiceAccount could rewrite unrelated ConfigMaps for as long as the binding existed. The Job only ever touches one object. get and patch now name that object through resourceNames. create stays unscoped in a rule of its own, because RBAC matches resourceNames against an object that does not exist yet and so never satisfies a create rule that names one; keeping it separate leaves the unscoped verb visible rather than buried alongside the scoped ones. Co-Authored-By: Claude Opus 5 (1M context) --- .../nvcf-upgrade-receipt/templates/role.yaml | 16 ++++++++++++---- .../self-managed/tests/upgrade-receipt-wiring.sh | 11 +++++++++-- 2 files changed, 21 insertions(+), 6 deletions(-) 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 index 1ad660c895..469367eead 100644 --- a/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/role.yaml +++ b/deploy/stacks/self-managed/charts/nvcf-upgrade-receipt/templates/role.yaml @@ -10,9 +10,17 @@ metadata: "helm.sh/hook-weight": "-5" "helm.sh/hook-delete-policy": before-hook-creation rules: - # get and patch are for the upgrade case where a receipt already exists; - # create is for the first install. Nothing here needs to read or write any - # other resource. + # 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"] - verbs: ["get", "create", "patch"] + 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/tests/upgrade-receipt-wiring.sh b/deploy/stacks/self-managed/tests/upgrade-receipt-wiring.sh index 6161eeb9ed..f59ec3bc80 100755 --- a/deploy/stacks/self-managed/tests/upgrade-receipt-wiring.sh +++ b/deploy/stacks/self-managed/tests/upgrade-receipt-wiring.sh @@ -33,8 +33,15 @@ grep -q 'value: "nvcf-upgrade-receipt"' <<<"$rendered" \ for kind in ServiceAccount Role RoleBinding; do grep -q "kind: ${kind}" <<<"$rendered" || fail "missing ${kind}; the Job cannot write the ConfigMap without it" done -grep -qE '^\s+verbs: \["get", "create", "patch"\]' <<<"$rendered" \ - || fail "Role must allow get/create/patch on configmaps and nothing more" +# 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.