Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 64 additions & 42 deletions .github/workflows/ci-cd-ds-platform-utils.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,76 @@ on:
- "docs/**"

jobs:
check-version:
name: Check Version
guard-manual-version-bump:
name: Disallow manual version bumps
if: ${{ github.event_name == 'pull_request' && !(startsWith(github.head_ref, 'release-please--branches--') && github.event.pull_request.head.repo.full_name == github.repository) }}
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history for version tagging

- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: "${{ github.workspace }}/uv.lock"

- name: Check version
# https://github.com/gnprice/toml-cli
fetch-depth: 0
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false

- name: Fail if this PR changes the package version
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PACKAGE_NAME: ds-platform-utils
run: |
VERSION=$(uvx --from=toml-cli toml get --toml-path pyproject.toml project.version)
git tag "v$VERSION"
python3 <<'PY'
import os
import re
import subprocess
import sys

base_sha = os.environ["BASE_SHA"]
head_sha = os.environ["HEAD_SHA"]
package_name = os.environ["PACKAGE_NAME"]


def git_show(sha: str, path: str) -> str:
result = subprocess.run(
["git", "show", f"{sha}:{path}"],
capture_output=True,
text=True,
)
return result.stdout if result.returncode == 0 else ""


def pyproject_version(text: str) -> str | None:
match = re.search(r'(?m)^version\s*=\s*"([^"]+)"', text)
return match.group(1) if match else None


def lock_package_version(text: str, name: str) -> str | None:
match = re.search(
rf'name = "{re.escape(name)}"\nversion = "([^"]+)"',
text,
)
return match.group(1) if match else None


errors = []
base_py = pyproject_version(git_show(base_sha, "pyproject.toml"))
head_py = pyproject_version(git_show(head_sha, "pyproject.toml"))
if base_py != head_py:
errors.append(f"pyproject.toml version {base_py} -> {head_py}")

base_lock = lock_package_version(git_show(base_sha, "uv.lock"), package_name)
head_lock = lock_package_version(git_show(head_sha, "uv.lock"), package_name)
if base_lock != head_lock:
errors.append(f"uv.lock {package_name} version {base_lock} -> {head_lock}")

if errors:
print(
"::error::Do not bump the package version in feature PRs. "
"Release Please updates pyproject.toml and uv.lock in a dedicated release PR."
)
print("\n".join(errors))
sys.exit(1)
PY

code-quality-checks:
name: Lint, Format, and Static Code Quality Checks
Expand Down Expand Up @@ -117,31 +167,3 @@ jobs:
COVERAGE_DIR="$(python -c 'import ds_platform_utils; print(ds_platform_utils.__path__[0])')"
poe clean
poe test --cov="$COVERAGE_DIR" --no-cov -n auto

tag-version:
needs: [check-version, code-quality-checks, build-wheel, execute-tests]
# if - this is a merge to main or push directly to the main branch
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
name: Tag Version
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4

- name: Set up uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: "${{ github.workspace }}/uv.lock"

- name: Configure Git user
run: |
git config user.name "github-actions"
git config user.email "github-actions@users.noreply.github.com"

- name: Push Version Tag
# https://github.com/gnprice/toml-cli
run: |
VERSION=$(uvx --from=toml-cli toml get --toml-path pyproject.toml project.version)
git tag "v${VERSION}" -m "Release version v$VERSION"
git push origin "v$VERSION"
32 changes: 32 additions & 0 deletions .github/workflows/lint-pr-title.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Lint PR title

on:
pull_request_target:
types: [opened, edited, synchronize, reopened]

permissions:
pull-requests: read

jobs:
lint-pr-title:
name: Lint PR title
runs-on: ubuntu-latest
steps:
- uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
types: |
feat
fix
docs
perf
deps
revert
chore
refactor
test
ci
style
build
requireScope: false
26 changes: 26 additions & 0 deletions .github/workflows/release-please.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Release Please

on:
push:
branches:
- main
workflow_dispatch:

permissions:
contents: write
issues: write
pull-requests: write

jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
steps:
- uses: googleapis/release-please-action@v4
with:
# GITHUB_TOKEN can open the release PR if the repo allows Actions to
# create pull requests (Settings > Actions > General). Use a PAT
# (contents + pull requests) if you need CI to run on that PR.
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.6.1"
}
174 changes: 174 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,177 @@

- [Engineering best-practices audit](docs/engineering-best-practices-audit.md)

