diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f21aad4..8f44a38 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: SDK validation on: pull_request: push: - branches: [development] + branches: [development, main] permissions: contents: read jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e5ee91..3173e84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,20 +1,20 @@ 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: @@ -22,22 +22,44 @@ jobs: - 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 @@ -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: @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9918f0e..5577b68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/MIGRATION.md b/MIGRATION.md index af41368..d56ca8e 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -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. @@ -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 diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..fd674a3 --- /dev/null +++ b/RELEASING.md @@ -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.