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
22 changes: 22 additions & 0 deletions .github/workflows/putitoutthere-config-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ on:
branches: [main]
paths:
- 'putitoutthere.toml'
- '.github/workflows/release-ci.yml'
- 'internals/checks/src/checks/release_globs/**'
- '.github/workflows/putitoutthere-config-check.yml'

concurrency:
Expand All @@ -29,3 +31,23 @@ permissions:
jobs:
check:
uses: thekevinscott/putitoutthere/.github/workflows/check.yml@v0

# Upstream's `check` validates the config against its own schema; it has no
# opinion about which paths *should* cascade. This one asserts the repo-side
# policy the two files encode together: every path the publish globs match,
# release-ci.yml also builds, and neither reaches a path that never ships
# (fragments, attestation receipts, testing-conventions.toml). Without it a
# markdown-only merge releases all three packages byte-identical (#944).
release-globs:
name: Release globs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
python-version: "3.13"

- name: Assert publishing and the precheck agree on what ships
run: uv run --project internals/checks dirsql-checks release-globs
14 changes: 12 additions & 2 deletions .github/workflows/release-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,40 @@ name: Release CI
# plans only dirsql-py). putitoutthere's build.yml exposes no package filter,
# and the release cascade (rust -> py -> npm) is cross-package by design, so
# this stays one workflow rather than splitting per package.
#
# The exclusions must stay a SUBSET of what putitoutthere.toml's globs carve
# out, or a merge releases what this never built (#944). `dirsql-checks
# release-globs` fails the PR when they diverge.
on:
pull_request:
branches: [main]
paths:
- 'packages/rust/**'
- '!packages/rust/changelog.d/**'
- '!packages/rust/migrations.d/**'
- '!packages/rust/e2e-attestations/**'
- '!packages/rust/testing-conventions.toml'
- 'packages/python/**'
- '!packages/python/changelog.d/**'
- '!packages/python/migrations.d/**'
- '!packages/python/e2e-attestations/**'
- '!packages/python/testing-conventions.toml'
- 'packages/ts/**'
- '!packages/ts/changelog.d/**'
- '!packages/ts/migrations.d/**'
- '!packages/ts/e2e-attestations/**'
- '!packages/ts/testing-conventions.toml'
# putitoutthere's plugin globs are plugins/dirsql-plugin-embeddings/**;
# README stays in scope here (hatchling embeds it as the PyPI
# long-description, so it is a build input), fragments and receipts
# do not ship.
# do not ship. `tests/` is NOT excluded: hatchling drops only
# `**/*_test.py`, so `tests/conftest.py` and the package inits land in
# the sdist and a change there is a real build input.
- 'plugins/dirsql-plugin-embeddings/**'
- '!plugins/dirsql-plugin-embeddings/changelog.d/**'
- '!plugins/dirsql-plugin-embeddings/migrations.d/**'
- '!plugins/dirsql-plugin-embeddings/e2e-attestations/**'
- '!plugins/dirsql-plugin-embeddings/tests/**'
- '!plugins/dirsql-plugin-embeddings/testing-conventions.toml'
- 'Cargo.toml'
- 'Cargo.lock'
- 'putitoutthere.toml'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"command": "uv run python -m pytest tests/e2e -q",
"ran_at": 1787250623,
"exit_code": 0,
"commit": "bfa0d84d566244942fb9f11578abe10d6024508e",
"branch": "claude/tackle-944-iegeyj"
}
2 changes: 2 additions & 0 deletions internals/checks/src/checks/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from checks.npm_binary_extension_load.cli import cli as npm_binary_extension_load
from checks.preflight.cli import cli as preflight
from checks.pytest_gate.cli import cli as pytest_gate
from checks.release_globs.cli import cli as release_globs
from checks.wheel_extension_load.cli import cli as wheel_extension_load


Expand All @@ -28,4 +29,5 @@ def main() -> None:
main.add_command(npm_binary_extension_load, name="npm-binary-extension-load")
main.add_command(preflight, name="preflight")
main.add_command(pytest_gate, name="pytest-gate")
main.add_command(release_globs, name="release-globs")
main.add_command(wheel_extension_load, name="wheel-extension-load")
4 changes: 4 additions & 0 deletions internals/checks/src/checks/cli_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@ def test_declared_deps_is_registered():

