From 2095da91663c9cb9e37e37bdac4f31ba0abe573a Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 21 Sep 2026 00:57:13 +0000 Subject: [PATCH 1/3] Copier update: Task runner (v0.0.156 -> v0.1.5) Pull in the latest base template changes: - Task runner shim (Taskfile.yaml + .config/taskfiles/) with copier-update, copier-update-from-ref and prune-copier-rejects tasks, mirrored into template/ - Rename .claude/settings/permissions/write.jsonc to edit.jsonc - Dependency and GitHub Action version bumps in extensions/context.py, with pyproject.toml and uv.lock regenerated to match - CI workflow-summary gate now iterates the needs context via jq rather than listing each job by hand Conflicts resolved: - extensions/context.py: re-add pyright_version, which failed to re-apply after the surrounding version pins moved upstream. template/pyproject.toml.jinja consumes it, so without it the generated pyproject renders an empty specifier. - template/.github/workflows/ci.yaml.jinja: drop the local per-job success_pattern checks, now covered by the upstream jq loop over needs. - template/README.md.jinja: the update overwrote the jinja template with the base template's literal README. Restored and hand-merged the two real upstream changes (Task paragraph, task copier-update). Also mark template/scripts/delete_false_positive_rej.py executable. The new prune-copier-rejects task invokes it as ./scripts/..., so generated repos would have failed with exit 127. Co-Authored-By: Claude Opus 5 (1M context) --- .claude/.beads/config.yaml | 5 - .claude/commands/add-command.md | 2 +- .claude/commands/commit.md | 2 +- .claude/commands/create-adr.md | 2 +- .claude/commands/create-issues.md | 14 +- .claude/commands/gap.md | 2 +- .claude/commands/green.md | 2 +- .claude/commands/issue.md | 8 +- .claude/commands/polish.md | 2 +- .claude/commands/red.md | 2 +- .claude/commands/refactor.md | 2 +- .claude/commands/research.md | 2 +- .claude/commands/simplify.md | 2 +- .claude/commands/spike.md | 2 +- .claude/commands/summarize.md | 4 +- .claude/commands/tdd-review.md | 2 +- .claude/commands/tdd.md | 2 +- .claude/settings/models.jsonc | 1 + .claude/settings/permissions/bash.jsonc | 14 +- .../permissions/{write.jsonc => edit.jsonc} | 7 +- .claude/skills/address-pr-comments/SKILL.md | 13 +- .config/.copier-answers.yml | 2 +- .config/.copier-managed-files.json | 7 +- .config/ruff-non-src.toml | 5 + .config/ruff.toml | 3 + .config/taskfiles/copier-template.yaml | 35 +++++ .devcontainer/devcontainer.json | 14 +- .devcontainer/docker-compose.yml | 8 +- .devcontainer/install-ci-tooling.py | 127 +++++++++++++----- .devcontainer/on-create-command.sh | 2 +- .devcontainer/post-start-command.sh | 12 +- .github/actions/install_deps/action.yml | 41 +++--- .../build-docker-image.yaml | 6 +- .github/workflows/ci.yaml | 18 +-- .pre-commit-config.yaml | 16 +++ AGENTS.md | 34 +++-- README.md | 5 +- Taskfile.yaml | 16 +++ extensions/context.py | 68 +++++----- pyproject.toml | 8 +- src/copier_tasks/copier_provenance.py | 13 +- template/.claude/.beads/config.yaml | 5 - template/.claude/commands/add-command.md | 2 +- template/.claude/commands/commit.md | 2 +- template/.claude/commands/create-adr.md | 2 +- template/.claude/commands/create-issues.md | 14 +- template/.claude/commands/gap.md | 2 +- template/.claude/commands/green.md | 2 +- template/.claude/commands/issue.md | 8 +- template/.claude/commands/polish.md | 2 +- template/.claude/commands/red.md | 2 +- template/.claude/commands/refactor.md | 2 +- template/.claude/commands/research.md | 2 +- template/.claude/commands/simplify.md | 2 +- template/.claude/commands/spike.md | 2 +- template/.claude/commands/summarize.md | 4 +- template/.claude/commands/tdd-review.md | 2 +- template/.claude/commands/tdd.md | 2 +- template/.claude/settings/models.jsonc | 1 + .../.claude/settings/permissions/bash.jsonc | 14 +- .../permissions/{write.jsonc => edit.jsonc} | 7 +- .../skills/address-pr-comments/SKILL.md | 13 +- template/.config/ruff-non-src.toml | 5 + template/.config/ruff.toml | 3 + .../.config/taskfiles/copier-template.yaml | 35 +++++ .../.devcontainer/devcontainer.json.jinja | 10 +- .../.devcontainer/docker-compose.yml.jinja | 10 +- .../.devcontainer/install-ci-tooling.py.jinja | 121 +++++++++++++---- .../.devcontainer/on-create-command.sh.jinja | 2 +- .../.devcontainer/post-start-command.sh.jinja | 12 +- .../.github/actions/install_deps/action.yml | 41 +++--- template/.github/workflows/ci.yaml.jinja | 18 ++- template/.pre-commit-config.yaml | 16 +++ template/AGENTS.md | 34 +++-- template/README.md.jinja | 6 +- template/Taskfile.yaml.jinja | 9 ++ template/scripts/delete_false_positive_rej.py | 0 tests/unit/copier_tasks/helpers.py | 3 + .../copier_tasks/test_copier_provenance.py | 42 ++++++ uv.lock | 52 +++---- 80 files changed, 728 insertions(+), 315 deletions(-) rename .claude/settings/permissions/{write.jsonc => edit.jsonc} (63%) create mode 100644 .config/taskfiles/copier-template.yaml create mode 100644 Taskfile.yaml rename template/.claude/settings/permissions/{write.jsonc => edit.jsonc} (63%) create mode 100644 template/.config/taskfiles/copier-template.yaml create mode 100644 template/Taskfile.yaml.jinja mode change 100644 => 100755 template/scripts/delete_false_positive_rej.py diff --git a/.claude/.beads/config.yaml b/.claude/.beads/config.yaml index 46b9e82e..5d0ceecc 100644 --- a/.claude/.beads/config.yaml +++ b/.claude/.beads/config.yaml @@ -26,11 +26,6 @@ # Default actor for audit trails (overridden by BD_ACTOR or --actor) # actor: "" -# Export events (audit trail) to .beads/events.jsonl on each flush/sync -# When enabled, new events are appended incrementally using a high-water mark. -# Use 'bd export --events' to trigger manually regardless of this setting. -# events-export: false - # Multi-repo configuration (experimental - bd-307) # Allows hydrating from multiple repositories and routing writes to the correct JSONL # repos: diff --git a/.claude/commands/add-command.md b/.claude/commands/add-command.md index ceb45a2d..23be23eb 100644 --- a/.claude/commands/add-command.md +++ b/.claude/commands/add-command.md @@ -11,7 +11,7 @@ argument-hint: - Write natural, descriptive code without meta-commentary about the development process - The code should speak for itself - TDD is the process, not the product -Beads is available for task tracking. Use `mcp__beads__*` tools to manage issues (the user interacts via `bd` commands). +Beads is available for task tracking. Use `bd` CLI commands to manage issues. ## Plan File Restriction diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md index e40205e9..2ac4ec53 100644 --- a/.claude/commands/commit.md +++ b/.claude/commands/commit.md @@ -11,7 +11,7 @@ argument-hint: [optional-commit-description] - Write natural, descriptive code without meta-commentary about the development process - The code should speak for itself - TDD is the process, not the product -Beads is available for task tracking. Use `mcp__beads__*` tools to manage issues (the user interacts via `bd` commands). +Beads is available for task tracking. Use `bd` CLI commands to manage issues. ## Plan File Restriction diff --git a/.claude/commands/create-adr.md b/.claude/commands/create-adr.md index 68bab708..4b79f296 100644 --- a/.claude/commands/create-adr.md +++ b/.claude/commands/create-adr.md @@ -17,7 +17,7 @@ Create a new ADR to document an architectural decision. ADRs capture the "why" b - Write natural, descriptive code without meta-commentary about the development process - The code should speak for itself - TDD is the process, not the product -Beads is available for task tracking. Use `mcp__beads__*` tools to manage issues (the user interacts via `bd` commands). +Beads is available for task tracking. Use `bd` CLI commands to manage issues. ## Plan File Restriction diff --git a/.claude/commands/create-issues.md b/.claude/commands/create-issues.md index 7c1ec62d..ae37f027 100644 --- a/.claude/commands/create-issues.md +++ b/.claude/commands/create-issues.md @@ -15,7 +15,7 @@ Create structured implementation plan that bridges product thinking (PRD) with t - Write natural, descriptive code without meta-commentary about the development process - The code should speak for itself - TDD is the process, not the product -Beads is available for task tracking. Use `mcp__beads__*` tools to manage issues (the user interacts via `bd` commands). +Beads is available for task tracking. Use `bd` CLI commands to manage issues. ## Plan File Restriction @@ -113,7 +113,7 @@ For each task, create a bd issue with: ```bash bd create "Task title" \ - --type [feature|bug|task|chore] \ + --type [feature|bug|task|epic|chore|decision] \ --priority [1-3] \ --description "Context and what needs to be built" \ --design "Technical approach, architecture notes" \ @@ -149,8 +149,10 @@ bd create "Task title" \ **Dependencies**: Link related issues +The first argument is the issue that becomes blocked; the second is the blocker it waits on. + ```bash -bd dep add ISSUE-123 ISSUE-456 --type blocks +bd dep add --type blocks ``` ### Validation @@ -179,20 +181,20 @@ After creating issues, verify: ### Beads Integration -Use Beads MCP to: +Use the `bd` CLI to: - Track work with `bd ready` to find next task - Create issues with `bd create "description"` - Track dependencies with `bd dep add` -See for more information. +See for more information. ## Integration with Other Commands - **Before /create-issues**: Use `/spike` if you need technical exploration first - **After /create-issues**: Use `/red` to start TDD on first task - **During work**: Use `bd update` to add notes/findings back to issues -- **When stuck**: Check `bd show ISSUE-ID` to review acceptance criteria +- **When stuck**: Check `bd show ` to review acceptance criteria -## Issue Tracking with bd (beads) +## Work Tracking with bd (beads) -**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. +**IMPORTANT**: Use **bd (beads)** to break down and track your own work while implementing a ticket. Within a working session it is the only place to track that breakdown — do NOT use markdown TODOs, task lists, or other scratch tracking methods. + +bd is not the team's ticket tracker. It is a local tool for a developer and an AI agent working together: `bd init --stealth` adds `.beads/` to `.git/info/exclude`, so neither the database nor its JSONL export is committed, and nothing recorded in bd is visible to reviewers, teammates, or CI. The ticket a session is implementing lives in whatever system the team owning the repository uses — GitHub Issues, Jira, Linear, or something else. ### Why bd? -- Dependency-aware: Track blockers and relationships between issues -- Git-friendly: Auto-syncs to JSONL for version control +- Dependency-aware: Track blockers and relationships between the pieces of work in a session - Agent-optimized: JSON output, ready work detection, discovered-from links -- Prevents duplicate tracking systems and confusion +- Keeps session breakdown out of the team's ticket tracker, and out of the codebase ### Quick Start @@ -115,20 +120,20 @@ bd ready --json ```bash bd create "Issue title" --description="Detailed context" -t bug|feature|task -p 0-4 --json -bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from:bd-123 --json +bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from: --json ``` **Claim and update:** ```bash -bd update bd-42 --status in_progress --json -bd update bd-42 --priority 1 --json +bd update --status in_progress --json +bd update --priority 1 --json ``` **Complete work:** ```bash -bd close bd-42 --reason "Completed" --json +bd close --reason "Completed" --json ``` **Creating human readable file:** @@ -145,6 +150,7 @@ bd export -o [relative path to repository root]/.claude/.beads/issues-dump.jsonl - `task` - Work item (tests, docs, refactoring) - `epic` - Large feature with subtasks - `chore` - Maintenance (dependencies, tooling) +- `decision` - An architectural decision (aliases: `dec`, `adr`) ### Priorities @@ -166,15 +172,15 @@ bd export -o [relative path to repository root]/.claude/.beads/issues-dump.jsonl ### Important Rules -- ✅ Use bd for ALL task tracking +- ✅ Use bd for ALL tracking of your own work within a session - ✅ Always use `--json` flag for programmatic use - ✅ Link discovered work with `discovered-from` dependencies - ✅ Check `bd ready` before asking "what should I work on?" -- ❌ Do NOT create markdown TODO lists -- ❌ Do NOT use external issue trackers -- ❌ Do NOT duplicate tracking systems +- ❌ Do NOT create markdown TODO lists to track session work +- ❌ Do NOT stand up a second scratch tracking system alongside bd +- ❌ Do NOT propose bd for work that needs tracking beyond the session — a bd issue is invisible to everyone else. Suggest a code comment, or a ticket in the team's own tracker, and do not assume which tracker that is -For more details, see README.md and docs/QUICKSTART.md. +For more details, run `bd quickstart` or see . # Copier-Managed Files diff --git a/README.md b/README.md index afc47eba..2b408e3e 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,11 @@ To create a new repository using this template: # Development +Multi-step workflows are defined as [Task](https://taskfile.dev) tasks. Run `task --list` to see them; the definitions live in `.config/taskfiles/`, and `Taskfile.yaml` in the repo root is only a shim that includes them. + ## Updating from the template -This repository uses a copier template. To pull in the latest updates from the template, use the command: -`copier update --answers-file .config/.copier-answers.yml --trust --conflict rej --defaults` +This repository uses a copier template. To pull in the latest updates from the template, run `task copier-update` -## Issue Tracking with bd (beads) +## Work Tracking with bd (beads) -**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. +**IMPORTANT**: Use **bd (beads)** to break down and track your own work while implementing a ticket. Within a working session it is the only place to track that breakdown — do NOT use markdown TODOs, task lists, or other scratch tracking methods. + +bd is not the team's ticket tracker. It is a local tool for a developer and an AI agent working together: `bd init --stealth` adds `.beads/` to `.git/info/exclude`, so neither the database nor its JSONL export is committed, and nothing recorded in bd is visible to reviewers, teammates, or CI. The ticket a session is implementing lives in whatever system the team owning the repository uses — GitHub Issues, Jira, Linear, or something else. ### Why bd? -- Dependency-aware: Track blockers and relationships between issues -- Git-friendly: Auto-syncs to JSONL for version control +- Dependency-aware: Track blockers and relationships between the pieces of work in a session - Agent-optimized: JSON output, ready work detection, discovered-from links -- Prevents duplicate tracking systems and confusion +- Keeps session breakdown out of the team's ticket tracker, and out of the codebase ### Quick Start @@ -115,20 +120,20 @@ bd ready --json ```bash bd create "Issue title" --description="Detailed context" -t bug|feature|task -p 0-4 --json -bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from:bd-123 --json +bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from: --json ``` **Claim and update:** ```bash -bd update bd-42 --status in_progress --json -bd update bd-42 --priority 1 --json +bd update --status in_progress --json +bd update --priority 1 --json ``` **Complete work:** ```bash -bd close bd-42 --reason "Completed" --json +bd close --reason "Completed" --json ``` **Creating human readable file:** @@ -145,6 +150,7 @@ bd export -o [relative path to repository root]/.claude/.beads/issues-dump.jsonl - `task` - Work item (tests, docs, refactoring) - `epic` - Large feature with subtasks - `chore` - Maintenance (dependencies, tooling) +- `decision` - An architectural decision (aliases: `dec`, `adr`) ### Priorities @@ -166,15 +172,15 @@ bd export -o [relative path to repository root]/.claude/.beads/issues-dump.jsonl ### Important Rules -- ✅ Use bd for ALL task tracking +- ✅ Use bd for ALL tracking of your own work within a session - ✅ Always use `--json` flag for programmatic use - ✅ Link discovered work with `discovered-from` dependencies - ✅ Check `bd ready` before asking "what should I work on?" -- ❌ Do NOT create markdown TODO lists -- ❌ Do NOT use external issue trackers -- ❌ Do NOT duplicate tracking systems +- ❌ Do NOT create markdown TODO lists to track session work +- ❌ Do NOT stand up a second scratch tracking system alongside bd +- ❌ Do NOT propose bd for work that needs tracking beyond the session — a bd issue is invisible to everyone else. Suggest a code comment, or a ticket in the team's own tracker, and do not assume which tracker that is -For more details, see README.md and docs/QUICKSTART.md. +For more details, run `bd quickstart` or see . # Copier-Managed Files diff --git a/template/README.md.jinja b/template/README.md.jinja index 21a3ded1..13938c53 100644 --- a/template/README.md.jinja +++ b/template/README.md.jinja @@ -23,6 +23,9 @@ Documentation is hosted on [ReadTheDocs](https://{% endraw %}{{ package_name }}{% raw %}.readthedocs.io/en/latest/?badge=latest). # Development + +Multi-step workflows are defined as [Task](https://taskfile.dev) tasks. Run `task --list` to see them; the definitions live in `.config/taskfiles/`, and `Taskfile.yaml` in the repo root is only a shim that includes them. + This project has a dev container. If you already have VS Code and Docker installed, you can click the badge above or [here](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url={% endraw %}{{ full_repo_url }}{% raw %}) to get started. Clicking these links will cause VS Code to automatically install the Dev Containers extension if needed, clone the source code into a container volume, and spin up a dev container for use. {% endraw %}{% if not deploy_as_executable %}{% raw %}To publish a new version of the repository, you can run the `Publish` workflow manually and publish to the staging registry from any branch, and you can check the 'Publish to Primary' option when on `main` to publish to the primary registry and create a git tag. @@ -37,5 +40,4 @@ This project has a dev container. If you already have VS Code and Docker install ## Updating from the template -This repository uses a copier template. To pull in the latest updates from the template, use the command: -`copier update --answers-file .config/.copier-answers.yml --trust --conflict rej --defaults`{% endraw %} +This repository uses a copier template. To pull in the latest updates from the template, run `task copier-update`{% endraw %} diff --git a/template/Taskfile.yaml.jinja b/template/Taskfile.yaml.jinja new file mode 100644 index 00000000..acf1ccfb --- /dev/null +++ b/template/Taskfile.yaml.jinja @@ -0,0 +1,9 @@ +{% raw %}# Task definitions live in .config/taskfiles/ next to the other tool configs so the repo root stays uncluttered. +# This shim exists because Task cannot be pointed at a config directory: auto-discovery covers only Taskfile.yml, taskfile.yml, Taskfile.yaml, taskfile.yaml and their .dist variants in the current directory walking up through parents, no environment variable sets the Taskfile path (https://github.com/go-task/task/issues/1721 requested one and was closed as a duplicate on 2025-12-10 with no linked replacement and no implementation), and requiring -t/--taskfile on every call would break both the ergonomics and the Claude Code bash permission matcher. +# Every include is flattened so that no task name contains a colon: Task's ':' namespace separator is not configurable and Claude Code cannot match a ':*' permission pattern (see .claude/settings/permissions/bash.jsonc). Flattened names share one global namespace, so a collision between two included files is a hard error rather than a silent shadow, and names are prefixed by hand (build-frontend) instead of namespaced (frontend:build). +version: '3' + +includes: + copier: + taskfile: ./.config/taskfiles/copier-template.yaml + flatten: true{% endraw %} diff --git a/template/scripts/delete_false_positive_rej.py b/template/scripts/delete_false_positive_rej.py old mode 100644 new mode 100755 diff --git a/tests/unit/copier_tasks/helpers.py b/tests/unit/copier_tasks/helpers.py index 2c6d7701..4b3da80d 100644 --- a/tests/unit/copier_tasks/helpers.py +++ b/tests/unit/copier_tasks/helpers.py @@ -19,6 +19,8 @@ def run_copier_task( script_path: Path, *args: str, env: dict[str, str] | None = None, + # bounded so a runaway traversal fails the test rather than wedging the runner + timeout: float = 60, ) -> subprocess.CompletedProcess[str]: return subprocess.run( # noqa: S603 -- these are our own scripts [sys.executable, str(script_path), *args], @@ -26,4 +28,5 @@ def run_copier_task( capture_output=True, text=True, env=env, + timeout=timeout, ) diff --git a/tests/unit/copier_tasks/test_copier_provenance.py b/tests/unit/copier_tasks/test_copier_provenance.py index 639bd879..9011a011 100644 --- a/tests/unit/copier_tasks/test_copier_provenance.py +++ b/tests/unit/copier_tasks/test_copier_provenance.py @@ -184,6 +184,48 @@ def test_jinja_if_check_directory_matched(self, tmp_path: Path) -> None: assert content.startswith(expected_hash_comment) +class TestWhenTaskRunAgainstDestination: + def test_Given_symlink_cycle__Then_managed_file_stamped(self, tmp_path: Path) -> None: + # Two links back to the root rather than one: a single link is bounded by the kernel's ELOOP + # limit, so it would terminate on its own and catch no regression. + template_dir = tmp_path / "template" + template_dir.mkdir() + (template_dir / "README.md.jinja-base").touch() + + dst_dir = tmp_path / "destination" + node_modules = dst_dir / "node_modules" + node_modules.mkdir(parents=True) + (node_modules / "pkg-a").symlink_to(dst_dir, target_is_directory=True) + (node_modules / "pkg-b").symlink_to(dst_dir, target_is_directory=True) + file_content = "some content\n" + _ = (dst_dir / "README.md").write_text(file_content, encoding="utf-8") + + _ = _run_script(src_template_dir=template_dir, dst_dir=dst_dir) + + content = (dst_dir / "README.md").read_text(encoding="utf-8") + assert content == file_content + "\n" + expected_markdown_comment + "\n" + + def test_Given_broken_symlink_at_managed_path__Then_path_skipped(self, tmp_path: Path) -> None: + template_dir = tmp_path / "template" + template_dir.mkdir() + (template_dir / "config.yaml.jinja-base").touch() + (template_dir / "keep.yaml.jinja-base").touch() + + dst_dir = tmp_path / "destination" + dst_dir.mkdir() + broken = dst_dir / "config.yaml" + broken.symlink_to(dst_dir / "does-not-exist.yaml") + _ = (dst_dir / "keep.yaml").write_text("key: value\n", encoding="utf-8") + assert broken.is_symlink() + + _ = _run_script(src_template_dir=template_dir, dst_dir=dst_dir) + + manifest = _read_manifest(dst_dir) + + assert (dst_dir / "keep.yaml").read_text(encoding="utf-8").startswith(expected_hash_comment) + assert manifest["templates"][0]["managed_files"] == ["keep.yaml"] + + class TestFileExtensionComments: @pytest.mark.parametrize( ("filename", "expected_location", "expected_comment"), diff --git a/uv.lock b/uv.lock index e3d2960f..f10339af 100644 --- a/uv.lock +++ b/uv.lock @@ -22,7 +22,7 @@ wheels = [ [[package]] name = "copier" -version = "9.17.1" +version = "9.18.2" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "colorama" }, @@ -39,9 +39,9 @@ dependencies = [ { name = "pyyaml" }, { name = "questionary" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/65/11c8635a27ce06ef525946bb02b1d5271b0f11e5acf9c5ab6d242512598e/copier-9.17.1.tar.gz", hash = "sha256:778e85f1c2de86824a72ddf4a34140744921d6354450c427c4c8a475371b1bda", size = 647338, upload-time = "2026-08-04T09:15:04.826Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/60/2fe1777b27f489450ed8ac5c75390caf91a175d647be05b4284bf515b413/copier-9.18.2.tar.gz", hash = "sha256:0f97902fee79374551c9391fb1132f5a038079f3b497f4dad673295489fc032e", size = 669824, upload-time = "2026-09-07T13:14:38.435Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/bf/3adcb9b3091b36de729dad91c107179c8c7c51adb2b08c31177bb540bef1/copier-9.17.1-py3-none-any.whl", hash = "sha256:f6a48f296e2e536c635213e1000ea71099f9f58f3b464a60f2f0fd386e343628", size = 66364, upload-time = "2026-08-04T09:15:03.026Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2a/854d86eb28ced1c4ade3d8c83bdc7ba529ac5df6c80b5d5202a090244d7a/copier-9.18.2-py3-none-any.whl", hash = "sha256:ef0e1b09dece031b09085f2aa637bdc3d5952b98d96b986feaa45a8f3a00d2c6", size = 68113, upload-time = "2026-09-07T13:14:36.934Z" }, ] [[package]] @@ -60,13 +60,13 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "copier", specifier = "==9.17.1" }, + { name = "copier", specifier = "==9.18.2" }, { name = "copier-template-extensions", specifier = "==0.3.3" }, - { name = "faker", specifier = ">=40.36.0" }, - { name = "pyrefly", specifier = ">=1.2.0" }, + { name = "faker", specifier = ">=40.39.0" }, + { name = "pyrefly", specifier = ">=1.3.1" }, { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-cov", specifier = ">=7.1.0" }, - { name = "pytest-randomly", specifier = ">=4.1.0" }, + { name = "pytest-randomly", specifier = ">=5.0.0" }, ] [[package]] @@ -159,14 +159,14 @@ wheels = [ [[package]] name = "faker" -version = "40.36.0" +version = "40.39.0" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/98/d2/026af1e002bbc6df534d1f8262b18ec79a974f928e9290bfbfdfe7c7b2af/faker-40.36.0.tar.gz", hash = "sha256:754048c76c03afa7de83eee8f4bcee3cf668cbb7d995f54a4e9678db7f110308", size = 2025903, upload-time = "2026-07-24T21:11:33.088Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/62/fea935af7a312f073c99d470b05a7c96650ee8d9562ce82e8cb7ee4c1247/faker-40.39.0.tar.gz", hash = "sha256:52799ad96fcf92aab2fc96a9e03869bbf1a634300f7cd91c38dbfd51f639f6fa", size = 2029463, upload-time = "2026-09-14T16:50:17.939Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/9a/b947ed175ce9a0dcb070ccf3607f0ce8720cfb5ed1a36166a150b2acd5af/faker-40.36.0-py3-none-any.whl", hash = "sha256:82b9497d9cfe017048075bcf969298a74b1b6e39f5e4dad1211085d1133f7b62", size = 2062829, upload-time = "2026-07-24T21:11:31.37Z" }, + { url = "https://files.pythonhosted.org/packages/45/c8/901282279841ebb59216433be01aac825fb905e1a597de3b7b5ab8b0c43e/faker-40.39.0-py3-none-any.whl", hash = "sha256:c4c7ec2cddfaf602c5a8a2c960614946aa8c161250bd9d49a8846cd24d1ef028", size = 2066232, upload-time = "2026-09-14T16:50:16.061Z" }, ] [[package]] @@ -374,21 +374,21 @@ wheels = [ [[package]] name = "pyrefly" -version = "1.2.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple/" } -sdist = { url = "https://files.pythonhosted.org/packages/89/01/a86e9f24722b095c3f88e3616132b75a21b0df53804bdc6a45314dd4d93c/pyrefly-1.2.0.tar.gz", hash = "sha256:5485f960fc2481617068c918335c39ab1507ef90b6b5bd35bf57726e60e73185", size = 6243654, upload-time = "2026-08-01T02:56:27.592Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/7e/082cc1b392318bacfbc087732524f3e089e9e7fe45b7645c4649b6f1f1e9/pyrefly-1.3.1.tar.gz", hash = "sha256:b774f1dfac82867fc16fe2175d80a630e2a72a6835d5b7e492568d6bfb6b05e7", size = 6679145, upload-time = "2026-09-15T05:09:34.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/9d/3c0ef1d4843987b22f996ed381ec9cf5a3b1273e29804db276252e4c95eb/pyrefly-1.2.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7f46d983ac49ddd2b043694960a01dc6a19a5cfd8eec609d6bd9c42866f91b4e", size = 14026305, upload-time = "2026-08-01T02:56:02.611Z" }, - { url = "https://files.pythonhosted.org/packages/0a/06/03bbb78fbea54cdc65b626619f3597d5611aca4fdef11e72a4e8360e7e63/pyrefly-1.2.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:756f669b5555090f5c1a4fef30db1785fabe657764f7e4e6dc88994dfb8ca82d", size = 13463880, upload-time = "2026-08-01T02:56:04.93Z" }, - { url = "https://files.pythonhosted.org/packages/13/5a/7d8bc00a38e93bbc9c3e7bd14d305f7948717e667c9bcddeab9dd42fd255/pyrefly-1.2.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3465812ce5ef4781fb592edbf2724547296f0a3124be115d73c7e8b2401862d", size = 13907329, upload-time = "2026-08-01T02:56:07.104Z" }, - { url = "https://files.pythonhosted.org/packages/be/94/9e08b4bf799d0b8f36b55a2783c7ba5f51730cf0632a85a67b5b5ed876cd/pyrefly-1.2.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5de7b2ad2bba5c8055181681a84b74143eac2234a48ba5d1b7ed7e7a722b02bd", size = 15039020, upload-time = "2026-08-01T02:56:09.208Z" }, - { url = "https://files.pythonhosted.org/packages/5b/bd/bca5fd0c80f4daf8ee6903a29df9f3de1feb05ff0946b8f35ec8c5096b13/pyrefly-1.2.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25822ea9505f589ea8a725e4268b475132fb89e038fbf092e446510443ac142a", size = 14986199, upload-time = "2026-08-01T02:56:11.924Z" }, - { url = "https://files.pythonhosted.org/packages/97/f7/f07087f3d185ad2eced0c56cef89ca5474dfb4ff25f146cd50a861c97553/pyrefly-1.2.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90efe75e17491ef5d636e10469e9278d7d0256b3b4c5e1f4750069bf3ae0f5d1", size = 14393715, upload-time = "2026-08-01T02:56:14.143Z" }, - { url = "https://files.pythonhosted.org/packages/d3/70/0d142c320e284b9e3ce35e9b1e58b8ce2ee1f578f2a7234bc30e5022b94f/pyrefly-1.2.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:368aaf7eee4f511ddc0f8e564cf14e01ab2f10b0db9105c6d5b153bf498d07bf", size = 13933008, upload-time = "2026-08-01T02:56:16.525Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e8/e84f11b6e1f63fd453ad3654213b9a0f6f4de8cef6b58038eef2d0d5955d/pyrefly-1.2.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:d52d5da7bc65fb7675fbaa80eda879d4f8787c494f04cac21603330d3abbdbbe", size = 14431827, upload-time = "2026-08-01T02:56:18.645Z" }, - { url = "https://files.pythonhosted.org/packages/0f/06/810d31380f66c75e1c0779a408d3b16117b1b368b57894f6aa66bef21686/pyrefly-1.2.0-py3-none-win32.whl", hash = "sha256:8c90751de8506d938e8f802659c74cf35bd7a0036510ee6c634a38eebb280bfa", size = 13229447, upload-time = "2026-08-01T02:56:20.921Z" }, - { url = "https://files.pythonhosted.org/packages/ed/98/4dafa3c7a1caed2dc8cc708dde09ba27963c7736508f55b626fff3024113/pyrefly-1.2.0-py3-none-win_amd64.whl", hash = "sha256:8a8964c224ccc4882730130955815de21ff443c1ac3f0b90685b19bf63848170", size = 14087387, upload-time = "2026-08-01T02:56:23.188Z" }, - { url = "https://files.pythonhosted.org/packages/1b/1c/df3cb0a2e5591660ded7a1836cd2f29dc48c91adb1c0a3a700a96f6d09e1/pyrefly-1.2.0-py3-none-win_arm64.whl", hash = "sha256:3a90bb8df39dfbac74b1f3b2e9d7c526b8f80568884c3944d955023a73ebf61e", size = 13430873, upload-time = "2026-08-01T02:56:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/6b/bc/f09b5bd7eed796349bd93627aa2c3086df7ed0a114dafc3d2b2841599ee7/pyrefly-1.3.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a2a7dfed824fb066aa8a7c8d856c152b98e998fb84b954d5f892fb9834c0bd1", size = 15090117, upload-time = "2026-09-15T05:08:58.477Z" }, + { url = "https://files.pythonhosted.org/packages/78/24/a31e7de8e0f310c9f0753826d0df8b6a8ff86793941a6bd88ba24b735633/pyrefly-1.3.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37a1a1fb41e7190fe3964a877875dd809d6ee2887809e2c3d6e5f25a69805bfd", size = 14430773, upload-time = "2026-09-15T05:09:01.67Z" }, + { url = "https://files.pythonhosted.org/packages/75/e1/022352beb186f36d6213a95a975c42676ee418ff5e730629378d25564c5e/pyrefly-1.3.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45b7650bc651a5a784701f2fb4f579da9a421c4d1b5fd91f9048f4529e46093c", size = 14877762, upload-time = "2026-09-15T05:09:04.767Z" }, + { url = "https://files.pythonhosted.org/packages/19/2c/d62a1b445aad1fd46375b83393b8a67da53883aeeba73d269ce71b3e8fd1/pyrefly-1.3.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ad57f38bc73679c14a5f62995a2bd3bc43649b7270c858487e2eab78a6f95362", size = 16118225, upload-time = "2026-09-15T05:09:07.828Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/3c3fa8a13e3ce8d8322ffb3627255a41a52261f17793e1a24e9214f10857/pyrefly-1.3.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3506b1252e9f5d755d0693c5a46d46b85579d36534f6e22a41eb8e098d0dde90", size = 16051197, upload-time = "2026-09-15T05:09:10.81Z" }, + { url = "https://files.pythonhosted.org/packages/19/d1/84f81b9e32288b5d11c75e006f67e0f6b78b85da575673bd1a21524c8172/pyrefly-1.3.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d5a17c1ad655e5f93a6e8e0be90b8db28c6015fe07d6ed4e054891c9e14ef8", size = 15440062, upload-time = "2026-09-15T05:09:14.641Z" }, + { url = "https://files.pythonhosted.org/packages/46/3c/2aa0c5ad6a2957a728bb38cfab70d1e9cafad21b0b1c1d92df74a3cf22bd/pyrefly-1.3.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c61e9dcf888beb81ca0e0c60ea4442640fdcf2f1f65bfe3d9f5d89b85516eb03", size = 14898534, upload-time = "2026-09-15T05:09:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/03/5d/2974f249fb2e285f966485719d977c5a2248cc4866403af94d935775ff15/pyrefly-1.3.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a78eed1c53f37807f0e2997be8edbe833b91eb9915f2cb553a1bdcd599e7105c", size = 15477758, upload-time = "2026-09-15T05:09:20.964Z" }, + { url = "https://files.pythonhosted.org/packages/68/79/d31f8f0abf5b0610ec24183286ee8a1ee5b76c9aaaa91091399d7e1aaf60/pyrefly-1.3.1-py3-none-win32.whl", hash = "sha256:ea0d0349f1bc80fe8e00a7b3640e40db7486398b685f87b9bd5d2207063668f0", size = 14133005, upload-time = "2026-09-15T05:09:25.03Z" }, + { url = "https://files.pythonhosted.org/packages/3f/23/1859f47f536314755d80c4ec2a76d2698435ae8b73604bcd1dfa57ecb39b/pyrefly-1.3.1-py3-none-win_amd64.whl", hash = "sha256:4c24e7d99df9237916c33be23714219a7c8eaf22bad2ac0c3e1d45adcf7df8fb", size = 15066557, upload-time = "2026-09-15T05:09:28.265Z" }, + { url = "https://files.pythonhosted.org/packages/92/2a/73e3721921c2bb2c950a9f8c362ee5e2367556b5d4416eb4f8f88503e66f/pyrefly-1.3.1-py3-none-win_arm64.whl", hash = "sha256:1ed85e2b957a78e93353125d890ce3668ce450e49b74025a15c87b6e8ec7f3d1", size = 14348209, upload-time = "2026-09-15T05:09:31.422Z" }, ] [[package]] @@ -423,14 +423,14 @@ wheels = [ [[package]] name = "pytest-randomly" -version = "4.1.0" +version = "5.0.0" source = { registry = "https://pypi.org/simple/" } dependencies = [ { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/27/b3/36192dacc0f470ac2cc516f73e01739c9a48a8224f76beada4f85e1c8a89/pytest_randomly-4.1.0.tar.gz", hash = "sha256:47f1d9746c3bc3efabd53ae1ebfb8bb385cf3d4df4b505b6d58d9c97a3dfe70f", size = 14302, upload-time = "2026-04-20T13:01:51.831Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/3b/6a40e1b9d925651e601e056a97f60d8a1daeddeac03d5609be60cb4362ce/pytest_randomly-5.0.0.tar.gz", hash = "sha256:e9c575a5873ef168ddbe340ed9e97ce9edb4492ccc821e4b2ac6bb1f0ed515d2", size = 8542, upload-time = "2026-09-01T22:34:20.441Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/db/2df9a1fca597a273f957a559c20c2d95d629928384507b2afa43ba6909d1/pytest_randomly-4.1.0-py3-none-any.whl", hash = "sha256:f55e89e53367b090c0c053697d7f9d77595543d0e0516c93978b50c0f6b252f9", size = 8353, upload-time = "2026-04-20T13:01:50.382Z" }, + { url = "https://files.pythonhosted.org/packages/10/b4/47e939285caad9a623d021512912ac08dc92a467ad075d179f43729d2934/pytest_randomly-5.0.0-py3-none-any.whl", hash = "sha256:8a0d4703115c0c25b38b6e129fc16b1947b9643ff26a41bc1d185d7e5a7689c1", size = 8920, upload-time = "2026-09-01T22:34:19.227Z" }, ] [[package]] From e5b866bdd54595a6a841b42ec19e383e92482220 Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 21 Sep 2026 01:05:20 +0000 Subject: [PATCH 2/3] bdump pyright --- extensions/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/context.py b/extensions/context.py index 036f5f7b..d69c4815 100644 --- a/extensions/context.py +++ b/extensions/context.py @@ -63,7 +63,7 @@ def hook( # noqa: PLR0915 # yes, this is a lot of statements, but it's all just context["pytest_reserial_version"] = ">=0.6.1" context["python_faker_version"] = ">=40.39.0" context["mutmut_version"] = ">=3.8.0" - context["pyright_version"] = ">=1.1.411" + context["pyright_version"] = ">=1.1.414" context["pyrefly_version"] = ">=1.3.1" context["vacuum_openapi_version"] = "0.30.0" From 8471f7d14e4e858a6bb7b03661e031bbac9bd46f Mon Sep 17 00:00:00 2001 From: Eli Fine Date: Mon, 21 Sep 2026 14:33:43 +0000 Subject: [PATCH 3/3] Copier update: SSM plugin install (v0.1.5 -> v0.1.6) - Refactor the SSM Session Manager plugin install out of main() into ensure_ssm_plugin/install_ssm_plugin/installed_ssm_plugin_version, and treat the pinned version as a floor (1.2.835.0) rather than an exact pin. GitHub's Windows runner images ship their own build of the plugin and only move it forward, so demanding an exact version asks the runner to downgrade, which its installer refuses with MSI error 1638. The installed version is now read back after the install rather than inferred from the exit code. - Ignore .devcontainer/devcontainer-lock.json - Bump the coderabbit and claude-code VS Code extensions Co-Authored-By: Claude Opus 5 (1M context) --- .config/.copier-answers.yml | 2 +- .devcontainer/devcontainer.json | 6 +- .devcontainer/install-ci-tooling.py | 167 ++++++++++++------ .gitignore | 4 + .../.devcontainer/devcontainer.json.jinja | 8 +- .../.devcontainer/install-ci-tooling.py.jinja | 167 ++++++++++++------ template/.gitignore | 4 + 7 files changed, 250 insertions(+), 108 deletions(-) diff --git a/.config/.copier-answers.yml b/.config/.copier-answers.yml index e2d34bd2..65e0be63 100644 --- a/.config/.copier-answers.yml +++ b/.config/.copier-answers.yml @@ -1,5 +1,5 @@ # Changes here will be overwritten by Copier -_commit: v0.1.5 +_commit: v0.1.6 _src_path: gh:LabAutomationAndScreening/copier-base-template.git copier_answers_directory: .config description: Copier template for creating Python libraries and executables diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d982ab94..980bb2b1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -22,10 +22,10 @@ "extensions": [ // basic tooling // "eamodio.gitlens@15.5.1", - "coderabbit.coderabbit-vscode@0.21.7", + "coderabbit.coderabbit-vscode@0.21.8", "ms-vscode.live-server@0.5.2025051301", "MS-vsliveshare.vsliveshare@1.0.5905", - "anthropic.claude-code@2.1.273", + "anthropic.claude-code@2.1.278", // Python "-ms-python.vscode-pylance", // pyrefly is the language server for this project; ms-python.python auto-installs Pylance via its extension pack, and Pylance then warns on every boot that it's disabled @@ -67,5 +67,5 @@ "initializeCommand": "sh .devcontainer/initialize-command.sh", "onCreateCommand": "sh .devcontainer/on-create-command.sh", "postStartCommand": "sh .devcontainer/post-start-command.sh" - // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 47e6d89e # spellchecker:disable-line + // Devcontainer context hash (do not manually edit this, it's managed by a pre-commit hook): 4d9b70d0 # spellchecker:disable-line } diff --git a/.devcontainer/install-ci-tooling.py b/.devcontainer/install-ci-tooling.py index 0ed4d17e..9e7c2502 100644 --- a/.devcontainer/install-ci-tooling.py +++ b/.devcontainer/install-ci-tooling.py @@ -27,6 +27,15 @@ # not on Windows, which is why uv is invoked through an absolute path there. LOCAL_BIN_DIR = Path.home() / ".local" / "bin" INSTALL_SSM_PLUGIN_BY_DEFAULT = False +# A floor rather than an exact pin: GitHub's runner images ship their own build of the plugin and only +# ever move it forward, so demanding an exact version means asking a Windows runner to downgrade, which +# its installer refuses outright with MSI error 1638. +SSM_PLUGIN_MINIMUM_VERSION = (1, 2, 835, 0) +SSM_PLUGIN_DOWNLOAD_VERSION = ".".join(str(part) for part in SSM_PLUGIN_MINIMUM_VERSION) +SSM_PLUGIN_EXECUTABLE = "session-manager-plugin" +# Where the Windows installer places the executable. Needed because a fresh install does not reach the +# PATH of the already-running process, so PATH alone cannot confirm the install landed. +SSM_PLUGIN_WINDOWS_PATH = Path(r"C:\Program Files\Amazon\SessionManagerPlugin\bin\session-manager-plugin.exe") parser = argparse.ArgumentParser(description="Install CI tooling for the repo") _ = parser.add_argument( "--no-python", @@ -123,6 +132,113 @@ def run_node_cmds(cmds: list[str], *, is_windows: bool) -> None: _ = subprocess.run(run_cmd, shell=True, check=True, timeout=DOWNLOAD_TIMEOUT_SECONDS) # noqa: S602 # we need shell=True for npm commands, and this is all our own input +def parse_version(raw: str) -> tuple[int, ...] | None: + stripped = raw.strip() + if not stripped: + return None + parts: list[int] = [] + for segment in stripped.split("."): + if not segment.isdigit(): + return None + parts.append(int(segment)) + return tuple(parts) + + +def resolve_ssm_plugin() -> str | None: + on_path = shutil.which(SSM_PLUGIN_EXECUTABLE) + if on_path is not None: + return on_path + if SSM_PLUGIN_WINDOWS_PATH.exists(): + return str(SSM_PLUGIN_WINDOWS_PATH) + return None + + +def installed_ssm_plugin_version() -> tuple[int, ...] | None: + executable = resolve_ssm_plugin() + if executable is None: + return None + try: + result = subprocess.run( # noqa: S603 # this is all our own input + [executable, "--version"], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError: + return None + return parse_version(result.stdout) + + +def install_ssm_plugin(*, is_windows: bool) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + if is_windows: + local_package_path = Path(tmp_dir) / "SessionManagerPluginSetup.exe" + # Based on https://docs.aws.amazon.com/systems-manager/latest/userguide/install-plugin-windows.html + _ = subprocess.run( # noqa: S603 # this is all our own input + [ # noqa: S607 # curl should always be on PATH + "curl", + f"https://s3.amazonaws.com/session-manager-downloads/plugin/{SSM_PLUGIN_DOWNLOAD_VERSION}/windows/SessionManagerPluginSetup.exe", + "-o", + f"{local_package_path}", + ], + check=True, + timeout=DOWNLOAD_TIMEOUT_SECONDS, + ) + _ = subprocess.run( # noqa: S603 # this is all our own input + [str(local_package_path), "/quiet"], + check=True, + ) + else: + local_package_path = Path(tmp_dir) / "session-manager-plugin.deb" + # Based on https://docs.aws.amazon.com/systems-manager/latest/userguide/install-plugin-debian-and-ubuntu.html + _ = subprocess.run( # noqa: S603 # this is all our own input + [ # noqa: S607 # curl should always be on PATH + "curl", + f"https://s3.amazonaws.com/session-manager-downloads/plugin/{SSM_PLUGIN_DOWNLOAD_VERSION}/ubuntu_64bit/session-manager-plugin.deb", + "-o", + f"{local_package_path}", + ], + check=True, + timeout=DOWNLOAD_TIMEOUT_SECONDS, + ) + _ = subprocess.run( # noqa: S603 # this is all our own input + [ # noqa: S607 # sudo should always be on PATH + "sudo", + "dpkg", + "-i", + str(local_package_path), + ], + check=True, + ) + + +def ensure_ssm_plugin(*, is_windows: bool) -> None: + installed = installed_ssm_plugin_version() + if installed is None: + print("SSM plugin not found, installing it") # noqa: T201 # we want the script to print to console for easy viewing + elif installed < SSM_PLUGIN_MINIMUM_VERSION: + print( # noqa: T201 # we want the script to print to console for easy viewing + f"SSM plugin {'.'.join(str(part) for part in installed)} is older than the required " + f"{SSM_PLUGIN_DOWNLOAD_VERSION}, upgrading it" + ) + else: + print( # noqa: T201 # we want the script to print to console for easy viewing + f"SSM Plugin Manager Version: {'.'.join(str(part) for part in installed)} " + f"(already at least {SSM_PLUGIN_DOWNLOAD_VERSION}, leaving it alone)" + ) + return + install_ssm_plugin(is_windows=is_windows) + # The installer exits zero without installing anything when the requested version is already + # present, so the version has to be read back rather than inferred from the exit code. + final = installed_ssm_plugin_version() + if final is None or final < SSM_PLUGIN_MINIMUM_VERSION: + raise RuntimeError( + f"The SSM plugin installer did not produce {SSM_PLUGIN_EXECUTABLE} " + f"version {SSM_PLUGIN_DOWNLOAD_VERSION} or newer, it left {final}" + ) + print(f"SSM Plugin Manager Version: {'.'.join(str(part) for part in final)}") # noqa: T201 # we want the script to print to console for easy viewing + + def main(): args = parser.parse_args(sys.argv[1:]) is_windows = platform.system() == "Windows" @@ -188,56 +304,7 @@ def main(): # 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 install_task(is_windows=is_windows) if INSTALL_SSM_PLUGIN_BY_DEFAULT and not args.skip_installing_ssm_plugin: - with tempfile.TemporaryDirectory() as tmp_dir: - if is_windows: - local_package_path = Path(tmp_dir) / "SessionManagerPluginSetup.exe" - # Based on https://docs.aws.amazon.com/systems-manager/latest/userguide/install-plugin-windows.html - # no specific reason for that version, just pinning it for best practice - _ = subprocess.run( # noqa: S603 # this is all our own input - [ # noqa: S607 # curl should always be on PATH - "curl", - "https://s3.amazonaws.com/session-manager-downloads/plugin/1.2.707.0/windows/SessionManagerPluginSetup.exe", - "-o", - f"{local_package_path}", - ], - check=True, - timeout=DOWNLOAD_TIMEOUT_SECONDS, - ) - _ = subprocess.run( # noqa: S603 # this is all our own input - [str(local_package_path), "/quiet"], - check=True, - ) - else: - local_package_path = Path(tmp_dir) / "session-manager-plugin.deb" - # Based on https://docs.aws.amazon.com/systems-manager/latest/userguide/install-plugin-debian-and-ubuntu.html - # no specific reason for that version, just pinning it for best practice - _ = subprocess.run( # noqa: S603 # this is all our own input - [ # noqa: S607 # curl should always be on PATH - "curl", - "https://s3.amazonaws.com/session-manager-downloads/plugin/1.2.707.0/ubuntu_64bit/session-manager-plugin.deb", - "-o", - f"{local_package_path}", - ], - check=True, - timeout=DOWNLOAD_TIMEOUT_SECONDS, - ) - _ = subprocess.run( # noqa: S603 # this is all our own input - [ # noqa: S607 # sudo should always be on PATH - "sudo", - "dpkg", - "-i", - str(local_package_path), - ], - check=True, - ) - print("SSM Plugin Manager Version: ") # noqa: T201 # we want the script to print to console for easy viewing - _ = subprocess.run( - [ # noqa: S607 # session-manager-plugin should be on PATH because we just installed it - "session-manager-plugin", - "--version", - ], - check=True, - ) + ensure_ssm_plugin(is_windows=is_windows) if __name__ == "__main__": diff --git a/.gitignore b/.gitignore index cc7c8b5b..4916f46e 100644 --- a/.gitignore +++ b/.gitignore @@ -123,4 +123,8 @@ dist # macOS dev cleanliness **/.DS_Store +# Devcontainer +# The devcontainer CLI writes this lock file to pin feature digests, but we are not using it yet +.devcontainer/devcontainer-lock.json + # Ignores specific to this repository diff --git a/template/.devcontainer/devcontainer.json.jinja b/template/.devcontainer/devcontainer.json.jinja index b2064313..7edbd22a 100644 --- a/template/.devcontainer/devcontainer.json.jinja +++ b/template/.devcontainer/devcontainer.json.jinja @@ -13,10 +13,10 @@ // https://github.com/devcontainers/features/blob/main/src/git-lfs/README.md "autoPull": false },{% endraw %}{% if is_child_of_copier_base_template is not defined %}{% raw %} - "ghcr.io/devcontainers/features/aws-cli:1.1.2": { + "ghcr.io/devcontainers/features/aws-cli:1.1.4": { // https://github.com/devcontainers/features/blob/main/src/aws-cli/devcontainer-feature.json // view latest version https://raw.githubusercontent.com/aws/aws-cli/v2/CHANGELOG.rst - "version": "2.34.34" + "version": "2.36.49" },{% endraw %}{% endif %}{% raw %}{% endraw %}{% if is_child_of_copier_base_template is not defined and template_uses_typescript is defined and template_uses_typescript is sameas(true) %}{% raw %} "ghcr.io/devcontainers/features/node:2.1.0": { // https://github.com/devcontainers/features/blob/main/src/node/devcontainer-feature.json @@ -34,10 +34,10 @@ "-AmazonWebServices.aws-toolkit-vscode", // the AWS CLI feature installs this automatically, but it's causing problems in VS Code{% endraw %}{% endif %}{% raw %} // basic tooling // "eamodio.gitlens@15.5.1", - "coderabbit.coderabbit-vscode@0.21.7", + "coderabbit.coderabbit-vscode@0.21.8", "ms-vscode.live-server@0.5.2025051301", "MS-vsliveshare.vsliveshare@1.0.5905",{% endraw %}{% if install_claude_cli %}{% raw %} - "anthropic.claude-code@2.1.273",{% endraw %}{% endif %}{% raw %}{% endraw %}{% if (is_child_of_copier_base_template is defined and is_child_of_copier_base_template is sameas(true)) or (is_child_of_copier_base_template is not defined and template_uses_python is defined and template_uses_python is sameas(true)) %}{% raw %} + "anthropic.claude-code@2.1.278",{% endraw %}{% endif %}{% raw %}{% endraw %}{% if (is_child_of_copier_base_template is defined and is_child_of_copier_base_template is sameas(true)) or (is_child_of_copier_base_template is not defined and template_uses_python is defined and template_uses_python is sameas(true)) %}{% raw %} // Python "-ms-python.vscode-pylance", // pyrefly is the language server for this project; ms-python.python auto-installs Pylance via its extension pack, and Pylance then warns on every boot that it's disabled diff --git a/template/.devcontainer/install-ci-tooling.py.jinja b/template/.devcontainer/install-ci-tooling.py.jinja index a157c247..15deaef1 100644 --- a/template/.devcontainer/install-ci-tooling.py.jinja +++ b/template/.devcontainer/install-ci-tooling.py.jinja @@ -20,6 +20,15 @@ DOWNLOAD_TIMEOUT_SECONDS = 90 # not on Windows, which is why uv is invoked through an absolute path there. LOCAL_BIN_DIR = Path.home() / ".local" / "bin" 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 %} +# A floor rather than an exact pin: GitHub's runner images ship their own build of the plugin and only +# ever move it forward, so demanding an exact version means asking a Windows runner to downgrade, which +# its installer refuses outright with MSI error 1638. +SSM_PLUGIN_MINIMUM_VERSION = (1, 2, 835, 0) +SSM_PLUGIN_DOWNLOAD_VERSION = ".".join(str(part) for part in SSM_PLUGIN_MINIMUM_VERSION) +SSM_PLUGIN_EXECUTABLE = "session-manager-plugin" +# Where the Windows installer places the executable. Needed because a fresh install does not reach the +# PATH of the already-running process, so PATH alone cannot confirm the install landed. +SSM_PLUGIN_WINDOWS_PATH = Path(r"C:\Program Files\Amazon\SessionManagerPlugin\bin\session-manager-plugin.exe") parser = argparse.ArgumentParser(description="Install CI tooling for the repo") _ = parser.add_argument( "--no-python", @@ -116,6 +125,113 @@ def run_node_cmds(cmds: list[str], *, is_windows: bool) -> None: _ = subprocess.run(run_cmd, shell=True, check=True, timeout=DOWNLOAD_TIMEOUT_SECONDS) # noqa: S602 # we need shell=True for npm commands, and this is all our own input +def parse_version(raw: str) -> tuple[int, ...] | None: + stripped = raw.strip() + if not stripped: + return None + parts: list[int] = [] + for segment in stripped.split("."): + if not segment.isdigit(): + return None + parts.append(int(segment)) + return tuple(parts) + + +def resolve_ssm_plugin() -> str | None: + on_path = shutil.which(SSM_PLUGIN_EXECUTABLE) + if on_path is not None: + return on_path + if SSM_PLUGIN_WINDOWS_PATH.exists(): + return str(SSM_PLUGIN_WINDOWS_PATH) + return None + + +def installed_ssm_plugin_version() -> tuple[int, ...] | None: + executable = resolve_ssm_plugin() + if executable is None: + return None + try: + result = subprocess.run( # noqa: S603 # this is all our own input + [executable, "--version"], + check=True, + capture_output=True, + text=True, + ) + except subprocess.CalledProcessError: + return None + return parse_version(result.stdout) + + +def install_ssm_plugin(*, is_windows: bool) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + if is_windows: + local_package_path = Path(tmp_dir) / "SessionManagerPluginSetup.exe" + # Based on https://docs.aws.amazon.com/systems-manager/latest/userguide/install-plugin-windows.html + _ = subprocess.run( # noqa: S603 # this is all our own input + [ # noqa: S607 # curl should always be on PATH + "curl", + f"https://s3.amazonaws.com/session-manager-downloads/plugin/{SSM_PLUGIN_DOWNLOAD_VERSION}/windows/SessionManagerPluginSetup.exe", + "-o", + f"{local_package_path}", + ], + check=True, + timeout=DOWNLOAD_TIMEOUT_SECONDS, + ) + _ = subprocess.run( # noqa: S603 # this is all our own input + [str(local_package_path), "/quiet"], + check=True, + ) + else: + local_package_path = Path(tmp_dir) / "session-manager-plugin.deb" + # Based on https://docs.aws.amazon.com/systems-manager/latest/userguide/install-plugin-debian-and-ubuntu.html + _ = subprocess.run( # noqa: S603 # this is all our own input + [ # noqa: S607 # curl should always be on PATH + "curl", + f"https://s3.amazonaws.com/session-manager-downloads/plugin/{SSM_PLUGIN_DOWNLOAD_VERSION}/ubuntu_64bit/session-manager-plugin.deb", + "-o", + f"{local_package_path}", + ], + check=True, + timeout=DOWNLOAD_TIMEOUT_SECONDS, + ) + _ = subprocess.run( # noqa: S603 # this is all our own input + [ # noqa: S607 # sudo should always be on PATH + "sudo", + "dpkg", + "-i", + str(local_package_path), + ], + check=True, + ) + + +def ensure_ssm_plugin(*, is_windows: bool) -> None: + installed = installed_ssm_plugin_version() + if installed is None: + print("SSM plugin not found, installing it") # noqa: T201 # we want the script to print to console for easy viewing + elif installed < SSM_PLUGIN_MINIMUM_VERSION: + print( # noqa: T201 # we want the script to print to console for easy viewing + f"SSM plugin {'.'.join(str(part) for part in installed)} is older than the required " + f"{SSM_PLUGIN_DOWNLOAD_VERSION}, upgrading it" + ) + else: + print( # noqa: T201 # we want the script to print to console for easy viewing + f"SSM Plugin Manager Version: {'.'.join(str(part) for part in installed)} " + f"(already at least {SSM_PLUGIN_DOWNLOAD_VERSION}, leaving it alone)" + ) + return + install_ssm_plugin(is_windows=is_windows) + # The installer exits zero without installing anything when the requested version is already + # present, so the version has to be read back rather than inferred from the exit code. + final = installed_ssm_plugin_version() + if final is None or final < SSM_PLUGIN_MINIMUM_VERSION: + raise RuntimeError( + f"The SSM plugin installer did not produce {SSM_PLUGIN_EXECUTABLE} " + f"version {SSM_PLUGIN_DOWNLOAD_VERSION} or newer, it left {final}" + ) + print(f"SSM Plugin Manager Version: {'.'.join(str(part) for part in final)}") # noqa: T201 # we want the script to print to console for easy viewing + + def main(): args = parser.parse_args(sys.argv[1:]) is_windows = platform.system() == "Windows" @@ -181,56 +297,7 @@ def main(): # 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 install_task(is_windows=is_windows) if INSTALL_SSM_PLUGIN_BY_DEFAULT and not args.skip_installing_ssm_plugin: - with tempfile.TemporaryDirectory() as tmp_dir: - if is_windows: - local_package_path = Path(tmp_dir) / "SessionManagerPluginSetup.exe" - # Based on https://docs.aws.amazon.com/systems-manager/latest/userguide/install-plugin-windows.html - # no specific reason for that version, just pinning it for best practice - _ = subprocess.run( # noqa: S603 # this is all our own input - [ # noqa: S607 # curl should always be on PATH - "curl", - "https://s3.amazonaws.com/session-manager-downloads/plugin/1.2.707.0/windows/SessionManagerPluginSetup.exe", - "-o", - f"{local_package_path}", - ], - check=True, - timeout=DOWNLOAD_TIMEOUT_SECONDS, - ) - _ = subprocess.run( # noqa: S603 # this is all our own input - [str(local_package_path), "/quiet"], - check=True, - ) - else: - local_package_path = Path(tmp_dir) / "session-manager-plugin.deb" - # Based on https://docs.aws.amazon.com/systems-manager/latest/userguide/install-plugin-debian-and-ubuntu.html - # no specific reason for that version, just pinning it for best practice - _ = subprocess.run( # noqa: S603 # this is all our own input - [ # noqa: S607 # curl should always be on PATH - "curl", - "https://s3.amazonaws.com/session-manager-downloads/plugin/1.2.707.0/ubuntu_64bit/session-manager-plugin.deb", - "-o", - f"{local_package_path}", - ], - check=True, - timeout=DOWNLOAD_TIMEOUT_SECONDS, - ) - _ = subprocess.run( # noqa: S603 # this is all our own input - [ # noqa: S607 # sudo should always be on PATH - "sudo", - "dpkg", - "-i", - str(local_package_path), - ], - check=True, - ) - print("SSM Plugin Manager Version: ") # noqa: T201 # we want the script to print to console for easy viewing - _ = subprocess.run( - [ # noqa: S607 # session-manager-plugin should be on PATH because we just installed it - "session-manager-plugin", - "--version", - ], - check=True, - ) + ensure_ssm_plugin(is_windows=is_windows) if __name__ == "__main__": diff --git a/template/.gitignore b/template/.gitignore index cc7c8b5b..4916f46e 100644 --- a/template/.gitignore +++ b/template/.gitignore @@ -123,4 +123,8 @@ dist # macOS dev cleanliness **/.DS_Store +# Devcontainer +# The devcontainer CLI writes this lock file to pin feature digests, but we are not using it yet +.devcontainer/devcontainer-lock.json + # Ignores specific to this repository