Skip to content

Commit 25437f2

Browse files
ejfineclaude
andauthored
Release job (#97)
## Why is this change necessary? The template had no `Release` workflow. The existing `Publish` / `Publish to Staging` workflows re-run lint/test/build themselves, duplicating everything CI already does for the commit. We want a release path that instead relies on a passing CI run — modeled on the `copier-nuxt-python-intranet-app` `release.yaml` — while publishing to PyPI (using the existing `publish.yaml` as the PyPI reference). While wiring this up we also found that instantiated repos hit pylint `R0801 duplicate-code` between `git_tag.py` and `extract_project_version.py`, because this repo carried a pre-refactor fork of `git_tag.py` (symlinked from `copier_template_resources/`) that parsed `pyproject.toml` itself. ## How does this change address the issue? - **CI builds the distribution.** Added a `build` job to `ci.yaml.jinja` that runs `uv build --no-sources` and uploads a `python-package-distributions` artifact (`if-no-files-found: error`), wired into `workflow-summary` (needs + fail-check). - **New `release.yaml.jinja`.** Manual `workflow_dispatch` with a `dry_run` input, generated for both libraries and executables. A `guard` job requires the ci.yaml run for the current commit to be `completed`/`success` (including the `workflow-summary` job), extracts the version via `extract_project_version.py`, and confirms the tag is absent — real releases must run from `main`. The flow then branches on `deploy_as_executable`: - **Library:** download the CI-built dist (never rebuilds), publish to Test PyPI and verify a fresh install, push the `v<version>` tag, publish to PyPI and verify again, then create a GitHub Release with generated notes. - **Executable:** push the `v<version>` tag, download every `exe-{os}-{python}` artifact the CI `executable` job built, package each (tar on Linux / zip on Windows), and attach them all to a GitHub Release. No PyPI publish. All tag/publish/release jobs are skipped on `dry_run`. - **Aligned `git_tag.py` with base/nuxt.** Replaced the local fork with the base version (`--version` required, no `pyproject.toml` parsing), and collapsed the `copier_template_resources/` symlink into a real file at `template/.github/workflows/git_tag.py` (matching its sibling `extract_project_version.py`). Updated all call sites in `publish.yaml` and `release.yaml` to source the version from `extract_project_version.py` and pass `--version`. - **Docs.** Documented the `Release` workflow in the generated README, plus a TODO to eventually merge `Publish` / `Publish to Staging` into `Release`. ## What side effects does this change have? - CI now has an extra `build` job on every run (gated `{% if not is_frozen_executable %}` to match `publish.yaml`). - `git_tag.py`'s CLI changed: `--version` is now required and it no longer prints/parses the version. Any external caller relying on the old no-arg print behavior must switch to `extract_project_version.py`. - `create-tag` gained a `needs` dependency (`guard` in `release.yaml`, `get-values` in `publish.yaml`) to obtain the version. - `template/.github/workflows/git_tag.py` changed from a symlink to a regular file; `copier_template_resources/git_tag.py` was deleted. ## How is this change tested? Rendered the template with `copier` (data1 / PyPI) and verified: - `ci.yaml`, `release.yaml`, `publish.yaml` all parse as YAML; job graph, `needs`, and `dry_run` guards asserted correct. - All `git_tag.py` call sites pass `--version`; version sourced from `extract_project_version.py`. - pylint on the rendered scripts: `10.00/10` (previously exit 8 / `R0801`). ruff and pyright pass on the source `git_tag.py`. - CodeArtifact conditional branches render structurally (kept for parity so existing CodeArtifact users aren't broken). Final enforcement happens in the `lint-matrix` CI job, which generates across data1/2/3 and runs pre-commit on the output. ## Other - Fixed a pre-existing template bug: `is_frozen_executable` (used to gate `entrypoint.py`, `main.py`, `publish.yaml`, `publish_to_staging.yaml`) is undefined and evaluates falsy — so executable projects shipped with no `entrypoint.py`/`main.py` yet still got PyPI publish workflows. Renamed all guards (plus the CI `build` job) to the real `deploy_as_executable` question. `release.yaml` is intentionally ungated — it serves both cases and branches internally; `publish.yaml` / `publish_to_staging.yaml` stay library-only. Verified renders: executables now get `entrypoint.py`/`main.py`, no publish, and a `release.yaml` that attaches the CI-built executables; libraries get publish/release + the CI build job and no entrypoint/main. - Test-data coverage gap (follow-up): after the fix, no `tests/copier_data` file is PyPI + library (data1/data3 = PyPI + executable, data2 = CodeArtifact + library), so `lint-matrix` no longer exercises the PyPI-library publish/release path. Consider a data file with `python_package_registry: PyPI` and `deploy_as_executable: no`. - `release.yaml`'s `create-tag` pushes the tag before `publish-to-primary`; the GitHub Release step then attaches to that existing tag (softprops/action-gh-release handles a pre-existing tag gracefully — no conflict). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a gated **Release** workflow with dry-run support, version extraction, and safe Git tag checks. * Generated templates now support publishing releases. * Optional packaging and uploading of platform executables during release. * Added staging publishing and install/verification prior to production release. * **Improvements** * CI now conditionally builds/uploads distribution artifacts for release and updates the Codecov action reference. * Templates now automate version extraction and tag management for the release pipeline. * **Documentation** * Updated release guidance to reflect the new **Release** workflow and deployment options. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 14aa2a6 commit 25437f2

15 files changed

Lines changed: 602 additions & 116 deletions

.config/.copier-answers.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ ssh_port_number: 55874
1515
template_might_want_to_install_aws_ssm_port_forwarding_plugin: true
1616
template_might_want_to_use_python_asyncio: true
1717
template_might_want_to_use_vcrpy: true
18-
template_publishes_releases: false
18+
template_publishes_releases: true
1919
template_uses_pulumi: false
2020
template_uses_python: true
2121
template_uses_typescript: false

.config/.copier-managed-files.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,7 @@
183183
"template/.github/actions/update-devcontainer-hash/action.yml",
184184
"template/.github/pull_request_template.md",
185185
"template/.github/workflows/confirm-on-tagged-copier-template.yaml",
186+
"template/.github/workflows/extract_project_version.py",
186187
"template/.github/workflows/get-values.yaml",
187188
"template/.github/workflows/git_tag.py",
188189
"template/.github/workflows/hash_git_files.py",

.devcontainer/devcontainer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,5 +60,5 @@
6060
"initializeCommand": "sh .devcontainer/initialize-command.sh",
6161
"onCreateCommand": "sh .devcontainer/on-create-command.sh",
6262
"postStartCommand": "sh .devcontainer/post-start-command.sh"
63-
// Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): bfadcd30 # spellchecker:disable-line
63+
// Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 2c1bcc28 # spellchecker:disable-line
6464
}