def test_preflight_is_registered():
assert "preflight" in main.commands


def test_release_globs_is_registered():
assert "release-globs" in main.commands
Empty file.
28 changes: 28 additions & 0 deletions internals/checks/src/checks/release_globs/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""The release-globs check -- repo-only (#944).

Backs `dirsql-checks release-globs`: the publish globs and the PR-time build
precheck must agree on which paths reach a released artifact.
"""

from __future__ import annotations

import click

from checks.release_globs.gate import run


@click.command()
@click.option(
"--config",
"config_path",
default="putitoutthere.toml",
help="Release config declaring each package's publish globs.",
)
@click.option(
"--workflow",
"workflow_path",
default=".github/workflows/release-ci.yml",
help="Workflow whose PR path filter gates the release build precheck.",
)
def cli(config_path: str, workflow_path: str) -> None:
raise SystemExit(run(config_path, workflow_path))
32 changes: 32 additions & 0 deletions internals/checks/src/checks/release_globs/cli_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Colocated unit tests for the release-globs command (isolation -- no
`CliRunner`). Driven through `.callback`; `run` is mocked at its import site.
"""

from unittest import mock

import pytest

from checks.release_globs.cli import cli


def invoke(**kwargs):
with mock.patch("checks.release_globs.cli.run", **kwargs) as run:
with pytest.raises(SystemExit) as exc_info:
cli.callback(config_path="p.toml", workflow_path="w.yml")
return run, exc_info.value.code


def test_exits_with_runs_return_code():
run, code = invoke(return_value=0)
run.assert_called_once_with("p.toml", "w.yml")
assert code == 0


def test_propagates_a_disagreeing_config_as_exit_one():
assert invoke(return_value=1)[1] == 1


def test_defaults_to_the_repo_root_config_and_the_release_precheck_workflow():
config_path, workflow_path = cli.params
assert config_path.default == "putitoutthere.toml"
assert workflow_path.default == ".github/workflows/release-ci.yml"
153 changes: 153 additions & 0 deletions internals/checks/src/checks/release_globs/decide.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Pure decision logic for the release-globs check (#944).

Merging to `main` is the release trigger, and putitoutthere decides what to
release by intersecting each `[[package]]`'s `globs` with that package's diff
since its own last tag. A bare ``packages/<pkg>/**`` therefore republishes on a
changelog fragment, an e2e attestation receipt, or a `testing-conventions.toml`
edit -- none of which reach the built artifact. Run 371 did exactly that: two
new gate-config files republished `dirsql-py` and `dirsql-npm` byte-identical to
the release before them.

**putitoutthere has no glob exclusion field and its leading-`!` negations do the
opposite of what they look like.** Verified against the pinned `@v0` sha
8f5876751f679ee3450617957c97495205002499: `globs` is `z.array(z.string())` on a
`.strict()` schema (no `exclude`-shaped key is representable), and both callers
-- `cascade.ts`'s seed pass and `check.ts` -- go through `glob.ts`'s
``matchesAny``, a plain OR that returns on the first hit. Under minimatch
10.2.6 a `!`-prefixed pattern matches every path *outside* its own subtree, so
adding ``!packages/rust/changelog.d/**`` neither subtracts the fragment (the
earlier ``packages/rust/**`` already returned true) nor stays inert -- it makes
`README.md` match, and the package cascades on every commit.

The form that does work is minimatch's extglob, which excludes *inside* a single
positive pattern: ``packages/rust/!(changelog.d|migrations.d)/**``. It stays a
carve-out rather than an allowlist -- a subdirectory added later is covered
automatically, and only the named names are dropped.
"""

from __future__ import annotations

# Non-shipping paths inside a published package's tree: the changelog and
# migration fragment dirs, the e2e attestation receipts, and the package's own
# testing-conventions gate config.
NON_SHIPPING_DIRS = ("changelog.d", "migrations.d", "e2e-attestations")
NON_SHIPPING_FILES = ("testing-conventions.toml",)

# Top-level directories holding independently published packages.
PUBLISHED_ROOTS = ("packages", "plugins")

_GLOB_META = "*?[]{}!"


def carve_out(root: str) -> list[str]:
"""The canonical publish globs for the package subtree at ``root``.

Two patterns, because a single extglob cannot cover both depths: the first
matches files sitting directly in the package root, the second everything
under its subdirectories.
"""
return [
f"{root}/!({'|'.join(NON_SHIPPING_FILES)})",
f"{root}/!({'|'.join(NON_SHIPPING_DIRS)})/**",
]


def subtree_root(glob: str):
"""``<root>/<package>`` when ``glob`` reaches into a published package tree,
else ``None``."""
parts = glob.split("/")
if len(parts) < 3 or parts[0] not in PUBLISHED_ROOTS:
return None
return f"{parts[0]}/{parts[1]}"


def is_wildcard(glob: str) -> bool:
"""True when the entry is a pattern rather than a literal path."""
return any(character in glob for character in _GLOB_META)


def negations(globs) -> list[str]:
"""Entries written as minimatch leading-``!`` negations."""
return [glob for glob in globs if glob.startswith("!")]


def subtree_globs(globs, root: str) -> list[str]:
"""Sorted wildcard entries that reach into ``root``, negations excluded."""
return sorted(
glob
for glob in globs
if not glob.startswith("!") and is_wildcard(glob) and subtree_root(glob) == root
)


def subtree_roots(globs) -> list[str]:
"""Sorted package subtrees the non-negated wildcard entries reach into."""
roots = {
root
for glob in globs
if not glob.startswith("!")
and is_wildcard(glob)
and (root := subtree_root(glob)) is not None
}
return sorted(roots)


def glob_problems(packages) -> list[str]:
"""One message per `[[package]]` glob entry that would republish a no-op."""
problems = []
for package in packages:
name = package.get("name", "<unnamed>")
globs = package.get("globs", [])
for glob in negations(globs):
problems.append(
f'{name}: glob "{glob}" is a leading-`!` negation, which '
f"putitoutthere does not support -- its matcher ORs the globs "
f"together, so the negation subtracts nothing and instead "
f"matches every path outside its own subtree. Write the "
f"exclusion as an extglob inside a positive pattern: "
f'"{carve_out("<root>")[1]}".'
)
for root in subtree_roots(globs):
found = subtree_globs(globs, root)
expected = carve_out(root)
if found != sorted(expected):
problems.append(
f"{name}: publish globs for {root} are "
f"{', '.join(found)} -- expected {', '.join(expected)}. "
f"A subtree glob that does not carve out "
f"{', '.join((*NON_SHIPPING_DIRS, *NON_SHIPPING_FILES))} "
f"republishes the package for changes that never reach its "
f"artifact. Replace the entries above in putitoutthere.toml."
)
return problems


def unprechecked(exclusions) -> list[str]:
"""One message per precheck exclusion the publish globs would still match.

`release-ci.yml` is the PR-time build precheck; every path it skips must be
a path publishing also skips, or a merge cuts a release the matrix never
built. The comparison is by name rather than by matching, so it needs no
second copy of putitoutthere's glob semantics.
"""
problems = []
for entry in exclusions:
path = entry.removeprefix("!")
root = subtree_root(path)
if root is None:
continue
rest = path[len(root) + 1 :]
if rest.endswith("/**") and rest.removesuffix("/**") in NON_SHIPPING_DIRS:
continue
if rest in NON_SHIPPING_FILES:
continue
problems.append(
f'release-ci.yml excludes "{entry}", but publishing does not: '
f"{rest.removesuffix('/**')} is not one of "
f"{', '.join((*NON_SHIPPING_DIRS, *NON_SHIPPING_FILES))}, so "
f"putitoutthere still cascades {root} on a change there and a merge "
f"releases what the precheck never built. Either drop the exclusion "
f"from release-ci.yml or add the name to NON_SHIPPING_DIRS / "
f"NON_SHIPPING_FILES and re-run this check."
)
return problems
Loading
Loading