Skip to content

[FEATURE] Branching strategy Phase 2.B - release trigger and versioning docs - #557

Open
John McCall (lowlydba) wants to merge 31 commits into
mainfrom
533-devops-branching-strategy---phase-2b---release-versions
Open

[FEATURE] Branching strategy Phase 2.B - release trigger and versioning docs#557
John McCall (lowlydba) wants to merge 31 commits into
mainfrom
533-devops-branching-strategy---phase-2b---release-versions

Conversation

@lowlydba

@lowlydba John McCall (lowlydba) commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase 2.B of the branching strategy (#533): release detection on main, the changelog machinery, and the versioning docs. No publish workflows, those are Phase 3 (#509), which this unblocks.

The versioning model settled during review (thanks Victor Schappert (@vcschapp), Seth Fitzsimmons (@sethfitz)): humans own the full <major>.<minor>.<patch> per package, any bump merged to main is a release, and CI only versions the interim internal builds between releases.

What lands

Release trigger

  • release-trigger.yaml + detect_version_bumps.py: on push to main, diffs every packages/*/pyproject.toml version against the previous commit. Any increase (patch included) cuts one published GitHub Release per bumped package: tag <pkg>-v<version>, title `<pkg>` <version>, notes read from that package's CHANGELOG.md. Fails loudly on version decreases and on pre-existing tags. The umbrella overture-schema release is marked Latest.
  • Major-bump cascade guard: a package whose direct workspace dependency takes a major bump must take one itself, since the old dependency floor would admit a breaking version. Enforced at PR time by the version check and again by release-trigger. Direct-dep checking covers longer chains, each unbumped link fails its own check.
  • create-package-release composite action with a dry-run input for testing.

Changelog machinery

  • towncrier fragments per package (packages/<pkg>/changelog.d/), config centralized in the root pyproject.toml.
  • require-changelog-fragment.yaml: any PR touching a package must carry a fragment or a built CHANGELOG.md. Plain git diff + grep, no action dependencies.
  • Release PRs run towncrier build so the notes are reviewed as part of the version-bump diff.

Internal build versioning

  • compute-version reworked: no-bump merges produce <version>.postN+main.<sha> (or +vnext.<sha>). PEP 440 post-releases order after the release, so >=X.Y.Z consumers resolve the freshest internal build while ==X.Y.Z selects the clean release; local labels are rejected by public PyPI, keeping these builds internal by construction. N is a per-version sequence resolved from CodeArtifact: the first internal build of a version is .post0, later builds increment the highest published .postN.
  • Verified against uv with a local two-wheel resolution test; the one spec gotcha (> never matches post-releases) is documented.
  • main and vnext builds must not share a CodeArtifact repository (local labels don't participate in ordering, so >= consumers could resolve vnext breaking builds). vnext publishing is blocked on the dedicated dev repo in ops-team#299.

Workspace dependency floors

  • Intra-repo dependencies now follow uv's dual declaration pattern: explicit >= floors in project.dependencies alongside the [tool.uv.sources] workspace entries. Built wheels carry the floors directly, no build-time metadata rewriting. Floors are hand-maintained; the cascade guard above is the sanity net. This replaced an earlier just-in-time materialization script after review discussion.

Docs

  • docs/versioning.md: scheme, trigger x destination table, bump rules, floor maintenance, release walkthrough, pin guidance.
  • CONTRIBUTING.md: per-path release flows with gitGraphs, versioning quick reference, phase roadmap.

Bonus

  • 🧹 package_versions.py replaces the dual-checkout version diff: reads pyproject.toml blobs straight from git and derives the topological order from declared dependencies instead of a hardcoded map. The reusable check drops a checkout, two uv syncs, and Python setup on the no-change path.
  • 🧹 The change-type label check sheds its github-script JS for jq over the event payload.
  • 🔧 The legacy publish workflow syncs with --locked so it installs exactly the committed uv.lock.

Testing

Detection, cascade enforcement, release-notes extraction, floor-carrying wheel metadata, and the resolver-ordering claims are tested locally against real repo history (simulated bump, downgrade, uncascaded-major, and multi-package cases). The workflows themselves can't run until they're on main; release-trigger supports dry-run for a low-stakes first live pass. zizmor is clean on all touched workflows.

Closes #533

Detects an umbrella overture-schema major/minor bump on main and creates
a draft GitHub Release at v<major>.<minor>.0. Publishing the draft is the
only trigger for the public PyPI publish (Phase 3).

Decisions locked: patch builds stay CodeArtifact-only; release notes are
authored manually at release time; only the umbrella package bump cuts a
release.

Closes #533

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Signed-off-by: John McCall <john@overturemaps.org>
…ases

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Signed-off-by: John McCall <john@overturemaps.org>
…ft release tag

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Signed-off-by: John McCall <john@overturemaps.org>
@lowlydba
John McCall (lowlydba) marked this pull request as ready for review July 14, 2026 14:54
Copilot AI review requested due to automatic review settings July 14, 2026 14:54
@lowlydba
John McCall (lowlydba) requested a review from a team as a code owner July 14, 2026 14:54

This comment was marked as outdated.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Signed-off-by: John McCall <john@overturemaps.org>
@vcschapp

This comment has been minimized.

@vcschapp

This comment has been minimized.

@vcschapp

This comment has been minimized.

@vcschapp

This comment has been minimized.

@vcschapp

This comment has been minimized.

@vcschapp

This comment has been minimized.

@vcschapp

This comment has been minimized.

@lowlydba
John McCall (lowlydba) marked this pull request as draft July 21, 2026 18:54
…changelog

- release-trigger: detect per-package major.minor bumps on main and cut a
  published GitHub Release per package (tag `<package>-v<major>.<minor>.0`,
  notes extracted from that package's CHANGELOG.md; umbrella marked Latest)
- require-changelog-fragment: new PR check enforcing a changelog update on any
  major.minor bump (accepts a towncrier fragment or a built CHANGELOG.md)
- towncrier: centralize [tool.towncrier] in root pyproject.toml; per-package
  changelog.d/ with a DRY README that defers to docs/versioning.md (no .gitkeep)
- docs/versioning.md: Diataxis rewrite (reference/how-to/why) with a TOC
- CONTRIBUTING.md: lean guiding-light rewrite (217 -> 91 lines); richer,
  workflow-accurate gitGraphs
- publish-python-packages: add contents: read for reusable-workflow checkout

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Signed-off-by: John McCall <john@overturemaps.org>
Wrap each release path (main->patch, main->minor, vnext->major) in a
collapsible <details> section with its own workflow-accurate gitGraph.
Drop the branch-protection bullet and reduce the CI notes to authoritative
links to the vnext-compat and pr-advisory workflows, which comment their
own fix steps inline.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Signed-off-by: John McCall <john@overturemaps.org>
… a PR

Add how/when each release path reaches consumers (patch rides the next
release to PyPI via the umbrella; minor and major publish to PyPI on merge).
Reword the Opening a PR CI notes to point at the checks without narrating.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Signed-off-by: John McCall <john@overturemaps.org>
Move the inline detect heredoc and the extract_notes heredoc out of
release-trigger.yaml into .github/workflows/scripts/detect_version_bumps.py
and extract_release_notes.py, matching the existing package-versions.py
convention. The workflow steps now just invoke them, so the Python is
lint-covered, testable, and free of heredoc quoting. Behavior is unchanged.

Also vary the CONTRIBUTING release diagrams across distinct packages
(places/base/transportation themes) and note that a no-bump merge still
publishes a patch to internal CodeArtifact.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Signed-off-by: John McCall <john@overturemaps.org>
@sethfitz

Copy link
Copy Markdown
Collaborator

the packages inside the workspace do not refer to their monorepo-friend dependencies by explicit versions

But they can (pretty sure), so my read is that we can skip the materialization in favor of keeping things up to date manually (modulo sanity checks).

@vcschapp

Copy link
Copy Markdown
Collaborator

the packages inside the workspace do not refer to their monorepo-friend dependencies by explicit versions

But they can (pretty sure), so my read is that we can skip the materialization in favor of keeping things up to date manually (modulo sanity checks).

That's also fine w me - it would simplify. John McCall (@lowlydba) Seth Fitzsimmons (@sethfitz)

@lowlydba

Copy link
Copy Markdown
Contributor Author

the packages inside the workspace do not refer to their monorepo-friend dependencies by explicit versions

But they can (pretty sure), so my read is that we can skip the materialization in favor of keeping things up to date manually (modulo sanity checks).

That's also fine w me - it would simplify. John McCall (John McCall (@lowlydba)) Seth Fitzsimmons (Seth Fitzsimmons (@sethfitz))

Love it - this is exactly why I had included this callout in the latest versioning.md edit:

uv's documented alternative is static dual declaration: hand-maintained specifiers in project.dependencies alongside the workspace source. Not used here; with 13 interdependent packages...

Especially since this cuts down on invisible/magic versioning work behind the scenes that may be non-obvious to a contributor at face value (or even future us 🙃 )

…erialization

Consensus from PR review: maintainers keep explicit >= floors in
project.dependencies by hand, uv's documented dual-declaration pattern,
instead of a CI step rewriting bare names at publish time. Wheel
metadata now carries the floors with no pre-build mutation, and the
versioning behavior is visible in the pyproject.toml a contributor
actually reads.

materialize_workspace_deps.py is deleted; the major-bump cascade check
stays as the sanity net.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Signed-off-by: John McCall <john@overturemaps.org>
Bare uv sync could resolve newer dependency versions than the lock the
change was tested against. --locked installs exactly uv.lock and fails
if it is stale, matching check-python-code.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Signed-off-by: John McCall <john@overturemaps.org>
@lowlydba

John McCall (lowlydba) commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

the packages inside the workspace do not refer to their monorepo-friend dependencies by explicit versions

But they can (pretty sure), so my read is that we can skip the materialization in favor of keeping things up to date manually (modulo sanity checks).

That's also fine w me - it would simplify. John McCall (John McCall (@lowlydba)) Seth Fitzsimmons (Seth Fitzsimmons (@sethfitz))

+ Victor Schappert (@vcschapp)

Done in 7842f7c. All 12 dependent packages now declare explicit >= floors in project.dependencies alongside their [tool.uv.sources] workspace entries, uv's dual declaration pattern, and materialize_workspace_deps.py is deleted (the script's one glorious final act was writing the floors 🪦 ).

New contributors hit the fragment check before they ever read
versioning.md, so give them a 30-second copy-paste answer: quick-start
section up top of the how-to, the CI failure message pointing at it,
and the changelog.d keeper READMEs linking it. The old failure message
also told contributors to run towncrier build, which is the
release-time maintainer step, not theirs.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Signed-off-by: John McCall <john@overturemaps.org>
@sethfitz

Copy link
Copy Markdown
Collaborator

Important thread that's not surfacing on this PR because I commented directly on a commit: 7842f7c#r194263713

It's about how versioned dependencies work within a uv workspace, and the answer complicates CI.

@lowlydba

Copy link
Copy Markdown
Contributor Author

Important thread that's not surfacing on this PR because I commented directly on a commit: 7842f7c#r194263713

It's about how versioned dependencies work within a uv workspace, and the answer complicates CI.

Its showing 0 comments on that commit for me - can you re-post in the main thread here? Seth Fitzsimmons (@sethfitz)

@sethfitz

Copy link
Copy Markdown
Collaborator

Development resolves against the workspace source; built wheels carry the specifier.

(docs/versioning.md:60)

Hmm. I think this fact breaks our CI assumptions, since they run against the workspace (and therefore the tip of each package version). The "lowest-direct" CI cell is the thing we depend on here, since it verifies that floor and ceiling behavior is the same, but if it's not able to test against the floor (because it can't/won't be installed), we don't have the guardrail we expect for intra-workspace dependencies.

We might be able to fix that by testing each package in isolation with the "lowest-direct" cell (so it pulls from PyPI instead of using the workspace).

This all assumes that we will only ever use >= or > comparisons, since <= and < can't be fulfilled without isolated testing within the other CI cells, which might also fail because the tips of each intra-workspace dependency haven't been published (so we can't check work in progress).

I may be wrong here. The answer hinges on whether foo = { workspace = true } forces the package to come from the workspace, or if the workspace is a viable source alongside PyPI.

(And if there's a way to list PyPI as an alternative source to the workspace, if the former.)

If the version in the workspace can't satisfy the dependency, will it fall back to PyPI for previously-published versions?

Verified, unfortunately:

workspace = true installs from the workspace regardless of the specifier (no fallback, no error, floor inert).

@lowlydba

Copy link
Copy Markdown
Contributor Author

Copilot resolve the merge conflicts in this pull request

Copilot AI commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Copilot resolve the merge conflicts in this pull request

Done. Merged main into the branch; the only conflict was in .github/workflows/reusable-check-python-package-versions.yaml. Kept the branch's optimized single-checkout approach (git blob diffing via package_versions.py) and adopted the dependency-bump versions from main (actions/checkout@v7.0.1, aws-actions/configure-aws-credentials@v6.2.3). Resolved in 756625d (merge) + fix in the follow-up commit.

@vcschapp

Copy link
Copy Markdown
Collaborator

Development resolves against the workspace source; built wheels carry the specifier.

(docs/versioning.md:60)

Hmm. I think this fact breaks our CI assumptions, since they run against the workspace (and therefore the tip of each package version). The "lowest-direct" CI cell is the thing we depend on here, since it verifies that floor and ceiling behavior is the same, but if it's not able to test against the floor (because it can't/won't be installed), we don't have the guardrail we expect for intra-workspace dependencies.

We might be able to fix that by testing each package in isolation with the "lowest-direct" cell (so it pulls from PyPI instead of using the workspace).

This all assumes that we will only ever use >= or > comparisons, since <= and < can't be fulfilled without isolated testing within the other CI cells, which might also fail because the tips of each intra-workspace dependency haven't been published (so we can't check work in progress).

I may be wrong here. The answer hinges on whether foo = { workspace = true } forces the package to come from the workspace, or if the workspace is a viable source alongside PyPI.

(And if there's a way to list PyPI as an alternative source to the workspace, if the former.)

If the version in the workspace can't satisfy the dependency, will it fall back to PyPI for previously-published versions?

Verified, unfortunately:

workspace = true installs from the workspace regardless of the specifier (no fallback, no error, floor inert).

I want to make sure I'm understanding this. Are you saying that if package P in the WS has current version 1.5.6 but the dependent package Q says P>=1.2.3 then we are only exercising =1.5.6 in the CI but not >=1.2.3?

@sethfitz

Copy link
Copy Markdown
Collaborator

I want to make sure I'm understanding this. Are you saying that if package P in the WS has current version 1.5.6 but the dependent package Q says P>=1.2.3 then we are only exercising =1.5.6 in the CI but not >=1.2.3?

Yes.

P>=1.2.3 is ignored entirely as long as there's a package in the workspace that matches by name.

@vcschapp

Copy link
Copy Markdown
Collaborator

I want to make sure I'm understanding this. Are you saying that if package P in the WS has current version 1.5.6 but the dependent package Q says P>=1.2.3 then we are only exercising =1.5.6 in the CI but not >=1.2.3?

Yes.

P>=1.2.3 is ignored entirely as long as there's a package in the workspace that matches by name.

Cool.

John McCall (@lowlydba) and I just met on this topic. The tl;dr is:

  • We acknowledge it's a real issue.
  • The solution is a bit complex.
  • We're recommending to go forward and finish this phase without solving it, and let our experience with the system in the wild tell us how important it is to add guardrails. In other words, defer for now; if needed fix later.

Seth Fitzsimmons (@sethfitz) What are your thoughts on the above?


The best solution I can think of to make it work correctly is to have the CI machinery:

  1. Run the tests once with workspace = true to make sure that the workspace itself is in a consistent state.
  2. Then, during the publish:
    • Iteratively working upward from "leaf" nodes (e.g., system) to "root" nodes (e.g., schema), publishing one package at a time...
    • ...And after each publish of a package P, run the tests of each workspace package that depends on P either directly or transitively but now using --resolution lowest-direct instead of workspace = true so P comes from the upstream, i.e., PyPI, and it takes the lowest version of P that fits the constraint.

Part 2 of the above solution is a bit complicated and comes with the downside that the publish can succeed partially and fail partially, e.g., I successfully publish package P but then the tests fail for dependent package Q so the automation has to abort without publishing Q.

This partly succeeded, partly failed, publish state is mostly fine since the packages that did succeed in being published work by definition.† But then it leaves you with the question of what do you do with the packages that we made version bump commits for but for which version bumps failed.

Here's a concrete example of that situation.

  • Suppose the same commit bumps system v1.2.3 -> v1.3.0 and common v5.6.7 -> v5.7.0. Assume the reason for these bumps is that a new type or function was added to system that is then used in common, a classic minor version bump situation.
  • In this situation, we needed common's dependencies to be updated to require system>=5.7.0 but let's suppose for the sake of argument that detail was missed.
  • Suppose the workspace true tests pass.
  • Now the iterative Part 2 kicks in:
    • Publish system v1.3.0, so far so good.
    • Now try to run the tests for common using system >= v1.2.3 and --resolution lowest-direct. This fails because the current common code requires a feature of system that's only available in v1.3.0.
  • Now we've failed in a half-published state where system v1.3.0 is published, common v5.7.0 is not published, and the pyproject.toml version for common is set to v5.7.0.

How do we recover from this state? John McCall (@lowlydba) and I thought of two available manual recovery options and they're both equally valid:

  • Could publish a commit setting common back to v5.6.7, fix the pyproject.toml bug, and then re-promote to v5.6.7.
  • Or, fix the pyproject.toml bug, consider v5.7.0 as being burned, and publish a bump commit setting common to v5.7.1.

Anyway, and in conclusion, it's a bit complicated and I do think it makes sense to go live without that guardrail for the time being, see how pervasive these versioning bugs are, and if necessary course correct by building the guardrail later.


†: The partially failed state can also be avoided by creating another internal private CodeArtifact repo to use instead of PyPI, but again that's more complexity and probably not worth doing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Devops] Branching Strategy - Phase 2.B - Release Versions

5 participants