copier_template_resources/git_tag.py

Lines changed: 0 additions & 102 deletions
This file was deleted.

extensions/context.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just
110110
context["gha_amazon_ecr_login"] = "v2.1.5"
111111
context["gha_setup_node"] = "v6.4.0"
112112
context["gha_action_gh_release"] = "v3.0.0"
113+
context["gha_codecov"] = "v7.0.0"
113114
context["gha_mutex"] = "1ebad517141198e08d47cf72f3c0975316620a65 # v1.0.0-alpha.10"
114115
context["gha_pypi_publish"] = "v1.14.0"
115116
context["gha_sleep"] = "v2.0.3"

template/.github/workflows/ci.yaml.jinja

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ jobs:
9797
{% endraw %}{% if use_codecov %}{% raw %} - name: Upload coverage to Codecov
9898
# only upload coverage from fastest job
9999
if: ${{ matrix.JOB_MATCHING_DEV_ENV && github.actor != 'dependabot[bot]' }} # dependabot by default doesn't have access to the necessary secret...and dependabot should never be changing coverage anyway so it's fine not uploading it
100-
uses: codecov/codecov-action@v5.1.1
100+
uses: codecov/codecov-action@{% endraw %}{{ gha_codecov }}{% raw %}
101101
with:
102102
files: ./coverage.xml
103103
flags: unittests
@@ -107,6 +107,50 @@ jobs:
107107
token: ${{ secrets.CODECOV_TOKEN }}
108108
slug: {% endraw %}{{ full_repo_url | replace("https://github.com/", "") }}{% raw %}{% endraw %}{% endif %}{% raw %}
109109

