Project Setup

Updating Generated CLI Projects with copier update

Keep CLIs generated from a template current: tag the template, record answers, run copier update, resolve conflicts and automate update pull requests.

Updated

A project template pays for itself on day one: every new internal CLI starts with the same layout, the same Ruff and pytest configuration, the same CI workflow. The trouble starts on day ninety, when the template gains a better release workflow and a security fix to its pre-commit configuration — and the twelve CLIs generated from it do not. With Cookiecutter, generation is one-way; each project drifts from the moment it is created. Copier was designed for the opposite: it records which template version and which answers produced a project, and copier update replays the template's changes since then onto the project, merging them with the project's own edits. This guide sets up a template so updates work, runs an update including the conflict case, and automates update pull requests across many projects. It belongs to the project scaffolding topic; Copier vs Cookiecutter for CLI templates explains the wider comparison.

Prerequisites

  • Copier 9 (uv tool install copier), and git.
  • A CLI template in its own git repository; the examples use a minimal one.

How an update works

Copier treats an update as a three-way merge. It regenerates the project from the old template version with the recorded answers, regenerates it from the new version with the same answers, computes the difference between the two, and applies that difference to the current project. Changes you made in the project are kept; changes the template made are applied; when both touched the same lines, you get a conflict to resolve.

copier update is a three-way merge Copier regenerates the project from the old and new template versions with the recorded answers, diffs them, and applies that diff to the current project. copier update is a three-way merge Old render tag in _commit New render newest tag Template diff old → new Your project diff applied answers compare merge Your own edits survive; overlapping changes become conflicts to resolve.

That design has two requirements. The template must be a git repository with tags, so Copier can check out the old and new versions. And the project must contain the answers file, which records the template source, the version used and every answer.

The recipe

1. Make the template updatable

# copier.yml
_subdirectory: template
_answers_file: .copier-answers.yml

project_name:
  type: str
  help: Name of the CLI (as published)
package_name:
  type: str
  default: "{{ project_name | lower | replace('-', '_') }}"
python_min:
  type: str
  default: "3.11"
  choices: ["3.10", "3.11", "3.12"]

The template's files live under template/, so repository files such as the template's own README and CI do not get copied into projects. One file in the template writes the answers back into every generated project:

