From 1fd4e1220964b5f50307891df7aa3cdb771aa527 Mon Sep 17 00:00:00 2001 From: Michael Leer Date: Thu, 12 Mar 2026 17:06:25 +0000 Subject: [PATCH 1/7] chore: add project scaffolding and CI/CD workflows Add gitignore updates, Apache-2.0 license, pre-commit config, PR guidance, PR template, and GitHub Actions workflows for releases, release candidates, pull request testing, and lint/security scanning. Release versioning uses conventional commits for semver detection. --- .github/pull_request_template.md | 32 ++++ .github/workflows/github_release.yml | 117 +++++++++++++++ .github/workflows/github_release_rc.yml | 106 +++++++++++++ .github/workflows/lint_and_scan.yml | 54 +++++++ .github/workflows/pull_request.yml | 30 ++++ .gitignore | 9 ++ .pre-commit-config.yaml | 22 +++ LICENSE | 191 ++++++++++++++++++++++++ PR_GUIDANCE.md | 62 ++++++++ 9 files changed, 623 insertions(+) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/github_release.yml create mode 100644 .github/workflows/github_release_rc.yml create mode 100644 .github/workflows/lint_and_scan.yml create mode 100644 .github/workflows/pull_request.yml create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE create mode 100644 PR_GUIDANCE.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d996cba --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,32 @@ +## Summary + + + +## Changes + +- + +## Type of change + +- [ ] feat: new feature +- [ ] fix: bug fix +- [ ] docs: documentation update +- [ ] refactor: code restructuring +- [ ] test: test additions or updates +- [ ] chore: maintenance or dependency update + +## Testing + +- [ ] Tests added/updated +- [ ] All existing tests pass (`uv run pytest`) +- [ ] Linting passes (`uv run ruff check .`) + +## Breaking changes + + + +None + +## Related issues + + diff --git a/.github/workflows/github_release.yml b/.github/workflows/github_release.yml new file mode 100644 index 0000000..0b4a9e7 --- /dev/null +++ b/.github/workflows/github_release.yml @@ -0,0 +1,117 @@ +name: Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: + inputs: + bump: + description: "Version bump type (leave empty to auto-detect from commits)" + required: false + type: choice + options: + - auto + - major + - minor + - patch + default: auto + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine version + id: version + run: | + if [ "${{ github.event_name }}" = "push" ]; then + TAG="${GITHUB_REF#refs/tags/}" + VERSION="${TAG#v}" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + exit 0 + fi + + LATEST_STABLE=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + BUMP="${{ inputs.bump }}" + + if [ "$BUMP" = "auto" ] || [ -z "$BUMP" ]; then + if [ -z "$LATEST_STABLE" ]; then + COMMITS=$(git log --pretty=format:"%s%n%b" HEAD) + else + COMMITS=$(git log --pretty=format:"%s%n%b" "${LATEST_STABLE}..HEAD") + fi + + BUMP="patch" + if echo "$COMMITS" | grep -qE '^feat(\(.+\))?!:|^fix(\(.+\))?!:|^refactor(\(.+\))?!:|^[a-z]+(\(.+\))?!:|BREAKING CHANGE:'; then + BUMP="major" + elif echo "$COMMITS" | grep -qE '^feat(\(.+\))?:'; then + BUMP="minor" + fi + fi + + if [ -z "$LATEST_STABLE" ]; then + MAJOR=0; MINOR=1; PATCH=0 + else + VERSION="${LATEST_STABLE#v}" + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + fi + + case "$BUMP" in + major) + MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 + ;; + minor) + MINOR=$((MINOR + 1)); PATCH=0 + ;; + patch) + PATCH=$((PATCH + 1)) + ;; + esac + + NEXT_VERSION="${MAJOR}.${MINOR}.${PATCH}" + TAG="v${NEXT_VERSION}" + + echo "version=${NEXT_VERSION}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + + - name: Create and push tag (manual dispatch) + if: github.event_name == 'workflow_dispatch' + run: | + git tag "${{ steps.version.outputs.tag }}" + git push origin "${{ steps.version.outputs.tag }}" + + - name: Generate changelog + id: changelog + run: | + PREVIOUS_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -2 | tail -1) + if [ -z "$PREVIOUS_TAG" ]; then + CHANGELOG=$(git log --pretty=format:"- %s (%h)" HEAD) + else + CHANGELOG=$(git log --pretty=format:"- %s (%h)" "${PREVIOUS_TAG}..HEAD") + fi + { + echo "changelog<> "$GITHUB_OUTPUT" + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + name: ${{ steps.version.outputs.tag }} + body: | + ## What's Changed + + ${{ steps.changelog.outputs.changelog }} + draft: false + prerelease: false diff --git a/.github/workflows/github_release_rc.yml b/.github/workflows/github_release_rc.yml new file mode 100644 index 0000000..7ea564c --- /dev/null +++ b/.github/workflows/github_release_rc.yml @@ -0,0 +1,106 @@ +name: Release Candidate + +on: + push: + branches: [main] + +permissions: + contents: write + +jobs: + rc-release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine version bump from commits + id: bump + run: | + LATEST_STABLE=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + + if [ -z "$LATEST_STABLE" ]; then + COMMITS=$(git log --pretty=format:"%s%n%b" HEAD) + else + COMMITS=$(git log --pretty=format:"%s%n%b" "${LATEST_STABLE}..HEAD") + fi + + BUMP="patch" + + if echo "$COMMITS" | grep -qE '^feat(\(.+\))?!:|^fix(\(.+\))?!:|^refactor(\(.+\))?!:|^[a-z]+(\(.+\))?!:|BREAKING CHANGE:'; then + BUMP="major" + elif echo "$COMMITS" | grep -qE '^feat(\(.+\))?:'; then + BUMP="minor" + fi + + echo "bump=${BUMP}" >> "$GITHUB_OUTPUT" + + - name: Calculate next version + id: version + run: | + LATEST_STABLE=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -1) + BUMP="${{ steps.bump.outputs.bump }}" + + if [ -z "$LATEST_STABLE" ]; then + MAJOR=0; MINOR=1; PATCH=0 + else + VERSION="${LATEST_STABLE#v}" + IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION" + fi + + case "$BUMP" in + major) + MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 + ;; + minor) + MINOR=$((MINOR + 1)); PATCH=0 + ;; + patch) + PATCH=$((PATCH + 1)) + ;; + esac + + NEXT_VERSION="${MAJOR}.${MINOR}.${PATCH}" + RC_COUNT=$(git tag --sort=-v:refname | grep -cE "^v${NEXT_VERSION}-rc\." || true) + RC_NUM=$((RC_COUNT + 1)) + RC_TAG="v${NEXT_VERSION}-rc.${RC_NUM}" + + echo "tag=${RC_TAG}" >> "$GITHUB_OUTPUT" + echo "version=${NEXT_VERSION}-rc.${RC_NUM}" >> "$GITHUB_OUTPUT" + echo "bump=${BUMP}" >> "$GITHUB_OUTPUT" + + - name: Generate changelog + id: changelog + run: | + LATEST_TAG=$(git tag --sort=-v:refname | head -1) + if [ -z "$LATEST_TAG" ]; then + CHANGELOG=$(git log --pretty=format:"- %s (%h)" HEAD) + else + CHANGELOG=$(git log --pretty=format:"- %s (%h)" "${LATEST_TAG}..HEAD") + fi + { + echo "changelog<> "$GITHUB_OUTPUT" + + - name: Create RC tag + run: | + git tag "${{ steps.version.outputs.tag }}" + git push origin "${{ steps.version.outputs.tag }}" + + - name: Create GitHub pre-release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + name: ${{ steps.version.outputs.tag }} + body: | + ## Pre-release: ${{ steps.version.outputs.tag }} + + **Version bump**: `${{ steps.version.outputs.bump }}` (determined from conventional commits) + + ${{ steps.changelog.outputs.changelog }} + draft: false + prerelease: true diff --git a/.github/workflows/lint_and_scan.yml b/.github/workflows/lint_and_scan.yml new file mode 100644 index 0000000..0bea2c4 --- /dev/null +++ b/.github/workflows/lint_and_scan.yml @@ -0,0 +1,54 @@ +name: Lint & Security Scan + +on: + pull_request: + branches: [main] + +permissions: + contents: read + security-events: write + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + run: uv python install 3.13 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Ruff lint + run: uv run ruff check . + + - name: Ruff format check + run: uv run ruff format --check . + + security: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Gitleaks secret scan + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python + run: uv python install 3.13 + + - name: Install dependencies + run: uv sync --all-extras + + - name: Pip audit + run: uv run pip-audit diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..9bb68e3 --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,30 @@ +name: Pull Request + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.12", "3.13"] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --all-extras + + - name: Run tests + run: uv run pytest --tb=short -q diff --git a/.gitignore b/.gitignore index 741f511..497468d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,12 @@ venv/ .pytest_cache/ .coverage htmlcov/ +*.so +.mypy_cache/ +.ruff_cache/ +.env +.env.* +!.env.example +*.lock +Pulumi.*.yaml +!Pulumi.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..16c11d3 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + - id: detect-private-key + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.10 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: https://github.com/gitleaks/gitleaks + rev: v8.22.1 + hooks: + - id: gitleaks diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4eae942 --- /dev/null +++ b/LICENSE @@ -0,0 +1,191 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2024 Gremlin LTD + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/PR_GUIDANCE.md b/PR_GUIDANCE.md new file mode 100644 index 0000000..27eb412 --- /dev/null +++ b/PR_GUIDANCE.md @@ -0,0 +1,62 @@ +# Pull Request Guidance + +## Before opening a PR + +1. Ensure your branch is up to date with `main`. +2. Run the test suite locally and confirm all tests pass: + ```bash + uv run pytest + ``` +3. Run linting and formatting checks: + ```bash + uv run ruff check . + uv run ruff format --check . + ``` +4. Confirm pre-commit hooks pass: + ```bash + pre-commit run --all-files + ``` + +## Branch naming (internal contributors) + +Internal contributors should follow Gitflow conventions: + +- `feature/` for new functionality +- `bugfix/` for bug fixes +- `hotfix/` for urgent production fixes +- `release/` for release preparation + +External contributors can use any descriptive branch name. + +## Commit messages + +Use [Conventional Commits](https://www.conventionalcommits.org/): + +- `feat:` new feature +- `fix:` bug fix +- `docs:` documentation only +- `test:` adding or updating tests +- `chore:` maintenance, dependencies, CI +- `refactor:` code restructuring without behaviour change + +## PR checklist + +- [ ] Title is concise and describes the change +- [ ] Description explains *why* the change is needed +- [ ] Tests are added or updated for any behaviour change +- [ ] No secrets, credentials, or `.env` files are included +- [ ] Breaking changes are documented in the description +- [ ] Linked to a relevant issue (if applicable) + +## Review process + +- All PRs require at least one approving review before merge. +- CI checks (tests, linting, security scanning) must pass. +- Squash-merge into `main` is preferred for a clean history. + +## Releasing + +Releases are managed via GitHub Actions: + +- **Release candidates**: automatically created when commits are pushed to `main`. +- **Stable releases**: triggered by pushing a version tag (`v1.0.0`) or manually running the release workflow. From ca4aa099449781a1d15eb5b0c2551e3e31461097 Mon Sep 17 00:00:00 2001 From: Michael Leer Date: Fri, 13 Mar 2026 09:11:25 +0000 Subject: [PATCH 2/7] fix: add ruff and pip-audit as dev deps, pass GITLEAKS_LICENSE secret --- .github/workflows/lint_and_scan.yml | 1 + pyproject.toml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/lint_and_scan.yml b/.github/workflows/lint_and_scan.yml index 0bea2c4..455c0cb 100644 --- a/.github/workflows/lint_and_scan.yml +++ b/.github/workflows/lint_and_scan.yml @@ -40,6 +40,7 @@ jobs: uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} - name: Install uv uses: astral-sh/setup-uv@v5 diff --git a/pyproject.toml b/pyproject.toml index 8127f9e..4549988 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,8 @@ dependencies = [ dev = [ "pytest>=8.0.0", "pytest-cov>=5.0.0", + "ruff>=0.9.0", + "pip-audit>=2.7.0", ] [tool.hatch.build.targets.wheel] From c737075ac7d184ea54673caedf8748318f48750d Mon Sep 17 00:00:00 2001 From: Michael Leer Date: Fri, 13 Mar 2026 09:13:20 +0000 Subject: [PATCH 3/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- LICENSE | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/LICENSE b/LICENSE index 4eae942..f3edad0 100644 --- a/LICENSE +++ b/LICENSE @@ -189,3 +189,28 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. From ac17f869cce615f4dd9b93157b23b52ae0c463a1 Mon Sep 17 00:00:00 2001 From: Michael Leer Date: Fri, 13 Mar 2026 09:14:10 +0000 Subject: [PATCH 4/7] fix: prevent duplicate release on manual dispatch Manual dispatch now only creates and pushes the tag, then exits. The tag-push trigger handles changelog generation and release creation, avoiding a double run. --- .github/workflows/github_release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/github_release.yml b/.github/workflows/github_release.yml index 0b4a9e7..5c5d5ea 100644 --- a/.github/workflows/github_release.yml +++ b/.github/workflows/github_release.yml @@ -88,8 +88,10 @@ jobs: run: | git tag "${{ steps.version.outputs.tag }}" git push origin "${{ steps.version.outputs.tag }}" + echo "Tag pushed. The release will be created by the tag-push trigger." - name: Generate changelog + if: github.event_name == 'push' id: changelog run: | PREVIOUS_TAG=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | head -2 | tail -1) @@ -105,6 +107,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Create GitHub release + if: github.event_name == 'push' uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.version.outputs.tag }} From 536ebeafe913da82749dee8a3c70ec6e79ed6b17 Mon Sep 17 00:00:00 2001 From: Michael Leer Date: Fri, 13 Mar 2026 09:19:13 +0000 Subject: [PATCH 5/7] fix: remove unused Sequence import, add pull-requests permission for gitleaks --- .github/workflows/lint_and_scan.yml | 1 + pulumi_ec2_capacity_fallback/types.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint_and_scan.yml b/.github/workflows/lint_and_scan.yml index 455c0cb..3f49097 100644 --- a/.github/workflows/lint_and_scan.yml +++ b/.github/workflows/lint_and_scan.yml @@ -6,6 +6,7 @@ on: permissions: contents: read + pull-requests: read security-events: write jobs: diff --git a/pulumi_ec2_capacity_fallback/types.py b/pulumi_ec2_capacity_fallback/types.py index a564225..281156f 100644 --- a/pulumi_ec2_capacity_fallback/types.py +++ b/pulumi_ec2_capacity_fallback/types.py @@ -1,7 +1,7 @@ """Type definitions for the Resilient EC2 component.""" from dataclasses import dataclass, field -from typing import Dict, List, Optional, Sequence +from typing import Dict, List, Optional @dataclass From 569c185ad4762b4e2a8ce4656fdbe60518177a33 Mon Sep 17 00:00:00 2001 From: Michael Leer Date: Fri, 13 Mar 2026 09:21:17 +0000 Subject: [PATCH 6/7] fix: add fetch-depth 0 to security job for gitleaks PR scanning --- .github/workflows/lint_and_scan.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/lint_and_scan.yml b/.github/workflows/lint_and_scan.yml index 3f49097..7ce5dfb 100644 --- a/.github/workflows/lint_and_scan.yml +++ b/.github/workflows/lint_and_scan.yml @@ -36,6 +36,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Gitleaks secret scan uses: gitleaks/gitleaks-action@v2 From 44bd12e681e87a676ef70720cfd08763ed946fd9 Mon Sep 17 00:00:00 2001 From: Michael Leer Date: Fri, 13 Mar 2026 09:22:05 +0000 Subject: [PATCH 7/7] style: apply ruff formatting to existing files --- examples/basic/__main__.py | 7 +- pulumi_ec2_capacity_fallback/provider.py | 38 ++---- tests/test_component.py | 143 +++++++++++++++++++---- 3 files changed, 138 insertions(+), 50 deletions(-) diff --git a/examples/basic/__main__.py b/examples/basic/__main__.py index f9b37b4..77025de 100644 --- a/examples/basic/__main__.py +++ b/examples/basic/__main__.py @@ -13,7 +13,12 @@ ami = aws.ec2.get_ami( most_recent=True, owners=["099720109477"], - filters=[{"name": "name", "values": ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]}], + filters=[ + { + "name": "name", + "values": ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"], + } + ], ) # Create a GPU instance with fallback types diff --git a/pulumi_ec2_capacity_fallback/provider.py b/pulumi_ec2_capacity_fallback/provider.py index cefc597..9df9954 100644 --- a/pulumi_ec2_capacity_fallback/provider.py +++ b/pulumi_ec2_capacity_fallback/provider.py @@ -27,7 +27,6 @@ class ResilientInstanceProvider(ResourceProvider): - def _get_client(self, region: str): return boto3.client("ec2", region_name=region) @@ -98,15 +97,15 @@ def _get_offered_combos( return combos - def _build_run_params(self, inputs: Dict[str, Any], instance_type: str, subnet_id: str) -> Dict[str, Any]: + def _build_run_params( + self, inputs: Dict[str, Any], instance_type: str, subnet_id: str + ) -> Dict[str, Any]: """Build the boto3 run_instances parameters from provider inputs.""" tags = inputs.get("tags", {}) tag_specs = [ { "ResourceType": "instance", - "Tags": [ - {"Key": k, "Value": str(v)} for k, v in tags.items() - ], + "Tags": [{"Key": k, "Value": str(v)} for k, v in tags.items()], } ] @@ -130,9 +129,7 @@ def _build_run_params(self, inputs: Dict[str, Any], instance_type: str, subnet_i "MinCount": 1, "MaxCount": 1, "SecurityGroupIds": inputs["security_group_ids"], - "BlockDeviceMappings": [ - {"DeviceName": "/dev/sda1", "Ebs": ebs_config} - ], + "BlockDeviceMappings": [{"DeviceName": "/dev/sda1", "Ebs": ebs_config}], "TagSpecifications": tag_specs, } @@ -160,16 +157,12 @@ def create(self, inputs: Dict[str, Any]) -> CreateResult: ) if not filtered_subnets: suffix_desc = ", ".join(az_suffixes) if az_suffixes else "any" - raise Exception( - f"No subnets available in AZs ending with [{suffix_desc}]" - ) + raise Exception(f"No subnets available in AZs ending with [{suffix_desc}]") if prefer_least_used: least_used = self._get_least_used_subnet(filtered_subnets, ec2) filtered_subnets = [least_used] - subnet_az_map = { - k: v for k, v in subnet_az_map.items() if k == least_used - } + subnet_az_map = {k: v for k, v in subnet_az_map.items() if k == least_used} # Pre-filter to combinations where the type is actually offered combos = self._get_offered_combos(instance_types, subnet_az_map, ec2) @@ -187,9 +180,7 @@ def create(self, inputs: Dict[str, Any]) -> CreateResult: for instance_type, subnet_id, az in combos: try: - pulumi.log.info( - f"Attempting to launch {instance_type} in {az}..." - ) + pulumi.log.info(f"Attempting to launch {instance_type} in {az}...") params = self._build_run_params(inputs, instance_type, subnet_id) response = ec2.run_instances(**params) @@ -215,9 +206,7 @@ def create(self, inputs: Dict[str, Any]) -> CreateResult: f"(primary {instance_types[0]} had no capacity)" ) else: - pulumi.log.info( - f"Successfully launched {instance_type} in {az}" - ) + pulumi.log.info(f"Successfully launched {instance_type} in {az}") return CreateResult( id_=instance_id, @@ -315,10 +304,7 @@ def update( if new_tags: ec2.create_tags( Resources=[id], - Tags=[ - {"Key": k, "Value": str(v)} - for k, v in new_tags.items() - ], + Tags=[{"Key": k, "Value": str(v)} for k, v in new_tags.items()], ) # Update security groups if changed @@ -374,9 +360,7 @@ def read(self, id: str, props: Dict[str, Any]) -> ReadResult: "instance_id": id, "private_ip": instance.get("PrivateIpAddress", ""), "public_ip": instance.get("PublicIpAddress", ""), - "availability_zone": instance["Placement"][ - "AvailabilityZone" - ], + "availability_zone": instance["Placement"]["AvailabilityZone"], "launched_instance_type": instance["InstanceType"], "launched_subnet_id": instance["SubnetId"], }, diff --git a/tests/test_component.py b/tests/test_component.py index 5f3a468..529ca04 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -112,9 +112,21 @@ class TestSubnetFiltering: def test_filters_to_az_a_and_b(self, provider): ec2 = _mock_ec2_client( subnets=[ - {"SubnetId": "subnet-a", "AvailabilityZone": "us-east-1a", "AvailableIpAddressCount": 100}, - {"SubnetId": "subnet-b", "AvailabilityZone": "us-east-1b", "AvailableIpAddressCount": 100}, - {"SubnetId": "subnet-c", "AvailabilityZone": "us-east-1c", "AvailableIpAddressCount": 100}, + { + "SubnetId": "subnet-a", + "AvailabilityZone": "us-east-1a", + "AvailableIpAddressCount": 100, + }, + { + "SubnetId": "subnet-b", + "AvailabilityZone": "us-east-1b", + "AvailableIpAddressCount": 100, + }, + { + "SubnetId": "subnet-c", + "AvailabilityZone": "us-east-1c", + "AvailableIpAddressCount": 100, + }, ] ) filtered, az_map = provider._filter_subnets_by_az( @@ -126,8 +138,16 @@ def test_filters_to_az_a_and_b(self, provider): def test_no_filter_when_suffixes_none(self, provider): ec2 = _mock_ec2_client( subnets=[ - {"SubnetId": "subnet-a", "AvailabilityZone": "us-east-1a", "AvailableIpAddressCount": 100}, - {"SubnetId": "subnet-c", "AvailabilityZone": "us-east-1c", "AvailableIpAddressCount": 100}, + { + "SubnetId": "subnet-a", + "AvailabilityZone": "us-east-1a", + "AvailableIpAddressCount": 100, + }, + { + "SubnetId": "subnet-c", + "AvailabilityZone": "us-east-1c", + "AvailableIpAddressCount": 100, + }, ] ) filtered, az_map = provider._filter_subnets_by_az( @@ -138,8 +158,16 @@ def test_no_filter_when_suffixes_none(self, provider): def test_least_used_subnet(self, provider): ec2 = _mock_ec2_client( subnets=[ - {"SubnetId": "subnet-a", "AvailabilityZone": "us-east-1a", "AvailableIpAddressCount": 10}, - {"SubnetId": "subnet-b", "AvailabilityZone": "us-east-1b", "AvailableIpAddressCount": 200}, + { + "SubnetId": "subnet-a", + "AvailabilityZone": "us-east-1a", + "AvailableIpAddressCount": 10, + }, + { + "SubnetId": "subnet-b", + "AvailabilityZone": "us-east-1b", + "AvailableIpAddressCount": 200, + }, ] ) result = provider._get_least_used_subnet(["subnet-a", "subnet-b"], ec2) @@ -150,7 +178,11 @@ class TestOfferingsCheck: def test_filters_unavailable_combos(self, provider): ec2 = _mock_ec2_client( subnets=[ - {"SubnetId": "subnet-a", "AvailabilityZone": "us-east-1a", "AvailableIpAddressCount": 100}, + { + "SubnetId": "subnet-a", + "AvailabilityZone": "us-east-1a", + "AvailableIpAddressCount": 100, + }, ], offerings=[ {"InstanceType": "g5.xlarge", "Location": "us-east-1a"}, @@ -168,7 +200,11 @@ def test_filters_unavailable_combos(self, provider): def test_preserves_priority_order(self, provider): ec2 = _mock_ec2_client( subnets=[ - {"SubnetId": "subnet-a", "AvailabilityZone": "us-east-1a", "AvailableIpAddressCount": 100}, + { + "SubnetId": "subnet-a", + "AvailabilityZone": "us-east-1a", + "AvailableIpAddressCount": 100, + }, ], offerings=[ {"InstanceType": "g6.xlarge", "Location": "us-east-1a"}, @@ -189,8 +225,16 @@ class TestCreateWithFallback: def test_primary_type_succeeds(self, mock_get_client, provider, base_inputs): ec2 = _mock_ec2_client( subnets=[ - {"SubnetId": "subnet-aaa", "AvailabilityZone": "us-east-1a", "AvailableIpAddressCount": 100}, - {"SubnetId": "subnet-bbb", "AvailabilityZone": "us-east-1b", "AvailableIpAddressCount": 100}, + { + "SubnetId": "subnet-aaa", + "AvailabilityZone": "us-east-1a", + "AvailableIpAddressCount": 100, + }, + { + "SubnetId": "subnet-bbb", + "AvailabilityZone": "us-east-1b", + "AvailableIpAddressCount": 100, + }, ], offerings=[ {"InstanceType": "g6.xlarge", "Location": "us-east-1a"}, @@ -213,8 +257,16 @@ def test_primary_type_succeeds(self, mock_get_client, provider, base_inputs): def test_fallback_on_capacity_error(self, mock_get_client, provider, base_inputs): ec2 = _mock_ec2_client( subnets=[ - {"SubnetId": "subnet-aaa", "AvailabilityZone": "us-east-1a", "AvailableIpAddressCount": 100}, - {"SubnetId": "subnet-bbb", "AvailabilityZone": "us-east-1b", "AvailableIpAddressCount": 100}, + { + "SubnetId": "subnet-aaa", + "AvailabilityZone": "us-east-1a", + "AvailableIpAddressCount": 100, + }, + { + "SubnetId": "subnet-bbb", + "AvailabilityZone": "us-east-1b", + "AvailableIpAddressCount": 100, + }, ], offerings=[ {"InstanceType": "g6.xlarge", "Location": "us-east-1a"}, @@ -238,7 +290,11 @@ def test_fallback_on_capacity_error(self, mock_get_client, provider, base_inputs def test_all_combos_exhausted_raises(self, mock_get_client, provider, base_inputs): ec2 = _mock_ec2_client( subnets=[ - {"SubnetId": "subnet-aaa", "AvailabilityZone": "us-east-1a", "AvailableIpAddressCount": 100}, + { + "SubnetId": "subnet-aaa", + "AvailabilityZone": "us-east-1a", + "AvailableIpAddressCount": 100, + }, ], offerings=[ {"InstanceType": "g6.xlarge", "Location": "us-east-1a"}, @@ -255,10 +311,16 @@ def test_all_combos_exhausted_raises(self, mock_get_client, provider, base_input provider.create(base_inputs) @patch.object(ResilientInstanceProvider, "_get_client") - def test_no_subnets_in_target_azs_raises(self, mock_get_client, provider, base_inputs): + def test_no_subnets_in_target_azs_raises( + self, mock_get_client, provider, base_inputs + ): ec2 = _mock_ec2_client( subnets=[ - {"SubnetId": "subnet-aaa", "AvailabilityZone": "us-east-1c", "AvailableIpAddressCount": 100}, + { + "SubnetId": "subnet-aaa", + "AvailabilityZone": "us-east-1c", + "AvailableIpAddressCount": 100, + }, ], ) mock_get_client.return_value = ec2 @@ -270,7 +332,11 @@ def test_no_subnets_in_target_azs_raises(self, mock_get_client, provider, base_i def test_no_types_offered_raises(self, mock_get_client, provider, base_inputs): ec2 = _mock_ec2_client( subnets=[ - {"SubnetId": "subnet-aaa", "AvailabilityZone": "us-east-1a", "AvailableIpAddressCount": 100}, + { + "SubnetId": "subnet-aaa", + "AvailabilityZone": "us-east-1a", + "AvailableIpAddressCount": 100, + }, ], offerings=[], # Nothing offered ) @@ -283,7 +349,11 @@ def test_no_types_offered_raises(self, mock_get_client, provider, base_inputs): def test_non_capacity_error_reraised(self, mock_get_client, provider, base_inputs): ec2 = _mock_ec2_client( subnets=[ - {"SubnetId": "subnet-aaa", "AvailabilityZone": "us-east-1a", "AvailableIpAddressCount": 100}, + { + "SubnetId": "subnet-aaa", + "AvailabilityZone": "us-east-1a", + "AvailableIpAddressCount": 100, + }, ], offerings=[ {"InstanceType": "g6.xlarge", "Location": "us-east-1a"}, @@ -299,26 +369,55 @@ def test_non_capacity_error_reraised(self, mock_get_client, provider, base_input class TestDiff: def test_no_changes(self, provider): - props = {"ami_id": "ami-1", "key_name": "k", "user_data": "u", "root_block_device": {"volume_size": 50}, "tags": {"a": "1"}, "security_group_ids": ["sg-1"]} + props = { + "ami_id": "ami-1", + "key_name": "k", + "user_data": "u", + "root_block_device": {"volume_size": 50}, + "tags": {"a": "1"}, + "security_group_ids": ["sg-1"], + } result = provider.diff("i-123", props, props) assert not result.changes assert not result.replaces def test_ami_change_triggers_replace(self, provider): - old = {"ami_id": "ami-1", "key_name": "k", "user_data": "u", "root_block_device": {"volume_size": 50}, "tags": {}, "security_group_ids": []} + old = { + "ami_id": "ami-1", + "key_name": "k", + "user_data": "u", + "root_block_device": {"volume_size": 50}, + "tags": {}, + "security_group_ids": [], + } new = {**old, "ami_id": "ami-2"} result = provider.diff("i-123", old, new) assert "ami_id" in result.replaces def test_tag_change_is_update_not_replace(self, provider): - old = {"ami_id": "ami-1", "key_name": "k", "user_data": "u", "root_block_device": {"volume_size": 50}, "tags": {"a": "1"}, "security_group_ids": []} + old = { + "ami_id": "ami-1", + "key_name": "k", + "user_data": "u", + "root_block_device": {"volume_size": 50}, + "tags": {"a": "1"}, + "security_group_ids": [], + } new = {**old, "tags": {"a": "2"}} result = provider.diff("i-123", old, new) assert result.changes assert not result.replaces def test_instance_types_change_ignored(self, provider): - old = {"ami_id": "ami-1", "key_name": "k", "user_data": "u", "root_block_device": {"volume_size": 50}, "tags": {}, "security_group_ids": [], "instance_types": ["g6.xlarge"]} + old = { + "ami_id": "ami-1", + "key_name": "k", + "user_data": "u", + "root_block_device": {"volume_size": 50}, + "tags": {}, + "security_group_ids": [], + "instance_types": ["g6.xlarge"], + } new = {**old, "instance_types": ["g5.xlarge", "g6.xlarge"]} result = provider.diff("i-123", old, new) assert not result.changes