Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/actions/agent-run-base/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ runs:
push_allowed="false"
fi

env_name_pattern='^[A-Za-z_][A-Za-z0-9_]*$'
if [[ ! "$MODE_ENV_NAME" =~ $env_name_pattern ]]; then
echo "Invalid mode_env_name: expected a shell environment variable name" >&2
exit 1
fi
if [[ ! "$PR_NUMBER_ENV_NAME" =~ $env_name_pattern ]]; then
echo "Invalid pr_number_env_name: expected a shell environment variable name" >&2
exit 1
fi

{
echo "checkout_token=${checkout_token}"
echo "push_token=${push_token}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ test('runtime validation matches schema patterns and top-level fields', () => {
() => validateCapabilityBundle(validBundle({ local_control: 'reroute this run' })),
/unknown top-level fields: local_control/,
);
assert.throws(
() => validateCapabilityBundle(validBundle({ contentHash: 'sha256:deadbeef' })),
/unknown top-level fields: contentHash/,
);
});

test('missing required owner and rollback fields are rejected', () => {
Expand Down
11 changes: 11 additions & 0 deletions .github/scripts/__tests__/keepalive-prompt-composer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,3 +92,14 @@ test('composePrompt treats empty-string capability bundles as absent', () => {
assert.equal(result.text, 'Base instructions');
assert.deepEqual(result.capability_bundles.applied, []);
});

test('composePrompt treats false capability inputs as absent', () => {
const result = composePrompt({
capabilityBundles: false,
knownCapabilities: false,
segments: [{ id: 'base', text: 'Base instructions' }],
});

assert.equal(result.text, 'Base instructions');
assert.deepEqual(result.capability_bundles.applied, []);
});
1 change: 0 additions & 1 deletion .github/scripts/capability_bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ function sha256Hex(value) {
function bundleHashPayload(bundle) {
const {
content_hash: _contentHash,
contentHash: _contentHashCamel,
...payload
} = bundle || {};
return payload;
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/keepalive_prompt_composer.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function normaliseSegmentId(value, fallback) {
}

function coerceSegments(value) {
if (value === undefined || value === null || value === '') {
if (value === undefined || value === null || value === '' || value === false) {
return [];
}
if (Array.isArray(value)) {
Expand Down
17 changes: 13 additions & 4 deletions .github/workflows/maint-52-sync-dev-versions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,20 @@ jobs:
scripts/sync_dev_dependencies.py
sparse-checkout-cone-mode: false

- name: Compute versions hash
- name: Compute wave hash
id: hash
run: |
hash=$(sha256sum .github/workflows/autofix-versions.env | cut -d' ' -f1)
# Include the propagation implementation so a script-only repair opens
# a replacement wave instead of skipping PRs from the prior behavior.
hash=$(
sha256sum \
.github/workflows/autofix-versions.env \
scripts/sync_dev_dependencies.py \
| sha256sum \
| cut -d' ' -f1
)
echo "hash=${hash:0:12}" >> "$GITHUB_OUTPUT"
echo "Versions hash: ${hash:0:12}"
echo "Wave hash: ${hash:0:12}"
echo ""
echo "Current versions:"
grep -v '^#' .github/workflows/autofix-versions.env | grep '='
Expand Down Expand Up @@ -265,9 +273,10 @@ jobs:

# Create branch and commit
git checkout -b "$branch_name"
# Add lockfile if it exists and was modified
# Add supported lockfiles if they exist and were modified.
git add pyproject.toml .github/workflows/autofix-versions.env
if [ -f requirements.lock ]; then git add requirements.lock; fi
if [ -f requirements-dev.lock ]; then git add requirements-dev.lock; fi
Comment thread
stranske marked this conversation as resolved.

# Commit with multi-line message
commit_msg="deps: sync dev tool versions from Workflows
Expand Down
2 changes: 1 addition & 1 deletion docs/WORKFLOW_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ _Inline Gate helper_
- **`maint-46-post-ci.yml`** — Post-CI recovery watcher triggered by `workflow_run` on Gate completion. It inspects the Gate summary job before touching the repo, and only checks out helpers / installs the token-balanced API client when the summary leg actually failed, keeping the default token pool free unless recovery is required.
- **`maint-47-disable-legacy-workflows.yml`** — Manual dispatch utility to disable retired workflows that still appear in the Actions UI (with a dry-run preview + allowlist overrides); now relies solely on the default workflow token because the helper script never leaves the repository.
- **`maint-50-tool-version-check.yml`** — Weekly/manual tool-version audit that reads `autofix-versions.env`, hits PyPI to detect drifts, and files/refreshes the maintenance issue via the default token + load-balanced helper (no extra App mint).
- **`maint-52-sync-dev-versions.yml`** — Fans out to each registered consumer repo (or a supplied subset), reports `autofix-versions.env` freshness for visibility, then syncs the dev-dependency pins using the PAT provided via `REPO_TOKEN`; now reuses `scripts/list_registered_consumer_repos.py` and avoids redundant GitHub App token mints.
- **`maint-52-sync-dev-versions.yml`** — Fans out to each registered consumer repo (or a supplied subset), reports `autofix-versions.env` freshness for visibility, then exact-pins matching `pyproject.toml` entries and updates direct pins in `requirements.lock` and `requirements-dev.lock` when present; its wave ID covers both the pin set and propagation script so implementation repairs create replacement PRs. It uses the PAT provided via `REPO_TOKEN`, reuses `scripts/list_registered_consumer_repos.py`, and avoids redundant GitHub App token mints.
- **`maint-52-validate-workflows.yml`** — PR/push workflow that dry-parses every workflow file with `yq`, runs actionlint with the repo allowlist, and caches both binaries; no extra GitHub App token is minted because the job never leaves the repository.
- **`maint-60-release.yml`** — Tag-triggered release workflow that publishes notes with `softprops/action-gh-release` when a `v*` tag is pushed; only the default workflow token is needed, so no extra App mint runs. (Retains a legacy floating-`v1` tag step for any `v1.*` push, but consumers ride `@main` — the single supported pin — so the floating tag is no longer part of normal operation.)
- **`maint-61-release-please.yml`** — Main-branch release-please automation that opens or updates the Conventional Commits-driven Release PR from the manifest seeded at `1.1.2`, preferring the Workflows GitHub App token so the generated PR can trigger Gate.
Expand Down
2 changes: 1 addition & 1 deletion docs/ci/WORKFLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ The gate uses the shared `.github/scripts/detect-changes.js` helper to decide wh
* [`maint-39-test-llm-providers.yml`](../../.github/workflows/maint-39-test-llm-providers.yml) verifies LLM provider API keys (GitHub Models, OpenAI) are configured correctly for task completion analysis.
* [`maint-sync-env-from-pyproject.yml`](../../.github/workflows/maint-sync-env-from-pyproject.yml) syncs `pyproject.toml`, templates, and direct `requirements.lock` pins from the canonical `autofix-versions.env` file after source pin changes land.
* [`maint-52-validate-workflows.yml`](../../.github/workflows/maint-52-validate-workflows.yml) dry-parses every workflow with `yq`, runs `actionlint` with the repository allowlist, and fails fast when malformed YAML or unapproved actionlint findings slip in.
* [`maint-52-sync-dev-versions.yml`](../../.github/workflows/maint-52-sync-dev-versions.yml) syncs dev tool versions (ruff, mypy, black, isort, pytest) from `autofix-versions.env` to consumer repository `pyproject.toml` files weekly or on version changes.
* [`maint-52-sync-dev-versions.yml`](../../.github/workflows/maint-52-sync-dev-versions.yml) syncs dev tool versions (ruff, mypy, black, isort, pytest, pytest-cov, pytest-xdist, hypothesis, coverage, and docformatter) from `autofix-versions.env` to consumer `pyproject.toml` files and direct pins in `requirements.lock` and `requirements-dev.lock` weekly or on version changes.
* [`maint-auto-update-pypi-versions.yml`](../../.github/workflows/maint-auto-update-pypi-versions.yml) checks PyPI daily for latest dev tool versions and creates a PR to update `autofix-versions.env` when versions are outdated.
* [`maint-62-integration-consumer.yml`](../../.github/workflows/maint-62-integration-consumer.yml) runs daily at 05:05 UTC, on release publication, or by manual dispatch to execute the integration-repo scenarios via the reusable Python CI template and keep the integration failure issue updated.
* [`maint-65-sync-label-docs.yml`](../../.github/workflows/maint-65-sync-label-docs.yml) synchronizes `docs/LABELS.md` to consumer repositories weekly (Sundays 00:00 UTC) or via manual dispatch.
Expand Down
7 changes: 5 additions & 2 deletions docs/ops/CONSUMER_REPO_MAINTENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,13 +266,16 @@ Workflows owns shared autofix/dev-tool pins in
`.github/workflows/autofix-versions.env`. Treat that file as the source of truth
for `ruff`, `black`, `mypy`, `pytest`, `coverage`, `isort`, and `docformatter`.
The matching `pyproject.toml`, consumer template, integration template, and
direct `requirements.lock` pins must move in the same Workflows PR.
direct `requirements.lock` pins must move in the same Workflows PR. Maint 52
also updates direct tool pins in a consumer's `requirements-dev.lock` when that
additional generated lockfile exists.

Consumer repos receive those pins through `maint-52-sync-dev-versions.yml`, not
the general `maint-68-sync-consumer-repos.yml` template sync. Keep
`.github/workflows/autofix-versions.env` out of `.github/sync-manifest.yml` so a
workflow-template sync PR cannot update the env file without the matching
`pyproject.toml` and `requirements.lock` changes.
`pyproject.toml`, `requirements.lock`, and supported `requirements-dev.lock`
changes.

Dependabot should not be merged when it only bumps one of those shared tool pins
in `pyproject.toml`; route that change through the Workflows source pin update
Expand Down
8 changes: 4 additions & 4 deletions langsmith-fleet-worker-attempt.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
{
"agent": "codex",
"cli_version": "0.125.0",
"emitted_at": "2026-07-16T06:27:48.218526Z",
"emitted_at": "2026-07-17T21:24:31.100466Z",
"execution_profile": "codex-default",
"fallback_models": [
"gpt-5.4"
],
"operation_role": "worker",
"pr_number": "2785",
"pr_number": "2789",
"requested_model": "gpt-5.5",
"runner": "reusable-codex-run",
"schema": "langsmith-fleet/v1",
"selected_model": "",
"selection_reason": ""
"selected_model": "gpt-5.5",
"selection_reason": "input"
}
23 changes: 12 additions & 11 deletions scripts/sync_dev_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
python sync_dev_dependencies.py --check # Verify versions match
python sync_dev_dependencies.py --apply # Update pyproject.toml
python sync_dev_dependencies.py --apply --create-if-missing # Create dev deps if missing
python sync_dev_dependencies.py --apply # Syncs requirements.lock automatically if it exists
python sync_dev_dependencies.py --apply # Syncs supported requirements lockfiles when present
"""

from __future__ import annotations
Expand All @@ -26,7 +26,7 @@
# Default paths (can be overridden for testing)
PIN_FILE = Path(".github/workflows/autofix-versions.env")
PYPROJECT_FILE = Path("pyproject.toml")
LOCKFILE_FILE = Path("requirements.lock")
LOCKFILE_FILES = (Path("requirements.lock"), Path("requirements-dev.lock"))
Comment thread
stranske marked this conversation as resolved.

# Map env file keys to package names
# Format: ENV_KEY -> (package_name, optional_alternative_names)
Expand Down Expand Up @@ -280,8 +280,10 @@ def sync_pyproject(
if pkg_lower in current_packages:
actual_pkg, current_op, current_ver = current_packages[pkg_lower]

# Check if version differs
if current_ver != target_version:
# Normalize both the version and the operator. A dependency that
# already has the target version but still uses ">=" is not in
# sync with the reproducible, exact-pin contract.
if current_ver != target_version or (use_exact_pins and current_op != "=="):
new_section, changed = update_dependency_in_section(
new_section, actual_pkg, target_version, use_exact_pins
)
Expand Down Expand Up @@ -316,7 +318,7 @@ def _build_lockfile_targets(pins: dict[str, str]) -> dict[str, str]:
def sync_lockfile(
lockfile_path: Path, pins: dict[str, str], apply: bool = False
) -> tuple[list[str], list[str]]:
"""Sync versions from pin file to requirements.lock."""
"""Sync direct tool pins in one supported requirements lockfile."""
if not lockfile_path.exists():
return [], []

Expand All @@ -336,7 +338,7 @@ def sync_lockfile(
version = match.group("version")
target_version = targets.get(name.lower())
if target_version and version != target_version:
changes.append(f"requirements.lock:{name}: {version} -> =={target_version}")
changes.append(f"{lockfile_path.name}:{name}: {version} -> =={target_version}")
Comment thread
stranske marked this conversation as resolved.
if apply:
updated_lines.append(
f"{match.group('lead')}{name}=={target_version}{match.group('trail')}"
Expand Down Expand Up @@ -368,7 +370,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument(
"--apply",
action="store_true",
help="Apply version updates to pyproject.toml",
help="Apply version updates to pyproject.toml and supported requirements lockfiles",
)
parser.add_argument(
"--create-if-missing",
Expand All @@ -383,7 +385,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument(
"--lockfile",
action="store_true",
help="Force lockfile sync even if requirements.lock doesn't exist (no-op)",
help="Compatibility flag; supported requirements lockfiles are always checked",
)
parser.add_argument(
"--pin-file",
Expand Down Expand Up @@ -421,9 +423,8 @@ def main(argv: list[str] | None = None) -> int:
create_if_missing=args.create_if_missing,
)

lockfile_enabled = args.lockfile or LOCKFILE_FILE.exists()
if lockfile_enabled:
lock_changes, lock_errors = sync_lockfile(LOCKFILE_FILE, pins, apply=args.apply)
for lockfile_path in LOCKFILE_FILES:
lock_changes, lock_errors = sync_lockfile(lockfile_path, pins, apply=args.apply)
changes.extend(lock_changes)
errors.extend(lock_errors)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ function sha256Hex(value) {
function bundleHashPayload(bundle) {
const {
content_hash: _contentHash,
contentHash: _contentHashCamel,
...payload
} = bundle || {};
return payload;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function normaliseSegmentId(value, fallback) {
}

function coerceSegments(value) {
if (value === undefined || value === null || value === '') {
if (value === undefined || value === null || value === '' || value === false) {
return [];
}
if (Array.isArray(value)) {
Expand Down
15 changes: 7 additions & 8 deletions templates/consumer-repo/docs/SETUP_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -591,21 +591,20 @@ curl -o .github/workflows/agents-70-orchestrator.yml \

### 4.2 Autofix Versions Configuration

> **Important**: Each repository maintains its own `autofix-versions.env` file
> with dependency versions matching its lock files. This file is NOT synced.
> **Important**: `autofix-versions.env` is not part of the general template sync.
> Registered first-party consumers receive the canonical shared tool pins through
> `maint-52-sync-dev-versions.yml`, together with matching dependency-file updates.

Create `.github/workflows/autofix-versions.env`:

```bash
# Tool versions for autofix - match your project's lock files
RUFF_VERSION=0.8.1
MYPY_VERSION=1.14.0
BLACK_VERSION=24.10.0
ISORT_VERSION=5.13.2
curl -fsSL \
https://raw.githubusercontent.com/stranske/Workflows/main/.github/workflows/autofix-versions.env \
-o .github/workflows/autofix-versions.env
```

- [ ] `autofix-versions.env` file created
- [ ] Versions match project's dependency versions
- [ ] Shared tool versions match the canonical Workflows pins and project lock files

To find your current versions:
```bash
Expand Down
64 changes: 64 additions & 0 deletions tests/scripts/test_sync_dev_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,20 @@ def test_sync_lockfile_apply_updates_versions(tmp_path: Path) -> None:
assert "requests==2.0.0" in updated


def test_sync_lockfile_reports_requirements_dev_lockfile_name(tmp_path: Path) -> None:
lockfile = tmp_path / "requirements-dev.lock"
lockfile.write_text("ruff==0.1.0\n", encoding="utf-8")

changes, errors = sdd.sync_lockfile(
lockfile,
{"RUFF_VERSION": "1.0.0"},
apply=False,
)

assert errors == []
assert changes == ["requirements-dev.lock:ruff: 0.1.0 -> ==1.0.0"]


Comment on lines +65 to +78

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert that dry-run mode leaves the lockfile unchanged.

This test uses apply=False but only checks the reported change. A regression that mutates requirements-dev.lock during check mode would still pass; assert the file content remains ruff==0.1.0\n.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/scripts/test_sync_dev_dependencies.py` around lines 65 - 78, Extend
test_sync_lockfile_reports_requirements_dev_lockfile_name to read the lockfile
after the apply=False call and assert its content remains exactly
"ruff==0.1.0\n". Keep the existing changes and errors assertions unchanged.

Source: Path instructions

def test_sync_lockfile_preserves_comments_and_whitespace(tmp_path: Path) -> None:
lockfile = tmp_path / "requirements.lock"
lockfile.write_text(
Expand All @@ -87,6 +101,26 @@ def test_sync_lockfile_preserves_comments_and_whitespace(tmp_path: Path) -> None
assert "black==2.0.0" in updated


def test_sync_pyproject_normalizes_minimum_pin_at_target_version(
tmp_path: Path,
) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
'[project.optional-dependencies]\ndev = [\n "black>=2.0.0",\n]\n',
encoding="utf-8",
)

changes, errors = sdd.sync_pyproject(
pyproject,
{"BLACK_VERSION": "2.0.0"},
apply=True,
)

assert errors == []
assert changes == ["black: >=2.0.0 -> ==2.0.0"]
assert '"black==2.0.0"' in pyproject.read_text(encoding="utf-8")


def test_main_apply_updates_pyproject_and_lockfile(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
Expand Down Expand Up @@ -117,6 +151,36 @@ def test_main_apply_updates_pyproject_and_lockfile(
assert "ruff==1.0.0" in lockfile.read_text(encoding="utf-8")


def test_main_apply_updates_all_present_requirements_lockfiles(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
env_path = tmp_path / "pins.env"
pyproject_path = tmp_path / "pyproject.toml"
requirements_lock = tmp_path / "requirements.lock"
requirements_dev_lock = tmp_path / "requirements-dev.lock"
pins = {"RUFF_VERSION": "1.0.0", "BLACK_VERSION": "2.0.0"}

_write_env_file(env_path, pins)
_write_pyproject(pyproject_path, "0.9.0", "2.0.0")
requirements_lock.write_text("ruff==0.9.0\n", encoding="utf-8")
requirements_dev_lock.write_text("ruff==0.8.0\n", encoding="utf-8")
monkeypatch.chdir(tmp_path)

exit_code = sdd.main(
[
"--apply",
"--pin-file",
str(env_path),
"--pyproject",
str(pyproject_path),
]
)

assert exit_code == 0
assert requirements_lock.read_text(encoding="utf-8") == "ruff==1.0.0\n"
assert requirements_dev_lock.read_text(encoding="utf-8") == "ruff==1.0.0\n"


def test_main_check_reports_lockfile_mismatch(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
Expand Down
7 changes: 4 additions & 3 deletions tests/scripts/test_task_decomposer.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,10 +508,11 @@ def test_get_llm_client_github_token_defaults(monkeypatch) -> None:
client, provider = client_info
assert provider == "github-models"
assert isinstance(client, FakeChatOpenAI)
from tools import llm_provider
from tools.llm_provider import GITHUB_MODELS_BASE_URL
from tools.llm_registry import configured_model_for_provider

assert client.kwargs["model"] == llm_provider.DEFAULT_MODEL
assert client.kwargs["base_url"] == llm_provider.GITHUB_MODELS_BASE_URL
assert client.kwargs["model"] == configured_model_for_provider("github-models")
assert client.kwargs["base_url"] == GITHUB_MODELS_BASE_URL
Comment on lines +511 to +515

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the production fallback contract instead of mirroring the selector.

GitHubModelsProvider._get_client calls configured_model_for_provider("github-models", fallback=DEFAULT_MODEL), but this test omits the fallback. It can therefore expect "" while production selects DEFAULT_MODEL; additionally, deriving the expected value from the same selector can let selection regressions pass unnoticed. Configure the registry deterministically and assert the intended model, with a separate fallback case.

As per path instructions, Python changes should prioritize correctness and test coverage, including tests for new or changed behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/scripts/test_task_decomposer.py` around lines 511 - 515, Update the
test covering GitHubModelsProvider._get_client to configure the model registry
deterministically and assert the explicit intended model rather than deriving it
via configured_model_for_provider. Add a separate fallback-case assertion that
verifies DEFAULT_MODEL is selected when no configured model exists, while
retaining the base URL assertion.

Source: Path instructions



def test_get_llm_client_with_openai_token(monkeypatch) -> None:
Expand Down
17 changes: 17 additions & 0 deletions tests/tools/test_ci_failure_triage.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,20 @@ def __init__(self, **kwargs: object) -> None:
assert provider == "openai"
assert isinstance(client, FakeChatOpenAI)
assert created == [{"model": "gpt-5-mini", "api_key": "openai-token", "temperature": 0.1}]


def test_llm_triage_does_not_use_empty_openai_token(monkeypatch) -> None:
created: list[object] = []

class FakeChatOpenAI:
def __init__(self, **kwargs: object) -> None:
created.append(kwargs)

monkeypatch.setitem(
sys.modules, "langchain_openai", types.SimpleNamespace(ChatOpenAI=FakeChatOpenAI)
)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.setenv("OPENAI_API_KEY", "")

assert ci_failure_triage._get_llm_client() is None
assert created == []
Loading
Loading