Design: actors, render, and update
Who acts on a project made from this template, what each actor can change, and which workflows hold publishing tokens. The security assurance case follows.
Actors
| Actor | Actions | Scope |
|---|---|---|
| Copier user | Runs copier copy or copier update locally |
All files in their project via git push |
| Template repo CI | Tests answer combinations and executes generated project checks | Runner container only; publishes only template releases |
| Generated project CI | Runs stack workflows on pull requests, pushes to main, and scheduled jobs |
Read-only by default (contents: read or {}); elevated privileges scoped to specific jobs |
| Dependency bot (Renovate or Dependabot) | Opens PRs for dependencies, lockfiles, and GitHub Actions | Branch and PR. Renovate auto-merges minor and patch updates when required checks pass. Dependabot never auto-merges |
copier update workflow |
Renders latest template release weekly and opens an update PR | Pushes only to chore/copier-update. Opens an issue if workflow updates require manual approval |
| Maintainer | Reviews and merges PRs, tags releases (vX.Y.Z) |
Project main branch and published releases |
Render
copier copy reads copier.yml from the newest tagged release, collects answers, and writes the files in template/. It executes no commands or migration scripts. Answers and the template commit are saved to .copier-answers.yml. Copier only touches the network to fetch the template.
Update
copier update performs a three-way merge between the recorded commit in .copier-answers.yml, the latest template release, and the local project tree. Conflicting changes leave standard git conflict markers. Files listed in _skip_if_exists are never modified by updates (which files). Running with --defaults accepts default values for newly added questions.
The weekly CI update workflow runs Copier in a read-only job, generates a binary patch, and applies it in a fresh job that holds the push token. This push job runs no template code or git hooks. The split has to be across jobs, not steps: steps in one job share the runner's state, GITHUB_PATH included, so a template step could change what a later step runs. Only the patch crosses between the jobs, never configuration or scripts.
Workflows that can publish
These rendered workflows publish. Most request id-token: write to exchange OpenID Connect tokens with external registries without long-lived secrets; the Cloudflare jobs use a stored API token:
| Workflow | Condition | Trigger | Token scope | Protections |
|---|---|---|---|---|
release.yml |
Not publish_to_pypi |
Pushed vX.Y.Z tag |
contents: write, to create the GitHub release from the tag's CHANGELOG section |
Publishes only a tag signed with a key in .github/allowed_signers on the default branch. Holds no OIDC token, builds nothing and runs only verify-tag.sh and gh release create |
python-release.yml |
publish_to_pypi |
Pushed vX.Y.Z tag |
PyPI trusted publishing, attestation signing, GitHub release | OIDC token scoped to publish job; release job has only contents: write. Neither runs project code. Limit pypi environment to v* tags |
ts-release.yml |
publish_to_npm |
Pushed vX.Y.Z tag |
Publish to npm | publish job uploads the tarball generated by build. Limit npm environment to v* tags |
docker.yml (push job) |
publish_to_ghcr |
Pushes to main, tags, manual dispatch |
Sign image provenance; packages: write to push to GHCR |
Pull requests run only the unprivileged build job. Require review on ghcr environment |
benchmarks.yml |
codspeed extra |
PRs that touch benchmarked code, pushes to main, manual dispatch |
Upload benchmark results to CodSpeed | Fork PRs receive no OIDC token. Only in-repo branches can upload results |
ts.yml (preview job) |
ts_kind=web-app |
Ready, non-bot pull requests from this repo, not the Copier update branch | CLOUDFLARE_API_TOKEN in the preview environment: deploys a <slug>-pr-<n> Worker through deploy-worker.sh |
Fork and bot pull requests get no token. Runs the branch's own code with the token: see the threat table |
ts.yml (production job) |
ts_kind=web-app |
Pushes to main |
CLOUDFLARE_API_TOKEN in the production environment: deploys the Worker through deploy-worker.sh |
Deploys the build the checks ran on; only main can deploy to the environment |
preview-retire.yml |
ts_kind=web-app or site |
Pull request closed, merged or turned draft; daily; manual dispatch | CLOUDFLARE_API_TOKEN in the preview environment: deletes preview Workers |
Checks out the base branch, not the pull request; skips fork pull requests |
site.yml (deploy job) |
site, site_host=pages |
Pushes to main, manual dispatch on main |
pages: write and id-token: write, on this job only: publishes to GitHub Pages |
Deploys the build job's artifact and runs no repository code |
site.yml (deploy job) |
site, site_host=cloudflare |
Pushes to main, manual dispatch on main |
CLOUDFLARE_API_TOKEN in the production environment: deploys the <slug>-site Worker through deploy-worker.sh, with --domain for a custom domain |
Deploys the build job's artifact; only main can deploy to the environment |
site.yml (preview job) |
site |
Ready, non-bot pull requests from this repo, not the Copier update branch | CLOUDFLARE_API_TOKEN in the preview environment: deploys a <slug>-site-pr-<n> Worker through deploy-worker.sh |
Fork and bot pull requests get no token. Deploys the build job's artifact, but runs the branch's own deploy-worker.sh with the token, as the web app's preview does |
copier-update.yml (push job) |
dependency_bot=dependabot |
Weekly, manual dispatch | With the update App: an App token with Contents, Pull requests and Workflows write, minted from the copier-update environment's key. Without it, GITHUB_TOKEN with contents: write and pull-requests: write. The job also holds issues: write, to open the issue when a push is not possible |
Applies the patch the read-only job made and runs no template code or hooks (Update). Limit the copier-update environment to main |
docs.yml |
docs_site extra |
Pushes to main, manual dispatch on main |
pages: write and id-token: write, on the deploy job only: publishes to GitHub Pages |
Deploys the build job's artifact from main and runs no repository code |
scorecard.yml |
public |
Pushes to main, weekly, branch rule changes |
Publish OpenSSF Scorecard results | Runs approved actions on main; never triggered by pull requests |
Scheduled workflows report a failure through report-failure.yml. Its one job holds issues: write and runs no repository code; the caller passes only an issue title.
None of these workflows use pull_request_target or workflow_run, preventing untrusted fork code from running with write permissions. Actions pin full commit SHAs, and checkouts set persist-credentials: false.
just repo-settings creates the pypi, npm, ghcr, preview, production and copier-update environments a project uses. It limits the release environments to v* tags (ghcr also to main), adds you as their reviewer, and limits production and copier-update to main. preview takes no reviewer or branch limit, because previews run on pull request refs and start unattended.
Assurance case
Security claims and controls for this template and generated projects. Vulnerability disclosures follow SECURITY.md.
Threat model
Defended threats:
- Malicious code, branch names, or titles submitted via pull requests.
- Compromised third-party actions, packages, or tools (including Copier).
- Unauthorized attempts to acquire publishing tokens within CI jobs.
- Crafted Copier answers designed to break out of templated files.
- Accidental secret commits.
Out of scope: maintainer account compromise, deliberate weakening of workflows by maintainers, and a compromise of the platforms themselves: GitHub, Cloudflare, and the PyPI, npm and GHCR registries.
Trust boundaries
| Boundary | Cross-boundary inputs | Controls |
|---|---|---|
| User machine | Remote template and user answers | No Copier tasks or migrations in copier.yml; validator rules check answers (Render) |
| Template repo CI | PR code and metadata | pull_request trigger only; contents: read in ci.yml, dco.yml, and pr-title.yml; ci ok required check |
| Template releases | Pushed vX.Y.Z tag |
release.yml has only contents: write, holds no OIDC token, requires a tag signed with a key in .github/allowed_signers on main (a check that holds only with the v* tag ruleset: a tag runs its own commit's workflow and script), runs only release script and gh release create |
| Template docs site | PR code and pushes to main |
docs.yml builds with contents: read. Only its deploy job holds pages: write and id-token: write, and it runs on main alone. It checks out no code and deploys the build job's artifact |
| Generated project CI | PR code and metadata | Workflows use contents: read or {}; no pull_request_target or workflow_run; persist-credentials: false on checkouts |
| Release jobs with OIDC | Built artifacts | OIDC tokens isolated to upload jobs without project code execution (Workflows that can publish); ruleset limits v* tags to admins |
| Weekly update workflow | New template releases | copier-update.yml runs Copier read-only and applies patches in a separate job holding push credentials (Update) |
| Dependency updates | New package and action versions | Renovate and Dependabot enforce a 7-day cooldown on new releases; uv (exclude-newer) and pnpm (minimumReleaseAge) keep the same week on install; Cargo and Go do not |
Secure design principles
- Least privilege: Workflows default to
contents: readorpermissions: {}. Jobs request write permissions only where required. - Separation of privilege: Build and publish steps run in separate jobs. The update workflow isolates Copier rendering from the git push job.
- Fail-safe defaults:
publish_to_*default to off, andextrasstarts empty except forcold_install, which a public licensed project aboveminimalgets.user_datahas no default, preventing unintentional data collection claims. - Complete mediation: Changes to
mainrequire PRs and passing checks via branch rulesets (main.json, plusmain-checks.jsonin a public repo). - Economy of mechanism: Renders run no executable tasks. Updates apply plain
git applypatches without executing hooks. - Open design: Templates, workflows, and assurance cases are public and auditable.
Countering common weaknesses
- Workflow injection: No
run:step expands event expressions inline; expressions pass viaenv:variables. CI validates workflows with zizmor and jactionlint. - Token theft: Registry tokens are restricted to dedicated jobs that run no repository code. The Cloudflare
previewjobs are the exception: they deploy the pull request's code, and the web app's also installs the branch's packages and runs itswranglerwith the token. Checkouts disable credential persistence. - Dependency compromise: Actions pin full commit SHAs. Generated projects commit lockfiles, and CI installs with strict lockfile flags. A 7-day cooldown delays non-urgent updates.
- Answer injection: validators in
copier.ymlconstrain identifiers, URLs, versions, and descriptions. Descriptions are restricted to single lines without quotes or backslashes. Values pass into structured data viato_json. - Untested templates:
check-render.shensures every file intemplate/is rendered and validated across test combinations.
Security assessment
Reviewed 2026-11-12 for release 0.15.0:
| Threat | Impact | Controls |
|---|---|---|
| Event metadata injected into shell steps | Arbitrary command execution with workflow token | Event fields pass through env: instead of inline expansion; zizmor and jactionlint audit workflows |
| Release job attempts token theft | Malicious package or container published | OIDC tokens scoped strictly to upload jobs without project code; registries restrict uploads to v* tags |
| Copier compromises update workflow | Unauthorized commit pushed to repository | Copier runs read-only; patch applied in separate push job without hook execution |
| Compromised package or action release | Malicious code in CI or runtime | Pinned action SHAs, committed lockfiles, and 7-day update cooldown |
| Crafted answer escapes template syntax | Malformed or insecure project structure | Validators in copier.yml validate types, formats, and escape sequences |
Branch code reads the Cloudflare token in a preview job |
Anyone who can push a branch to a pull request a person opened can replace or delete the production Worker, or copy the token until it is rotated | Fork pull requests, pull requests a bot opened (Renovate, Dependabot, the update App) and the Copier update branch get no token, and a bot's push does not start a preview. A person who later marks the pull request ready or reopens it deploys its head as it is, bot commits included. Accepted while the Worker serves only static files: the harm is a replaced site, as with a bad dependency merged to main. Give previews their own Cloudflare account before a Worker gets bindings (KV, D1, secrets), since those reach every preview |
| Update App key read from another branch | Anyone who can push a branch can mint a token with Contents, Pull requests and Workflows write | The copier-update environment is limited to main. On a private repo on GitHub Free there are no environment branch policies, so the key is readable from any branch: use one App per repo there, or the GITHUB_TOKEN path (The update App). just repo-settings says so when the plan refuses its rulesets |
| Release tag signed by the wrong person | A package published from a tag no maintainer signed | verify-tag.sh checks the SSH signature against allowed_signers on main. It holds only with the v* tag ruleset, since a tag runs its own commit's workflow. Template updates follow the newest v* tag whether or not release.yml accepted it |
| Accidental secret commit | Leaked credentials | betterleaks hook blocks local commits; on public repos, GitHub secret scanning with push protection covers remotes |