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.

PyPI version - Build Status + Build Status


@@ -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