## Releasing

Versions are bumped automatically by [Release Please](https://github.com/googleapis/release-please) from your **PR title**, using [Conventional Commits](https://www.conventionalcommits.org/) and [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`).

Do **not** edit `project.version` in `pyproject.toml` (or `uv.lock`) on a feature PR. Squash-merge so the squash commit equals the PR title. CI fails if the title has no conventional prefix.

Ask: *could an existing caller keep their code unchanged and still get the same behavior?* Then pick PATCH, MINOR, or MAJOR below.

### How to set the PR title

Format: `type(optional-scope): short description`

Examples: `fix: use the new Snowflake warehouse in publish tests`, `feat(tags): stamp LAST_UPDATED on published tables`, `feat!: drop support for unquoted identifiers`.

#### PATCH (`fix:`) — bug fix, same API

Bump PATCH (`0.6.1` → `0.6.2`). Callers do not change their code. You corrected wrong behavior or accepted more valid inputs without changing meaning.

Use `fix:` when you:

- Correct a bug (`publish`, Snowflake queries, tags, `@pypi` extras)
- Tighten validation or fix a wrong SQL/warehouse/identifier
- Make an existing argument accept more valid values

```
fix: update warehouse name in test_publish_pandas_with_warehouse
fix: wrap ds.owner flow tag as ds-<owner>-team alias
```

Do **not** use `fix:` for a new public function or a new optional argument — that is MINOR.

#### MINOR (`feat:`) — new capability, still compatible

Bump MINOR (`0.6.1` → `0.7.0`). You add something callers *may* use. Old flows keep working with the same decorators and helpers.

Use `feat:` when you:

- Add an optional argument (`warehouse=`, extra table tags)
- Add a new helper (`uv_pypi`, `restore_step_state`, tag builders)
- Extend behavior behind a default that preserves today's results

```
feat(pypi): derive Metaflow @pypi environments from uv.lock
feat(tags): add LAST_UPDATED tag with auto-stamped timestamp
```

If old call sites would break or change meaning, that is **not** MINOR — use MAJOR (`!`).

#### MAJOR (`feat!:` / `fix!:` / `BREAKING CHANGE:`) — incompatible API

Incompatible change to the public API: `publish`, `publish_pandas`, `uv_pypi`, tag helpers, Snowflake helpers, or required arguments.

Use a bang on the type, or a `BREAKING CHANGE:` footer in the PR body (footer must be at the end):

```
feat!: require quoted Snowflake identifiers

fix!: rename execute() to execute_string()

feat: tag all published tables by default

BREAKING CHANGE: dev tables are tagged as well as prod; pass tag_dev=False to keep the old prod-only behavior.
```

Examples of breaking:

- Rename/remove a public function, argument, or return shape
- Change a default so existing flows write different tables/tags/SQL
- Drop a Python version or make a previously optional argument required

While the package is `0.y.z`, a breaking title bumps **MINOR** (`0.6.1` → `0.7.0`), not `1.0.0`. After `1.0.0`, the same title bumps MAJOR (`1.2.3` → `2.0.0`). Cut `1.0.0` on purpose with:

```shell
git commit --allow-empty -m "chore: release 1.0.0" -m "Release-As: 1.0.0"
```

#### Titles that should not bump the library version

| Prefix | Use for |
| ------ | ------- |
| `chore:` | lockfile, formatting, or other work with no user-facing change |

`docs:`, `perf:`, `deps:`, and `revert:` are PATCH (they appear in the changelog, so they open a release PR). Hidden types above do not. If a refactor or chore **does** break callers, put `!` on it (`refactor!: ...`) or add a `BREAKING CHANGE:` footer.

### Quick chooser

1. Would existing flow code fail or silently change meaning? → `feat!:` / `fix!:` / `BREAKING CHANGE:`
2. Else, did you add optional API or new helpers? → `feat:`
3. Else, did you fix incorrect behavior? → `fix:`
4. Else (tests, internal CI, tidy-up) → `chore:` / `ci:` / `test:` / `refactor:`

Wrong prefix = wrong version. `feat:` on a one-line bugfix cuts a MINOR. `fix:` on a new public helper hides a MINOR as a PATCH.

### After you merge

1. Release Please opens or updates a release PR (`pyproject.toml`, `uv.lock`, `CHANGELOG.md`).
2. Merge that PR when you want to publish. It tags `vX.Y.Z` and creates a GitHub Release.

Enable **Allow GitHub Actions to create and approve pull requests** under **Settings → Actions → General**. To run CI on the release PR, point `release-please.yaml` at a PAT with `contents`, `pull-requests`, and `issues` instead of `GITHUB_TOKEN`.

### FAQ

**If both a `fix:` and a `feat:` land before we merge the release PR, which version do we get?**

The **highest** bump wins. They are not applied one after another.

Example: library is `1.2.3`. Someone merges `fix: …` (would be `1.2.4`). Before the release PR is merged, someone else merges `feat: …`. The release PR becomes **`1.3.0`**, not `1.2.4` and not `1.3.1`. The bugfix is still listed in that `1.3.0` changelog.

Same idea with breaking: `fix:` + `feat:` + `feat!:` → MAJOR (`2.0.0` once you are past `1.0.0`; while on `0.y.z` that breaking change is a MINOR).

**Do I change `pyproject.toml` in my PR?**

No. CI will fail if a feature PR edits `project.version`. Only the Release Please PR may bump it.

**Why didn’t merging my PR create a `v*` tag?**

Tags are created when the **release PR** is merged, not when your feature PR is merged. `chore:` / `ci:` / `test:` / `refactor:` (without `!`) also do not open a release.

**Why is there a second PR?**

Your PR is the code change. The bot PR is the version + changelog. You choose when to cut the release by merging the bot PR. Until then, more `fix:` / `feat:` PRs can pile into the same upcoming version.

**I already merged with the wrong title (`feat:` on a bugfix). What now?**

You cannot rewrite a merged squash commit. On the merged PR, add an override so the next Release Please run uses a different message:

```
BEGIN_COMMIT_OVERRIDE
fix: use the new Snowflake warehouse in publish tests
END_COMMIT_OVERRIDE
```

This only works with **squash-merge**. If the release PR is already open, it should update after the next run (or re-run the Release Please workflow).

**Can one PR include both a bugfix and a new feature?**

Prefer two PRs so each title is honest. If they must ship together, title it for the **highest** bump (`feat:` if there is a new helper; `feat!:` if anything breaks). Extra conventional lines in the squash commit body can show up as extra changelog entries, but the version still follows the highest type.

**Does `(tags)` or another scope change the version?**

No. `feat(tags):` is still MINOR. `fix(snowflake):` is still PATCH. The word before `:` (and `!`) decides the bump.

**We used “Create a merge commit” instead of squash. Does the title still count?**

Unreliable. Release Please then reads **each commit** on the branch. Squash-merge is required so the PR title is the one commit on `main`.

**Why did `docs:` bump the version?**

For Python, Release Please treats `docs:` as PATCH. Use `chore:` if the README/docs change should not release.

**We are on `0.6.x`. Will `feat!:` jump to `1.0.0`?**

No. Breaking changes bump MINOR until you intentionally release `1.0.0` (`Release-As: 1.0.0`). After `1.0.0`, `!` / `BREAKING CHANGE:` bump MAJOR.

**How do I force a specific version (e.g. skip to `0.8.0` or `1.0.0`)?**

Empty commit on `main` (or a `chore:` PR) with a `Release-As:` footer:

```shell
git commit --allow-empty -m "chore: release 1.0.0" -m "Release-As: 1.0.0"
```

**The release PR has no CI checks.**

`GITHUB_TOKEN` PRs often do not trigger other workflows. That is expected. Checks already ran on the feature PRs. To run CI on the release PR, use a PAT in `release-please.yaml`.

**Should I wait for the release PR before merging more work?**

No. Merge more feature PRs; the bot updates the same release PR. Merge the release PR when you want users to get a new tag.

**My feature PR failed `Disallow manual version bumps`.**

Revert the `version =` change in `pyproject.toml` (and the matching `ds-platform-utils` version in `uv.lock`). Leave versioning to the bot. Other `uv.lock` dependency updates are allowed.

20 changes: 20 additions & 0 deletions release-please-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"release-type": "python",
"include-component-in-tag": false,
"include-v-in-tag": true,
"bump-minor-pre-major": true,
"bootstrap-sha": "60ea68e168eabadbc68bd80868ed273ebc62938d",
"packages": {
".": {
"package-name": "ds-platform-utils",
"extra-files": [
{
"type": "toml",
"path": "uv.lock",
"jsonpath": "$.package[?(@.name.value=='ds-platform-utils')].version"
}
]
}
}
}
Loading