diff --git a/.github/actions/compute-version/action.yml b/.github/actions/compute-version/action.yml index f87b677ee..7bc288013 100644 --- a/.github/actions/compute-version/action.yml +++ b/.github/actions/compute-version/action.yml @@ -1,16 +1,25 @@ name: Compute package version description: > - Computes the version string for a package given branch context. + Computes the internal build version string for a package on a no-bump merge. - Contexts: - - `vnext`: `+dev.` (PEP 440 local version). - Falls back to `..0+dev.` if never published. - Local versions are rejected by PyPI — only suitable for private indexes - like CodeArtifact. - - `main`: `..` — increments the highest - published patch for the same major.minor series. - - `main-bump`: `..0` — used when a major/minor bump commit - lands on main (patch resets to 0). + All three released version components (..) are + human-owned in pyproject.toml; releases publish that version as-is. This + action only versions the interim internal builds published to CodeArtifact + between releases, using a PEP 440 post-release so they order AFTER the + released version and are picked up by `>=` specifiers: + + - `main`: `.post+main.` + - `vnext`: `.post+vnext.` + + `N` is a per-version sequence: the first internal build of a version is + `.post0` (communicating "identical to the release"), and each subsequent + build increments from the highest `.postN` already published to + CodeArtifact. + + The `+main`/`+vnext` local label distinguishes the two build streams; local + labels are ignored during version comparison so ordering comes from + `.post` alone. Local versions are rejected by public PyPI, which + guarantees these builds stay internal. Prerequisites: repo must be checked out and `uv` must be available. @@ -20,8 +29,8 @@ inputs: required: true context: description: > - Branch context controlling the version formula. - Supported values: `vnext`, `main`, `main-bump`. + Branch context naming the build stream. Supported values: `main`, + `vnext`. required: true index_url: description: > @@ -45,88 +54,49 @@ runs: PACKAGE: ${{ inputs.package }} CONTEXT: ${{ inputs.context }} INDEX_URL: ${{ inputs.index_url }} - RUN_NUMBER: ${{ github.run_number }} + SHA: ${{ github.sha }} run: | set -euo pipefail - # --- Read seed version from pyproject.toml --- - SEED=$(cd "packages/${PACKAGE}" && uv version --short) - MAJOR_MINOR=$(echo "$SEED" | grep -oE '^[0-9]+\.[0-9]+') - echo "Seed version for ${PACKAGE}: ${SEED} (major.minor: ${MAJOR_MINOR})" - - # --- Query CodeArtifact for the latest published version --- - # uv pip compile resolves the latest matching version from the index. - # We constrain to the current major.minor series for `main` context. - resolve_latest() { - local constraint="$1" - local output - # uv pip compile exits non-zero both when nothing matches (a normal - # "not published yet" result) and on real failures (network/auth/etc). - # Only treat the former as benign; anything else must surface and fail. - output=$(echo "$constraint" \ - | uv pip compile - --index-url "$INDEX_URL" --no-deps --quiet 2>&1) || { - if echo "$output" | grep -qiE 'no solution found|could not find a version|not found in the package registry'; then - echo "" - return 0 - fi - echo "ERROR: uv pip compile failed for '${constraint}':" >&2 - echo "$output" >&2 - exit 1 - } - echo "$output" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true - } - - # --- Compute version based on context --- case "$CONTEXT" in - vnext) - LATEST=$(resolve_latest "$PACKAGE") - if [ -n "$LATEST" ]; then - BASE="$LATEST" - echo "Latest published version: ${LATEST}" - else - # No published version at all — use the pyproject.toml seed - # as-is (not just its major.minor) so a patch already bumped - # there (e.g. baselining) isn't regressed. - BASE="$SEED" - echo "No published version found — falling back to seed version ${BASE}" - fi - VERSION="${BASE}+dev.${RUN_NUMBER}" - ;; - - main) - # Resolve the highest patch within the current major.minor series. - LATEST_IN_SERIES=$(resolve_latest "${PACKAGE}>=${MAJOR_MINOR}.0,<${MAJOR_MINOR}.99999") - SEED_PATCH=$(echo "$SEED" | grep -oE '[0-9]+$') - if [ -n "$LATEST_IN_SERIES" ]; then - CURRENT_PATCH=$(echo "$LATEST_IN_SERIES" | grep -oE '[0-9]+$') - NEXT_FROM_PUBLISHED=$((CURRENT_PATCH + 1)) - echo "Latest in ${MAJOR_MINOR}.x series: ${LATEST_IN_SERIES} → next patch: ${NEXT_FROM_PUBLISHED}" - else - NEXT_FROM_PUBLISHED=0 - echo "No published version in ${MAJOR_MINOR}.x series" - fi - # The pyproject.toml seed's patch acts as a floor so a manual - # bump there (e.g. baselining) is never regressed — CI only - # takes over incrementing once publishing has caught up to it. - if [ "$SEED_PATCH" -gt "$NEXT_FROM_PUBLISHED" ]; then - NEXT_PATCH=$SEED_PATCH - echo "Seed patch (${SEED_PATCH}) is ahead of published — using it as the baseline" - else - NEXT_PATCH=$NEXT_FROM_PUBLISHED - fi - VERSION="${MAJOR_MINOR}.${NEXT_PATCH}" - ;; - - main-bump) - VERSION="${MAJOR_MINOR}.0" - echo "Major/minor bump — patch resets to 0" - ;; - + main|vnext) ;; *) - echo "::error::Unknown context '${CONTEXT}'. Supported: vnext, main, main-bump." + echo "::error::Unknown context '${CONTEXT}'. Supported: main, vnext." exit 1 ;; esac + SEED=$(cd "packages/${PACKAGE}" && uv version --short) + SHORT_SHA=$(echo "$SHA" | cut -c1-7) + + # Resolve the highest published build of the seed version. The `.*` + # prefix match includes the release itself and its post-releases. + # uv pip compile exits non-zero both when nothing matches (a normal + # "not published yet" result) and on real failures (network/auth/etc). + # Only treat the former as benign; anything else must surface and fail. + LATEST="" + if ! OUTPUT=$(echo "${PACKAGE}==${SEED}.*" \ + | uv pip compile - --index-url "$INDEX_URL" --no-deps --quiet 2>&1); then + if ! echo "$OUTPUT" | grep -qiE 'no solution found|could not find a version|not found in the package registry'; then + echo "ERROR: uv pip compile failed for '${PACKAGE}==${SEED}.*':" >&2 + echo "$OUTPUT" >&2 + exit 1 + fi + else + LATEST=$(echo "$OUTPUT" | grep -oE "==[0-9][^ ]*" | head -1 | cut -c3-) + fi + + # First build of this version is .post0; otherwise increment the + # highest published .postN. + if [[ "$LATEST" =~ \.post([0-9]+) ]]; then + N=$(( ${BASH_REMATCH[1]} + 1 )) + echo "Latest published build: ${LATEST} -> next N: ${N}" + else + N=0 + echo "No published .post build for ${SEED} (latest: '${LATEST:-none}') -> N: 0" + fi + + VERSION="${SEED}.post${N}+${CONTEXT}.${SHORT_SHA}" + echo "Computed version for ${PACKAGE} (${CONTEXT}): ${VERSION}" - echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" \ No newline at end of file diff --git a/.github/actions/create-package-release/action.yml b/.github/actions/create-package-release/action.yml new file mode 100644 index 000000000..d18fe1322 --- /dev/null +++ b/.github/actions/create-package-release/action.yml @@ -0,0 +1,146 @@ +name: Create package release +description: > + Creates a published GitHub Release for a single package from its towncrier + CHANGELOG.md. + + Reads the `## []` section of `packages//CHANGELOG.md` for + the release notes (falling back to a pointer message if absent), fails if the + target tag already exists, and creates the release tagged + `-v` at `target`. Optionally also creates a bare vanity + tag (no second GitHub Release) to continue a legacy tag series. + + Prerequisites: repo must be checked out and the GitHub CLI (`gh`) available + (both true on GitHub-hosted runners). + +inputs: + package: + description: Package directory name under packages/ (e.g. overture-schema). + required: true + version: + description: Release version, major.minor.patch (e.g. 1.18.0). + required: true + tag: + description: Release tag (e.g. overture-schema-v1.18.0). + required: true + target: + description: Commit SHA the tag should point at. + required: true + latest: + description: Whether to mark this release as "Latest" (true/false). + required: false + default: "false" + vanity-tag: + description: > + Optional additional bare git tag to create at `target` (e.g. v1.18.0), + with no GitHub Release attached. A pre-existing tag warns instead of + failing; the vanity tag is cosmetic and must not fail a completed + release. + required: false + default: "" + dry-run: + description: > + When "true", resolve and print what would be released without creating + anything. Read-only: a pre-existing tag is reported as a warning, not a + failure. + required: false + default: "false" + token: + description: "Token used to create the release (needs contents: write)." + required: true + +outputs: + release-url: + description: URL of the created release, or empty on a dry run. + value: ${{ steps.release.outputs.release-url }} + +runs: + using: composite + steps: + - name: Create release + id: release + shell: bash + env: + GH_TOKEN: ${{ inputs.token }} + PACKAGE: ${{ inputs.package }} + VERSION: ${{ inputs.version }} + TAG: ${{ inputs.tag }} + TARGET: ${{ inputs.target }} + LATEST: ${{ inputs.latest }} + VANITY_TAG: ${{ inputs.vanity-tag }} + DRY_RUN: ${{ inputs.dry-run }} + run: | + set -euo pipefail + + tag_exists=false + if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + tag_exists=true + fi + + if [ "$tag_exists" = "true" ] && [ "$DRY_RUN" != "true" ]; then + echo "::error::Release ${TAG} already exists. A duplicate ${PACKAGE} ${VERSION} bump landed on main; investigate before re-releasing." + exit 1 + fi + + changelog="packages/${PACKAGE}/CHANGELOG.md" + notes=$(python3 "${GITHUB_ACTION_PATH}/extract_release_notes.py" "$VERSION" "$changelog") + if [ -z "$notes" ]; then + notes="Release ${VERSION} of \`${PACKAGE}\`. No changelog section was found; add towncrier fragments under \`packages/${PACKAGE}/changelog.d\` and run \`uv run towncrier build --config pyproject.toml --dir packages/${PACKAGE} --version ${VERSION}\`." + fi + printf '%s\n' "$notes" > "${RUNNER_TEMP}/notes.md" + + latest_flag="--latest=false" + [ "$LATEST" = "true" ] && latest_flag="--latest" + + if [ "$DRY_RUN" = "true" ]; then + [ "$tag_exists" = "true" ] && echo "::warning::Release ${TAG} already exists; a real run would fail here." + { + echo "## 🔍 Dry run: \`${PACKAGE}\` ${VERSION}" + echo "" + echo "Would create tag \`${TAG}\` at \`${TARGET}\` (${latest_flag})." + if [ -n "$VANITY_TAG" ]; then + echo "Would also create vanity tag \`${VANITY_TAG}\` (no release)." + fi + echo "" + echo "
Notes" + echo "" + cat "${RUNNER_TEMP}/notes.md" + echo "" + echo "
" + } >> "$GITHUB_STEP_SUMMARY" + echo "release-url=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + gh release create "$TAG" \ + --repo "$GITHUB_REPOSITORY" \ + --target "$TARGET" \ + --title "\`${PACKAGE}\` ${VERSION}" \ + --notes-file "${RUNNER_TEMP}/notes.md" \ + $latest_flag + + url=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json url --jq .url) + echo "release-url=${url}" >> "$GITHUB_OUTPUT" + + # Vanity tag: a bare ref continuing the legacy series, no second + # release. Cosmetic, so a collision warns rather than failing the + # already-published release. + if [ -n "$VANITY_TAG" ]; then + if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${VANITY_TAG}" >/dev/null 2>&1; then + echo "::warning::Vanity tag ${VANITY_TAG} already exists; leaving it untouched." + else + gh api "repos/${GITHUB_REPOSITORY}/git/refs" \ + -f ref="refs/tags/${VANITY_TAG}" \ + -f sha="$TARGET" >/dev/null + echo "Created vanity tag ${VANITY_TAG} at ${TARGET}." + fi + fi + + { + echo "## 📦 Released \`${PACKAGE}\` ${VERSION}" + echo "" + echo "Tag \`${TAG}\`: [published GitHub Release](${url})." + if [ -n "$VANITY_TAG" ]; then + echo "" + echo "Vanity tag \`${VANITY_TAG}\` continues the legacy series (no separate release)." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/create-package-release/extract_release_notes.py b/.github/actions/create-package-release/extract_release_notes.py new file mode 100644 index 000000000..860214717 --- /dev/null +++ b/.github/actions/create-package-release/extract_release_notes.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 + +""" +Extract one package's release notes from its CHANGELOG.md. + +Run by the `Publish GitHub release` workflow. Prints the changelog section for the +given version (the block from `## []` up to the next `## [` heading, +stripped). Prints nothing if the changelog or the section is absent, so the +caller can fall back to a default message. + +Usage: + extract_release_notes.py +""" + +from pathlib import Path +import sys + + +def extract(version: str, changelog: str) -> str: + """Return the trimmed `## []` section, or "" if not found.""" + path = Path(changelog) + if not path.is_file(): + return "" + + lines = path.read_text(encoding="utf-8").splitlines() + start = next( + (i for i, line in enumerate(lines) if line.startswith(f"## [{version}]")), + None, + ) + if start is None: + return "" + + end = next( + (j for j in range(start + 1, len(lines)) if lines[j].startswith("## [")), + len(lines), + ) + return "\n".join(lines[start:end]).strip() + + +def main() -> None: + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(2) + + sys.stdout.write(extract(sys.argv[1], sys.argv[2])) + + +if __name__ == "__main__": + main() diff --git a/.github/actions/detect-version-bumps/action.yml b/.github/actions/detect-version-bumps/action.yml new file mode 100644 index 000000000..22cab9ab0 --- /dev/null +++ b/.github/actions/detect-version-bumps/action.yml @@ -0,0 +1,46 @@ +name: Detect version bumps +description: > + Detects per-package version bumps between two commits. + + Composes the shared `package_versions.py diff` (reads versions from git, + enforces the major-bump cascade) with `detect_version_bumps.py` (filters + the diff to releasable bumps). Any `..` increase is a + bump; version decreases and non-cascaded major bumps fail the action (see + docs/versioning.md). + + Prerequisites: repo must be checked out with `fetch-depth: 0` so `before` + is reachable. + +inputs: + before: + description: The base commit SHA to compare against (e.g. github.event.before). + required: true + +outputs: + count: + description: Number of bumped packages. + value: ${{ steps.filter.outputs.count }} + bumps: + description: > + JSON array of {"package", "version", "tag"} objects, one per bump. + Suitable as a matrix include list. + value: ${{ steps.filter.outputs.bumps }} + +runs: + using: composite + steps: + - name: Diff package versions + id: diff + shell: bash + env: + BEFORE: ${{ inputs.before }} + run: | + python3 ./.github/workflows/scripts/package_versions.py diff "$BEFORE" HEAD \ + > "${RUNNER_TEMP}/package-version-diff.json" + + - name: Filter to releasable bumps + id: filter + shell: bash + run: | + python3 "${GITHUB_ACTION_PATH}/detect_version_bumps.py" \ + < "${RUNNER_TEMP}/package-version-diff.json" >> "$GITHUB_OUTPUT" diff --git a/.github/actions/detect-version-bumps/detect_version_bumps.py b/.github/actions/detect-version-bumps/detect_version_bumps.py new file mode 100644 index 000000000..fabdd9aef --- /dev/null +++ b/.github/actions/detect-version-bumps/detect_version_bumps.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + +""" +Filter a package version diff down to releasable bumps. + +Reads the JSON array produced by `package_versions.py diff` on stdin: + + [ {"package": "p1", "before": "v1", "after": "v2"}, ... ] + +and emits `$GITHUB_OUTPUT` lines on stdout (progress goes to stderr): + + count= Number of bumped packages. + bumps= JSON array of {"package", "version", "tag"} objects, one per + bump. Consumed as a matrix by the release job. + +Policy applied (see docs/versioning.md): + - Released versions must be plain `..`; PEP 440 + variants like `1.2.3rc4` fail loudly. + - A version decrease fails: it must never land on main. + - Added packages (`before` null) and removed packages (`after` null) are + not releases; they are skipped. + +The `detect-version-bumps` action composes this with `package_versions.py`, +which owns reading versions from git (and enforces the major-bump cascade +via its own exit status). + +Exit status: + 0 Success (including the no-bump case). + 1 A version is not plain X.Y.Z, or went backwards. +""" + +import json +import re +import sys + +# Released versions are plain X.Y.Z by policy (docs/versioning.md); PEP 440 +# variants like 1.2.3rc4 or 1.2.3.post1 must not appear in pyproject.toml. +PLAIN_SEMVER = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + +def semver(package: str, version: str) -> tuple[int, int, int]: + match = PLAIN_SEMVER.match(version) + if not match: + raise ValueError( + f"{package}: version {version!r} is not plain ..; " + "pre-release/post-release segments are not allowed in pyproject.toml " + "(see docs/versioning.md)" + ) + return int(match.group(1)), int(match.group(2)), int(match.group(3)) + + +def info(message: str) -> None: + print(message, file=sys.stderr) + + +def main() -> None: + changes = json.load(sys.stdin) + + bumps: list[dict[str, str]] = [] + errors: list[str] = [] + + for change in changes: + package = change["package"] + before_raw = change["before"] + after_raw = change["after"] + + if before_raw is None or after_raw is None: + info(f"{package}: added or removed, not a release. Skipping.") + continue + + before = semver(package, before_raw) + after = semver(package, after_raw) + + if after < before: + errors.append(f"{package}: {before_raw} -> {after_raw}") + continue + + info(f"{package}: {before_raw} -> {after_raw} (bump)") + bumps.append({"package": package, "version": after_raw, "tag": f"{package}-v{after_raw}"}) + + if errors: + for e in errors: + info( + f"::error::{e}: version went backwards. Version decreases " + "must never land on main; revert or fix the version." + ) + sys.exit(1) + + if not bumps: + info("No version bumps detected.") + + print(f"count={len(bumps)}") + print(f"bumps={json.dumps(bumps)}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/compute-versions-dry-run.yaml b/.github/workflows/compute-versions-dry-run.yaml index fe30628d1..5c8ea99f3 100644 --- a/.github/workflows/compute-versions-dry-run.yaml +++ b/.github/workflows/compute-versions-dry-run.yaml @@ -27,7 +27,7 @@ on: context: description: "Version context to simulate" type: choice - options: [vnext, main, main-bump] + options: [vnext, main] default: vnext permissions: diff --git a/.github/workflows/enforce-change-type-label.yaml b/.github/workflows/enforce-change-type-label.yaml index 9f476feec..2264c4652 100644 --- a/.github/workflows/enforce-change-type-label.yaml +++ b/.github/workflows/enforce-change-type-label.yaml @@ -14,34 +14,35 @@ concurrency: jobs: check-label: name: Check label - runs-on: ubuntu-latest + runs-on: ubuntu-slim permissions: contents: read # Required for reading PR labels steps: - name: Require exactly one change type label - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const allChangeTypeLabels = new Set([ - 'change type - cosmetic 🌹', - 'change type - documentation - docs team 📝', - 'change type - documentation - member 📝', - 'change type - major 🚨', - 'change type - minor 🤏', - 'automation 🦾', - ]); - const prLabels = context.payload.pull_request.labels.map(label => label.name); - const appliedChangeTypeLabels = prLabels.filter(prLabel => allChangeTypeLabels.has(prLabel)); - if (appliedChangeTypeLabels.length !== 1) { - const baseMessage = `The PR must have EXACTLY one of the following CHANGE TYPE labels: ${Array.from(allChangeTypeLabels).sort().join(', ')}. ` - const n = appliedChangeTypeLabels.length; - let contextualMessage; - if (n === 0) { - contextualMessage = 'It currently has no change type label. Please ➕ add one label. 🙏' - } else { - contextualMessage = `It currently has ${n} change type labels (${JSON.stringify(appliedChangeTypeLabels)}). 🙏 Please ❌ remove ${n-1} label(s).` - } - core.setFailed(baseMessage + contextualMessage); - } + run: | + set -euo pipefail + + change_type_labels='change type - cosmetic 🌹 + change type - documentation - docs team 📝 + change type - documentation - member 📝 + change type - major 🚨 + change type - minor 🤏 + automation 🦾' + + applied=$(jq -r '.pull_request.labels[].name' "$GITHUB_EVENT_PATH" \ + | grep -Fxf <(echo "$change_type_labels" | sed 's/^[[:space:]]*//') || true) + count=$(echo "$applied" | grep -c . || true) + + if [ "$count" -ne 1 ]; then + echo "::error::The PR must have EXACTLY one change type label; it has ${count}." + echo "Allowed labels:" + echo "$change_type_labels" | sed 's/^[[:space:]]*/ - /' + if [ "$count" -gt 1 ]; then + echo "Currently applied:" + echo "$applied" | sed 's/^/ - /' + fi + exit 1 + fi + echo "Change type label check passed: ${applied}" \ No newline at end of file diff --git a/.github/workflows/publish-python-packages.yaml b/.github/workflows/publish-python-packages.yaml index 627914224..c2502297e 100644 --- a/.github/workflows/publish-python-packages.yaml +++ b/.github/workflows/publish-python-packages.yaml @@ -66,7 +66,7 @@ jobs: persist-credentials: false - name: Sync code to make packages visible to Python - run: uv sync --all-packages + run: uv sync --locked --all-packages - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 diff --git a/.github/workflows/release-trigger.yaml b/.github/workflows/release-trigger.yaml new file mode 100644 index 000000000..b54b7b75c --- /dev/null +++ b/.github/workflows/release-trigger.yaml @@ -0,0 +1,86 @@ +name: Publish GitHub release + +# Runs on every push to main that touches any package's pyproject.toml. +# For each package whose .. was bumped, cuts a published +# GitHub Release tagged `-v`, titled `` `` ``, +# with notes taken from that package's CHANGELOG.md section. +# +# Packages version independently (see docs/versioning.md). The umbrella +# `overture-schema` release is marked "Latest"; all others are not. +# +# Release notes come from towncrier: a release PR bumps the version and runs +# `uv run towncrier build`, folding that package's changelog.d/ fragments into its +# CHANGELOG.md (reviewed in the PR). This workflow reads back the section. +# +# All three version components are human-owned; any increase (patch included) +# is a release. No-bump merges publish internal `.postN` builds instead (see +# .github/actions/compute-version). +# +# NOTE: releases are created with GITHUB_TOKEN, which by design does NOT trigger +# further workflow runs. The Phase 3 publish workflow (#509) must therefore be +# triggered by an app/PAT token here or via repository_dispatch, a plain +# `on: release: published` listener will not fire for these releases. + +on: + push: + branches: [main] + paths: + - packages/*/pyproject.toml + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + detect: + name: Detect version bumps + if: github.event.repository.full_name == github.repository + runs-on: ubuntu-slim + permissions: + contents: read # Read pyproject.toml history to detect bumps + outputs: + count: ${{ steps.detect.outputs.count }} + bumps: ${{ steps.detect.outputs.bumps }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Detect version bumps + id: detect + uses: ./.github/actions/detect-version-bumps + with: + before: ${{ github.event.before }} + + release: + name: Release ${{ matrix.package }} ${{ matrix.version }} + needs: detect + if: needs.detect.outputs.count != '0' + runs-on: ubuntu-slim + permissions: + contents: write # Required to create releases and their tags + strategy: + fail-fast: false # One package's failure must not block sibling releases + matrix: + include: ${{ fromJSON(needs.detect.outputs.bumps) }} + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Create release + uses: ./.github/actions/create-package-release + with: + package: ${{ matrix.package }} + version: ${{ matrix.version }} + tag: ${{ matrix.tag }} + target: ${{ github.event.after }} + latest: ${{ matrix.package == 'overture-schema' }} + # The umbrella package continues the legacy bare tag series as a + # vanity tag (no second release); see docs/versioning.md. + vanity-tag: ${{ matrix.package == 'overture-schema' && format('v{0}', matrix.version) || '' }} + token: ${{ github.token }} diff --git a/.github/workflows/require-changelog-fragment.yaml b/.github/workflows/require-changelog-fragment.yaml new file mode 100644 index 000000000..2a7c249ba --- /dev/null +++ b/.github/workflows/require-changelog-fragment.yaml @@ -0,0 +1,77 @@ +name: Changelog fragment verification + +# Enforces the changelog policy: any PR that changes a package must also carry +# that package's changelog update, either a new towncrier fragment under +# changelog.d/, or a built CHANGELOG.md (the result of running +# `uv run towncrier build`). +# +# "Changes a package" means any file under packages// other than that +# package's own changelog artifacts (changelog.d/ and CHANGELOG.md). Every +# user-facing change gets a fragment, regardless of whether it bumps the +# version (see docs/versioning.md). + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + check-fragment: + name: Require changelog on package change + runs-on: ubuntu-slim + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 # The merge base with the PR base branch must be reachable + persist-credentials: false + + - name: Require a changelog update for each changed package + env: + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + + changed=$(git diff --name-only "origin/${BASE_REF}...HEAD") + + # Packages with changes outside their own changelog artifacts. + touched=$(echo "$changed" \ + | grep -oE '^packages/[^/]+/' \ + | sort -u \ + | while read -r prefix; do + pkg=$(basename "$prefix") + if echo "$changed" | grep -E "^packages/${pkg}/" \ + | grep -vE "^packages/${pkg}/(changelog\.d/|CHANGELOG\.md$)" \ + | grep -q .; then + echo "$pkg" + fi + done) + + missing="" + for pkg in $touched; do + has_fragment=$(echo "$changed" \ + | grep -cE "^packages/${pkg}/changelog\.d/.+\.(breaking|feature|bugfix|docs|misc)\.md$" || true) + has_changelog=$(echo "$changed" \ + | grep -cE "^packages/${pkg}/CHANGELOG\.md$" || true) + if [ "$has_fragment" -eq 0 ] && [ "$has_changelog" -eq 0 ]; then + missing="${missing} - ${pkg}"$'\n' + fi + done + + if [ -n "$missing" ]; then + echo "::error::These packages changed but include no changelog update:" + printf '%s' "$missing" + echo "" + echo "Fix: one markdown file per package, named packages//changelog.d/..md" + echo "(type: breaking | feature | bugfix | docs | misc), body = one past-tense sentence." + echo "Quick start: docs/versioning.md#changelog-quick-start" + exit 1 + fi + echo "Changelog fragment check passed." \ No newline at end of file diff --git a/.github/workflows/reusable-check-python-package-versions.yaml b/.github/workflows/reusable-check-python-package-versions.yaml index da94117bc..6f2d68258 100644 --- a/.github/workflows/reusable-check-python-package-versions.yaml +++ b/.github/workflows/reusable-check-python-package-versions.yaml @@ -1,5 +1,9 @@ name: "[REUSABLE] Check Python package versions" +# Diffs packages/*/pyproject.toml versions between two commits, entirely from +# git blobs (single checkout, no environment sync), and verifies that any new +# version does not already exist in CodeArtifact. + on: workflow_call: inputs: @@ -67,60 +71,27 @@ jobs: changed_packages: ${{ steps.save-changes.outputs.changed_packages }} num_changed_packages: ${{ steps.save-changes.outputs.num_changed_packages }} steps: - - name: Install jq - run: sudo apt-get update && sudo apt-get install -y jq - - - name: Install uv - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 - with: - version: latest - - - name: Check out code before change + - name: Check out code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: ${{ inputs.before_commit }} + fetch-depth: 0 # Both comparison commits must be reachable persist-credentials: false - - name: Set up Python - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version-file: .python-version - - - name: Sync code before change to make packages visible to Python - run: uv sync --all-packages - - - name: Capture package versions before change - run: uv run python ./.github/workflows/scripts/package-versions.py collect > /tmp/package-versions-before.json - - - name: Check out code after change - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ inputs.after_commit }} - persist-credentials: false - - - name: Sync code after change to make packages visible to Python - run: uv sync --all-packages --refresh - - - name: Capture package versions after change - run: uv run python ./.github/workflows/scripts/package-versions.py collect > /tmp/package-versions-after.json - - - name: Compare package versions before and after change - run: | - uv run python ./.github/workflows/scripts/package-versions.py compare \ - /tmp/package-versions-before.json \ - /tmp/package-versions-after.json \ - >/tmp/package-version-diff.json - - - name: Print changed versions - run: cat /tmp/package-version-diff.json - - - name: Save changed versions as output + - name: Diff package versions id: save-changes + env: + BEFORE: ${{ inputs.before_commit }} + AFTER: ${{ inputs.after_commit }} run: | - echo 'changed_packages<> $GITHUB_OUTPUT - cat /tmp/package-version-diff.json >> $GITHUB_OUTPUT - echo EOF >> $GITHUB_OUTPUT - printf 'num_changed_packages=%s\n' "$(jq -c '. | length' /tmp/package-version-diff.json)" >> $GITHUB_OUTPUT + python3 ./.github/workflows/scripts/package_versions.py diff "$BEFORE" "$AFTER" \ + > /tmp/package-version-diff.json + cat /tmp/package-version-diff.json + { + echo 'changed_packages<> "$GITHUB_OUTPUT" - name: Configure AWS credentials if: steps.save-changes.outputs.num_changed_packages > 0 @@ -149,8 +120,12 @@ jobs: jq -c '.[]' /tmp/package-version-diff.json | while read -r entry; do package=$(echo "$entry" | jq -r '.package') after=$(echo "$entry" | jq -r '.after') + if [ "$after" = "null" ]; then + echo "Package ${package} was removed. Skipping existence check." + continue + fi exit_code=0 - output=$(uv run pip download "${package}==${after}" --index-url "${INDEX_URL}" --no-deps -d /tmp --quiet 2>&1) || exit_code=$? + output=$(python3 -m pip download "${package}==${after}" --index-url "${INDEX_URL}" --no-deps -d /tmp --quiet 2>&1) || exit_code=$? if [[ $exit_code -eq 0 || ( "${output,,}" != *"could not find a version"* && "${output,,}" != *"no matching distributions"* @@ -162,4 +137,4 @@ jobs: else echo "Package ${package} version ${after} is new, as expected. Continuing." fi - done + done \ No newline at end of file diff --git a/.github/workflows/scripts/package-versions.py b/.github/workflows/scripts/package-versions.py deleted file mode 100755 index 575827218..000000000 --- a/.github/workflows/scripts/package-versions.py +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env python3 - -from importlib import metadata -from pathlib import Path -import json -import re -import sys - - -def collect(): - """ - Collect Python package versions and print them as a JSON array. - - Form of the JSON array: - - [ {"package": "p1", "version": "v1"}, {"package": "p2", "version": "v2"}, ... ] - """ - packages_dir = Path("packages") - - packages = sorted( - d.name - for d in packages_dir.iterdir() - if d.is_dir() and d.name.startswith("overture-schema") and (d / "pyproject.toml").exists() - ) - - package_versions = [ - {"package": p, "version": metadata.version(p.replace("-", "."))} - for p in packages - ] - - print(json.dumps(package_versions, indent=2)) - - -def compare(before_file: str, after_file: str): - """ - Compare two JSON files containing package versions and print the packages that have a version - number change as a JSON array. - - The output JSON array is sorted in topological order by package name, so those changed packages - that do not depend on other changed packages appear first. - - Form of the JSON array: - - [ {"package": "p1", "before": "v1", "after": "v2"}, ... ] - - Note that `before` will be `null` if the package did not exist in the "before" file, and `after` - will be `null` if the package did not exist in the "after" file. - """ - before_array = load(before_file) - after_array = load(after_file) - - before_dict = {item["package"]: item["version"] for item in before_array} - after_dict = {item["package"]: item["version"] for item in after_array} - - def level(package: str) -> int: - """ - Return the level of a package for topological sorting. - - This is brittle and hard to keep in sync, so we should replace it with a version that - dynamically computes dependencies in the future. - """ - if package == "overture-schema-system": - return 0 - elif package in ["overture-schema-common", "overture-schema-core"]: - return 1 - elif re.fullmatch(r'overture-schema-.*-theme', package) or package in ["overture-schema", "overture-schema-cli", "overture-schema-codegen", "overture-schema-pyspark"]: - return 2 - else: - raise ValueError(f"Unknown package for level computation: {package}") - - combined_keys = sorted(list(set(before_dict.keys()) | set(after_dict.keys())), key=level) - - changed_packages = [] - for package in combined_keys: - before_version = before_dict.get(package) - after_version = after_dict.get(package) - if before_version != after_version: - changed_packages.append( - { - "package": package, - "before": before_version, - "after": after_version, - } - ) - - print(json.dumps(changed_packages, indent=2)) - - -def load(file_path: str) -> list[dict[str, str]]: - path = Path(file_path) - if not path.exists(): - print(f"File not found: {file_path}") - sys.exit(1) - - with path.open() as f: - value = json.load(f) - - if not isinstance(value, list): - print( - f"File {file_path} contains unexpected root value: expected a `list` but got value {repr(value)} of type `{type(value).__name__}`" - ) - sys.exit(1) - - for i, item in enumerate(value): - if not isinstance(item, dict): - print( - f"File {file_path} contains unexpected item at index {i}: expected `dict` but got value {repr(item)} of type `{type(item).__name__}`" - ) - sys.exit(1) - elif sorted(item.keys()) != ["package", "version"]: - print( - f"File {file_path} contains unexpected item at index {i}: expected keys `['package', 'version']` but got keys {sorted(item.keys())}" - ) - sys.exit(1) - elif not isinstance(item["package"], str): - print( - f"File {file_path} contains unexpected item at index {i}: expected `package` to be of type `str` but got value {repr(item['package'])} of type `{type(item['package']).__name__}`" - ) - sys.exit(1) - elif not isinstance(item["version"], str): - print( - f"File {file_path} contains unexpected item at index {i}: expected `version` to be of type `str` but got value {repr(item['version'])} of type `{type(item['version']).__name__}`" - ) - sys.exit(1) - - return value - - -def usage(): - print("Usage:") - print(f" ./{sys.argv[0]} collect") - print(f" ./{sys.argv[0]} compare BEFORE_FILE AFTER_FILE") - sys.exit(1) - - -def main(): - if len(sys.argv) < 2: - usage() - - cmd = sys.argv[1] - - if cmd == "collect": - collect() - elif cmd == "compare": - if len(sys.argv) != 4: - usage() - compare(sys.argv[2], sys.argv[3]) - else: - print(f"Unknown command: {cmd}") - usage() - - -if __name__ == "__main__": - main() diff --git a/.github/workflows/scripts/package_versions.py b/.github/workflows/scripts/package_versions.py new file mode 100644 index 000000000..c13e7a6f2 --- /dev/null +++ b/.github/workflows/scripts/package_versions.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 + +""" +Diff per-package versions between two git commits. + +Run from the repository root: + + python3 package_versions.py diff + +Reads each `packages/*/pyproject.toml` blob directly from git at both commits +(no checkout switching, no environment sync) and prints the packages whose +version changed as a JSON array, topologically sorted so that packages with no +changed dependencies come first. The dependency order is derived from each +package's declared `project.dependencies`, restricted to workspace members. + +Also enforces the major-bump cascade: a package whose direct workspace +dependency takes a major bump must take one itself (see +`check_major_cascade`). + +Form of the JSON array: + + [ {"package": "p1", "before": "v1", "after": "v2"}, ... ] + +`before` is null if the package did not exist at the before commit; `after` is +null if it no longer exists at the after commit. + +Exit status: + 0 Success. + 1 Usage error, or a major bump that does not cascade to its dependents. +""" + +from graphlib import TopologicalSorter +import json +import re +import subprocess +import sys +import tomllib + +PACKAGES_DIR = "packages" + +# The distribution name at the start of a PEP 508 requirement string. +REQUIREMENT_NAME = re.compile(r"^\s*([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)") + + +def git(*args: str) -> str: + result = subprocess.run(["git", *args], capture_output=True, check=True) + return result.stdout.decode("utf-8") + + +def package_manifests(commit: str) -> dict[str, dict]: + """Map package directory name -> parsed pyproject.toml at `commit`.""" + try: + listing = git("ls-tree", "--name-only", commit, f"{PACKAGES_DIR}/") + except subprocess.CalledProcessError as e: + # Expected when the commit is unreadable (force-push, history rewrite) + # or predates the packages directory; anything else deserves eyes. + print( + f"::notice::No readable {PACKAGES_DIR}/ tree at {commit} " + "(force-push or no packages directory yet); treating as empty.", + file=sys.stderr, + ) + stderr = e.stderr.decode("utf-8", errors="replace").strip() + print(f"::debug::git ls-tree failed: {stderr}", file=sys.stderr) + return {} + + manifests: dict[str, dict] = {} + for line in listing.splitlines(): + package = line.removeprefix(f"{PACKAGES_DIR}/") + try: + blob = git("show", f"{commit}:{PACKAGES_DIR}/{package}/pyproject.toml") + except subprocess.CalledProcessError: + continue # not a package directory + manifests[package] = tomllib.loads(blob) + return manifests + + +def topo_order(manifests: dict[str, dict]) -> list[str]: + """Package names sorted so dependencies come before their dependents.""" + graph = dependency_graph(manifests) + return list(TopologicalSorter(graph).static_order()) + + +def dependency_graph(manifests: dict[str, dict]) -> dict[str, set[str]]: + """Map each package directory name to its direct workspace dependencies.""" + dist_to_dir = { + str(m["project"]["name"]): package for package, m in manifests.items() + } + graph: dict[str, set[str]] = {} + for package, manifest in manifests.items(): + deps = set() + for requirement in manifest["project"].get("dependencies", []): + match = REQUIREMENT_NAME.match(str(requirement)) + if match and match.group(1) in dist_to_dir: + deps.add(dist_to_dir[match.group(1)]) + graph[package] = deps + return graph + + +def manifest_version(manifests: dict[str, dict], package: str) -> str | None: + """Static `project.version` of `package`, or None if absent/dynamic.""" + manifest = manifests.get(package) + if manifest is None: + return None + value = manifest["project"].get("version") + return str(value) if value is not None else None + + +def check_major_cascade( + before_manifests: dict[str, dict], after_manifests: dict[str, dict] +) -> list[str]: + """ + Enforce that major bumps cascade up the dependency tree. + + Workspace dependency floors are declared statically in each package's + `project.dependencies`, so a major bump of a dependency is a breaking + change behind every dependent's existing floor. A package whose direct + workspace dependency takes a major bump must therefore, in the same + change, take a major bump itself and raise its floor on that dependency + to the new major. Checking direct dependencies is enough: each unbumped + link in a longer chain fails its own check. + + Returns a list of violation descriptions (empty when compliant). + """ + + def major(version: str | None) -> int | None: + return int(version.split(".")[0]) if version else None + + def floor_major(manifests: dict[str, dict], package: str, dep: str) -> int | None: + """Major of `package`'s declared floor on distribution `dep`, if any.""" + manifest = manifests.get(package) + if manifest is None: + return None + for requirement in manifest["project"].get("dependencies", []): + requirement = str(requirement) + match = REQUIREMENT_NAME.match(requirement) + if not match or match.group(1) != dep: + continue + version_match = re.search(r">=\s*(\d+)", requirement) + return int(version_match.group(1)) if version_match else None + return None + + dist_names = { + package: str(m["project"]["name"]) for package, m in after_manifests.items() + } + + errors = [] + for package, deps in dependency_graph(after_manifests).items(): + pkg_before = major(manifest_version(before_manifests, package)) + pkg_after = major(manifest_version(after_manifests, package)) + pkg_bumped = pkg_before is not None and pkg_after is not None and pkg_after > pkg_before + + for dep in sorted(deps): + dep_before = major(manifest_version(before_manifests, dep)) + dep_after = major(manifest_version(after_manifests, dep)) + if dep_before is None or dep_after is None or dep_after <= dep_before: + continue + if not pkg_bumped: + errors.append( + f"{package} depends on {dep}, which takes a major bump " + f"({dep_before}.x -> {dep_after}.x), but {package} does not. " + "Major bumps must cascade to dependents." + ) + floor = floor_major(after_manifests, package, dist_names[dep]) + if floor is not None and floor < dep_after: + errors.append( + f"{package} declares a floor of {dist_names[dep]}>={floor} " + f"but {dep} is now {dep_after}.x. Raise the floor to the " + "new major so published metadata cannot admit the old one." + ) + return errors + + +def diff(before: str, after: str) -> None: + before_manifests = package_manifests(before) + after_manifests = package_manifests(after) + + # Order from the after commit, which knows about newly added packages; + # packages that only exist in before (deleted) are appended at the end. + order = topo_order(after_manifests) + order += sorted(set(before_manifests) - set(after_manifests)) + + changed = [ + {"package": p, "before": b, "after": a} + for p in order + if (b := manifest_version(before_manifests, p)) + != (a := manifest_version(after_manifests, p)) + ] + print(json.dumps(changed, indent=2)) + + violations = check_major_cascade(before_manifests, after_manifests) + if violations: + for v in violations: + print(f"::error::{v}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + if len(sys.argv) != 4 or sys.argv[1] != "diff": + print(f"Usage: {sys.argv[0]} diff BEFORE_COMMIT AFTER_COMMIT", file=sys.stderr) + sys.exit(1) + diff(sys.argv[2], sys.argv[3]) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfe6c6e3a..cf31d7a4a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,175 +2,137 @@ Thank you for your interest in contributing. -## Branching Strategy +> **The branching and versioning strategy is rolling out in phases.** See the +> [DevOps tracking issue #490](https://github.com/OvertureMaps/schema/issues/490) +> for current status and what is planned next. -> **Work in progress.** This strategy is being rolled out incrementally. See the [DevOps tracking issue #490](https://github.com/OvertureMaps/schema/issues/490) for current status and upcoming phases. +## Where to send your change -This repository uses a two-branch model. Choose your target branch based on the nature of your change. See the [Change Classification](https://lf-overturemaps.atlassian.net/wiki/spaces/SCHEM/pages/14286874/Schema+versioning+and+stability#Change-Classification) wiki page for a detailed breakdown of what constitutes a minor vs. major change. +This repository uses a two-branch model. Target the branch that matches your +change; when in doubt, target `main` and note in your PR if you think it belongs +in `vnext`. The +[Change Classification](https://lf-overturemaps.atlassian.net/wiki/spaces/SCHEM/pages/14286874/Schema+versioning+and+stability#Change-Classification) +wiki page breaks down what counts as a minor vs. major change. -| Branch | Purpose | +| Branch | Use for | |--------|---------| | `main` | Default branch. Bug fixes, minor features, schema improvements. | | `vnext` | Major or breaking changes tied to an active `vnext` milestone. | -When in doubt, target `main` and note in your PR description if you think it belongs in `vnext`. +Three common paths take a branch to a merge. Expand each for the commit-level flow. -### Normal contribution (`main`) +
+main → everyday change (no version bump) + +Everyday fixes and schema tweaks that don't warrant an immediate release. You +do not touch the version; on merge CI publishes an interim internal build +(`.postN+main.`) to CodeArtifact, where it is consumable +immediately. No GitHub Release is cut and nothing lands on public PyPI; the +change reaches public PyPI with the package's next version bump (patch, minor, +or major). ```mermaid gitGraph - commit id: "prior work" - commit id: "prior work 2" - branch vnext - branch feature-branch - checkout feature-branch - commit id: "your work" - commit id: "your work 2" + commit id: "places-theme-v0.4.0" + branch fix-places-brand-enum + checkout fix-places-brand-enum + commit id: "fix brand enum values" + commit id: "add bugfix fragment" checkout main - merge feature-branch id: "merge PR" - commit id: "next work" + merge fix-places-brand-enum id: "PR #561 (CodeArtifact 0.4.0.postN)" + commit id: "more fixes" ``` -### Major / breaking change (`vnext`) +
+ +
+main → patch or minor release (version bump) + +A bug fix or minor feature that bumps the version in the PR and builds the +changelog. On merge, `release-trigger` cuts a published GitHub Release and the +new version lands on PyPI, immediately available to consumers. ```mermaid gitGraph - commit id: "prior work" - branch vnext - checkout vnext - branch feature-a - checkout feature-a - commit id: "work A" - checkout vnext - merge feature-a - branch feature-b - checkout feature-b - commit id: "work B" - checkout vnext - merge feature-b + commit id: "base-theme-v0.1.0" + branch feat-base-land-cover + checkout feat-base-land-cover + commit id: "add land_cover subtype" + commit id: "bump 0.1 to 0.2 + build changelog" checkout main - merge vnext id: "release" + merge feat-base-land-cover id: "PR #564" tag: "base-theme-v0.2.0" commit id: "next work" ``` -## Branch Protections - -Both `main` and `vnext` require a PR and at least two approving reviews before merge. No direct pushes. - -## CI Checks - -### PR target check (advisory) - -Every PR runs an advisory label-vs-target check. It **never blocks** a merge — the reviewer is the -source of truth for change classification. - -| Situation | Warning | -|-----------|---------| -| PR targets `vnext`, label is not `change type - major 🚨` | Consider targeting `main` instead | -| PR targets `main`, label is `change type - major 🚨` | Consider targeting `vnext` instead | - -### vnext compatibility check - -Every PR targeting `main` runs a compatibility check: - -1. The PR is squash-simulated onto `main` in a throwaway clone. -2. `vnext` is dry-run rebased onto the result. -3. If there is no conflict — the check passes silently. -4. If there is a conflict — the check **fails** and CI posts a comment with exact commands. - -**Skipped** for `vnext`→`main` release PRs. - -#### Resolving a vnext conflict - -If this check flags your PR, CI will post a comment listing the conflicting files. Do **not** rebase -your branch onto `vnext` — that would pull unreleased breaking changes into `main`. - -1. See exactly what `vnext` changes in the conflicting files: - ```bash - git fetch origin - git diff origin/main...origin/vnext -- - ``` -2. Open each conflicting file in your editor. The diff above shows what `vnext` adds or changes - there — adjust your edits so they no longer overlap with those lines. -3. Commit the adjustment and push: - ```bash - git add - git commit -m "fix: resolve vnext compatibility" - git push origin your-branch - ``` +
-After pushing, the check re-runs automatically. +
+vnext → major release -### Post-merge vnext rebase +Breaking changes stack on `vnext` until the milestone is ready. Then `vnext` +merges into `main` as a regular merge (not a squash), which cuts a published +GitHub Release and puts the new major on PyPI for consumers. -When any PR merges to `main`, `vnext` is automatically force-rebased onto the new `main` HEAD -using the `overture-pull-requester` GitHub App. - -**Skipped** for `vnext`→`main` release merges — `vnext` is already equal to `main` at that point. - -If the automatic rebase fails, a GitHub issue is opened and assigned to the author of the merged PR. - -> **Accepted tradeoff — in-flight PRs targeting `vnext`:** after the automatic rebase, the base of -> any open PR that targets `vnext` will be force-updated. If you have such a PR open, run -> `git pull --rebase` (or `git fetch origin && git rebase origin/vnext`) on your branch before -> pushing again. - -### Version dry-run (informational) - -After each push to `main` or `vnext`, CI runs the `compute-versions-dry-run` workflow. It logs what package versions **would** be stamped at publish time — no artifacts are actually produced. Check the workflow's job summary for a table of computed versions. - -This workflow will be replaced by actual publish workflows in Phase 3. - -## Migration Notes - -> **Roadmap:** this branching strategy is rolled out in phases, tracked under the parent -> issue [#490](https://github.com/OvertureMaps/schema/issues/490). When Phases 0-4 are -> complete, this section can be removed in favor of more permanent documentation. - -| Phase | Status | Delivers | -|-------|--------|----------| -| [0](https://github.com/OvertureMaps/schema/issues/506) | ✅ Done | Switch from `dev`/`staging` to the `main`/`vnext` model. | -| [1](https://github.com/OvertureMaps/schema/issues/507) | ✅ Done | CI guardrails: PR target check, vnext compatibility check, automatic post-merge rebase. | -| [2.A](https://github.com/OvertureMaps/schema/issues/508) | ✅ Done | Version baselines + `compute-version` action. Computes versions only — nothing is published yet. | -| [2.B](https://github.com/OvertureMaps/schema/issues/533) | 🚧 Next | Detect a `.` bump landing on `main` and cut the GitHub Release that triggers a public publish. | -| [3](https://github.com/OvertureMaps/schema/issues/509) | ⏳ Planned | The actual publish workflows: `vnext` dev builds to CodeArtifact, `main` patch builds, and public PyPI releases. | -| [4](https://github.com/OvertureMaps/schema/issues/510) | ⏳ Planned | Documentation polish — diagrams, contributor walkthroughs, FAQ. | - -### [Phase 0](https://github.com/OvertureMaps/schema/issues/506), May 2026 - -- `main` was fast-forwarded to the former `dev` HEAD. -- All open PRs were retargeted `dev` → `main` automatically. -- `dev` and `staging` branches were deleted. -- `vnext` was created from the new `main`. - -If your fork still references `dev` or `staging`, update your remotes accordingly. +```mermaid +gitGraph + commit id: "transportation-theme-v0.5.0" + branch vnext + checkout vnext + branch feat-access-restructure + checkout feat-access-restructure + commit id: "breaking: restructure access" + commit id: "add breaking fragment" + checkout vnext + merge feat-access-restructure id: "PR #570" + branch feat-segment-model + checkout feat-segment-model + commit id: "breaking: new segment model" + commit id: "add breaking fragment" + checkout vnext + merge feat-segment-model id: "PR #572" + commit id: "bump 0.5 to 1.0 + build changelog" + checkout main + merge vnext id: "release merge (not squash)" tag: "transportation-theme-v1.0.0" + commit id: "next patch work" +``` -### [Phase 1](https://github.com/OvertureMaps/schema/issues/507), May 2026 +
-- Advisory PR target check added: warns when your change-type label and target branch look mismatched. -- vnext compatibility check added: every PR to `main` verifies that `vnext` can rebase cleanly on top; posts exact fix commands on conflict. -- Post-merge automatic rebase added: `vnext` is force-rebased onto `main` after every merge; if it fails, a GitHub issue is opened. +The `bump ... + build changelog` commit edits the package version in +`pyproject.toml` and folds its `changelog.d/` fragments into `CHANGELOG.md`. On +merge to `main`, CI cuts a published GitHub Release tagged +`-v` with those notes. See +[docs/versioning.md](docs/versioning.md). -### [Phase 2.A](https://github.com/OvertureMaps/schema/issues/508), May 2026 +## Opening a PR -- All packages baselined with static versions in `pyproject.toml` (`overture-schema` at `1.17.1`, others at `0.1.1`). -- `compute-version` composite action added: computes PEP 440 versions for vnext (dev), main (patch), and main-bump (reset) contexts. -- `code-artifact` composite action added: replaces the legacy shell script for AWS CodeArtifact auth. -- `compute-versions-dry-run` workflow added for version visibility until Phase 3 publish workflows land. +Two CI checks may comment on your PR: -### [Phase 2.B](https://github.com/OvertureMaps/schema/issues/533) +- [vnext compatibility check](.github/workflows/vnext-compat.yaml): fails and + posts the fix if your change clashes with unreleased `vnext` work. +- [PR advisory check](.github/workflows/pr-advisory.yaml): flags a likely + change-type / target-branch mismatch. Advisory only; the reviewer decides. -Not started. Will add a `p2-release-trigger` workflow that detects a `.` bump -landing on `main` and cuts a GitHub Release — the only trigger for a public PyPI publish. +Follow the comment each check leaves on the PR. -### [Phase 3](https://github.com/OvertureMaps/schema/issues/509) +If you have an open PR against `vnext`, its base may be force-updated after a +merge to `main`; run `git pull --rebase` before pushing again. -Not started. Will add the actual publish workflows (`p3-dev-builds-ca`, `p3-main-publish`, -`p3-release-publish`) that call the `compute-version` action from Phase 2.A. Where patch -builds on `main` publish to (CodeArtifact-only vs. public PyPI) is still an open decision — -see the linked issue. +## Changing a package version -### [Phase 4](https://github.com/OvertureMaps/schema/issues/510) +- The full `..` is your call: edit it in `pyproject.toml` + and any increase (patch included) ships a release. Patch and minor bumps + target `main`; major bumps target `vnext`. +- Between releases, CI stamps interim internal builds as + `.postN+main.` (or `+vnext.`); never write `.postN` + suffixes manually. +- Every package versions and releases independently. Consumers pin only + `overture-schema`, which pulls in the theme and support packages for a coherent + set. +- Any change to a package **requires a changelog fragment**: one sentence in + one file under `packages//changelog.d/`, see the + [changelog quick start](docs/versioning.md#changelog-quick-start). CI + enforces it. Fragments are folded into `CHANGELOG.md` at release time, not + in your PR. -Not started. Final documentation pass: diagrams, contributor walkthroughs, and an FAQ. No -new procedures — this phase only makes the existing ones easier to read. +Full version scheme, tag scheme, and release flow: [docs/versioning.md](docs/versioning.md). diff --git a/docs/versioning.md b/docs/versioning.md new file mode 100644 index 000000000..88a31f995 --- /dev/null +++ b/docs/versioning.md @@ -0,0 +1,209 @@ +# Versioning and releases + +Reference and how-to for package versions and releases. Branch mechanics and the +`vnext`/`main` workflow live in [CONTRIBUTING.md](../CONTRIBUTING.md). + +## Contents + +- [Reference](#reference) + - [Version scheme](#version-scheme) + - [Version → destination](#version--destination) + - [Workspace dependency floors](#workspace-dependency-floors) + - [Tag scheme](#tag-scheme) + - [Guardrails](#guardrails) +- [How to](#how-to) + - [Changelog quick start](#changelog-quick-start) + - [Add a changelog fragment](#add-a-changelog-fragment) + - [Cut a release](#cut-a-release) +- [Why](#why) + +## Reference + +### Version scheme + +Every distributable package under `packages/*` carries its own independent +`..` ([PEP 440](https://peps.python.org/pep-0440/)) in its `pyproject.toml`. + +| Component | Owner | Set by | +|-----------|-------|--------| +| `..` | Human | Edited in `pyproject.toml` via a reviewed PR. Any increase, patch included, is a release. | +| `.post+.` | CI | [`compute-version`](../.github/actions/compute-version/action.yml) stamps interim internal builds between releases. | + +### Version → destination + +| Event | Version | Destination | +|-------|---------|-------------| +| Push to `main`, no bump | `.postN+main.` | CodeArtifact | +| Version bump on `main` | `` | GitHub Release, then public PyPI | +| Push to `vnext` | `.postN+vnext.` | Blocked on a dedicated dev repository ([ops-team#299](https://github.com/OvertureMaps/ops-team/issues/299)) | + +Internal builds use PEP 440 post-releases so they order after the released +``: a consumer pinning `>=1.2.3` resolves `1.2.3.post4+main.abc1234` +from CodeArtifact when present (verified with uv), while `==1.2.3` still +selects the clean release. Pin with `>=`, never `>`: PEP 440 excludes +post-releases from exclusive ordered comparisons, so `>1.2.3` matches no +internal build. `N` is a per-version sequence: the first internal build of a +version is `.post0` (identical contents to the release), incrementing from +the highest `.postN` already published. The +`+main`/`+vnext` local label names the build stream but does not participate +in version ordering, so the two streams must never share a repository: in a +shared repo, a `>=` consumer can resolve a `vnext` build (breaking changes) +over the `main` one. `vnext` builds publish only once a separate dev +repository exists. Local labels are rejected by public PyPI, which keeps +internal builds off the public index by construction. + +### Workspace dependency floors + +Intra-repo dependencies follow uv's +[dual declaration](https://docs.astral.sh/uv/concepts/projects/dependencies/#workspace-member) +pattern: an explicit specifier in `project.dependencies` (e.g. +`overture-schema-common>=0.1.1`) alongside a `[tool.uv.sources]` workspace +entry. Development resolves against the workspace source; built wheels carry +the specifier. + +Floors are maintained by hand. Raise a floor only when your package needs +something from the newer dependency version; floors carry released versions +only, never a `.postN` suffix. Major bumps are the exception, they must +cascade (see [Guardrails](#guardrails)). + +### Tag scheme + +Each package has its own release series: tag `-v..`, +title `` `` ``. The umbrella `overture-schema` release is +flagged **Latest**, and additionally continues the historical bare series +(`v0.4.0` … `v1.17.0`) as a vanity tag: each umbrella release also creates a +bare `v` git tag at the same commit, with no second GitHub Release +attached. The umbrella package is the primary entrypoint for most consumers, +so its bare tags keep the long-standing convention alive; all other packages +use only the package-prefixed scheme. + +### Guardrails + +- A changelog fragment is **required** on any change to a package, enforced by + the `Changelog fragment verification` check. +- `release-trigger` fails if the target tag already exists, or if a version goes + backwards. +- Major bumps cascade: a package whose workspace dependency takes a major bump + must take one itself, and its floor on that dependency must be raised, since + the old floor would admit a breaking version. Enforced at PR time by the + version check and again by `release-trigger`. + +## How to + +### Changelog quick start + +The `Changelog fragment verification` check failed, or you're about to open a +PR that touches a package. The whole fix, using a places-theme bugfix as the +example: + +```bash +# 1. One small markdown file, named ..md +echo 'Fixed brand enum values rejecting valid entries.' \ + > packages/overture-schema-places-theme/changelog.d/561.bugfix.md + +# 2. Commit it with your change +git add packages/overture-schema-places-theme/changelog.d/561.bugfix.md +git commit -m 'Add changelog fragment' +``` + +That's the entire contribution-time cost: one sentence in one file, one per +changed package. No tool to install, no config to touch. towncrier only runs +at release time, when a maintainer folds the accumulated fragments into +`CHANGELOG.md` (see [Cut a release](#cut-a-release)). + +> [!NOTE] +> `towncrier build` consumes fragments: it deletes them from `changelog.d/` +> as it folds them into `CHANGELOG.md`. The fragment directory only ever +> holds unreleased changes, and the committed `CHANGELOG.md` is the sole +> durable record of past releases. + +### Add a changelog fragment + +Release notes are assembled from +[towncrier](https://towncrier.readthedocs.io) fragments. Add one for every change +to a package, including patch-level fixes and internal work (use the `misc` +type), under the affected package: + +```text +packages//changelog.d/..md +``` + +| `` | For | +|----------|-----| +| `breaking` | Backward-incompatible changes | +| `feature` | New functionality | +| `bugfix` | Bug fixes | +| `docs` | Documentation-only changes | +| `misc` | Tooling / internal changes | + +The file body is the note itself, written in past tense +(e.g. `Added `provider` to the sources resource.`). Preview the rendered section: + +```bash +# from the repo root +uv run towncrier build --config pyproject.toml --dir packages/ --draft --version +``` + +A fragment (or an already-built `CHANGELOG.md` entry) is required on any PR that +changes that package, whether or not it bumps the version. + +> [!NOTE] +> The towncrier categories above are defined once in the root `pyproject.toml`. +> A package can override them by adding its own `[tool.towncrier]` block and +> building from that package directory (towncrier replaces, not merges). + +### Cut a release + +1. Bump the version in the package's `pyproject.toml`, then fold its fragments + into `CHANGELOG.md` from the repo root: + + ```bash + uv run towncrier build --config pyproject.toml --dir packages/ --version + ``` + + `--version` is required: towncrier only discovers a version from its + [config file](https://towncrier.readthedocs.io/en/stable/configuration.html) + (`version` or `package` key), and our config is the shared root + `pyproject.toml`, which deliberately has neither so one number can't stamp + every package. Pass the version you just bumped to. + + Patch and minor bumps target `main`; major bumps go via `vnext` and reach + `main` through a release merge. +2. On merge to `main`, `release-trigger` publishes one GitHub Release per bumped + package: tag `-v`, notes from that package's + `CHANGELOG.md`. +3. Publishing the release starts the PyPI publish, gated by a maintainer + approval. + +```mermaid +flowchart LR + A[bump + towncrier build
merged to main] --> B[release-trigger:
GitHub Release per package] + B --> C[PyPI publish
maintainer approval] --> D[public PyPI] + E[no-bump merge] --> F[.postN internal build
CodeArtifact only] +``` + +## Why + +- **Humans own the full version.** Every released `..` is + a reviewed decision, so patch-level bug fixes can ship to PyPI without + masquerading as minor releases. CI versions only the interim `.postN` + builds between releases. +- **Post-releases for internal builds.** `.postN` orders after the released + version, so `>=` specifiers pick up the freshest internal build + from CodeArtifact; the `+main`/`+vnext` label separates the two streams and + keeps the builds off public PyPI. +- **Independent per-package versions.** Packages evolve at their own pace. + Consumers pin only `overture-schema`, which depends on the theme/support + packages, giving them a coherent set without tracking each one. +- **towncrier fragments.** Notes are written in context per PR and assembled + automatically, with no merge conflicts on a shared changelog and no + hand-written notes at release time. +- **CI never writes to `main`.** Every commit on `main` arrives through a + reviewed PR; CI is a pure reader. Automation that commits back to `main` + needs a bot identity with branch-protection bypass (a compromised workflow + can then push arbitrary code), skip-guards against re-triggering + push-driven workflows on its own commits, and retry logic for races with + human merges, and every synthetic commit is an unreviewed change on the + protected branch. Designs that require a write-back (e.g. on-demand + changelog generation with post-release fragment cleanup) are rejected on + this principle. diff --git a/packages/overture-schema-addresses-theme/changelog.d/557.misc.md b/packages/overture-schema-addresses-theme/changelog.d/557.misc.md new file mode 100644 index 000000000..67efb8d14 --- /dev/null +++ b/packages/overture-schema-addresses-theme/changelog.d/557.misc.md @@ -0,0 +1 @@ +Declared explicit version floors for workspace dependencies. diff --git a/packages/overture-schema-addresses-theme/changelog.d/README.md b/packages/overture-schema-addresses-theme/changelog.d/README.md new file mode 100644 index 000000000..83ffd44b1 --- /dev/null +++ b/packages/overture-schema-addresses-theme/changelog.d/README.md @@ -0,0 +1,17 @@ +# Changelog fragments + +[towncrier](https://towncrier.readthedocs.io) news fragments for this package. +One file per change to this package (including patch-level fixes and internal +work): + +```text +changelog.d/..md +``` + +Types, body format, the preview command, and when a fragment is required are +documented once in +[docs/versioning.md -> Changelog quick start](../../../docs/versioning.md#changelog-quick-start). + +> [!NOTE] +> This README also keeps `changelog.d/` tracked in git, so no `.gitkeep` is +> needed. Leave it in place even when the directory holds no fragments. \ No newline at end of file diff --git a/packages/overture-schema-addresses-theme/pyproject.toml b/packages/overture-schema-addresses-theme/pyproject.toml index 9efd0ce6e..7cb105b5e 100644 --- a/packages/overture-schema-addresses-theme/pyproject.toml +++ b/packages/overture-schema-addresses-theme/pyproject.toml @@ -3,8 +3,8 @@ maintainers = [ {name = "Overture Maps Schema Working Group"}, ] dependencies = [ - "overture-schema-common", - "overture-schema-system", + "overture-schema-common>=0.1.1", + "overture-schema-system>=0.1.1", "pydantic>=2.12.0", ] description = "Overture Maps addresses theme models and structures" diff --git a/packages/overture-schema-base-theme/changelog.d/557.misc.md b/packages/overture-schema-base-theme/changelog.d/557.misc.md new file mode 100644 index 000000000..67efb8d14 --- /dev/null +++ b/packages/overture-schema-base-theme/changelog.d/557.misc.md @@ -0,0 +1 @@ +Declared explicit version floors for workspace dependencies. diff --git a/packages/overture-schema-base-theme/changelog.d/README.md b/packages/overture-schema-base-theme/changelog.d/README.md new file mode 100644 index 000000000..83ffd44b1 --- /dev/null +++ b/packages/overture-schema-base-theme/changelog.d/README.md @@ -0,0 +1,17 @@ +# Changelog fragments + +[towncrier](https://towncrier.readthedocs.io) news fragments for this package. +One file per change to this package (including patch-level fixes and internal +work): + +```text +changelog.d/..md +``` + +Types, body format, the preview command, and when a fragment is required are +documented once in +[docs/versioning.md -> Changelog quick start](../../../docs/versioning.md#changelog-quick-start). + +> [!NOTE] +> This README also keeps `changelog.d/` tracked in git, so no `.gitkeep` is +> needed. Leave it in place even when the directory holds no fragments. \ No newline at end of file diff --git a/packages/overture-schema-base-theme/pyproject.toml b/packages/overture-schema-base-theme/pyproject.toml index cd0644108..38662bafa 100644 --- a/packages/overture-schema-base-theme/pyproject.toml +++ b/packages/overture-schema-base-theme/pyproject.toml @@ -3,8 +3,8 @@ maintainers = [ {name = "Overture Maps Schema Working Group"}, ] dependencies = [ - "overture-schema-common", - "overture-schema-system", + "overture-schema-common>=0.1.1", + "overture-schema-system>=0.1.1", "pydantic>=2.12.0", ] description = "Overture Maps base theme shared structures and models (bathymetry, infrastructure, land, land_cover, land_use, water)" diff --git a/packages/overture-schema-buildings-theme/changelog.d/557.misc.md b/packages/overture-schema-buildings-theme/changelog.d/557.misc.md new file mode 100644 index 000000000..67efb8d14 --- /dev/null +++ b/packages/overture-schema-buildings-theme/changelog.d/557.misc.md @@ -0,0 +1 @@ +Declared explicit version floors for workspace dependencies. diff --git a/packages/overture-schema-buildings-theme/changelog.d/README.md b/packages/overture-schema-buildings-theme/changelog.d/README.md new file mode 100644 index 000000000..83ffd44b1 --- /dev/null +++ b/packages/overture-schema-buildings-theme/changelog.d/README.md @@ -0,0 +1,17 @@ +# Changelog fragments + +[towncrier](https://towncrier.readthedocs.io) news fragments for this package. +One file per change to this package (including patch-level fixes and internal +work): + +```text +changelog.d/..md +``` + +Types, body format, the preview command, and when a fragment is required are +documented once in +[docs/versioning.md -> Changelog quick start](../../../docs/versioning.md#changelog-quick-start). + +> [!NOTE] +> This README also keeps `changelog.d/` tracked in git, so no `.gitkeep` is +> needed. Leave it in place even when the directory holds no fragments. \ No newline at end of file diff --git a/packages/overture-schema-buildings-theme/pyproject.toml b/packages/overture-schema-buildings-theme/pyproject.toml index 2ab6a3352..0cbb9f172 100644 --- a/packages/overture-schema-buildings-theme/pyproject.toml +++ b/packages/overture-schema-buildings-theme/pyproject.toml @@ -3,8 +3,8 @@ maintainers = [ {name = "Overture Maps Schema Working Group"}, ] dependencies = [ - "overture-schema-common", - "overture-schema-system", + "overture-schema-common>=0.1.1", + "overture-schema-system>=0.1.1", "pydantic>=2.12.0", ] description = "Overture Maps buildings theme shared structures, building types, and building part types" diff --git a/packages/overture-schema-cli/changelog.d/557.misc.md b/packages/overture-schema-cli/changelog.d/557.misc.md new file mode 100644 index 000000000..67efb8d14 --- /dev/null +++ b/packages/overture-schema-cli/changelog.d/557.misc.md @@ -0,0 +1 @@ +Declared explicit version floors for workspace dependencies. diff --git a/packages/overture-schema-cli/changelog.d/README.md b/packages/overture-schema-cli/changelog.d/README.md new file mode 100644 index 000000000..83ffd44b1 --- /dev/null +++ b/packages/overture-schema-cli/changelog.d/README.md @@ -0,0 +1,17 @@ +# Changelog fragments + +[towncrier](https://towncrier.readthedocs.io) news fragments for this package. +One file per change to this package (including patch-level fixes and internal +work): + +```text +changelog.d/..md +``` + +Types, body format, the preview command, and when a fragment is required are +documented once in +[docs/versioning.md -> Changelog quick start](../../../docs/versioning.md#changelog-quick-start). + +> [!NOTE] +> This README also keeps `changelog.d/` tracked in git, so no `.gitkeep` is +> needed. Leave it in place even when the directory holds no fragments. \ No newline at end of file diff --git a/packages/overture-schema-cli/pyproject.toml b/packages/overture-schema-cli/pyproject.toml index f32fd78ef..45b16434f 100644 --- a/packages/overture-schema-cli/pyproject.toml +++ b/packages/overture-schema-cli/pyproject.toml @@ -3,8 +3,8 @@ maintainers = [ {name = "Overture Maps Schema Working Group"}, ] dependencies = [ - "overture-schema-common", - "overture-schema-system", + "overture-schema-common>=0.1.1", + "overture-schema-system>=0.1.1", "pydantic>=2.12.0", "pyyaml>=6.0.2", "click>=8.1", diff --git a/packages/overture-schema-codegen/changelog.d/557.misc.md b/packages/overture-schema-codegen/changelog.d/557.misc.md new file mode 100644 index 000000000..67efb8d14 --- /dev/null +++ b/packages/overture-schema-codegen/changelog.d/557.misc.md @@ -0,0 +1 @@ +Declared explicit version floors for workspace dependencies. diff --git a/packages/overture-schema-codegen/changelog.d/README.md b/packages/overture-schema-codegen/changelog.d/README.md new file mode 100644 index 000000000..83ffd44b1 --- /dev/null +++ b/packages/overture-schema-codegen/changelog.d/README.md @@ -0,0 +1,17 @@ +# Changelog fragments + +[towncrier](https://towncrier.readthedocs.io) news fragments for this package. +One file per change to this package (including patch-level fixes and internal +work): + +```text +changelog.d/..md +``` + +Types, body format, the preview command, and when a fragment is required are +documented once in +[docs/versioning.md -> Changelog quick start](../../../docs/versioning.md#changelog-quick-start). + +> [!NOTE] +> This README also keeps `changelog.d/` tracked in git, so no `.gitkeep` is +> needed. Leave it in place even when the directory holds no fragments. \ No newline at end of file diff --git a/packages/overture-schema-codegen/pyproject.toml b/packages/overture-schema-codegen/pyproject.toml index 60c835553..dc3c09445 100644 --- a/packages/overture-schema-codegen/pyproject.toml +++ b/packages/overture-schema-codegen/pyproject.toml @@ -6,9 +6,9 @@ requires = ["hatchling"] dependencies = [ "click>=8.1", "jinja2>=3.0", - "overture-schema-cli", - "overture-schema-common", - "overture-schema-system", + "overture-schema-cli>=0.1.1", + "overture-schema-common>=0.1.1", + "overture-schema-system>=0.1.1", "tomli>=2.0; python_version < '3.11'", "typing-extensions>=4.0", ] diff --git a/packages/overture-schema-common/changelog.d/557.misc.md b/packages/overture-schema-common/changelog.d/557.misc.md new file mode 100644 index 000000000..67efb8d14 --- /dev/null +++ b/packages/overture-schema-common/changelog.d/557.misc.md @@ -0,0 +1 @@ +Declared explicit version floors for workspace dependencies. diff --git a/packages/overture-schema-common/changelog.d/README.md b/packages/overture-schema-common/changelog.d/README.md new file mode 100644 index 000000000..83ffd44b1 --- /dev/null +++ b/packages/overture-schema-common/changelog.d/README.md @@ -0,0 +1,17 @@ +# Changelog fragments + +[towncrier](https://towncrier.readthedocs.io) news fragments for this package. +One file per change to this package (including patch-level fixes and internal +work): + +```text +changelog.d/..md +``` + +Types, body format, the preview command, and when a fragment is required are +documented once in +[docs/versioning.md -> Changelog quick start](../../../docs/versioning.md#changelog-quick-start). + +> [!NOTE] +> This README also keeps `changelog.d/` tracked in git, so no `.gitkeep` is +> needed. Leave it in place even when the directory holds no fragments. \ No newline at end of file diff --git a/packages/overture-schema-common/pyproject.toml b/packages/overture-schema-common/pyproject.toml index bf80b50ac..e63298425 100644 --- a/packages/overture-schema-common/pyproject.toml +++ b/packages/overture-schema-common/pyproject.toml @@ -12,7 +12,7 @@ description = "Common components that are shared across Overture theme schemas" requires-python = ">=3.10" license = "MIT" dependencies = [ - "overture-schema-system", + "overture-schema-system>=0.1.1", "pydantic>=2.12.0", "shapely>=2.1.1", ] diff --git a/packages/overture-schema-divisions-theme/changelog.d/557.misc.md b/packages/overture-schema-divisions-theme/changelog.d/557.misc.md new file mode 100644 index 000000000..67efb8d14 --- /dev/null +++ b/packages/overture-schema-divisions-theme/changelog.d/557.misc.md @@ -0,0 +1 @@ +Declared explicit version floors for workspace dependencies. diff --git a/packages/overture-schema-divisions-theme/changelog.d/README.md b/packages/overture-schema-divisions-theme/changelog.d/README.md new file mode 100644 index 000000000..83ffd44b1 --- /dev/null +++ b/packages/overture-schema-divisions-theme/changelog.d/README.md @@ -0,0 +1,17 @@ +# Changelog fragments + +[towncrier](https://towncrier.readthedocs.io) news fragments for this package. +One file per change to this package (including patch-level fixes and internal +work): + +```text +changelog.d/..md +``` + +Types, body format, the preview command, and when a fragment is required are +documented once in +[docs/versioning.md -> Changelog quick start](../../../docs/versioning.md#changelog-quick-start). + +> [!NOTE] +> This README also keeps `changelog.d/` tracked in git, so no `.gitkeep` is +> needed. Leave it in place even when the directory holds no fragments. \ No newline at end of file diff --git a/packages/overture-schema-divisions-theme/pyproject.toml b/packages/overture-schema-divisions-theme/pyproject.toml index c361c2783..e1ac36d2e 100644 --- a/packages/overture-schema-divisions-theme/pyproject.toml +++ b/packages/overture-schema-divisions-theme/pyproject.toml @@ -3,8 +3,8 @@ maintainers = [ { name = "Overture Maps Schema Working Group" }, ] dependencies = [ - "overture-schema-common", - "overture-schema-system", + "overture-schema-common>=0.1.1", + "overture-schema-system>=0.1.1", "pydantic>=2.12.0", ] description = "Overture Maps divisions theme shared structures, division, division area and division boundary types" diff --git a/packages/overture-schema-places-theme/changelog.d/557.misc.md b/packages/overture-schema-places-theme/changelog.d/557.misc.md new file mode 100644 index 000000000..67efb8d14 --- /dev/null +++ b/packages/overture-schema-places-theme/changelog.d/557.misc.md @@ -0,0 +1 @@ +Declared explicit version floors for workspace dependencies. diff --git a/packages/overture-schema-places-theme/changelog.d/README.md b/packages/overture-schema-places-theme/changelog.d/README.md new file mode 100644 index 000000000..83ffd44b1 --- /dev/null +++ b/packages/overture-schema-places-theme/changelog.d/README.md @@ -0,0 +1,17 @@ +# Changelog fragments + +[towncrier](https://towncrier.readthedocs.io) news fragments for this package. +One file per change to this package (including patch-level fixes and internal +work): + +```text +changelog.d/..md +``` + +Types, body format, the preview command, and when a fragment is required are +documented once in +[docs/versioning.md -> Changelog quick start](../../../docs/versioning.md#changelog-quick-start). + +> [!NOTE] +> This README also keeps `changelog.d/` tracked in git, so no `.gitkeep` is +> needed. Leave it in place even when the directory holds no fragments. \ No newline at end of file diff --git a/packages/overture-schema-places-theme/pyproject.toml b/packages/overture-schema-places-theme/pyproject.toml index 995fd707a..811a3d92a 100644 --- a/packages/overture-schema-places-theme/pyproject.toml +++ b/packages/overture-schema-places-theme/pyproject.toml @@ -3,8 +3,8 @@ maintainers = [ {name = "Overture Maps Schema Working Group"}, ] dependencies = [ - "overture-schema-common", - "overture-schema-system", + "overture-schema-common>=0.1.1", + "overture-schema-system>=0.1.1", "pydantic[email]>=2.12.0", ] description = "Overture Maps places theme with place type models" diff --git a/packages/overture-schema-pyspark/changelog.d/557.misc.md b/packages/overture-schema-pyspark/changelog.d/557.misc.md new file mode 100644 index 000000000..67efb8d14 --- /dev/null +++ b/packages/overture-schema-pyspark/changelog.d/557.misc.md @@ -0,0 +1 @@ +Declared explicit version floors for workspace dependencies. diff --git a/packages/overture-schema-pyspark/changelog.d/README.md b/packages/overture-schema-pyspark/changelog.d/README.md new file mode 100644 index 000000000..83ffd44b1 --- /dev/null +++ b/packages/overture-schema-pyspark/changelog.d/README.md @@ -0,0 +1,17 @@ +# Changelog fragments + +[towncrier](https://towncrier.readthedocs.io) news fragments for this package. +One file per change to this package (including patch-level fixes and internal +work): + +```text +changelog.d/..md +``` + +Types, body format, the preview command, and when a fragment is required are +documented once in +[docs/versioning.md -> Changelog quick start](../../../docs/versioning.md#changelog-quick-start). + +> [!NOTE] +> This README also keeps `changelog.d/` tracked in git, so no `.gitkeep` is +> needed. Leave it in place even when the directory holds no fragments. \ No newline at end of file diff --git a/packages/overture-schema-pyspark/pyproject.toml b/packages/overture-schema-pyspark/pyproject.toml index 646dc88c4..027a5ae60 100644 --- a/packages/overture-schema-pyspark/pyproject.toml +++ b/packages/overture-schema-pyspark/pyproject.toml @@ -5,7 +5,7 @@ requires = ["hatchling"] [project] dependencies = [ "click>=8.0", - "overture-schema-system", + "overture-schema-system>=0.1.1", "pyspark>=3.4", ] description = "PySpark validation expressions for Overture Maps data" diff --git a/packages/overture-schema-system/changelog.d/README.md b/packages/overture-schema-system/changelog.d/README.md new file mode 100644 index 000000000..83ffd44b1 --- /dev/null +++ b/packages/overture-schema-system/changelog.d/README.md @@ -0,0 +1,17 @@ +# Changelog fragments + +[towncrier](https://towncrier.readthedocs.io) news fragments for this package. +One file per change to this package (including patch-level fixes and internal +work): + +```text +changelog.d/..md +``` + +Types, body format, the preview command, and when a fragment is required are +documented once in +[docs/versioning.md -> Changelog quick start](../../../docs/versioning.md#changelog-quick-start). + +> [!NOTE] +> This README also keeps `changelog.d/` tracked in git, so no `.gitkeep` is +> needed. Leave it in place even when the directory holds no fragments. \ No newline at end of file diff --git a/packages/overture-schema-transportation-theme/changelog.d/557.misc.md b/packages/overture-schema-transportation-theme/changelog.d/557.misc.md new file mode 100644 index 000000000..67efb8d14 --- /dev/null +++ b/packages/overture-schema-transportation-theme/changelog.d/557.misc.md @@ -0,0 +1 @@ +Declared explicit version floors for workspace dependencies. diff --git a/packages/overture-schema-transportation-theme/changelog.d/README.md b/packages/overture-schema-transportation-theme/changelog.d/README.md new file mode 100644 index 000000000..83ffd44b1 --- /dev/null +++ b/packages/overture-schema-transportation-theme/changelog.d/README.md @@ -0,0 +1,17 @@ +# Changelog fragments + +[towncrier](https://towncrier.readthedocs.io) news fragments for this package. +One file per change to this package (including patch-level fixes and internal +work): + +```text +changelog.d/..md +``` + +Types, body format, the preview command, and when a fragment is required are +documented once in +[docs/versioning.md -> Changelog quick start](../../../docs/versioning.md#changelog-quick-start). + +> [!NOTE] +> This README also keeps `changelog.d/` tracked in git, so no `.gitkeep` is +> needed. Leave it in place even when the directory holds no fragments. \ No newline at end of file diff --git a/packages/overture-schema-transportation-theme/pyproject.toml b/packages/overture-schema-transportation-theme/pyproject.toml index ee241cf6d..5fa8df617 100644 --- a/packages/overture-schema-transportation-theme/pyproject.toml +++ b/packages/overture-schema-transportation-theme/pyproject.toml @@ -3,8 +3,8 @@ maintainers = [ { name = "Overture Maps Schema Working Group" }, ] dependencies = [ - "overture-schema-common", - "overture-schema-system", + "overture-schema-common>=0.1.1", + "overture-schema-system>=0.1.1", "pydantic>=2.12.0", ] description = "Overture Maps transportation theme with shared structures and connector and segment types" diff --git a/packages/overture-schema/changelog.d/557.misc.md b/packages/overture-schema/changelog.d/557.misc.md new file mode 100644 index 000000000..3dd1286bc --- /dev/null +++ b/packages/overture-schema/changelog.d/557.misc.md @@ -0,0 +1,2 @@ +Added per-package release-trigger workflow, towncrier changelog fragments, and a fragment-required CI check. +Declared explicit version floors for workspace dependencies. diff --git a/packages/overture-schema/changelog.d/README.md b/packages/overture-schema/changelog.d/README.md new file mode 100644 index 000000000..83ffd44b1 --- /dev/null +++ b/packages/overture-schema/changelog.d/README.md @@ -0,0 +1,17 @@ +# Changelog fragments + +[towncrier](https://towncrier.readthedocs.io) news fragments for this package. +One file per change to this package (including patch-level fixes and internal +work): + +```text +changelog.d/..md +``` + +Types, body format, the preview command, and when a fragment is required are +documented once in +[docs/versioning.md -> Changelog quick start](../../../docs/versioning.md#changelog-quick-start). + +> [!NOTE] +> This README also keeps `changelog.d/` tracked in git, so no `.gitkeep` is +> needed. Leave it in place even when the directory holds no fragments. \ No newline at end of file diff --git a/packages/overture-schema/pyproject.toml b/packages/overture-schema/pyproject.toml index bb748c46e..e49866c40 100644 --- a/packages/overture-schema/pyproject.toml +++ b/packages/overture-schema/pyproject.toml @@ -3,16 +3,16 @@ maintainers = [ {name = "Overture Maps Schema Working Group"}, ] dependencies = [ - "overture-schema-addresses-theme", - "overture-schema-base-theme", - "overture-schema-buildings-theme", - "overture-schema-divisions-theme", - "overture-schema-places-theme", - "overture-schema-transportation-theme", - "overture-schema-common", + "overture-schema-addresses-theme>=0.1.1", + "overture-schema-base-theme>=0.1.1", + "overture-schema-buildings-theme>=0.1.1", + "overture-schema-divisions-theme>=0.1.1", + "overture-schema-places-theme>=0.1.1", + "overture-schema-transportation-theme>=0.1.1", + "overture-schema-common>=0.1.1", "pydantic>=2.12.0", "pyyaml>=6.0.2", - "overture-schema-cli", + "overture-schema-cli>=0.1.1", ] description = "Complete Overture Maps schema collection with all themes and types" version = "1.17.1" diff --git a/pyproject.toml b/pyproject.toml index 3439b87f6..ae02f5090 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ dev = [ "pytest-cov>=7.0.0", "pytest-testmon>=2.2.0", "ruff>=0.13.0", + "towncrier>=25.8.0", ] [tool.pytest.ini_options] @@ -78,3 +79,40 @@ pythonpath = [ "packages/overture-schema/tests", ] verbosity_subtests = 0 + +# Shared changelog (towncrier) config for every package under packages/*. +# Build a package's notes from the repo root: +# uv run towncrier build --config pyproject.toml --dir packages/ --version .. +# A package may override these categories by adding its own [tool.towncrier] +# block and building from that package directory (towncrier replaces, not merges). +[tool.towncrier] +directory = "changelog.d" +filename = "CHANGELOG.md" +title_format = "## [{version}] - {project_date}" +issue_format = "[#{issue}](https://github.com/OvertureMaps/schema/issues/{issue})" +ignore = ["README.md"] + +[[tool.towncrier.type]] +directory = "breaking" +name = "Breaking Changes" +showcontent = true + +[[tool.towncrier.type]] +directory = "feature" +name = "Features" +showcontent = true + +[[tool.towncrier.type]] +directory = "bugfix" +name = "Bug Fixes" +showcontent = true + +[[tool.towncrier.type]] +directory = "docs" +name = "Documentation" +showcontent = true + +[[tool.towncrier.type]] +directory = "misc" +name = "Miscellaneous" +showcontent = true diff --git a/uv.lock b/uv.lock index 2fbeeb99a..88eb636c2 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-07-28T19:24:56.7525677Z" exclude-newer-span = "P1W" [manifest] @@ -1214,6 +1214,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-testmon" }, { name = "ruff" }, + { name = "towncrier" }, ] [package.metadata] @@ -1227,6 +1228,7 @@ dev = [ { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "pytest-testmon", specifier = ">=2.2.0" }, { name = "ruff", specifier = ">=0.13.0" }, + { name = "towncrier", specifier = ">=25.8.0" }, ] [[package]] @@ -1725,6 +1727,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "towncrier" +version = "25.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "jinja2" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/eb/5bf25a34123698d3bbab39c5bc5375f8f8bcbcc5a136964ade66935b8b9d/towncrier-25.8.0.tar.gz", hash = "sha256:eef16d29f831ad57abb3ae32a0565739866219f1ebfbdd297d32894eb9940eb1", size = 76322, upload-time = "2025-08-30T11:41:55.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/06/8ba22ec32c74ac1be3baa26116e3c28bc0e76a5387476921d20b6fdade11/towncrier-25.8.0-py3-none-any.whl", hash = "sha256:b953d133d98f9aeae9084b56a3563fd2519dfc6ec33f61c9cd2c61ff243fb513", size = 65101, upload-time = "2025-08-30T11:41:53.644Z" }, +] + [[package]] name = "types-pyyaml" version = "6.0.12.20260518"