diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e803096 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,87 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Lint • Type Check • Test (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - python-version: "3.11" + experimental: false + - python-version: "3.12" + experimental: false + - python-version: "3.13" + experimental: false + continue-on-error: ${{ matrix.experimental }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + id: py + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-create: true + virtualenvs-in-project: true + installer-parallel: true + + - name: Cache virtualenv + uses: actions/cache@v4 + id: cache + with: + path: .venv + key: v1-poetry-${{ runner.os }}-py${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }} + + - name: Install deps (with dev group) + if: steps.cache.outputs.cache-hit != 'true' + run: | + poetry env use "${{ steps.py.outputs.python-path }}" + poetry install --no-interaction --no-ansi --with dev + + - name: Sync deps (cache hit) + if: steps.cache.outputs.cache-hit == 'true' + run: | + poetry env use "${{ steps.py.outputs.python-path }}" + poetry install --no-interaction --no-ansi --with dev + + - name: Show tool versions + run: | + poetry run python -V + poetry run ruff --version + poetry run mypy --version + poetry run pytest --version + + - name: Ruff (format & lint) + run: | + poetry run ruff format --check . + poetry run ruff check . + + - name: Mypy (install types if missing, then type check) + run: | + # Auto-install missing type stubs in CI (non-interactive) + poetry run mypy --install-types --non-interactive || true + poetry run mypy src/ tests/ --ignore-missing-imports --disable-error-code=call-overload + + - name: Pytest (coverage gate) + run: | + poetry run pytest -q --cov=change_me --cov-report=term-missing --cov-fail-under=85 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..dad113a --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,119 @@ +name: Docker Build & Publish + +on: + push: + branches: ["**"] + tags: ["v*"] + paths-ignore: + - "**/*.md" + - "**/*.rst" + - "docs/**" + - ".github/ISSUE_TEMPLATE/**" + workflow_dispatch: {} + +permissions: + contents: read + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} # may include uppercase (we'll normalize) + # Flip this to "true" if you want multi-arch on all branches + MULTIARCH_ON_BRANCHES: "false" + +concurrency: + group: docker-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-push: + runs-on: ubuntu-latest + env: + IS_MAIN: ${{ github.ref == 'refs/heads/main' }} + IS_TAG: ${{ startsWith(github.ref, 'refs/tags/v') }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + # Only set up QEMU when we actually need arm64 (main or tag builds) + - name: Set up QEMU (for arm64) + if: env.IS_MAIN == 'true' || env.IS_TAG == 'true' || env.MULTIARCH_ON_BRANCHES == 'true' + uses: docker/setup-qemu-action@v3 + + - name: Normalize image name to lowercase + id: repo + run: | + echo "image_name_lc=$(echo '${{ env.IMAGE_NAME }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + + - name: Derive tags + id: meta + run: | + REF="${GITHUB_REF#refs/}" + SHA_TAG=${GITHUB_SHA::12} + echo "sha_tag=${SHA_TAG}" >> "$GITHUB_OUTPUT" + if [[ "$REF" == tags/v* ]]; then + V="${REF#tags/v}" + echo "version_tag=${V}" >> "$GITHUB_OUTPUT" + fi + if [[ "$REF" == heads/main ]]; then + echo "latest_tag=latest" >> "$GITHUB_OUTPUT" + fi + + # Fast path for branches: single-arch build, cache, don't push + - name: Build (branch) — fast single-arch, no push + if: env.IS_MAIN != 'true' && env.IS_TAG != 'true' && env.MULTIARCH_ON_BRANCHES != 'true' + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: false + load: true # so we can run the smoke test + platforms: linux/amd64 + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + tags: | + ${{ env.REGISTRY }}/${{ steps.repo.outputs.image_name_lc }}:${{ steps.meta.outputs.sha_tag }} + + # Publish path for main/tags: multi-arch build + push (with cache) + - name: Build & push (multi-arch) + if: env.IS_MAIN == 'true' || env.IS_TAG == 'true' || env.MULTIARCH_ON_BRANCHES == 'true' + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + tags: | + ${{ env.REGISTRY }}/${{ steps.repo.outputs.image_name_lc }}:${{ steps.meta.outputs.sha_tag }} + ${{ steps.meta.outputs.version_tag && format('{0}/{1}:{2}', env.REGISTRY, steps.repo.outputs.image_name_lc, steps.meta.outputs.version_tag) || '' }} + ${{ steps.meta.outputs.latest_tag && format('{0}/{1}:{2}', env.REGISTRY, steps.repo.outputs.image_name_lc, steps.meta.outputs.latest_tag) || '' }} + + # Smoke test: use the local image for branch builds, or pull the pushed one for main/tags + - name: Smoke test (branch image) + if: env.IS_MAIN != 'true' && env.IS_TAG != 'true' && env.MULTIARCH_ON_BRANCHES != 'true' + run: | + IMAGE="${{ env.REGISTRY }}/${{ steps.repo.outputs.image_name_lc }}:${{ steps.meta.outputs.sha_tag }}" + echo "Smoke testing local image ${IMAGE}" + docker run --rm "$IMAGE" python -c "import change_me, sys; print('✅ import ok:', getattr(change_me,'__version__','?'), 'on', sys.version)" + + - name: Smoke test (pushed image) + if: env.IS_MAIN == 'true' || env.IS_TAG == 'true' || env.MULTIARCH_ON_BRANCHES == 'true' + run: | + IMAGE="${{ env.REGISTRY }}/${{ steps.repo.outputs.image_name_lc }}:${{ steps.meta.outputs.sha_tag }}" + echo "Smoke testing pushed image ${IMAGE}" + docker pull "$IMAGE" + docker run --rm "$IMAGE" python -c "import change_me, sys; print('✅ import ok:', getattr(change_me,'__version__','?'), 'on', sys.version)" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7d0bf31 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing to This Project + +First of all, thank you for considering contributing! 🎉 +We welcome issues, bug reports, feature requests, and pull requests. + +--- + +## How to Contribute + +### Reporting Issues +* Use the [GitHub Issues](../../issues) tab. +* Search first to see if your issue is already reported. +* Include steps to reproduce, expected behavior, and screenshots/logs if relevant. + +### Making Changes +1. Fork the repository. +2. Create a new branch for your changes: + ```bash + git checkout -b feature/my-new-feature + ``` +3. Make your changes and commit with a clear message: + ```bash + git commit -m "Add feature: my-new-feature" + ``` +4. Push to your fork and open a Pull Request (PR). + +### Code Style +* Follow the existing code style in the project. +* Use descriptive names for variables, functions, and classes. +* Add docstrings or comments where needed for clarity. + +### Testing +* Ensure that your code runs without errors. +* Add or update tests if applicable. +* Run all tests before submitting: + ```bash + make test + ``` + +### Pull Requests +* Keep PRs focused — one feature or bug fix per PR. +* Describe your changes clearly in the PR description. +* Reference related issues if applicable. + +--- + +## Community Guidelines +* Be respectful and constructive in discussions. +* Assume good intentions and help others learn. + +--- + +## License +By contributing, you agree that your contributions will be licensed under the [MIT License](LICENSE). diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aa87932 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# syntax=docker/dockerfile:1.7-labs +FROM python:3.12-slim + +# Speed + deterministic behavior +ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +# Select package + version at build-time +ARG PKG=change_me +ARG VER=latest +ENV PKG=${PKG} VER=${VER} + +# Install package from PyPI with a cache mount (huge speedup on rebuilds) +# NOTE: --root-user-action needs a value; use =ignore to silence root warnings. +RUN --mount=type=cache,target=/root/.cache/pip \ + python -m pip install --upgrade pip && \ + if [ "$VER" = "latest" ]; then \ + pip install --root-user-action=ignore "$PKG"; \ + else \ + pip install --root-user-action=ignore "$PKG==$VER"; \ + fi + +# Simple smoke test script +WORKDIR /app +RUN printf '%s\n' \ + "import importlib, os, sys" \ + "m = importlib.import_module(os.environ.get('PKG', '${PKG}'))" \ + "print('✅ import ok:', getattr(m, '__version__', 'unknown'), 'on', sys.version)" \ + > smoke.py + +CMD ["python", "smoke.py"] diff --git a/Makefile b/Makefile index f6317ea..9beb8a4 100644 --- a/Makefile +++ b/Makefile @@ -29,3 +29,8 @@ clean: update: poetry cache clear pypi --all poetry update + + +docker: + docker build --no-cache -f Dockerfile -t change_me-smoke . + docker run --rm change_me-smoke