110+
{% endraw %}{% if not deploy_as_executable %}{% raw %}
111+
build:
112+
needs:
113+
- test
114+
- check-skip-duplicate
115+
if: needs.check-skip-duplicate.outputs.should-run == 'true'
116+
timeout-minutes: {% endraw %}{{ gha_medium_timeout_minutes }}{% raw %}
117+
runs-on: {% endraw %}{{ gha_linux_runner }}{% raw %}
118+
119+
steps:
120+
- name: Checkout code
121+
uses: actions/checkout@{% endraw %}{{ gha_checkout }}{% raw %}
122+
123+
- name: Install python tooling
124+
uses: ./.github/actions/install_deps
125+
with:
126+
skip-installing-ssm-plugin-manager: true
127+
skip-installing-pulumi-cli: true
128+
python-version: {% endraw %}{{ python_version }}{% if python_package_registry == "AWS CodeArtifact" %}{% raw %}
129+
code-artifact-auth-role-name: CoreInfraBaseAccess
130+
code-artifact-auth-role-account-id: {% endraw %}{{ aws_central_infrastructure_account_id }}{% raw %}
131+
code-artifact-auth-region: {% endraw %}{{ aws_org_home_region }}{% endif %}{% raw %}
132+
133+
{% endraw %}{% if python_package_registry == "AWS CodeArtifact" %}{% raw %}
134+
- name: OIDC Auth for Installing any dependencies that uv may need for build (sometimes it likes to install setuptools...even if it's already in the package dependencies)
135+
uses: aws-actions/configure-aws-credentials@{% endraw %}{{ gha_configure_aws_credentials }}{% raw %}
136+
with:
137+
role-to-assume: arn:aws:iam::{% endraw %}{{ aws_central_infrastructure_account_id }}{% raw %}:role/CoreInfraBaseAccess
138+
aws-region: {% endraw %}{{ aws_org_home_region }}{% raw %}
139+
140+
{% endraw %}{% endif %}{% raw %}
141+
- name: Build package
142+
run: |
143+
{% endraw %}{% if python_package_registry == "AWS CodeArtifact" %}{% raw %} . .devcontainer/code-artifact-auth.sh{% endraw %}{% endif %}{% raw %}
144+
uv build --no-sources
145+
146+
- name: Upload build package
147+
uses: actions/upload-artifact@{% endraw %}{{ gha_upload_artifact }}{% raw %}
148+
with:
149+
name: python-package-distributions
150+
path: dist/
151+
if-no-files-found: error
152+
{% endraw %}{% endif %}{% raw %}
153+
110154
{% endraw %}{% if deploy_as_executable %}{% raw %} executable:
111155
needs:
112156
- test
@@ -193,7 +237,8 @@ jobs:
193237
- get-values
194238
- check-skip-duplicate
195239
- lint
196-
- test{% endraw %}{% if create_docs %}
240+
- test{% endraw %}{% if not deploy_as_executable %}
241+
- build{% endif %}{% if create_docs %}
197242
- build-docs{% endif %}{% if deploy_as_executable %}
198243
- executable{% endif %}{% raw %}
199244
- confirm-on-tagged-copier-template
@@ -207,7 +252,8 @@ jobs:
207252

208253
if [[ ! "${{ needs.get-values.result }}" =~ $success_pattern ]] ||
209254
[[ ! "${{ needs.check-skip-duplicate.result }}" =~ $success_pattern ]] ||
210-
[[ ! "${{ needs.lint.result }}" =~ $success_pattern ]] ||{% endraw %}{% if create_docs %}{% raw %}
255+
[[ ! "${{ needs.lint.result }}" =~ $success_pattern ]] ||{% endraw %}{% if not deploy_as_executable %}{% raw %}
256+
[[ ! "${{ needs.build.result }}" =~ $success_pattern ]] ||{% endraw %}{% endif %}{% if create_docs %}{% raw %}
211257
[[ ! "${{ needs.build-docs.result }}" =~ $success_pattern ]] ||{% endraw %}{% endif %}{% raw %}{% endraw %}{% if deploy_as_executable %}{% raw %}
212258
[[ ! "${{ needs.executable.result }}" =~ $success_pattern ]] ||{% endraw %}{% endif %}{% raw %}
213259
[[ ! "${{ needs.test.result }}" =~ $success_pattern ]] ||
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# ============== WARNING ==============================================================================
2+
# File is managed by copier template: gh:LabAutomationAndScreening/copier-base-template.git
3+
# See .config/.copier-managed-files.json for details.
4+
#
5+
# You are welcome to make changes to this file in your repo if they are custom to your project,
6+
# but if the change should be shared with other projects, please backport it to the template repo.
7+
# =====================================================================================================
8+
import argparse
9+
import json
10+
import tomllib
11+
from pathlib import Path
12+
13+
14+
def extract_version(file_path: Path | str) -> str:
15+
path = Path(file_path)
16+
17+
if path.name == "package.json":
18+
data = json.loads(path.read_text())
19+
if version := data.get("version"):
20+
return version
21+
raise KeyError(f"No version field found in {path!r}")
22+
23+
if path.name == "pyproject.toml":
24+
with path.open("rb") as f:
25+
data = tomllib.load(f)
26+
project = data.get("project", {})
27+
if version := project.get("version"):
28+
return version
29+
tool = data.get("tool", {})
30+
if version := tool.get("poetry", {}).get("version"):
31+
return version
32+
raise KeyError(f"No version field found in {path!r}")
33+
34+
raise ValueError(f"Unsupported file type {path.name!r}; expected pyproject.toml or package.json")
35+
36+
37+
def main() -> None:
38+
parser = argparse.ArgumentParser(
39+
description="Extract the version from a pyproject.toml or package.json file and print it."
40+
)
41+
_ = parser.add_argument(
42+
"file",
43+
nargs="?",
44+
default="pyproject.toml",
45+
help="Path to pyproject.toml or package.json (default: pyproject.toml)",
46+
)
47+
args = parser.parse_args()
48+
49+
print(extract_version(args.file)) # noqa: T201 # specifically printing this out so CI pipelines can read the value from stdout
50+
51+
52+
if __name__ == "__main__":
53+
main()

template/.github/workflows/git_tag.py

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# ============== WARNING ==============================================================================
2+
# File is managed by copier template: gh:LabAutomationAndScreening/copier-base-template.git
3+
# See .config/.copier-managed-files.json for details.
4+
#
5+
# You are welcome to make changes to this file in your repo if they are custom to your project,
6+
# but if the change should be shared with other projects, please backport it to the template repo.
7+
# =====================================================================================================
8+
import argparse
9+
import subprocess
10+
11+
12+
def ensure_tag_not_present(tag: str, remote: str) -> None:
13+
no_matching_refs_return_code = 2
14+
result = subprocess.run( # noqa: S603 # this is trusted input, it's our own arguments being passed in
15+
["git", "ls-remote", "--exit-code", "--tags", remote, f"refs/tags/{tag}"], # noqa: S607 # if `git` isn't in PATH already, then there are bigger problems to solve
16+
stdout=subprocess.DEVNULL,
17+
check=False,
18+
)
19+
if result.returncode == 0:
20+
raise Exception(f"Error: tag '{tag}' exists on remote '{remote}'") # noqa: TRY002 # not worth a custom exception
21+
if (
22+
result.returncode != no_matching_refs_return_code
23+
): # anything else is a real error (bad remote, auth failure, network)
24+
raise Exception(f"git ls-remote exited with code {result.returncode} (remote={remote!r})") # noqa: TRY002 # not worth a custom exception
25+
26+
27+
def main() -> None:
28+
parser = argparse.ArgumentParser(
29+
description=("Confirm that git tag v<version> is not present on a remote, or create and push the tag.")
30+
)
31+
_ = parser.add_argument(
32+
"--version",
33+
required=True,
34+
help="Version string (e.g. 1.0.6 or v1.0.6); the tag will always be v<version>",
35+
)
36+
mode = parser.add_mutually_exclusive_group(required=True)
37+
_ = mode.add_argument(
38+
"--confirm-tag-not-present",
39+
action="store_true",
40+
help="Check that git tag v<version> is NOT present on the remote. If the tag exists, exit with an error.",
41+
)
42+
_ = mode.add_argument(
43+
"--push-tag-to-remote",
44+
action="store_true",
45+
help="Create git tag v<version> locally and push it to the remote. Internally confirms the tag is not already present.",
46+
)
47+
_ = parser.add_argument(
48+
"--remote",
49+
default="origin",
50+
help="Name of git remote to query/push (default: origin)",
51+
)
52+
args = parser.parse_args()
53+
54+
tag = args.version if args.version.startswith("v") else f"v{args.version}"
55+
56+
if args.push_tag_to_remote:
57+
ensure_tag_not_present(tag, args.remote)
58+
_ = subprocess.run(["git", "tag", tag], check=True) # noqa: S603,S607 # this is trusted input, it's our own version string. and if `git` isn't in PATH, then there are larger problems anyway
59+
_ = subprocess.run(["git", "push", args.remote, tag], check=True) # noqa: S603,S607 # this is trusted input, it's our own version string. and if `git` isn't in PATH, then there are larger problems anyway
60+
return
61+
62+
if args.confirm_tag_not_present:
63+
ensure_tag_not_present(tag, args.remote)
64+
65+
66+
if __name__ == "__main__":
67+
main()

0 commit comments

Comments
 (0)