From dd6f71d8970219ef0789ae3c864328d2947f4a83 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Mon, 27 Jul 2026 16:00:54 +0300 Subject: [PATCH] ci: add weekly scheduled dependency check; adopt ruff 0.16.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compose2pod had no scheduled check, so dependency drift only surfaced when someone happened to open a PR. Twelve sibling repos caught ruff 0.16.0 on Monday and filed issues; this repo was silently in the same state. Add the same scheduled.yml + report-scheduled-failure.sh pair they use. The drift it would have caught, fixed here: ruff 0.16.0 stabilized CPY001 out of preview, so `select = ["ALL"]` now picks it up (52 hits) — ignore it, matching modern-di. One ISC004 autofix. 0.16.0 also formats Python code blocks inside Markdown; reformat the 11 affected planning bundles. --- .github/scripts/report-scheduled-failure.sh | 27 ++++++++++++++++ .github/workflows/scheduled.yml | 28 ++++++++++++++++ .../2026-07-09.08-service-key-registry.md | 6 ++-- planning/changes/2026-07-10.03-configs.md | 32 ++++++++++++------- planning/changes/2026-07-11.01-extends.md | 4 +-- .../changes/2026-07-12.03-one-script-plan.md | 7 ++-- ...erive-extends-merge-policy-from-keyspec.md | 5 +-- .../2026-07-13.09-uniform-flag-composition.md | 2 +- ...6-07-14.06-extra-hosts-equals-separator.md | 4 +-- .../2026-07-16.01-quoted-boolean-coercion.md | 10 +++--- ...6-07-16.03-healthcheck-duration-grammar.md | 13 ++++++-- .../2026-07-16.04-grammar-trailing-newline.md | 4 +-- .../2026-07-17.01-tmpfs-into-registry.md | 4 ++- pyproject.toml | 1 + tests/integration/test_pod_level_options.py | 10 +++--- 15 files changed, 121 insertions(+), 36 deletions(-) create mode 100755 .github/scripts/report-scheduled-failure.sh create mode 100644 .github/workflows/scheduled.yml diff --git a/.github/scripts/report-scheduled-failure.sh b/.github/scripts/report-scheduled-failure.sh new file mode 100755 index 0000000..c9ba42d --- /dev/null +++ b/.github/scripts/report-scheduled-failure.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +LABEL="scheduled-failure" +TITLE="Scheduled dependency check failed" + +# Ensure the label exists. --force makes this idempotent: creates if absent, +# updates color/description without error if present. +gh label create "$LABEL" \ + --color "FBCA04" \ + --description "Weekly dependency check failures" \ + --force + +# Find an open issue with our label, if any. --jq '.[0].number // empty' +# yields the first number or an empty string when there are no matches. +existing=$(gh issue list --label "$LABEL" --state open --json number --jq '.[0].number // empty') + +if [ -z "$existing" ]; then + body=$(printf '%s\n\n%s\n\n%s\n\n%s' \ + "The weekly scheduled dependency check failed." \ + "First failing run: ${RUN_URL}" \ + "Likely cause: a transitive dev or lint dependency (ruff, ty, eof-fixer, typing-extensions, types-PyYAML) released a breaking change. Reproduce locally with \`just install\` then \`just lint\` and \`just test\`. The integration and conformance jobs additionally need podman and docker." \ + "Close this issue once fixed. The next scheduled failure will open a fresh issue.") + gh issue create --title "$TITLE" --label "$LABEL" --body "$body" +else + gh issue comment "$existing" --body "Failed again: ${RUN_URL}" +fi diff --git a/.github/workflows/scheduled.yml b/.github/workflows/scheduled.yml new file mode 100644 index 0000000..b1fe433 --- /dev/null +++ b/.github/workflows/scheduled.yml @@ -0,0 +1,28 @@ +name: scheduled-dep-check +on: + schedule: + - cron: "0 6 * * 1" # Mondays 06:00 UTC + workflow_dispatch: {} + +concurrency: + group: scheduled-dep-check + cancel-in-progress: false + +jobs: + checks: + uses: ./.github/workflows/_checks.yml + + report-failure: + needs: checks + if: failure() && github.event_name == 'schedule' + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - uses: actions/checkout@v6 + - name: Open or update tracking issue + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: bash .github/scripts/report-scheduled-failure.sh diff --git a/planning/changes/2026-07-09.08-service-key-registry.md b/planning/changes/2026-07-09.08-service-key-registry.md index d4489fc..f0dbc44 100644 --- a/planning/changes/2026-07-09.08-service-key-registry.md +++ b/planning/changes/2026-07-09.08-service-key-registry.md @@ -35,8 +35,8 @@ to the key surface. ```python @dataclass(frozen=True) class KeySpec: - validate: Callable[[str, str, Any], None] # (service_name, key, value) -> raises - emit: Callable[[Any], list[Token]] # (value) -> tokens + validate: Callable[[str, str, Any], None] # (service_name, key, value) -> raises + emit: Callable[[Any], list[Token]] # (value) -> tokens ``` `SERVICE_KEYS: dict[str, KeySpec]` holds the ~16 declarative-flag keys (`user`, @@ -55,7 +55,7 @@ one loop: ```python for key, spec in SERVICE_KEYS.items(): if key in svc: - flags += spec.emit(svc[key]) # emit; or spec.validate(name, key, svc[key]) + flags += spec.emit(svc[key]) # emit; or spec.validate(name, key, svc[key]) ``` This retires `_add_declarative_flags`, the three `_add_*_flags` bespoke helpers, diff --git a/planning/changes/2026-07-10.03-configs.md b/planning/changes/2026-07-10.03-configs.md index 2b6a861..2a7b668 100644 --- a/planning/changes/2026-07-10.03-configs.md +++ b/planning/changes/2026-07-10.03-configs.md @@ -35,12 +35,12 @@ machinery, parameterized by a frozen `StoreKind`: ```python @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) class StoreKind: - label: str # "secret" | "config" — error wording - top_key: str # "secrets" | "configs" - prefix: str # "" | "config-" → store = f"{pod}-{prefix}{name}" - sources: frozenset[str] # {file,environment} | {file,environment,content} + label: str # "secret" | "config" — error wording + top_key: str # "secrets" | "configs" + prefix: str # "" | "config-" → store = f"{pod}-{prefix}{name}" + sources: frozenset[str] # {file,environment} | {file,environment,content} default_target: Callable[[str], str] # secret: name ; config: "/"+name - require_absolute_target: bool # secret: False ; config: True + require_absolute_target: bool # secret: False ; config: True ``` It exposes kind-parameterized primitives (`validate_store`, `referenced_names`, @@ -53,13 +53,23 @@ the union entry points that iterate a `kinds` tuple — emit's single seam: ```python # secrets.py — prefix="" preserves the exact current store names -SECRET = StoreKind(label="secret", top_key="secrets", prefix="", - sources=frozenset({"file", "environment"}), - default_target=lambda n: n, require_absolute_target=False) +SECRET = StoreKind( + label="secret", + top_key="secrets", + prefix="", + sources=frozenset({"file", "environment"}), + default_target=lambda n: n, + require_absolute_target=False, +) # configs.py -CONFIG = StoreKind(label="config", top_key="configs", prefix="config-", - sources=frozenset({"file", "environment", "content"}), - default_target=lambda n: f"/{n}", require_absolute_target=True) +CONFIG = StoreKind( + label="config", + top_key="configs", + prefix="config-", + sources=frozenset({"file", "environment", "content"}), + default_target=lambda n: f"/{n}", + require_absolute_target=True, +) ``` A small `STORE_KINDS = (SECRET, CONFIG)` aggregator (importing both specs — no diff --git a/planning/changes/2026-07-11.01-extends.md b/planning/changes/2026-07-11.01-extends.md index 91691b3..6a35579 100644 --- a/planning/changes/2026-07-11.01-extends.md +++ b/planning/changes/2026-07-11.01-extends.md @@ -38,8 +38,8 @@ filesystem access and no `--project-dir` dependency. Import direction: `cli` -> ```python compose = _read_compose(text, args.format) -compose = resolve_extends(compose) # flatten inheritance -warnings = validate(compose) # never sees `extends` +compose = resolve_extends(compose) # flatten inheritance +warnings = validate(compose) # never sees `extends` script = emit_script(compose=compose, options=options) ``` diff --git a/planning/changes/2026-07-12.03-one-script-plan.md b/planning/changes/2026-07-12.03-one-script-plan.md index ad613de..e3209f5 100644 --- a/planning/changes/2026-07-12.03-one-script-plan.md +++ b/planning/changes/2026-07-12.03-one-script-plan.md @@ -35,16 +35,19 @@ Introduce a single internal traversal and make the two readers project from it: ```python @dataclasses.dataclass(frozen=True, slots=True, kw_only=True) class PlannedScript: - script: str # finished: joined lines + trailing newline - variables: list[str] # finished: sorted, unique + script: str # finished: joined lines + trailing newline + variables: list[str] # finished: sorted, unique + def _plan(compose: dict[str, Any], options: EmitOptions) -> PlannedScript: ... + def emit_script(compose, options) -> str: if not POD_NAME_PATTERN.fullmatch(options.pod): raise UnsupportedComposeError(...) return _plan(compose, options).script + def referenced_variables(compose, options) -> list[str]: return _plan(compose, options).variables ``` diff --git a/planning/changes/2026-07-13.05-derive-extends-merge-policy-from-keyspec.md b/planning/changes/2026-07-13.05-derive-extends-merge-policy-from-keyspec.md index 833c84d..821e64e 100644 --- a/planning/changes/2026-07-13.05-derive-extends-merge-policy-from-keyspec.md +++ b/planning/changes/2026-07-13.05-derive-extends-merge-policy-from-keyspec.md @@ -57,6 +57,7 @@ def _list(flag: str) -> KeySpec: ... return KeySpec(validate=_validate_list, emit=emit, merge=_concat_list) + def _map(flag: str) -> KeySpec: ... return KeySpec(validate=_validate_map, emit=emit, merge=_merge_map) @@ -82,9 +83,9 @@ def _merge(base: dict[str, Any], local: dict[str, Any], name: str) -> dict[str, spec = SERVICE_KEYS.get(key) if key in base and spec is not None and spec.merge is not None: merged[key] = spec.merge(name, key, base[key], local_val) - elif key in base and key in _MERGE_KEYS: # structural keys only + elif key in base and key in _MERGE_KEYS: # structural keys only ... - elif key in base and key in _CONCAT_KEYS: # structural keys only + elif key in base and key in _CONCAT_KEYS: # structural keys only ... else: merged[key] = local_val diff --git a/planning/changes/2026-07-13.09-uniform-flag-composition.md b/planning/changes/2026-07-13.09-uniform-flag-composition.md index 407c48c..2f84f2a 100644 --- a/planning/changes/2026-07-13.09-uniform-flag-composition.md +++ b/planning/changes/2026-07-13.09-uniform-flag-composition.md @@ -36,7 +36,7 @@ accumulating into a local list and returning it: ```python def _health_flags(healthcheck: dict[str, Any]) -> list[Token]: flags: list[Token] = [] - ... # body unchanged + ... # body unchanged return flags ``` diff --git a/planning/changes/2026-07-14.06-extra-hosts-equals-separator.md b/planning/changes/2026-07-14.06-extra-hosts-equals-separator.md index af89187..8e806f9 100644 --- a/planning/changes/2026-07-14.06-extra-hosts-equals-separator.md +++ b/planning/changes/2026-07-14.06-extra-hosts-equals-separator.md @@ -43,9 +43,9 @@ every site that reads an entry: ```python def split_extra_host(entry: str) -> tuple[str, str]: - if "=" in entry: # Compose's documented separator + if "=" in entry: # Compose's documented separator host, _sep, address = entry.partition("=") - else: # legacy 'host:ip' + else: # legacy 'host:ip' host, _sep, address = entry.partition(":") return host, address ``` diff --git a/planning/changes/2026-07-16.01-quoted-boolean-coercion.md b/planning/changes/2026-07-16.01-quoted-boolean-coercion.md index a5fb2db..11f3f81 100644 --- a/planning/changes/2026-07-16.01-quoted-boolean-coercion.md +++ b/planning/changes/2026-07-16.01-quoted-boolean-coercion.md @@ -41,13 +41,15 @@ strings; this field-level cast is what re-accepts them, matching Docker. **Primitive (`values.py`)**, beside `has_variable`/`is_number`: ```python -_BOOL_TRUE = frozenset({"y","Y","yes","Yes","YES","true","True","TRUE","on","On","ON"}) -_BOOL_FALSE = frozenset({"n","N","no","No","NO","false","False","FALSE","off","Off","OFF"}) +_BOOL_TRUE = frozenset({"y", "Y", "yes", "Yes", "YES", "true", "True", "TRUE", "on", "On", "ON"}) +_BOOL_FALSE = frozenset({"n", "N", "no", "No", "NO", "false", "False", "FALSE", "off", "Off", "OFF"}) -def is_bool_like(value): # the accept-test every bool validator uses + +def is_bool_like(value): # the accept-test every bool validator uses return isinstance(value, bool) or (isinstance(value, str) and (value in _BOOL_TRUE or value in _BOOL_FALSE)) -def as_bool(value): # coerce a validated bool-or-bool-string to a real bool + +def as_bool(value): # coerce a validated bool-or-bool-string to a real bool return value if isinstance(value, bool) else value in _BOOL_TRUE ``` diff --git a/planning/changes/2026-07-16.03-healthcheck-duration-grammar.md b/planning/changes/2026-07-16.03-healthcheck-duration-grammar.md index b04ccd7..5203121 100644 --- a/planning/changes/2026-07-16.03-healthcheck-duration-grammar.md +++ b/planning/changes/2026-07-16.03-healthcheck-duration-grammar.md @@ -44,8 +44,17 @@ component parser, beside a `unit → seconds` map: _UNITS = "ns|us|µs|ms|s|m|h|d|w" _INTERVAL_DURATION = re.compile(rf"^[+-]?(?:[0-9]+(?:\.[0-9]+)?(?:{_UNITS}))+$") _DURATION_COMPONENT = re.compile(rf"([0-9]+(?:\.[0-9]+)?)({_UNITS})") -_UNIT_SECONDS = {"ns": 1e-9, "us": 1e-6, "µs": 1e-6, "ms": 1e-3, - "s": 1.0, "m": 60.0, "h": 3600.0, "d": 86400.0, "w": 604800.0} +_UNIT_SECONDS = { + "ns": 1e-9, + "us": 1e-6, + "µs": 1e-6, + "ms": 1e-3, + "s": 1.0, + "m": 60.0, + "h": 3600.0, + "d": 86400.0, + "w": 604800.0, +} ``` `interval_seconds`: diff --git a/planning/changes/2026-07-16.04-grammar-trailing-newline.md b/planning/changes/2026-07-16.04-grammar-trailing-newline.md index b21cfac..a4cb7fc 100644 --- a/planning/changes/2026-07-16.04-grammar-trailing-newline.md +++ b/planning/changes/2026-07-16.04-grammar-trailing-newline.md @@ -43,10 +43,10 @@ probe (`" 512m "`) tests spaces, not newlines, so the class went uncaught. regexes: ```python -_SIZE = re.compile(r"...\Z", re.VERBOSE) # was ...$ +_SIZE = re.compile(r"...\Z", re.VERBOSE) # was ...$ _DURATION = re.compile(r"^[+-]?(?:[0-9]+(?:\.[0-9]+)?(?:ns|us|µs|ms|s|m|h))+\Z") _STRICT_INT_STRING = re.compile(r"^[+-]?[0-9]+\Z") -_PORT = re.compile(r"...\Z", re.VERBOSE) # was ...$ +_PORT = re.compile(r"...\Z", re.VERBOSE) # was ...$ ``` `\Z` matches only the true end of the string, so a trailing `\n` no longer diff --git a/planning/changes/2026-07-17.01-tmpfs-into-registry.md b/planning/changes/2026-07-17.01-tmpfs-into-registry.md index f2a7374..ecf11c1 100644 --- a/planning/changes/2026-07-17.01-tmpfs-into-registry.md +++ b/planning/changes/2026-07-17.01-tmpfs-into-registry.md @@ -34,7 +34,7 @@ shares one uniform shape, not a structural-key registry). **`keys.py`** — add a scalar-or-list factory and the registry entry: ```python -def _validate_string_or_string_list(name, key, value): # moved from parsing.py, None-guard dropped +def _validate_string_or_string_list(name, key, value): # moved from parsing.py, None-guard dropped if not isinstance(value, str | list): raise UnsupportedComposeError(f"service {name!r}: '{key}' must be a string or list") if isinstance(value, list): @@ -42,6 +42,7 @@ def _validate_string_or_string_list(name, key, value): # moved from parsing.py if not isinstance(entry, str): raise UnsupportedComposeError(f"service {name!r}: '{key}' entry must be a string") + def _scalar_or_list(flag): def emit(value): items = [value] if isinstance(value, str) else value @@ -49,6 +50,7 @@ def _scalar_or_list(flag): for item in items: tokens += [flag, Expand(value=str(item))] return tokens + return KeySpec(validate=_validate_string_or_string_list, emit=emit, merge=concat_list) ``` diff --git a/pyproject.toml b/pyproject.toml index cd30989..5d9e1e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,7 @@ ignore = [ "D213", # "multi-line-summary-second-line" conflicting with D212 "COM812", # flake8-commas "Trailing comma missing" "ISC001", # flake8-implicit-str-concat + "CPY001", # no per-file copyright header ] isort.lines-after-imports = 2 isort.no-lines-before = ["standard-library", "local-folder"] diff --git a/tests/integration/test_pod_level_options.py b/tests/integration/test_pod_level_options.py index 6d67ff4..d700aee 100644 --- a/tests/integration/test_pod_level_options.py +++ b/tests/integration/test_pod_level_options.py @@ -16,10 +16,12 @@ def test_pod_level_options_land_on_the_pod(run_pod: Callable[..., PodRun]) -> No "command": [ "sh", "-c", - "grep 9.9.9.9 /etc/resolv.conf " - "&& grep 1024 /proc/sys/net/core/somaxconn " - "&& grep 10.0.0.9 /etc/hosts " - "&& grep external-svc /etc/hosts", + ( + "grep 9.9.9.9 /etc/resolv.conf " + "&& grep 1024 /proc/sys/net/core/somaxconn " + "&& grep 10.0.0.9 /etc/hosts " + "&& grep external-svc /etc/hosts" + ), ], }, },