From 7fb13fb973496c97e3803e098fe07603910711ed Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Thu, 23 Jul 2026 15:13:51 -0400 Subject: [PATCH 1/3] feat(dockerhub): select authentication by subscription --- .github/etc/dictionary.txt | 1 + cookiecutter.json | 13 ++++- docs/configuration.md | 25 ++++++---- hooks/post_gen_project.py | 32 ++++++------ tests/test_cookiecutter.py | 50 +++++++++++++++++++ .../.github/workflows/release.yml | 16 +++++- {{cookiecutter.project_name}}/README.md | 18 +++++++ {{cookiecutter.project_name}}/Taskfile.yml | 2 +- 8 files changed, 125 insertions(+), 32 deletions(-) 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..a602c3d 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 generated README and setup reminder describe 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..363b22c 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") + readme = (result.project_path / "README.md").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 "## Docker Hub Publishing" not in readme + assert "\n publish:\n" not in taskfile + elif dockerhub_subscription in {"team", "business"}: + assert "\n publish:\n" in taskfile + assert "id-token: write" in release_workflow + assert "uses: docker/login-action@v4" in release_workflow + assert "DOCKERHUB_OIDC_CONNECTIONID: ${{ vars.DOCKERHUB_OIDC_CONNECTIONID }}" in release_workflow + assert "username: ${{ vars.DOCKERHUB_ORGANIZATION }}" in release_workflow + assert "DOCKERHUB_PAT" not in release_workflow + assert "Docker Hub OIDC" in readme + assert "DOCKERHUB_PAT" not in readme + else: + assert "\n publish:\n" in taskfile + assert "id-token: write" not in release_workflow + assert "uses: docker/login-action@v4" in release_workflow + assert "username: ${{ secrets.DOCKERHUB_USERNAME }}" in release_workflow + assert "password: ${{ secrets.DOCKERHUB_PAT }}" in release_workflow + assert "DOCKERHUB_OIDC_CONNECTIONID" not in release_workflow + assert "Docker Hub OIDC" not in readme + assert "DOCKERHUB_PAT" in readme + + @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..857f6aa 100644 --- a/{{cookiecutter.project_name}}/.github/workflows/release.yml +++ b/{{cookiecutter.project_name}}/.github/workflows/release.yml @@ -48,12 +48,17 @@ 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 +77,17 @@ 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 }}" }} +{% endif %} - name: Build and publish multiplatform Docker image run: | diff --git a/{{cookiecutter.project_name}}/README.md b/{{cookiecutter.project_name}}/README.md index 7467214..23e265d 100644 --- a/{{cookiecutter.project_name}}/README.md +++ b/{{cookiecutter.project_name}}/README.md @@ -28,6 +28,24 @@ PLATFORM=all task build ``` You can also specify a single platform of either `linux/arm64` or `linux/amd64` +{% if cookiecutter.dockerhub_subscription != 'none' %} +## Docker Hub Publishing + +{% if cookiecutter.dockerhub_subscription in ['team', 'business'] %} +Docker Hub OIDC requires a Team or Business subscription. Releases authenticate with short-lived tokens. [Create a Docker Hub OIDC +connection](https://docs.docker.com/enterprise/security/oidc-connections/create-manage/) for this repository, then add these GitHub Actions variables: + +- `DOCKERHUB_ORGANIZATION`: Docker Hub organization name +- `DOCKERHUB_OIDC_CONNECTIONID`: OIDC connection ID from Docker Hub + +The Docker Hub connection ruleset must allow this GitHub repository to write to the target image repository. Personal Docker Hub accounts are not supported. +{% else %} +Releases authenticate with Docker Hub credentials. Add these GitHub Actions secrets: + +- `DOCKERHUB_USERNAME`: Docker Hub username +- `DOCKERHUB_PAT`: Docker Hub Personal Access Token +{% endif %} +{% endif %} ## Optional setup 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. From 30915ee4b44812b673333b8eb2d9c0aa0b9c621c Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Thu, 23 Jul 2026 15:17:30 -0400 Subject: [PATCH 2/3] docs(dockerhub): keep setup out of generated readme --- docs/configuration.md | 2 +- tests/test_cookiecutter.py | 6 ------ {{cookiecutter.project_name}}/README.md | 18 ------------------ 3 files changed, 1 insertion(+), 25 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index a602c3d..10fe6c1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -77,4 +77,4 @@ The `dockerhub_subscription` choice controls publishing and authentication: generated repository write access. Add `DOCKERHUB_ORGANIZATION` and `DOCKERHUB_OIDC_CONNECTIONID` as GitHub Actions variables. 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 generated README and setup reminder describe only the selected authentication path. +The setup reminder describes only the selected authentication path. diff --git a/tests/test_cookiecutter.py b/tests/test_cookiecutter.py index 363b22c..7428ace 100755 --- a/tests/test_cookiecutter.py +++ b/tests/test_cookiecutter.py @@ -149,12 +149,10 @@ def test_dockerhub_release_authentication(cookies, dockerhub_subscription): assert result.exit_code == 0 release_workflow = (result.project_path / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8") - readme = (result.project_path / "README.md").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 "## Docker Hub Publishing" not in readme assert "\n publish:\n" not in taskfile elif dockerhub_subscription in {"team", "business"}: assert "\n publish:\n" in taskfile @@ -163,8 +161,6 @@ def test_dockerhub_release_authentication(cookies, dockerhub_subscription): assert "DOCKERHUB_OIDC_CONNECTIONID: ${{ vars.DOCKERHUB_OIDC_CONNECTIONID }}" in release_workflow assert "username: ${{ vars.DOCKERHUB_ORGANIZATION }}" in release_workflow assert "DOCKERHUB_PAT" not in release_workflow - assert "Docker Hub OIDC" in readme - assert "DOCKERHUB_PAT" not in readme else: assert "\n publish:\n" in taskfile assert "id-token: write" not in release_workflow @@ -172,8 +168,6 @@ def test_dockerhub_release_authentication(cookies, dockerhub_subscription): assert "username: ${{ secrets.DOCKERHUB_USERNAME }}" in release_workflow assert "password: ${{ secrets.DOCKERHUB_PAT }}" in release_workflow assert "DOCKERHUB_OIDC_CONNECTIONID" not in release_workflow - assert "Docker Hub OIDC" not in readme - assert "DOCKERHUB_PAT" in readme @pytest.mark.integration diff --git a/{{cookiecutter.project_name}}/README.md b/{{cookiecutter.project_name}}/README.md index 23e265d..7467214 100644 --- a/{{cookiecutter.project_name}}/README.md +++ b/{{cookiecutter.project_name}}/README.md @@ -28,24 +28,6 @@ PLATFORM=all task build ``` You can also specify a single platform of either `linux/arm64` or `linux/amd64` -{% if cookiecutter.dockerhub_subscription != 'none' %} -## Docker Hub Publishing - -{% if cookiecutter.dockerhub_subscription in ['team', 'business'] %} -Docker Hub OIDC requires a Team or Business subscription. Releases authenticate with short-lived tokens. [Create a Docker Hub OIDC -connection](https://docs.docker.com/enterprise/security/oidc-connections/create-manage/) for this repository, then add these GitHub Actions variables: - -- `DOCKERHUB_ORGANIZATION`: Docker Hub organization name -- `DOCKERHUB_OIDC_CONNECTIONID`: OIDC connection ID from Docker Hub - -The Docker Hub connection ruleset must allow this GitHub repository to write to the target image repository. Personal Docker Hub accounts are not supported. -{% else %} -Releases authenticate with Docker Hub credentials. Add these GitHub Actions secrets: - -- `DOCKERHUB_USERNAME`: Docker Hub username -- `DOCKERHUB_PAT`: Docker Hub Personal Access Token -{% endif %} -{% endif %} ## Optional setup From 57bc0f11263b1814a7b815caecbba91e6ddba2d2 Mon Sep 17 00:00:00 2001 From: Jon Zeolla Date: Thu, 23 Jul 2026 15:23:09 -0400 Subject: [PATCH 3/3] clleanup --- tests/test_cookiecutter.py | 14 ++++++++++---- .../.github/workflows/release.yml | 15 +++++---------- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/tests/test_cookiecutter.py b/tests/test_cookiecutter.py index 7428ace..99fe788 100755 --- a/tests/test_cookiecutter.py +++ b/tests/test_cookiecutter.py @@ -156,17 +156,23 @@ def test_dockerhub_release_authentication(cookies, dockerhub_subscription): 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" 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 }}" 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" 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 }}" 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 diff --git a/{{cookiecutter.project_name}}/.github/workflows/release.yml b/{{cookiecutter.project_name}}/.github/workflows/release.yml index 857f6aa..017febc 100644 --- a/{{cookiecutter.project_name}}/.github/workflows/release.yml +++ b/{{cookiecutter.project_name}}/.github/workflows/release.yml @@ -55,10 +55,8 @@ jobs: needs: release runs-on: ubuntu-24.04 permissions: - contents: read -{% if cookiecutter.dockerhub_subscription in ['team', 'business'] %} - id-token: write -{% endif %} + contents: read{% if cookiecutter.dockerhub_subscription in ['team', 'business'] %} + id-token: write{% endif %} steps: - name: Checkout the repository uses: actions/checkout@v6 @@ -77,17 +75,14 @@ jobs: uses: docker/setup-qemu-action@v3 - name: Login to Docker Hub - uses: docker/login-action@v4 -{% if cookiecutter.dockerhub_subscription in ['team', 'business'] %} + 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 %} + username: ${{ "{{ vars.DOCKERHUB_ORGANIZATION }}" }}{% else %} with: username: ${{ "{{ secrets.DOCKERHUB_USERNAME }}" }} - password: ${{ "{{ secrets.DOCKERHUB_PAT }}" }} -{% endif %} + password: ${{ "{{ secrets.DOCKERHUB_PAT }}" }}{% endif %} - name: Build and publish multiplatform Docker image run: |