diff --git a/.github/app.template.yaml b/.github/app.template.yaml index 2ed7342a..619ba4cc 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 6d289bdd..b080c327 100644 --- a/.github/workflows/CD_production.yml +++ b/.github/workflows/CD_production.yml @@ -2,10 +2,13 @@ name: CD (Production) on: push: - branches: [production] + 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: write + contents: read jobs: production-deploy: @@ -66,6 +69,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 +145,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/CD_staging.yml b/.github/workflows/CD_staging.yml index 935ec8b0..1381fa58 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 1ef88e8f..ad4ab706 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 }}" diff --git a/.github/workflows/hotfix-start.yml b/.github/workflows/hotfix-start.yml new file mode 100644 index 00000000..095bcd74 --- /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.3 + 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 00000000..0917ed1b --- /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 00000000..6f42ae3e --- /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 26a05356..00000000 --- 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.3 - 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 00000000..37fcefaa --- /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 102256d4..17c04484 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 e1b94db0..95ea93b6 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.removeprefix("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 9209a78d..0af9aa46 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 00000000..4011c4f8 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,29 @@ +{ + "$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", + "prerelease": true, + "prerelease-type": "rc" + } + } +} diff --git a/uv.lock b/uv.lock index 3cfa8ce4..487b728c 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" },