From 2dfd8ea2b4ff35e48da301ca7f9271fdf13ccf5f Mon Sep 17 00:00:00 2001 From: Night Date: Sun, 25 Oct 2026 02:05:35 +1100 Subject: [PATCH 1/5] feat: copier update leaves a project's VERSION and renovate.json alone * both files are created with the project and owned by it from then on --- copier.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/copier.yml b/copier.yml index 16d1c0a..86c0c59 100644 --- a/copier.yml +++ b/copier.yml @@ -3,6 +3,11 @@ _min_copier_version: "9.10.0" _subdirectory: template _answers_file: .copier-generic-project/.copier-py-answers.yml +# the project owns these once created +_skip_if_exists: + - VERSION + - renovate.json + #=============================================================================== # Interactive From 7d0827009fd890679d3b96ada2fc7379705fdb30 Mon Sep 17 00:00:00 2001 From: Night Date: Sun, 25 Oct 2026 02:05:35 +1100 Subject: [PATCH 2/5] feat: new projects ship the shared GitHub CI * every generated project gets the workflow from template-github-ci v1.1.1, a VERSION file starting at v0.1.0 and a renovate.json using the shared settings * pull requests must bump VERSION and pass run.sh --format-dry-run, --build, --test and --release in the dev container * each merge to main publishes a GitHub release with the merged pull request's description, the wheel and the sdist --- template/.github/.copier-ci-answers.yml | 4 + template/.github/workflows/ci.yml | 146 ++++++++++++++++++++++++ template/VERSION | 1 + template/renovate.json | 4 + 4 files changed, 155 insertions(+) create mode 100644 template/.github/.copier-ci-answers.yml create mode 100644 template/.github/workflows/ci.yml create mode 100644 template/VERSION create mode 100644 template/renovate.json diff --git a/template/.github/.copier-ci-answers.yml b/template/.github/.copier-ci-answers.yml new file mode 100644 index 0000000..4a3d4df --- /dev/null +++ b/template/.github/.copier-ci-answers.yml @@ -0,0 +1,4 @@ +# .github/.copier-ci-answers.yml.jinja +# Changes here will be overwritten by Copier; NEVER EDIT MANUALLY +_commit: v1.1.1 +_src_path: gh:karshosphere/template-github-ci diff --git a/template/.github/workflows/ci.yml b/template/.github/workflows/ci.yml new file mode 100644 index 0000000..35a6097 --- /dev/null +++ b/template/.github/workflows/ci.yml @@ -0,0 +1,146 @@ +name: ci + +# pull_request rather than pull_request_target, so a fork's code never runs with this repository's secrets +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + # pull requests share a group per branch so newer pushes replace older runs; each commit on main + # gets its own group, since a pending run in a shared group is dropped and its tag would be skipped + group: ci-${{ github.event_name == 'pull_request' && github.ref || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # renovate: datasource=node-version depName=node + NODE_VERSION: 24.22.0 + # renovate: datasource=npm depName=@devcontainers/cli + DEVCONTAINER_CLI_VERSION: 0.89.0 + +jobs: + version: + runs-on: ubuntu-26.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # full history, so every existing release tag is available to compare against + fetch-depth: 0 + - name: VERSION holds a single vX.Y.Z version + run: | + if ! grep -Eqx 'v[0-9]+\.[0-9]+\.[0-9]+' VERSION || [ "$(grep -c "" VERSION)" -ne 1 ]; then + echo "::error file=VERSION::VERSION must be one line of the form vX.Y.Z" + exit 1 + fi + - name: VERSION is higher than every release tag + run: | + version=$(tr -d '[:space:]' < VERSION) + latest=$(git tag --list --sort=-v:refname | { grep -Ex 'v[0-9]+\.[0-9]+\.[0-9]+' || true; } | sed -n 1p) + if [ -n "$latest" ]; then + highest=$(printf '%s\n' "$latest" "$version" | sort -V | tail -n 1) + if [ "$version" = "$latest" ] || [ "$highest" != "$version" ]; then + echo "::error file=VERSION::VERSION is $version but $latest is already released; bump it above $latest" + exit 1 + fi + fi + + checks: + runs-on: ubuntu-26.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: run.sh is present and executable + run: | + [ -x run.sh ] || { + echo "::error file=run.sh::run.sh is missing or not executable" + exit 1 + } + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: ${{ env.NODE_VERSION }} + - run: npm install --global "@devcontainers/cli@$DEVCONTAINER_CLI_VERSION" + - name: Start the dev container + run: devcontainer up --workspace-folder . + - name: Format + run: devcontainer exec --workspace-folder . ./run.sh --format-dry-run + - name: Build + run: devcontainer exec --workspace-folder . ./run.sh --build + - name: Test + run: devcontainer exec --workspace-folder . ./run.sh --test + - name: Release build + run: devcontainer exec --workspace-folder . ./run.sh --release + - if: github.event_name == 'push' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: dist + path: dist/ + # a project releasing no targets leaves dist/ empty + if-no-files-found: ignore + + release: + if: github.event_name == 'push' + needs: [version, checks] + runs-on: ubuntu-26.04 + permissions: + # publishes the release, which also creates its tag + contents: write + # reads the merged pull request's description for the release notes + pull-requests: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - id: version + run: echo "tag=$(tr -d "[:space:]" < VERSION)" >> "$GITHUB_OUTPUT" + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + # a pattern rather than a name, so a missing artifact means no files instead of a failure + pattern: dist + merge-multiple: true + path: dist + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + TAG: ${{ steps.version.outputs.tag }} + with: + script: | + const fs = require('fs'); + const path = require('path'); + const tag = process.env.TAG; + + const { data: pulls } = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + ...context.repo, + commit_sha: context.sha, + }); + const merged = pulls.find(pull => pull.merge_commit_sha === context.sha) ?? pulls.find(pull => pull.merged_at); + const notes = merged?.body || context.payload.head_commit?.message || ''; + + const files = fs.existsSync('dist') + ? fs.readdirSync('dist', { withFileTypes: true }).filter(entry => entry.isFile()).map(entry => entry.name) + : []; + + // a draft holds the tag back until every file is attached, so a failed upload leaves no half-made release + const { data: release } = await github.rest.repos.createRelease({ + ...context.repo, + tag_name: tag, + target_commitish: context.sha, + name: tag, + body: notes, + draft: true, + }); + for (const name of files) { + const data = fs.readFileSync(path.join('dist', name)); + await github.rest.repos.uploadReleaseAsset({ + ...context.repo, + release_id: release.id, + name, + data, + headers: { 'content-type': 'application/octet-stream', 'content-length': data.length }, + }); + } + await github.rest.repos.updateRelease({ ...context.repo, release_id: release.id, draft: false }); + core.notice(`Released ${tag} with ${files.length} file(s)`); diff --git a/template/VERSION b/template/VERSION new file mode 100644 index 0000000..b82608c --- /dev/null +++ b/template/VERSION @@ -0,0 +1 @@ +v0.1.0 diff --git a/template/renovate.json b/template/renovate.json new file mode 100644 index 0000000..c28a4de --- /dev/null +++ b/template/renovate.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["github>karshosphere/template-github-ci"] +} From 34d210d4b82e2eab5915aad23abd0f13617b0137 Mon Sep 17 00:00:00 2001 From: Night Date: Sun, 25 Oct 2026 02:05:35 +1100 Subject: [PATCH 3/5] chore: this template uses the shared Renovate settings * Renovate for this repository follows the shared settings from template-github-ci, so it stays in step with every other template --- renovate.json | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/renovate.json b/renovate.json index eb38bac..c28a4de 100644 --- a/renovate.json +++ b/renovate.json @@ -1,27 +1,4 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended", - "helpers:pinGitHubActionDigests", - "docker:pinDigests", - ":semanticCommitScopeDisabled" - ], - "timezone": "Australia/Melbourne", - "schedule": ["before 6am on sunday"], - "minimumReleaseAge": "7 days", - "internalChecksFilter": "strict", - "dependencyDashboard": true, - "automerge": false, - "semanticCommits": "enabled", - "semanticCommitType": "chore", - "customManagers": [ - { - "customType": "regex", - "description": "versions marked with a renovate comment on the line above", - "managerFilePatterns": ["/^\\.github/workflows/[^/]+\\.ya?ml$/", "/(^|/)Dockerfile$/"], - "matchStrings": [ - "# renovate: datasource=(?[a-z-]+) depName=(?\\S+)\\s+(?:ARG |ENV )?[\\w-]+[:=] *\"?(?[^\\s\"@]+)(?:@(?sha256:[a-f0-9]+))?\"?" - ] - } - ] + "extends": ["github>karshosphere/template-github-ci"] } From df883a6eae799a72d1965a5b1b715a8a5c5cb058 Mon Sep 17 00:00:00 2001 From: Night Date: Sun, 25 Oct 2026 02:05:35 +1100 Subject: [PATCH 4/5] chore: version v1.2.0 * minor release for the shared GitHub CI --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 795460f..79127d8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v1.1.0 +v1.2.0 From abe8b513ea4d2856bd8321d8d330a19af88f3d5b Mon Sep 17 00:00:00 2001 From: Night Date: Sun, 25 Oct 2026 02:05:35 +1100 Subject: [PATCH 5/5] docs: readme and changelog for feature/github-ci * README explains the vendored workflow and how to update it * changelog entry for this branch --- CHANGELOG.md | 1 + README.md | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dce1e2d..071bbd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,3 +6,4 @@ * fix/readme-badge: the Copier badge in generated READMEs links to karshosphere/template-python. * fix/project-builds: generated projects install, start and build, with a uv.lock, the right colorlog package, a working FastAPI lifespan and a pinned uv in the dev container. * feature/run-script: generated projects ship a run.sh for building, formatting with ruff, testing with pytest and building a wheel and sdist, with starter tests and ruff-clean code. +* feature/github-ci: generated projects ship the shared GitHub CI, a VERSION file and the shared Renovate settings, which copier update leaves alone. diff --git a/README.md b/README.md index a4e8398..adeab6e 100644 --- a/README.md +++ b/README.md @@ -59,3 +59,14 @@ Every generated project has a `run.sh` at its root: * `./run.sh --format` formats the Python sources with ruff; `--format-dry-run` lists unformatted files and fails if there are any. * `./run.sh --test` runs the tests with pytest. * `./run.sh --release` builds a wheel and an sdist into `dist/`. + + +### GitHub CI + +Generated projects ship with the shared workflow from [template-github-ci](https://github.com/karshosphere/template-github-ci), vendored into `template/.github/`. It expects the project's `run.sh` and `.devcontainer/`, requires a `VERSION` bump on every pull request, and publishes a GitHub release on each merge to main. + +To bring in a newer release of the workflow, from this repository's root: + +```bash +$ copier update -a .github/.copier-ci-answers.yml template +```