diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..02e6a9b --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,5 @@ +changelog: + exclude: + authors: + - dependabot + - dependabot[bot] diff --git a/.github/workflows/release-testpypi.yml b/.github/workflows/release-testpypi.yml index 6357c42..f710fc2 100644 --- a/.github/workflows/release-testpypi.yml +++ b/.github/workflows/release-testpypi.yml @@ -9,6 +9,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Set up uv uses: astral-sh/setup-uv@v5 @@ -18,12 +20,6 @@ jobs: - name: Set up Python run: uv python install 3.12 - - name: Show package version - run: | - VERSION="$(uv run python -c 'import tomllib; print(tomllib.loads(open("pyproject.toml","rb").read().decode())["project"]["version"])')" - echo "Publishing version: ${VERSION}" - echo "::notice title=TestPyPI version::${VERSION}" - - name: Build run: uv build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 947e861..7fe1ba7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,21 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate release tag + run: | + tag="${GITHUB_REF_NAME}" + if ! printf '%s' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Tag $tag is not in vMAJOR.MINOR.PATCH form" + exit 1 + fi + newest="$(git tag --list 'v*.*.*' --sort=-v:refname | head -n1)" + if [ "$tag" != "$newest" ]; then + echo "::error::Tag $tag is not the newest release tag ($newest); refusing to publish an out-of-order version" + exit 1 + fi - name: Set up uv uses: astral-sh/setup-uv@v5 @@ -20,17 +35,6 @@ jobs: - name: Set up Python run: uv python install 3.12 - - name: Verify tag matches package version - run: | - TAG_VERSION="${GITHUB_REF_NAME#v}" - PKG_VERSION="$(uv run python -c 'import tomllib,sys; print(tomllib.loads(open("pyproject.toml","rb").read().decode())["project"]["version"])')" - echo "Tag version: ${TAG_VERSION}" - echo "Package version: ${PKG_VERSION}" - if [ "${TAG_VERSION}" != "${PKG_VERSION}" ]; then - echo "::error::Tag ${GITHUB_REF_NAME} does not match pyproject.toml version ${PKG_VERSION}" - exit 1 - fi - - name: Build run: uv build diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 3205275..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,82 +0,0 @@ -# Changelog - -## v1.4.0 - -### Added -- `dm connections create --file` now supports Databricks SQL Warehouse - (`"type": "databricks"`) and MongoDB (`"type": "mongodb"`) connections. - Both list, get, create, and delete like the existing connection types. - -## v1.3.0 - -### Added -- `dm system ai-engine show` and `dm system ai-engine set ` — view and - configure the AI Engine URL. - -## v1.2.0 - -### Added -- `dm ifm` command group - for managing in-flight masking ruleset plans - and running mask operations against the IFM service: - - `dm ifm list` — - list all IFM ruleset plans. - - `dm ifm get ` — - show plan metadata, - or the ruleset YAML with `--yaml`. - - `dm ifm create --name --file ` — - create a plan from a YAML ruleset, - with optional `--enabled/--disabled` and `--log-level`. - - `dm ifm update ` — - update a plan; - pass any of `--file`, `--enabled/--disabled`, `--log-level` - and only those fields are sent. - - `dm ifm delete ` — - delete a plan - (interactive confirm, - or `--yes` to skip). - - `dm ifm mask --data ` — - mask a JSON list of records against a plan, - with `--disable-instance-secret`, - `--run-secret`, - `--log-level`, - `--request-id`, - and `--json/--no-json` (NDJSON) output. - - `dm ifm verify-token` — - verify the current IFM token and list its scopes. - - Authentication reuses your existing `dm` profile credentials - via the SDK's `DataMasqueIfmClient`, - which transparently exchanges admin-server credentials for an IFM JWT. - -## v1.1.0 - -### Added -- `dm catalog` command — emits the full subcommand tree as JSON for agent - introspection. `--compact` for `{path, help}` only (~1.4kB), default for - full options/arguments. -- Auto-detection of agent context: output flips to JSON automatically when - stdout is not a TTY, when `DM_OUTPUT=json` is set, or when the - vendor-neutral `AI_AGENT` env var is present. `DM_OUTPUT=table` forces - human output. -- Structured error envelope on stderr in agent mode: - `{"error": {"code": "...", "message": "...", "hint": "..."}}` — stdout - stays empty on failure so downstream pipes don't trip. - -### Changed -- Exit codes are now differentiated by error category. Previously every - error returned 1; now: `not_found`=3, `invalid_input`=4, `ambiguous`=5, - `auth_required`=6, `auth_failed`=7, `conflict`=8, `transport_error`=9. - `error` (unclassified) remains 1; 2 is reserved for typer/click usage - errors. Stable across minor versions. -- Long values (UUIDs especially) now fold across lines in table output - rather than being silently truncated with `…` in narrow terminals. - -### Internal -- `ErrorCode` and `ConnectionType` are now `StrEnum`s; the abort code arg - is type-checked at edit time and the connection-type "Valid: ..." hint - is generated from the enum. - -## v1.0.0 - -Initial release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83b3fa1..13924a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ File an issue on the [GitHub issue tracker](https://github.com/datamasque/datamasque-cli/issues). Please include: -- the version of `datamasque-cli` you're using (`dm --version` or `pip show datamasque-cli`); +- the version of `datamasque-cli` you're using (`dm version` or `pip show datamasque-cli`); - the Python version and operating system; - the command you ran (with credentials and other sensitive arguments redacted); - the full output, including any traceback. @@ -48,8 +48,8 @@ so the `dm` entry point on the venv reflects your working tree — no reinstall after each edit. ```console -uv run dm --version # one-shot, no venv activation needed -source .venv/bin/activate && dm --version # or activate once per shell +uv run dm version # one-shot, no venv activation needed +source .venv/bin/activate && dm version # or activate once per shell ``` Point it at a DataMasque instance. @@ -217,22 +217,41 @@ and so on. ## Releasing -Releases are published automatically by CI when a version tag is pushed. +A release is a Git tag. +The version comes from the tag rather than a file, +so there is nothing to bump by hand and nothing is committed to `main`. +The notes are generated by GitHub from the pull requests merged since the last +release, so there is no changelog to maintain either. + +Tags are semver, `v`-prefixed: `vMAJOR.MINOR.PATCH`, for example `v1.4.1`. + +To cut a release, pick the change level: ```console -make release-patch # 0.1.0 → 0.1.1 — bug fixes -make release-minor # 0.1.0 → 0.2.0 — new features -make release-major # 0.1.0 → 1.0.0 — breaking changes +make release-patch # bug fixes only +make release-minor # backwards-compatible features +make release-major # breaking changes ``` -Each target runs `make check`, -bumps the version in `pyproject.toml`, -refreshes `uv.lock`, -commits, tags, and pushes. -CI handles the publish to PyPI. - -To smoke-test a release against TestPyPI without tagging, -trigger the `Release (TestPyPI)` workflow manually from the GitHub Actions tab. +Each reads the latest tag, +works out the next version, +asks you to confirm, +and creates the release. +Creating the tag is what triggers CI to build and publish to PyPI, +so give your pull requests clear titles +for the generated notes to read well. + +The same thing by hand is `gh release create v1.4.1 --generate-notes`, +or the GitHub Releases UI. +A published version is immutable: +PyPI will not let you reuse a number, +so a wrong tag means moving on to the next one. + +To exercise a build without releasing, +run the `Release (TestPyPI)` workflow from the **Actions** tab. +Between tags the version is a `.devN` pre-release, +which is also how an unreleased change can be published +for others to install and test. ## Toolchain diff --git a/Makefile b/Makefile index 8664726..368ab6e 100644 --- a/Makefile +++ b/Makefile @@ -33,33 +33,11 @@ format-check: build: uv build -# Bump version, commit, tag, push — CI publishes automatically. -# Usage: make release-patch (0.1.0 → 0.1.1) -# make release-minor (0.1.0 → 0.2.0) -# make release-major (0.1.0 → 1.0.0) -release-patch: check - $(eval VERSION := $(shell python3 scripts/bump_version.py patch)) - uv lock - git add pyproject.toml uv.lock - git commit -m "Release v$(VERSION)" - git tag "v$(VERSION)" - git push && git push --tags - @echo "Released v$(VERSION) — CI will publish to PyPI (https://pypi.org/p/datamasque-cli)" +release-patch: + @sh scripts/release.sh patch -release-minor: check - $(eval VERSION := $(shell python3 scripts/bump_version.py minor)) - uv lock - git add pyproject.toml uv.lock - git commit -m "Release v$(VERSION)" - git tag "v$(VERSION)" - git push && git push --tags - @echo "Released v$(VERSION) — CI will publish to PyPI (https://pypi.org/p/datamasque-cli)" +release-minor: + @sh scripts/release.sh minor -release-major: check - $(eval VERSION := $(shell python3 scripts/bump_version.py major)) - uv lock - git add pyproject.toml uv.lock - git commit -m "Release v$(VERSION)" - git tag "v$(VERSION)" - git push && git push --tags - @echo "Released v$(VERSION) — CI will publish to PyPI (https://pypi.org/p/datamasque-cli)" +release-major: + @sh scripts/release.sh major diff --git a/pyproject.toml b/pyproject.toml index b4dfa47..827067e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "datamasque-cli" -version = "1.4.0" +dynamic = ["version"] description = "Official command-line interface for the DataMasque data-masking platform." authors = [ { name = "DataMasque Ltd" }, @@ -127,8 +127,14 @@ markers = [ addopts = "-m 'not integration'" [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" +[tool.hatch.version] +source = "vcs" + +[tool.hatch.version.raw-options] +local_scheme = "no-local-version" + [tool.hatch.build.targets.wheel] packages = ["src/datamasque_cli"] diff --git a/scripts/bump_version.py b/scripts/bump_version.py deleted file mode 100644 index b76fce4..0000000 --- a/scripts/bump_version.py +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env python3 -"""Bump the version in pyproject.toml and print the new version.""" - -from __future__ import annotations - -import re -import sys - -LEVEL = sys.argv[1] if len(sys.argv) > 1 else "patch" - -with open("pyproject.toml") as f: - content = f.read() - -match = re.search(r'version = "(\d+)\.(\d+)\.(\d+)"', content) -if not match: - print("Could not find version in pyproject.toml", file=sys.stderr) - sys.exit(1) - -major, minor, patch = int(match.group(1)), int(match.group(2)), int(match.group(3)) - -if LEVEL == "patch": - patch += 1 -elif LEVEL == "minor": - minor += 1 - patch = 0 -elif LEVEL == "major": - major += 1 - minor = 0 - patch = 0 -else: - print(f"Unknown level: {LEVEL}. Use patch, minor, or major.", file=sys.stderr) - sys.exit(1) - -new_version = f"{major}.{minor}.{patch}" -new_content = content.replace(match.group(0), f'version = "{new_version}"') - -with open("pyproject.toml", "w") as f: - f.write(new_content) - -print(new_version) diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 0000000..3a406e6 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env sh +# Cut a release by creating a tag only: +# the version is derived from the tag, so nothing is committed to main. +set -eu + +level=${1:-patch} +git fetch --tags --quiet +latest=$(git describe --tags --abbrev=0 --match 'v*.*.*') + +IFS=. read -r major minor patch <&2; exit 1 ;; +esac + +next="v$major.$minor.$patch" +printf 'Release %s (from %s)? This publishes to PyPI and cannot be undone. [y/N] ' "$next" "$latest" +read -r reply +case "$reply" in + [yY]*) gh release create "$next" --generate-notes --target main ;; + *) echo "Aborted." >&2; exit 1 ;; +esac diff --git a/uv.lock b/uv.lock index 13b056f..938a4a7 100644 --- a/uv.lock +++ b/uv.lock @@ -141,7 +141,6 @@ wheels = [ [[package]] name = "datamasque-cli" -version = "1.4.0" source = { editable = "." } dependencies = [ { name = "datamasque-python" },