diff --git a/copier.yml b/copier.yml index be9a1a1..64aea9c 100644 --- a/copier.yml +++ b/copier.yml @@ -133,3 +133,11 @@ citation: type: bool help: Include CITATION.cff (Zenodo-ready, version-synced on release)? default: false + +update_bot: + type: bool + help: >- + Open template updates as pull requests via a GitHub App? Needs an App with Workflows write, + because the built-in token may not change files under .github/workflows. Without it, updates + arrive as an issue with the command to run locally + default: false diff --git a/profiles/python/.github/workflows/template-update.yml b/profiles/python/.github/workflows/template-update.yml index fbfbffa..6119afe 100644 --- a/profiles/python/.github/workflows/template-update.yml +++ b/profiles/python/.github/workflows/template-update.yml @@ -1,13 +1,24 @@ # Backports from the template: `copier update` replays changes made in -# coregraft since this repository was generated, and opens a pull request. +# coregraft since this repository was generated. # # Local edits are preserved: copier regenerates the old template version from # .copier-answers.yml, diffs it against this repository to capture what you # changed, applies the new template version, then re-applies your diff. # Anything it cannot merge arrives as conflict markers for a human to resolve, -# so the pull request is a proposal, never an automatic overwrite. +# so the result is a proposal, never an automatic overwrite. # -# Runs weekly and on demand. Needs no secret: the built-in token opens the PR. +# Two delivery modes, because GitHub refuses to let the built-in token create +# or update anything under .github/workflows/, and template updates routinely +# do exactly that: +# +# * With a GitHub App that has Workflows write, this opens a pull request. +# * Without one, it opens an issue naming the version and the command, and a +# maintainer runs the update locally where their own credentials apply. +# +# Both use the same `copier update`, so the merge quality is identical; only +# the automation differs. See the README's "Updates from the template". +# +# Runs weekly and on demand. name: template-update on: @@ -18,6 +29,7 @@ on: permissions: contents: write pull-requests: write + issues: write concurrency: group: template-update @@ -26,6 +38,9 @@ concurrency: jobs: update: runs-on: ubuntu-latest + env: + # `secrets` cannot be read in a job-level `if`, but it can here. + HAS_APP: ${{ secrets.RELEASE_APP_ID != '' }} steps: - name: Check out uses: actions/checkout@v4 @@ -35,11 +50,6 @@ jobs: - name: Set up the environment uses: ./.github/actions/setup-python-env - - name: Configure git identity - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - name: Check for a newer template version id: check run: | @@ -57,18 +67,35 @@ jobs: echo "Already on $current." fi + # >>> update_bot + - name: Generate app token + id: app-token + if: steps.check.outputs.outdated == 'true' && env.HAS_APP == 'true' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Configure git identity + if: steps.app-token.outcome == 'success' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # --conflict inline leaves git-style markers in place of a failed merge, # so the pull request shows exactly what needs a decision. --trust is # required because the template personalises through a task. - name: Apply the template update - if: steps.check.outputs.outdated == 'true' + if: steps.app-token.outcome == 'success' run: | uvx copier update --skip-answered --trust --conflict inline --defaults - name: Open a pull request - if: steps.check.outputs.outdated == 'true' + id: pr + if: steps.app-token.outcome == 'success' uses: peter-evans/create-pull-request@v7 with: + token: ${{ steps.app-token.outputs.token }} # the built-in token cannot write workflows branch: template-update title: "chore: update from template ${{ steps.check.outputs.latest }}" commit-message: "chore: update from template ${{ steps.check.outputs.latest }}" @@ -80,3 +107,37 @@ jobs: Review as you would any pull request; nothing here is applied automatically. labels: template-update delete-branch: true + # <<< update_bot + + # Fallback. Also covers the case where the bot is configured in the + # template but the secret is missing here: a step that never ran has an + # empty conclusion, so this still fires. + - name: Open an issue instead + if: steps.check.outputs.outdated == 'true' && steps.pr.conclusion != 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CURRENT: ${{ steps.check.outputs.current }} + LATEST: ${{ steps.check.outputs.latest }} + run: | + title="Template update available: $LATEST" + if gh issue list --state open --label template-update --json title --jq '.[].title' \ + | grep -qxF "$title"; then + echo "An issue for $LATEST is already open." + exit 0 + fi + gh label create template-update --color 0e8a16 \ + --description "Updates from the template" 2>/dev/null || true + gh issue create --title "$title" --label template-update --body "$(cat <>> benchmarks + | `make bench` | Runs the performance benchmarks | -# <<< benchmarks + `make check` lints untracked files on purpose: `pre-commit run -a` skips them, which is how a new file passes locally and then fails in CI the moment it is committed. Commits follow [Conventional Commits](https://www.conventionalcommits.org/); releases, the changelog and versioned documentation are automated on merge to main. Until a release App is configured, the release workflow skips itself instead of failing. -# >>> benchmarks + + ## Benchmarks `make bench` runs the performance benchmarks in `tests/benchmarks/`. On pull requests they also run in CI and upload their results. To track them over time, create a project on [Bencher](https://bencher.dev) (free for public repositories), add a `BENCHER_API_TOKEN` secret and set `BENCHER_PROJECT` in `.github/workflows/main.yml`. Bencher then comments each pull request with a statistical comparison against the base branch. Without the secret the benchmarks still run; only the tracking step is skipped. -# <<< benchmarks + + +## Updates from the template + +This repository was generated from [coregraft](https://github.com/OO-LD/coregraft) and records which version in `.copier-answers.yml`. A weekly workflow checks whether the template has moved on and, if so, replays its changes here. Your own edits are preserved: copier re-applies them on top of the new template version rather than over it, and anything it cannot merge arrives as `<<<<<<<` conflict markers for a human to decide. + +By default the update arrives as an **issue** naming the new version and the command to run: + +```bash +uvx copier update --skip-answered --trust --conflict inline +``` + +To get it as a **pull request** instead, this repository needs a GitHub App, because GitHub refuses to let the built-in token create or update anything under `.github/workflows/`, and template updates routinely do. One-time setup: + +1. Use or create a GitHub App with **Contents: read and write**, **Pull requests: read and write** and **Workflows: read and write**. The last one is the whole point; without it the push is rejected. Organisations often already have a release App, which may need the Workflows permission added and the updated permission accepted on each installation. +2. Install the App on this repository. +3. Provide `RELEASE_APP_ID` and `RELEASE_APP_PRIVATE_KEY` as repository secrets, or share the organisation secrets with this repository. Installing the App and sharing the secret are two separate settings. + +The workflow detects the secret at runtime, so it opens a pull request once the App is available and falls back to an issue when it is not. Either way the merge is the same; only the automation differs. --- diff --git a/profiles/schema/.github/workflows/template-update.yml b/profiles/schema/.github/workflows/template-update.yml index fbfbffa..6119afe 100644 --- a/profiles/schema/.github/workflows/template-update.yml +++ b/profiles/schema/.github/workflows/template-update.yml @@ -1,13 +1,24 @@ # Backports from the template: `copier update` replays changes made in -# coregraft since this repository was generated, and opens a pull request. +# coregraft since this repository was generated. # # Local edits are preserved: copier regenerates the old template version from # .copier-answers.yml, diffs it against this repository to capture what you # changed, applies the new template version, then re-applies your diff. # Anything it cannot merge arrives as conflict markers for a human to resolve, -# so the pull request is a proposal, never an automatic overwrite. +# so the result is a proposal, never an automatic overwrite. # -# Runs weekly and on demand. Needs no secret: the built-in token opens the PR. +# Two delivery modes, because GitHub refuses to let the built-in token create +# or update anything under .github/workflows/, and template updates routinely +# do exactly that: +# +# * With a GitHub App that has Workflows write, this opens a pull request. +# * Without one, it opens an issue naming the version and the command, and a +# maintainer runs the update locally where their own credentials apply. +# +# Both use the same `copier update`, so the merge quality is identical; only +# the automation differs. See the README's "Updates from the template". +# +# Runs weekly and on demand. name: template-update on: @@ -18,6 +29,7 @@ on: permissions: contents: write pull-requests: write + issues: write concurrency: group: template-update @@ -26,6 +38,9 @@ concurrency: jobs: update: runs-on: ubuntu-latest + env: + # `secrets` cannot be read in a job-level `if`, but it can here. + HAS_APP: ${{ secrets.RELEASE_APP_ID != '' }} steps: - name: Check out uses: actions/checkout@v4 @@ -35,11 +50,6 @@ jobs: - name: Set up the environment uses: ./.github/actions/setup-python-env - - name: Configure git identity - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - name: Check for a newer template version id: check run: | @@ -57,18 +67,35 @@ jobs: echo "Already on $current." fi + # >>> update_bot + - name: Generate app token + id: app-token + if: steps.check.outputs.outdated == 'true' && env.HAS_APP == 'true' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + + - name: Configure git identity + if: steps.app-token.outcome == 'success' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # --conflict inline leaves git-style markers in place of a failed merge, # so the pull request shows exactly what needs a decision. --trust is # required because the template personalises through a task. - name: Apply the template update - if: steps.check.outputs.outdated == 'true' + if: steps.app-token.outcome == 'success' run: | uvx copier update --skip-answered --trust --conflict inline --defaults - name: Open a pull request - if: steps.check.outputs.outdated == 'true' + id: pr + if: steps.app-token.outcome == 'success' uses: peter-evans/create-pull-request@v7 with: + token: ${{ steps.app-token.outputs.token }} # the built-in token cannot write workflows branch: template-update title: "chore: update from template ${{ steps.check.outputs.latest }}" commit-message: "chore: update from template ${{ steps.check.outputs.latest }}" @@ -80,3 +107,37 @@ jobs: Review as you would any pull request; nothing here is applied automatically. labels: template-update delete-branch: true + # <<< update_bot + + # Fallback. Also covers the case where the bot is configured in the + # template but the secret is missing here: a step that never ran has an + # empty conclusion, so this still fires. + - name: Open an issue instead + if: steps.check.outputs.outdated == 'true' && steps.pr.conclusion != 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CURRENT: ${{ steps.check.outputs.current }} + LATEST: ${{ steps.check.outputs.latest }} + run: | + title="Template update available: $LATEST" + if gh issue list --state open --label template-update --json title --jq '.[].title' \ + | grep -qxF "$title"; then + echo "An issue for $LATEST is already open." + exit 0 + fi + gh label create template-update --color 0e8a16 \ + --description "Updates from the template" 2>/dev/null || true + gh issue create --title "$title" --label template-update --body "$(cat <>> key` suits anything with +# hash comments; Markdown needs ``, because a line starting +# with "# " is a level-one heading there and trips every linter in the source +# file, even though the marker never survives into a generated repository. +OPEN = r"(?:#|)?" + TEXT_SUFFIXES = { ".py", ".md", @@ -95,14 +104,16 @@ def strip_markers(answers: dict[str, str]) -> None: continue if ">>>" not in text: continue - for key in re.findall(r"# >>> (\w+)", text): + for key in re.findall(rf"{OPEN} (\w+)", text): if truthy(answers.get(key, "")): - text = re.sub(rf"[ \t]*# (?:>>>|<<<) {key}\n", "", text) + text = re.sub(rf"[ \t]*{ANY} {key}{CLOSE}\n\n?", "", text) else: # Consume one blank line before the block too, otherwise # removing a trailing block leaves the file ending in a blank # line and end-of-file-fixer rewrites it on the first commit. - text = re.sub(rf"\n?[ \t]*# >>> {key}\n.*?[ \t]*# <<< {key}\n", "\n", text, flags=re.S) + text = re.sub( + rf"\n?[ \t]*{OPEN} {key}{CLOSE}\n.*?[ \t]*{SHUT} {key}{CLOSE}\n\n?", "\n", text, flags=re.S + ) # Whatever the surgery left behind, the file ends with exactly one newline. path.write_text(text.rstrip("\n") + "\n", encoding="utf-8") diff --git a/tests/test_backport_flow.py b/tests/test_backport_flow.py index 7de58d7..a4cb4cf 100644 --- a/tests/test_backport_flow.py +++ b/tests/test_backport_flow.py @@ -110,12 +110,41 @@ def test_copier_update_backports_into_a_generated_repository(tmp_path: Path, pro @pytest.mark.parametrize("profile", PROFILES) def test_every_profile_ships_the_update_workflow(profile: str) -> None: workflow = (REPO / "profiles" / profile / ".github/workflows/template-update.yml").read_text(encoding="utf-8") - # Needs no secret: the built-in token opens the pull request. - assert "secrets." not in workflow # Conflicts must surface for a human instead of being overwritten. assert "--conflict inline" in workflow assert "copier update" in workflow assert "peter-evans/create-pull-request" in workflow + # The pull request must be pushed with an App token. GitHub refuses to let + # the built-in token create or update anything under .github/workflows/, + # and template updates routinely do; a real backport failed on exactly + # that. There is no `workflows` permission to grant GITHUB_TOKEN. + assert "steps.app-token.outputs.token" in workflow, f"{profile}: PR pushed with a token that cannot write workflows" + + +@pytest.mark.parametrize("profile", PROFILES) +def test_the_update_falls_back_to_an_issue_without_a_bot(tmp_path: Path, profile: str) -> None: + # Not every repository will install a GitHub App, and none should be left + # without a way to learn that the template moved. The fallback opens an + # issue with the command to run locally, where a maintainer's own + # credentials may write workflow files. + instance = tmp_path / profile + shutil.copytree(REPO, instance, ignore=IGNORE) + data: list[str] = [] + for pair in (f"profile={profile}", "project_name=demo-repo", "owner=demo-org", "description=A demo"): + data += ["--data", pair] + _run(sys.executable, str(instance / "scripts/init.py"), "--defaults", *data, cwd=instance) + + spec = yaml.safe_load((instance / ".github/workflows/template-update.yml").read_text(encoding="utf-8")) + assert spec["permissions"]["issues"] == "write", "cannot open the fallback issue without this" + steps = {step.get("name", ""): step for step in spec["jobs"]["update"]["steps"]} + # update_bot defaults to false, so the whole App path is stripped. + assert "Generate app token" not in steps + assert "Open a pull request" not in steps + fallback = steps["Open an issue instead"] + # A step id that never ran yields an empty conclusion, so the condition + # holds both when the App path was stripped and when its secret is absent. + assert "steps.pr.conclusion" in fallback["if"] + assert "copier update" in fallback["run"], "the issue must name the command to run" def test_registry_reads_the_template_version() -> None: diff --git a/tests/test_profiles.py b/tests/test_profiles.py index c33ad06..a03efbf 100644 --- a/tests/test_profiles.py +++ b/tests/test_profiles.py @@ -324,5 +324,6 @@ def test_generated_files_are_pre_commit_clean(tmp_path: Path, profile: str) -> N continue assert text.endswith("\n"), f"{path.name}: no final newline" assert not text.endswith("\n\n"), f"{path.name}: trailing blank line" - # Only our own marker syntax; prose about git conflict markers is fine. - assert not re.search(r"# (?:>>>|<<<) \w+", text), f"{path.name}: marker survived" + # Only our own marker syntax, in either spelling; prose about git + # conflict markers is fine. + assert not re.search(r"(?:#|