Skip to content

Commit 7ccd45f

Browse files
ejfineclaude
andcommitted
Copier update: install Task via uv (v0.1.6 -> v0.1.7)
Task is now installed with `uv tool install go-task-bin==3.53.1` instead of from its own GitHub release. One uv call replaces the hand-rolled per-OS install (PowerShell Invoke-WebRequest plus Expand-Archive on Windows, the taskfile.dev install script on POSIX) and covers Windows, macOS and Linux on both x86_64 and arm64, so shlex is no longer needed. Note that this trades a first-party source, where the installer verified the pinned tag against Task's published checksums, for a third-party repackaging of the upstream release archives on PyPI. The version stays pinned and uv records the wheel hash in its tool receipt. The uv bootstrap moves out of the --no-python branch into its own install_uv and now always runs, because installing Task depends on it. `uv tool list` moves after install_task so Task appears in the listing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6ce0db5 commit 7ccd45f

4 files changed

Lines changed: 110 additions & 134 deletions

File tree

‎.config/.copier-answers.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# Changes here will be overwritten by Copier
2-
_commit: v0.1.6
2+
_commit: v0.1.7
33
_src_path: gh:LabAutomationAndScreening/copier-base-template.git
44
copier_answers_directory: .config
55
description: Copier template for creating Python libraries and executables

‎.devcontainer/devcontainer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,5 @@
6767
"initializeCommand": "sh .devcontainer/initialize-command.sh",
6868
"onCreateCommand": "sh .devcontainer/on-create-command.sh",
6969
"postStartCommand": "sh .devcontainer/post-start-command.sh"
70-
// Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 4d9b70d0 # spellchecker:disable-line
70+
// Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 079f70d6 # spellchecker:disable-line
7171
}

‎.devcontainer/install-ci-tooling.py‎

Lines changed: 54 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
import argparse
99
import os
1010
import platform
11-
import shlex
1211
import shutil
1312
import subprocess
1413
import sys
@@ -22,9 +21,9 @@
2221
PRE_COMMIT_VERSION = "4.6.2"
2322
TASK_VERSION = "3.53.1"
2423
DOWNLOAD_TIMEOUT_SECONDS = 90
25-
# Where both uv's and Task's installers place binaries. Resolves from USERPROFILE on Windows, so it
26-
# matches the runner's home directory without assuming its user name. Already on PATH on POSIX, but
27-
# not on Windows, which is why uv is invoked through an absolute path there.
24+
# Where uv places both itself and the executables of the tools it installs. Resolves from USERPROFILE
25+
# on Windows, so it matches the runner's home directory without assuming its user name. Already on
26+
# PATH on POSIX, but not on Windows, which is why uv is invoked through an absolute path there.
2827
LOCAL_BIN_DIR = Path.home() / ".local" / "bin"
2928
INSTALL_SSM_PLUGIN_BY_DEFAULT = False
3029
# A floor rather than an exact pin: GitHub's runner images ship their own build of the plugin and only
@@ -67,55 +66,60 @@ def pwsh_cmd(cmd: str) -> list[str]:
6766
return [pwsh, "-NoProfile", "-NonInteractive", "-Command", cmd]
6867

6968

