diff --git a/.github/etc/dictionary.txt b/.github/etc/dictionary.txt index 12daada..6f52dc6 100644 --- a/.github/etc/dictionary.txt +++ b/.github/etc/dictionary.txt @@ -2,6 +2,7 @@ allstar anchore buildx conftest +connectionid cookiecutter dependabot digestabot diff --git a/cookiecutter.json b/cookiecutter.json index 0812ad3..d003e33 100644 --- a/cookiecutter.json +++ b/cookiecutter.json @@ -7,7 +7,16 @@ "github_org": "zenable-io", "project_owner_github_username": "jonzeolla", "python_version": ["3.13", "3.12", "3.11"], - "dockerhub": ["no", "yes"], + "dockerhub_subscription": ["none", "personal", "team", "business"], "public": ["no", "yes"], - "license": ["NONE", "MIT", "BSD-3-Clause"] + "license": ["NONE", "MIT", "BSD-3-Clause"], + "__prompts__": { + "dockerhub_subscription": { + "__prompt__": "Select your Docker Hub subscription; choose none to disable Docker Hub publishing", + "none": "None — disable Docker Hub publishing", + "personal": "Personal — authenticate with username and PAT", + "team": "Team — authenticate with OIDC", + "business": "Business — authenticate with OIDC" + } + } } diff --git a/docs/configuration.md b/docs/configuration.md index dc8a4a7..10fe6c1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,12 +27,12 @@ When generating a new project, you'll be prompted for the following configuratio ### Technical Options -| Variable | Description | Default | Options | -| ---------------- | ---------------------------- | ------- | ----------------------------- | -| `python_version` | Minimum Python version | "3.13" | "3.11", "3.12", "3.13" | -| `dockerhub` | Enable Docker Hub publishing | "no" | "yes", "no" | -| `public` | Make repository public | "yes" | "yes", "no" | -| `license` | Project license | "NONE" | "NONE", "MIT", "BSD-3-Clause" | +| Variable | Description | Default | Options | +| ------------------------ | --------------------------------------------------- | ------- | ---------------------------------------- | +| `python_version` | Minimum Python version | "3.13" | "3.11", "3.12", "3.13" | +| `dockerhub_subscription` | Docker Hub plan; `none` disables image publishing | "none" | "none", "personal", "team", "business" | +| `public` | Make repository public | "yes" | "yes", "no" | +| `license` | Project license | "NONE" | "NONE", "MIT", "BSD-3-Clause" | ## Post-Generation Configuration @@ -67,11 +67,14 @@ vars: For detailed information about pre-commit hooks configuration and available hooks, see the [Hooks Guide](hooks.md#pre-commit-hooks). -#### Docker Hub Secrets +#### Docker Hub Authentication -If you enabled Docker Hub publishing: +The `dockerhub_subscription` choice controls publishing and authentication: -- `DOCKERHUB_USERNAME` -- `DOCKERHUB_PAT` +- **None:** Docker Hub publishing is not generated. +- **Personal:** Add `DOCKERHUB_USERNAME` and `DOCKERHUB_PAT` as GitHub Actions secrets. +- **Team or Business:** [Create a Docker Hub OIDC connection](https://docs.docker.com/enterprise/security/oidc-connections/create-manage/) whose ruleset grants the + generated repository write access. Add `DOCKERHUB_ORGANIZATION` and `DOCKERHUB_OIDC_CONNECTIONID` as GitHub Actions variables. -A reminder to set this is also printed after project generation if you answered "yes" to the docker hub question. +Personal subscriptions use the standard Docker Hub login. Team and Business subscriptions exchange the GitHub identity token for a short-lived Docker Hub token. +The setup reminder describes only the selected authentication path. diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 476b4c9..8ede582 100755 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -125,22 +125,21 @@ def write_context(*, context: dict) -> None: yaml.dump(context, file) -def notify_dockerhub_secrets() -> None: - """Notify user about required Docker Hub secrets for releases.""" - # We no longer need this once https://github.com/docker/roadmap/issues/314 is available +def notify_dockerhub_configuration(*, use_oidc: bool) -> None: + """Notify user about the selected Docker Hub authentication configuration.""" print("\n" + "=" * 70) print("IMPORTANT: Docker Hub Publishing Enabled") print("=" * 70) - print("\nYou have enabled Docker Hub publishing for releases") - print("Please ensure the following GitHub secrets are configured:") - print("\n • DOCKERHUB_USERNAME - Your Docker Hub username") - print(" • DOCKERHUB_PAT - Your Docker Hub Personal Access Token") - print("\nWithout these secrets, your releases will fail during the") - print("Docker image publishing step") - print("\nTo add these secrets:") - print("1. Go to your GitHub repository settings") - print("2. Navigate to Settings → Secrets and variables → Actions") - print("3. Add the required secrets") + if use_oidc: + print("\nConfigure a Docker Hub OIDC connection for this GitHub repository.") + print("Then add these GitHub Actions variables:") + print("\n • DOCKERHUB_ORGANIZATION - Your Docker Hub organization name") + print(" • DOCKERHUB_OIDC_CONNECTIONID - The Docker Hub OIDC connection ID") + print("\nReleases use short-lived Docker Hub tokens.") + else: + print("\nAdd these GitHub Actions secrets:") + print("\n • DOCKERHUB_USERNAME - Your Docker Hub username") + print(" • DOCKERHUB_PAT - Your Docker Hub Personal Access Token") print("=" * 70 + "\n") @@ -428,9 +427,10 @@ def run_post_gen_hook(): # (i.e. check=False) subprocess.run(["task", "init"], check=False, capture_output=True) - # Notify about Docker Hub secrets if Docker Hub publishing is enabled - if cookiecutter_context.get("dockerhub") == "yes": - notify_dockerhub_secrets() + # Notify about Docker Hub authentication if publishing is enabled + dockerhub_subscription = cookiecutter_context.get("dockerhub_subscription") + if dockerhub_subscription != "none": + notify_dockerhub_configuration(use_oidc=dockerhub_subscription in {"team", "business"}) except subprocess.CalledProcessError as error: stdout = error.stdout.decode("utf-8") if error.stdout else "No stdout" stderr = error.stderr.decode("utf-8") if error.stderr else "No stderr" diff --git a/tests/test_cookiecutter.py b/tests/test_cookiecutter.py index a7f0276..99fe788 100755 --- a/tests/test_cookiecutter.py +++ b/tests/test_cookiecutter.py @@ -33,6 +33,8 @@ def render_config(*, config: dict) -> dict: """Render the provided config""" rendered_config: dict[str, str | list] = {} for key, value in config.items(): + if key.startswith("__"): + continue if isinstance(value, str): # Sanitize by removing the "cookiecutter." prefix sanitized_template = value.replace("cookiecutter.", "") @@ -126,6 +128,54 @@ def test_supported_options(cookies, context_override): check_files(files) +@pytest.mark.unit +def test_dockerhub_subscription_prompt(): + """Offer one Docker Hub subscription question that can disable publishing.""" + config = get_config() + + assert config["dockerhub_subscription"] == ["none", "personal", "team", "business"] + prompt = config["__prompts__"]["dockerhub_subscription"] + assert prompt["__prompt__"] == "Select your Docker Hub subscription; choose none to disable Docker Hub publishing" + assert prompt["none"] == "None — disable Docker Hub publishing" + + +@pytest.mark.unit +@pytest.mark.parametrize("dockerhub_subscription", ["none", "personal", "team", "business"]) +def test_dockerhub_release_authentication(cookies, dockerhub_subscription): + """Render the selected Docker Hub authentication path.""" + os.environ["RUN_POST_HOOK"] = "false" + + result = cookies.bake(extra_context={"dockerhub_subscription": dockerhub_subscription}) + + assert result.exit_code == 0 + release_workflow = (result.project_path / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") + taskfile = (result.project_path / "Taskfile.yml").read_text(encoding="utf-8") + + if dockerhub_subscription == "none": + assert "publish-docker:" not in release_workflow + assert "\n publish:\n" not in taskfile + elif dockerhub_subscription in {"team", "business"}: + assert "\n publish:\n" in taskfile + assert "uses: docker/setup-qemu-action@v3\n\n - name: Login to Docker Hub" in release_workflow + assert "id-token: write" in release_workflow + assert "uses: docker/login-action@v4\n env:" in release_workflow + assert "DOCKERHUB_OIDC_CONNECTIONID: ${{ vars.DOCKERHUB_OIDC_CONNECTIONID }}" in release_workflow + assert ( + "username: ${{ vars.DOCKERHUB_ORGANIZATION }}\n\n - name: Build and publish multiplatform Docker image" + ) in release_workflow + assert "DOCKERHUB_PAT" not in release_workflow + else: + assert "\n publish:\n" in taskfile + assert "uses: docker/setup-qemu-action@v3\n\n - name: Login to Docker Hub" in release_workflow + assert "id-token: write" not in release_workflow + assert "uses: docker/login-action@v4\n with:" in release_workflow + assert "username: ${{ secrets.DOCKERHUB_USERNAME }}" in release_workflow + assert ( + "password: ${{ secrets.DOCKERHUB_PAT }}\n\n - name: Build and publish multiplatform Docker image" + ) in release_workflow + assert "DOCKERHUB_OIDC_CONNECTIONID" not in release_workflow + + @pytest.mark.integration def test_update(cookies): """ diff --git a/{{cookiecutter.project_name}}/.github/workflows/release.yml b/{{cookiecutter.project_name}}/.github/workflows/release.yml index a27a184..017febc 100644 --- a/{{cookiecutter.project_name}}/.github/workflows/release.yml +++ b/{{cookiecutter.project_name}}/.github/workflows/release.yml @@ -48,12 +48,15 @@ jobs: TAG=$(git describe --tags --abbrev=0) echo "tag=${TAG}" | tee -a "${GITHUB_OUTPUT}" echo "Created release tag: ${TAG}" -{%- if cookiecutter.dockerhub == 'yes' %} +{%- if cookiecutter.dockerhub_subscription != 'none' %} publish-docker: name: Publish Docker Image needs: release runs-on: ubuntu-24.04 + permissions: + contents: read{% if cookiecutter.dockerhub_subscription in ['team', 'business'] %} + id-token: write{% endif %} steps: - name: Checkout the repository uses: actions/checkout@v6 @@ -72,10 +75,14 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Login to Docker Hub - uses: docker/login-action@v3 + uses: docker/login-action@v4{% if cookiecutter.dockerhub_subscription in ['team', 'business'] %} + env: + DOCKERHUB_OIDC_CONNECTIONID: ${{ "{{ vars.DOCKERHUB_OIDC_CONNECTIONID }}" }} + with: + username: ${{ "{{ vars.DOCKERHUB_ORGANIZATION }}" }}{% else %} with: username: ${{ "{{ secrets.DOCKERHUB_USERNAME }}" }} - password: ${{ "{{ secrets.DOCKERHUB_PAT }}" }} + password: ${{ "{{ secrets.DOCKERHUB_PAT }}" }}{% endif %} - name: Build and publish multiplatform Docker image run: | diff --git a/{{cookiecutter.project_name}}/Taskfile.yml b/{{cookiecutter.project_name}}/Taskfile.yml index 47eaa99..e5dbfd5 100644 --- a/{{cookiecutter.project_name}}/Taskfile.yml +++ b/{{cookiecutter.project_name}}/Taskfile.yml @@ -257,7 +257,7 @@ tasks: as: tool cmd: '{{ '{{if eq .FORCE_LINK "true"}}' }}brew link --force {{ '{{.tool}}' }}{{ '{{else}}' }}true{{ '{{end}}' }}' -{%- if cookiecutter.dockerhub == 'yes' %} +{%- if cookiecutter.dockerhub_subscription != 'none' %} publish: desc: Publish the project artifacts; docker images, compiled binaries, etc.