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
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,36 @@ uv run tangle sdk published-components publish components/my-component.yaml --dr
uv run tangle sdk published-components deprecate sha256:old --superseded-by sha256:new
```

`publish` accepts `--image`, `--name`, `--description`, `--annotations` (JSON), `--dry-run`, `--published-by`, generic git metadata fields, generic API auth fields, `--log-type`, and `--config`. By default it scopes version checks and automatic old-version deprecation to the current authenticated user via `users_me()`; use `--published-by` to supply an explicit owner/publisher filter. Publishing fails closed if no owner can be determined.
`publish` accepts `--image`, `--name`, `--description`, `--annotations` (JSON), `--dry-run`, `--allow-downgrade`, `--published-by`, generic git metadata fields, generic API auth fields, `--log-type`, and `--config`. By default it scopes version checks and automatic old-version deprecation to the current authenticated user via `users_me()`; use `--published-by` to supply an explicit owner/publisher filter. Publishing fails closed if no owner can be determined.

#### Monotonic publishing and result digests

Publishing is monotonic against the highest **non-deprecated, owner-scoped** published version of the component (ordering comes from `compare_versions`, which zero-pads shorter versions so `1.0.1 > 1.0`):

| Local vs latest published | Outcome | Notes |
| --- | --- | --- |
| nothing published (no non-deprecated owner-scoped version) | `proceed` | first publish |
| local strictly newer | `proceed` | publishes, then deprecates owner-scoped versions proven older |
| local equal | `skip` | no create/deprecate calls |
| local strictly older | `skip` | no-op; never publishes an older version and never deprecates a newer one |
| published version unreadable, or ambiguous tie at the latest version | `error` | fails closed; no create/deprecate calls |

Every result carries the digest of the version it compared against:

- `digest` — digest of a **newly created** publication (SUCCESS only, unchanged meaning).
- `latest_digest` — exact digest of the selected latest published version (set on PROCEED/SKIP, and carried through the SUCCESS/ERROR results that follow a version check). JSON output includes it as `latest_digest`.
- `ProcessingResult.resolved_digest` — the digest a caller should pin: `digest or latest_digest`, but deliberately `None` for any outcome other than SUCCESS/SKIP, so a failed publish never hands back a stale-but-plausible digest.

The check **fails closed** (an `error`, with nothing published and nothing deprecated) whenever the published state cannot be read completely:

- any non-deprecated owner-scoped candidate whose digest is missing, or whose spec/version cannot be fetched or parsed — an unreadable row could be newer than the local version, and must never be deprecated sight-unseen;
- two or more non-deprecated candidates tied at the selected latest version, where no exact digest can be chosen. The reason names the tied digests instead of guessing from API ordering.

Deprecated components are never selected as "latest", and all digest lists in results/logs are sorted, so diagnostics do not depend on API response order.

The published state is re-read immediately before create, and the same policy is re-applied to that fresh observation: a version that appeared concurrently since the first check can still turn the publish into a skip or an error. After a successful create, only digests **proven strictly older** in that final observation are deprecated — a row first seen after the publish decision is never deprecated on the strength of the earlier one. A race after the final read is not preventable client-side and needs a server-side conditional/CAS operation.

**Contract change:** republishing an older version used to proceed (publishing the older spec and deprecating the newer one). It is now a skip. `--allow-downgrade` publishes the older spec but still never deprecates a strictly newer row; deprecate those explicitly with `published-components deprecate` if that is really intended. Deliberate downgrades must opt in with `--allow-downgrade` on the CLI, or `ComponentPublisher(allow_downgrade=True)` / `allow_downgrade=True` on the `publish_component_to_tangle` / `perform_version_check` wrappers. Republishing the same version is still a skip, as before.

There is no separate OSS `publish-all` command. To publish multiple components, pass a YAML/JSON config list, or `_defaults` + `configs`, to the same `published-components publish` command; the command aggregates results and exits nonzero if any component errors. A top-level `_select` node can choose between such documents per environment (see [Environment-selected configs](#environment-selected-configs-_select)).

Expand Down
2 changes: 1 addition & 1 deletion packages/tangle-cli/src/tangle_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@
try:
__version__ = metadata_version("tangle-cli")
except PackageNotFoundError:
__version__ = "0.1.14"
__version__ = "0.1.15"

__all__ = ["TangleDynamicDiscoveryClient", "__version__"]
77 changes: 77 additions & 0 deletions packages/tangle-cli/src/tangle_cli/authenticated_identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""Resolution of the authenticated account, and the symbolic ``me`` publisher.

Compilation is offline, so a compiler cannot write the author's account id into
a component entry. It writes the symbolic publisher :data:`ME` instead, and the
account is resolved at hydration time by whoever is actually authenticated.

Two callers need the account: the publisher, which scopes its version check and
deprecation to the owner, and hydration of entries whose publisher is ``me``.
They must agree, so the parsing lives here once.

They differ only in what an *unknown* account means, so that choice is left to
the caller: the publisher degrades, while resolution must fail closed -- see
:func:`require_authenticated_user_id`.
"""

from __future__ import annotations

from collections.abc import Mapping
from typing import Any

#: Symbolic publisher meaning "whoever is authenticated at hydration time".
#: Matched exactly and case-sensitively, so a literal account id that happens to
#: differ in case is never mistaken for the sentinel.
ME = "me"

__all__ = [
"ME",
"IdentityUnavailableError",
"authenticated_user_id",
"is_symbolic_me",
"require_authenticated_user_id",
]


class IdentityUnavailableError(RuntimeError):
"""The authenticated account could not be determined."""


def is_symbolic_me(publisher: Any) -> bool:
"""Whether ``publisher`` is the symbolic self-reference rather than an id."""
return publisher == ME


def authenticated_user_id(client: Any) -> str | None:
"""Return the current user id, or ``None`` if it cannot be read.

An empty or missing id is as unusable as no answer at all.
"""
try:
user_info = client.users_me()
except Exception:
return None
if user_info is None:
return None
if isinstance(user_info, Mapping):
value = user_info.get("id")
else:
value = getattr(user_info, "id", None)
return str(value) if value else None


def require_authenticated_user_id(client: Any) -> str:
"""Return the current user id, raising when it cannot be determined.

For owner-scoped *resolution* an unknown account must never widen the
search: continuing unscoped is what would let a component published by
someone else under the same name be resolved as the author's own code.
"""
user_id = authenticated_user_id(client)
if not user_id:
raise IdentityUnavailableError(
"Cannot determine the authenticated account, so a component "
f"published by '{ME}' cannot be resolved. Refusing to fall back to "
"an unscoped lookup, which could resolve a component owned by "
"someone else. Re-authenticate and retry."
)
return user_id
10 changes: 10 additions & 0 deletions packages/tangle-cli/src/tangle_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -952,12 +952,22 @@ def find_existing_components(
search_digests.add(str(data["digest"]))

publisher_filter = published_by_substring or published_by
# The API parameter is a SUBSTRING match. When the caller asked for an
# exact owner it is therefore only a prefilter, and accepting its
# results verbatim would let a superset owner id (``alice`` matching
# ``alice2``) satisfy an exact request. An owner-scoped lookup is an
# identity control, so exactness is enforced here on the returned rows
# and a row with no owner is never accepted. Explicit
# ``published_by_substring`` callers keep substring semantics.
exact_owner = published_by if published_by and not published_by_substring else None
found: dict[str, ComponentInfo] = {}

def add(info: ComponentInfo) -> None:
key = info.digest or info.name
if not key:
return
if exact_owner is not None and info.published_by != exact_owner:
return
found[key] = info
if verbose:
self.logger.info(f" Found existing component: {info.name} ({key[:16]}...)")
Expand Down
9 changes: 7 additions & 2 deletions packages/tangle-cli/src/tangle_cli/component_from_func.py
Original file line number Diff line number Diff line change
Expand Up @@ -986,8 +986,13 @@ def _is_main_str(n: ast.expr) -> bool:
# _strip_authoring_constructs). ``registered`` marks an op published separately
# via its own gen_config.yaml; when that same op is baked (through its
# local_from_python entry) the decorator + its authoring import must be stripped
# too, exactly like @task.
_AUTHORING_DECORATOR_NAMES = frozenset({"task", "pipeline", "subpipeline", "registered"})
# too, exactly like @task. ``Publish`` likewise only records a publication
# declaration for the compiler; leaving it in the baked program would raise
# ``NameError`` at container startup, since its import is stripped with the
# rest of the authoring surface.
_AUTHORING_DECORATOR_NAMES = frozenset(
{"task", "pipeline", "subpipeline", "registered", "Publish"}
)

# The python-pipeline authoring modules. ONLY imports of these modules (and
# their submodules) are authoring-only and stripped from the baked source. We
Expand Down
Loading
Loading