{# template/{{_copier_conf.answers_file}}.jinja #}
{{ _copier_answers|to_nice_yaml -}}

Tag releases of the template with semantic versions — v1.0.0, v1.1.0 — exactly as you would a library. Copier updates to the newest tag by default, so untagged commits are invisible to updates, which gives you a natural "release" step for template changes.

2. Generate a project

copier copy --trust gh:acme/cli-template acme-deploy

Use a URL or an absolute path as the source; the answers file records it, and copier update needs to find it again from inside the project. The generated .copier-answers.yml looks like this and must be committed:

_commit: v1.0.0
_src_path: gh:acme/cli-template
package_name: acme_deploy
project_name: acme-deploy
python_min: '3.11'

3. Update

cd acme-deploy
git status --short             # must be clean: copier refuses to update a dirty tree
copier update --trust          # newest tag; asks only about new questions
git diff                       # review what the template changed

When the template's change and the project's own edits do not overlap, the update applies cleanly and the answers file's _commit moves to the new tag. When they do overlap, Copier writes inline conflict markers, just like a git merge:

[project]
name = "acme-deploy"
requires-python = ">=3.11"
<<<<<<< before updating
# my change
=======

[tool.ruff]
line-length = 100
>>>>>>> after updating

Resolve them as you would any merge conflict, run the tests, and commit. git status shows conflicted files as unmerged, so they are hard to miss. If you prefer separate files, copier update --conflict rej writes .rej files next to the originals instead.

An update with a conflict Terminal session running copier update in a generated project, showing a conflicted file and the answers file moving to the new template version. An update with a conflict bash $ copier update --trust Updating to template version 1.1.0 conflict pyproject.toml $ git status --short M .copier-answers.yml UU pyproject.toml $ grep _commit .copier-answers.yml _commit: v1.1.0 Conflicted files show as unmerged, exactly like a git merge.

4. Change an answer

Answers are not frozen. copier update --data python_min=3.12 (or re-answering when prompted with --ask python_min) regenerates with the new value and applies the resulting differences, so bumping the minimum Python version across a project's template-managed files is a single update.

Automating updates across many CLIs

With a dozen projects, nobody runs copier update by hand. A scheduled workflow in each generated project can do it and open a pull request:

# .github/workflows/template-update.yml (in each generated project)
name: template-update
on:
  schedule:
    - cron: "0 6 * * 1"
  workflow_dispatch:

permissions:
  contents: write
  pull-requests: write

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: astral-sh/setup-uv@v6
      - run: |
          git config user.name "template-bot"
          git config user.email "template-bot@users.noreply.github.com"
          uvx copier update --trust --defaults --conflict rej
      - uses: peter-evans/create-pull-request@v7
        with:
          branch: template-update
          title: "Update from project template"
          body: "Automated `copier update`. Check for `.rej` files before merging."
Template changes reaching every project Sequence of a template release being picked up by a scheduled workflow in each generated project, which runs copier update and opens a pull request for review. Template changes reaching every project template repo project CI copier maintainers tag v1.1.0 weekly: update --defaults diff applied, .rej files pull request review + merge Small, tagged template releases make each of these pull requests easy to review.

--defaults accepts default answers for any new questions, so the job never waits for input; --conflict rej keeps conflicts out of the code so the pull request's diff stays readable, with .rej files flagging what needs a human. Renovate also understands Copier answer files and can open the same pull requests across an organisation from one configuration.

Migrations between template versions

Some template changes cannot be expressed as a diff: renaming a question, moving a file whose content the project has heavily edited, or deleting a tool configuration that projects should migrate away from. Copier supports migrations in copier.yml — commands that run before or after updating across a given template version:

_migrations:
  - version: v2.0.0
    before:
      - "{{ _copier_python }} -c \"import pathlib; p = pathlib.Path('setup.cfg'); p.exists() and p.unlink()\""
  - version: v2.0.0
    after:
      - "{{ _copier_python }} -m pip --version"

Migrations are code that runs in every project being updated, so they require --trust and deserve the same review as any script with write access to a repository. Keep them small, idempotent and safe to run on a project that has already been fixed by hand. For renamed questions, prefer keeping the old name with a new default for one major version, then removing it — answers files from old projects keep working during the transition. And always describe migrations in the template changelog: they are the part of an update most likely to surprise someone reviewing the pull request.

UX considerations

The users here are the maintainers of generated projects, who did not write the template:

  • Write a template changelog. An update pull request is far easier to review when the template's release notes explain why the CI workflow changed.
  • Keep template changes small and tagged often. Many small updates conflict less than one large one a year later.
  • Separate "owned by the template" from "owned by the project". Files the project should customise freely (the README body, the command code) are better generated once and excluded from updates with _skip_if_exists, so updates never fight with them.
  • Do not make every value a question. Each answer is a dimension the template must support forever; prefer conventions over options.
  • Test the template, including updates from older tags, as in testing a project template with pytest.

Testing the behaviour

Copier exposes run_copy and run_update for Python, which makes it possible to test the update path of a template in its own CI: generate from the previous tag, make a typical local edit, update to the working tree, and check the result:

# tests/test_update.py
import subprocess
from pathlib import Path

from copier import run_copy, run_update

TEMPLATE = Path(__file__).resolve().parents[1]


def git(*args, cwd):
    subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True,
                   env={"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.com",
                        "GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.com",
                        "PATH": "/usr/bin:/bin"})


def test_update_from_previous_release(tmp_path):
    project = tmp_path / "proj"
    run_copy(str(TEMPLATE), project, data={"project_name": "acme-deploy"},
             vcs_ref="v1.0.0", defaults=True, unsafe=True, quiet=True)
    git("init", "-q", "-b", "main", cwd=project)
    git("add", "-A", cwd=project)
    git("commit", "-qm", "generated", cwd=project)

    run_update(project, defaults=True, unsafe=True, quiet=True, overwrite=True)

    answers = (project / ".copier-answers.yml").read_text()
    assert "_commit: v1.0.0" not in answers
    assert "[tool.ruff]" in (project / "pyproject.toml").read_text()

Run it in the template's CI against the last two or three tags, so a template change that breaks updates for existing projects fails before it is released.

Conclusion

Copier turns a project template from a one-time starting point into a maintained dependency. Keep the template in git with semantic-version tags and an answers file template, generate projects from a URL, commit the answers file, and run copier update — by hand or from a weekly pull-request workflow — to replay template changes onto each project as a three-way merge. Small, tagged template releases with a changelog keep those updates easy to review.

Frequently asked questions

Can I update a project that was generated with Cookiecutter?

Not directly — Cookiecutter records no template version. You can migrate by recreating the project with Copier from the template version closest to the original (with copier copy --vcs-ref), committing the answers file, and from then on using copier update. The first update will be noisy; later ones will not.

What if the template renames a file?

Copier applies the rename as part of the diff, so the project's copy moves too — including any local edits, if they do not conflict. Mention renames in the template changelog, because they are the changes reviewers most often misread.

Why does copier update refuse to run?

The usual reasons: uncommitted changes (commit or stash first), a relative _src_path that cannot be resolved from the project directory, or a template without tags. copier update --vcs-ref HEAD can update to an untagged commit for testing, but releases should be tagged.

Do I need --trust?

Only if the template runs tasks or uses Jinja extensions — Copier refuses those from untrusted sources by default. For your own organisation's template, --trust is fine; for a third-party template, read its _tasks first, just as you would review post-generation hooks.