diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..f018eb4
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,135 @@
+name: Release
+
+on:
+ push:
+ tags: ['v*']
+
+permissions: {}
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: "3.14"
+ - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/pip
+ key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
+ restore-keys: |
+ ${{ runner.os }}-pip-
+ ${{ runner.os }}-
+ - run: |
+ pip install flake8
+ flake8 ./chartmogul
+
+ test:
+ name: Test (Python ${{ matrix.python-version }})
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ strategy:
+ fail-fast: true
+ matrix:
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: ${{ matrix.python-version }}
+ - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
+ with:
+ path: ~/.cache/pip
+ key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
+ restore-keys: |
+ ${{ runner.os }}-pip-
+ ${{ runner.os }}-
+ - run: |
+ python -m pip install --upgrade pip setuptools wheel
+ pip install -e .[testing]
+ - run: python -m unittest
+
+ build:
+ name: Build
+ needs: [lint, test]
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: "3.14"
+
+ - name: Verify version matches tag
+ run: |
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
+ PKG_VERSION=$(python -c "import re; print(re.search(r\"^__version__\s*=\s*['\\\"]([^'\\\"]*)['\\\"]\" , open('chartmogul/version.py').read(), re.MULTILINE).group(1))")
+ if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then
+ echo "::error::Tag version ($TAG_VERSION) does not match package version ($PKG_VERSION)"
+ exit 1
+ fi
+
+ - name: Build sdist and wheel
+ run: |
+ python -m pip install --upgrade build
+ python -m build
+
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: dist
+ path: dist/
+ if-no-files-found: error
+ retention-days: 5
+
+ publish:
+ name: Publish to PyPI
+ needs: [build]
+ runs-on: ubuntu-24.04
+ permissions:
+ id-token: write
+ environment:
+ name: pypi
+ url: https://pypi.org/project/chartmogul/
+ steps:
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: dist
+ path: dist/
+
+ - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
+ with:
+ attestations: true
+
+ release:
+ name: GitHub Release
+ needs: [build]
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ persist-credentials: false
+
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: dist
+ path: dist/
+
+ - name: Create GitHub Release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: gh release create "$GITHUB_REF_NAME" dist/* --generate-notes
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 925628c..4691f95 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,4 +1,4 @@
-name: Run specs and generate Code Climate report
+name: Test
on:
push:
branches: [ main ]
@@ -54,7 +54,7 @@ jobs:
${{ runner.os }}-
- name: Install dependencies
run: |
- python -m pip install --upgrade pip setuptools wheel coverage
+ python -m pip install --upgrade pip setuptools wheel
pip install -e .[testing]
- name: Run tests
- run: coverage run -m unittest && coverage xml -i --include='chartmogul/*'
\ No newline at end of file
+ run: python -m unittest
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 20b8b1b..9c1d43d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,88 +1,3 @@
# Changelog
-All notable changes to this project will be documented in this file.
-
-The format is based on [Keep a Changelog],
-and this project adheres to [Semantic Versioning].
-
-[Keep a Changelog]: https://keepachangelog.com/en/1.0.0/
-[Semantic Versioning]: https://semver.org/spec/v2.0.0.html
-
-## [4.9.0] - 2026-03-16
-- Add `external_id` key to Contact model
-
-## [4.8.0] - 2026-01-06
-- Mention filters (CFL) param for metrics api
-- Add Customer.subscriptions
-- Update CustomerSubscription.list_imported to deprecated
-- Add disabled, disabled_at, edit_history_summary and errors fields to Invoice
-
-## [4.7.0] - 2025-12-22
-- Add fetching additional DataSource fields
-
-## [4.6.4] - 2025-11-24
-- Add Subscription Set ID key to Activity
-
-## [4.6.3] - 2025-09-01
-- Remove future dependency to resolve vulnerability issues
-
-## [4.6.2] - 2025-07-09
-- Update Marshmallow dependency to use >=3.24.0
-
-## [4.6.1] - 2025-05-19
-- Fixed Tasks API schema issue
-- Unify requirements in a single place
-- Support for Python 3.13.
-
-## [4.6.0] - 2025-04-25
-- Adds support for Tasks (https://dev.chartmogul.com/reference/tasks)
-
-## [4.5.1] - 2025-04-07
-- Update urllib3 dependency to use >=2.2.3 to allow for future minor updates
-
-## [4.5.0] - 2025-03-18
-- Adds support for disconnecting subscriptions
-- Adds support for transaction fees to transactions
-
-## [4.4.0] - 2024-10-24
-- Adds support for unmerging customers
-
-## [4.3.2] - 2024-06-26
-- Remove VCR dependencies
-- Replaced unit tests that depended on VCR using `request_mock`
-- Updated urllib3 to latest secure version
-
-## [4.3.1] - 2024-06-20
-- Update the urllib3 dependency to a secure version
-
-## [4.3.0] - 2024-03-25
-- Adds support for Opportunities (https://dev.chartmogul.com/reference/opportunities)
-
-## [4.2.1] - 2024-01-15
-- Fix customer website_url, add missing allow_none=True
-
-## [4.2.0] - 2024-01-08
-- Add support for customer website_url
-
-## [4.1.1] - 2023-12-21
-- Fix missing customer_uuid when creating a note from a customer
-
-## [4.1.0] - 2023-12-20
-- Support customer notes
-
-## [4.0.0] - 2023-10-04
-
-### Added
-- v4.0.0 upgrade instructions.
-- Support for Python 3.12.
-
-### Removed
-- Support for old pagination using `page` query params.
-- Deprecated `imp` module.
-- Support for Python 3.7.
-
-## [3.1.3] - 2023-09-27
-
-### Added
-- Support for cursor based pagination to `.all()` endpoints.
-- Changelog.
+Release notes are auto-generated from merged pull request titles and published on the [GitHub Releases page](https://github.com/chartmogul/chartmogul-python/releases).
diff --git a/README.md b/README.md
index 8c8b571..9bdafbb 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@
chartmogul-python provides convenient Python bindings for ChartMogul's API.
-
+
@@ -22,6 +22,8 @@
|
Contributing
|
+Security
+|
License
@@ -470,15 +472,17 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/chartm
## Releasing
-Make sure that:
-1. you have prepared `~/.pypirc` with credentials,
-2. a higher version has been set in `chartmogul/__init__.py`,
-3. Run tests `python3 -m unittest`
-4. Build package `python3 setup.py sdist`
-4. release works `twine upload --repository-url https://test.pypi.org/legacy/ dist/*`,
-5. release to production `twine upload dist/*`,
+See [RELEASING.md](RELEASING.md) for the full release process and security details.
+
+## Security
+
+### Verifying Releases
+
+All releases of this library are published as [immutable GitHub Releases](https://github.com/chartmogul/chartmogul-python/releases) with protected tags and as a package on [PyPI](https://pypi.org/project/chartmogul/).
-[Read full HOWTO](http://peterdowns.com/posts/first-time-with-pypi.html)
+To maximize supply chain security:
+- **Commit your lock file** to version control — both `uv.lock` and `requirements.txt` record package hashes automatically
+- **Use [uv](https://docs.astral.sh/uv/)** for hash-verified, reproducible installs
## License
diff --git a/RELEASING.md b/RELEASING.md
new file mode 100644
index 0000000..47c25e7
--- /dev/null
+++ b/RELEASING.md
@@ -0,0 +1,85 @@
+# Releasing chartmogul-python
+
+## Prerequisites
+
+- You must have push access to the repository
+- `git`, `gh`, `jq`, and `python3` must be installed
+- Tags matching `v*` are protected by GitHub tag protection rulesets
+- Releases are immutable once published (GitHub repository setting)
+- PyPI [trusted publishing](https://docs.pypi.org/trusted-publishers/) must be configured for the package (see [Repository Settings](#repository-settings-admin))
+
+## Release Process
+
+Run the release script from the repository root:
+
+```sh
+bin/release.sh
+```
+
+The script will:
+
+1. Verify prerequisites and that CI is green on `main`
+2. Show any open PRs targeting `main` and ask for confirmation
+3. Show PRs merged since the last tag (what's being released) and ask for confirmation
+4. Bump the version in `chartmogul/version.py`
+5. Create a release branch, commit, push, and open a PR
+6. Wait for the PR to be merged (poll every 10s)
+7. Tag the merge commit and push the tag
+8. Wait for the [release workflow](.github/workflows/release.yml) to complete, which will:
+ - Run lint and the full test suite across Python 3.10-3.14
+ - Verify that `chartmogul/version.py` version matches the tag
+ - Build sdist and wheel
+ - Create a GitHub Release with auto-generated release notes and attached distribution files
+ - Publish to PyPI via [OIDC trusted publishing](https://docs.pypi.org/trusted-publishers/) with [build attestations](https://docs.pypi.org/attestations/)
+9. Print links to the GitHub Release and PyPI package
+
+## Changelog
+
+Release notes are auto-generated from merged PR titles by the [release workflow](.github/workflows/release.yml). To ensure useful changelogs:
+
+- Use clear, descriptive PR titles (e.g., "Add External ID field to Contact model")
+- Prefix breaking changes with `BREAKING:` so they stand out in release notes
+- After the release is created, review and edit the notes on the [Releases page](https://github.com/chartmogul/chartmogul-python/releases) if needed
+
+## Pre-release Versions
+
+For pre-release versions, use a semver pre-release suffix:
+
+```sh
+git tag v4.X.Y-rc1
+git push origin v4.X.Y-rc1
+```
+
+These will be automatically marked as pre-releases on GitHub.
+
+## Security
+
+### Repository Protections
+
+- **Immutable releases**: Once a GitHub Release is published, its tag cannot be moved or deleted, and release assets cannot be modified
+- **Tag protection rulesets**: `v*` tags cannot be deleted or force-pushed
+
+### PyPI
+
+- Publishing uses [OIDC trusted publishing](https://docs.pypi.org/trusted-publishers/) — no long-lived API tokens are stored in the repository. GitHub Actions authenticates directly with PyPI via short-lived OIDC tokens.
+- Once a package version is published to PyPI, [it cannot be republished](https://pypi.org/help/#file-name-reuse) with different contents
+- Packages are published with [build attestations](https://docs.pypi.org/attestations/) (SLSA provenance), linking each version to the specific GitHub Actions run that built it
+- Users can verify package integrity using pip's hash-checking mode (`--require-hashes`)
+- Pinning versions in `requirements.txt` with hashes ensures reproducible installs
+
+### What This Protects Against
+
+- A compromised maintainer account cannot modify or delete existing releases
+- No long-lived API tokens exist that could be leaked or stolen
+- Tags cannot be moved to point to different commits after publication
+- The PyPI registry provides an independent immutability layer beyond GitHub
+- Build attestations allow anyone to verify a package was built from this repository by GitHub Actions
+
+### Repository Settings (Admin)
+
+These settings must be configured by a repository admin:
+
+1. **Immutable Releases**: Settings > General > Releases > Enable "Immutable releases"
+2. **Tag Protection Ruleset**: Settings > Rules > Rulesets > New ruleset targeting tags matching `v*` with deletion, force-push, and update prevention
+3. **GitHub Actions Environment**: Settings > Environments > New environment named `pypi`
+4. **PyPI Trusted Publishing**: On pypi.org, go to [chartmogul settings](https://pypi.org/manage/project/chartmogul/settings/publishing/) and configure a trusted publisher with: repository `chartmogul/chartmogul-python`, workflow `release.yml`, environment `pypi`
diff --git a/bin/release.sh b/bin/release.sh
new file mode 100755
index 0000000..634d1c9
--- /dev/null
+++ b/bin/release.sh
@@ -0,0 +1,216 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# ─── Usage ──────────────────────────────────────────────────────────────────────
+
+usage() {
+ echo "Usage: bin/release.sh "
+ exit 1
+}
+
+[[ $# -eq 1 ]] || usage
+
+BUMP_TYPE="$1"
+case "$BUMP_TYPE" in
+ patch|minor|major) ;;
+ *) usage ;;
+esac
+
+# ─── Prerequisites ──────────────────────────────────────────────────────────────
+
+for cmd in git gh jq python3; do
+ if ! command -v "$cmd" &>/dev/null; then
+ echo "Error: '$cmd' is required but not found on PATH." >&2
+ exit 1
+ fi
+done
+
+REPO=$(gh repo view --json nameWithOwner -q .nameWithOwner)
+STASHED=false
+
+restore_stash() {
+ if [[ "$STASHED" == true ]]; then
+ git checkout main 2>/dev/null || true
+ git stash pop --quiet 2>/dev/null || true
+ STASHED=false
+ fi
+}
+
+# ─── Check main CI is green ─────────────────────────────────────────────────────
+
+echo "Checking CI status on main..."
+LAST_RUN=$(gh run list --branch main --workflow test.yml --limit 1 --json conclusion,url)
+CONCLUSION=$(echo "$LAST_RUN" | jq -r '.[0].conclusion // empty')
+RUN_URL=$(echo "$LAST_RUN" | jq -r '.[0].url // empty')
+
+if [[ "$CONCLUSION" != "success" ]]; then
+ echo "Error: Latest CI run on main is not green (status: ${CONCLUSION:-unknown})." >&2
+ [[ -n "$RUN_URL" ]] && echo " $RUN_URL" >&2
+ exit 1
+fi
+echo "CI is green."
+
+# ─── Show open PRs ──────────────────────────────────────────────────────────────
+
+OPEN_PRS=$(gh pr list --base main --json number,title,url)
+PR_COUNT=$(echo "$OPEN_PRS" | jq 'length')
+
+if [[ "$PR_COUNT" -gt 0 ]]; then
+ echo ""
+ echo "There are $PR_COUNT open PR(s) targeting main:"
+ echo "$OPEN_PRS" | jq -r '.[] | " #\(.number) \(.title)\n \(.url)"'
+ echo ""
+ read -rp "Continue releasing? [y/N] " CONFIRM
+ if [[ ! "$CONFIRM" =~ ^[yY]$ ]]; then
+ echo "Aborted."
+ exit 0
+ fi
+fi
+
+# ─── Show PRs included in this release ───────────────────────────────────────────
+
+LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
+if [[ -n "$LAST_TAG" ]]; then
+ echo ""
+ echo "PRs merged since ${LAST_TAG}:"
+ MERGED_PRS=$(gh pr list --base main --state merged --search "merged:>=$(git log -1 --format=%aI "$LAST_TAG")" --json number,title,url)
+else
+ echo ""
+ echo "PRs merged (no previous tag found, showing recent):"
+ MERGED_PRS=$(gh pr list --base main --state merged --limit 10 --json number,title,url)
+fi
+
+MERGED_COUNT=$(echo "$MERGED_PRS" | jq 'length')
+if [[ "$MERGED_COUNT" -eq 0 ]]; then
+ echo " (none)"
+else
+ echo "$MERGED_PRS" | jq -r '.[] | " #\(.number) \(.title)\n \(.url)"'
+fi
+
+echo ""
+read -rp "Release these changes as ${BUMP_TYPE}? [y/N] " CONFIRM
+if [[ ! "$CONFIRM" =~ ^[yY]$ ]]; then
+ echo "Aborted."
+ exit 0
+fi
+
+# ─── Bump version ───────────────────────────────────────────────────────────────
+
+VERSION_FILE="chartmogul/version.py"
+CURRENT_VERSION=$(python3 -c "import re; print(re.search(r\"^__version__\s*=\s*['\\\"]([^'\\\"]*)['\\\"]\" , open('${VERSION_FILE}').read(), re.MULTILINE).group(1))")
+IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
+
+case "$BUMP_TYPE" in
+ major) NEW_VERSION="$((MAJOR + 1)).0.0" ;;
+ minor) NEW_VERSION="${MAJOR}.$((MINOR + 1)).0" ;;
+ patch) NEW_VERSION="${MAJOR}.${MINOR}.$((PATCH + 1))" ;;
+esac
+
+TAG="v${NEW_VERSION}"
+BRANCH="release/${TAG}"
+
+echo ""
+echo "Bumping version: ${CURRENT_VERSION} -> ${NEW_VERSION}"
+
+git stash --include-untracked --quiet
+STASHED=true
+
+sed -i '' "s/__version__ = ['\"].*['\"]/__version__ = \"${NEW_VERSION}\"/" "$VERSION_FILE"
+
+# ─── Create release branch and PR ───────────────────────────────────────────────
+
+git checkout -b "$BRANCH" main
+git add "$VERSION_FILE"
+git commit -m "Update version to ${TAG}"
+git push -u origin "$BRANCH"
+
+PR_URL=$(gh pr create --title "Release ${TAG}" --body "Bump version to ${TAG}" | tail -1)
+PR_NUMBER=$(gh pr view "$PR_URL" --json number -q .number)
+
+echo ""
+echo "PR created: $PR_URL"
+echo "Waiting for PR #${PR_NUMBER} to be merged..."
+
+# ─── Poll for PR merge ──────────────────────────────────────────────────────────
+
+interrupt_merge_wait() {
+ echo ""
+ echo "Interrupted. To finish the release manually:"
+ echo " 1. Merge PR: $PR_URL"
+ echo " 2. git checkout main && git pull"
+ echo " 3. git tag ${TAG} && git push origin ${TAG}"
+ echo " 4. Monitor the release workflow on GitHub Actions"
+ restore_stash
+ exit 0
+}
+
+trap interrupt_merge_wait INT
+
+while true; do
+ STATE=$(gh pr view "$PR_NUMBER" --json state -q .state)
+ if [[ "$STATE" == "MERGED" ]]; then
+ echo ""
+ echo "PR #${PR_NUMBER} merged."
+ break
+ fi
+ printf "."
+ sleep 10
+done
+
+trap - INT
+
+# ─── Tag and push ───────────────────────────────────────────────────────────────
+
+git checkout main
+git pull
+git tag "$TAG"
+git push origin "$TAG"
+
+echo "Tag ${TAG} pushed."
+echo "Waiting for release workflow..."
+
+# ─── Poll for release CI ────────────────────────────────────────────────────────
+
+interrupt_ci_wait() {
+ echo ""
+ echo "Interrupted. The tag ${TAG} has been pushed and the release workflow is running."
+ echo " Workflow: https://github.com/${REPO}/actions/workflows/release.yml"
+ echo " GitHub: https://github.com/${REPO}/releases/tag/${TAG}"
+ echo " PyPI: https://pypi.org/project/chartmogul/${NEW_VERSION}/"
+ restore_stash
+ exit 0
+}
+
+trap interrupt_ci_wait INT
+
+sleep 5
+while true; do
+ RUN=$(gh run list --branch "$TAG" --workflow release.yml --limit 1 --json status,conclusion,url)
+ STATUS=$(echo "$RUN" | jq -r '.[0].status // empty')
+ RUN_CONCLUSION=$(echo "$RUN" | jq -r '.[0].conclusion // empty')
+ RELEASE_RUN_URL=$(echo "$RUN" | jq -r '.[0].url // empty')
+
+ if [[ "$STATUS" == "completed" ]]; then
+ echo ""
+ if [[ "$RUN_CONCLUSION" == "success" ]]; then
+ echo "Release workflow completed successfully."
+ else
+ echo "Release workflow finished with status: ${RUN_CONCLUSION}" >&2
+ fi
+ [[ -n "$RELEASE_RUN_URL" ]] && echo " $RELEASE_RUN_URL"
+ break
+ fi
+ printf "."
+ sleep 10
+done
+
+trap - INT
+
+# ─── Summary ────────────────────────────────────────────────────────────────────
+
+restore_stash
+
+echo ""
+echo "Release ${TAG} complete!"
+echo " GitHub: https://github.com/${REPO}/releases/tag/${TAG}"
+echo " PyPI: https://pypi.org/project/chartmogul/${NEW_VERSION}/"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..a28da48
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools>=75.0", "wheel"]
+build-backend = "setuptools.build_meta"
diff --git a/setup.py b/setup.py
index 723efec..3138b3d 100644
--- a/setup.py
+++ b/setup.py
@@ -1,17 +1,11 @@
#!/usr/bin/env python
-import os
import re
-import sys
from codecs import open
from setuptools import setup
github_url = "https://github.com/chartmogul/chartmogul-python"
-if sys.argv[-1] == "publish":
- os.system("python setup.py sdist upload")
- sys.exit()
-
requires = [
"requests>=2.31.0",
"uritemplate>=4.1.1",
@@ -65,10 +59,10 @@
classifiers=[
"Programming Language :: Python",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
],
)
diff --git a/zizmor.yml b/zizmor.yml
index 39d1b18..72eb5ac 100644
--- a/zizmor.yml
+++ b/zizmor.yml
@@ -1,3 +1,7 @@
rules:
secrets-outside-env:
disable: true
+ cache-poisoning:
+ ignore:
+ - release.yml:22
+ - release.yml:49