From 747fc4621067ebff07f317eff6c8f9f73e470360 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 12 Jun 2026 01:42:53 +0200 Subject: [PATCH 01/31] Add .nextchanges fragment directory and collator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contributors keep adding entries to the single, append-only NEXT_CHANGELOG.md, so concurrent PRs constantly conflict and force a no-op rebase plus full CI rerun. Introduce .nextchanges/
/.md fragments instead: each PR adds its own file, so two PRs never touch the same path and never conflict. The filename is arbitrary (a feature name or PR number) and an entry is just a sentence — creatable straight from the GitHub UI; the leading bullet and a (#NNNN) reference are both optional. tools/collate_changelog.py folds the fragments into the matching NEXT_CHANGELOG.md sections at release time, leaving the release tooling (internal/genkit/tagging.py) to consume NEXT_CHANGELOG.md unchanged. The directory name matches databricks-sdk-py's .nextchanges/ for cross-repo consistency. 'task changelog-check' validates fragment placement and runs as part of 'task checks'. The bare 'cli' gitignore entry is anchored to '/cli' so it no longer ignores .nextchanges/cli/. Co-authored-by: Isaac --- .gitignore | 4 +- .nextchanges/README.md | 44 +++++ .nextchanges/api-changes/.gitkeep | 0 .nextchanges/bundles/.gitkeep | 0 .nextchanges/cli/.gitkeep | 0 .nextchanges/dependency-updates/.gitkeep | 0 .nextchanges/notable-changes/.gitkeep | 0 Taskfile.yml | 14 +- tools/collate_changelog.py | 226 +++++++++++++++++++++++ 9 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 .nextchanges/README.md create mode 100644 .nextchanges/api-changes/.gitkeep create mode 100644 .nextchanges/bundles/.gitkeep create mode 100644 .nextchanges/cli/.gitkeep create mode 100644 .nextchanges/dependency-updates/.gitkeep create mode 100644 .nextchanges/notable-changes/.gitkeep create mode 100755 tools/collate_changelog.py diff --git a/.gitignore b/.gitignore index 71622cd443c..e725ce38da5 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,9 @@ *.dll *.so *.dylib -cli +# Root binary from a bare `go build`; anchored so it doesn't also ignore +# nested paths like changelog.d/cli/. +/cli # Test binary, built with `go test -c` *.test diff --git a/.nextchanges/README.md b/.nextchanges/README.md new file mode 100644 index 00000000000..1b2d6296067 --- /dev/null +++ b/.nextchanges/README.md @@ -0,0 +1,44 @@ +# Changelog fragments + +Add a changelog entry by creating a **new file** in the section folder under +`.nextchanges/` that fits your change. Each PR adds its own file, so two PRs +never touch the same path — no merge conflicts, unlike everyone editing one +shared changelog file. + +## How to add an entry (takes 10 seconds) + +Create `.nextchanges/
/.md` and write what changed: + +``` +Added the `databricks quickstart` command. +``` + +You can do this straight from the GitHub UI: **Add file → Create new file**, +type the path (e.g. `.nextchanges/cli/quickstart.md`), write a sentence, commit. + +- `` is arbitrary — a feature name (`quickstart.md`) or your PR number + (`5464.md`), whatever you like, as long as it's unique. +- The leading `* ` is optional. +- A PR link is optional: write `(#5464)` anywhere in the text and it becomes a + full link automatically (see `tools/update_github_links.py`). +- One file is usually one entry; for several, put each on its own `* ` line. + +### Sections + +| Folder | Section in the released changelog | +| --- | --- | +| `.nextchanges/notable-changes/` | Notable Changes (prominent, called out at the top) | +| `.nextchanges/cli/` | CLI | +| `.nextchanges/bundles/` | Bundles | +| `.nextchanges/dependency-updates/` | Dependency updates | +| `.nextchanges/api-changes/` | API Changes | + +See [`.agent/skills/pr-checklist/SKILL.md`](../.agent/skills/pr-checklist/SKILL.md) +for when an entry is warranted. + +## How it's released + +You don't run anything. At release time, `tools/collate_changelog.py` folds +every fragment into the matching section of `NEXT_CHANGELOG.md`, deletes the +fragments, and the release tooling generates `CHANGELOG.md` as before. +`./task changelog-check` validates fragment placement on every PR. diff --git a/.nextchanges/api-changes/.gitkeep b/.nextchanges/api-changes/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.nextchanges/bundles/.gitkeep b/.nextchanges/bundles/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.nextchanges/cli/.gitkeep b/.nextchanges/cli/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.nextchanges/dependency-updates/.gitkeep b/.nextchanges/dependency-updates/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/.nextchanges/notable-changes/.gitkeep b/.nextchanges/notable-changes/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/Taskfile.yml b/Taskfile.yml index a0e2813afca..a7a45d38fa1 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -262,6 +262,17 @@ tasks: cmds: - "./tools/update_github_links.py" + changelog-collate: + desc: Collate .nextchanges fragments into NEXT_CHANGELOG.md (run at release time) + cmds: + - "./tools/collate_changelog.py" + - "./tools/update_github_links.py NEXT_CHANGELOG.md" + + changelog-check: + desc: Validate .nextchanges fragment placement + cmds: + - "./tools/collate_changelog.py --check" + deadcode: desc: Check for dead code sources: @@ -272,7 +283,7 @@ tasks: - ./tools/check_deadcode.py checks: - desc: Run quick checks (tidy, whitespace, links, deadcode) + desc: Run quick checks (tidy, whitespace, links, deadcode, changelog) # Sequential: `tidy` rewrites go.mod/go.sum and any future tidy work # touching more paths should not race with whitespace/link scanners. cmds: @@ -280,6 +291,7 @@ tasks: - task: ws - task: links - task: deadcode + - task: changelog-check install-pythons: desc: Install Python 3.9-3.13 via uv diff --git a/tools/collate_changelog.py b/tools/collate_changelog.py new file mode 100755 index 00000000000..fc4fac62e9b --- /dev/null +++ b/tools/collate_changelog.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.12" +# /// +"""Collate ``.nextchanges/`` fragments into ``NEXT_CHANGELOG.md``. + +Each PR adds its own file under ``.nextchanges/
/`` instead of editing +the shared ``NEXT_CHANGELOG.md``. Because two PRs never touch the same path, +they never produce a merge conflict. At release time this script folds every +fragment into the matching section of ``NEXT_CHANGELOG.md`` and deletes the +fragment files; the existing release tooling (``internal/genkit/tagging.py``) +then consumes ``NEXT_CHANGELOG.md`` unchanged. + +Usage: + collate_changelog.py # collate fragments into NEXT_CHANGELOG.md + collate_changelog.py --check # validate fragment placement only (no writes) +""" + +import argparse +import pathlib +import re +import sys + +CHANGELOG_DIR = ".nextchanges" +NEXT_CHANGELOG = "NEXT_CHANGELOG.md" + +# Section subdirectory -> ``### `` header text in NEXT_CHANGELOG.md, in the +# order sections appear in the file. The slug is the header lowercased with +# spaces replaced by hyphens; the mapping is explicit because "CLI" and +# "API Changes" don't round-trip through a simple title-case rule. +SECTIONS = ( + ("notable-changes", "Notable Changes"), + ("cli", "CLI"), + ("bundles", "Bundles"), + ("dependency-updates", "Dependency updates"), + ("api-changes", "API Changes"), +) + +SECTION_SLUGS = {slug for slug, _ in SECTIONS} + +# A level-2 or level-3 Markdown heading, i.e. a section boundary. +HEADING_RE = re.compile(r"#{2,3} ") + + +def normalize_entry(text): + """Return *text* as a Markdown bullet, adding the leading ``* `` if absent. + + The leading marker is optional in a fragment so authors can write just the + entry text. A ``-`` marker is normalized to ``*`` to match the changelog. + + >>> normalize_entry("Added a flag (#1).") + '* Added a flag (#1).' + >>> normalize_entry("* Already a bullet (#2).") + '* Already a bullet (#2).' + >>> normalize_entry("- Dash bullet (#3).") + '* Dash bullet (#3).' + + Only the first line is marked; continuation lines are left untouched so an + author can write a multi-line entry or several explicit bullets: + + >>> normalize_entry("First line.\\n continued") + '* First line.\\n continued' + """ + text = text.strip() + first, _, rest = text.partition("\n") + if first.startswith("* "): + pass + elif first.startswith("- "): + first = "* " + first[2:] + elif first in ("*", "-"): + first = "*" + else: + first = "* " + first + return first + ("\n" + rest if rest else "") + + +def insert_entries(changelog, header, entries): + r"""Insert *entries* under the ``### {header}`` section of *changelog*. + + Entries are appended after any existing content in the section, before the + blank line that precedes the next section. Existing lines are left byte for + byte intact so the diff is minimal. + + >>> cl = "## Release v1.0.0\n\n### CLI\n\n### Bundles\n" + >>> print(insert_entries(cl, "CLI", ["* Added a flag (#1)."]), end="") + ## Release v1.0.0 + + ### CLI + * Added a flag (#1). + + ### Bundles + + Appends after content already present in the section: + + >>> cl = "### CLI\n* Existing (#1).\n\n### Bundles\n" + >>> print(insert_entries(cl, "CLI", ["* New (#2)."]), end="") + ### CLI + * Existing (#1). + * New (#2). + + ### Bundles + + Works for the last section in the file: + + >>> print(insert_entries("### API Changes\n", "API Changes", ["* X (#1)."]), end="") + ### API Changes + * X (#1). + """ + lines = changelog.split("\n") + + header_line = f"### {header}" + try: + start = next(i for i, line in enumerate(lines) if line.strip() == header_line) + except StopIteration: + raise SystemExit(f"section '{header_line}' not found in {NEXT_CHANGELOG}") + + # End of the section: the next heading, or end of file. + end = len(lines) + for i in range(start + 1, len(lines)): + if HEADING_RE.match(lines[i].strip()): + end = i + break + + # Skip trailing blank lines so new entries attach directly to existing + # content (or to the header when the section is empty). + insert_at = end + while insert_at - 1 > start and lines[insert_at - 1].strip() == "": + insert_at -= 1 + + lines[insert_at:insert_at] = entries + return "\n".join(lines) + + +def iter_fragment_files(changelog_dir): + """Yield every ``*.md`` fragment under *changelog_dir*, excluding READMEs.""" + for path in sorted(changelog_dir.rglob("*.md")): + if path.name == "README.md": + continue + yield path + + +def find_misplaced(changelog_dir): + """Return fragment paths that are not ``.nextchanges/
/.md``.""" + misplaced = [] + for path in iter_fragment_files(changelog_dir): + rel = path.relative_to(changelog_dir) + if len(rel.parts) != 2 or rel.parts[0] not in SECTION_SLUGS: + misplaced.append(path) + return misplaced + + +def check(root): + """Validate fragment placement. Returns a process exit code.""" + changelog_dir = root / CHANGELOG_DIR + if not changelog_dir.is_dir(): + return 0 + + problems = [] + for path in find_misplaced(changelog_dir): + problems.append(f"{path}: not in a known section directory") + for path in iter_fragment_files(changelog_dir): + if not path.read_text(encoding="utf-8").strip(): + problems.append(f"{path}: empty fragment") + + if problems: + for msg in problems: + print(msg, file=sys.stderr) + valid = ", ".join(slug for slug, _ in SECTIONS) + print(f"\nFragments must live at {CHANGELOG_DIR}/
/.md", file=sys.stderr) + print(f"Valid sections: {valid}", file=sys.stderr) + return 1 + return 0 + + +def collate(root): + """Fold fragments into NEXT_CHANGELOG.md and delete them.""" + changelog_dir = root / CHANGELOG_DIR + next_changelog = root / NEXT_CHANGELOG + + misplaced = find_misplaced(changelog_dir) if changelog_dir.is_dir() else [] + if misplaced: + for path in misplaced: + print(f"{path}: not in a known section directory", file=sys.stderr) + raise SystemExit(1) + + content = next_changelog.read_text(encoding="utf-8") + consumed = [] + total = 0 + for slug, header in SECTIONS: + section_dir = changelog_dir / slug + if not section_dir.is_dir(): + continue + entries = [] + for path in sorted(section_dir.glob("*.md")): + if path.name == "README.md": + continue + entries.append(normalize_entry(path.read_text(encoding="utf-8"))) + consumed.append(path) + if entries: + content = insert_entries(content, header, entries) + total += len(entries) + print(f"{header}: collated {len(entries)} entr{'y' if len(entries) == 1 else 'ies'}") + + if not consumed: + print("No changelog fragments to collate.") + return + + next_changelog.write_text(content, encoding="utf-8") + for path in consumed: + path.unlink() + print(f"Collated {total} entries into {NEXT_CHANGELOG} and removed {len(consumed)} fragments.") + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--check", action="store_true", help="validate fragment placement without writing") + parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path.cwd(), help="repository root") + args = parser.parse_args(argv) + + if args.check: + sys.exit(check(args.root)) + collate(args.root) + + +if __name__ == "__main__": + main() From 368042d88d0ad678f199eb7a2a7bce17408f41af Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 12 Jun 2026 01:43:01 +0200 Subject: [PATCH 02/31] Add changelog-collate release workflow A manually dispatched workflow that runs tools/collate_changelog.py, expands any (#NNNN) references to links, and opens a single 'Collate changelog fragments' PR via peter-evans/create-pull-request (matching the bump-vuln-deps pattern of opening a PR rather than pushing to main). Run it and merge the PR before dispatching the 'tagging' workflow. With no fragments present the collator is a no-op, so no PR is opened. Co-authored-by: Isaac --- .github/workflows/changelog-collate.yml | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 .github/workflows/changelog-collate.yml diff --git a/.github/workflows/changelog-collate.yml b/.github/workflows/changelog-collate.yml new file mode 100644 index 00000000000..6092315e1a1 --- /dev/null +++ b/.github/workflows/changelog-collate.yml @@ -0,0 +1,52 @@ +name: changelog-collate + +# Release-prep step: fold all .nextchanges/ fragments into NEXT_CHANGELOG.md and +# open a single PR. Run this (and merge the PR) before dispatching the `tagging` +# workflow so the release picks up every entry. Between releases, fragments +# accumulate under .nextchanges/ without ever touching NEXT_CHANGELOG.md, so +# contributor PRs don't conflict. +on: + workflow_dispatch: + +# Ensure two dispatches don't race on the PR branch. +concurrency: + group: changelog-collate + +permissions: + contents: write + pull-requests: write + +jobs: + collate: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.8.9" + + - name: Collate fragments into NEXT_CHANGELOG.md + run: uv run --script tools/collate_changelog.py + + - name: Expand PR references to links + run: uv run --script tools/update_github_links.py NEXT_CHANGELOG.md + + - name: Determine release version + id: version + run: echo "version=$(grep -m1 '## Release v' NEXT_CHANGELOG.md | sed 's/^## Release //')" >> "$GITHUB_OUTPUT" + + - name: Create pull request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + # A fixed branch means a re-run updates the existing open PR in place + # rather than opening a new one. + branch: auto/collate-changelog + commit-message: "Collate changelog fragments for ${{ steps.version.outputs.version }}" + title: "Collate changelog fragments for ${{ steps.version.outputs.version }}" + body: |- + Folds every `.nextchanges/` fragment into the matching section of `NEXT_CHANGELOG.md` and removes the fragment files. + + Merge this before dispatching the `tagging` workflow so the release picks up every entry. No fragments means no diff and no PR. From 7707dd1d837db3104bb75be608f7f4af21d28b0a Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 12 Jun 2026 01:43:12 +0200 Subject: [PATCH 03/31] Point contributors at .nextchanges in PR template and pr-checklist Update the contributor-facing guidance to add a .nextchanges/
/.md fragment instead of editing NEXT_CHANGELOG.md: each PR adds its own file so entries never conflict, the filename is arbitrary, the leading bullet and the PR link are optional, and it can be created from the GitHub UI. These are the only two docs that described the old workflow. Co-authored-by: Isaac --- .agent/skills/pr-checklist/SKILL.md | 10 +++++----- .github/PULL_REQUEST_TEMPLATE.md | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.agent/skills/pr-checklist/SKILL.md b/.agent/skills/pr-checklist/SKILL.md index fc3d78e93ec..4ac86450821 100644 --- a/.agent/skills/pr-checklist/SKILL.md +++ b/.agent/skills/pr-checklist/SKILL.md @@ -55,7 +55,7 @@ If an agent (you) authored or substantially helped author the PR, disclose it on ## Changelog entry -Add a `NEXT_CHANGELOG.md` entry when your change is user-visible. CI generates the real `CHANGELOG.md` from `NEXT_CHANGELOG.md` at release time, so never hand-edit `CHANGELOG.md` directly. +Add a changelog fragment under `.nextchanges/` when your change is user-visible. Each PR adds its own file, so entries never conflict between PRs. CI collates the fragments and generates the real `CHANGELOG.md` at release time, so never hand-edit `CHANGELOG.md` or `NEXT_CHANGELOG.md` directly. **When to add an entry:** - New or changed CLI command, flag, or subcommand behavior @@ -69,7 +69,7 @@ Add a `NEXT_CHANGELOG.md` entry when your change is user-visible. CI generates t - Auto-generated output changes without a corresponding user-facing change **How to add:** -- Pick the right section (`CLI`, `Bundles`, `Dependency updates`) under the current `## Release vX.Y.Z` header. -- One or two sentences, user-facing language, no Jira links. -- Reference the PR number once it's open: after `gh pr create`, edit the entry to append ` (#NNNN)` or similar matching nearby entries. -- Match the voice and tense of the existing entries in the file. +- Create `.nextchanges/
/.md`, picking the section folder that fits: `cli`, `bundles`, `dependency-updates`, `notable-changes`, or `api-changes`. `` is arbitrary (a feature name or your PR number) — just keep it unique. You can create it straight from the GitHub UI. +- Write one or two sentences in user-facing language, no Jira links. The leading `* ` is optional. Match the voice and tense of existing changelog entries. +- A PR link is optional: write `(#NNNN)` in the text and it's expanded to a full link automatically. +- See `.nextchanges/README.md` for details. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2b6289b935b..21bad8efd76 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -9,4 +9,5 @@ For example, were there any decisions behind the change that are not reflected i +add a changelog fragment: create .nextchanges/
/.md with a +one-line description (e.g. .nextchanges/cli/quickstart.md). See .nextchanges/README.md. --> From 6543e3080fbd5bc8bca15fbeb53bdd85ecad946e Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 12 Jun 2026 14:17:10 +0200 Subject: [PATCH 04/31] Demo: add test changelog fragments (revert after review) Temporary commit for review (paired with the collation commit that follows, both to be reverted). Adds 8 .nextchanges/ fragments exercising every permutation: all five sections (empty and populated), * / - / no bullet marker, no ref / (#N) / bare #N / pre-expanded link, single / multi-bullet / multi-line continuation, and PR-number vs feature-name filenames. Co-authored-by: Isaac --- .nextchanges/api-changes/5630.md | 1 + .nextchanges/bundles/5610.md | 2 ++ .nextchanges/bundles/template-foo.md | 1 + .nextchanges/cli/5600.md | 1 + .nextchanges/cli/5601-stdin-fix.md | 1 + .nextchanges/cli/quickstart-improvements.md | 1 + .nextchanges/dependency-updates/bump-sdk.md | 1 + .nextchanges/notable-changes/multiline.md | 2 ++ 8 files changed, 10 insertions(+) create mode 100644 .nextchanges/api-changes/5630.md create mode 100644 .nextchanges/bundles/5610.md create mode 100644 .nextchanges/bundles/template-foo.md create mode 100644 .nextchanges/cli/5600.md create mode 100644 .nextchanges/cli/5601-stdin-fix.md create mode 100644 .nextchanges/cli/quickstart-improvements.md create mode 100644 .nextchanges/dependency-updates/bump-sdk.md create mode 100644 .nextchanges/notable-changes/multiline.md diff --git a/.nextchanges/api-changes/5630.md b/.nextchanges/api-changes/5630.md new file mode 100644 index 00000000000..0d0dc6fd33c --- /dev/null +++ b/.nextchanges/api-changes/5630.md @@ -0,0 +1 @@ +Added `Foo.bar` field on the Jobs API ([#5630](https://github.com/databricks/cli/pull/5630)). diff --git a/.nextchanges/bundles/5610.md b/.nextchanges/bundles/5610.md new file mode 100644 index 00000000000..5e5a9f827de --- /dev/null +++ b/.nextchanges/bundles/5610.md @@ -0,0 +1,2 @@ +* First bundles change in this PR (#5610). +* Second bundles change in the same PR (#5610). diff --git a/.nextchanges/bundles/template-foo.md b/.nextchanges/bundles/template-foo.md new file mode 100644 index 00000000000..39a17c7dda0 --- /dev/null +++ b/.nextchanges/bundles/template-foo.md @@ -0,0 +1 @@ +- Added `foo` support to bundle templates. diff --git a/.nextchanges/cli/5600.md b/.nextchanges/cli/5600.md new file mode 100644 index 00000000000..c05e69fedc3 --- /dev/null +++ b/.nextchanges/cli/5600.md @@ -0,0 +1 @@ +* Added a `--output json` flag to `databricks auth describe` (#5600). diff --git a/.nextchanges/cli/5601-stdin-fix.md b/.nextchanges/cli/5601-stdin-fix.md new file mode 100644 index 00000000000..5241ff35ff7 --- /dev/null +++ b/.nextchanges/cli/5601-stdin-fix.md @@ -0,0 +1 @@ +Fixed a panic in `databricks configure` when stdin is closed #5601 diff --git a/.nextchanges/cli/quickstart-improvements.md b/.nextchanges/cli/quickstart-improvements.md new file mode 100644 index 00000000000..93e2ab4fa57 --- /dev/null +++ b/.nextchanges/cli/quickstart-improvements.md @@ -0,0 +1 @@ +Improved the `databricks quickstart` command output formatting. diff --git a/.nextchanges/dependency-updates/bump-sdk.md b/.nextchanges/dependency-updates/bump-sdk.md new file mode 100644 index 00000000000..a9fd33db500 --- /dev/null +++ b/.nextchanges/dependency-updates/bump-sdk.md @@ -0,0 +1 @@ +Bump the Go SDK to v0.142.0 (#5620). diff --git a/.nextchanges/notable-changes/multiline.md b/.nextchanges/notable-changes/multiline.md new file mode 100644 index 00000000000..ea6af528799 --- /dev/null +++ b/.nextchanges/notable-changes/multiline.md @@ -0,0 +1,2 @@ +Introduced experimental support for X across all bundle resources. + This continuation line documents the migration note for the same entry. From bf196ac2c1be22e7607d6f3e69374760b41892ed Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 12 Jun 2026 14:17:31 +0200 Subject: [PATCH 05/31] Demo: collate test changelog fragments into NEXT_CHANGELOG.md (revert after review) Temporary commit for review (revert this and the preceding 'add test changelog fragments' commit). Ran 'task changelog-collate', which folds every .nextchanges/ fragment into the matching NEXT_CHANGELOG.md section, expands (#NNNN) references to links, and removes the consumed fragment files. Co-authored-by: Isaac --- .nextchanges/api-changes/5630.md | 1 - .nextchanges/bundles/5610.md | 2 -- .nextchanges/bundles/template-foo.md | 1 - .nextchanges/cli/5600.md | 1 - .nextchanges/cli/5601-stdin-fix.md | 1 - .nextchanges/cli/quickstart-improvements.md | 1 - .nextchanges/dependency-updates/bump-sdk.md | 1 - .nextchanges/notable-changes/multiline.md | 2 -- NEXT_CHANGELOG.md | 10 ++++++++++ 9 files changed, 10 insertions(+), 10 deletions(-) delete mode 100644 .nextchanges/api-changes/5630.md delete mode 100644 .nextchanges/bundles/5610.md delete mode 100644 .nextchanges/bundles/template-foo.md delete mode 100644 .nextchanges/cli/5600.md delete mode 100644 .nextchanges/cli/5601-stdin-fix.md delete mode 100644 .nextchanges/cli/quickstart-improvements.md delete mode 100644 .nextchanges/dependency-updates/bump-sdk.md delete mode 100644 .nextchanges/notable-changes/multiline.md diff --git a/.nextchanges/api-changes/5630.md b/.nextchanges/api-changes/5630.md deleted file mode 100644 index 0d0dc6fd33c..00000000000 --- a/.nextchanges/api-changes/5630.md +++ /dev/null @@ -1 +0,0 @@ -Added `Foo.bar` field on the Jobs API ([#5630](https://github.com/databricks/cli/pull/5630)). diff --git a/.nextchanges/bundles/5610.md b/.nextchanges/bundles/5610.md deleted file mode 100644 index 5e5a9f827de..00000000000 --- a/.nextchanges/bundles/5610.md +++ /dev/null @@ -1,2 +0,0 @@ -* First bundles change in this PR (#5610). -* Second bundles change in the same PR (#5610). diff --git a/.nextchanges/bundles/template-foo.md b/.nextchanges/bundles/template-foo.md deleted file mode 100644 index 39a17c7dda0..00000000000 --- a/.nextchanges/bundles/template-foo.md +++ /dev/null @@ -1 +0,0 @@ -- Added `foo` support to bundle templates. diff --git a/.nextchanges/cli/5600.md b/.nextchanges/cli/5600.md deleted file mode 100644 index c05e69fedc3..00000000000 --- a/.nextchanges/cli/5600.md +++ /dev/null @@ -1 +0,0 @@ -* Added a `--output json` flag to `databricks auth describe` (#5600). diff --git a/.nextchanges/cli/5601-stdin-fix.md b/.nextchanges/cli/5601-stdin-fix.md deleted file mode 100644 index 5241ff35ff7..00000000000 --- a/.nextchanges/cli/5601-stdin-fix.md +++ /dev/null @@ -1 +0,0 @@ -Fixed a panic in `databricks configure` when stdin is closed #5601 diff --git a/.nextchanges/cli/quickstart-improvements.md b/.nextchanges/cli/quickstart-improvements.md deleted file mode 100644 index 93e2ab4fa57..00000000000 --- a/.nextchanges/cli/quickstart-improvements.md +++ /dev/null @@ -1 +0,0 @@ -Improved the `databricks quickstart` command output formatting. diff --git a/.nextchanges/dependency-updates/bump-sdk.md b/.nextchanges/dependency-updates/bump-sdk.md deleted file mode 100644 index a9fd33db500..00000000000 --- a/.nextchanges/dependency-updates/bump-sdk.md +++ /dev/null @@ -1 +0,0 @@ -Bump the Go SDK to v0.142.0 (#5620). diff --git a/.nextchanges/notable-changes/multiline.md b/.nextchanges/notable-changes/multiline.md deleted file mode 100644 index ea6af528799..00000000000 --- a/.nextchanges/notable-changes/multiline.md +++ /dev/null @@ -1,2 +0,0 @@ -Introduced experimental support for X across all bundle resources. - This continuation line documents the migration note for the same entry. diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 078c98e6bf1..58528ddc053 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -4,16 +4,26 @@ ### Notable Changes * The `direct` deployment engine is now Generally Available and the default for new deployments. To opt out, set `engine: terraform` under `bundle` in your `databricks.yml` or set `DATABRICKS_BUNDLE_ENGINE=terraform`. Existing deployments keep their current engine; see https://docs.databricks.com/aws/en/dev-tools/bundles/direct to migrate. +* Introduced experimental support for X across all bundle resources. + This continuation line documents the migration note for the same entry. ### CLI * Added the `databricks quickstart` command, a short introduction to the CLI that prints a human-friendly guide interactively and an agent-oriented version when run non-interactively ([#5464](https://github.com/databricks/cli/pull/5464)). * `databricks auth login` no longer prompts for workspace selection when logging in to an account console host (`https://accounts.*`). Pass `--workspace-id` explicitly to store a workspace ID on such a profile ([#5504](https://github.com/databricks/cli/pull/5504)). +* Added a `--output json` flag to `databricks auth describe` ([#5600](https://github.com/databricks/cli/pull/5600)). +* Fixed a panic in `databricks configure` when stdin is closed ([#5601](https://github.com/databricks/cli/pull/5601)) +* Improved the `databricks quickstart` command output formatting. ### Bundles * Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)). * Mark vector search index index_subtype as backend_default to prevent drift after deployment ([#5454](https://github.com/databricks/cli/pull/5454)). * `bundle deployment migrate`: handle resources added to or removed from `databricks.yml` since the last Terraform deploy ([#5463](https://github.com/databricks/cli/pull/5463)). +* First bundles change in this PR ([#5610](https://github.com/databricks/cli/pull/5610)). +* Second bundles change in the same PR ([#5610](https://github.com/databricks/cli/pull/5610)). +* Added `foo` support to bundle templates. ### Dependency updates +* Bump the Go SDK to v0.142.0 ([#5620](https://github.com/databricks/cli/pull/5620)). ### API Changes +* Added `Foo.bar` field on the Jobs API ([#5630](https://github.com/databricks/cli/pull/5630)). From 73d3550fb4d58b0ed7e998772a845b288517a160 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 12 Jun 2026 14:19:01 +0200 Subject: [PATCH 06/31] Revert NEXT_CHANGELOG.md --- NEXT_CHANGELOG.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 58528ddc053..078c98e6bf1 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -4,26 +4,16 @@ ### Notable Changes * The `direct` deployment engine is now Generally Available and the default for new deployments. To opt out, set `engine: terraform` under `bundle` in your `databricks.yml` or set `DATABRICKS_BUNDLE_ENGINE=terraform`. Existing deployments keep their current engine; see https://docs.databricks.com/aws/en/dev-tools/bundles/direct to migrate. -* Introduced experimental support for X across all bundle resources. - This continuation line documents the migration note for the same entry. ### CLI * Added the `databricks quickstart` command, a short introduction to the CLI that prints a human-friendly guide interactively and an agent-oriented version when run non-interactively ([#5464](https://github.com/databricks/cli/pull/5464)). * `databricks auth login` no longer prompts for workspace selection when logging in to an account console host (`https://accounts.*`). Pass `--workspace-id` explicitly to store a workspace ID on such a profile ([#5504](https://github.com/databricks/cli/pull/5504)). -* Added a `--output json` flag to `databricks auth describe` ([#5600](https://github.com/databricks/cli/pull/5600)). -* Fixed a panic in `databricks configure` when stdin is closed ([#5601](https://github.com/databricks/cli/pull/5601)) -* Improved the `databricks quickstart` command output formatting. ### Bundles * Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)). * Mark vector search index index_subtype as backend_default to prevent drift after deployment ([#5454](https://github.com/databricks/cli/pull/5454)). * `bundle deployment migrate`: handle resources added to or removed from `databricks.yml` since the last Terraform deploy ([#5463](https://github.com/databricks/cli/pull/5463)). -* First bundles change in this PR ([#5610](https://github.com/databricks/cli/pull/5610)). -* Second bundles change in the same PR ([#5610](https://github.com/databricks/cli/pull/5610)). -* Added `foo` support to bundle templates. ### Dependency updates -* Bump the Go SDK to v0.142.0 ([#5620](https://github.com/databricks/cli/pull/5620)). ### API Changes -* Added `Foo.bar` field on the Jobs API ([#5630](https://github.com/databricks/cli/pull/5630)). From 25cb944d3e09894dd42f9c579e5683c5d4b7efbc Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 12 Jun 2026 14:36:40 +0200 Subject: [PATCH 07/31] Reuse the changelog-collate task in the collate workflow Addresses review feedback: invoke 'task changelog-collate' (via go tool) instead of duplicating the collate + update_github_links steps inline, so the sequence stays defined in one place (Taskfile.yml). Swaps the uv setup for Go accordingly. Co-authored-by: Isaac --- .github/workflows/changelog-collate.yml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/.github/workflows/changelog-collate.yml b/.github/workflows/changelog-collate.yml index 6092315e1a1..25dd4479391 100644 --- a/.github/workflows/changelog-collate.yml +++ b/.github/workflows/changelog-collate.yml @@ -23,16 +23,15 @@ jobs: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Install uv - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - version: "0.8.9" + go-version-file: go.mod - - name: Collate fragments into NEXT_CHANGELOG.md - run: uv run --script tools/collate_changelog.py - - - name: Expand PR references to links - run: uv run --script tools/update_github_links.py NEXT_CHANGELOG.md + # Reuse the changelog-collate task so collate + link-expansion stay + # defined in one place (Taskfile.yml). + - name: Collate fragments and expand PR links + run: go tool -modfile=tools/task/go.mod task changelog-collate - name: Determine release version id: version From 3108bff6c63484259711cc5d6fee2bd3be0664b3 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 12 Jun 2026 14:36:42 +0200 Subject: [PATCH 08/31] Tighten .nextchanges guidance in pr-checklist Addresses review feedback: drop the 'create from the GitHub UI' note (this skill is for agents, which don't use the UI) and clarify that NNNN in (#NNNN) is the PR number. Co-authored-by: Isaac --- .agent/skills/pr-checklist/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.agent/skills/pr-checklist/SKILL.md b/.agent/skills/pr-checklist/SKILL.md index 4ac86450821..9699c7238f3 100644 --- a/.agent/skills/pr-checklist/SKILL.md +++ b/.agent/skills/pr-checklist/SKILL.md @@ -69,7 +69,7 @@ Add a changelog fragment under `.nextchanges/` when your change is user-visible. - Auto-generated output changes without a corresponding user-facing change **How to add:** -- Create `.nextchanges/
/.md`, picking the section folder that fits: `cli`, `bundles`, `dependency-updates`, `notable-changes`, or `api-changes`. `` is arbitrary (a feature name or your PR number) — just keep it unique. You can create it straight from the GitHub UI. +- Create `.nextchanges/
/.md`, picking the section folder that fits: `cli`, `bundles`, `dependency-updates`, `notable-changes`, or `api-changes`. `` is arbitrary (a feature name or your PR number) — just keep it unique. - Write one or two sentences in user-facing language, no Jira links. The leading `* ` is optional. Match the voice and tense of existing changelog entries. -- A PR link is optional: write `(#NNNN)` in the text and it's expanded to a full link automatically. +- A PR link is optional: write `(#NNNN)` (with NNNN being the PR number) in the text and it's expanded to a full link automatically. - See `.nextchanges/README.md` for details. From e92c6be1b1624b509be913aba18d3ebc61c91a58 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 16:36:55 +0200 Subject: [PATCH 09/31] Render .nextchanges/ into CHANGELOG.md via a tagging.py wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit At release time the changelog is built from the per-PR .nextchanges/ fragments and written straight into CHANGELOG.md. The release script is synced verbatim from universe (openapi/tagging/tagging.py) and shared across the SDK repos, so it must stay pristine — editing it in place would be clobbered by the next `genkit update-sdk` and would diverge a shared file. Keep it pristine by renaming the synced copy to internal/genkit/tagging_upstream.py and adding a repo-owned internal/genkit/tagging.py wrapper (despite the name, it's hand-maintained). The wrapper imports tagging_upstream, rebinds two module-level seams — get_next_tag_info (render the body from .nextchanges/
/*.md) and clean_next_changelog (delete the consumed fragments and bump .nextchanges/version) — adds a GitHubRepo.delete_file helper, then delegates to the untouched process() for all commit/tag/race/recovery logic. The release version is read from .nextchanges/version (bumped to the next minor after each release; edit it to cut a patch/major) — the role NEXT_CHANGELOG.md's "## Release vX.Y.Z" header played upstream. Because the wrapper is named tagging.py, tagging.yml runs it with the same `uv run tagging.py` as upstream — the only divergence from the synced workflow is the internal/genkit/ path. generate-genkit relocates the synced file to tagging_upstream.py and keeps the wrapper + repo-owned tagging.yml. ruff.toml excludes tagging_upstream.py (synced) and lints the wrapper. Co-authored-by: Isaac --- .github/workflows/tagging.yml | 5 +- .nextchanges/version | 1 + Taskfile.yml | 33 +- internal/genkit/tagging.py | 1134 +++------------------- internal/genkit/tagging.py.lock | 24 +- internal/genkit/tagging_upstream.py | 1057 ++++++++++++++++++++ internal/genkit/tagging_upstream.py.lock | 302 ++++++ ruff.toml | 2 +- 8 files changed, 1518 insertions(+), 1040 deletions(-) create mode 100644 .nextchanges/version create mode 100755 internal/genkit/tagging_upstream.py create mode 100644 internal/genkit/tagging_upstream.py.lock diff --git a/.github/workflows/tagging.yml b/.github/workflows/tagging.yml index b1a2e3f93ed..db2b5f6b7fa 100755 --- a/.github/workflows/tagging.yml +++ b/.github/workflows/tagging.yml @@ -1,4 +1,7 @@ -# Generated file. DO NOT EDIT. +# Synced from universe, then relocated to run internal/genkit/tagging.py (a +# repo-owned wrapper that renders CHANGELOG.md from .nextchanges/ fragments; +# see that file). The `generate-genkit` task handles the path rewrite, so the +# only divergence from the synced workflow is the script path. name: tagging on: diff --git a/.nextchanges/version b/.nextchanges/version new file mode 100644 index 00000000000..f0bb29e7638 --- /dev/null +++ b/.nextchanges/version @@ -0,0 +1 @@ +1.3.0 diff --git a/Taskfile.yml b/Taskfile.yml index a7a45d38fa1..5b67f7152bd 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -262,16 +262,10 @@ tasks: cmds: - "./tools/update_github_links.py" - changelog-collate: - desc: Collate .nextchanges fragments into NEXT_CHANGELOG.md (run at release time) - cmds: - - "./tools/collate_changelog.py" - - "./tools/update_github_links.py NEXT_CHANGELOG.md" - changelog-check: desc: Validate .nextchanges fragment placement cmds: - - "./tools/collate_changelog.py --check" + - "./tools/validate_nextchanges.py" deadcode: desc: Check for dead code @@ -766,9 +760,11 @@ tasks: # don't keep. Genkit does NOT modify go.mod/go.sum — SDK bumps are a manual # `go get` step before running this task. The cmds below then post-process # genkit's output: assert the SDK version matches the OpenAPI SHA, drop the - # next-changelog workflow, relocate tagging.py under internal/genkit/ and - # rewrite the tagging.yml workflow to match, then yamlfmt + whitespace fix - # so the tree is clean. + # next-changelog workflow, relocate the synced tagging.py (+ lock) to + # internal/genkit/tagging_upstream.py, and discard genkit's tagging.yml. + # internal/genkit/tagging.py is NOT genkit's file — it's a repo-owned wrapper + # that renders CHANGELOG.md from .nextchanges/ around tagging_upstream.py; the + # tagging.yml workflow runs it, so we keep our copy rather than the synced one. generate-genkit: desc: Run genkit to generate CLI commands and tagging workflow (requires universe repo) sources: @@ -802,15 +798,14 @@ tasks: - "cat .gitattributes.manual .gitattributes > .gitattributes.tmp && mv .gitattributes.tmp .gitattributes" - go test -timeout 240s -run TestConsistentDatabricksSdkVersion github.com/databricks/cli/internal/build - rm .github/workflows/next-changelog.yml - - mv tagging.py internal/genkit/tagging.py - - mv tagging.py.lock internal/genkit/tagging.py.lock - - | - if [ "$(uname)" = "Darwin" ]; then - sed -i '' 's|tagging.py|internal/genkit/tagging.py|g' .github/workflows/tagging.yml - else - sed -i 's|tagging.py|internal/genkit/tagging.py|g' .github/workflows/tagging.yml - fi - - "{{.GO_TOOL}} yamlfmt .github/workflows/tagging.yml" + # tagging.yml is repo-owned (it runs the internal/genkit/tagging.py + # wrapper); discard genkit's regenerated copy rather than sed-rewriting it. + - git checkout -- .github/workflows/tagging.yml + # The synced file becomes tagging_upstream.py; the repo-owned wrapper keeps + # the tagging.py name (and reuses the synced lock, since it shares deps). + - mv tagging.py internal/genkit/tagging_upstream.py + - mv tagging.py.lock internal/genkit/tagging_upstream.py.lock + - cp internal/genkit/tagging_upstream.py.lock internal/genkit/tagging.py.lock - task: ws # Refreshes out.fields.txt, which records the field paths / types emitted by diff --git a/internal/genkit/tagging.py b/internal/genkit/tagging.py index a3897bbeedf..49017b36eeb 100755 --- a/internal/genkit/tagging.py +++ b/internal/genkit/tagging.py @@ -2,1056 +2,176 @@ # /// script # dependencies = ["PyGithub>=2,<3", "pyjwt<2.12.0", "charset-normalizer<3.4.6"] # /// +"""databricks/cli release entrypoint. + +Thin wrapper around ``tagging_upstream.py`` — which is regenerated verbatim from +universe (``openapi/tagging/tagging.py``) by ``genkit update-sdk`` and must stay +pristine. Despite the filename, THIS file is hand-maintained: the tagging +workflow runs ``tagging.py`` (this wrapper), which holds the CLI-specific +behavior instead of editing the synced file: + +* the changelog body is rendered from per-PR ``.nextchanges/
/*.md`` + fragments rather than read from a ``NEXT_CHANGELOG.md`` file, and +* the release version is read from ``.nextchanges/version`` (bumped to the next + minor on each release) rather than a hand-maintained ``## Release vX.Y.Z`` + header. + +It injects that behavior by rebinding two module-level seams in the upstream +module (``get_next_tag_info`` and ``clean_next_changelog``, both called by name +from ``process_package``/``preview_tag_infos``) and then delegates to the +untouched ``process()`` for all commit/tag/race/recovery logic. +""" import os import re -import argparse -from typing import Optional, List, Callable, Dict -from dataclasses import dataclass, replace -import subprocess -import time -import json -from github import Github, Repository, InputGitTreeElement, InputGitAuthor -from datetime import datetime, timezone - -NEXT_CHANGELOG_FILE_NAME = "NEXT_CHANGELOG.md" -CHANGELOG_FILE_NAME = "CHANGELOG.md" -PACKAGE_FILE_NAME = ".package.json" -CODEGEN_FILE_NAME = ".codegen.json" -CREATED_TAGS_FILE_NAME = "created_tags.json" -""" -This script tags the release of the SDKs using a combination of the GitHub API and Git commands. -It reads the local repository to determine necessary changes, updates changelogs, and creates tags. - -### How it Works: -- It does **not** modify the local repository directly. -- Instead of committing and pushing changes locally, it uses the **GitHub API** to create commits and tags. -""" - - -@dataclass(frozen=True) -class Version: - """ - A semver 2.0.0-compliant version (https://semver.org). - - Mirrors the API of the `semver` PyPI package so this implementation can be - swapped for that library if it is ever added to the wheelhouse. Supports - parsing, stringification, and the two bumps we need: minor (for stable - releases) and prerelease (for release trains). - """ - - # Permissive pattern for locating a semver version string inside larger - # text (e.g. a changelog header). Callers use it in f-strings; strict - # validation happens via Version.parse. - PATTERN = r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?" - - # Strict anchored regex per https://semver.org. Rejects leading zeros in - # numeric identifiers and invalid pre-release/build identifier charsets. - _PARSE_REGEX = re.compile( - r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" - r"(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" - r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" - r"(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" - ) - - major: int - minor: int - patch: int - prerelease: str = "" - build: str = "" - - @classmethod - def parse(cls, text: str) -> "Version": - """Parse a semver string, raising ValueError on malformed input.""" - match = cls._PARSE_REGEX.match(text) - if not match: - raise ValueError(f"Invalid semver version: {text!r}") - major, minor, patch, prerelease, build = match.groups() - return cls( - major=int(major), - minor=int(minor), - patch=int(patch), - prerelease=prerelease or "", - build=build or "", - ) - - def __str__(self) -> str: - result = f"{self.major}.{self.minor}.{self.patch}" - if self.prerelease: - result += f"-{self.prerelease}" - if self.build: - result += f"+{self.build}" - return result - - def bump_minor(self) -> "Version": - """ - Bump the minor version and reset patch. - - Per semver item 9, a pre-release version has lower precedence than - the same MAJOR.MINOR.PATCH, so bumping to a new minor drops any - pre-release and build metadata. - """ - return Version(major=self.major, minor=self.minor + 1, patch=0) - - def bump_prerelease(self) -> "Version": - """ - Increment the rightmost numeric identifier in the pre-release. - - Matches the npm `prerelease` bump semantics: - 0.0.0-alpha.1 -> 0.0.0-alpha.2 - 0.0.0-alpha -> 0.0.0-alpha.1 - 0.0.0-rc.1.2 -> 0.0.0-rc.1.3 - - Raises ValueError if the version has no pre-release to bump. - Build metadata is dropped since it does not affect precedence. - """ - if not self.prerelease: - raise ValueError(f"Cannot bump prerelease of {self}: no pre-release component") - parts = self.prerelease.split(".") - for i in range(len(parts) - 1, -1, -1): - if parts[i].isdigit(): - parts[i] = str(int(parts[i]) + 1) - return replace(self, prerelease=".".join(parts), build="") - # No numeric identifier exists; append ".1" to start a counter. - return replace(self, prerelease=f"{self.prerelease}.1", build="") - - def next_release_version(self) -> "Version": - """ - Default next version for the changelog after this one is released. - - If on a pre-release track, stay on it by bumping the pre-release - identifier (npm convention). Otherwise, bump the minor version, - the script's historical default for stable releases. Teams can - override the default in the release PR. - """ - if self.prerelease: - return self.bump_prerelease() - return self.bump_minor() - - -def _read_local_head_sha() -> str: - """ - Returns the SHA of the local working tree's HEAD via ``git rev-parse``. - """ - return subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() - - -class MainAdvancedError(Exception): - """ - Raised when ``origin/main`` has advanced since the workflow's - checkout — i.e., another commit landed during this run. The local - working tree is now stale, so any commit produced from it would - silently revert whatever the concurrent push added. - """ - - -# GitHub does not support signing commits for GitHub Apps directly. -# This class replaces usages for git commands such as "git add", "git commit", and "git push". -@dataclass -class GitHubRepo: - def __init__(self, repo: Repository): - self.repo = repo - self.changed_files: list[InputGitTreeElement] = [] - self.ref = "heads/main" - # Anchor ``self.sha`` to the **local checkout** rather than a - # live API call. ``actions/checkout`` populates the working tree - # at this SHA, and every subsequent file read in this run is - # against that tree; the API HEAD is only relevant when we go - # to push. - self.sha = _read_local_head_sha() - - # Replaces "git add file" - def add_file(self, loc: str, content: str): - local_path = os.path.relpath(loc, os.getcwd()) - print(f"Adding file {local_path}") - blob = self.repo.create_git_blob(content=content, encoding="utf-8") - element = InputGitTreeElement(path=local_path, mode="100644", type="blob", sha=blob.sha) - self.changed_files.append(element) - - # Replaces "git commit && git push" - def commit_and_push(self, message: str): - head_ref = self.repo.get_git_ref(self.ref) - if head_ref.object.sha != self.sha: - raise MainAdvancedError( - f"origin/main advanced from {self.sha} to {head_ref.object.sha} " - f"during this run. Local working tree is stale; aborting before " - f"the commit would silently revert the new content. Re-run the " - f"workflow." - ) - base_tree = self.repo.get_git_tree(sha=head_ref.object.sha) - new_tree = self.repo.create_git_tree(self.changed_files, base_tree) - parent_commit = self.repo.get_git_commit(head_ref.object.sha) - - new_commit = self.repo.create_git_commit(message=message, tree=new_tree, parents=[parent_commit]) - # Update branch reference. - head_ref.edit(new_commit.sha) - self.sha = new_commit.sha - - def reset(self, sha: Optional[str] = None): - self.changed_files = [] - if sha: - self.sha = sha - else: - self.sha = _read_local_head_sha() - - def tag(self, tag_name: str, tag_message: str): - # Create a tag pointing to the new commit - # The email MUST be the GitHub Apps email. - # Otherwise, the tag will not be verified. - tagger = InputGitAuthor( - name="Databricks SDK Release Bot", email="DECO-SDK-Tagging[bot]@users.noreply.github.com" - ) - - tag = self.repo.create_git_tag(tag=tag_name, message=tag_message, object=self.sha, type="commit", tagger=tagger) - # Create a Git ref (the actual reference for the tag in the repo) - self.repo.create_git_ref(ref=f"refs/tags/{tag_name}", sha=tag.sha) - - -gh: Optional[GitHubRepo] = None - - -@dataclass -class Package: - """ - Represents a package in the repository. - :name: The package name. - :path: The path to the package relative to the repository root. - """ - - name: str - path: str - - -@dataclass -class TagInfo: - """ - Represents all changes on a release. - :package: package info. - :version: release version for the package. Format: v.. - :content: changes for the release, as they appear in the changelog. - When written to CHANGELOG.md, the current date (YYYY-MM-DD) is automatically added. - - Example (from NEXT_CHANGELOG.md): - - ## Release v0.56.0 - - ### New Features and Improvements - * Feature - * Some improvement - - ### Bug Fixes - * Bug fix - - ### Documentation - * Doc Changes - - ### Internal Changes - * More Changes - - ### API Changes - * Add new Service - - Note: When written to CHANGELOG.md, the header becomes: ## Release v0.56.0 (YYYY-MM-DD) - - """ - - package: Package - version: str - content: str - - def tag_name(self) -> str: - return f"{self.package.name}/v{self.version}" if self.package.name else f"v{self.version}" - - -def get_package_name(package_path: str) -> str: - """ - Returns the package name from the package path. - The name is found inside the .package.json file: - { - "package": "package_name" - } - """ - filepath = os.path.join(os.getcwd(), package_path, PACKAGE_FILE_NAME) - with open(filepath, "r") as file: - content = json.load(file) - if "package" in content: - return content["package"] - # Legacy SDKs have no packages. - return "" - - -def stage_version_updates(tag_infos: List[TagInfo], packages: List[Package]) -> None: - """ - Stages all version-related edits for the release in a single pass over - every package the workspace already opts in via ``.package.json``. - """ - - # Load patterns from '.codegen.json' at the top level of the repository. - package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) - with open(package_file_path, "r") as file: - codegen = json.load(file) - - version_patterns = codegen.get("version", {}) - dep_patterns = codegen.get("dependency_pattern", {}) - name_template = codegen.get("dependency_name_template", "") - - if not version_patterns and not dep_patterns: - print("Neither `version` nor `dependency_pattern` found in .codegen.json. Nothing to update.") - return - - bumped_by_dir: Dict[str, TagInfo] = {info.package.path: info for info in tag_infos} - new_dep_versions = compute_dependency_rewrites(tag_infos, name_template) - - files = sorted(set(version_patterns.keys()) | set(dep_patterns.keys())) - - for pkg in packages: - for filename in files: - loc = os.path.join(os.getcwd(), pkg.path, filename) - - with open(loc, "r") as file: - content = file.read() - original = content - - # Own version (only when this package is being released and the - # file has a version pattern declared). - info = bumped_by_dir.get(pkg.path) - if info is not None and filename in version_patterns: - pattern = version_patterns[filename] - previous_version = pattern.replace("$VERSION", Version.PATTERN) - new_version = pattern.replace("$VERSION", info.version) - content = re.sub(previous_version, new_version, content) - - # Sibling dependency rewrites (only when the file has a - # dependency pattern and there is at least one bumped sibling). - if filename in dep_patterns and new_dep_versions: - content = rewrite_dependencies(content, dep_patterns[filename], new_dep_versions) - - if content != original: - gh.add_file(loc, content) - - -def compute_dependency_rewrites( - tag_infos: List[TagInfo], - name_template: str, -) -> Dict[str, str]: - """ - Returns a map of dependency-name to the new semver string for each - bumped package. - """ - if not name_template: - return {} - rewrites: Dict[str, str] = {} - for info in tag_infos: - # Skip legacy releases that don't have a per-package name; their - # tag_info has an empty package.name and they can't be referenced - # as a sibling dep anyway. - if not info.package.name: - continue - dep_name = name_template.replace("$PACKAGE", info.package.name) - rewrites[dep_name] = info.version - return rewrites - - -def rewrite_dependencies(content: str, pattern: str, new_versions: Dict[str, str]) -> str: - """ - Apply ``pattern`` (with ``$DEPENDENCY`` and ``$VERSION`` placeholders) to - rewrite every entry in ``content`` whose dependency name appears in - ``new_versions``. - """ - # Sentinel strings used to protect the placeholders through re.escape: - # we substitute them in, escape the whole template, then swap them out - # for the dep-name literal and Version.PATTERN. Control characters so - # they can't collide with anything in real .codegen.json patterns. - dep_sentinel = "\x01DEPENDENCY\x01" - ver_sentinel = "\x01VERSION\x01" - - for dep_name, new_value in new_versions.items(): - regex = pattern.replace("$DEPENDENCY", dep_sentinel).replace("$VERSION", ver_sentinel) - regex = re.escape(regex) - regex = regex.replace(re.escape(dep_sentinel), re.escape(dep_name)) - regex = regex.replace(re.escape(ver_sentinel), Version.PATTERN) - - # Build the literal replacement text by substituting the same - # placeholders directly. A lambda is used instead of a string to - # avoid re.sub interpreting \1, \g<...>, etc. inside the value. - replacement_text = pattern.replace("$DEPENDENCY", dep_name).replace("$VERSION", new_value) - content = re.sub(regex, lambda _m, text=replacement_text: text, content) - return content - - -def clean_next_changelog(package_path: str) -> None: - """ - Cleans the "NEXT_CHANGELOG.md" file. It performs 2 operations: - * Increase the version to the next minor version. - * Remove release notes. Sections names are kept to - keep consistency in the section names between releases. - """ - - file_path = os.path.join(os.getcwd(), package_path, NEXT_CHANGELOG_FILE_NAME) - with open(file_path, "r") as file: - content = file.read() - - # Remove content between ### sections. - cleaned_content = re.sub(r"(### [^\n]+\n)(?:.*?\n?)*?(?=###|$)", r"\1", content) - # Ensure there is exactly one empty line before each section. - cleaned_content = re.sub(r"(\n*)(###[^\n]+)", r"\n\n\2", cleaned_content) - # Find the version number and compute the default next release version. - # Teams can adjust the version in the PR if the default is not desired. - # For stable versions, bump minor (historical default since minor releases - # are more common than patch or major). For pre-release versions, stay on - # the same track by bumping the pre-release identifier (npm convention). - version_match = re.search(rf"Release v({Version.PATTERN})", cleaned_content) - if not version_match: - raise Exception("Version not found in the changelog") - current = Version.parse(version_match.group(1)) - new_header = f"Release v{current.next_release_version()}" - cleaned_content = cleaned_content.replace(version_match.group(0), new_header) - - # Update file with cleaned content - gh.add_file(file_path, cleaned_content) - - -def get_previous_tag_info(package: Package) -> Optional[TagInfo]: - """ - Extracts the previous tag info from the "CHANGELOG.md" file. - Used for failure recovery purposes. - """ - changelog_path = os.path.join(os.getcwd(), package.path, CHANGELOG_FILE_NAME) - - with open(changelog_path, "r") as f: - changelog = f.read() - - # Extract the latest release section using regex. - match = re.search( - rf"## (\[Release\] )?Release v{Version.PATTERN}.*?(?=\n## (\[Release\] )?Release v|\Z)", - changelog, - re.S, - ) - - # E.g., for new packages. - if not match: - return None - - latest_release = match.group(0) - version_match = re.search(rf"## (\[Release\] )?Release v({Version.PATTERN})", latest_release) - - if not version_match: - raise Exception("Version not found in the changelog") - - # Validate the extracted string is spec-compliant; fail loudly otherwise. - version = str(Version.parse(version_match.group(2))) - return TagInfo(package=package, version=version, content=latest_release) - - -def _load_codegen_config() -> Dict: - """ - Loads ``.codegen.json`` from the repo root. Returns an empty dict when - the file is missing. - """ - package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) - if not os.path.exists(package_file_path): - return {} - with open(package_file_path, "r") as file: - return json.load(file) - - -def get_next_tag_info(package: Package) -> Optional[TagInfo]: - """ - Extracts the changes from the "NEXT_CHANGELOG.md" file. - The result is already processed. - """ - next_changelog_path = os.path.join(os.getcwd(), package.path, NEXT_CHANGELOG_FILE_NAME) - # Read NEXT_CHANGELOG.md - with open(next_changelog_path, "r") as f: - next_changelog = f.read() - - # Remove "# NEXT CHANGELOG" line - next_changelog = re.sub(r"^# NEXT CHANGELOG(\n+)", "", next_changelog, flags=re.MULTILINE) - - # Remove empty sections - next_changelog = re.sub(r"###[^\n]+\n+(?=##|\Z)", "", next_changelog) - # Ensure there is exactly one empty line before each section - next_changelog = re.sub(r"(\n*)(###[^\n]+)", r"\n\n\2", next_changelog) +from typing import Optional - # By default, packages whose NEXT_CHANGELOG.md has no populated - # sections are skipped — there's nothing meaningful to release. - # Repos like sdk-js which are still in development can opt in - # by setting ``allow_empty_changelog: true`` in .codegen.json. - if not re.search(r"###", next_changelog) and not _load_codegen_config().get("allow_empty_changelog", False): - print("All sections are empty. No changes will be made to the changelog.") - return None - - version_match = re.search(rf"## Release v({Version.PATTERN})", next_changelog) - - if not version_match: - raise Exception("Version not found in the changelog") - - # Validate the extracted string is spec-compliant; fail loudly otherwise. - version = str(Version.parse(version_match.group(1))) - return TagInfo(package=package, version=version, content=next_changelog) +import tagging_upstream as tagging +NEXTCHANGES_DIR = ".nextchanges" -def write_changelog(tag_info: TagInfo) -> None: - """ - Updates the changelog with a new tag info. - """ - changelog_path = os.path.join(os.getcwd(), tag_info.package.path, CHANGELOG_FILE_NAME) - with open(changelog_path, "r") as f: - changelog = f.read() +# Tracks the version of the next release. Read at release time and bumped to the +# next minor afterward — the role NEXT_CHANGELOG.md's "## Release vX.Y.Z" header +# played upstream. +VERSION_FILE = "version" - # Add current date to the release header. - current_date = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") - content_with_date = re.sub( - rf"## Release v({Version.PATTERN})", - rf"## Release v\1 ({current_date})", - tag_info.content.strip(), - ) +# Section subdirectory -> "### " header, in changelog order. Mirrors the +# section folders documented in .nextchanges/README.md and validated by +# tools/validate_nextchanges.py — keep the three in sync. +NEXTCHANGES_SECTIONS = ( + ("notable-changes", "Notable Changes"), + ("cli", "CLI"), + ("bundles", "Bundles"), + ("dependency-updates", "Dependency updates"), + ("api-changes", "API Changes"), +) - updated_changelog = re.sub(r"(# Version changelog\n\n)", f"\\1{content_with_date}\n\n\n", changelog) - gh.add_file(changelog_path, updated_changelog) - -def process_package(package: Package) -> TagInfo: +def _expand_pr_links(text: str) -> str: """ - Processes a package's changelog scaffolding for the release. + Convert raw PR references to canonical markdown links, matching + ``tools/update_github_links.py``: ``(#1234)`` and bare ``#1234`` become + ``([#1234](https://github.com//pull/1234))``. Existing links are + left alone. """ - print(f"Processing package {package.name}") - tag_info = get_next_tag_info(package) - - # If there are no updates, skip. - if tag_info is None: - return - - write_changelog(tag_info) - clean_next_changelog(package.path) - return tag_info - - -def find_packages() -> List[Package]: - """ - Returns all directories which contains a ".package.json" file. - """ - paths = _find_directories_with_file(PACKAGE_FILE_NAME) - return [Package(name=get_package_name(path), path=path) for path in paths] - - -def _find_directories_with_file(target_file: str) -> List[str]: - root_path = os.getcwd() - matching_directories = [] + repo = os.environ.get("GITHUB_REPOSITORY", "databricks/cli") - for dirpath, _, filenames in os.walk(root_path): - if target_file in filenames: - path = os.path.relpath(dirpath, root_path) - # If the path is the root directory (e.g., SDK V0), set it to an empty string. - if path == ".": - path = "" - matching_directories.append(path) + def link(n: str) -> str: + return f"([#{n}](https://github.com/{repo}/pull/{n}))" - return matching_directories + text = re.sub(r"\(#(\d+)\)", lambda m: link(m.group(1)), text) + # Bare #1234 not already inside a link ('[') or paren-wrapped ('('). + return re.sub(r"(? bool: +def render_nextchanges(package_path: str) -> Optional[str]: """ - Returns whether a tag is already applied in the repository. - - :param tag: The tag to check. - :return: True if the tag is applied, False otherwise. - :raises Exception: If the git command fails. - """ - try: - # Check if the specific tag exists - result = subprocess.check_output(["git", "tag", "--list", tag.tag_name()], stderr=subprocess.PIPE, text=True) - return result.strip() == tag.tag_name() - except subprocess.CalledProcessError as e: - # Raise a exception for git command errors - raise Exception(f"Git command failed: {e.stderr.strip() or e}") from e - - -def find_last_release_tag(package: Package) -> Optional[str]: + Render ``/.nextchanges/
/*.md`` into the changelog + body: one ``###
`` block per non-empty section in + NEXTCHANGES_SECTIONS order, fragments sorted by filename. A leading + ``* ``/``- `` marker is optional in a fragment. Returns None when there + are no fragments. """ - Returns the most recent ``/v*`` tag in the repository, or - ``None`` if no such tag exists. Tags are sorted by semver ordering - (``--sort=-v:refname``) so pre-releases sort below their stable - counterparts. - - :raises Exception: If the git command fails. - """ - pattern = f"{package.name}/v*" if package.name else "v*" - try: - output = subprocess.check_output( - ["git", "tag", "--list", pattern, "--sort=-v:refname"], - stderr=subprocess.PIPE, - text=True, - ).strip() - except subprocess.CalledProcessError as e: - raise Exception(f"Git command failed: {e.stderr.strip() or e}") from e - if not output: + base = os.path.join(os.getcwd(), package_path, NEXTCHANGES_DIR) + if not os.path.isdir(base): return None - return output.split("\n")[0].strip() - - -def has_commits_since_tag(tag: str, path: str) -> bool: - """ - Returns True iff at least one commit reachable from HEAD but not from - ``tag`` touches ``path``. Used to detect that a sibling dependency has - unreleased changes that would ship stale if we tagged a dependent - without re-tagging the dependency. - - :raises Exception: If the git command fails. - """ - args = ["git", "log", "--oneline", f"{tag}..HEAD", "--", path or "."] - try: - output = subprocess.check_output(args, stderr=subprocess.PIPE, text=True).strip() - except subprocess.CalledProcessError as e: - raise Exception(f"Git command failed: {e.stderr.strip() or e}") from e - return bool(output) - - -def check_dependency_freshness(tag_infos: List[TagInfo], all_packages: List[Package]) -> None: - """ - Hard-fails when a package being released depends on a sibling package - that has unreleased commits since its last tag. - - Why: dependency rewrites (``stage_version_updates``) only fire for - siblings that are *also* being released. Without this check, releasing - package_a alone — when package_b has commits since its last tag — - publishes ``package_a@new`` pinning the *old* package_b artifact, which - won't have the changes package_a's source depends on. The check is - commit-based (not changelog-based) so a missing ``NEXT_CHANGELOG.md`` - entry on package_b is still caught. - No-op when ``.codegen.json`` declares no dependency pattern (legacy - SDKs without per-package wiring). - """ - if not tag_infos: - return - - package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) - with open(package_file_path, "r") as file: - codegen = json.load(file) - - name_template = codegen.get("dependency_name_template", "") - dep_patterns = codegen.get("dependency_pattern", {}) - if not name_template or not dep_patterns: - return - - releasing_paths = {info.package.path for info in tag_infos} - by_dep_name: Dict[str, Package] = {} - for pkg in all_packages: - if not pkg.name: + blocks = [] + for slug, header in NEXTCHANGES_SECTIONS: + section_dir = os.path.join(base, slug) + if not os.path.isdir(section_dir): continue - by_dep_name[name_template.replace("$PACKAGE", pkg.name)] = pkg - - issues: List[str] = [] - for info in tag_infos: - for filename, pattern in dep_patterns.items(): - loc = os.path.join(os.getcwd(), info.package.path, filename) - if not os.path.exists(loc): + entries = [] + for name in sorted(os.listdir(section_dir)): + if not name.endswith(".md") or name == "README.md": continue - with open(loc, "r") as f: - content = f.read() - - for dep_name, dep_pkg in by_dep_name.items(): - if dep_pkg.path == info.package.path: - continue - if dep_pkg.path in releasing_paths: - continue - - # Same regex construction used by ``rewrite_dependencies``, - # so "is this dep referenced?" matches "would the rewrite - # touch it?". Keeps the two in lockstep. - regex = ( - re.escape(pattern) - .replace(re.escape("$DEPENDENCY"), re.escape(dep_name)) - .replace(re.escape("$VERSION"), Version.PATTERN) - ) - if not re.search(regex, content): - continue - - last_tag = find_last_release_tag(dep_pkg) - if last_tag is None: - # No prior tag means the dep was never released; we - # can't reason about staleness. Surface it anyway so - # the human resolves it explicitly. - issues.append( - f"{info.package.name} depends on {dep_pkg.name}, " - f"which has never been released. Release " - f"{dep_pkg.name} first or include it in this run." - ) - continue - if has_commits_since_tag(last_tag, dep_pkg.path): - issues.append( - f"{info.package.name} depends on {dep_pkg.name}, " - f"which has commits since {last_tag} but is not " - f"being released. Either release {dep_pkg.name} " - f"as well, or hold this release until its changes " - f"are reverted." - ) - - if issues: - raise Exception("Dependency freshness check failed:\n - " + "\n - ".join(issues)) - - -def find_last_tags() -> List[TagInfo]: - """ - Finds the last tags for each package. - - Returns a list of TagInfo objects for each package with a non-None changelog. - """ - packages = find_packages() - - return [info for info in (get_previous_tag_info(package) for package in packages) if info is not None] - - -def find_pending_tags() -> List[TagInfo]: - """ - Finds all tags that are pending to be applied. - """ - tag_infos = find_last_tags() - return [tag for tag in tag_infos if not is_tag_applied(tag)] - - -def generate_commit_message(tag_infos: List[TagInfo]) -> str: - """ - Generates a commit message for the release. - """ - if not tag_infos: - raise Exception("No tag infos provided to generate commit message") - - info = tag_infos[0] - # Legacy mode for SDKs without per service packaging - if not info.package.name: - if len(tag_infos) > 1: - raise Exception("Multiple packages found in legacy mode") - return f"[Release] Release v{info.version}\n\n{info.content}" - - # Sort tag_infos by package name for consistency. - tag_infos.sort(key=lambda info: info.package.name) - titles = ", ".join(f"{info.package.name}/v{info.version}" for info in tag_infos) - body = "\n\n".join(f"## {info.package.name}/v{info.version}\n\n{info.content}" for info in tag_infos) - return f"[Release] {titles}\n\n{body}" - - -def push_changes(tag_infos: List[TagInfo]) -> None: - """Pushes changes to the remote repository after handling possible merge conflicts.""" - - commit_message = generate_commit_message(tag_infos) - - # Create the release metadata file - file_name = os.path.join(os.getcwd(), ".release_metadata.json") - metadata = {"timestamp": datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S%z")} - content = json.dumps(metadata, indent=4) - gh.add_file(file_name, content) - - gh.commit_and_push(commit_message) - - -def reset_repository(hash: Optional[str] = None) -> None: - """ - Reset git to the specified commit. Defaults to HEAD. - - :param hash: The commit hash to reset to. If None, it resets to HEAD. - """ - # Fetch the latest changes from the remote repository. - subprocess.run(["git", "fetch"]) - - # Determine the commit hash (default to origin/main if none is provided). - commit_hash = hash or "origin/main" - - # ``git reset --hard`` must land before ``gh.reset(None)``, since - # ``gh.reset(None)`` reads ``git rev-parse HEAD`` to anchor - # ``self.sha`` to the local working tree. - subprocess.run(["git", "reset", "--hard", commit_hash], check=True) - gh.reset(hash) - - -def retry_function( - func: Callable[[], List[TagInfo]], cleanup: Callable[[], None], max_attempts: int = 5, delay: int = 5 -) -> List[TagInfo]: - """ - Calls a function call up to `max_attempts` times if an exception occurs. - - :param func: The function to call. - :param cleanup: Cleanup function in between retries - :param max_attempts: The maximum number of retries. - :param delay: The delay between retries in seconds. - :return: The return value of the function, or None if all retries fail. - """ - attempts = 0 - while attempts <= max_attempts: - try: - return func() # Call the function - except MainAdvancedError: - # Permanent failure: another commit landed on main during - # this run, so the local tree is stale. Retrying with the - # same stale tree would just hit the same mismatch — only - # a fresh workflow run against the new main can recover. - raise - except Exception as e: - attempts += 1 - print(f"Attempt {attempts} failed: {e}") - if attempts < max_attempts: - time.sleep(delay) # Wait before retrying - cleanup() - else: - print("All retry attempts failed.") - raise e # Re-raise the exception after max retries - - -def update_changelogs(selected_packages: List[Package], all_packages: List[Package]) -> List[TagInfo]: - """ - Updates changelogs and pushes the commits. - - ``selected_packages`` are the packages whose ``NEXT_CHANGELOG.md`` is - consulted to decide what gets released this run. ``all_packages`` is - the full repo inventory used for cross-package dep rewrites. - - The freshness check is deliberately *not* called here. ``process`` - runs it before entering the retry loop so a freshness violation - fails fast — the check is deterministic against the same git state, - so wrapping it in retry would just delay the same failure five - times. - """ - tag_infos = [info for info in (process_package(package) for package in selected_packages) if info is not None] - # If any package was changed, stage version updates and push. - if tag_infos: - stage_version_updates(tag_infos, all_packages) - push_changes(tag_infos) - return tag_infos - - -def preview_tag_infos(packages: List[Package]) -> List[TagInfo]: - """ - Read-only sibling of ``process_package``: returns the TagInfos that - would be released for ``packages`` without writing any changelog - edits. ``process`` calls this before the retry loop so the freshness - check has a snapshot to validate against. ``process_package`` will - re-derive the same TagInfos when ``update_changelogs`` runs; the - duplication is just a couple of NEXT_CHANGELOG.md reads. - """ - return [info for info in (get_next_tag_info(package) for package in packages) if info is not None] - - -def order_tag_infos_by_dependency(tag_infos: List[TagInfo]) -> List[TagInfo]: - """ - Returns ``tag_infos`` in topological order: every package appears - after every sibling it depends on. - """ - if not tag_infos: - return list(tag_infos) - - if any(not info.package.name for info in tag_infos) and len(tag_infos) > 1: - raise Exception("Multiple packages found in legacy mode") - - package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) - with open(package_file_path, "r") as file: - codegen = json.load(file) - - name_template = codegen.get("dependency_name_template", "") - dep_patterns = codegen.get("dependency_pattern", {}) - if not name_template or not dep_patterns: - return list(tag_infos) - - by_dep_name: Dict[str, TagInfo] = { - name_template.replace("$PACKAGE", info.package.name): info for info in tag_infos if info.package.name - } - - # Adjacency: path -> set of paths it depends on (within tag_infos). - deps: Dict[str, set] = {info.package.path: set() for info in tag_infos} - for info in tag_infos: - for filename, pattern in dep_patterns.items(): - loc = os.path.join(os.getcwd(), info.package.path, filename) - if not os.path.exists(loc): + with open(os.path.join(section_dir, name)) as f: + text = f.read().strip() + if not text: continue - with open(loc, "r") as f: - content = f.read() - for dep_name, dep_info in by_dep_name.items(): - if dep_info.package.path == info.package.path: - continue - regex = ( - re.escape(pattern) - .replace(re.escape("$DEPENDENCY"), re.escape(dep_name)) - .replace(re.escape("$VERSION"), Version.PATTERN) - ) - if re.search(regex, content): - deps[info.package.path].add(dep_info.package.path) - - # Stable topological sort: at each step, emit every node whose deps - # are already emitted, alphabetically by package name. Ties broken - # alphabetically so the manifest is reproducible across runs. - emitted: set = set() - ordered: List[TagInfo] = [] - while len(ordered) < len(tag_infos): - ready = sorted( - ( - info - for info in tag_infos - if info.package.path not in emitted and deps[info.package.path].issubset(emitted) - ), - key=lambda info: info.package.name, - ) - if not ready: - remaining = [info.package.name for info in tag_infos if info.package.path not in emitted] - raise Exception(f"Cyclic dependency detected among packages: {remaining}") - for info in ready: - ordered.append(info) - emitted.add(info.package.path) - return ordered - - -def push_tags(tag_infos: List[TagInfo]) -> None: - """ - Creates and pushes tags to the repository. - - Tags are emitted in topological order — dependencies before - dependents — so downstream publishing pipelines reading - ``created_tags.json`` can walk it sequentially without re-deriving - the dependency graph. See ``order_tag_infos_by_dependency``. + first, _, rest = text.partition("\n") + first = "* " + first[2:] if first.startswith(("* ", "- ")) else "* " + first + entries.append(first + (("\n" + rest) if rest else "")) + if entries: + blocks.append(f"### {header}\n" + "\n".join(entries)) - As a side effect, writes the names of successfully created tags to - ``./created_tags.json`` so that workflows triggering this script can - discover what was produced (the GitHub Actions workflow uploads this - file as the ``created-tags`` artifact). + if not blocks: + return None + return _expand_pr_links("\n\n".join(blocks)) - Schema: - {"tags": ["service-a/v1.2.3", "service-b/v0.4.0"]} - The manifest is written even if tag creation fails partway through: - tags that succeeded before the failure are flushed before the - exception is re-raised, so recovery-mode runs still surface their - output. - """ - tag_infos = order_tag_infos_by_dependency(tag_infos) - created: List[str] = [] - try: - for tag_info in tag_infos: - gh.tag(tag_info.tag_name(), tag_info.content) - created.append(tag_info.tag_name()) - finally: - manifest_path = os.path.join(os.getcwd(), CREATED_TAGS_FILE_NAME) - with open(manifest_path, "w") as f: - json.dump({"tags": created}, f) +def _version_path(package_path: str) -> str: + return os.path.join(os.getcwd(), package_path, NEXTCHANGES_DIR, VERSION_FILE) -def run_command(command: List[str]) -> str: +def next_version(package: tagging.Package) -> str: """ - Runs a command and returns the output + Release version for this run, read from ``.nextchanges/version``. To cut a + patch or major release, edit that file in the PR; otherwise the default + (bumped after the previous release) is the next minor. """ - output = subprocess.check_output(command) - print(f'Running command: {" ".join(command)}') - return output.decode() + with open(_version_path(package.path)) as f: + return str(tagging.Version.parse(f.read().strip().lstrip("v"))) -def pull_last_release_commit() -> None: +def get_next_tag_info(package: tagging.Package) -> Optional[tagging.TagInfo]: """ - Reset the repository to the last release. - Uses commit for last change to .release_metadata.json, since it's only updated on releases. - """ - commit_hash = subprocess.check_output( - ["git", "log", "-n", "1", "--format=%H", "--", ".release_metadata.json"], text=True - ).strip() - - # If no commit is found, raise an exception - if not commit_hash: - raise ValueError("No commit found for .release_metadata.json") - - # Reset the repository to the commit - reset_repository(commit_hash) - - -def get_packages_from_args() -> List[str]: + Replacement for ``tagging.get_next_tag_info``: build the release TagInfo + from ``.nextchanges/`` fragments. Returns None when there are no entries + (nothing to release), unless ``allow_empty_changelog`` is set in + ``.codegen.json`` — matching the pristine skip behavior. """ - Retrieves the list of packages to tag. - - python3 ./tagging.py --package # single package - python3 ./tagging.py --package , # multiple packages - - Returns an empty list when --package is omitted, which means all packages - with pending releases will be tagged. - """ - parser = argparse.ArgumentParser(description="Update changelogs and tag the release.") - parser.add_argument( - "--package", - "-p", - type=str, - default="", - help="Comma-separated list of packages to tag. Leave empty to tag all packages with pending releases.", - ) - args = parser.parse_args() - return [name.strip() for name in args.package.split(",") if name.strip()] - + body = render_nextchanges(package.path) + if body is None and not tagging._load_codegen_config().get("allow_empty_changelog", False): + print("No .nextchanges/ entries. No changes will be made to the changelog.") + return None -def init_github(): - token = os.environ["GITHUB_TOKEN"] - repo_name = os.environ["GITHUB_REPOSITORY"] - g = Github(token) - repo = g.get_repo(repo_name) - global gh - gh = GitHubRepo(repo) + version = next_version(package) + # write_changelog() keys off the "## Release v…" header, so include it. + content = f"## Release v{version}\n" + (f"\n{body}\n" if body else "") + return tagging.TagInfo(package=package, version=version, content=content) -def process(): +def clear_nextchanges(package_path: str) -> None: """ - Main entry point for tagging process. - - Tagging process consist of multiple steps: - * For each package, update the corresponding CHANGELOG.md file based on the contents of NEXT_CHANGELOG.md file - * If any package has been updated, commit and push the changes. - * Apply and push the new tags matching the version. - - If a specific pagkage is provided as a parameter, only that package will be tagged. - - If any tag are pending from an early process, it will skip updating the CHANGELOG.md files and only apply the tags. + Replacement for ``tagging.clean_next_changelog``: stage deletion of the + ``.nextchanges/`` fragments consumed by this release and bump + ``.nextchanges/version`` to the next minor (its post-release default; teams + can still override it in a PR). Section directories and their README.md are + left in place. ``process_package`` calls this as + ``clean_next_changelog(package.path)``, so the signature matches. """ + base = os.path.join(os.getcwd(), package_path, NEXTCHANGES_DIR) + for slug, _ in NEXTCHANGES_SECTIONS: + section_dir = os.path.join(base, slug) + if not os.path.isdir(section_dir): + continue + for name in sorted(os.listdir(section_dir)): + if name.endswith(".md") and name != "README.md": + tagging.gh.delete_file(os.path.join(section_dir, name)) - package_names = get_packages_from_args() - pending_tags = find_pending_tags() - - # pending_tags is non-empty only when the tagging process previously failed or interrupted. - # We must complete the interrupted tagging process before starting a new one to avoid inconsistent states and missing changelog entries. - # Therefore, we don't support specifying packages until the previously started process has been successfully completed. - if pending_tags and package_names: - pending_packages = [tag.package.name for tag in pending_tags] - raise Exception(f"Cannot release packages {package_names}. Pending release for {pending_packages}") - - if pending_tags: - print("Found pending tags from previous executions, entering recovery mode.") - pull_last_release_commit() - push_tags(pending_tags) - return - - all_packages = find_packages() - # If packages are specified as an argument, only release those — but - # dep rewrites and the freshness check still operate over the full - # set. - selected_packages = all_packages - if package_names: - selected_packages = [package for package in all_packages if package.name in package_names] + version_path = _version_path(package_path) + with open(version_path) as f: + released = tagging.Version.parse(f.read().strip().lstrip("v")) + tagging.gh.add_file(version_path, f"{released.next_release_version()}\n") - # Run the freshness check against a read-only preview before the - # retry loop, since the check is deterministic. A freshness - # violation fails the run immediately, with no commits, no tags, no - # retry storm. - check_dependency_freshness(preview_tag_infos(selected_packages), all_packages) - pending_tags = retry_function( - func=lambda: update_changelogs(selected_packages, all_packages), - cleanup=reset_repository, - ) - push_tags(pending_tags) +def _delete_file(self, loc: str): + """``git rm`` equivalent for GitHubRepo: stage a tree deletion (sha=None).""" + local_path = os.path.relpath(loc, os.getcwd()) + print(f"Deleting file {local_path}") + self.changed_files.append(tagging.InputGitTreeElement(path=local_path, mode="100644", type="blob", sha=None)) -def validate_git_root(): - """ - Validate that the script is run from the root of the repository. - """ - repo_root = subprocess.check_output(["git", "rev-parse", "--show-toplevel"]).strip().decode("utf-8") - current_dir = subprocess.check_output(["pwd"]).strip().decode("utf-8") - if repo_root != current_dir: - raise Exception("Please run this script from the root of the repository.") +def install_nextchanges() -> None: + """Rebind the tagging seams to the CLI's .nextchanges behavior.""" + tagging.GitHubRepo.delete_file = _delete_file + tagging.get_next_tag_info = get_next_tag_info + tagging.clean_next_changelog = clear_nextchanges if __name__ == "__main__": - validate_git_root() - init_github() - process() + install_nextchanges() + tagging.validate_git_root() + tagging.init_github() + tagging.process() diff --git a/internal/genkit/tagging.py.lock b/internal/genkit/tagging.py.lock index 2bd746a66c2..b1530109674 100644 --- a/internal/genkit/tagging.py.lock +++ b/internal/genkit/tagging.py.lock @@ -12,7 +12,7 @@ requirements = [ [[package]] name = "certifi" version = "2026.2.25" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, @@ -21,7 +21,7 @@ wheels = [ [[package]] name = "cffi" version = "2.0.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] @@ -78,7 +78,7 @@ wheels = [ [[package]] name = "charset-normalizer" version = "3.4.5" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, @@ -135,7 +135,7 @@ wheels = [ [[package]] name = "cryptography" version = "46.0.5" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] @@ -188,7 +188,7 @@ wheels = [ [[package]] name = "idna" version = "3.11" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, @@ -197,7 +197,7 @@ wheels = [ [[package]] name = "pycparser" version = "3.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, @@ -206,7 +206,7 @@ wheels = [ [[package]] name = "pygithub" version = "2.8.1" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyjwt", extra = ["crypto"] }, { name = "pynacl" }, @@ -222,7 +222,7 @@ wheels = [ [[package]] name = "pyjwt" version = "2.11.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, @@ -236,7 +236,7 @@ crypto = [ [[package]] name = "pynacl" version = "1.6.2" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, ] @@ -271,7 +271,7 @@ wheels = [ [[package]] name = "requests" version = "2.32.5" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "charset-normalizer" }, @@ -286,7 +286,7 @@ wheels = [ [[package]] name = "typing-extensions" version = "4.15.0" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, @@ -295,7 +295,7 @@ wheels = [ [[package]] name = "urllib3" version = "2.6.3" -source = { registry = "https://pypi-proxy.cloud.databricks.com/simple/" } +source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, diff --git a/internal/genkit/tagging_upstream.py b/internal/genkit/tagging_upstream.py new file mode 100755 index 00000000000..a3897bbeedf --- /dev/null +++ b/internal/genkit/tagging_upstream.py @@ -0,0 +1,1057 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = ["PyGithub>=2,<3", "pyjwt<2.12.0", "charset-normalizer<3.4.6"] +# /// + +import os +import re +import argparse +from typing import Optional, List, Callable, Dict +from dataclasses import dataclass, replace +import subprocess +import time +import json +from github import Github, Repository, InputGitTreeElement, InputGitAuthor +from datetime import datetime, timezone + +NEXT_CHANGELOG_FILE_NAME = "NEXT_CHANGELOG.md" +CHANGELOG_FILE_NAME = "CHANGELOG.md" +PACKAGE_FILE_NAME = ".package.json" +CODEGEN_FILE_NAME = ".codegen.json" +CREATED_TAGS_FILE_NAME = "created_tags.json" +""" +This script tags the release of the SDKs using a combination of the GitHub API and Git commands. +It reads the local repository to determine necessary changes, updates changelogs, and creates tags. + +### How it Works: +- It does **not** modify the local repository directly. +- Instead of committing and pushing changes locally, it uses the **GitHub API** to create commits and tags. +""" + + +@dataclass(frozen=True) +class Version: + """ + A semver 2.0.0-compliant version (https://semver.org). + + Mirrors the API of the `semver` PyPI package so this implementation can be + swapped for that library if it is ever added to the wheelhouse. Supports + parsing, stringification, and the two bumps we need: minor (for stable + releases) and prerelease (for release trains). + """ + + # Permissive pattern for locating a semver version string inside larger + # text (e.g. a changelog header). Callers use it in f-strings; strict + # validation happens via Version.parse. + PATTERN = r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?" + + # Strict anchored regex per https://semver.org. Rejects leading zeros in + # numeric identifiers and invalid pre-release/build identifier charsets. + _PARSE_REGEX = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" + r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" + r"(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" + ) + + major: int + minor: int + patch: int + prerelease: str = "" + build: str = "" + + @classmethod + def parse(cls, text: str) -> "Version": + """Parse a semver string, raising ValueError on malformed input.""" + match = cls._PARSE_REGEX.match(text) + if not match: + raise ValueError(f"Invalid semver version: {text!r}") + major, minor, patch, prerelease, build = match.groups() + return cls( + major=int(major), + minor=int(minor), + patch=int(patch), + prerelease=prerelease or "", + build=build or "", + ) + + def __str__(self) -> str: + result = f"{self.major}.{self.minor}.{self.patch}" + if self.prerelease: + result += f"-{self.prerelease}" + if self.build: + result += f"+{self.build}" + return result + + def bump_minor(self) -> "Version": + """ + Bump the minor version and reset patch. + + Per semver item 9, a pre-release version has lower precedence than + the same MAJOR.MINOR.PATCH, so bumping to a new minor drops any + pre-release and build metadata. + """ + return Version(major=self.major, minor=self.minor + 1, patch=0) + + def bump_prerelease(self) -> "Version": + """ + Increment the rightmost numeric identifier in the pre-release. + + Matches the npm `prerelease` bump semantics: + 0.0.0-alpha.1 -> 0.0.0-alpha.2 + 0.0.0-alpha -> 0.0.0-alpha.1 + 0.0.0-rc.1.2 -> 0.0.0-rc.1.3 + + Raises ValueError if the version has no pre-release to bump. + Build metadata is dropped since it does not affect precedence. + """ + if not self.prerelease: + raise ValueError(f"Cannot bump prerelease of {self}: no pre-release component") + parts = self.prerelease.split(".") + for i in range(len(parts) - 1, -1, -1): + if parts[i].isdigit(): + parts[i] = str(int(parts[i]) + 1) + return replace(self, prerelease=".".join(parts), build="") + # No numeric identifier exists; append ".1" to start a counter. + return replace(self, prerelease=f"{self.prerelease}.1", build="") + + def next_release_version(self) -> "Version": + """ + Default next version for the changelog after this one is released. + + If on a pre-release track, stay on it by bumping the pre-release + identifier (npm convention). Otherwise, bump the minor version, + the script's historical default for stable releases. Teams can + override the default in the release PR. + """ + if self.prerelease: + return self.bump_prerelease() + return self.bump_minor() + + +def _read_local_head_sha() -> str: + """ + Returns the SHA of the local working tree's HEAD via ``git rev-parse``. + """ + return subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() + + +class MainAdvancedError(Exception): + """ + Raised when ``origin/main`` has advanced since the workflow's + checkout — i.e., another commit landed during this run. The local + working tree is now stale, so any commit produced from it would + silently revert whatever the concurrent push added. + """ + + +# GitHub does not support signing commits for GitHub Apps directly. +# This class replaces usages for git commands such as "git add", "git commit", and "git push". +@dataclass +class GitHubRepo: + def __init__(self, repo: Repository): + self.repo = repo + self.changed_files: list[InputGitTreeElement] = [] + self.ref = "heads/main" + # Anchor ``self.sha`` to the **local checkout** rather than a + # live API call. ``actions/checkout`` populates the working tree + # at this SHA, and every subsequent file read in this run is + # against that tree; the API HEAD is only relevant when we go + # to push. + self.sha = _read_local_head_sha() + + # Replaces "git add file" + def add_file(self, loc: str, content: str): + local_path = os.path.relpath(loc, os.getcwd()) + print(f"Adding file {local_path}") + blob = self.repo.create_git_blob(content=content, encoding="utf-8") + element = InputGitTreeElement(path=local_path, mode="100644", type="blob", sha=blob.sha) + self.changed_files.append(element) + + # Replaces "git commit && git push" + def commit_and_push(self, message: str): + head_ref = self.repo.get_git_ref(self.ref) + if head_ref.object.sha != self.sha: + raise MainAdvancedError( + f"origin/main advanced from {self.sha} to {head_ref.object.sha} " + f"during this run. Local working tree is stale; aborting before " + f"the commit would silently revert the new content. Re-run the " + f"workflow." + ) + base_tree = self.repo.get_git_tree(sha=head_ref.object.sha) + new_tree = self.repo.create_git_tree(self.changed_files, base_tree) + parent_commit = self.repo.get_git_commit(head_ref.object.sha) + + new_commit = self.repo.create_git_commit(message=message, tree=new_tree, parents=[parent_commit]) + # Update branch reference. + head_ref.edit(new_commit.sha) + self.sha = new_commit.sha + + def reset(self, sha: Optional[str] = None): + self.changed_files = [] + if sha: + self.sha = sha + else: + self.sha = _read_local_head_sha() + + def tag(self, tag_name: str, tag_message: str): + # Create a tag pointing to the new commit + # The email MUST be the GitHub Apps email. + # Otherwise, the tag will not be verified. + tagger = InputGitAuthor( + name="Databricks SDK Release Bot", email="DECO-SDK-Tagging[bot]@users.noreply.github.com" + ) + + tag = self.repo.create_git_tag(tag=tag_name, message=tag_message, object=self.sha, type="commit", tagger=tagger) + # Create a Git ref (the actual reference for the tag in the repo) + self.repo.create_git_ref(ref=f"refs/tags/{tag_name}", sha=tag.sha) + + +gh: Optional[GitHubRepo] = None + + +@dataclass +class Package: + """ + Represents a package in the repository. + :name: The package name. + :path: The path to the package relative to the repository root. + """ + + name: str + path: str + + +@dataclass +class TagInfo: + """ + Represents all changes on a release. + :package: package info. + :version: release version for the package. Format: v.. + :content: changes for the release, as they appear in the changelog. + When written to CHANGELOG.md, the current date (YYYY-MM-DD) is automatically added. + + Example (from NEXT_CHANGELOG.md): + + ## Release v0.56.0 + + ### New Features and Improvements + * Feature + * Some improvement + + ### Bug Fixes + * Bug fix + + ### Documentation + * Doc Changes + + ### Internal Changes + * More Changes + + ### API Changes + * Add new Service + + Note: When written to CHANGELOG.md, the header becomes: ## Release v0.56.0 (YYYY-MM-DD) + + """ + + package: Package + version: str + content: str + + def tag_name(self) -> str: + return f"{self.package.name}/v{self.version}" if self.package.name else f"v{self.version}" + + +def get_package_name(package_path: str) -> str: + """ + Returns the package name from the package path. + The name is found inside the .package.json file: + { + "package": "package_name" + } + """ + filepath = os.path.join(os.getcwd(), package_path, PACKAGE_FILE_NAME) + with open(filepath, "r") as file: + content = json.load(file) + if "package" in content: + return content["package"] + # Legacy SDKs have no packages. + return "" + + +def stage_version_updates(tag_infos: List[TagInfo], packages: List[Package]) -> None: + """ + Stages all version-related edits for the release in a single pass over + every package the workspace already opts in via ``.package.json``. + """ + + # Load patterns from '.codegen.json' at the top level of the repository. + package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) + with open(package_file_path, "r") as file: + codegen = json.load(file) + + version_patterns = codegen.get("version", {}) + dep_patterns = codegen.get("dependency_pattern", {}) + name_template = codegen.get("dependency_name_template", "") + + if not version_patterns and not dep_patterns: + print("Neither `version` nor `dependency_pattern` found in .codegen.json. Nothing to update.") + return + + bumped_by_dir: Dict[str, TagInfo] = {info.package.path: info for info in tag_infos} + new_dep_versions = compute_dependency_rewrites(tag_infos, name_template) + + files = sorted(set(version_patterns.keys()) | set(dep_patterns.keys())) + + for pkg in packages: + for filename in files: + loc = os.path.join(os.getcwd(), pkg.path, filename) + + with open(loc, "r") as file: + content = file.read() + original = content + + # Own version (only when this package is being released and the + # file has a version pattern declared). + info = bumped_by_dir.get(pkg.path) + if info is not None and filename in version_patterns: + pattern = version_patterns[filename] + previous_version = pattern.replace("$VERSION", Version.PATTERN) + new_version = pattern.replace("$VERSION", info.version) + content = re.sub(previous_version, new_version, content) + + # Sibling dependency rewrites (only when the file has a + # dependency pattern and there is at least one bumped sibling). + if filename in dep_patterns and new_dep_versions: + content = rewrite_dependencies(content, dep_patterns[filename], new_dep_versions) + + if content != original: + gh.add_file(loc, content) + + +def compute_dependency_rewrites( + tag_infos: List[TagInfo], + name_template: str, +) -> Dict[str, str]: + """ + Returns a map of dependency-name to the new semver string for each + bumped package. + """ + if not name_template: + return {} + rewrites: Dict[str, str] = {} + for info in tag_infos: + # Skip legacy releases that don't have a per-package name; their + # tag_info has an empty package.name and they can't be referenced + # as a sibling dep anyway. + if not info.package.name: + continue + dep_name = name_template.replace("$PACKAGE", info.package.name) + rewrites[dep_name] = info.version + return rewrites + + +def rewrite_dependencies(content: str, pattern: str, new_versions: Dict[str, str]) -> str: + """ + Apply ``pattern`` (with ``$DEPENDENCY`` and ``$VERSION`` placeholders) to + rewrite every entry in ``content`` whose dependency name appears in + ``new_versions``. + """ + # Sentinel strings used to protect the placeholders through re.escape: + # we substitute them in, escape the whole template, then swap them out + # for the dep-name literal and Version.PATTERN. Control characters so + # they can't collide with anything in real .codegen.json patterns. + dep_sentinel = "\x01DEPENDENCY\x01" + ver_sentinel = "\x01VERSION\x01" + + for dep_name, new_value in new_versions.items(): + regex = pattern.replace("$DEPENDENCY", dep_sentinel).replace("$VERSION", ver_sentinel) + regex = re.escape(regex) + regex = regex.replace(re.escape(dep_sentinel), re.escape(dep_name)) + regex = regex.replace(re.escape(ver_sentinel), Version.PATTERN) + + # Build the literal replacement text by substituting the same + # placeholders directly. A lambda is used instead of a string to + # avoid re.sub interpreting \1, \g<...>, etc. inside the value. + replacement_text = pattern.replace("$DEPENDENCY", dep_name).replace("$VERSION", new_value) + content = re.sub(regex, lambda _m, text=replacement_text: text, content) + return content + + +def clean_next_changelog(package_path: str) -> None: + """ + Cleans the "NEXT_CHANGELOG.md" file. It performs 2 operations: + * Increase the version to the next minor version. + * Remove release notes. Sections names are kept to + keep consistency in the section names between releases. + """ + + file_path = os.path.join(os.getcwd(), package_path, NEXT_CHANGELOG_FILE_NAME) + with open(file_path, "r") as file: + content = file.read() + + # Remove content between ### sections. + cleaned_content = re.sub(r"(### [^\n]+\n)(?:.*?\n?)*?(?=###|$)", r"\1", content) + # Ensure there is exactly one empty line before each section. + cleaned_content = re.sub(r"(\n*)(###[^\n]+)", r"\n\n\2", cleaned_content) + # Find the version number and compute the default next release version. + # Teams can adjust the version in the PR if the default is not desired. + # For stable versions, bump minor (historical default since minor releases + # are more common than patch or major). For pre-release versions, stay on + # the same track by bumping the pre-release identifier (npm convention). + version_match = re.search(rf"Release v({Version.PATTERN})", cleaned_content) + if not version_match: + raise Exception("Version not found in the changelog") + current = Version.parse(version_match.group(1)) + new_header = f"Release v{current.next_release_version()}" + cleaned_content = cleaned_content.replace(version_match.group(0), new_header) + + # Update file with cleaned content + gh.add_file(file_path, cleaned_content) + + +def get_previous_tag_info(package: Package) -> Optional[TagInfo]: + """ + Extracts the previous tag info from the "CHANGELOG.md" file. + Used for failure recovery purposes. + """ + changelog_path = os.path.join(os.getcwd(), package.path, CHANGELOG_FILE_NAME) + + with open(changelog_path, "r") as f: + changelog = f.read() + + # Extract the latest release section using regex. + match = re.search( + rf"## (\[Release\] )?Release v{Version.PATTERN}.*?(?=\n## (\[Release\] )?Release v|\Z)", + changelog, + re.S, + ) + + # E.g., for new packages. + if not match: + return None + + latest_release = match.group(0) + version_match = re.search(rf"## (\[Release\] )?Release v({Version.PATTERN})", latest_release) + + if not version_match: + raise Exception("Version not found in the changelog") + + # Validate the extracted string is spec-compliant; fail loudly otherwise. + version = str(Version.parse(version_match.group(2))) + return TagInfo(package=package, version=version, content=latest_release) + + +def _load_codegen_config() -> Dict: + """ + Loads ``.codegen.json`` from the repo root. Returns an empty dict when + the file is missing. + """ + package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) + if not os.path.exists(package_file_path): + return {} + with open(package_file_path, "r") as file: + return json.load(file) + + +def get_next_tag_info(package: Package) -> Optional[TagInfo]: + """ + Extracts the changes from the "NEXT_CHANGELOG.md" file. + The result is already processed. + """ + next_changelog_path = os.path.join(os.getcwd(), package.path, NEXT_CHANGELOG_FILE_NAME) + # Read NEXT_CHANGELOG.md + with open(next_changelog_path, "r") as f: + next_changelog = f.read() + + # Remove "# NEXT CHANGELOG" line + next_changelog = re.sub(r"^# NEXT CHANGELOG(\n+)", "", next_changelog, flags=re.MULTILINE) + + # Remove empty sections + next_changelog = re.sub(r"###[^\n]+\n+(?=##|\Z)", "", next_changelog) + # Ensure there is exactly one empty line before each section + next_changelog = re.sub(r"(\n*)(###[^\n]+)", r"\n\n\2", next_changelog) + + # By default, packages whose NEXT_CHANGELOG.md has no populated + # sections are skipped — there's nothing meaningful to release. + # Repos like sdk-js which are still in development can opt in + # by setting ``allow_empty_changelog: true`` in .codegen.json. + if not re.search(r"###", next_changelog) and not _load_codegen_config().get("allow_empty_changelog", False): + print("All sections are empty. No changes will be made to the changelog.") + return None + + version_match = re.search(rf"## Release v({Version.PATTERN})", next_changelog) + + if not version_match: + raise Exception("Version not found in the changelog") + + # Validate the extracted string is spec-compliant; fail loudly otherwise. + version = str(Version.parse(version_match.group(1))) + return TagInfo(package=package, version=version, content=next_changelog) + + +def write_changelog(tag_info: TagInfo) -> None: + """ + Updates the changelog with a new tag info. + """ + changelog_path = os.path.join(os.getcwd(), tag_info.package.path, CHANGELOG_FILE_NAME) + with open(changelog_path, "r") as f: + changelog = f.read() + + # Add current date to the release header. + current_date = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + content_with_date = re.sub( + rf"## Release v({Version.PATTERN})", + rf"## Release v\1 ({current_date})", + tag_info.content.strip(), + ) + + updated_changelog = re.sub(r"(# Version changelog\n\n)", f"\\1{content_with_date}\n\n\n", changelog) + gh.add_file(changelog_path, updated_changelog) + + +def process_package(package: Package) -> TagInfo: + """ + Processes a package's changelog scaffolding for the release. + """ + print(f"Processing package {package.name}") + tag_info = get_next_tag_info(package) + + # If there are no updates, skip. + if tag_info is None: + return + + write_changelog(tag_info) + clean_next_changelog(package.path) + return tag_info + + +def find_packages() -> List[Package]: + """ + Returns all directories which contains a ".package.json" file. + """ + paths = _find_directories_with_file(PACKAGE_FILE_NAME) + return [Package(name=get_package_name(path), path=path) for path in paths] + + +def _find_directories_with_file(target_file: str) -> List[str]: + root_path = os.getcwd() + matching_directories = [] + + for dirpath, _, filenames in os.walk(root_path): + if target_file in filenames: + path = os.path.relpath(dirpath, root_path) + # If the path is the root directory (e.g., SDK V0), set it to an empty string. + if path == ".": + path = "" + matching_directories.append(path) + + return matching_directories + + +def is_tag_applied(tag: TagInfo) -> bool: + """ + Returns whether a tag is already applied in the repository. + + :param tag: The tag to check. + :return: True if the tag is applied, False otherwise. + :raises Exception: If the git command fails. + """ + try: + # Check if the specific tag exists + result = subprocess.check_output(["git", "tag", "--list", tag.tag_name()], stderr=subprocess.PIPE, text=True) + return result.strip() == tag.tag_name() + except subprocess.CalledProcessError as e: + # Raise a exception for git command errors + raise Exception(f"Git command failed: {e.stderr.strip() or e}") from e + + +def find_last_release_tag(package: Package) -> Optional[str]: + """ + Returns the most recent ``/v*`` tag in the repository, or + ``None`` if no such tag exists. Tags are sorted by semver ordering + (``--sort=-v:refname``) so pre-releases sort below their stable + counterparts. + + :raises Exception: If the git command fails. + """ + pattern = f"{package.name}/v*" if package.name else "v*" + try: + output = subprocess.check_output( + ["git", "tag", "--list", pattern, "--sort=-v:refname"], + stderr=subprocess.PIPE, + text=True, + ).strip() + except subprocess.CalledProcessError as e: + raise Exception(f"Git command failed: {e.stderr.strip() or e}") from e + if not output: + return None + return output.split("\n")[0].strip() + + +def has_commits_since_tag(tag: str, path: str) -> bool: + """ + Returns True iff at least one commit reachable from HEAD but not from + ``tag`` touches ``path``. Used to detect that a sibling dependency has + unreleased changes that would ship stale if we tagged a dependent + without re-tagging the dependency. + + :raises Exception: If the git command fails. + """ + args = ["git", "log", "--oneline", f"{tag}..HEAD", "--", path or "."] + try: + output = subprocess.check_output(args, stderr=subprocess.PIPE, text=True).strip() + except subprocess.CalledProcessError as e: + raise Exception(f"Git command failed: {e.stderr.strip() or e}") from e + return bool(output) + + +def check_dependency_freshness(tag_infos: List[TagInfo], all_packages: List[Package]) -> None: + """ + Hard-fails when a package being released depends on a sibling package + that has unreleased commits since its last tag. + + Why: dependency rewrites (``stage_version_updates``) only fire for + siblings that are *also* being released. Without this check, releasing + package_a alone — when package_b has commits since its last tag — + publishes ``package_a@new`` pinning the *old* package_b artifact, which + won't have the changes package_a's source depends on. The check is + commit-based (not changelog-based) so a missing ``NEXT_CHANGELOG.md`` + entry on package_b is still caught. + + No-op when ``.codegen.json`` declares no dependency pattern (legacy + SDKs without per-package wiring). + """ + if not tag_infos: + return + + package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) + with open(package_file_path, "r") as file: + codegen = json.load(file) + + name_template = codegen.get("dependency_name_template", "") + dep_patterns = codegen.get("dependency_pattern", {}) + if not name_template or not dep_patterns: + return + + releasing_paths = {info.package.path for info in tag_infos} + by_dep_name: Dict[str, Package] = {} + for pkg in all_packages: + if not pkg.name: + continue + by_dep_name[name_template.replace("$PACKAGE", pkg.name)] = pkg + + issues: List[str] = [] + for info in tag_infos: + for filename, pattern in dep_patterns.items(): + loc = os.path.join(os.getcwd(), info.package.path, filename) + if not os.path.exists(loc): + continue + with open(loc, "r") as f: + content = f.read() + + for dep_name, dep_pkg in by_dep_name.items(): + if dep_pkg.path == info.package.path: + continue + if dep_pkg.path in releasing_paths: + continue + + # Same regex construction used by ``rewrite_dependencies``, + # so "is this dep referenced?" matches "would the rewrite + # touch it?". Keeps the two in lockstep. + regex = ( + re.escape(pattern) + .replace(re.escape("$DEPENDENCY"), re.escape(dep_name)) + .replace(re.escape("$VERSION"), Version.PATTERN) + ) + if not re.search(regex, content): + continue + + last_tag = find_last_release_tag(dep_pkg) + if last_tag is None: + # No prior tag means the dep was never released; we + # can't reason about staleness. Surface it anyway so + # the human resolves it explicitly. + issues.append( + f"{info.package.name} depends on {dep_pkg.name}, " + f"which has never been released. Release " + f"{dep_pkg.name} first or include it in this run." + ) + continue + if has_commits_since_tag(last_tag, dep_pkg.path): + issues.append( + f"{info.package.name} depends on {dep_pkg.name}, " + f"which has commits since {last_tag} but is not " + f"being released. Either release {dep_pkg.name} " + f"as well, or hold this release until its changes " + f"are reverted." + ) + + if issues: + raise Exception("Dependency freshness check failed:\n - " + "\n - ".join(issues)) + + +def find_last_tags() -> List[TagInfo]: + """ + Finds the last tags for each package. + + Returns a list of TagInfo objects for each package with a non-None changelog. + """ + packages = find_packages() + + return [info for info in (get_previous_tag_info(package) for package in packages) if info is not None] + + +def find_pending_tags() -> List[TagInfo]: + """ + Finds all tags that are pending to be applied. + """ + tag_infos = find_last_tags() + return [tag for tag in tag_infos if not is_tag_applied(tag)] + + +def generate_commit_message(tag_infos: List[TagInfo]) -> str: + """ + Generates a commit message for the release. + """ + if not tag_infos: + raise Exception("No tag infos provided to generate commit message") + + info = tag_infos[0] + # Legacy mode for SDKs without per service packaging + if not info.package.name: + if len(tag_infos) > 1: + raise Exception("Multiple packages found in legacy mode") + return f"[Release] Release v{info.version}\n\n{info.content}" + + # Sort tag_infos by package name for consistency. + tag_infos.sort(key=lambda info: info.package.name) + titles = ", ".join(f"{info.package.name}/v{info.version}" for info in tag_infos) + body = "\n\n".join(f"## {info.package.name}/v{info.version}\n\n{info.content}" for info in tag_infos) + return f"[Release] {titles}\n\n{body}" + + +def push_changes(tag_infos: List[TagInfo]) -> None: + """Pushes changes to the remote repository after handling possible merge conflicts.""" + + commit_message = generate_commit_message(tag_infos) + + # Create the release metadata file + file_name = os.path.join(os.getcwd(), ".release_metadata.json") + metadata = {"timestamp": datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S%z")} + content = json.dumps(metadata, indent=4) + gh.add_file(file_name, content) + + gh.commit_and_push(commit_message) + + +def reset_repository(hash: Optional[str] = None) -> None: + """ + Reset git to the specified commit. Defaults to HEAD. + + :param hash: The commit hash to reset to. If None, it resets to HEAD. + """ + # Fetch the latest changes from the remote repository. + subprocess.run(["git", "fetch"]) + + # Determine the commit hash (default to origin/main if none is provided). + commit_hash = hash or "origin/main" + + # ``git reset --hard`` must land before ``gh.reset(None)``, since + # ``gh.reset(None)`` reads ``git rev-parse HEAD`` to anchor + # ``self.sha`` to the local working tree. + subprocess.run(["git", "reset", "--hard", commit_hash], check=True) + gh.reset(hash) + + +def retry_function( + func: Callable[[], List[TagInfo]], cleanup: Callable[[], None], max_attempts: int = 5, delay: int = 5 +) -> List[TagInfo]: + """ + Calls a function call up to `max_attempts` times if an exception occurs. + + :param func: The function to call. + :param cleanup: Cleanup function in between retries + :param max_attempts: The maximum number of retries. + :param delay: The delay between retries in seconds. + :return: The return value of the function, or None if all retries fail. + """ + attempts = 0 + while attempts <= max_attempts: + try: + return func() # Call the function + except MainAdvancedError: + # Permanent failure: another commit landed on main during + # this run, so the local tree is stale. Retrying with the + # same stale tree would just hit the same mismatch — only + # a fresh workflow run against the new main can recover. + raise + except Exception as e: + attempts += 1 + print(f"Attempt {attempts} failed: {e}") + if attempts < max_attempts: + time.sleep(delay) # Wait before retrying + cleanup() + else: + print("All retry attempts failed.") + raise e # Re-raise the exception after max retries + + +def update_changelogs(selected_packages: List[Package], all_packages: List[Package]) -> List[TagInfo]: + """ + Updates changelogs and pushes the commits. + + ``selected_packages`` are the packages whose ``NEXT_CHANGELOG.md`` is + consulted to decide what gets released this run. ``all_packages`` is + the full repo inventory used for cross-package dep rewrites. + + The freshness check is deliberately *not* called here. ``process`` + runs it before entering the retry loop so a freshness violation + fails fast — the check is deterministic against the same git state, + so wrapping it in retry would just delay the same failure five + times. + """ + tag_infos = [info for info in (process_package(package) for package in selected_packages) if info is not None] + # If any package was changed, stage version updates and push. + if tag_infos: + stage_version_updates(tag_infos, all_packages) + push_changes(tag_infos) + return tag_infos + + +def preview_tag_infos(packages: List[Package]) -> List[TagInfo]: + """ + Read-only sibling of ``process_package``: returns the TagInfos that + would be released for ``packages`` without writing any changelog + edits. ``process`` calls this before the retry loop so the freshness + check has a snapshot to validate against. ``process_package`` will + re-derive the same TagInfos when ``update_changelogs`` runs; the + duplication is just a couple of NEXT_CHANGELOG.md reads. + """ + return [info for info in (get_next_tag_info(package) for package in packages) if info is not None] + + +def order_tag_infos_by_dependency(tag_infos: List[TagInfo]) -> List[TagInfo]: + """ + Returns ``tag_infos`` in topological order: every package appears + after every sibling it depends on. + """ + if not tag_infos: + return list(tag_infos) + + if any(not info.package.name for info in tag_infos) and len(tag_infos) > 1: + raise Exception("Multiple packages found in legacy mode") + + package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) + with open(package_file_path, "r") as file: + codegen = json.load(file) + + name_template = codegen.get("dependency_name_template", "") + dep_patterns = codegen.get("dependency_pattern", {}) + if not name_template or not dep_patterns: + return list(tag_infos) + + by_dep_name: Dict[str, TagInfo] = { + name_template.replace("$PACKAGE", info.package.name): info for info in tag_infos if info.package.name + } + + # Adjacency: path -> set of paths it depends on (within tag_infos). + deps: Dict[str, set] = {info.package.path: set() for info in tag_infos} + for info in tag_infos: + for filename, pattern in dep_patterns.items(): + loc = os.path.join(os.getcwd(), info.package.path, filename) + if not os.path.exists(loc): + continue + with open(loc, "r") as f: + content = f.read() + for dep_name, dep_info in by_dep_name.items(): + if dep_info.package.path == info.package.path: + continue + regex = ( + re.escape(pattern) + .replace(re.escape("$DEPENDENCY"), re.escape(dep_name)) + .replace(re.escape("$VERSION"), Version.PATTERN) + ) + if re.search(regex, content): + deps[info.package.path].add(dep_info.package.path) + + # Stable topological sort: at each step, emit every node whose deps + # are already emitted, alphabetically by package name. Ties broken + # alphabetically so the manifest is reproducible across runs. + emitted: set = set() + ordered: List[TagInfo] = [] + while len(ordered) < len(tag_infos): + ready = sorted( + ( + info + for info in tag_infos + if info.package.path not in emitted and deps[info.package.path].issubset(emitted) + ), + key=lambda info: info.package.name, + ) + if not ready: + remaining = [info.package.name for info in tag_infos if info.package.path not in emitted] + raise Exception(f"Cyclic dependency detected among packages: {remaining}") + for info in ready: + ordered.append(info) + emitted.add(info.package.path) + return ordered + + +def push_tags(tag_infos: List[TagInfo]) -> None: + """ + Creates and pushes tags to the repository. + + Tags are emitted in topological order — dependencies before + dependents — so downstream publishing pipelines reading + ``created_tags.json`` can walk it sequentially without re-deriving + the dependency graph. See ``order_tag_infos_by_dependency``. + + As a side effect, writes the names of successfully created tags to + ``./created_tags.json`` so that workflows triggering this script can + discover what was produced (the GitHub Actions workflow uploads this + file as the ``created-tags`` artifact). + + Schema: + {"tags": ["service-a/v1.2.3", "service-b/v0.4.0"]} + + The manifest is written even if tag creation fails partway through: + tags that succeeded before the failure are flushed before the + exception is re-raised, so recovery-mode runs still surface their + output. + """ + tag_infos = order_tag_infos_by_dependency(tag_infos) + created: List[str] = [] + try: + for tag_info in tag_infos: + gh.tag(tag_info.tag_name(), tag_info.content) + created.append(tag_info.tag_name()) + finally: + manifest_path = os.path.join(os.getcwd(), CREATED_TAGS_FILE_NAME) + with open(manifest_path, "w") as f: + json.dump({"tags": created}, f) + + +def run_command(command: List[str]) -> str: + """ + Runs a command and returns the output + """ + output = subprocess.check_output(command) + print(f'Running command: {" ".join(command)}') + return output.decode() + + +def pull_last_release_commit() -> None: + """ + Reset the repository to the last release. + Uses commit for last change to .release_metadata.json, since it's only updated on releases. + """ + commit_hash = subprocess.check_output( + ["git", "log", "-n", "1", "--format=%H", "--", ".release_metadata.json"], text=True + ).strip() + + # If no commit is found, raise an exception + if not commit_hash: + raise ValueError("No commit found for .release_metadata.json") + + # Reset the repository to the commit + reset_repository(commit_hash) + + +def get_packages_from_args() -> List[str]: + """ + Retrieves the list of packages to tag. + + python3 ./tagging.py --package # single package + python3 ./tagging.py --package , # multiple packages + + Returns an empty list when --package is omitted, which means all packages + with pending releases will be tagged. + """ + parser = argparse.ArgumentParser(description="Update changelogs and tag the release.") + parser.add_argument( + "--package", + "-p", + type=str, + default="", + help="Comma-separated list of packages to tag. Leave empty to tag all packages with pending releases.", + ) + args = parser.parse_args() + return [name.strip() for name in args.package.split(",") if name.strip()] + + +def init_github(): + token = os.environ["GITHUB_TOKEN"] + repo_name = os.environ["GITHUB_REPOSITORY"] + g = Github(token) + repo = g.get_repo(repo_name) + global gh + gh = GitHubRepo(repo) + + +def process(): + """ + Main entry point for tagging process. + + Tagging process consist of multiple steps: + * For each package, update the corresponding CHANGELOG.md file based on the contents of NEXT_CHANGELOG.md file + * If any package has been updated, commit and push the changes. + * Apply and push the new tags matching the version. + + If a specific pagkage is provided as a parameter, only that package will be tagged. + + If any tag are pending from an early process, it will skip updating the CHANGELOG.md files and only apply the tags. + """ + + package_names = get_packages_from_args() + pending_tags = find_pending_tags() + + # pending_tags is non-empty only when the tagging process previously failed or interrupted. + # We must complete the interrupted tagging process before starting a new one to avoid inconsistent states and missing changelog entries. + # Therefore, we don't support specifying packages until the previously started process has been successfully completed. + if pending_tags and package_names: + pending_packages = [tag.package.name for tag in pending_tags] + raise Exception(f"Cannot release packages {package_names}. Pending release for {pending_packages}") + + if pending_tags: + print("Found pending tags from previous executions, entering recovery mode.") + pull_last_release_commit() + push_tags(pending_tags) + return + + all_packages = find_packages() + # If packages are specified as an argument, only release those — but + # dep rewrites and the freshness check still operate over the full + # set. + selected_packages = all_packages + if package_names: + selected_packages = [package for package in all_packages if package.name in package_names] + + # Run the freshness check against a read-only preview before the + # retry loop, since the check is deterministic. A freshness + # violation fails the run immediately, with no commits, no tags, no + # retry storm. + check_dependency_freshness(preview_tag_infos(selected_packages), all_packages) + + pending_tags = retry_function( + func=lambda: update_changelogs(selected_packages, all_packages), + cleanup=reset_repository, + ) + push_tags(pending_tags) + + +def validate_git_root(): + """ + Validate that the script is run from the root of the repository. + """ + repo_root = subprocess.check_output(["git", "rev-parse", "--show-toplevel"]).strip().decode("utf-8") + current_dir = subprocess.check_output(["pwd"]).strip().decode("utf-8") + if repo_root != current_dir: + raise Exception("Please run this script from the root of the repository.") + + +if __name__ == "__main__": + validate_git_root() + init_github() + process() diff --git a/internal/genkit/tagging_upstream.py.lock b/internal/genkit/tagging_upstream.py.lock new file mode 100644 index 00000000000..b1530109674 --- /dev/null +++ b/internal/genkit/tagging_upstream.py.lock @@ -0,0 +1,302 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[manifest] +requirements = [ + { name = "charset-normalizer", specifier = "<3.4.6" }, + { name = "pygithub", specifier = ">=2,<3" }, + { name = "pyjwt", specifier = "<2.12.0" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygithub" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "pynacl" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/74/e560bdeffea72ecb26cff27f0fad548bbff5ecc51d6a155311ea7f9e4c4c/pygithub-2.8.1.tar.gz", hash = "sha256:341b7c78521cb07324ff670afd1baa2bf5c286f8d9fd302c1798ba594a5400c9", size = 2246994, upload-time = "2025-09-02T17:41:54.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/ba/7049ce39f653f6140aac4beb53a5aaf08b4407b6a3019aae394c1c5244ff/pygithub-2.8.1-py3-none-any.whl", hash = "sha256:23a0a5bca93baef082e03411bf0ce27204c32be8bfa7abc92fe4a3e132936df0", size = 432709, upload-time = "2025-09-02T17:41:52.947Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] diff --git a/ruff.toml b/ruff.toml index b3f7f35d3d6..54119bb558e 100644 --- a/ruff.toml +++ b/ruff.toml @@ -2,5 +2,5 @@ line-length = 120 exclude = [ - "tagging.py" # tagging.py is synced from universe in the `openapi/tagging` directory and follows different format rules. + "tagging_upstream.py" # synced verbatim from universe (openapi/tagging) and follows different format rules; the hand-maintained internal/genkit/tagging.py wrapper is linted normally. ] From 38eef2166ae2580638c14780f673829f49e435b3 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 16:37:26 +0200 Subject: [PATCH 10/31] Remove the NEXT_CHANGELOG.md collate bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the tagging.py wrapper rendering .nextchanges/ straight into CHANGELOG.md, the release-time collate step is no longer needed. Removes the changelog-collate workflow and the collate mode of the tools script — which becomes tools/validate_nextchanges.py, a PR-time validator (run by `task changelog-check`) that checks fragment placement and the .nextchanges/version file. update_github_links.py no longer processes NEXT_CHANGELOG.md. NEXT_CHANGELOG.md itself is left in the tree for now (unused); a follow-up cutover PR deletes it. Co-authored-by: Isaac --- .github/workflows/changelog-collate.yml | 51 ------ tools/collate_changelog.py | 226 ------------------------ tools/update_github_links.py | 2 +- tools/validate_nextchanges.py | 78 ++++++++ 4 files changed, 79 insertions(+), 278 deletions(-) delete mode 100644 .github/workflows/changelog-collate.yml delete mode 100755 tools/collate_changelog.py create mode 100755 tools/validate_nextchanges.py diff --git a/.github/workflows/changelog-collate.yml b/.github/workflows/changelog-collate.yml deleted file mode 100644 index 25dd4479391..00000000000 --- a/.github/workflows/changelog-collate.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: changelog-collate - -# Release-prep step: fold all .nextchanges/ fragments into NEXT_CHANGELOG.md and -# open a single PR. Run this (and merge the PR) before dispatching the `tagging` -# workflow so the release picks up every entry. Between releases, fragments -# accumulate under .nextchanges/ without ever touching NEXT_CHANGELOG.md, so -# contributor PRs don't conflict. -on: - workflow_dispatch: - -# Ensure two dispatches don't race on the PR branch. -concurrency: - group: changelog-collate - -permissions: - contents: write - pull-requests: write - -jobs: - collate: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - - # Reuse the changelog-collate task so collate + link-expansion stay - # defined in one place (Taskfile.yml). - - name: Collate fragments and expand PR links - run: go tool -modfile=tools/task/go.mod task changelog-collate - - - name: Determine release version - id: version - run: echo "version=$(grep -m1 '## Release v' NEXT_CHANGELOG.md | sed 's/^## Release //')" >> "$GITHUB_OUTPUT" - - - name: Create pull request - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - with: - # A fixed branch means a re-run updates the existing open PR in place - # rather than opening a new one. - branch: auto/collate-changelog - commit-message: "Collate changelog fragments for ${{ steps.version.outputs.version }}" - title: "Collate changelog fragments for ${{ steps.version.outputs.version }}" - body: |- - Folds every `.nextchanges/` fragment into the matching section of `NEXT_CHANGELOG.md` and removes the fragment files. - - Merge this before dispatching the `tagging` workflow so the release picks up every entry. No fragments means no diff and no PR. diff --git a/tools/collate_changelog.py b/tools/collate_changelog.py deleted file mode 100755 index fc4fac62e9b..00000000000 --- a/tools/collate_changelog.py +++ /dev/null @@ -1,226 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.12" -# /// -"""Collate ``.nextchanges/`` fragments into ``NEXT_CHANGELOG.md``. - -Each PR adds its own file under ``.nextchanges/
/`` instead of editing -the shared ``NEXT_CHANGELOG.md``. Because two PRs never touch the same path, -they never produce a merge conflict. At release time this script folds every -fragment into the matching section of ``NEXT_CHANGELOG.md`` and deletes the -fragment files; the existing release tooling (``internal/genkit/tagging.py``) -then consumes ``NEXT_CHANGELOG.md`` unchanged. - -Usage: - collate_changelog.py # collate fragments into NEXT_CHANGELOG.md - collate_changelog.py --check # validate fragment placement only (no writes) -""" - -import argparse -import pathlib -import re -import sys - -CHANGELOG_DIR = ".nextchanges" -NEXT_CHANGELOG = "NEXT_CHANGELOG.md" - -# Section subdirectory -> ``### `` header text in NEXT_CHANGELOG.md, in the -# order sections appear in the file. The slug is the header lowercased with -# spaces replaced by hyphens; the mapping is explicit because "CLI" and -# "API Changes" don't round-trip through a simple title-case rule. -SECTIONS = ( - ("notable-changes", "Notable Changes"), - ("cli", "CLI"), - ("bundles", "Bundles"), - ("dependency-updates", "Dependency updates"), - ("api-changes", "API Changes"), -) - -SECTION_SLUGS = {slug for slug, _ in SECTIONS} - -# A level-2 or level-3 Markdown heading, i.e. a section boundary. -HEADING_RE = re.compile(r"#{2,3} ") - - -def normalize_entry(text): - """Return *text* as a Markdown bullet, adding the leading ``* `` if absent. - - The leading marker is optional in a fragment so authors can write just the - entry text. A ``-`` marker is normalized to ``*`` to match the changelog. - - >>> normalize_entry("Added a flag (#1).") - '* Added a flag (#1).' - >>> normalize_entry("* Already a bullet (#2).") - '* Already a bullet (#2).' - >>> normalize_entry("- Dash bullet (#3).") - '* Dash bullet (#3).' - - Only the first line is marked; continuation lines are left untouched so an - author can write a multi-line entry or several explicit bullets: - - >>> normalize_entry("First line.\\n continued") - '* First line.\\n continued' - """ - text = text.strip() - first, _, rest = text.partition("\n") - if first.startswith("* "): - pass - elif first.startswith("- "): - first = "* " + first[2:] - elif first in ("*", "-"): - first = "*" - else: - first = "* " + first - return first + ("\n" + rest if rest else "") - - -def insert_entries(changelog, header, entries): - r"""Insert *entries* under the ``### {header}`` section of *changelog*. - - Entries are appended after any existing content in the section, before the - blank line that precedes the next section. Existing lines are left byte for - byte intact so the diff is minimal. - - >>> cl = "## Release v1.0.0\n\n### CLI\n\n### Bundles\n" - >>> print(insert_entries(cl, "CLI", ["* Added a flag (#1)."]), end="") - ## Release v1.0.0 - - ### CLI - * Added a flag (#1). - - ### Bundles - - Appends after content already present in the section: - - >>> cl = "### CLI\n* Existing (#1).\n\n### Bundles\n" - >>> print(insert_entries(cl, "CLI", ["* New (#2)."]), end="") - ### CLI - * Existing (#1). - * New (#2). - - ### Bundles - - Works for the last section in the file: - - >>> print(insert_entries("### API Changes\n", "API Changes", ["* X (#1)."]), end="") - ### API Changes - * X (#1). - """ - lines = changelog.split("\n") - - header_line = f"### {header}" - try: - start = next(i for i, line in enumerate(lines) if line.strip() == header_line) - except StopIteration: - raise SystemExit(f"section '{header_line}' not found in {NEXT_CHANGELOG}") - - # End of the section: the next heading, or end of file. - end = len(lines) - for i in range(start + 1, len(lines)): - if HEADING_RE.match(lines[i].strip()): - end = i - break - - # Skip trailing blank lines so new entries attach directly to existing - # content (or to the header when the section is empty). - insert_at = end - while insert_at - 1 > start and lines[insert_at - 1].strip() == "": - insert_at -= 1 - - lines[insert_at:insert_at] = entries - return "\n".join(lines) - - -def iter_fragment_files(changelog_dir): - """Yield every ``*.md`` fragment under *changelog_dir*, excluding READMEs.""" - for path in sorted(changelog_dir.rglob("*.md")): - if path.name == "README.md": - continue - yield path - - -def find_misplaced(changelog_dir): - """Return fragment paths that are not ``.nextchanges/
/.md``.""" - misplaced = [] - for path in iter_fragment_files(changelog_dir): - rel = path.relative_to(changelog_dir) - if len(rel.parts) != 2 or rel.parts[0] not in SECTION_SLUGS: - misplaced.append(path) - return misplaced - - -def check(root): - """Validate fragment placement. Returns a process exit code.""" - changelog_dir = root / CHANGELOG_DIR - if not changelog_dir.is_dir(): - return 0 - - problems = [] - for path in find_misplaced(changelog_dir): - problems.append(f"{path}: not in a known section directory") - for path in iter_fragment_files(changelog_dir): - if not path.read_text(encoding="utf-8").strip(): - problems.append(f"{path}: empty fragment") - - if problems: - for msg in problems: - print(msg, file=sys.stderr) - valid = ", ".join(slug for slug, _ in SECTIONS) - print(f"\nFragments must live at {CHANGELOG_DIR}/
/.md", file=sys.stderr) - print(f"Valid sections: {valid}", file=sys.stderr) - return 1 - return 0 - - -def collate(root): - """Fold fragments into NEXT_CHANGELOG.md and delete them.""" - changelog_dir = root / CHANGELOG_DIR - next_changelog = root / NEXT_CHANGELOG - - misplaced = find_misplaced(changelog_dir) if changelog_dir.is_dir() else [] - if misplaced: - for path in misplaced: - print(f"{path}: not in a known section directory", file=sys.stderr) - raise SystemExit(1) - - content = next_changelog.read_text(encoding="utf-8") - consumed = [] - total = 0 - for slug, header in SECTIONS: - section_dir = changelog_dir / slug - if not section_dir.is_dir(): - continue - entries = [] - for path in sorted(section_dir.glob("*.md")): - if path.name == "README.md": - continue - entries.append(normalize_entry(path.read_text(encoding="utf-8"))) - consumed.append(path) - if entries: - content = insert_entries(content, header, entries) - total += len(entries) - print(f"{header}: collated {len(entries)} entr{'y' if len(entries) == 1 else 'ies'}") - - if not consumed: - print("No changelog fragments to collate.") - return - - next_changelog.write_text(content, encoding="utf-8") - for path in consumed: - path.unlink() - print(f"Collated {total} entries into {NEXT_CHANGELOG} and removed {len(consumed)} fragments.") - - -def main(argv=None): - parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument("--check", action="store_true", help="validate fragment placement without writing") - parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path.cwd(), help="repository root") - args = parser.parse_args(argv) - - if args.check: - sys.exit(check(args.root)) - collate(args.root) - - -if __name__ == "__main__": - main() diff --git a/tools/update_github_links.py b/tools/update_github_links.py index 93196607962..354430b85ec 100755 --- a/tools/update_github_links.py +++ b/tools/update_github_links.py @@ -15,7 +15,7 @@ import re import sys -DEFAULT_FILES = ("NEXT_CHANGELOG.md", "CHANGELOG.md") +DEFAULT_FILES = ("CHANGELOG.md",) # Canonical form: ([#1234](https://github.com/databricks/cli/pull/1234)) CONVERTED_LINK_RE = re.compile( diff --git a/tools/validate_nextchanges.py b/tools/validate_nextchanges.py new file mode 100755 index 00000000000..661f7a8d353 --- /dev/null +++ b/tools/validate_nextchanges.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.12" +# /// +"""Validate changelog fragment placement under ``.nextchanges/``. + +Each PR adds its own file under ``.nextchanges/
/`` (see +``.nextchanges/README.md``). This fails CI when a fragment is misfiled or +empty, so it is caught up front rather than silently dropped when the release +renders ``.nextchanges/`` into ``CHANGELOG.md`` (see +``internal/genkit/tagging.py``). +""" + +import argparse +import pathlib +import re +import sys + +CHANGELOG_DIR = ".nextchanges" + +# Known section subdirectories. Mirrors NEXTCHANGES_SECTIONS in +# internal/genkit/tagging.py — keep the two in sync. +SECTIONS = ("notable-changes", "cli", "bundles", "dependency-updates", "api-changes") + +# .nextchanges/version holds the next release version; the release reads it and +# bumps it. Accept a bare semver (optionally v-prefixed), e.g. 1.4.0 / v1.4.0. +VERSION_FILE = "version" +SEMVER_RE = re.compile(r"^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$") + + +def iter_fragment_files(changelog_dir): + """Yield every ``*.md`` fragment under *changelog_dir*, excluding READMEs.""" + for path in sorted(changelog_dir.rglob("*.md")): + if path.name == "README.md": + continue + yield path + + +def find_problems(changelog_dir): + """Return a list of ``(path, message)`` for misplaced/empty fragments or a + missing/malformed version file.""" + problems = [] + for path in iter_fragment_files(changelog_dir): + rel = path.relative_to(changelog_dir) + if len(rel.parts) != 2 or rel.parts[0] not in SECTIONS: + problems.append((path, "not in a known section directory")) + elif not path.read_text(encoding="utf-8").strip(): + problems.append((path, "empty fragment")) + + version_path = changelog_dir / VERSION_FILE + if not version_path.is_file(): + problems.append((version_path, "missing; expected the next release version (e.g. 1.4.0)")) + elif not SEMVER_RE.match(version_path.read_text(encoding="utf-8").strip()): + problems.append((version_path, "not a valid semver version (e.g. 1.4.0)")) + return problems + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--root", type=pathlib.Path, default=pathlib.Path.cwd(), help="repository root") + args = parser.parse_args(argv) + + changelog_dir = args.root / CHANGELOG_DIR + if not changelog_dir.is_dir(): + return + + problems = find_problems(changelog_dir) + if problems: + for path, msg in problems: + print(f"{path}: {msg}", file=sys.stderr) + print(f"\nFragments must live at {CHANGELOG_DIR}/
/.md", file=sys.stderr) + print(f"Valid sections: {', '.join(SECTIONS)}", file=sys.stderr) + print(f"{CHANGELOG_DIR}/{VERSION_FILE} must hold the next release version.", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() From 1fd1ae271eabd5eb8262f362fa73097425281f52 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 16:37:28 +0200 Subject: [PATCH 11/31] Update changelog docs for direct-to-CHANGELOG release The .nextchanges/ README, the pr-checklist skill, and the changelog-guard message now describe fragments being rendered straight into CHANGELOG.md at release, and the README documents the .nextchanges/version file. Co-authored-by: Isaac --- .agent/skills/pr-checklist/SKILL.md | 2 +- .github/workflows/changelog-guard.yml | 2 +- .nextchanges/README.md | 15 +++++++++++---- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.agent/skills/pr-checklist/SKILL.md b/.agent/skills/pr-checklist/SKILL.md index 9699c7238f3..7da1560a156 100644 --- a/.agent/skills/pr-checklist/SKILL.md +++ b/.agent/skills/pr-checklist/SKILL.md @@ -55,7 +55,7 @@ If an agent (you) authored or substantially helped author the PR, disclose it on ## Changelog entry -Add a changelog fragment under `.nextchanges/` when your change is user-visible. Each PR adds its own file, so entries never conflict between PRs. CI collates the fragments and generates the real `CHANGELOG.md` at release time, so never hand-edit `CHANGELOG.md` or `NEXT_CHANGELOG.md` directly. +Add a changelog fragment under `.nextchanges/` when your change is user-visible. Each PR adds its own file, so entries never conflict between PRs. The release renders these fragments into the real `CHANGELOG.md`, so never hand-edit `CHANGELOG.md` directly. **When to add an entry:** - New or changed CLI command, flag, or subcommand behavior diff --git a/.github/workflows/changelog-guard.yml b/.github/workflows/changelog-guard.yml index 73969171d4f..3890a56e142 100644 --- a/.github/workflows/changelog-guard.yml +++ b/.github/workflows/changelog-guard.yml @@ -13,6 +13,6 @@ jobs: if: "!contains(github.event.pull_request.labels.*.name, 'override-changelog-guard')" run: |- echo "::error::CHANGELOG.md should not be modified directly by PRs." - echo "The release workflow manages CHANGELOG.md automatically from NEXT_CHANGELOG.md." + echo "The release workflow manages CHANGELOG.md automatically from .nextchanges/ fragments." echo "If this is intentional, add the 'override-changelog-guard' label to this PR." exit 1 diff --git a/.nextchanges/README.md b/.nextchanges/README.md index 1b2d6296067..3b569170869 100644 --- a/.nextchanges/README.md +++ b/.nextchanges/README.md @@ -38,7 +38,14 @@ for when an entry is warranted. ## How it's released -You don't run anything. At release time, `tools/collate_changelog.py` folds -every fragment into the matching section of `NEXT_CHANGELOG.md`, deletes the -fragments, and the release tooling generates `CHANGELOG.md` as before. -`./task changelog-check` validates fragment placement on every PR. +You don't run anything. At release time the tagging workflow renders every +fragment into the matching section of `CHANGELOG.md`, deletes the consumed +fragments, and bumps `version` to the next minor (see +`internal/genkit/tagging.py`). `./task changelog-check` validates fragment +placement and the `version` file on every PR. + +### `version` + +`.nextchanges/version` holds the version of the next release (e.g. `1.4.0`). +It's bumped to the next minor automatically after each release — edit it in a +PR only to cut a patch or major release instead. From d77445340b07c5499f26f1d1ee28a4d26ab8efc9 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 16:56:18 +0200 Subject: [PATCH 12/31] Name the release wrapper release_tagging.py, keep tagging.py pristine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simpler split than the earlier tagging_upstream.py rename: the genkit-synced release script keeps its own name, internal/genkit/tagging.py (regenerated verbatim from universe, ruff-excluded, listed as generated in auto-generated-files.md — all unchanged). The CLI's .nextchanges behavior lives in a repo-owned internal/genkit/release_tagging.py wrapper that imports tagging. The tagging workflow runs the wrapper: generate-clijson keeps genkit's synced tagging.py and sed-rewrites `uv run --locked tagging.py` to point at release_tagging.py (restoring genkit's "# Generated file. DO NOT EDIT." header and yamlfmt, matching how the path relocation already worked), and copies the synced lock to release_tagging.py.lock (same deps). ruff lints the wrapper and keeps excluding the synced tagging.py. Co-authored-by: Isaac --- .github/workflows/tagging.yml | 9 +- Taskfile.yml | 22 +- internal/genkit/release_tagging.py | 176 +++ ...stream.py.lock => release_tagging.py.lock} | 0 internal/genkit/tagging.py | 1135 +++++++++++++++-- internal/genkit/tagging_upstream.py | 1057 --------------- ruff.toml | 17 +- 7 files changed, 1209 insertions(+), 1207 deletions(-) create mode 100644 internal/genkit/release_tagging.py rename internal/genkit/{tagging_upstream.py.lock => release_tagging.py.lock} (100%) mode change 100644 => 100755 internal/genkit/tagging.py delete mode 100755 internal/genkit/tagging_upstream.py diff --git a/.github/workflows/tagging.yml b/.github/workflows/tagging.yml index b565f80a647..735ef5c7ebc 100644 --- a/.github/workflows/tagging.yml +++ b/.github/workflows/tagging.yml @@ -1,7 +1,4 @@ -# Synced from universe, then relocated to run internal/genkit/tagging.py (a -# repo-owned wrapper that renders CHANGELOG.md from .nextchanges/ fragments; -# see that file). The `generate-clijson` task handles the path rewrite, so the -# only divergence from the synced workflow is the script path. +# Generated file. DO NOT EDIT. name: tagging on: @@ -77,9 +74,9 @@ jobs: PACKAGES: ${{ inputs.packages }} run: | if [ -n "$PACKAGES" ]; then - uv run --locked internal/genkit/tagging.py --package "$PACKAGES" + uv run --locked internal/genkit/release_tagging.py --package "$PACKAGES" else - uv run --locked internal/genkit/tagging.py + uv run --locked internal/genkit/release_tagging.py fi - name: Upload created tags artifact diff --git a/Taskfile.yml b/Taskfile.yml index 7b4b2ff9d20..c245991614d 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -882,14 +882,20 @@ tasks: - git checkout -- .gitattributes - go test -timeout 240s -run TestConsistentDatabricksSdkVersion github.com/databricks/cli/internal/build - rm .github/workflows/next-changelog.yml - # tagging.yml is repo-owned (it runs the internal/genkit/tagging.py - # wrapper); discard genkit's regenerated copy rather than sed-rewriting it. - - git checkout -- .github/workflows/tagging.yml - # The synced file becomes tagging_upstream.py; the repo-owned wrapper keeps - # the tagging.py name (and reuses the synced lock, since it shares deps). - - mv tagging.py internal/genkit/tagging_upstream.py - - mv tagging.py.lock internal/genkit/tagging_upstream.py.lock - - cp internal/genkit/tagging_upstream.py.lock internal/genkit/tagging.py.lock + - mv tagging.py internal/genkit/tagging.py + - mv tagging.py.lock internal/genkit/tagging.py.lock + # The release runs internal/genkit/release_tagging.py, a repo-owned wrapper + # that renders CHANGELOG.md from .nextchanges/ around the synced tagging.py. + # Point the workflow at it (genkit emits `uv run --locked tagging.py`), and + # give the wrapper a matching lock (it declares the same deps). + - | + if [ "$(uname)" = "Darwin" ]; then + sed -i '' 's|uv run --locked tagging.py|uv run --locked internal/genkit/release_tagging.py|g' .github/workflows/tagging.yml + else + sed -i 's|uv run --locked tagging.py|uv run --locked internal/genkit/release_tagging.py|g' .github/workflows/tagging.yml + fi + - cp internal/genkit/tagging.py.lock internal/genkit/release_tagging.py.lock + - "{{.GO_TOOL}} yamlfmt .github/workflows/tagging.yml" - task: ws # Refreshes out.fields.txt, which records the field paths / types emitted by diff --git a/internal/genkit/release_tagging.py b/internal/genkit/release_tagging.py new file mode 100644 index 00000000000..20583e47f8a --- /dev/null +++ b/internal/genkit/release_tagging.py @@ -0,0 +1,176 @@ +# /// script +# dependencies = ["PyGithub>=2,<3", "pyjwt<2.12.0", "charset-normalizer<3.4.6"] +# /// +"""databricks/cli release entrypoint. + +Thin wrapper around the synced ``tagging.py`` — which is regenerated verbatim +from universe (``openapi/tagging/tagging.py``) by ``genkit update-sdk`` and must +stay pristine. The tagging workflow runs this file (``release_tagging.py``) +instead, so the CLI-specific behavior lives here rather than as edits to the +synced file: + +* the changelog body is rendered from per-PR ``.nextchanges/
/*.md`` + fragments rather than read from a ``NEXT_CHANGELOG.md`` file, and +* the release version is read from ``.nextchanges/version`` (bumped to the next + minor on each release) rather than a hand-maintained ``## Release vX.Y.Z`` + header. + +It injects that behavior by rebinding two module-level seams in the upstream +module (``get_next_tag_info`` and ``clean_next_changelog``, both called by name +from ``process_package``/``preview_tag_infos``) and then delegates to the +untouched ``process()`` for all commit/tag/race/recovery logic. +""" + +import os +import re +from typing import Optional + +import tagging + +NEXTCHANGES_DIR = ".nextchanges" + +# Tracks the version of the next release. Read at release time and bumped to the +# next minor afterward — the role NEXT_CHANGELOG.md's "## Release vX.Y.Z" header +# played upstream. +VERSION_FILE = "version" + +# Section subdirectory -> "### " header, in changelog order. Mirrors the +# section folders documented in .nextchanges/README.md and validated by +# tools/validate_nextchanges.py — keep the three in sync. +NEXTCHANGES_SECTIONS = ( + ("notable-changes", "Notable Changes"), + ("cli", "CLI"), + ("bundles", "Bundles"), + ("dependency-updates", "Dependency updates"), + ("api-changes", "API Changes"), +) + + +def _expand_pr_links(text: str) -> str: + """ + Convert raw PR references to canonical markdown links, matching + ``tools/update_github_links.py``: ``(#1234)`` and bare ``#1234`` become + ``([#1234](https://github.com//pull/1234))``. Existing links are + left alone. + """ + repo = os.environ.get("GITHUB_REPOSITORY", "databricks/cli") + + def link(n: str) -> str: + return f"([#{n}](https://github.com/{repo}/pull/{n}))" + + text = re.sub(r"\(#(\d+)\)", lambda m: link(m.group(1)), text) + # Bare #1234 not already inside a link ('[') or paren-wrapped ('('). + return re.sub(r"(? Optional[str]: + """ + Render ``/.nextchanges/
/*.md`` into the changelog + body: one ``###
`` block per non-empty section in + NEXTCHANGES_SECTIONS order, fragments sorted by filename. A leading + ``* ``/``- `` marker is optional in a fragment. Returns None when there + are no fragments. + """ + base = os.path.join(os.getcwd(), package_path, NEXTCHANGES_DIR) + if not os.path.isdir(base): + return None + + blocks = [] + for slug, header in NEXTCHANGES_SECTIONS: + section_dir = os.path.join(base, slug) + if not os.path.isdir(section_dir): + continue + entries = [] + for name in sorted(os.listdir(section_dir)): + if not name.endswith(".md") or name == "README.md": + continue + with open(os.path.join(section_dir, name)) as f: + text = f.read().strip() + if not text: + continue + first, _, rest = text.partition("\n") + first = "* " + first[2:] if first.startswith(("* ", "- ")) else "* " + first + entries.append(first + (("\n" + rest) if rest else "")) + if entries: + blocks.append(f"### {header}\n" + "\n".join(entries)) + + if not blocks: + return None + return _expand_pr_links("\n\n".join(blocks)) + + +def _version_path(package_path: str) -> str: + return os.path.join(os.getcwd(), package_path, NEXTCHANGES_DIR, VERSION_FILE) + + +def next_version(package: tagging.Package) -> str: + """ + Release version for this run, read from ``.nextchanges/version``. To cut a + patch or major release, edit that file in the PR; otherwise the default + (bumped after the previous release) is the next minor. + """ + with open(_version_path(package.path)) as f: + return str(tagging.Version.parse(f.read().strip().lstrip("v"))) + + +def get_next_tag_info(package: tagging.Package) -> Optional[tagging.TagInfo]: + """ + Replacement for ``tagging.get_next_tag_info``: build the release TagInfo + from ``.nextchanges/`` fragments. Returns None when there are no entries + (nothing to release), unless ``allow_empty_changelog`` is set in + ``.codegen.json`` — matching the pristine skip behavior. + """ + body = render_nextchanges(package.path) + if body is None and not tagging._load_codegen_config().get("allow_empty_changelog", False): + print("No .nextchanges/ entries. No changes will be made to the changelog.") + return None + + version = next_version(package) + # write_changelog() keys off the "## Release v…" header, so include it. + content = f"## Release v{version}\n" + (f"\n{body}\n" if body else "") + return tagging.TagInfo(package=package, version=version, content=content) + + +def clear_nextchanges(package_path: str) -> None: + """ + Replacement for ``tagging.clean_next_changelog``: stage deletion of the + ``.nextchanges/`` fragments consumed by this release and bump + ``.nextchanges/version`` to the next minor (its post-release default; teams + can still override it in a PR). Section directories and their README.md are + left in place. ``process_package`` calls this as + ``clean_next_changelog(package.path)``, so the signature matches. + """ + base = os.path.join(os.getcwd(), package_path, NEXTCHANGES_DIR) + for slug, _ in NEXTCHANGES_SECTIONS: + section_dir = os.path.join(base, slug) + if not os.path.isdir(section_dir): + continue + for name in sorted(os.listdir(section_dir)): + if name.endswith(".md") and name != "README.md": + tagging.gh.delete_file(os.path.join(section_dir, name)) + + version_path = _version_path(package_path) + with open(version_path) as f: + released = tagging.Version.parse(f.read().strip().lstrip("v")) + tagging.gh.add_file(version_path, f"{released.next_release_version()}\n") + + +def _delete_file(self, loc: str): + """``git rm`` equivalent for GitHubRepo: stage a tree deletion (sha=None).""" + local_path = os.path.relpath(loc, os.getcwd()) + print(f"Deleting file {local_path}") + self.changed_files.append(tagging.InputGitTreeElement(path=local_path, mode="100644", type="blob", sha=None)) + + +def install_nextchanges() -> None: + """Rebind the tagging seams to the CLI's .nextchanges behavior.""" + tagging.GitHubRepo.delete_file = _delete_file + tagging.get_next_tag_info = get_next_tag_info + tagging.clean_next_changelog = clear_nextchanges + + +if __name__ == "__main__": + install_nextchanges() + tagging.validate_git_root() + tagging.init_github() + tagging.process() diff --git a/internal/genkit/tagging_upstream.py.lock b/internal/genkit/release_tagging.py.lock similarity index 100% rename from internal/genkit/tagging_upstream.py.lock rename to internal/genkit/release_tagging.py.lock diff --git a/internal/genkit/tagging.py b/internal/genkit/tagging.py old mode 100644 new mode 100755 index a2508e384de..a3897bbeedf --- a/internal/genkit/tagging.py +++ b/internal/genkit/tagging.py @@ -1,176 +1,1057 @@ +#!/usr/bin/env python3 # /// script # dependencies = ["PyGithub>=2,<3", "pyjwt<2.12.0", "charset-normalizer<3.4.6"] # /// -"""databricks/cli release entrypoint. - -Thin wrapper around ``tagging_upstream.py`` — which is regenerated verbatim from -universe (``openapi/tagging/tagging.py``) by ``genkit update-sdk`` and must stay -pristine. Despite the filename, THIS file is hand-maintained: the tagging -workflow runs ``tagging.py`` (this wrapper), which holds the CLI-specific -behavior instead of editing the synced file: - -* the changelog body is rendered from per-PR ``.nextchanges/
/*.md`` - fragments rather than read from a ``NEXT_CHANGELOG.md`` file, and -* the release version is read from ``.nextchanges/version`` (bumped to the next - minor on each release) rather than a hand-maintained ``## Release vX.Y.Z`` - header. - -It injects that behavior by rebinding two module-level seams in the upstream -module (``get_next_tag_info`` and ``clean_next_changelog``, both called by name -from ``process_package``/``preview_tag_infos``) and then delegates to the -untouched ``process()`` for all commit/tag/race/recovery logic. -""" import os import re -from typing import Optional +import argparse +from typing import Optional, List, Callable, Dict +from dataclasses import dataclass, replace +import subprocess +import time +import json +from github import Github, Repository, InputGitTreeElement, InputGitAuthor +from datetime import datetime, timezone + +NEXT_CHANGELOG_FILE_NAME = "NEXT_CHANGELOG.md" +CHANGELOG_FILE_NAME = "CHANGELOG.md" +PACKAGE_FILE_NAME = ".package.json" +CODEGEN_FILE_NAME = ".codegen.json" +CREATED_TAGS_FILE_NAME = "created_tags.json" +""" +This script tags the release of the SDKs using a combination of the GitHub API and Git commands. +It reads the local repository to determine necessary changes, updates changelogs, and creates tags. + +### How it Works: +- It does **not** modify the local repository directly. +- Instead of committing and pushing changes locally, it uses the **GitHub API** to create commits and tags. +""" + + +@dataclass(frozen=True) +class Version: + """ + A semver 2.0.0-compliant version (https://semver.org). + + Mirrors the API of the `semver` PyPI package so this implementation can be + swapped for that library if it is ever added to the wheelhouse. Supports + parsing, stringification, and the two bumps we need: minor (for stable + releases) and prerelease (for release trains). + """ + + # Permissive pattern for locating a semver version string inside larger + # text (e.g. a changelog header). Callers use it in f-strings; strict + # validation happens via Version.parse. + PATTERN = r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?" + + # Strict anchored regex per https://semver.org. Rejects leading zeros in + # numeric identifiers and invalid pre-release/build identifier charsets. + _PARSE_REGEX = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" + r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" + r"(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" + ) + + major: int + minor: int + patch: int + prerelease: str = "" + build: str = "" + + @classmethod + def parse(cls, text: str) -> "Version": + """Parse a semver string, raising ValueError on malformed input.""" + match = cls._PARSE_REGEX.match(text) + if not match: + raise ValueError(f"Invalid semver version: {text!r}") + major, minor, patch, prerelease, build = match.groups() + return cls( + major=int(major), + minor=int(minor), + patch=int(patch), + prerelease=prerelease or "", + build=build or "", + ) + + def __str__(self) -> str: + result = f"{self.major}.{self.minor}.{self.patch}" + if self.prerelease: + result += f"-{self.prerelease}" + if self.build: + result += f"+{self.build}" + return result -import tagging_upstream as tagging + def bump_minor(self) -> "Version": + """ + Bump the minor version and reset patch. -NEXTCHANGES_DIR = ".nextchanges" + Per semver item 9, a pre-release version has lower precedence than + the same MAJOR.MINOR.PATCH, so bumping to a new minor drops any + pre-release and build metadata. + """ + return Version(major=self.major, minor=self.minor + 1, patch=0) -# Tracks the version of the next release. Read at release time and bumped to the -# next minor afterward — the role NEXT_CHANGELOG.md's "## Release vX.Y.Z" header -# played upstream. -VERSION_FILE = "version" + def bump_prerelease(self) -> "Version": + """ + Increment the rightmost numeric identifier in the pre-release. -# Section subdirectory -> "### " header, in changelog order. Mirrors the -# section folders documented in .nextchanges/README.md and validated by -# tools/validate_nextchanges.py — keep the three in sync. -NEXTCHANGES_SECTIONS = ( - ("notable-changes", "Notable Changes"), - ("cli", "CLI"), - ("bundles", "Bundles"), - ("dependency-updates", "Dependency updates"), - ("api-changes", "API Changes"), -) + Matches the npm `prerelease` bump semantics: + 0.0.0-alpha.1 -> 0.0.0-alpha.2 + 0.0.0-alpha -> 0.0.0-alpha.1 + 0.0.0-rc.1.2 -> 0.0.0-rc.1.3 + Raises ValueError if the version has no pre-release to bump. + Build metadata is dropped since it does not affect precedence. + """ + if not self.prerelease: + raise ValueError(f"Cannot bump prerelease of {self}: no pre-release component") + parts = self.prerelease.split(".") + for i in range(len(parts) - 1, -1, -1): + if parts[i].isdigit(): + parts[i] = str(int(parts[i]) + 1) + return replace(self, prerelease=".".join(parts), build="") + # No numeric identifier exists; append ".1" to start a counter. + return replace(self, prerelease=f"{self.prerelease}.1", build="") -def _expand_pr_links(text: str) -> str: + def next_release_version(self) -> "Version": + """ + Default next version for the changelog after this one is released. + + If on a pre-release track, stay on it by bumping the pre-release + identifier (npm convention). Otherwise, bump the minor version, + the script's historical default for stable releases. Teams can + override the default in the release PR. + """ + if self.prerelease: + return self.bump_prerelease() + return self.bump_minor() + + +def _read_local_head_sha() -> str: + """ + Returns the SHA of the local working tree's HEAD via ``git rev-parse``. """ - Convert raw PR references to canonical markdown links, matching - ``tools/update_github_links.py``: ``(#1234)`` and bare ``#1234`` become - ``([#1234](https://github.com//pull/1234))``. Existing links are - left alone. + return subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() + + +class MainAdvancedError(Exception): """ - repo = os.environ.get("GITHUB_REPOSITORY", "databricks/cli") + Raised when ``origin/main`` has advanced since the workflow's + checkout — i.e., another commit landed during this run. The local + working tree is now stale, so any commit produced from it would + silently revert whatever the concurrent push added. + """ + + +# GitHub does not support signing commits for GitHub Apps directly. +# This class replaces usages for git commands such as "git add", "git commit", and "git push". +@dataclass +class GitHubRepo: + def __init__(self, repo: Repository): + self.repo = repo + self.changed_files: list[InputGitTreeElement] = [] + self.ref = "heads/main" + # Anchor ``self.sha`` to the **local checkout** rather than a + # live API call. ``actions/checkout`` populates the working tree + # at this SHA, and every subsequent file read in this run is + # against that tree; the API HEAD is only relevant when we go + # to push. + self.sha = _read_local_head_sha() + + # Replaces "git add file" + def add_file(self, loc: str, content: str): + local_path = os.path.relpath(loc, os.getcwd()) + print(f"Adding file {local_path}") + blob = self.repo.create_git_blob(content=content, encoding="utf-8") + element = InputGitTreeElement(path=local_path, mode="100644", type="blob", sha=blob.sha) + self.changed_files.append(element) + + # Replaces "git commit && git push" + def commit_and_push(self, message: str): + head_ref = self.repo.get_git_ref(self.ref) + if head_ref.object.sha != self.sha: + raise MainAdvancedError( + f"origin/main advanced from {self.sha} to {head_ref.object.sha} " + f"during this run. Local working tree is stale; aborting before " + f"the commit would silently revert the new content. Re-run the " + f"workflow." + ) + base_tree = self.repo.get_git_tree(sha=head_ref.object.sha) + new_tree = self.repo.create_git_tree(self.changed_files, base_tree) + parent_commit = self.repo.get_git_commit(head_ref.object.sha) + + new_commit = self.repo.create_git_commit(message=message, tree=new_tree, parents=[parent_commit]) + # Update branch reference. + head_ref.edit(new_commit.sha) + self.sha = new_commit.sha + + def reset(self, sha: Optional[str] = None): + self.changed_files = [] + if sha: + self.sha = sha + else: + self.sha = _read_local_head_sha() + + def tag(self, tag_name: str, tag_message: str): + # Create a tag pointing to the new commit + # The email MUST be the GitHub Apps email. + # Otherwise, the tag will not be verified. + tagger = InputGitAuthor( + name="Databricks SDK Release Bot", email="DECO-SDK-Tagging[bot]@users.noreply.github.com" + ) + + tag = self.repo.create_git_tag(tag=tag_name, message=tag_message, object=self.sha, type="commit", tagger=tagger) + # Create a Git ref (the actual reference for the tag in the repo) + self.repo.create_git_ref(ref=f"refs/tags/{tag_name}", sha=tag.sha) - def link(n: str) -> str: - return f"([#{n}](https://github.com/{repo}/pull/{n}))" - text = re.sub(r"\(#(\d+)\)", lambda m: link(m.group(1)), text) - # Bare #1234 not already inside a link ('[') or paren-wrapped ('('). - return re.sub(r"(? Optional[str]: +@dataclass +class Package: """ - Render ``/.nextchanges/
/*.md`` into the changelog - body: one ``###
`` block per non-empty section in - NEXTCHANGES_SECTIONS order, fragments sorted by filename. A leading - ``* ``/``- `` marker is optional in a fragment. Returns None when there - are no fragments. + Represents a package in the repository. + :name: The package name. + :path: The path to the package relative to the repository root. """ - base = os.path.join(os.getcwd(), package_path, NEXTCHANGES_DIR) - if not os.path.isdir(base): - return None - blocks = [] - for slug, header in NEXTCHANGES_SECTIONS: - section_dir = os.path.join(base, slug) - if not os.path.isdir(section_dir): + name: str + path: str + + +@dataclass +class TagInfo: + """ + Represents all changes on a release. + :package: package info. + :version: release version for the package. Format: v.. + :content: changes for the release, as they appear in the changelog. + When written to CHANGELOG.md, the current date (YYYY-MM-DD) is automatically added. + + Example (from NEXT_CHANGELOG.md): + + ## Release v0.56.0 + + ### New Features and Improvements + * Feature + * Some improvement + + ### Bug Fixes + * Bug fix + + ### Documentation + * Doc Changes + + ### Internal Changes + * More Changes + + ### API Changes + * Add new Service + + Note: When written to CHANGELOG.md, the header becomes: ## Release v0.56.0 (YYYY-MM-DD) + + """ + + package: Package + version: str + content: str + + def tag_name(self) -> str: + return f"{self.package.name}/v{self.version}" if self.package.name else f"v{self.version}" + + +def get_package_name(package_path: str) -> str: + """ + Returns the package name from the package path. + The name is found inside the .package.json file: + { + "package": "package_name" + } + """ + filepath = os.path.join(os.getcwd(), package_path, PACKAGE_FILE_NAME) + with open(filepath, "r") as file: + content = json.load(file) + if "package" in content: + return content["package"] + # Legacy SDKs have no packages. + return "" + + +def stage_version_updates(tag_infos: List[TagInfo], packages: List[Package]) -> None: + """ + Stages all version-related edits for the release in a single pass over + every package the workspace already opts in via ``.package.json``. + """ + + # Load patterns from '.codegen.json' at the top level of the repository. + package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) + with open(package_file_path, "r") as file: + codegen = json.load(file) + + version_patterns = codegen.get("version", {}) + dep_patterns = codegen.get("dependency_pattern", {}) + name_template = codegen.get("dependency_name_template", "") + + if not version_patterns and not dep_patterns: + print("Neither `version` nor `dependency_pattern` found in .codegen.json. Nothing to update.") + return + + bumped_by_dir: Dict[str, TagInfo] = {info.package.path: info for info in tag_infos} + new_dep_versions = compute_dependency_rewrites(tag_infos, name_template) + + files = sorted(set(version_patterns.keys()) | set(dep_patterns.keys())) + + for pkg in packages: + for filename in files: + loc = os.path.join(os.getcwd(), pkg.path, filename) + + with open(loc, "r") as file: + content = file.read() + original = content + + # Own version (only when this package is being released and the + # file has a version pattern declared). + info = bumped_by_dir.get(pkg.path) + if info is not None and filename in version_patterns: + pattern = version_patterns[filename] + previous_version = pattern.replace("$VERSION", Version.PATTERN) + new_version = pattern.replace("$VERSION", info.version) + content = re.sub(previous_version, new_version, content) + + # Sibling dependency rewrites (only when the file has a + # dependency pattern and there is at least one bumped sibling). + if filename in dep_patterns and new_dep_versions: + content = rewrite_dependencies(content, dep_patterns[filename], new_dep_versions) + + if content != original: + gh.add_file(loc, content) + + +def compute_dependency_rewrites( + tag_infos: List[TagInfo], + name_template: str, +) -> Dict[str, str]: + """ + Returns a map of dependency-name to the new semver string for each + bumped package. + """ + if not name_template: + return {} + rewrites: Dict[str, str] = {} + for info in tag_infos: + # Skip legacy releases that don't have a per-package name; their + # tag_info has an empty package.name and they can't be referenced + # as a sibling dep anyway. + if not info.package.name: continue - entries = [] - for name in sorted(os.listdir(section_dir)): - if not name.endswith(".md") or name == "README.md": - continue - with open(os.path.join(section_dir, name)) as f: - text = f.read().strip() - if not text: - continue - first, _, rest = text.partition("\n") - first = "* " + first[2:] if first.startswith(("* ", "- ")) else "* " + first - entries.append(first + (("\n" + rest) if rest else "")) - if entries: - blocks.append(f"### {header}\n" + "\n".join(entries)) + dep_name = name_template.replace("$PACKAGE", info.package.name) + rewrites[dep_name] = info.version + return rewrites + + +def rewrite_dependencies(content: str, pattern: str, new_versions: Dict[str, str]) -> str: + """ + Apply ``pattern`` (with ``$DEPENDENCY`` and ``$VERSION`` placeholders) to + rewrite every entry in ``content`` whose dependency name appears in + ``new_versions``. + """ + # Sentinel strings used to protect the placeholders through re.escape: + # we substitute them in, escape the whole template, then swap them out + # for the dep-name literal and Version.PATTERN. Control characters so + # they can't collide with anything in real .codegen.json patterns. + dep_sentinel = "\x01DEPENDENCY\x01" + ver_sentinel = "\x01VERSION\x01" + + for dep_name, new_value in new_versions.items(): + regex = pattern.replace("$DEPENDENCY", dep_sentinel).replace("$VERSION", ver_sentinel) + regex = re.escape(regex) + regex = regex.replace(re.escape(dep_sentinel), re.escape(dep_name)) + regex = regex.replace(re.escape(ver_sentinel), Version.PATTERN) + + # Build the literal replacement text by substituting the same + # placeholders directly. A lambda is used instead of a string to + # avoid re.sub interpreting \1, \g<...>, etc. inside the value. + replacement_text = pattern.replace("$DEPENDENCY", dep_name).replace("$VERSION", new_value) + content = re.sub(regex, lambda _m, text=replacement_text: text, content) + return content + + +def clean_next_changelog(package_path: str) -> None: + """ + Cleans the "NEXT_CHANGELOG.md" file. It performs 2 operations: + * Increase the version to the next minor version. + * Remove release notes. Sections names are kept to + keep consistency in the section names between releases. + """ + + file_path = os.path.join(os.getcwd(), package_path, NEXT_CHANGELOG_FILE_NAME) + with open(file_path, "r") as file: + content = file.read() + + # Remove content between ### sections. + cleaned_content = re.sub(r"(### [^\n]+\n)(?:.*?\n?)*?(?=###|$)", r"\1", content) + # Ensure there is exactly one empty line before each section. + cleaned_content = re.sub(r"(\n*)(###[^\n]+)", r"\n\n\2", cleaned_content) + # Find the version number and compute the default next release version. + # Teams can adjust the version in the PR if the default is not desired. + # For stable versions, bump minor (historical default since minor releases + # are more common than patch or major). For pre-release versions, stay on + # the same track by bumping the pre-release identifier (npm convention). + version_match = re.search(rf"Release v({Version.PATTERN})", cleaned_content) + if not version_match: + raise Exception("Version not found in the changelog") + current = Version.parse(version_match.group(1)) + new_header = f"Release v{current.next_release_version()}" + cleaned_content = cleaned_content.replace(version_match.group(0), new_header) + + # Update file with cleaned content + gh.add_file(file_path, cleaned_content) + + +def get_previous_tag_info(package: Package) -> Optional[TagInfo]: + """ + Extracts the previous tag info from the "CHANGELOG.md" file. + Used for failure recovery purposes. + """ + changelog_path = os.path.join(os.getcwd(), package.path, CHANGELOG_FILE_NAME) + + with open(changelog_path, "r") as f: + changelog = f.read() - if not blocks: + # Extract the latest release section using regex. + match = re.search( + rf"## (\[Release\] )?Release v{Version.PATTERN}.*?(?=\n## (\[Release\] )?Release v|\Z)", + changelog, + re.S, + ) + + # E.g., for new packages. + if not match: return None - return _expand_pr_links("\n\n".join(blocks)) + latest_release = match.group(0) + version_match = re.search(rf"## (\[Release\] )?Release v({Version.PATTERN})", latest_release) + + if not version_match: + raise Exception("Version not found in the changelog") + + # Validate the extracted string is spec-compliant; fail loudly otherwise. + version = str(Version.parse(version_match.group(2))) + return TagInfo(package=package, version=version, content=latest_release) + + +def _load_codegen_config() -> Dict: + """ + Loads ``.codegen.json`` from the repo root. Returns an empty dict when + the file is missing. + """ + package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) + if not os.path.exists(package_file_path): + return {} + with open(package_file_path, "r") as file: + return json.load(file) + + +def get_next_tag_info(package: Package) -> Optional[TagInfo]: + """ + Extracts the changes from the "NEXT_CHANGELOG.md" file. + The result is already processed. + """ + next_changelog_path = os.path.join(os.getcwd(), package.path, NEXT_CHANGELOG_FILE_NAME) + # Read NEXT_CHANGELOG.md + with open(next_changelog_path, "r") as f: + next_changelog = f.read() + + # Remove "# NEXT CHANGELOG" line + next_changelog = re.sub(r"^# NEXT CHANGELOG(\n+)", "", next_changelog, flags=re.MULTILINE) + + # Remove empty sections + next_changelog = re.sub(r"###[^\n]+\n+(?=##|\Z)", "", next_changelog) + # Ensure there is exactly one empty line before each section + next_changelog = re.sub(r"(\n*)(###[^\n]+)", r"\n\n\2", next_changelog) + + # By default, packages whose NEXT_CHANGELOG.md has no populated + # sections are skipped — there's nothing meaningful to release. + # Repos like sdk-js which are still in development can opt in + # by setting ``allow_empty_changelog: true`` in .codegen.json. + if not re.search(r"###", next_changelog) and not _load_codegen_config().get("allow_empty_changelog", False): + print("All sections are empty. No changes will be made to the changelog.") + return None + + version_match = re.search(rf"## Release v({Version.PATTERN})", next_changelog) + + if not version_match: + raise Exception("Version not found in the changelog") + + # Validate the extracted string is spec-compliant; fail loudly otherwise. + version = str(Version.parse(version_match.group(1))) + return TagInfo(package=package, version=version, content=next_changelog) + + +def write_changelog(tag_info: TagInfo) -> None: + """ + Updates the changelog with a new tag info. + """ + changelog_path = os.path.join(os.getcwd(), tag_info.package.path, CHANGELOG_FILE_NAME) + with open(changelog_path, "r") as f: + changelog = f.read() + + # Add current date to the release header. + current_date = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + content_with_date = re.sub( + rf"## Release v({Version.PATTERN})", + rf"## Release v\1 ({current_date})", + tag_info.content.strip(), + ) + + updated_changelog = re.sub(r"(# Version changelog\n\n)", f"\\1{content_with_date}\n\n\n", changelog) + gh.add_file(changelog_path, updated_changelog) + + +def process_package(package: Package) -> TagInfo: + """ + Processes a package's changelog scaffolding for the release. + """ + print(f"Processing package {package.name}") + tag_info = get_next_tag_info(package) + + # If there are no updates, skip. + if tag_info is None: + return -def _version_path(package_path: str) -> str: - return os.path.join(os.getcwd(), package_path, NEXTCHANGES_DIR, VERSION_FILE) + write_changelog(tag_info) + clean_next_changelog(package.path) + return tag_info -def next_version(package: tagging.Package) -> str: +def find_packages() -> List[Package]: """ - Release version for this run, read from ``.nextchanges/version``. To cut a - patch or major release, edit that file in the PR; otherwise the default - (bumped after the previous release) is the next minor. + Returns all directories which contains a ".package.json" file. """ - with open(_version_path(package.path)) as f: - return str(tagging.Version.parse(f.read().strip().lstrip("v"))) + paths = _find_directories_with_file(PACKAGE_FILE_NAME) + return [Package(name=get_package_name(path), path=path) for path in paths] -def get_next_tag_info(package: tagging.Package) -> Optional[tagging.TagInfo]: +def _find_directories_with_file(target_file: str) -> List[str]: + root_path = os.getcwd() + matching_directories = [] + + for dirpath, _, filenames in os.walk(root_path): + if target_file in filenames: + path = os.path.relpath(dirpath, root_path) + # If the path is the root directory (e.g., SDK V0), set it to an empty string. + if path == ".": + path = "" + matching_directories.append(path) + + return matching_directories + + +def is_tag_applied(tag: TagInfo) -> bool: """ - Replacement for ``tagging.get_next_tag_info``: build the release TagInfo - from ``.nextchanges/`` fragments. Returns None when there are no entries - (nothing to release), unless ``allow_empty_changelog`` is set in - ``.codegen.json`` — matching the pristine skip behavior. + Returns whether a tag is already applied in the repository. + + :param tag: The tag to check. + :return: True if the tag is applied, False otherwise. + :raises Exception: If the git command fails. + """ + try: + # Check if the specific tag exists + result = subprocess.check_output(["git", "tag", "--list", tag.tag_name()], stderr=subprocess.PIPE, text=True) + return result.strip() == tag.tag_name() + except subprocess.CalledProcessError as e: + # Raise a exception for git command errors + raise Exception(f"Git command failed: {e.stderr.strip() or e}") from e + + +def find_last_release_tag(package: Package) -> Optional[str]: """ - body = render_nextchanges(package.path) - if body is None and not tagging._load_codegen_config().get("allow_empty_changelog", False): - print("No .nextchanges/ entries. No changes will be made to the changelog.") + Returns the most recent ``/v*`` tag in the repository, or + ``None`` if no such tag exists. Tags are sorted by semver ordering + (``--sort=-v:refname``) so pre-releases sort below their stable + counterparts. + + :raises Exception: If the git command fails. + """ + pattern = f"{package.name}/v*" if package.name else "v*" + try: + output = subprocess.check_output( + ["git", "tag", "--list", pattern, "--sort=-v:refname"], + stderr=subprocess.PIPE, + text=True, + ).strip() + except subprocess.CalledProcessError as e: + raise Exception(f"Git command failed: {e.stderr.strip() or e}") from e + if not output: return None + return output.split("\n")[0].strip() - version = next_version(package) - # write_changelog() keys off the "## Release v…" header, so include it. - content = f"## Release v{version}\n" + (f"\n{body}\n" if body else "") - return tagging.TagInfo(package=package, version=version, content=content) +def has_commits_since_tag(tag: str, path: str) -> bool: + """ + Returns True iff at least one commit reachable from HEAD but not from + ``tag`` touches ``path``. Used to detect that a sibling dependency has + unreleased changes that would ship stale if we tagged a dependent + without re-tagging the dependency. -def clear_nextchanges(package_path: str) -> None: + :raises Exception: If the git command fails. """ - Replacement for ``tagging.clean_next_changelog``: stage deletion of the - ``.nextchanges/`` fragments consumed by this release and bump - ``.nextchanges/version`` to the next minor (its post-release default; teams - can still override it in a PR). Section directories and their README.md are - left in place. ``process_package`` calls this as - ``clean_next_changelog(package.path)``, so the signature matches. + args = ["git", "log", "--oneline", f"{tag}..HEAD", "--", path or "."] + try: + output = subprocess.check_output(args, stderr=subprocess.PIPE, text=True).strip() + except subprocess.CalledProcessError as e: + raise Exception(f"Git command failed: {e.stderr.strip() or e}") from e + return bool(output) + + +def check_dependency_freshness(tag_infos: List[TagInfo], all_packages: List[Package]) -> None: + """ + Hard-fails when a package being released depends on a sibling package + that has unreleased commits since its last tag. + + Why: dependency rewrites (``stage_version_updates``) only fire for + siblings that are *also* being released. Without this check, releasing + package_a alone — when package_b has commits since its last tag — + publishes ``package_a@new`` pinning the *old* package_b artifact, which + won't have the changes package_a's source depends on. The check is + commit-based (not changelog-based) so a missing ``NEXT_CHANGELOG.md`` + entry on package_b is still caught. + + No-op when ``.codegen.json`` declares no dependency pattern (legacy + SDKs without per-package wiring). """ - base = os.path.join(os.getcwd(), package_path, NEXTCHANGES_DIR) - for slug, _ in NEXTCHANGES_SECTIONS: - section_dir = os.path.join(base, slug) - if not os.path.isdir(section_dir): + if not tag_infos: + return + + package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) + with open(package_file_path, "r") as file: + codegen = json.load(file) + + name_template = codegen.get("dependency_name_template", "") + dep_patterns = codegen.get("dependency_pattern", {}) + if not name_template or not dep_patterns: + return + + releasing_paths = {info.package.path for info in tag_infos} + by_dep_name: Dict[str, Package] = {} + for pkg in all_packages: + if not pkg.name: continue - for name in sorted(os.listdir(section_dir)): - if name.endswith(".md") and name != "README.md": - tagging.gh.delete_file(os.path.join(section_dir, name)) + by_dep_name[name_template.replace("$PACKAGE", pkg.name)] = pkg - version_path = _version_path(package_path) - with open(version_path) as f: - released = tagging.Version.parse(f.read().strip().lstrip("v")) - tagging.gh.add_file(version_path, f"{released.next_release_version()}\n") + issues: List[str] = [] + for info in tag_infos: + for filename, pattern in dep_patterns.items(): + loc = os.path.join(os.getcwd(), info.package.path, filename) + if not os.path.exists(loc): + continue + with open(loc, "r") as f: + content = f.read() + + for dep_name, dep_pkg in by_dep_name.items(): + if dep_pkg.path == info.package.path: + continue + if dep_pkg.path in releasing_paths: + continue + # Same regex construction used by ``rewrite_dependencies``, + # so "is this dep referenced?" matches "would the rewrite + # touch it?". Keeps the two in lockstep. + regex = ( + re.escape(pattern) + .replace(re.escape("$DEPENDENCY"), re.escape(dep_name)) + .replace(re.escape("$VERSION"), Version.PATTERN) + ) + if not re.search(regex, content): + continue -def _delete_file(self, loc: str): - """``git rm`` equivalent for GitHubRepo: stage a tree deletion (sha=None).""" - local_path = os.path.relpath(loc, os.getcwd()) - print(f"Deleting file {local_path}") - self.changed_files.append(tagging.InputGitTreeElement(path=local_path, mode="100644", type="blob", sha=None)) + last_tag = find_last_release_tag(dep_pkg) + if last_tag is None: + # No prior tag means the dep was never released; we + # can't reason about staleness. Surface it anyway so + # the human resolves it explicitly. + issues.append( + f"{info.package.name} depends on {dep_pkg.name}, " + f"which has never been released. Release " + f"{dep_pkg.name} first or include it in this run." + ) + continue + if has_commits_since_tag(last_tag, dep_pkg.path): + issues.append( + f"{info.package.name} depends on {dep_pkg.name}, " + f"which has commits since {last_tag} but is not " + f"being released. Either release {dep_pkg.name} " + f"as well, or hold this release until its changes " + f"are reverted." + ) + if issues: + raise Exception("Dependency freshness check failed:\n - " + "\n - ".join(issues)) -def install_nextchanges() -> None: - """Rebind the tagging seams to the CLI's .nextchanges behavior.""" - tagging.GitHubRepo.delete_file = _delete_file - tagging.get_next_tag_info = get_next_tag_info - tagging.clean_next_changelog = clear_nextchanges + +def find_last_tags() -> List[TagInfo]: + """ + Finds the last tags for each package. + + Returns a list of TagInfo objects for each package with a non-None changelog. + """ + packages = find_packages() + + return [info for info in (get_previous_tag_info(package) for package in packages) if info is not None] + + +def find_pending_tags() -> List[TagInfo]: + """ + Finds all tags that are pending to be applied. + """ + tag_infos = find_last_tags() + return [tag for tag in tag_infos if not is_tag_applied(tag)] + + +def generate_commit_message(tag_infos: List[TagInfo]) -> str: + """ + Generates a commit message for the release. + """ + if not tag_infos: + raise Exception("No tag infos provided to generate commit message") + + info = tag_infos[0] + # Legacy mode for SDKs without per service packaging + if not info.package.name: + if len(tag_infos) > 1: + raise Exception("Multiple packages found in legacy mode") + return f"[Release] Release v{info.version}\n\n{info.content}" + + # Sort tag_infos by package name for consistency. + tag_infos.sort(key=lambda info: info.package.name) + titles = ", ".join(f"{info.package.name}/v{info.version}" for info in tag_infos) + body = "\n\n".join(f"## {info.package.name}/v{info.version}\n\n{info.content}" for info in tag_infos) + return f"[Release] {titles}\n\n{body}" + + +def push_changes(tag_infos: List[TagInfo]) -> None: + """Pushes changes to the remote repository after handling possible merge conflicts.""" + + commit_message = generate_commit_message(tag_infos) + + # Create the release metadata file + file_name = os.path.join(os.getcwd(), ".release_metadata.json") + metadata = {"timestamp": datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S%z")} + content = json.dumps(metadata, indent=4) + gh.add_file(file_name, content) + + gh.commit_and_push(commit_message) + + +def reset_repository(hash: Optional[str] = None) -> None: + """ + Reset git to the specified commit. Defaults to HEAD. + + :param hash: The commit hash to reset to. If None, it resets to HEAD. + """ + # Fetch the latest changes from the remote repository. + subprocess.run(["git", "fetch"]) + + # Determine the commit hash (default to origin/main if none is provided). + commit_hash = hash or "origin/main" + + # ``git reset --hard`` must land before ``gh.reset(None)``, since + # ``gh.reset(None)`` reads ``git rev-parse HEAD`` to anchor + # ``self.sha`` to the local working tree. + subprocess.run(["git", "reset", "--hard", commit_hash], check=True) + gh.reset(hash) + + +def retry_function( + func: Callable[[], List[TagInfo]], cleanup: Callable[[], None], max_attempts: int = 5, delay: int = 5 +) -> List[TagInfo]: + """ + Calls a function call up to `max_attempts` times if an exception occurs. + + :param func: The function to call. + :param cleanup: Cleanup function in between retries + :param max_attempts: The maximum number of retries. + :param delay: The delay between retries in seconds. + :return: The return value of the function, or None if all retries fail. + """ + attempts = 0 + while attempts <= max_attempts: + try: + return func() # Call the function + except MainAdvancedError: + # Permanent failure: another commit landed on main during + # this run, so the local tree is stale. Retrying with the + # same stale tree would just hit the same mismatch — only + # a fresh workflow run against the new main can recover. + raise + except Exception as e: + attempts += 1 + print(f"Attempt {attempts} failed: {e}") + if attempts < max_attempts: + time.sleep(delay) # Wait before retrying + cleanup() + else: + print("All retry attempts failed.") + raise e # Re-raise the exception after max retries + + +def update_changelogs(selected_packages: List[Package], all_packages: List[Package]) -> List[TagInfo]: + """ + Updates changelogs and pushes the commits. + + ``selected_packages`` are the packages whose ``NEXT_CHANGELOG.md`` is + consulted to decide what gets released this run. ``all_packages`` is + the full repo inventory used for cross-package dep rewrites. + + The freshness check is deliberately *not* called here. ``process`` + runs it before entering the retry loop so a freshness violation + fails fast — the check is deterministic against the same git state, + so wrapping it in retry would just delay the same failure five + times. + """ + tag_infos = [info for info in (process_package(package) for package in selected_packages) if info is not None] + # If any package was changed, stage version updates and push. + if tag_infos: + stage_version_updates(tag_infos, all_packages) + push_changes(tag_infos) + return tag_infos + + +def preview_tag_infos(packages: List[Package]) -> List[TagInfo]: + """ + Read-only sibling of ``process_package``: returns the TagInfos that + would be released for ``packages`` without writing any changelog + edits. ``process`` calls this before the retry loop so the freshness + check has a snapshot to validate against. ``process_package`` will + re-derive the same TagInfos when ``update_changelogs`` runs; the + duplication is just a couple of NEXT_CHANGELOG.md reads. + """ + return [info for info in (get_next_tag_info(package) for package in packages) if info is not None] + + +def order_tag_infos_by_dependency(tag_infos: List[TagInfo]) -> List[TagInfo]: + """ + Returns ``tag_infos`` in topological order: every package appears + after every sibling it depends on. + """ + if not tag_infos: + return list(tag_infos) + + if any(not info.package.name for info in tag_infos) and len(tag_infos) > 1: + raise Exception("Multiple packages found in legacy mode") + + package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) + with open(package_file_path, "r") as file: + codegen = json.load(file) + + name_template = codegen.get("dependency_name_template", "") + dep_patterns = codegen.get("dependency_pattern", {}) + if not name_template or not dep_patterns: + return list(tag_infos) + + by_dep_name: Dict[str, TagInfo] = { + name_template.replace("$PACKAGE", info.package.name): info for info in tag_infos if info.package.name + } + + # Adjacency: path -> set of paths it depends on (within tag_infos). + deps: Dict[str, set] = {info.package.path: set() for info in tag_infos} + for info in tag_infos: + for filename, pattern in dep_patterns.items(): + loc = os.path.join(os.getcwd(), info.package.path, filename) + if not os.path.exists(loc): + continue + with open(loc, "r") as f: + content = f.read() + for dep_name, dep_info in by_dep_name.items(): + if dep_info.package.path == info.package.path: + continue + regex = ( + re.escape(pattern) + .replace(re.escape("$DEPENDENCY"), re.escape(dep_name)) + .replace(re.escape("$VERSION"), Version.PATTERN) + ) + if re.search(regex, content): + deps[info.package.path].add(dep_info.package.path) + + # Stable topological sort: at each step, emit every node whose deps + # are already emitted, alphabetically by package name. Ties broken + # alphabetically so the manifest is reproducible across runs. + emitted: set = set() + ordered: List[TagInfo] = [] + while len(ordered) < len(tag_infos): + ready = sorted( + ( + info + for info in tag_infos + if info.package.path not in emitted and deps[info.package.path].issubset(emitted) + ), + key=lambda info: info.package.name, + ) + if not ready: + remaining = [info.package.name for info in tag_infos if info.package.path not in emitted] + raise Exception(f"Cyclic dependency detected among packages: {remaining}") + for info in ready: + ordered.append(info) + emitted.add(info.package.path) + return ordered + + +def push_tags(tag_infos: List[TagInfo]) -> None: + """ + Creates and pushes tags to the repository. + + Tags are emitted in topological order — dependencies before + dependents — so downstream publishing pipelines reading + ``created_tags.json`` can walk it sequentially without re-deriving + the dependency graph. See ``order_tag_infos_by_dependency``. + + As a side effect, writes the names of successfully created tags to + ``./created_tags.json`` so that workflows triggering this script can + discover what was produced (the GitHub Actions workflow uploads this + file as the ``created-tags`` artifact). + + Schema: + {"tags": ["service-a/v1.2.3", "service-b/v0.4.0"]} + + The manifest is written even if tag creation fails partway through: + tags that succeeded before the failure are flushed before the + exception is re-raised, so recovery-mode runs still surface their + output. + """ + tag_infos = order_tag_infos_by_dependency(tag_infos) + created: List[str] = [] + try: + for tag_info in tag_infos: + gh.tag(tag_info.tag_name(), tag_info.content) + created.append(tag_info.tag_name()) + finally: + manifest_path = os.path.join(os.getcwd(), CREATED_TAGS_FILE_NAME) + with open(manifest_path, "w") as f: + json.dump({"tags": created}, f) + + +def run_command(command: List[str]) -> str: + """ + Runs a command and returns the output + """ + output = subprocess.check_output(command) + print(f'Running command: {" ".join(command)}') + return output.decode() + + +def pull_last_release_commit() -> None: + """ + Reset the repository to the last release. + Uses commit for last change to .release_metadata.json, since it's only updated on releases. + """ + commit_hash = subprocess.check_output( + ["git", "log", "-n", "1", "--format=%H", "--", ".release_metadata.json"], text=True + ).strip() + + # If no commit is found, raise an exception + if not commit_hash: + raise ValueError("No commit found for .release_metadata.json") + + # Reset the repository to the commit + reset_repository(commit_hash) + + +def get_packages_from_args() -> List[str]: + """ + Retrieves the list of packages to tag. + + python3 ./tagging.py --package # single package + python3 ./tagging.py --package , # multiple packages + + Returns an empty list when --package is omitted, which means all packages + with pending releases will be tagged. + """ + parser = argparse.ArgumentParser(description="Update changelogs and tag the release.") + parser.add_argument( + "--package", + "-p", + type=str, + default="", + help="Comma-separated list of packages to tag. Leave empty to tag all packages with pending releases.", + ) + args = parser.parse_args() + return [name.strip() for name in args.package.split(",") if name.strip()] + + +def init_github(): + token = os.environ["GITHUB_TOKEN"] + repo_name = os.environ["GITHUB_REPOSITORY"] + g = Github(token) + repo = g.get_repo(repo_name) + global gh + gh = GitHubRepo(repo) + + +def process(): + """ + Main entry point for tagging process. + + Tagging process consist of multiple steps: + * For each package, update the corresponding CHANGELOG.md file based on the contents of NEXT_CHANGELOG.md file + * If any package has been updated, commit and push the changes. + * Apply and push the new tags matching the version. + + If a specific pagkage is provided as a parameter, only that package will be tagged. + + If any tag are pending from an early process, it will skip updating the CHANGELOG.md files and only apply the tags. + """ + + package_names = get_packages_from_args() + pending_tags = find_pending_tags() + + # pending_tags is non-empty only when the tagging process previously failed or interrupted. + # We must complete the interrupted tagging process before starting a new one to avoid inconsistent states and missing changelog entries. + # Therefore, we don't support specifying packages until the previously started process has been successfully completed. + if pending_tags and package_names: + pending_packages = [tag.package.name for tag in pending_tags] + raise Exception(f"Cannot release packages {package_names}. Pending release for {pending_packages}") + + if pending_tags: + print("Found pending tags from previous executions, entering recovery mode.") + pull_last_release_commit() + push_tags(pending_tags) + return + + all_packages = find_packages() + # If packages are specified as an argument, only release those — but + # dep rewrites and the freshness check still operate over the full + # set. + selected_packages = all_packages + if package_names: + selected_packages = [package for package in all_packages if package.name in package_names] + + # Run the freshness check against a read-only preview before the + # retry loop, since the check is deterministic. A freshness + # violation fails the run immediately, with no commits, no tags, no + # retry storm. + check_dependency_freshness(preview_tag_infos(selected_packages), all_packages) + + pending_tags = retry_function( + func=lambda: update_changelogs(selected_packages, all_packages), + cleanup=reset_repository, + ) + push_tags(pending_tags) + + +def validate_git_root(): + """ + Validate that the script is run from the root of the repository. + """ + repo_root = subprocess.check_output(["git", "rev-parse", "--show-toplevel"]).strip().decode("utf-8") + current_dir = subprocess.check_output(["pwd"]).strip().decode("utf-8") + if repo_root != current_dir: + raise Exception("Please run this script from the root of the repository.") if __name__ == "__main__": - install_nextchanges() - tagging.validate_git_root() - tagging.init_github() - tagging.process() + validate_git_root() + init_github() + process() diff --git a/internal/genkit/tagging_upstream.py b/internal/genkit/tagging_upstream.py deleted file mode 100755 index a3897bbeedf..00000000000 --- a/internal/genkit/tagging_upstream.py +++ /dev/null @@ -1,1057 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# dependencies = ["PyGithub>=2,<3", "pyjwt<2.12.0", "charset-normalizer<3.4.6"] -# /// - -import os -import re -import argparse -from typing import Optional, List, Callable, Dict -from dataclasses import dataclass, replace -import subprocess -import time -import json -from github import Github, Repository, InputGitTreeElement, InputGitAuthor -from datetime import datetime, timezone - -NEXT_CHANGELOG_FILE_NAME = "NEXT_CHANGELOG.md" -CHANGELOG_FILE_NAME = "CHANGELOG.md" -PACKAGE_FILE_NAME = ".package.json" -CODEGEN_FILE_NAME = ".codegen.json" -CREATED_TAGS_FILE_NAME = "created_tags.json" -""" -This script tags the release of the SDKs using a combination of the GitHub API and Git commands. -It reads the local repository to determine necessary changes, updates changelogs, and creates tags. - -### How it Works: -- It does **not** modify the local repository directly. -- Instead of committing and pushing changes locally, it uses the **GitHub API** to create commits and tags. -""" - - -@dataclass(frozen=True) -class Version: - """ - A semver 2.0.0-compliant version (https://semver.org). - - Mirrors the API of the `semver` PyPI package so this implementation can be - swapped for that library if it is ever added to the wheelhouse. Supports - parsing, stringification, and the two bumps we need: minor (for stable - releases) and prerelease (for release trains). - """ - - # Permissive pattern for locating a semver version string inside larger - # text (e.g. a changelog header). Callers use it in f-strings; strict - # validation happens via Version.parse. - PATTERN = r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?" - - # Strict anchored regex per https://semver.org. Rejects leading zeros in - # numeric identifiers and invalid pre-release/build identifier charsets. - _PARSE_REGEX = re.compile( - r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" - r"(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)" - r"(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?" - r"(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" - ) - - major: int - minor: int - patch: int - prerelease: str = "" - build: str = "" - - @classmethod - def parse(cls, text: str) -> "Version": - """Parse a semver string, raising ValueError on malformed input.""" - match = cls._PARSE_REGEX.match(text) - if not match: - raise ValueError(f"Invalid semver version: {text!r}") - major, minor, patch, prerelease, build = match.groups() - return cls( - major=int(major), - minor=int(minor), - patch=int(patch), - prerelease=prerelease or "", - build=build or "", - ) - - def __str__(self) -> str: - result = f"{self.major}.{self.minor}.{self.patch}" - if self.prerelease: - result += f"-{self.prerelease}" - if self.build: - result += f"+{self.build}" - return result - - def bump_minor(self) -> "Version": - """ - Bump the minor version and reset patch. - - Per semver item 9, a pre-release version has lower precedence than - the same MAJOR.MINOR.PATCH, so bumping to a new minor drops any - pre-release and build metadata. - """ - return Version(major=self.major, minor=self.minor + 1, patch=0) - - def bump_prerelease(self) -> "Version": - """ - Increment the rightmost numeric identifier in the pre-release. - - Matches the npm `prerelease` bump semantics: - 0.0.0-alpha.1 -> 0.0.0-alpha.2 - 0.0.0-alpha -> 0.0.0-alpha.1 - 0.0.0-rc.1.2 -> 0.0.0-rc.1.3 - - Raises ValueError if the version has no pre-release to bump. - Build metadata is dropped since it does not affect precedence. - """ - if not self.prerelease: - raise ValueError(f"Cannot bump prerelease of {self}: no pre-release component") - parts = self.prerelease.split(".") - for i in range(len(parts) - 1, -1, -1): - if parts[i].isdigit(): - parts[i] = str(int(parts[i]) + 1) - return replace(self, prerelease=".".join(parts), build="") - # No numeric identifier exists; append ".1" to start a counter. - return replace(self, prerelease=f"{self.prerelease}.1", build="") - - def next_release_version(self) -> "Version": - """ - Default next version for the changelog after this one is released. - - If on a pre-release track, stay on it by bumping the pre-release - identifier (npm convention). Otherwise, bump the minor version, - the script's historical default for stable releases. Teams can - override the default in the release PR. - """ - if self.prerelease: - return self.bump_prerelease() - return self.bump_minor() - - -def _read_local_head_sha() -> str: - """ - Returns the SHA of the local working tree's HEAD via ``git rev-parse``. - """ - return subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip() - - -class MainAdvancedError(Exception): - """ - Raised when ``origin/main`` has advanced since the workflow's - checkout — i.e., another commit landed during this run. The local - working tree is now stale, so any commit produced from it would - silently revert whatever the concurrent push added. - """ - - -# GitHub does not support signing commits for GitHub Apps directly. -# This class replaces usages for git commands such as "git add", "git commit", and "git push". -@dataclass -class GitHubRepo: - def __init__(self, repo: Repository): - self.repo = repo - self.changed_files: list[InputGitTreeElement] = [] - self.ref = "heads/main" - # Anchor ``self.sha`` to the **local checkout** rather than a - # live API call. ``actions/checkout`` populates the working tree - # at this SHA, and every subsequent file read in this run is - # against that tree; the API HEAD is only relevant when we go - # to push. - self.sha = _read_local_head_sha() - - # Replaces "git add file" - def add_file(self, loc: str, content: str): - local_path = os.path.relpath(loc, os.getcwd()) - print(f"Adding file {local_path}") - blob = self.repo.create_git_blob(content=content, encoding="utf-8") - element = InputGitTreeElement(path=local_path, mode="100644", type="blob", sha=blob.sha) - self.changed_files.append(element) - - # Replaces "git commit && git push" - def commit_and_push(self, message: str): - head_ref = self.repo.get_git_ref(self.ref) - if head_ref.object.sha != self.sha: - raise MainAdvancedError( - f"origin/main advanced from {self.sha} to {head_ref.object.sha} " - f"during this run. Local working tree is stale; aborting before " - f"the commit would silently revert the new content. Re-run the " - f"workflow." - ) - base_tree = self.repo.get_git_tree(sha=head_ref.object.sha) - new_tree = self.repo.create_git_tree(self.changed_files, base_tree) - parent_commit = self.repo.get_git_commit(head_ref.object.sha) - - new_commit = self.repo.create_git_commit(message=message, tree=new_tree, parents=[parent_commit]) - # Update branch reference. - head_ref.edit(new_commit.sha) - self.sha = new_commit.sha - - def reset(self, sha: Optional[str] = None): - self.changed_files = [] - if sha: - self.sha = sha - else: - self.sha = _read_local_head_sha() - - def tag(self, tag_name: str, tag_message: str): - # Create a tag pointing to the new commit - # The email MUST be the GitHub Apps email. - # Otherwise, the tag will not be verified. - tagger = InputGitAuthor( - name="Databricks SDK Release Bot", email="DECO-SDK-Tagging[bot]@users.noreply.github.com" - ) - - tag = self.repo.create_git_tag(tag=tag_name, message=tag_message, object=self.sha, type="commit", tagger=tagger) - # Create a Git ref (the actual reference for the tag in the repo) - self.repo.create_git_ref(ref=f"refs/tags/{tag_name}", sha=tag.sha) - - -gh: Optional[GitHubRepo] = None - - -@dataclass -class Package: - """ - Represents a package in the repository. - :name: The package name. - :path: The path to the package relative to the repository root. - """ - - name: str - path: str - - -@dataclass -class TagInfo: - """ - Represents all changes on a release. - :package: package info. - :version: release version for the package. Format: v.. - :content: changes for the release, as they appear in the changelog. - When written to CHANGELOG.md, the current date (YYYY-MM-DD) is automatically added. - - Example (from NEXT_CHANGELOG.md): - - ## Release v0.56.0 - - ### New Features and Improvements - * Feature - * Some improvement - - ### Bug Fixes - * Bug fix - - ### Documentation - * Doc Changes - - ### Internal Changes - * More Changes - - ### API Changes - * Add new Service - - Note: When written to CHANGELOG.md, the header becomes: ## Release v0.56.0 (YYYY-MM-DD) - - """ - - package: Package - version: str - content: str - - def tag_name(self) -> str: - return f"{self.package.name}/v{self.version}" if self.package.name else f"v{self.version}" - - -def get_package_name(package_path: str) -> str: - """ - Returns the package name from the package path. - The name is found inside the .package.json file: - { - "package": "package_name" - } - """ - filepath = os.path.join(os.getcwd(), package_path, PACKAGE_FILE_NAME) - with open(filepath, "r") as file: - content = json.load(file) - if "package" in content: - return content["package"] - # Legacy SDKs have no packages. - return "" - - -def stage_version_updates(tag_infos: List[TagInfo], packages: List[Package]) -> None: - """ - Stages all version-related edits for the release in a single pass over - every package the workspace already opts in via ``.package.json``. - """ - - # Load patterns from '.codegen.json' at the top level of the repository. - package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) - with open(package_file_path, "r") as file: - codegen = json.load(file) - - version_patterns = codegen.get("version", {}) - dep_patterns = codegen.get("dependency_pattern", {}) - name_template = codegen.get("dependency_name_template", "") - - if not version_patterns and not dep_patterns: - print("Neither `version` nor `dependency_pattern` found in .codegen.json. Nothing to update.") - return - - bumped_by_dir: Dict[str, TagInfo] = {info.package.path: info for info in tag_infos} - new_dep_versions = compute_dependency_rewrites(tag_infos, name_template) - - files = sorted(set(version_patterns.keys()) | set(dep_patterns.keys())) - - for pkg in packages: - for filename in files: - loc = os.path.join(os.getcwd(), pkg.path, filename) - - with open(loc, "r") as file: - content = file.read() - original = content - - # Own version (only when this package is being released and the - # file has a version pattern declared). - info = bumped_by_dir.get(pkg.path) - if info is not None and filename in version_patterns: - pattern = version_patterns[filename] - previous_version = pattern.replace("$VERSION", Version.PATTERN) - new_version = pattern.replace("$VERSION", info.version) - content = re.sub(previous_version, new_version, content) - - # Sibling dependency rewrites (only when the file has a - # dependency pattern and there is at least one bumped sibling). - if filename in dep_patterns and new_dep_versions: - content = rewrite_dependencies(content, dep_patterns[filename], new_dep_versions) - - if content != original: - gh.add_file(loc, content) - - -def compute_dependency_rewrites( - tag_infos: List[TagInfo], - name_template: str, -) -> Dict[str, str]: - """ - Returns a map of dependency-name to the new semver string for each - bumped package. - """ - if not name_template: - return {} - rewrites: Dict[str, str] = {} - for info in tag_infos: - # Skip legacy releases that don't have a per-package name; their - # tag_info has an empty package.name and they can't be referenced - # as a sibling dep anyway. - if not info.package.name: - continue - dep_name = name_template.replace("$PACKAGE", info.package.name) - rewrites[dep_name] = info.version - return rewrites - - -def rewrite_dependencies(content: str, pattern: str, new_versions: Dict[str, str]) -> str: - """ - Apply ``pattern`` (with ``$DEPENDENCY`` and ``$VERSION`` placeholders) to - rewrite every entry in ``content`` whose dependency name appears in - ``new_versions``. - """ - # Sentinel strings used to protect the placeholders through re.escape: - # we substitute them in, escape the whole template, then swap them out - # for the dep-name literal and Version.PATTERN. Control characters so - # they can't collide with anything in real .codegen.json patterns. - dep_sentinel = "\x01DEPENDENCY\x01" - ver_sentinel = "\x01VERSION\x01" - - for dep_name, new_value in new_versions.items(): - regex = pattern.replace("$DEPENDENCY", dep_sentinel).replace("$VERSION", ver_sentinel) - regex = re.escape(regex) - regex = regex.replace(re.escape(dep_sentinel), re.escape(dep_name)) - regex = regex.replace(re.escape(ver_sentinel), Version.PATTERN) - - # Build the literal replacement text by substituting the same - # placeholders directly. A lambda is used instead of a string to - # avoid re.sub interpreting \1, \g<...>, etc. inside the value. - replacement_text = pattern.replace("$DEPENDENCY", dep_name).replace("$VERSION", new_value) - content = re.sub(regex, lambda _m, text=replacement_text: text, content) - return content - - -def clean_next_changelog(package_path: str) -> None: - """ - Cleans the "NEXT_CHANGELOG.md" file. It performs 2 operations: - * Increase the version to the next minor version. - * Remove release notes. Sections names are kept to - keep consistency in the section names between releases. - """ - - file_path = os.path.join(os.getcwd(), package_path, NEXT_CHANGELOG_FILE_NAME) - with open(file_path, "r") as file: - content = file.read() - - # Remove content between ### sections. - cleaned_content = re.sub(r"(### [^\n]+\n)(?:.*?\n?)*?(?=###|$)", r"\1", content) - # Ensure there is exactly one empty line before each section. - cleaned_content = re.sub(r"(\n*)(###[^\n]+)", r"\n\n\2", cleaned_content) - # Find the version number and compute the default next release version. - # Teams can adjust the version in the PR if the default is not desired. - # For stable versions, bump minor (historical default since minor releases - # are more common than patch or major). For pre-release versions, stay on - # the same track by bumping the pre-release identifier (npm convention). - version_match = re.search(rf"Release v({Version.PATTERN})", cleaned_content) - if not version_match: - raise Exception("Version not found in the changelog") - current = Version.parse(version_match.group(1)) - new_header = f"Release v{current.next_release_version()}" - cleaned_content = cleaned_content.replace(version_match.group(0), new_header) - - # Update file with cleaned content - gh.add_file(file_path, cleaned_content) - - -def get_previous_tag_info(package: Package) -> Optional[TagInfo]: - """ - Extracts the previous tag info from the "CHANGELOG.md" file. - Used for failure recovery purposes. - """ - changelog_path = os.path.join(os.getcwd(), package.path, CHANGELOG_FILE_NAME) - - with open(changelog_path, "r") as f: - changelog = f.read() - - # Extract the latest release section using regex. - match = re.search( - rf"## (\[Release\] )?Release v{Version.PATTERN}.*?(?=\n## (\[Release\] )?Release v|\Z)", - changelog, - re.S, - ) - - # E.g., for new packages. - if not match: - return None - - latest_release = match.group(0) - version_match = re.search(rf"## (\[Release\] )?Release v({Version.PATTERN})", latest_release) - - if not version_match: - raise Exception("Version not found in the changelog") - - # Validate the extracted string is spec-compliant; fail loudly otherwise. - version = str(Version.parse(version_match.group(2))) - return TagInfo(package=package, version=version, content=latest_release) - - -def _load_codegen_config() -> Dict: - """ - Loads ``.codegen.json`` from the repo root. Returns an empty dict when - the file is missing. - """ - package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) - if not os.path.exists(package_file_path): - return {} - with open(package_file_path, "r") as file: - return json.load(file) - - -def get_next_tag_info(package: Package) -> Optional[TagInfo]: - """ - Extracts the changes from the "NEXT_CHANGELOG.md" file. - The result is already processed. - """ - next_changelog_path = os.path.join(os.getcwd(), package.path, NEXT_CHANGELOG_FILE_NAME) - # Read NEXT_CHANGELOG.md - with open(next_changelog_path, "r") as f: - next_changelog = f.read() - - # Remove "# NEXT CHANGELOG" line - next_changelog = re.sub(r"^# NEXT CHANGELOG(\n+)", "", next_changelog, flags=re.MULTILINE) - - # Remove empty sections - next_changelog = re.sub(r"###[^\n]+\n+(?=##|\Z)", "", next_changelog) - # Ensure there is exactly one empty line before each section - next_changelog = re.sub(r"(\n*)(###[^\n]+)", r"\n\n\2", next_changelog) - - # By default, packages whose NEXT_CHANGELOG.md has no populated - # sections are skipped — there's nothing meaningful to release. - # Repos like sdk-js which are still in development can opt in - # by setting ``allow_empty_changelog: true`` in .codegen.json. - if not re.search(r"###", next_changelog) and not _load_codegen_config().get("allow_empty_changelog", False): - print("All sections are empty. No changes will be made to the changelog.") - return None - - version_match = re.search(rf"## Release v({Version.PATTERN})", next_changelog) - - if not version_match: - raise Exception("Version not found in the changelog") - - # Validate the extracted string is spec-compliant; fail loudly otherwise. - version = str(Version.parse(version_match.group(1))) - return TagInfo(package=package, version=version, content=next_changelog) - - -def write_changelog(tag_info: TagInfo) -> None: - """ - Updates the changelog with a new tag info. - """ - changelog_path = os.path.join(os.getcwd(), tag_info.package.path, CHANGELOG_FILE_NAME) - with open(changelog_path, "r") as f: - changelog = f.read() - - # Add current date to the release header. - current_date = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") - content_with_date = re.sub( - rf"## Release v({Version.PATTERN})", - rf"## Release v\1 ({current_date})", - tag_info.content.strip(), - ) - - updated_changelog = re.sub(r"(# Version changelog\n\n)", f"\\1{content_with_date}\n\n\n", changelog) - gh.add_file(changelog_path, updated_changelog) - - -def process_package(package: Package) -> TagInfo: - """ - Processes a package's changelog scaffolding for the release. - """ - print(f"Processing package {package.name}") - tag_info = get_next_tag_info(package) - - # If there are no updates, skip. - if tag_info is None: - return - - write_changelog(tag_info) - clean_next_changelog(package.path) - return tag_info - - -def find_packages() -> List[Package]: - """ - Returns all directories which contains a ".package.json" file. - """ - paths = _find_directories_with_file(PACKAGE_FILE_NAME) - return [Package(name=get_package_name(path), path=path) for path in paths] - - -def _find_directories_with_file(target_file: str) -> List[str]: - root_path = os.getcwd() - matching_directories = [] - - for dirpath, _, filenames in os.walk(root_path): - if target_file in filenames: - path = os.path.relpath(dirpath, root_path) - # If the path is the root directory (e.g., SDK V0), set it to an empty string. - if path == ".": - path = "" - matching_directories.append(path) - - return matching_directories - - -def is_tag_applied(tag: TagInfo) -> bool: - """ - Returns whether a tag is already applied in the repository. - - :param tag: The tag to check. - :return: True if the tag is applied, False otherwise. - :raises Exception: If the git command fails. - """ - try: - # Check if the specific tag exists - result = subprocess.check_output(["git", "tag", "--list", tag.tag_name()], stderr=subprocess.PIPE, text=True) - return result.strip() == tag.tag_name() - except subprocess.CalledProcessError as e: - # Raise a exception for git command errors - raise Exception(f"Git command failed: {e.stderr.strip() or e}") from e - - -def find_last_release_tag(package: Package) -> Optional[str]: - """ - Returns the most recent ``/v*`` tag in the repository, or - ``None`` if no such tag exists. Tags are sorted by semver ordering - (``--sort=-v:refname``) so pre-releases sort below their stable - counterparts. - - :raises Exception: If the git command fails. - """ - pattern = f"{package.name}/v*" if package.name else "v*" - try: - output = subprocess.check_output( - ["git", "tag", "--list", pattern, "--sort=-v:refname"], - stderr=subprocess.PIPE, - text=True, - ).strip() - except subprocess.CalledProcessError as e: - raise Exception(f"Git command failed: {e.stderr.strip() or e}") from e - if not output: - return None - return output.split("\n")[0].strip() - - -def has_commits_since_tag(tag: str, path: str) -> bool: - """ - Returns True iff at least one commit reachable from HEAD but not from - ``tag`` touches ``path``. Used to detect that a sibling dependency has - unreleased changes that would ship stale if we tagged a dependent - without re-tagging the dependency. - - :raises Exception: If the git command fails. - """ - args = ["git", "log", "--oneline", f"{tag}..HEAD", "--", path or "."] - try: - output = subprocess.check_output(args, stderr=subprocess.PIPE, text=True).strip() - except subprocess.CalledProcessError as e: - raise Exception(f"Git command failed: {e.stderr.strip() or e}") from e - return bool(output) - - -def check_dependency_freshness(tag_infos: List[TagInfo], all_packages: List[Package]) -> None: - """ - Hard-fails when a package being released depends on a sibling package - that has unreleased commits since its last tag. - - Why: dependency rewrites (``stage_version_updates``) only fire for - siblings that are *also* being released. Without this check, releasing - package_a alone — when package_b has commits since its last tag — - publishes ``package_a@new`` pinning the *old* package_b artifact, which - won't have the changes package_a's source depends on. The check is - commit-based (not changelog-based) so a missing ``NEXT_CHANGELOG.md`` - entry on package_b is still caught. - - No-op when ``.codegen.json`` declares no dependency pattern (legacy - SDKs without per-package wiring). - """ - if not tag_infos: - return - - package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) - with open(package_file_path, "r") as file: - codegen = json.load(file) - - name_template = codegen.get("dependency_name_template", "") - dep_patterns = codegen.get("dependency_pattern", {}) - if not name_template or not dep_patterns: - return - - releasing_paths = {info.package.path for info in tag_infos} - by_dep_name: Dict[str, Package] = {} - for pkg in all_packages: - if not pkg.name: - continue - by_dep_name[name_template.replace("$PACKAGE", pkg.name)] = pkg - - issues: List[str] = [] - for info in tag_infos: - for filename, pattern in dep_patterns.items(): - loc = os.path.join(os.getcwd(), info.package.path, filename) - if not os.path.exists(loc): - continue - with open(loc, "r") as f: - content = f.read() - - for dep_name, dep_pkg in by_dep_name.items(): - if dep_pkg.path == info.package.path: - continue - if dep_pkg.path in releasing_paths: - continue - - # Same regex construction used by ``rewrite_dependencies``, - # so "is this dep referenced?" matches "would the rewrite - # touch it?". Keeps the two in lockstep. - regex = ( - re.escape(pattern) - .replace(re.escape("$DEPENDENCY"), re.escape(dep_name)) - .replace(re.escape("$VERSION"), Version.PATTERN) - ) - if not re.search(regex, content): - continue - - last_tag = find_last_release_tag(dep_pkg) - if last_tag is None: - # No prior tag means the dep was never released; we - # can't reason about staleness. Surface it anyway so - # the human resolves it explicitly. - issues.append( - f"{info.package.name} depends on {dep_pkg.name}, " - f"which has never been released. Release " - f"{dep_pkg.name} first or include it in this run." - ) - continue - if has_commits_since_tag(last_tag, dep_pkg.path): - issues.append( - f"{info.package.name} depends on {dep_pkg.name}, " - f"which has commits since {last_tag} but is not " - f"being released. Either release {dep_pkg.name} " - f"as well, or hold this release until its changes " - f"are reverted." - ) - - if issues: - raise Exception("Dependency freshness check failed:\n - " + "\n - ".join(issues)) - - -def find_last_tags() -> List[TagInfo]: - """ - Finds the last tags for each package. - - Returns a list of TagInfo objects for each package with a non-None changelog. - """ - packages = find_packages() - - return [info for info in (get_previous_tag_info(package) for package in packages) if info is not None] - - -def find_pending_tags() -> List[TagInfo]: - """ - Finds all tags that are pending to be applied. - """ - tag_infos = find_last_tags() - return [tag for tag in tag_infos if not is_tag_applied(tag)] - - -def generate_commit_message(tag_infos: List[TagInfo]) -> str: - """ - Generates a commit message for the release. - """ - if not tag_infos: - raise Exception("No tag infos provided to generate commit message") - - info = tag_infos[0] - # Legacy mode for SDKs without per service packaging - if not info.package.name: - if len(tag_infos) > 1: - raise Exception("Multiple packages found in legacy mode") - return f"[Release] Release v{info.version}\n\n{info.content}" - - # Sort tag_infos by package name for consistency. - tag_infos.sort(key=lambda info: info.package.name) - titles = ", ".join(f"{info.package.name}/v{info.version}" for info in tag_infos) - body = "\n\n".join(f"## {info.package.name}/v{info.version}\n\n{info.content}" for info in tag_infos) - return f"[Release] {titles}\n\n{body}" - - -def push_changes(tag_infos: List[TagInfo]) -> None: - """Pushes changes to the remote repository after handling possible merge conflicts.""" - - commit_message = generate_commit_message(tag_infos) - - # Create the release metadata file - file_name = os.path.join(os.getcwd(), ".release_metadata.json") - metadata = {"timestamp": datetime.now(tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S%z")} - content = json.dumps(metadata, indent=4) - gh.add_file(file_name, content) - - gh.commit_and_push(commit_message) - - -def reset_repository(hash: Optional[str] = None) -> None: - """ - Reset git to the specified commit. Defaults to HEAD. - - :param hash: The commit hash to reset to. If None, it resets to HEAD. - """ - # Fetch the latest changes from the remote repository. - subprocess.run(["git", "fetch"]) - - # Determine the commit hash (default to origin/main if none is provided). - commit_hash = hash or "origin/main" - - # ``git reset --hard`` must land before ``gh.reset(None)``, since - # ``gh.reset(None)`` reads ``git rev-parse HEAD`` to anchor - # ``self.sha`` to the local working tree. - subprocess.run(["git", "reset", "--hard", commit_hash], check=True) - gh.reset(hash) - - -def retry_function( - func: Callable[[], List[TagInfo]], cleanup: Callable[[], None], max_attempts: int = 5, delay: int = 5 -) -> List[TagInfo]: - """ - Calls a function call up to `max_attempts` times if an exception occurs. - - :param func: The function to call. - :param cleanup: Cleanup function in between retries - :param max_attempts: The maximum number of retries. - :param delay: The delay between retries in seconds. - :return: The return value of the function, or None if all retries fail. - """ - attempts = 0 - while attempts <= max_attempts: - try: - return func() # Call the function - except MainAdvancedError: - # Permanent failure: another commit landed on main during - # this run, so the local tree is stale. Retrying with the - # same stale tree would just hit the same mismatch — only - # a fresh workflow run against the new main can recover. - raise - except Exception as e: - attempts += 1 - print(f"Attempt {attempts} failed: {e}") - if attempts < max_attempts: - time.sleep(delay) # Wait before retrying - cleanup() - else: - print("All retry attempts failed.") - raise e # Re-raise the exception after max retries - - -def update_changelogs(selected_packages: List[Package], all_packages: List[Package]) -> List[TagInfo]: - """ - Updates changelogs and pushes the commits. - - ``selected_packages`` are the packages whose ``NEXT_CHANGELOG.md`` is - consulted to decide what gets released this run. ``all_packages`` is - the full repo inventory used for cross-package dep rewrites. - - The freshness check is deliberately *not* called here. ``process`` - runs it before entering the retry loop so a freshness violation - fails fast — the check is deterministic against the same git state, - so wrapping it in retry would just delay the same failure five - times. - """ - tag_infos = [info for info in (process_package(package) for package in selected_packages) if info is not None] - # If any package was changed, stage version updates and push. - if tag_infos: - stage_version_updates(tag_infos, all_packages) - push_changes(tag_infos) - return tag_infos - - -def preview_tag_infos(packages: List[Package]) -> List[TagInfo]: - """ - Read-only sibling of ``process_package``: returns the TagInfos that - would be released for ``packages`` without writing any changelog - edits. ``process`` calls this before the retry loop so the freshness - check has a snapshot to validate against. ``process_package`` will - re-derive the same TagInfos when ``update_changelogs`` runs; the - duplication is just a couple of NEXT_CHANGELOG.md reads. - """ - return [info for info in (get_next_tag_info(package) for package in packages) if info is not None] - - -def order_tag_infos_by_dependency(tag_infos: List[TagInfo]) -> List[TagInfo]: - """ - Returns ``tag_infos`` in topological order: every package appears - after every sibling it depends on. - """ - if not tag_infos: - return list(tag_infos) - - if any(not info.package.name for info in tag_infos) and len(tag_infos) > 1: - raise Exception("Multiple packages found in legacy mode") - - package_file_path = os.path.join(os.getcwd(), CODEGEN_FILE_NAME) - with open(package_file_path, "r") as file: - codegen = json.load(file) - - name_template = codegen.get("dependency_name_template", "") - dep_patterns = codegen.get("dependency_pattern", {}) - if not name_template or not dep_patterns: - return list(tag_infos) - - by_dep_name: Dict[str, TagInfo] = { - name_template.replace("$PACKAGE", info.package.name): info for info in tag_infos if info.package.name - } - - # Adjacency: path -> set of paths it depends on (within tag_infos). - deps: Dict[str, set] = {info.package.path: set() for info in tag_infos} - for info in tag_infos: - for filename, pattern in dep_patterns.items(): - loc = os.path.join(os.getcwd(), info.package.path, filename) - if not os.path.exists(loc): - continue - with open(loc, "r") as f: - content = f.read() - for dep_name, dep_info in by_dep_name.items(): - if dep_info.package.path == info.package.path: - continue - regex = ( - re.escape(pattern) - .replace(re.escape("$DEPENDENCY"), re.escape(dep_name)) - .replace(re.escape("$VERSION"), Version.PATTERN) - ) - if re.search(regex, content): - deps[info.package.path].add(dep_info.package.path) - - # Stable topological sort: at each step, emit every node whose deps - # are already emitted, alphabetically by package name. Ties broken - # alphabetically so the manifest is reproducible across runs. - emitted: set = set() - ordered: List[TagInfo] = [] - while len(ordered) < len(tag_infos): - ready = sorted( - ( - info - for info in tag_infos - if info.package.path not in emitted and deps[info.package.path].issubset(emitted) - ), - key=lambda info: info.package.name, - ) - if not ready: - remaining = [info.package.name for info in tag_infos if info.package.path not in emitted] - raise Exception(f"Cyclic dependency detected among packages: {remaining}") - for info in ready: - ordered.append(info) - emitted.add(info.package.path) - return ordered - - -def push_tags(tag_infos: List[TagInfo]) -> None: - """ - Creates and pushes tags to the repository. - - Tags are emitted in topological order — dependencies before - dependents — so downstream publishing pipelines reading - ``created_tags.json`` can walk it sequentially without re-deriving - the dependency graph. See ``order_tag_infos_by_dependency``. - - As a side effect, writes the names of successfully created tags to - ``./created_tags.json`` so that workflows triggering this script can - discover what was produced (the GitHub Actions workflow uploads this - file as the ``created-tags`` artifact). - - Schema: - {"tags": ["service-a/v1.2.3", "service-b/v0.4.0"]} - - The manifest is written even if tag creation fails partway through: - tags that succeeded before the failure are flushed before the - exception is re-raised, so recovery-mode runs still surface their - output. - """ - tag_infos = order_tag_infos_by_dependency(tag_infos) - created: List[str] = [] - try: - for tag_info in tag_infos: - gh.tag(tag_info.tag_name(), tag_info.content) - created.append(tag_info.tag_name()) - finally: - manifest_path = os.path.join(os.getcwd(), CREATED_TAGS_FILE_NAME) - with open(manifest_path, "w") as f: - json.dump({"tags": created}, f) - - -def run_command(command: List[str]) -> str: - """ - Runs a command and returns the output - """ - output = subprocess.check_output(command) - print(f'Running command: {" ".join(command)}') - return output.decode() - - -def pull_last_release_commit() -> None: - """ - Reset the repository to the last release. - Uses commit for last change to .release_metadata.json, since it's only updated on releases. - """ - commit_hash = subprocess.check_output( - ["git", "log", "-n", "1", "--format=%H", "--", ".release_metadata.json"], text=True - ).strip() - - # If no commit is found, raise an exception - if not commit_hash: - raise ValueError("No commit found for .release_metadata.json") - - # Reset the repository to the commit - reset_repository(commit_hash) - - -def get_packages_from_args() -> List[str]: - """ - Retrieves the list of packages to tag. - - python3 ./tagging.py --package # single package - python3 ./tagging.py --package , # multiple packages - - Returns an empty list when --package is omitted, which means all packages - with pending releases will be tagged. - """ - parser = argparse.ArgumentParser(description="Update changelogs and tag the release.") - parser.add_argument( - "--package", - "-p", - type=str, - default="", - help="Comma-separated list of packages to tag. Leave empty to tag all packages with pending releases.", - ) - args = parser.parse_args() - return [name.strip() for name in args.package.split(",") if name.strip()] - - -def init_github(): - token = os.environ["GITHUB_TOKEN"] - repo_name = os.environ["GITHUB_REPOSITORY"] - g = Github(token) - repo = g.get_repo(repo_name) - global gh - gh = GitHubRepo(repo) - - -def process(): - """ - Main entry point for tagging process. - - Tagging process consist of multiple steps: - * For each package, update the corresponding CHANGELOG.md file based on the contents of NEXT_CHANGELOG.md file - * If any package has been updated, commit and push the changes. - * Apply and push the new tags matching the version. - - If a specific pagkage is provided as a parameter, only that package will be tagged. - - If any tag are pending from an early process, it will skip updating the CHANGELOG.md files and only apply the tags. - """ - - package_names = get_packages_from_args() - pending_tags = find_pending_tags() - - # pending_tags is non-empty only when the tagging process previously failed or interrupted. - # We must complete the interrupted tagging process before starting a new one to avoid inconsistent states and missing changelog entries. - # Therefore, we don't support specifying packages until the previously started process has been successfully completed. - if pending_tags and package_names: - pending_packages = [tag.package.name for tag in pending_tags] - raise Exception(f"Cannot release packages {package_names}. Pending release for {pending_packages}") - - if pending_tags: - print("Found pending tags from previous executions, entering recovery mode.") - pull_last_release_commit() - push_tags(pending_tags) - return - - all_packages = find_packages() - # If packages are specified as an argument, only release those — but - # dep rewrites and the freshness check still operate over the full - # set. - selected_packages = all_packages - if package_names: - selected_packages = [package for package in all_packages if package.name in package_names] - - # Run the freshness check against a read-only preview before the - # retry loop, since the check is deterministic. A freshness - # violation fails the run immediately, with no commits, no tags, no - # retry storm. - check_dependency_freshness(preview_tag_infos(selected_packages), all_packages) - - pending_tags = retry_function( - func=lambda: update_changelogs(selected_packages, all_packages), - cleanup=reset_repository, - ) - push_tags(pending_tags) - - -def validate_git_root(): - """ - Validate that the script is run from the root of the repository. - """ - repo_root = subprocess.check_output(["git", "rev-parse", "--show-toplevel"]).strip().decode("utf-8") - current_dir = subprocess.check_output(["pwd"]).strip().decode("utf-8") - if repo_root != current_dir: - raise Exception("Please run this script from the root of the repository.") - - -if __name__ == "__main__": - validate_git_root() - init_github() - process() diff --git a/ruff.toml b/ruff.toml index 1e521a00fad..03f80efc543 100644 --- a/ruff.toml +++ b/ruff.toml @@ -3,16 +3,15 @@ line-length = 120 # Excluded from BOTH linting and formatting. These must be pruned during # directory traversal (not lint-only) because they either follow different rules -# entirely (tagging_upstream.py is synced from universe) or live under their own -# nested ruff config that a root-level lint exclude cannot reach: the -# instantiated `bundle init` projects (acceptance/**/output) carry their own -# pyproject [tool.ruff], and some testdata carries its own .ruff.toml. Formatting -# these is unnecessary anyway — they are generated outputs and -# intentionally-varied test inputs. extend-exclude (not exclude) keeps ruff's -# built-in defaults. The hand-maintained internal/genkit/tagging.py wrapper is -# linted normally. +# entirely (tagging.py is synced from universe) or live under their own nested +# ruff config that a root-level lint exclude cannot reach: the instantiated +# `bundle init` projects (acceptance/**/output) carry their own pyproject +# [tool.ruff], and some testdata carries its own .ruff.toml. Formatting these is +# unnecessary anyway — they are generated outputs and intentionally-varied test +# inputs. extend-exclude (not exclude) keeps ruff's built-in defaults. The +# hand-maintained internal/genkit/release_tagging.py wrapper is linted normally. extend-exclude = [ - "tagging_upstream.py", + "tagging.py", "acceptance/**/output", # default-sql carries its own .ruff.toml above output/, which re-roots # traversal and shields output/ from the acceptance/**/output prune above. From dbb13e79b520f39154ae017e446f4a5f5c7b9bba Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 16:57:52 +0200 Subject: [PATCH 13/31] Revert ruff.toml comment --- internal/genkit/tagging.py | 0 ruff.toml | 3 +-- 2 files changed, 1 insertion(+), 2 deletions(-) mode change 100755 => 100644 internal/genkit/tagging.py diff --git a/internal/genkit/tagging.py b/internal/genkit/tagging.py old mode 100755 new mode 100644 diff --git a/ruff.toml b/ruff.toml index 03f80efc543..db6ffa98604 100644 --- a/ruff.toml +++ b/ruff.toml @@ -8,8 +8,7 @@ line-length = 120 # `bundle init` projects (acceptance/**/output) carry their own pyproject # [tool.ruff], and some testdata carries its own .ruff.toml. Formatting these is # unnecessary anyway — they are generated outputs and intentionally-varied test -# inputs. extend-exclude (not exclude) keeps ruff's built-in defaults. The -# hand-maintained internal/genkit/release_tagging.py wrapper is linted normally. +# inputs. extend-exclude (not exclude) keeps ruff's built-in defaults. extend-exclude = [ "tagging.py", "acceptance/**/output", From 3a32f0a2897deaff44c667790248ae2f75339773 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 17:03:31 +0200 Subject: [PATCH 14/31] Revert tagging.py --- internal/genkit/tagging.py | 1 - 1 file changed, 1 deletion(-) diff --git a/internal/genkit/tagging.py b/internal/genkit/tagging.py index a3897bbeedf..0c8cd582296 100644 --- a/internal/genkit/tagging.py +++ b/internal/genkit/tagging.py @@ -1,4 +1,3 @@ -#!/usr/bin/env python3 # /// script # dependencies = ["PyGithub>=2,<3", "pyjwt<2.12.0", "charset-normalizer<3.4.6"] # /// From ae081d045c42f5fdf92cb02d7382b2b4325f3278 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 17:10:32 +0200 Subject: [PATCH 15/31] Tidy Taskfile --- Taskfile.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Taskfile.yml b/Taskfile.yml index c245991614d..c73112845f9 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -886,13 +886,13 @@ tasks: - mv tagging.py.lock internal/genkit/tagging.py.lock # The release runs internal/genkit/release_tagging.py, a repo-owned wrapper # that renders CHANGELOG.md from .nextchanges/ around the synced tagging.py. - # Point the workflow at it (genkit emits `uv run --locked tagging.py`), and - # give the wrapper a matching lock (it declares the same deps). + # Point the workflow at it (genkit emits a bare `tagging.py`) and give the + # wrapper a matching lock (it declares the same deps). - | if [ "$(uname)" = "Darwin" ]; then - sed -i '' 's|uv run --locked tagging.py|uv run --locked internal/genkit/release_tagging.py|g' .github/workflows/tagging.yml + sed -i '' 's|tagging.py|internal/genkit/release_tagging.py|g' .github/workflows/tagging.yml else - sed -i 's|uv run --locked tagging.py|uv run --locked internal/genkit/release_tagging.py|g' .github/workflows/tagging.yml + sed -i 's|tagging.py|internal/genkit/release_tagging.py|g' .github/workflows/tagging.yml fi - cp internal/genkit/tagging.py.lock internal/genkit/release_tagging.py.lock - "{{.GO_TOOL}} yamlfmt .github/workflows/tagging.yml" From 4b174439941a1404dc4c0695e77ba72ebf2ee325 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 17:12:57 +0200 Subject: [PATCH 16/31] Rename changelog task --- .nextchanges/README.md | 2 +- Taskfile.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.nextchanges/README.md b/.nextchanges/README.md index 3b569170869..da4c024131d 100644 --- a/.nextchanges/README.md +++ b/.nextchanges/README.md @@ -41,7 +41,7 @@ for when an entry is warranted. You don't run anything. At release time the tagging workflow renders every fragment into the matching section of `CHANGELOG.md`, deletes the consumed fragments, and bumps `version` to the next minor (see -`internal/genkit/tagging.py`). `./task changelog-check` validates fragment +`internal/genkit/tagging.py`). `./task check-changelog` validates fragment placement and the `version` file on every PR. ### `version` diff --git a/Taskfile.yml b/Taskfile.yml index c73112845f9..b33ed634193 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -286,7 +286,7 @@ tasks: cmds: - "./tools/update_github_links.py" - changelog-check: + check-changelog: desc: Validate .nextchanges fragment placement cmds: - "./tools/validate_nextchanges.py" @@ -316,7 +316,7 @@ tasks: - task: ws - task: links - task: deadcode - - task: changelog-check + - task: check-changelog - task: check-uv-lock install-pythons: From 04e637a44150d9d9a00977d8b221efed6e63f0db Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 17:18:12 +0200 Subject: [PATCH 17/31] Demo: add test changelog fragments (revert after review) Temporary commit for review (paired with the render commit that follows, both to be reverted). Adds .nextchanges/ fragments across CLI, Bundles, and Dependency updates to exercise the release rendering. Co-authored-by: Isaac --- .nextchanges/bundles/data-security-mode.md | 1 + .nextchanges/cli/quickstart.md | 1 + .nextchanges/dependency-updates/sdk-bump.md | 1 + 3 files changed, 3 insertions(+) create mode 100644 .nextchanges/bundles/data-security-mode.md create mode 100644 .nextchanges/cli/quickstart.md create mode 100644 .nextchanges/dependency-updates/sdk-bump.md diff --git a/.nextchanges/bundles/data-security-mode.md b/.nextchanges/bundles/data-security-mode.md new file mode 100644 index 00000000000..3217b50c492 --- /dev/null +++ b/.nextchanges/bundles/data-security-mode.md @@ -0,0 +1 @@ +Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates (#5452). diff --git a/.nextchanges/cli/quickstart.md b/.nextchanges/cli/quickstart.md new file mode 100644 index 00000000000..0452bc4ebc4 --- /dev/null +++ b/.nextchanges/cli/quickstart.md @@ -0,0 +1 @@ +Added the `databricks quickstart` command (#5464). diff --git a/.nextchanges/dependency-updates/sdk-bump.md b/.nextchanges/dependency-updates/sdk-bump.md new file mode 100644 index 00000000000..0b6dec5fe13 --- /dev/null +++ b/.nextchanges/dependency-updates/sdk-bump.md @@ -0,0 +1 @@ +Bump the Go SDK to the latest release (#5519). From 7a290e21a19bedd21e67f58178fbd0b8a8a124db Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 17:18:58 +0200 Subject: [PATCH 18/31] Demo: render fragments into CHANGELOG.md (revert after review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary commit for review (revert this and the preceding "add test changelog fragments" commit). Simulates the release locally: renders the .nextchanges/ fragments into a dated section of CHANGELOG.md, deletes the consumed fragments, and bumps .nextchanges/version to the next minor — exactly what internal/genkit/release_tagging.py does at release time (via the GitHub API). Co-authored-by: Isaac --- .nextchanges/bundles/data-security-mode.md | 1 - .nextchanges/cli/quickstart.md | 1 - .nextchanges/dependency-updates/sdk-bump.md | 1 - .nextchanges/version | 2 +- CHANGELOG.md | 12 ++++++++++++ 5 files changed, 13 insertions(+), 4 deletions(-) delete mode 100644 .nextchanges/bundles/data-security-mode.md delete mode 100644 .nextchanges/cli/quickstart.md delete mode 100644 .nextchanges/dependency-updates/sdk-bump.md diff --git a/.nextchanges/bundles/data-security-mode.md b/.nextchanges/bundles/data-security-mode.md deleted file mode 100644 index 3217b50c492..00000000000 --- a/.nextchanges/bundles/data-security-mode.md +++ /dev/null @@ -1 +0,0 @@ -Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates (#5452). diff --git a/.nextchanges/cli/quickstart.md b/.nextchanges/cli/quickstart.md deleted file mode 100644 index 0452bc4ebc4..00000000000 --- a/.nextchanges/cli/quickstart.md +++ /dev/null @@ -1 +0,0 @@ -Added the `databricks quickstart` command (#5464). diff --git a/.nextchanges/dependency-updates/sdk-bump.md b/.nextchanges/dependency-updates/sdk-bump.md deleted file mode 100644 index 0b6dec5fe13..00000000000 --- a/.nextchanges/dependency-updates/sdk-bump.md +++ /dev/null @@ -1 +0,0 @@ -Bump the Go SDK to the latest release (#5519). diff --git a/.nextchanges/version b/.nextchanges/version index bd8bf882d06..27f9cd322bb 100644 --- a/.nextchanges/version +++ b/.nextchanges/version @@ -1 +1 @@ -1.7.0 +1.8.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 269250ade5f..e6a9d21db93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Version changelog +## Release v1.7.0 (2026-07-03) + +### CLI +* Added the `databricks quickstart` command ([#5464](https://github.com/databricks/cli/pull/5464)). + +### Bundles +* Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)). + +### Dependency updates +* Bump the Go SDK to the latest release ([#5519](https://github.com/databricks/cli/pull/5519)). + + ## Release v1.6.0 (2026-07-02) ### CLI From e0a2aa4299efd355bf61f61959e041bc91a7e845 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 17:24:13 +0200 Subject: [PATCH 19/31] Revert "Demo: render fragments into CHANGELOG.md (revert after review)" This reverts commit 7a290e21a19bedd21e67f58178fbd0b8a8a124db. --- .nextchanges/bundles/data-security-mode.md | 1 + .nextchanges/cli/quickstart.md | 1 + .nextchanges/dependency-updates/sdk-bump.md | 1 + .nextchanges/version | 2 +- CHANGELOG.md | 12 ------------ 5 files changed, 4 insertions(+), 13 deletions(-) create mode 100644 .nextchanges/bundles/data-security-mode.md create mode 100644 .nextchanges/cli/quickstart.md create mode 100644 .nextchanges/dependency-updates/sdk-bump.md diff --git a/.nextchanges/bundles/data-security-mode.md b/.nextchanges/bundles/data-security-mode.md new file mode 100644 index 00000000000..3217b50c492 --- /dev/null +++ b/.nextchanges/bundles/data-security-mode.md @@ -0,0 +1 @@ +Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates (#5452). diff --git a/.nextchanges/cli/quickstart.md b/.nextchanges/cli/quickstart.md new file mode 100644 index 00000000000..0452bc4ebc4 --- /dev/null +++ b/.nextchanges/cli/quickstart.md @@ -0,0 +1 @@ +Added the `databricks quickstart` command (#5464). diff --git a/.nextchanges/dependency-updates/sdk-bump.md b/.nextchanges/dependency-updates/sdk-bump.md new file mode 100644 index 00000000000..0b6dec5fe13 --- /dev/null +++ b/.nextchanges/dependency-updates/sdk-bump.md @@ -0,0 +1 @@ +Bump the Go SDK to the latest release (#5519). diff --git a/.nextchanges/version b/.nextchanges/version index 27f9cd322bb..bd8bf882d06 100644 --- a/.nextchanges/version +++ b/.nextchanges/version @@ -1 +1 @@ -1.8.0 +1.7.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index e6a9d21db93..269250ade5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,5 @@ # Version changelog -## Release v1.7.0 (2026-07-03) - -### CLI -* Added the `databricks quickstart` command ([#5464](https://github.com/databricks/cli/pull/5464)). - -### Bundles -* Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)). - -### Dependency updates -* Bump the Go SDK to the latest release ([#5519](https://github.com/databricks/cli/pull/5519)). - - ## Release v1.6.0 (2026-07-02) ### CLI From d2093d8501cb046dc3fa52c957f5913628f1c8bd Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 17:24:23 +0200 Subject: [PATCH 20/31] Revert "Demo: add test changelog fragments (revert after review)" This reverts commit 04e637a44150d9d9a00977d8b221efed6e63f0db. --- .nextchanges/bundles/data-security-mode.md | 1 - .nextchanges/cli/quickstart.md | 1 - .nextchanges/dependency-updates/sdk-bump.md | 1 - 3 files changed, 3 deletions(-) delete mode 100644 .nextchanges/bundles/data-security-mode.md delete mode 100644 .nextchanges/cli/quickstart.md delete mode 100644 .nextchanges/dependency-updates/sdk-bump.md diff --git a/.nextchanges/bundles/data-security-mode.md b/.nextchanges/bundles/data-security-mode.md deleted file mode 100644 index 3217b50c492..00000000000 --- a/.nextchanges/bundles/data-security-mode.md +++ /dev/null @@ -1 +0,0 @@ -Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates (#5452). diff --git a/.nextchanges/cli/quickstart.md b/.nextchanges/cli/quickstart.md deleted file mode 100644 index 0452bc4ebc4..00000000000 --- a/.nextchanges/cli/quickstart.md +++ /dev/null @@ -1 +0,0 @@ -Added the `databricks quickstart` command (#5464). diff --git a/.nextchanges/dependency-updates/sdk-bump.md b/.nextchanges/dependency-updates/sdk-bump.md deleted file mode 100644 index 0b6dec5fe13..00000000000 --- a/.nextchanges/dependency-updates/sdk-bump.md +++ /dev/null @@ -1 +0,0 @@ -Bump the Go SDK to the latest release (#5519). From 1d0689948b734c13f9262eb55a2b0d48b2f978de Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 17:31:03 +0200 Subject: [PATCH 21/31] Match CHANGELOG.md formatting in release_tagging.py render The wrapper's render_nextchanges now emits the CHANGELOG.md convention: a blank line after each `###
` heading and every entry as a ` * ` bullet (a leading space before the `*`). Fragment markers (`* `, `- `, or none) are all normalized, so the released changelog is uniform regardless of how authors wrote each fragment. Co-authored-by: Isaac --- internal/genkit/release_tagging.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/internal/genkit/release_tagging.py b/internal/genkit/release_tagging.py index 20583e47f8a..22a4bd29c5c 100644 --- a/internal/genkit/release_tagging.py +++ b/internal/genkit/release_tagging.py @@ -67,9 +67,13 @@ def render_nextchanges(package_path: str) -> Optional[str]: """ Render ``/.nextchanges/
/*.md`` into the changelog body: one ``###
`` block per non-empty section in - NEXTCHANGES_SECTIONS order, fragments sorted by filename. A leading - ``* ``/``- `` marker is optional in a fragment. Returns None when there - are no fragments. + NEXTCHANGES_SECTIONS order, fragments sorted by filename. Returns None when + there are no fragments. + + The output matches the CHANGELOG.md convention: a blank line after each + ``###
`` heading, and every entry as a `` * `` bullet (a leading + space before the ``*``). A leading ``* ``/``- `` marker is optional in a + fragment and is normalized; continuation lines are left as authored. """ base = os.path.join(os.getcwd(), package_path, NEXTCHANGES_DIR) if not os.path.isdir(base): @@ -89,10 +93,13 @@ def render_nextchanges(package_path: str) -> Optional[str]: if not text: continue first, _, rest = text.partition("\n") - first = "* " + first[2:] if first.startswith(("* ", "- ")) else "* " + first - entries.append(first + (("\n" + rest) if rest else "")) + if first.startswith(("* ", "- ")): + first = first[2:] + # Leading-space bullet to match CHANGELOG.md (e.g. " * entry"). + entries.append(f" * {first}" + (("\n" + rest) if rest else "")) if entries: - blocks.append(f"### {header}\n" + "\n".join(entries)) + # Blank line after the heading, matching CHANGELOG.md. + blocks.append(f"### {header}\n\n" + "\n".join(entries)) if not blocks: return None From e925f8d35c60b793902183cba263002b7fa90ab2 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 17:31:21 +0200 Subject: [PATCH 22/31] Demo: add test changelog fragments (revert after review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary commit for review (paired with the render commit that follows, both to be reverted). Fragments deliberately use mixed markers — none, `* `, and `- ` — to show the render normalizes them all to ` * `. Co-authored-by: Isaac --- .nextchanges/bundles/data-security-mode.md | 1 + .nextchanges/cli/quickstart.md | 1 + .nextchanges/dependency-updates/sdk-bump.md | 1 + 3 files changed, 3 insertions(+) create mode 100644 .nextchanges/bundles/data-security-mode.md create mode 100644 .nextchanges/cli/quickstart.md create mode 100644 .nextchanges/dependency-updates/sdk-bump.md diff --git a/.nextchanges/bundles/data-security-mode.md b/.nextchanges/bundles/data-security-mode.md new file mode 100644 index 00000000000..725a04fb545 --- /dev/null +++ b/.nextchanges/bundles/data-security-mode.md @@ -0,0 +1 @@ +* Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates (#5452). diff --git a/.nextchanges/cli/quickstart.md b/.nextchanges/cli/quickstart.md new file mode 100644 index 00000000000..0452bc4ebc4 --- /dev/null +++ b/.nextchanges/cli/quickstart.md @@ -0,0 +1 @@ +Added the `databricks quickstart` command (#5464). diff --git a/.nextchanges/dependency-updates/sdk-bump.md b/.nextchanges/dependency-updates/sdk-bump.md new file mode 100644 index 00000000000..78c45a74c37 --- /dev/null +++ b/.nextchanges/dependency-updates/sdk-bump.md @@ -0,0 +1 @@ +- Bump the Go SDK to the latest release (#5519). From a339ab0bb5890b82989dcf73d7a24676f2b1f7ef Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 17:31:46 +0200 Subject: [PATCH 23/31] Demo: render fragments into CHANGELOG.md (revert after review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary commit for review (revert this and the preceding "add test changelog fragments" commit). Simulates the release locally: renders the .nextchanges/ fragments into a dated CHANGELOG.md section (blank line after each heading, all markers normalized to ` * `), deletes the consumed fragments, and bumps .nextchanges/version to the next minor — what release_tagging.py does at release time via the GitHub API. Co-authored-by: Isaac --- .nextchanges/bundles/data-security-mode.md | 1 - .nextchanges/cli/quickstart.md | 1 - .nextchanges/dependency-updates/sdk-bump.md | 1 - .nextchanges/version | 2 +- CHANGELOG.md | 15 +++++++++++++++ 5 files changed, 16 insertions(+), 4 deletions(-) delete mode 100644 .nextchanges/bundles/data-security-mode.md delete mode 100644 .nextchanges/cli/quickstart.md delete mode 100644 .nextchanges/dependency-updates/sdk-bump.md diff --git a/.nextchanges/bundles/data-security-mode.md b/.nextchanges/bundles/data-security-mode.md deleted file mode 100644 index 725a04fb545..00000000000 --- a/.nextchanges/bundles/data-security-mode.md +++ /dev/null @@ -1 +0,0 @@ -* Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates (#5452). diff --git a/.nextchanges/cli/quickstart.md b/.nextchanges/cli/quickstart.md deleted file mode 100644 index 0452bc4ebc4..00000000000 --- a/.nextchanges/cli/quickstart.md +++ /dev/null @@ -1 +0,0 @@ -Added the `databricks quickstart` command (#5464). diff --git a/.nextchanges/dependency-updates/sdk-bump.md b/.nextchanges/dependency-updates/sdk-bump.md deleted file mode 100644 index 78c45a74c37..00000000000 --- a/.nextchanges/dependency-updates/sdk-bump.md +++ /dev/null @@ -1 +0,0 @@ -- Bump the Go SDK to the latest release (#5519). diff --git a/.nextchanges/version b/.nextchanges/version index bd8bf882d06..27f9cd322bb 100644 --- a/.nextchanges/version +++ b/.nextchanges/version @@ -1 +1 @@ -1.7.0 +1.8.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 269250ade5f..1a18c7ef1e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Version changelog +## Release v1.7.0 (2026-07-03) + +### CLI + + * Added the `databricks quickstart` command ([#5464](https://github.com/databricks/cli/pull/5464)). + +### Bundles + + * Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)). + +### Dependency updates + + * Bump the Go SDK to the latest release ([#5519](https://github.com/databricks/cli/pull/5519)). + + ## Release v1.6.0 (2026-07-02) ### CLI From fcdb4ccb6e31de5f2461316e7d53c495423b16d8 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 17:31:59 +0200 Subject: [PATCH 24/31] Revert "Demo: render fragments into CHANGELOG.md (revert after review)" This reverts commit a339ab0bb5890b82989dcf73d7a24676f2b1f7ef. --- .nextchanges/bundles/data-security-mode.md | 1 + .nextchanges/cli/quickstart.md | 1 + .nextchanges/dependency-updates/sdk-bump.md | 1 + .nextchanges/version | 2 +- CHANGELOG.md | 15 --------------- 5 files changed, 4 insertions(+), 16 deletions(-) create mode 100644 .nextchanges/bundles/data-security-mode.md create mode 100644 .nextchanges/cli/quickstart.md create mode 100644 .nextchanges/dependency-updates/sdk-bump.md diff --git a/.nextchanges/bundles/data-security-mode.md b/.nextchanges/bundles/data-security-mode.md new file mode 100644 index 00000000000..725a04fb545 --- /dev/null +++ b/.nextchanges/bundles/data-security-mode.md @@ -0,0 +1 @@ +* Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates (#5452). diff --git a/.nextchanges/cli/quickstart.md b/.nextchanges/cli/quickstart.md new file mode 100644 index 00000000000..0452bc4ebc4 --- /dev/null +++ b/.nextchanges/cli/quickstart.md @@ -0,0 +1 @@ +Added the `databricks quickstart` command (#5464). diff --git a/.nextchanges/dependency-updates/sdk-bump.md b/.nextchanges/dependency-updates/sdk-bump.md new file mode 100644 index 00000000000..78c45a74c37 --- /dev/null +++ b/.nextchanges/dependency-updates/sdk-bump.md @@ -0,0 +1 @@ +- Bump the Go SDK to the latest release (#5519). diff --git a/.nextchanges/version b/.nextchanges/version index 27f9cd322bb..bd8bf882d06 100644 --- a/.nextchanges/version +++ b/.nextchanges/version @@ -1 +1 @@ -1.8.0 +1.7.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a18c7ef1e9..269250ade5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,5 @@ # Version changelog -## Release v1.7.0 (2026-07-03) - -### CLI - - * Added the `databricks quickstart` command ([#5464](https://github.com/databricks/cli/pull/5464)). - -### Bundles - - * Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates ([#5452](https://github.com/databricks/cli/pull/5452)). - -### Dependency updates - - * Bump the Go SDK to the latest release ([#5519](https://github.com/databricks/cli/pull/5519)). - - ## Release v1.6.0 (2026-07-02) ### CLI From e3341688e473968310d19c321cff92ff9eb6b5b0 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 17:31:59 +0200 Subject: [PATCH 25/31] Revert "Demo: add test changelog fragments (revert after review)" This reverts commit e925f8d35c60b793902183cba263002b7fa90ab2. --- .nextchanges/bundles/data-security-mode.md | 1 - .nextchanges/cli/quickstart.md | 1 - .nextchanges/dependency-updates/sdk-bump.md | 1 - 3 files changed, 3 deletions(-) delete mode 100644 .nextchanges/bundles/data-security-mode.md delete mode 100644 .nextchanges/cli/quickstart.md delete mode 100644 .nextchanges/dependency-updates/sdk-bump.md diff --git a/.nextchanges/bundles/data-security-mode.md b/.nextchanges/bundles/data-security-mode.md deleted file mode 100644 index 725a04fb545..00000000000 --- a/.nextchanges/bundles/data-security-mode.md +++ /dev/null @@ -1 +0,0 @@ -* Set the default `data_security_mode` to `DATA_SECURITY_MODE_AUTO` in bundle templates (#5452). diff --git a/.nextchanges/cli/quickstart.md b/.nextchanges/cli/quickstart.md deleted file mode 100644 index 0452bc4ebc4..00000000000 --- a/.nextchanges/cli/quickstart.md +++ /dev/null @@ -1 +0,0 @@ -Added the `databricks quickstart` command (#5464). diff --git a/.nextchanges/dependency-updates/sdk-bump.md b/.nextchanges/dependency-updates/sdk-bump.md deleted file mode 100644 index 78c45a74c37..00000000000 --- a/.nextchanges/dependency-updates/sdk-bump.md +++ /dev/null @@ -1 +0,0 @@ -- Bump the Go SDK to the latest release (#5519). From 0826ce030b8c7222272649b255e889fe0be2ef47 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 17:45:52 +0200 Subject: [PATCH 26/31] Update changelog agent rule for .nextchanges/ Point the rule at the .nextchanges/ fragment workflow instead of NEXT_CHANGELOG.md, and extend its globs to trigger on .nextchanges/ edits too. Co-authored-by: Isaac --- .agent/rules/changelog.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.agent/rules/changelog.md b/.agent/rules/changelog.md index ee0c1d0e7fd..6dc7868cb8e 100644 --- a/.agent/rules/changelog.md +++ b/.agent/rules/changelog.md @@ -1,7 +1,8 @@ --- -description: New changelog entries go in NEXT_CHANGELOG.md, not CHANGELOG.md +description: New changelog entries go in .nextchanges/, not CHANGELOG.md globs: - "CHANGELOG.md" + - ".nextchanges/**" --- -**RULE: Do not add new release entries to `CHANGELOG.md`.** New entries for the upcoming release go in `NEXT_CHANGELOG.md`. `CHANGELOG.md` is updated automatically by the release process. +**RULE: Do not add new release entries to `CHANGELOG.md`.** For a user-visible change, add a fragment file under `.nextchanges/
/` (sections: `notable-changes`, `cli`, `bundles`, `dependency-updates`, `api-changes`). Each PR adds its own file, so entries never conflict. `CHANGELOG.md` is generated automatically from these fragments by the release process — never hand-edit it. See `.nextchanges/README.md`. From 2ee900026ee5701a91f34c188d31f1827178cc95 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 17:47:28 +0200 Subject: [PATCH 27/31] Reject unexpected files under .nextchanges/ validate_nextchanges.py now walks every file under .nextchanges/ (not just *.md) and flags anything that isn't a section fragment or known scaffolding (version, README.md, .gitkeep): stray root files, non-.md files in a section, wrong-depth paths, and unknown section directories. This catches misplaced files up front rather than having them silently ignored by the release render. Co-authored-by: Isaac --- tools/validate_nextchanges.py | 42 +++++++++++++++++++++++------------ 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/tools/validate_nextchanges.py b/tools/validate_nextchanges.py index 661f7a8d353..c89eb9b0083 100755 --- a/tools/validate_nextchanges.py +++ b/tools/validate_nextchanges.py @@ -27,25 +27,39 @@ VERSION_FILE = "version" SEMVER_RE = re.compile(r"^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$") - -def iter_fragment_files(changelog_dir): - """Yield every ``*.md`` fragment under *changelog_dir*, excluding READMEs.""" - for path in sorted(changelog_dir.rglob("*.md")): - if path.name == "README.md": - continue - yield path +# Non-fragment files allowed to sit alongside fragments at any depth. +SCAFFOLDING = ("README.md", ".gitkeep") def find_problems(changelog_dir): - """Return a list of ``(path, message)`` for misplaced/empty fragments or a - missing/malformed version file.""" + """Return a list of ``(path, message)`` for anything unexpected under + ``.nextchanges/``: files that aren't a section fragment or known scaffolding, + empty fragments, and a missing/malformed version file.""" problems = [] - for path in iter_fragment_files(changelog_dir): + for path in sorted(changelog_dir.rglob("*")): + if path.is_dir(): + continue rel = path.relative_to(changelog_dir) - if len(rel.parts) != 2 or rel.parts[0] not in SECTIONS: - problems.append((path, "not in a known section directory")) - elif not path.read_text(encoding="utf-8").strip(): - problems.append((path, "empty fragment")) + name = path.name + + # Root-level: only the version file and scaffolding belong here. + if len(rel.parts) == 1: + if name != VERSION_FILE and name not in SCAFFOLDING: + problems.append((path, "unexpected file at .nextchanges root")) + continue + + # Section-level: .nextchanges/
/. + if len(rel.parts) == 2 and rel.parts[0] in SECTIONS: + if name in SCAFFOLDING: + continue + if not name.endswith(".md"): + problems.append((path, "unexpected file (fragments must be *.md)")) + elif not path.read_text(encoding="utf-8").strip(): + problems.append((path, "empty fragment")) + continue + + # Wrong depth or an unknown section directory. + problems.append((path, "not in a known section directory")) version_path = changelog_dir / VERSION_FILE if not version_path.is_file(): From 606a0eedb8b9536b251b369c3a029e8595f70a9a Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 17:56:39 +0200 Subject: [PATCH 28/31] Expand PR links via update_github_links, not in the release wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `links` task now processes .nextchanges/ fragments in addition to CHANGELOG.md (update_github_links.py's default file set includes both), so raw PR references in fragments are expanded at PR time and enforced by CI's `git diff --exit-code`. release_tagging.py no longer carries its own _expand_pr_links reimplementation — it renders the already-expanded fragments verbatim. One canonical link-expander, no drift. Co-authored-by: Isaac --- .nextchanges/README.md | 5 +++-- Taskfile.yml | 5 +++-- internal/genkit/release_tagging.py | 24 +++++------------------- tools/update_github_links.py | 22 ++++++++++++++++++---- 4 files changed, 29 insertions(+), 27 deletions(-) diff --git a/.nextchanges/README.md b/.nextchanges/README.md index da4c024131d..985694ca20f 100644 --- a/.nextchanges/README.md +++ b/.nextchanges/README.md @@ -19,8 +19,9 @@ type the path (e.g. `.nextchanges/cli/quickstart.md`), write a sentence, commit. - `` is arbitrary — a feature name (`quickstart.md`) or your PR number (`5464.md`), whatever you like, as long as it's unique. - The leading `* ` is optional. -- A PR link is optional: write `(#5464)` anywhere in the text and it becomes a - full link automatically (see `tools/update_github_links.py`). +- A PR link is optional: write `(#5464)` anywhere in the text and + `tools/update_github_links.py` (the `links` task, run by `task fmt`/CI) + expands it to a full markdown link. - One file is usually one entry; for several, put each on its own `* ` line. ### Sections diff --git a/Taskfile.yml b/Taskfile.yml index b33ed634193..8a783d6ce8b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -279,9 +279,10 @@ tasks: - "./tools/validate_whitespace.py --fix" links: - desc: Update GitHub links in docs + desc: Update GitHub links in CHANGELOG.md and .nextchanges/ fragments sources: - - "**/*.md" + - CHANGELOG.md + - ".nextchanges/**/*.md" - tools/update_github_links.py cmds: - "./tools/update_github_links.py" diff --git a/internal/genkit/release_tagging.py b/internal/genkit/release_tagging.py index 22a4bd29c5c..5a9c10904da 100644 --- a/internal/genkit/release_tagging.py +++ b/internal/genkit/release_tagging.py @@ -22,7 +22,6 @@ """ import os -import re from typing import Optional import tagging @@ -46,23 +45,6 @@ ) -def _expand_pr_links(text: str) -> str: - """ - Convert raw PR references to canonical markdown links, matching - ``tools/update_github_links.py``: ``(#1234)`` and bare ``#1234`` become - ``([#1234](https://github.com//pull/1234))``. Existing links are - left alone. - """ - repo = os.environ.get("GITHUB_REPOSITORY", "databricks/cli") - - def link(n: str) -> str: - return f"([#{n}](https://github.com/{repo}/pull/{n}))" - - text = re.sub(r"\(#(\d+)\)", lambda m: link(m.group(1)), text) - # Bare #1234 not already inside a link ('[') or paren-wrapped ('('). - return re.sub(r"(? Optional[str]: """ Render ``/.nextchanges/
/*.md`` into the changelog @@ -74,6 +56,10 @@ def render_nextchanges(package_path: str) -> Optional[str]: ``###
`` heading, and every entry as a `` * `` bullet (a leading space before the ``*``). A leading ``* ``/``- `` marker is optional in a fragment and is normalized; continuation lines are left as authored. + + Raw PR references (``(#1234)``/``#1234``) in fragments are converted to + markdown links before release by ``tools/update_github_links.py`` (the + ``links`` task, enforced in CI), so no link expansion happens here. """ base = os.path.join(os.getcwd(), package_path, NEXTCHANGES_DIR) if not os.path.isdir(base): @@ -103,7 +89,7 @@ def render_nextchanges(package_path: str) -> Optional[str]: if not blocks: return None - return _expand_pr_links("\n\n".join(blocks)) + return "\n\n".join(blocks) def _version_path(package_path: str) -> str: diff --git a/tools/update_github_links.py b/tools/update_github_links.py index 354430b85ec..01f940d47d4 100755 --- a/tools/update_github_links.py +++ b/tools/update_github_links.py @@ -8,6 +8,10 @@ `([#1234](https://github.com/databricks/cli/pull/1234))`. 2. Validate that for existing converted references the PR number in the text and in the URL match. + +By default this processes CHANGELOG.md and every .nextchanges/ fragment, so +raw references in fragments are expanded here (via the `links` task, enforced +in CI) before the release renders them into CHANGELOG.md. """ import argparse @@ -15,7 +19,13 @@ import re import sys -DEFAULT_FILES = ("CHANGELOG.md",) + +def default_files(): + """CHANGELOG.md plus every .nextchanges/ fragment (README excluded).""" + files = [pathlib.Path("CHANGELOG.md")] + files += sorted(p for p in pathlib.Path(".nextchanges").glob("*/*.md") if p.name != "README.md") + return files + # Canonical form: ([#1234](https://github.com/databricks/cli/pull/1234)) CONVERTED_LINK_RE = re.compile( @@ -128,12 +138,16 @@ def process_file(path): def main(argv=None): parser = argparse.ArgumentParser(description="Convert #PR references in changelogs to links.") - parser.add_argument("files", nargs="*", help=f"Markdown files to process (default: {DEFAULT_FILES})") + parser.add_argument( + "files", + nargs="*", + help="Markdown files to process (default: CHANGELOG.md and .nextchanges/ fragments)", + ) args = parser.parse_args(argv) + files = [pathlib.Path(f) for f in args.files] if args.files else default_files() modified_any = False - for file_path in args.files or DEFAULT_FILES: - file_path = pathlib.Path(file_path) + for file_path in files: modified_any |= process_file(file_path) From 129fb7cdbcc4647318fcd610df8810d3c68435be Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 3 Jul 2026 18:01:08 +0200 Subject: [PATCH 29/31] Clarify PR-link expansion in .nextchanges README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make explicit that raw `(#5464)` refs must be expanded at PR time (`task fmt` / `task links`, enforced by CI) — the release renders fragments verbatim and does not expand links, so a raw ref left in a fragment fails CI rather than being handled later. Co-authored-by: Isaac --- .nextchanges/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.nextchanges/README.md b/.nextchanges/README.md index 985694ca20f..12ffa9aabb0 100644 --- a/.nextchanges/README.md +++ b/.nextchanges/README.md @@ -19,9 +19,10 @@ type the path (e.g. `.nextchanges/cli/quickstart.md`), write a sentence, commit. - `` is arbitrary — a feature name (`quickstart.md`) or your PR number (`5464.md`), whatever you like, as long as it's unique. - The leading `* ` is optional. -- A PR link is optional: write `(#5464)` anywhere in the text and - `tools/update_github_links.py` (the `links` task, run by `task fmt`/CI) - expands it to a full markdown link. +- A PR link is optional. If you want one, write `(#5464)` and run `task fmt` + (or `task links`) to expand it into a full markdown link in place; CI fails + if a raw `(#5464)` is left unexpanded. The release does not expand links, so + the fragment must already be expanded when it lands. - One file is usually one entry; for several, put each on its own `* ` line. ### Sections From 1e09675a6291a2386ee974b0ea2e6946e8c36ae2 Mon Sep 17 00:00:00 2001 From: Jan Rose Date: Fri, 10 Jul 2026 12:54:51 +0200 Subject: [PATCH 30/31] Add release note preview workflow for PRs --- .github/workflows/changelog-preview.yml | 46 +++++++++++++++++++++++++ Taskfile.yml | 5 +++ internal/genkit/release_tagging.py | 44 +++++++++++++++++++++-- 3 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/changelog-preview.yml diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml new file mode 100644 index 00000000000..ef95d421b15 --- /dev/null +++ b/.github/workflows/changelog-preview.yml @@ -0,0 +1,46 @@ +name: Changelog Preview + +# Renders the CHANGELOG.md section the next release would generate from this +# PR's .nextchanges/ fragments, so reviewers can see the result (in this check's +# logs / summary) without cutting a release. +# +# Uses `pull_request` with a read-only token: it renders the PR's own +# .nextchanges/ content and never needs write credentials. +on: + pull_request: + types: [opened, reopened, synchronize] + paths: + - ".nextchanges/**" + - "internal/genkit/release_tagging.py" + - "internal/genkit/tagging.py" + - "tools/validate_nextchanges.py" + - "tools/update_github_links.py" + +permissions: + contents: read + +jobs: + preview: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + with: + version: "0.8.9" + + - name: Render changelog preview + run: |- + { + echo '### Changelog preview' + echo '' + echo "What the next release would add to CHANGELOG.md from this PR's .nextchanges/:" + echo '' + echo '```markdown' + uv run --locked internal/genkit/release_tagging.py --preview + echo '```' + echo '' + echo '_Preview only — the date and version are computed at release time._' + } | tee "$GITHUB_STEP_SUMMARY" diff --git a/Taskfile.yml b/Taskfile.yml index 8a783d6ce8b..2ed38189f50 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -292,6 +292,11 @@ tasks: cmds: - "./tools/validate_nextchanges.py" + changelog-preview: + desc: Print the CHANGELOG.md section the next release would add from .nextchanges/ + cmds: + - "uv run --locked internal/genkit/release_tagging.py --preview" + deadcode: desc: Check for dead code sources: diff --git a/internal/genkit/release_tagging.py b/internal/genkit/release_tagging.py index 5a9c10904da..bbb0328d60b 100644 --- a/internal/genkit/release_tagging.py +++ b/internal/genkit/release_tagging.py @@ -21,7 +21,10 @@ untouched ``process()`` for all commit/tag/race/recovery logic. """ +import argparse import os +import re +from datetime import datetime, timezone from typing import Optional import tagging @@ -162,8 +165,43 @@ def install_nextchanges() -> None: tagging.clean_next_changelog = clear_nextchanges +def preview() -> None: + """ + Print the ``## Release vX.Y.Z`` section(s) the next release would prepend to + CHANGELOG.md, rendered from the current ``.nextchanges/`` — without touching + git, GitHub, or any file. Mirrors write_changelog's date stamp so the output + matches what would land. Read-only: safe to run anytime, no credentials. + """ + current_date = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + printed = False + for package in tagging.find_packages(): + tag_info = get_next_tag_info(package) + if tag_info is None: + continue + dated = re.sub( + rf"## Release v({tagging.Version.PATTERN})", + rf"## Release v\1 ({current_date})", + tag_info.content.strip(), + ) + print(dated) + printed = True + if not printed: + print("No .nextchanges/ entries — the next release would add no changelog section.") + + if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--preview", + action="store_true", + help="Print the changelog section the next release would add, then exit (no writes, no network).", + ) + args = parser.parse_args() + install_nextchanges() - tagging.validate_git_root() - tagging.init_github() - tagging.process() + if args.preview: + preview() + else: + tagging.validate_git_root() + tagging.init_github() + tagging.process() From a6287a8c36c2ee7ace1c506d1b0703b736241372 Mon Sep 17 00:00:00 2001 From: Jan N Rose Date: Fri, 10 Jul 2026 18:17:26 +0200 Subject: [PATCH 31/31] Update .github/workflows/changelog-preview.yml Co-authored-by: Pieter Noordhuis --- .github/workflows/changelog-preview.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/changelog-preview.yml b/.github/workflows/changelog-preview.yml index ef95d421b15..7a9379bae5c 100644 --- a/.github/workflows/changelog-preview.yml +++ b/.github/workflows/changelog-preview.yml @@ -11,8 +11,7 @@ on: types: [opened, reopened, synchronize] paths: - ".nextchanges/**" - - "internal/genkit/release_tagging.py" - - "internal/genkit/tagging.py" + - "internal/genkit/**" - "tools/validate_nextchanges.py" - "tools/update_github_links.py"