70-
def install_task(*, is_windows: bool) -> None:
71-
"""Install the pinned Task release into `LOCAL_BIN_DIR`.
69+
def install_uv(uv_env: dict[str, str], *, is_windows: bool) -> None:
70+
"""Install the pinned uv release into `LOCAL_BIN_DIR`.
7271
73-
Deliberately not `npm install -g @go-task/cli`: that writes to the global prefix of whichever
74-
node is on PATH, and in CI that is the pnpm-managed node installed by `pnpm/setup`, whose prefix
75-
bin directory is not the one on PATH. The package installs successfully and the linked
76-
executable is then unreachable, so `task` fails with exit status 127.
72+
Runs regardless of `--no-python`, because uv is also how Task is installed, and every job needs
73+
the task runner even when it has no Python environments to set up.
7774
78-
Verification goes through the absolute path because this process resolved PATH before the
79-
install; `GITHUB_PATH` is appended so later steps in the same CI job can invoke `task` by name.
75+
`uv_env` is mutated on Windows rather than copied: `LOCAL_BIN_DIR` is not on the runner's PATH
76+
there, and every later uv invocation needs it in front.
8077
"""
81-
LOCAL_BIN_DIR.mkdir(parents=True, exist_ok=True)
8278
if is_windows:
83-
if platform.machine().lower() == "arm64":
84-
windows_arch = "arm64"
85-
else:
86-
windows_arch = "amd64"
87-
archive_url = (
88-
f"https://github.com/go-task/task/releases/download/v{TASK_VERSION}/task_windows_{windows_arch}.zip"
89-
)
90-
# The bin directory is handed over as an environment variable rather than interpolated into the
91-
# script text: PowerShell single quotes take no escapes, so an apostrophe in the home directory
92-
# would terminate the string early. A variable reference is one argument whatever it contains.
93-
task_env = dict(os.environ)
94-
task_env.update({"TASK_BIN_DIR": str(LOCAL_BIN_DIR)})
95-
powershell_statements = [
96-
"$ErrorActionPreference = 'Stop'",
97-
"$archive = Join-Path $env:TEMP 'task-release.zip'",
98-
f"Invoke-WebRequest -UseBasicParsing -Uri '{archive_url}' -OutFile $archive",
99-
"Expand-Archive -Path $archive -DestinationPath $env:TASK_BIN_DIR -Force",
100-
"Remove-Item $archive",
101-
]
79+
uv_env.update({"PATH": rf"{LOCAL_BIN_DIR};{uv_env['PATH']}"})
10280
_ = subprocess.run( # noqa: S603 # this is all our own input
103-
pwsh_cmd("; ".join(powershell_statements)),
81+
pwsh_cmd(f"irm https://astral.sh/uv/{UV_VERSION}/install.ps1 | iex"),
10482
check=True,
105-
env=task_env,
83+
env=uv_env,
10684
timeout=DOWNLOAD_TIMEOUT_SECONDS,
10785
)
108-
task_path = LOCAL_BIN_DIR / "task.exe"
10986
else:
110-
# The installer resolves the pinned tag against the published checksums, so the archive is verified.
111-
# The bin directory is shell-quoted because a space or apostrophe in the home directory would
112-
# otherwise split it into multiple arguments, or unbalance the quoting outright.
11387
_ = subprocess.run( # noqa: S602 # we need to set shell to true to use the pipe operator, and this is all our own input
114-
f"curl -fsSL --connect-timeout 20 --max-time 40 --retry 3 --retry-delay 5 --retry-connrefused --proto '=https' https://taskfile.dev/install.sh | sh -s -- -b {shlex.quote(str(LOCAL_BIN_DIR))} v{TASK_VERSION}",
88+
f"curl -fsSL --connect-timeout 20 --max-time 40 --retry 3 --retry-delay 5 --retry-connrefused --proto '=https' https://astral.sh/uv/{UV_VERSION}/install.sh | sh",
11589
check=True,
11690
shell=True,
91+
env=uv_env,
11792
timeout=DOWNLOAD_TIMEOUT_SECONDS,
11893
)
94+
# TODO: add uv autocompletion to the shell https://docs.astral.sh/uv/getting-started/installation/#shell-autocompletion
95+
96+
97+
def install_task(uv_path: str, uv_env: dict[str, str], *, is_windows: bool) -> None:
98+
"""Install the pinned Task release into `LOCAL_BIN_DIR` as a uv tool.
99+
100+
`go-task-bin` repackages the upstream release archives as one wheel per platform, so a single uv
101+
invocation covers Windows, macOS and Linux on both x86_64 and arm64. Task publishes nothing to
102+
PyPI itself, so this is knowingly a third-party distribution: the version is pinned and uv
103+
records the wheel hash in the tool receipt it writes.
104+
105+
Deliberately not `npm install -g @go-task/cli`: that writes to the global prefix of whichever
106+
node is on PATH, and in CI that is the pnpm-managed node installed by `pnpm/setup`, whose prefix
107+
bin directory is not the one on PATH. The package installs successfully and the linked
108+
executable is then unreachable, so `task` fails with exit status 127.
109+
110+
Verification goes through the absolute path because this process resolved PATH before the
111+
install; `GITHUB_PATH` is appended so later steps in the same CI job can invoke `task` by name.
112+
"""
113+
LOCAL_BIN_DIR.mkdir(parents=True, exist_ok=True)
114+
_ = subprocess.run( # noqa: S603 # this is all our own input
115+
[uv_path, "tool", "install", f"go-task-bin=={TASK_VERSION}"],
116+
check=True,
117+
env=uv_env,
118+
timeout=DOWNLOAD_TIMEOUT_SECONDS,
119+
)
120+
if is_windows:
121+
task_path = LOCAL_BIN_DIR / "task.exe"
122+
else:
119123
task_path = LOCAL_BIN_DIR / "task"
120124
_ = subprocess.run([str(task_path), "--version"], check=True) # noqa: S603 # this is all our own input
121125
if "GITHUB_PATH" in os.environ:
@@ -248,24 +252,8 @@ def main():
248252
uv_path = str(LOCAL_BIN_DIR / "uv")
249253
else:
250254
uv_path = "uv"
255+
install_uv(uv_env, is_windows=is_windows)
251256
if not args.no_python:
252-
if is_windows:
253-
uv_env.update({"PATH": rf"{LOCAL_BIN_DIR};{uv_env['PATH']}"})
254-
_ = subprocess.run( # noqa: S603 # this is all our own input
255-
pwsh_cmd(f"irm https://astral.sh/uv/{UV_VERSION}/install.ps1 | iex"),
256-
check=True,
257-
env=uv_env,
258-
timeout=DOWNLOAD_TIMEOUT_SECONDS,
259-
)
260-
else:
261-
_ = subprocess.run( # noqa: S602 # we need to set shell to true to use the pipe operator, and this is all our own input
262-
f"curl -fsSL --connect-timeout 20 --max-time 40 --retry 3 --retry-delay 5 --retry-connrefused --proto '=https' https://astral.sh/uv/{UV_VERSION}/install.sh | sh",
263-
check=True,
264-
shell=True,
265-
env=uv_env,
266-
timeout=DOWNLOAD_TIMEOUT_SECONDS,
267-
)
268-
# TODO: add uv autocompletion to the shell https://docs.astral.sh/uv/getting-started/installation/#shell-autocompletion
269257
_ = subprocess.run( # noqa: S603 # this is all our own input
270258
[
271259
uv_path,
@@ -290,19 +278,19 @@ def main():
290278
env=uv_env,
291279
timeout=DOWNLOAD_TIMEOUT_SECONDS,
292280
)
293-
_ = subprocess.run( # noqa: S603 # this is all our own input
294-
[
295-
uv_path,
296-
"tool",
297-
"list",
298-
],
299-
check=True,
300-
env=uv_env,
301-
)
302281
if not args.no_node:
303282
run_node_cmds(["npm -v", f"npm install -g pnpm@{PNPM_VERSION}", "pnpm -v"], is_windows=is_windows)
304283
# Task is installed outside the --no-node branch because CI always passes --no-node (pnpm/setup handles pnpm there), and every job still needs the task runner
305-
install_task(is_windows=is_windows)
284+
install_task(uv_path, uv_env, is_windows=is_windows)
285+
_ = subprocess.run( # noqa: S603 # this is all our own input
286+
[
287+
uv_path,
288+
"tool",
289+
"list",
290+
],
291+
check=True,
292+
env=uv_env,
293+
)
306294
if INSTALL_SSM_PLUGIN_BY_DEFAULT and not args.skip_installing_ssm_plugin:
307295
ensure_ssm_plugin(is_windows=is_windows)
308296

‎template/.devcontainer/install-ci-tooling.py.jinja‎

Lines changed: 54 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
{% raw %}import argparse
22
import os
33
import platform
4-
import shlex
54
import shutil
65
import subprocess
76
import sys
@@ -15,9 +14,9 @@ COPIER_TEMPLATE_EXTENSIONS_VERSION = "{% endraw %}{{ copier_template_extensions_
1514
PRE_COMMIT_VERSION = "{% endraw %}{{ pre_commit_version }}{% raw %}"
1615
TASK_VERSION = "{% endraw %}{{ task_version }}{% raw %}"
1716
DOWNLOAD_TIMEOUT_SECONDS = 90
18-
# Where both uv's and Task's installers place binaries. Resolves from USERPROFILE on Windows, so it
19-
# matches the runner's home directory without assuming its user name. Already on PATH on POSIX, but
20-
# not on Windows, which is why uv is invoked through an absolute path there.
17+
# Where uv places both itself and the executables of the tools it installs. Resolves from USERPROFILE
18+
# on Windows, so it matches the runner's home directory without assuming its user name. Already on
19+
# PATH on POSIX, but not on Windows, which is why uv is invoked through an absolute path there.
2120
LOCAL_BIN_DIR = Path.home() / ".local" / "bin"
2221
INSTALL_SSM_PLUGIN_BY_DEFAULT = {% endraw %}{% if is_child_of_copier_base_template is not defined and install_aws_ssm_port_forwarding_plugin is defined and install_aws_ssm_port_forwarding_plugin is sameas(true) %}True{% else %}False{% endif %}{% raw %}
2322
# A floor rather than an exact pin: GitHub's runner images ship their own build of the plugin and only
@@ -60,55 +59,60 @@ def pwsh_cmd(cmd: str) -> list[str]:
6059
return [pwsh, "-NoProfile", "-NonInteractive", "-Command", cmd]
6160

6261

63-
def install_task(*, is_windows: bool) -> None:
64-
"""Install the pinned Task release into `LOCAL_BIN_DIR`.
62+
def install_uv(uv_env: dict[str, str], *, is_windows: bool) -> None:
63+
"""Install the pinned uv release into `LOCAL_BIN_DIR`.
6564

66-
Deliberately not `npm install -g @go-task/cli`: that writes to the global prefix of whichever
67-
node is on PATH, and in CI that is the pnpm-managed node installed by `pnpm/setup`, whose prefix
68-
bin directory is not the one on PATH. The package installs successfully and the linked
69-
executable is then unreachable, so `task` fails with exit status 127.
65+
Runs regardless of `--no-python`, because uv is also how Task is installed, and every job needs
66+
the task runner even when it has no Python environments to set up.
7067

71-
Verification goes through the absolute path because this process resolved PATH before the
72-
install; `GITHUB_PATH` is appended so later steps in the same CI job can invoke `task` by name.
68+
`uv_env` is mutated on Windows rather than copied: `LOCAL_BIN_DIR` is not on the runner's PATH
69+
there, and every later uv invocation needs it in front.
7370
"""
74-
LOCAL_BIN_DIR.mkdir(parents=True, exist_ok=True)
7571
if is_windows:
76-
if platform.machine().lower() == "arm64":
77-
windows_arch = "arm64"
78-
else:
79-
windows_arch = "amd64"
80-
archive_url = (
81-
f"https://github.com/go-task/task/releases/download/v{TASK_VERSION}/task_windows_{windows_arch}.zip"
82-
)
83-
# The bin directory is handed over as an environment variable rather than interpolated into the
84-
# script text: PowerShell single quotes take no escapes, so an apostrophe in the home directory
85-
# would terminate the string early. A variable reference is one argument whatever it contains.
86-
task_env = dict(os.environ)
87-
task_env.update({"TASK_BIN_DIR": str(LOCAL_BIN_DIR)})
88-
powershell_statements = [
89-
"$ErrorActionPreference = 'Stop'",
90-
"$archive = Join-Path $env:TEMP 'task-release.zip'",
91-
f"Invoke-WebRequest -UseBasicParsing -Uri '{archive_url}' -OutFile $archive",
92-
"Expand-Archive -Path $archive -DestinationPath $env:TASK_BIN_DIR -Force",
93-
"Remove-Item $archive",
94-
]
72+
uv_env.update({"PATH": rf"{LOCAL_BIN_DIR};{uv_env['PATH']}"})
9573
_ = subprocess.run( # noqa: S603 # this is all our own input
96-
pwsh_cmd("; ".join(powershell_statements)),
74+
pwsh_cmd(f"irm https://astral.sh/uv/{UV_VERSION}/install.ps1 | iex"),
9775
check=True,
98-
env=task_env,
76+
env=uv_env,
9977
timeout=DOWNLOAD_TIMEOUT_SECONDS,
10078
)
101-
task_path = LOCAL_BIN_DIR / "task.exe"
10279
else:
103-
# The installer resolves the pinned tag against the published checksums, so the archive is verified.
104-
# The bin directory is shell-quoted because a space or apostrophe in the home directory would
105-
# otherwise split it into multiple arguments, or unbalance the quoting outright.
10680
_ = subprocess.run( # noqa: S602 # we need to set shell to true to use the pipe operator, and this is all our own input
107-
f"curl -fsSL --connect-timeout 20 --max-time 40 --retry 3 --retry-delay 5 --retry-connrefused --proto '=https' https://taskfile.dev/install.sh | sh -s -- -b {shlex.quote(str(LOCAL_BIN_DIR))} v{TASK_VERSION}",
81+
f"curl -fsSL --connect-timeout 20 --max-time 40 --retry 3 --retry-delay 5 --retry-connrefused --proto '=https' https://astral.sh/uv/{UV_VERSION}/install.sh | sh",
10882
check=True,
10983
shell=True,
84+
env=uv_env,
11085
timeout=DOWNLOAD_TIMEOUT_SECONDS,
11186
)
87+
# TODO: add uv autocompletion to the shell https://docs.astral.sh/uv/getting-started/installation/#shell-autocompletion
88+
89+
90+
def install_task(uv_path: str, uv_env: dict[str, str], *, is_windows: bool) -> None:
91+
"""Install the pinned Task release into `LOCAL_BIN_DIR` as a uv tool.
92+
93+
`go-task-bin` repackages the upstream release archives as one wheel per platform, so a single uv
94+
invocation covers Windows, macOS and Linux on both x86_64 and arm64. Task publishes nothing to
95+
PyPI itself, so this is knowingly a third-party distribution: the version is pinned and uv
96+
records the wheel hash in the tool receipt it writes.
97+
98+
Deliberately not `npm install -g @go-task/cli`: that writes to the global prefix of whichever
99+
node is on PATH, and in CI that is the pnpm-managed node installed by `pnpm/setup`, whose prefix
100+
bin directory is not the one on PATH. The package installs successfully and the linked
101+
executable is then unreachable, so `task` fails with exit status 127.
102+
103+
Verification goes through the absolute path because this process resolved PATH before the
104+
install; `GITHUB_PATH` is appended so later steps in the same CI job can invoke `task` by name.
105+
"""
106+
LOCAL_BIN_DIR.mkdir(parents=True, exist_ok=True)
107+
_ = subprocess.run( # noqa: S603 # this is all our own input
108+
[uv_path, "tool", "install", f"go-task-bin=={TASK_VERSION}"],
109+
check=True,
110+
env=uv_env,
111+
timeout=DOWNLOAD_TIMEOUT_SECONDS,
112+
)
113+
if is_windows:
114+
task_path = LOCAL_BIN_DIR / "task.exe"
115+
else:
112116
task_path = LOCAL_BIN_DIR / "task"
113117
_ = subprocess.run([str(task_path), "--version"], check=True) # noqa: S603 # this is all our own input
114118
if "GITHUB_PATH" in os.environ:
@@ -241,24 +245,8 @@ def main():
241245
uv_path = str(LOCAL_BIN_DIR / "uv")
242246
else:
243247
uv_path = "uv"
248+
install_uv(uv_env, is_windows=is_windows)
244249
if not args.no_python:
245-
if is_windows:
246-
uv_env.update({"PATH": rf"{LOCAL_BIN_DIR};{uv_env['PATH']}"})
247-
_ = subprocess.run( # noqa: S603 # this is all our own input
248-
pwsh_cmd(f"irm https://astral.sh/uv/{UV_VERSION}/install.ps1 | iex"),
249-
check=True,
250-
env=uv_env,
251-
timeout=DOWNLOAD_TIMEOUT_SECONDS,
252-
)
253-
else:
254-
_ = subprocess.run( # noqa: S602 # we need to set shell to true to use the pipe operator, and this is all our own input
255-
f"curl -fsSL --connect-timeout 20 --max-time 40 --retry 3 --retry-delay 5 --retry-connrefused --proto '=https' https://astral.sh/uv/{UV_VERSION}/install.sh | sh",
256-
check=True,
257-
shell=True,
258-
env=uv_env,
259-
timeout=DOWNLOAD_TIMEOUT_SECONDS,
260-
)
261-
# TODO: add uv autocompletion to the shell https://docs.astral.sh/uv/getting-started/installation/#shell-autocompletion
262250
_ = subprocess.run( # noqa: S603 # this is all our own input
263251
[
264252
uv_path,
@@ -283,19 +271,19 @@ def main():
283271
env=uv_env,
284272
timeout=DOWNLOAD_TIMEOUT_SECONDS,
285273
)
286-
_ = subprocess.run( # noqa: S603 # this is all our own input
287-
[
288-
uv_path,
289-
"tool",
290-
"list",
291-
],
292-
check=True,
293-
env=uv_env,
294-
)
295274
if not args.no_node:
296275
run_node_cmds(["npm -v", f"npm install -g pnpm@{PNPM_VERSION}", "pnpm -v"], is_windows=is_windows)
297276
# Task is installed outside the --no-node branch because CI always passes --no-node (pnpm/setup handles pnpm there), and every job still needs the task runner
298-
install_task(is_windows=is_windows)
277+
install_task(uv_path, uv_env, is_windows=is_windows)
278+
_ = subprocess.run( # noqa: S603 # this is all our own input
279+
[
280+
uv_path,
281+
"tool",
282+
"list",
283+
],
284+
check=True,
285+
env=uv_env,
286+
)
299287
if INSTALL_SSM_PLUGIN_BY_DEFAULT and not args.skip_installing_ssm_plugin:
300288
ensure_ssm_plugin(is_windows=is_windows)
301289

0 commit comments

Comments
 (0)