From 1fec361ba7e8d2ca260957e18289bf460748db77 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 2 Jun 2026 18:18:29 -0600 Subject: [PATCH 1/6] chore: adopt Data Services Versioning Standard - Bump pyproject version to 1.0.0 + add release-please manifest/config - Add release-please workflow (watches production + hotfix/v* branches) - Add hotfix-start workflow (workflow_dispatch creates hotfix/vX.Y.Z+1 off a release tag) - Add PR title lint workflow (Conventional Commits) - Retarget CD (Production) to fire on v*.*.* tag (no more push-to-branch deploys); drop self-tagging step; pass APP_VERSION through to App Engine env - Surface APP_VERSION via Settings.version (env override -> importlib.metadata fallback) and add /health endpoint returning version - Use settings.version for public OpenAPI schema (was hardcoded 0.0.1) - Remove Sentry release workflow Co-Authored-By: Claude Opus 4.7 --- .github/app.template.yaml | 1 + .github/workflows/CD_production.yml | 17 +----- .github/workflows/hotfix-start.yml | 90 ++++++++++++++++++++++++++++ .github/workflows/pr-title-lint.yml | 38 ++++++++++++ .github/workflows/release-please.yml | 21 +++++++ .github/workflows/release.yml | 26 -------- .release-please-manifest.json | 3 + core/app.py | 7 ++- core/settings.py | 13 +++- pyproject.toml | 2 +- release-please-config.json | 27 +++++++++ 11 files changed, 202 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/hotfix-start.yml create mode 100644 .github/workflows/pr-title-lint.yml create mode 100644 .github/workflows/release-please.yml delete mode 100644 .github/workflows/release.yml create mode 100644 .release-please-manifest.json create mode 100644 release-please-config.json diff --git a/.github/app.template.yaml b/.github/app.template.yaml index 2ed7342a3..619ba4cc5 100644 --- a/.github/app.template.yaml +++ b/.github/app.template.yaml @@ -15,6 +15,7 @@ handlers: env_variables: MODE: "production" ENVIRONMENT: "${ENVIRONMENT}" + APP_VERSION: "${APP_VERSION}" DB_DRIVER: "cloudsql" CLOUD_SQL_INSTANCE_NAME: "${CLOUD_SQL_INSTANCE_NAME}" CLOUD_SQL_DATABASE: "${CLOUD_SQL_DATABASE}" diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index f84629ed6..b3e66ef6b 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -2,10 +2,10 @@ name: CD (Production) on: push: - branches: [production] + tags: ['v*.*.*'] permissions: - contents: write + contents: read jobs: production-deploy: @@ -66,6 +66,7 @@ jobs: - name: Render App Engine configs env: + APP_VERSION: ${{ github.ref_name }} ENVIRONMENT: "production" CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" @@ -141,15 +142,3 @@ jobs: - name: Remove rendered configs run: | rm app.yaml - - # Use PR author's username as git user name - - name: Set up git user - run: | - git config --global user.name "${{ github.actor }}" - git config --global user.email "${{ github.actor }}@users.noreply.github.com" - - # ":" are not alloed in git tags, so replace with "-" - - name: Tag commit - run: | - git tag -a "production-deploy-$(date -u +%Y-%m-%d)T$(date -u +%H-%M-%S%z)" -m "production gcloud deployment: $(date -u +%Y-%m-%d)T$(date -u +%H:%M:%S%z)" - git push origin --tags diff --git a/.github/workflows/hotfix-start.yml b/.github/workflows/hotfix-start.yml new file mode 100644 index 000000000..4891f16fe --- /dev/null +++ b/.github/workflows/hotfix-start.yml @@ -0,0 +1,90 @@ +name: hotfix-start + +# Creates a hotfix branch off a release tag per the Data Services Versioning +# Standard §10. Workflow: +# 1. Run this workflow (optionally pin base_tag; default = latest v*.*.*). +# 2. Push fix commit(s) to the new hotfix/vX.Y.(Z+1) branch via PR. +# 3. release-please opens a Release PR on the hotfix branch. +# 4. Merge it -> tag vX.Y.(Z+1) -> CD (Production) deploys. +# 5. Open a forward-merge PR from hotfix/vX.Y.(Z+1) back into production. + +on: + workflow_dispatch: + inputs: + base_tag: + description: 'Release tag to branch from (e.g. v1.4.2). Empty = latest v*.*.* tag.' + required: false + type: string + +permissions: + contents: write + +jobs: + create-hotfix-branch: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.2 + with: + fetch-depth: 0 + + - name: Resolve base tag + id: base + run: | + BASE="${{ inputs.base_tag }}" + if [ -z "$BASE" ]; then + BASE="$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1)" + fi + if [ -z "$BASE" ]; then + echo "No release tag found (expected v*.*.* tags)." >&2 + exit 1 + fi + if ! git rev-parse -q --verify "refs/tags/$BASE" >/dev/null; then + echo "Tag $BASE not found in repo." >&2 + exit 1 + fi + echo "tag=$BASE" >> "$GITHUB_OUTPUT" + + - name: Compute next PATCH version + branch name + id: next + run: | + BASE="${{ steps.base.outputs.tag }}" + VER="${BASE#v}" + IFS='.' read -r MAJ MIN PAT <<<"$VER" + if ! [[ "$MAJ" =~ ^[0-9]+$ && "$MIN" =~ ^[0-9]+$ && "$PAT" =~ ^[0-9]+$ ]]; then + echo "Base tag $BASE is not strict SemVer vX.Y.Z." >&2 + exit 1 + fi + NEXT_VER="${MAJ}.${MIN}.$((PAT+1))" + BRANCH="hotfix/v${NEXT_VER}" + echo "version=${NEXT_VER}" >> "$GITHUB_OUTPUT" + echo "branch=${BRANCH}" >> "$GITHUB_OUTPUT" + + - name: Guard against existing branch + run: | + if git ls-remote --exit-code --heads origin "${{ steps.next.outputs.branch }}" >/dev/null 2>&1; then + echo "Branch ${{ steps.next.outputs.branch }} already exists on origin. Aborting." >&2 + exit 1 + fi + + - name: Create + push hotfix branch + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "${{ steps.next.outputs.branch }}" "${{ steps.base.outputs.tag }}" + git push origin "${{ steps.next.outputs.branch }}" + + - name: Summary + run: | + { + echo "### Hotfix branch ready" + echo "" + echo "- Base tag: \`${{ steps.base.outputs.tag }}\`" + echo "- New branch: \`${{ steps.next.outputs.branch }}\`" + echo "- Target version: \`v${{ steps.next.outputs.version }}\`" + echo "" + echo "Next steps:" + echo "1. Open a fix PR targeting \`${{ steps.next.outputs.branch }}\` (Conventional Commit title, \`fix:\` prefix)." + echo "2. After merge, release-please will open a Release PR on the hotfix branch." + echo "3. Merge the Release PR -> tag \`v${{ steps.next.outputs.version }}\` -> CD (Production) deploys." + echo "4. Open a forward-merge PR \`${{ steps.next.outputs.branch }}\` -> \`production\`." + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/pr-title-lint.yml b/.github/workflows/pr-title-lint.yml new file mode 100644 index 000000000..0917ed1b8 --- /dev/null +++ b/.github/workflows/pr-title-lint.yml @@ -0,0 +1,38 @@ +name: pr-title-lint + +# Enforces Conventional Commits on PR titles so squash-merged commits drive +# release-please correctly. See Data Services Versioning Standard §7. + +on: + pull_request: + types: [opened, edited, reopened, synchronize] + +permissions: + pull-requests: read + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + perf + docs + chore + refactor + test + ci + build + style + revert + deps + requireScope: false + subjectPattern: ^(?![A-Z]).+$ + subjectPatternError: | + PR title subject must start with a lowercase letter. + Example: `feat: add /wells/{id}/assets endpoint` diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 000000000..6f42ae3e0 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,21 @@ +name: release-please + +on: + push: + branches: + - production + - 'hotfix/v*' + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + target-branch: ${{ github.ref_name }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index e7ae52752..000000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Sentry Release - -on: - push: - branches: ["main"] - -permissions: - contents: read - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6.0.2 - with: - fetch-depth: 0 - - - name: Create Sentry release - uses: getsentry/action-release@v3 - env: - SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} - SENTRY_ORG: ${{ secrets.SENTRY_ORG }} - SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} - # SENTRY_URL: https://sentry.io/ - with: - environment: production \ No newline at end of file diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 000000000..37fcefaab --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "1.0.0" +} diff --git a/core/app.py b/core/app.py index 102256d4f..17c04484c 100644 --- a/core/app.py +++ b/core/app.py @@ -114,7 +114,7 @@ def full_openapi(): def public_openapi(): schema = get_openapi( title="Ocotillo API (Public)", - version="0.0.1", + version=settings.version, description="Public API schema (anonymous users)", routes=app.routes, ) @@ -218,6 +218,11 @@ async def swagger_ui_redirect(): async def warmup(): return {"status": "ok"} + @app.get("/health", tags=["meta"]) + @public_route + async def health(): + return {"status": "ok", "version": settings.version} + return app diff --git a/core/settings.py b/core/settings.py index e1b94db06..7665ee7a2 100644 --- a/core/settings.py +++ b/core/settings.py @@ -14,10 +14,21 @@ # limitations under the License. # =============================================================================== import os +from importlib.metadata import PackageNotFoundError, version as _pkg_version + + +def _resolve_version() -> str: + env = os.getenv("APP_VERSION") + if env: + return env.lstrip("v") + try: + return _pkg_version("OcotilloAPI") + except PackageNotFoundError: + return "0.0.0" class Settings: - version = "0.0.1" + version = _resolve_version() def __init__(self): self.mode = os.getenv("MODE", "") # Default mode diff --git a/pyproject.toml b/pyproject.toml index 9209a78db..0af9aa466 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "OcotilloAPI" -version = "0.1.0" +version = "1.0.0" description = "FastAPI backend and CLI for managing Ocotillo groundwater locations, wells, assets, and bulk water-level data transfers." readme = "README.md" requires-python = ">=3.13" diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 000000000..2b810a65a --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "python", + "include-v-in-tag": true, + "include-component-in-tag": false, + "bump-minor-pre-major": false, + "bump-patch-for-minor-pre-major": false, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance" }, + { "type": "deps", "section": "Dependencies" }, + { "type": "revert", "section": "Reverts" }, + { "type": "docs", "section": "Documentation", "hidden": true }, + { "type": "chore", "section": "Chores", "hidden": true }, + { "type": "refactor", "section": "Refactors", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "build", "section": "Build", "hidden": true }, + { "type": "ci", "section": "CI", "hidden": true }, + { "type": "style", "section": "Style", "hidden": true } + ], + "packages": { + ".": { + "package-name": "OcotilloAPI" + } + } +} From d8e2d2d99e09ec5113f74dfb8302581583b88d16 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 2 Jun 2026 18:26:51 -0600 Subject: [PATCH 2/6] chore: regenerate uv.lock for project version 1.0.0 uv sync --locked compares lockfile project metadata to pyproject.toml; mismatched version (lock pinned 0.1.0 vs pyproject 1.0.0) would fail CI. Co-Authored-By: Claude Opus 4.7 --- uv.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/uv.lock b/uv.lock index 3cfa8ce46..487b728ca 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.13" [[package]] @@ -1440,7 +1440,7 @@ wheels = [ [[package]] name = "ocotilloapi" -version = "0.1.0" +version = "1.0.0" source = { editable = "." } dependencies = [ { name = "aiofiles" }, From 562753cb440bbdbe9121f168396d724f7e1c8ff8 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 2 Jun 2026 18:36:32 -0600 Subject: [PATCH 3/6] chore: enable release-please prereleases (rc) + broaden CD tag trigger - release-please-config.json: set "prerelease": true and "prerelease-type": "rc" on the root package so the next Release PR cuts 1.0.0rc1; subsequent cuts roll rc2, rc3, etc. Flip to false to graduate to 1.0.0 GA. - CD_production.yml: extend tag trigger to also match SemVer prerelease tags (v*.*.*-*) and PEP 440 prerelease tags (v*.*.*[a-z]*) so v1.0.0rc1 fires the deploy. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/CD_production.yml | 5 ++++- release-please-config.json | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CD_production.yml b/.github/workflows/CD_production.yml index 745f12a0b..b080c3272 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -2,7 +2,10 @@ name: CD (Production) on: push: - tags: ['v*.*.*'] + tags: + - 'v*.*.*' # GA releases: v1.0.0, v1.4.2 + - 'v*.*.*-*' # SemVer pre-releases: v1.0.0-rc.1 + - 'v*.*.*[a-z]*' # PEP 440 pre-releases: v1.0.0rc1, v1.0.0b2 (release-please-python form) permissions: contents: read diff --git a/release-please-config.json b/release-please-config.json index 2b810a65a..4011c4f86 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -21,7 +21,9 @@ ], "packages": { ".": { - "package-name": "OcotilloAPI" + "package-name": "OcotilloAPI", + "prerelease": true, + "prerelease-type": "rc" } } } From 6b008c8c318c656c621b22fade0860ae8f61eb51 Mon Sep 17 00:00:00 2001 From: Jake Ross Date: Tue, 2 Jun 2026 18:52:25 -0600 Subject: [PATCH 4/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/hotfix-start.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/hotfix-start.yml b/.github/workflows/hotfix-start.yml index 4891f16fe..095bcd74d 100644 --- a/.github/workflows/hotfix-start.yml +++ b/.github/workflows/hotfix-start.yml @@ -23,7 +23,7 @@ jobs: create-hotfix-branch: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.2 + - uses: actions/checkout@v6.0.3 with: fetch-depth: 0 From 36c1b0f78e6e28911cd9989d3b96d9c27003e742 Mon Sep 17 00:00:00 2001 From: jakeross Date: Tue, 2 Jun 2026 18:53:30 -0600 Subject: [PATCH 5/6] chore: pass APP_VERSION through staging + testing deploys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit app.template.yaml requires APP_VERSION via envsubst; previously only the production workflow supplied it, so staging/testing deploys would render an empty value and Settings.version would fall back to importlib.metadata. That fallback also fails because uv export --no-emit-project skips installing the project, so /health and OpenAPI info.version were both reporting "0.0.0" on non-prod App Engine services. Sets APP_VERSION to "{branch}-{sha}" for staging + testing — unambiguous, sortable, and not mistakable for a release tag. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/CD_staging.yml | 1 + .github/workflows/CD_testing.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/CD_staging.yml b/.github/workflows/CD_staging.yml index 935ec8b0e..1381fa581 100644 --- a/.github/workflows/CD_staging.yml +++ b/.github/workflows/CD_staging.yml @@ -66,6 +66,7 @@ jobs: - name: Render App Engine configs env: + APP_VERSION: "${{ github.ref_name }}-${{ github.sha }}" ENVIRONMENT: "staging" CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" diff --git a/.github/workflows/CD_testing.yml b/.github/workflows/CD_testing.yml index 1ef88e8f7..ad4ab7060 100644 --- a/.github/workflows/CD_testing.yml +++ b/.github/workflows/CD_testing.yml @@ -66,6 +66,7 @@ jobs: - name: Render App Engine configs env: + APP_VERSION: "${{ github.ref_name }}-${{ github.sha }}" ENVIRONMENT: "staging" CLOUD_SQL_INSTANCE_NAME: "${{ secrets.CLOUD_SQL_INSTANCE_NAME }}" CLOUD_SQL_DATABASE: "${{ vars.CLOUD_SQL_DATABASE }}" From a09658de76619860bdb418beddf809fddc8bb818 Mon Sep 17 00:00:00 2001 From: Jake Ross Date: Tue, 2 Jun 2026 18:56:49 -0600 Subject: [PATCH 6/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- core/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/settings.py b/core/settings.py index 7665ee7a2..95ea93b68 100644 --- a/core/settings.py +++ b/core/settings.py @@ -20,7 +20,7 @@ def _resolve_version() -> str: env = os.getenv("APP_VERSION") if env: - return env.lstrip("v") + return env.removeprefix("v") try: return _pkg_version("OcotilloAPI") except PackageNotFoundError: