Skip to content
Merged
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: SDK validation
on:
pull_request:
push:
branches: [development]
branches: [development, main]
permissions:
contents: read
jobs:
Expand Down
71 changes: 54 additions & 17 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,43 +1,65 @@
name: Publish reviewed SDK
on:
push:
branches: [main]
workflow_dispatch:
inputs:
tag:
description: Reviewed version tag reachable from development (for example v3.0.0)
required: true
type: string
permissions:
contents: read
concurrency:
group: sdk-release-${{ inputs.tag }}
group: sdk-release-main
cancel-in-progress: false
jobs:
build:
if: github.ref == 'refs/heads/development'
prepare:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.version.outputs.tag }}
publish: ${{ steps.version.outputs.publish }}
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: '3.11'
- name: Validate and check out the reviewed release tag
- name: Select the version from the merged commit
id: version
env:
RELEASE_TAG: ${{ inputs.tag }}
GH_TOKEN: ${{ github.token }}
run: |
python -c "import os,re; assert re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', os.environ['RELEASE_TAG'])"
git fetch origin "refs/tags/$RELEASE_TAG:refs/tags/$RELEASE_TAG"
git merge-base --is-ancestor "$RELEASE_TAG^{commit}" origin/development
git checkout --detach "$RELEASE_TAG"
python -c "import ast,os,pathlib; version=ast.literal_eval(ast.parse(pathlib.Path('extend/__version__.py').read_text()).body[0].value); assert 'v'+version == os.environ['RELEASE_TAG']"
gh release list --limit 1000 --json tagName > /tmp/releases.json
python - <<'PYTHON'
import ast, json, os, pathlib, re, subprocess
version = ast.literal_eval(ast.parse(pathlib.Path('extend/__version__.py').read_text()).body[0].value)
if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+', version):
raise SystemExit('Expected a stable semantic version')
tag = 'v' + version
released = any(item['tagName'] == tag for item in json.loads(pathlib.Path('/tmp/releases.json').read_text()))
if not released:
existing = subprocess.run(['git', 'rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'], capture_output=True, text=True)
if existing.returncode == 0 and existing.stdout.strip() != os.environ['GITHUB_SHA']:
raise SystemExit('Release tag already points to another commit; do not reuse versions')
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
output.write(f'tag={tag}\npublish={str(not released).lower()}\n')
PYTHON
build:
needs: prepare
if: needs.prepare.outputs.publish == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
with:
fetch-depth: 0
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
with:
python-version: '3.11'
- run: python -m pip install . pytest==9.2.0 pytest-asyncio==1.4.1 ruff==0.17.3 build==1.6.1
- run: python -m ruff check extend tests tools
- run: python -m pytest -q
- run: python -m build
- name: Validate the built wheel and its runtime graph
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
run: |
python -m venv /tmp/release-wheel
/tmp/release-wheel/bin/python -m pip install -c requirements.lock dist/*.whl pytest==9.2.0 pytest-asyncio==1.4.1
Expand All @@ -52,7 +74,7 @@ jobs:
path: dist/*
if-no-files-found: error
publish:
needs: build
needs: [prepare, build]
runs-on: ubuntu-latest
environment: pypi
permissions:
Expand All @@ -63,3 +85,18 @@ jobs:
name: sdk-distributions
path: dist
- uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086 # release/v1
with:
skip-existing: true
release:
needs: [prepare, publish]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5
- name: Create the version tag and GitHub release after publication
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
run: |
gh release create "$RELEASE_TAG" --target "$GITHUB_SHA" --title "$RELEASE_TAG" --notes-file CHANGELOG.md
32 changes: 25 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,33 @@
# Changelog

## Unreleased
All notable changes to this project are documented here using
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

- Make the default test suite offline, with full source and installed-wheel CI coverage.
- Replace ambient live tests with explicit fixtures, opt-in writes, cleanup journals, and bounded automatch completion checks.
- Add origin-constrained DELETE transport for resource cleanup; mutations are never retried.
## [3.0.0]

All notable changes to this project will be documented in this file.
### Changed

- **Breaking:** require Python 3.11 or newer and an explicit `stage` or `prod` environment.
- **Breaking:** stop loading `.env` files automatically; applications own configuration loading.
- **Breaking:** validate low-level endpoint paths and resource identifiers; pass query values through `params`.
- Give clients independent authentication state and explicit asynchronous cleanup.
- Align transaction pagination, filters, card dates, recurrence and expense-label null handling with API contracts.
- Remove unused runtime dependencies; keep HTTPX as the only direct runtime dependency.

### Added

- Bounded retries for safe GET requests and structured, redacted API errors.
- Resource wrappers for organizations, members, users, expense policies and individual labels, subject to endpoint availability and permissions.
- Origin-constrained DELETE transport for resource cleanup; mutations are never retried.
- Offline source and installed-wheel tests on Python 3.11 through 3.14, plus opt-in live validation with explicit fixtures and cleanup journals.
- Migration, security and release documentation, dependency auditing and Trusted Publishing support.

### Security

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Validate request destinations before attaching credentials and disable redirects.
- Refresh authentication per request and prevent shared client credential reuse.
- Remove sensitive request logging and implicit environment-file loading.

## [2.0.0] - 2025-10-26

Expand Down
12 changes: 6 additions & 6 deletions MIGRATION.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Migrating from 2.x to 3.x

- Python 3.11 or newer is required. CI checks 3.11, 3.12 and 3.13.
- Python 3.11 or newer is required. CI checks 3.11, 3.12, 3.13 and 3.14.
- Select environment="stage" or environment="prod" when constructing a client.
An explicitly set ENV=stage or ENV=prod is supported for migration. Missing,
misspelled and legacy ENV=dev values fail at client construction. Import is safe.
Expand All @@ -25,11 +25,11 @@

## Releases

Release publication is manual, from a versioned tag reachable from development.
Run the release workflow on development with that tag after review and CI.
The protected pypi environment and Trusted Publisher must already be configured.
The workflow tests the source, builds a wheel, tests that wheel and publishes the
same artifacts. Merging a PR does not publish a package.
Merging a release PR into `main` automatically starts source validation, wheel
validation and building. Publication waits for approval of the protected `pypi`
environment, then uses Trusted Publishing and creates the version tag and GitHub
release. Both the environment and Trusted Publisher must be configured first.
See [RELEASING.md](RELEASING.md) for configuration and recovery instructions.

## Resource contracts

Expand Down
38 changes: 38 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Publishing a release

Releases promote reviewed changes to `main`. Merging a release PR automatically
starts validation and building. Publication waits for approval of the `pypi`
environment.

## One-time publisher configuration

1. Create the GitHub environment `pypi` and restrict deployment branches to `main`.
Configure the repository's required release reviewers before publishing.
2. On PyPI, configure a Trusted Publisher for the existing `paywithextend` project:
owner `paywithextend`, repository `extend-python`, workflow `release.yml`,
environment `pypi`.
3. Verify both configurations before releasing. The workflow uses GitHub OIDC;
the legacy `PYPI_API_TOKEN` secret is not used. Do not remove legacy credentials
until the replacement publisher has been verified.

## Release procedure

1. Update `extend/__version__.py`, the changelog and migration notes together.
2. Open a release PR to `main`; require review and successful Python 3.11–3.14 CI.
3. Merge the release PR. **Publish reviewed SDK** starts automatically on `main`,
reads the version from that exact commit, and skips versions with an existing
GitHub release. Never reuse a published version or move its tag.
4. After source tests, installed-wheel tests and runtime advisory checks pass,
an authorized reviewer approves the `pypi` deployment in the Actions run.
GitHub publishes the validated artifacts through OIDC, then creates the version
tag and GitHub release at the merged commit using the changelog as release notes.
5. Confirm both distributions exist on PyPI and a fresh installation reports the
expected version. For a failed run, use **Re-run failed jobs**. Publication skips
existing files so a partial upload or GitHub release failure can be recovered
without replacing published files. **Run workflow** on `main` remains available
for recovery; no tag input or manual tag creation is needed.
6. Merge release-only metadata changes back into `development` when necessary.

Publish SDK 3.0.0 before releasing packages that require `paywithextend==3.0.0`.
A successful offline test run does not establish live endpoint availability or
account permissions. See the availability notes in the README.
Loading