Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
6541d2a
checkout python_sdk @v1.22.3
wvandeun Aug 19, 2026
1958a2a
release notes
wvandeun Aug 19, 2026
122fa24
version bump to v1.10.9
wvandeun Aug 19, 2026
bb13582
Merge pull request #10327 from opsmill/prep-release-1.10.9
wvandeun Aug 19, 2026
85201d8
chore: update docker-compose
opsmill-bot Aug 19, 2026
1e79bc2
checkout python_sdk @v1.23.0
wvandeun Aug 19, 2026
992fde3
Merge remote-tracking branch 'origin/stable' into prep-release-1.11.0
wvandeun Aug 19, 2026
c6f1fa1
Merge pull request #10335 from opsmill/prep-release-1.11.0
wvandeun Aug 19, 2026
79760e0
fix version in docker compose file
wvandeun Aug 19, 2026
9868ac6
test: stop the cache and message-bus fixtures from leaking their conf…
Aug 19, 2026
9ce0686
test: do not retry a failed Prefect task manager setup once per test …
Aug 19, 2026
e9b4b6d
fix(graphql): reject user-supplied branched_from on branch creation (…
ajtmccarty Aug 19, 2026
39fa696
perf(testcontainers): replace rabbitmq-diagnostics healthcheck with r…
Aug 19, 2026
b15b659
test: stop the webhook traceback fixture from reconfiguring logging p…
Aug 19, 2026
b0d02d8
test(webhook): guard that the traceback suppression fixture restores …
Aug 19, 2026
f3c7a85
test(log): cover that startup installs the traceback filter on the ru…
Aug 19, 2026
4dff0fe
test(log): cut the rejected alternative from the startup test docstring
Aug 19, 2026
928f911
test(log): move the logging-state guard into the unit suite
Aug 19, 2026
7abb3fe
test(webhook): identify the render test's own flow run instead of cou…
Aug 19, 2026
20fad94
fix(ci): retry cosign transparency-log writes and upload SBOMs first
petercrocker Aug 20, 2026
7c1c161
chore(deps): bump the uv group across 2 directories with 5 updates (#…
dependabot[bot] Aug 20, 2026
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
4 changes: 4 additions & 0 deletions .agents/rules/testing-python.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,10 @@ Skip tests that only exercise library behavior: plain `Enum` value/round-trip ch

If the logic needs only in-memory inputs (a `SchemaBranch`, a dataclass, a pure function), write a unit test without DB fixtures — don't default to a component test because a neighbor uses one. Use the database or containers only when behavior genuinely depends on them.

## Don't leak process-global state

Every test in an xdist worker shares one interpreter. Change `logging` levels/handlers/filters, `structlog` config, module-level registries/singletons, `sys.path`/`sys.modules` or env vars only through a save/restore fixture (change it, `yield`, restore it), or `monkeypatch` where it applies. Never call an application startup routine such as `infrahub.log.configure_logging` from a test — it owns the whole process and undoes nothing, so it reconfigures every later test in the worker. Install only the piece under test and remove it after the `yield`. See `dev/guidelines/backend/testing.md` §"Leave process-global state as you found it".

## Test file placement

Test files mirror source structure: `infrahub/core/node.py` → `tests/unit/core/test_node.py`
Expand Down
86 changes: 70 additions & 16 deletions .github/workflows/ci-docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,33 @@ jobs:
password: ${{ secrets.HARBOR_PASSWORD }}

- name: Sign manifest
env:
IMAGE: ${{ vars.HARBOR_HOST }}/${{ github.repository }}@${{ needs.merge.outputs.digest }}
run: |
cosign sign --yes --recursive --new-bundle-format=false --use-signing-config=false \
"${{ vars.HARBOR_HOST }}/${{ github.repository }}@${{ needs.merge.outputs.digest }}"
# cosign gives up after two internal attempts when a Sigstore transparency
# log write fails, so a brief rekor.sigstore.dev blip is enough to fail a
# release. Retry the whole command instead; five attempts 60s apart cover a
# ~4-minute outage. The sbom job carries a verbatim copy of this function:
# the jobs run on separate runners, so sharing it would take a checkout or a
# third-party action in the signing path. Keep the two copies identical.
retry() {
local attempt
for attempt in 1 2 3 4 5; do
if "$@"; then
return 0
fi
if [ "${attempt}" -lt 5 ]; then
echo "::warning::${1} ${2} failed (attempt ${attempt}/5), retrying in 60s"
sleep 60
fi
done
echo "::error::${1} ${2} failed after 5 attempts"
return 1
}

retry cosign sign --yes --recursive \
--new-bundle-format=false --use-signing-config=false \
"${IMAGE}"

sbom:
needs: merge
Expand Down Expand Up @@ -236,20 +260,16 @@ jobs:
"${{ vars.HARBOR_HOST }}/${{ github.repository }}@${{ needs.merge.outputs.digest }}" \
--output cyclonedx-json=infrahub-sbom.cdx.json

- name: Attest SBOM (SPDX)
run: |
cosign attest --yes \
--type spdxjson \
--predicate infrahub-sbom.spdx.json \
"${{ vars.HARBOR_HOST }}/${{ github.repository }}@${{ needs.merge.outputs.digest }}"

- name: Attest SBOM (CycloneDX)
run: |
cosign attest --yes \
--type cyclonedx \
--predicate infrahub-sbom.cdx.json \
"${{ vars.HARBOR_HOST }}/${{ github.repository }}@${{ needs.merge.outputs.digest }}"

# Uploaded before the attestations so that a transparency-log outage cannot cost
# us the SBOMs themselves: v1.10.7 shipped without any because the attest step
# failed first and skipped this one.
#
# overwrite is required precisely because this now runs before a step that can
# fail. Artifacts are scoped to the run rather than the attempt, so on a re-run
# this name already exists from the earlier attempt and the default overwrite:
# false would fail the upload, breaking the recovery path this ordering exists to
# protect. Every caller passes a version unique to its invocation, so the only
# artifact this can replace is the same SBOM from a previous attempt.
- name: Upload SBOM artifacts
uses: actions/upload-artifact@v7
with:
Expand All @@ -258,3 +278,37 @@ jobs:
infrahub-sbom.spdx.json
infrahub-sbom.cdx.json
retention-days: 90
overwrite: true

- name: Attest SBOMs
env:
IMAGE: ${{ vars.HARBOR_HOST }}/${{ github.repository }}@${{ needs.merge.outputs.digest }}
run: |
# Same transparency-log flakiness the sign job guards against; this is a
# verbatim copy of that job's retry function (separate runners, so it cannot
# be shared without a checkout or a third-party action in the signing path).
# Keep the two copies identical.
retry() {
local attempt
for attempt in 1 2 3 4 5; do
if "$@"; then
return 0
fi
if [ "${attempt}" -lt 5 ]; then
echo "::warning::${1} ${2} failed (attempt ${attempt}/5), retrying in 60s"
sleep 60
fi
done
echo "::error::${1} ${2} failed after 5 attempts"
return 1
}

retry cosign attest --yes \
--type spdxjson \
--predicate infrahub-sbom.spdx.json \
"${IMAGE}"

retry cosign attest --yes \
--type cyclonedx \
--predicate infrahub-sbom.cdx.json \
"${IMAGE}"
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,13 @@ docker compose restart
- Restructured the node stages of `development/Dockerfile` (shared node base, frontend, docs) with BuildKit cache mounts, enabling parallel builds and much better layer-cache reuse; `pnpm install` only re-runs when a manifest changes.
- Significantly reduced the size of the Infrahub container image: the build toolchain now lives in a dedicated build stage that is excluded from the runtime image, and the `numpy` and `pyarrow` dependencies are no longer installed by default (`pyarrow` remains available via the `object-transfer` extra for `infrahubctl object load`).

## [Infrahub - v1.10.9](https://github.com/opsmill/infrahub/tree/infrahub-v1.10.9) - 2026-08-19

### Fixed

- Fixed a crash when loading the Tasks page where a task was tagged with a related node whose kind could no longer be resolved (for example a deleted definition or a stale tag). Such unresolvable related nodes are now omitted from the task instead of causing a GraphQL resolver error. ([#9662](https://github.com/opsmill/infrahub/issues/9662))
- Renaming an attribute in a schema on a branch now correctly closes the old attribute instead of only opening a newer path to the new attribute. This issue would have been mostly invisible to the user unless an attribute was renamed on a user's branch and that branch was then rebased, in which case there could be duplicated paths to the new attribute which could result in unexpected behavior when updating its value.

## [Infrahub - v1.10.8](https://github.com/opsmill/infrahub/tree/infrahub-v1.10.8) - 2026-08-14

### Fixed
Expand Down
26 changes: 20 additions & 6 deletions backend/infrahub/graphql/mutations/branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,19 @@ class BranchCreateInput(InputObjectType):
id = String(required=False)
name = String(required=True)
description = String(required=False)
origin_branch = String(required=False)
branched_from = String(required=False)
origin_branch = InputField(
String(required=False),
deprecation_reason="Branches can only be created from the default branch. Will be removed after version 1.12.",
)
branched_from = InputField(
String(required=False),
deprecation_reason="branched_from is set by the server and cannot be provided. Will be removed after version 1.12.",
)
sync_with_git = Boolean(required=False)
is_isolated = InputField(Boolean(required=False), deprecation_reason="Non isolated mode is not supported anymore")
is_isolated = InputField(
Boolean(required=False),
deprecation_reason="Non-isolated mode is not supported anymore. Will be removed after version 1.12.",
)


class BranchCreate(Mutation):
Expand All @@ -77,13 +86,18 @@ async def mutate(
background_execution: bool = False,
wait_until_completion: bool = True,
) -> Self:
if data.origin_branch and data.origin_branch != registry.default_branch:
raise ValueError(f"origin_branch must be '{registry.default_branch}'")
origin_branch = data.get("origin_branch")
if origin_branch is not None and origin_branch != registry.default_branch:
raise ValidationError(f"origin_branch must be '{registry.default_branch}'")
if data.get("branched_from") is not None:
raise ValidationError(
"branched_from input is deprecated and cannot be set, it will be the create time of the branch."
)

graphql_context: GraphqlContext = info.context
task: dict | None = None

model = BranchCreateModel(**data)
model = BranchCreateModel(**{key: value for key, value in data.items() if value is not None})
await apply_external_context(graphql_context=graphql_context, context_input=context)

try:
Expand Down
25 changes: 18 additions & 7 deletions backend/infrahub/log.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,22 @@ def filter(self, record: logging.LogRecord) -> bool:
return type(exception) not in self._suppressed_types


def install_traceback_suppression_filter() -> TracebackSuppressionFilter:
"""Install the traceback suppression filter on the Prefect run loggers and return it.

Prefect ships flow/task run logs to its API; drop tracebacks for failures that are reported as a
clean classified reason rather than a crash to debug. The filter reads the shared registry that
each expected-failure type opts into via suppress_traceback_in_logs.

The installed filter is returned so a caller that must leave logging state as it found it can
remove it again from every logger in PREFECT_RUN_LOGGERS.
"""
traceback_filter = TracebackSuppressionFilter(_TRACEBACK_SUPPRESSED_TYPES)
for prefect_logger_name in PREFECT_RUN_LOGGERS:
logging.getLogger(prefect_logger_name).addFilter(traceback_filter)
return traceback_filter


def clear_log_context() -> None:
structlog.contextvars.clear_contextvars()

Expand Down Expand Up @@ -86,13 +102,8 @@ def configure_logging(production: bool, log_level: str) -> None:
# the infrahub logger
importlib.import_module("prefect.main")

# Prefect ships flow/task run logs to its API; drop tracebacks for failures that
# are reported as a clean classified reason rather than a crash to debug. Installed after the
# prefect.main import above so it survives Prefect's logging reset; reads the shared registry that
# each expected-failure type opts into via suppress_traceback_in_logs.
traceback_filter = TracebackSuppressionFilter(_TRACEBACK_SUPPRESSED_TYPES)
for prefect_logger_name in PREFECT_RUN_LOGGERS:
logging.getLogger(prefect_logger_name).addFilter(traceback_filter)
# Installed after the prefect.main import above so it survives Prefect's logging reset.
install_traceback_suppression_filter()

shared_processors: list[Processor] = [
structlog.contextvars.merge_contextvars,
Expand Down
154 changes: 154 additions & 0 deletions backend/tests/component/graphql/mutations/test_branch.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from dataclasses import dataclass
from typing import Any
from unittest.mock import AsyncMock, patch

Expand All @@ -19,6 +20,7 @@
from infrahub.core.schema.schema_branch import SchemaBranch
from infrahub.core.timestamp import Timestamp
from infrahub.database import InfrahubDatabase
from infrahub.exceptions import BranchNotFoundError
from infrahub.graphql.initialization import prepare_graphql_params
from infrahub.services import InfrahubServices
from infrahub.services.adapters.workflow.local import WorkflowLocalExecution
Expand All @@ -28,6 +30,158 @@
from tests.helpers.graphql import graphql, graphql_mutation
from tests.helpers.test_app import TestInfrahubApp

BRANCH_CREATE = """
mutation(
$name: String!
$description: String
$originBranch: String
$branchedFrom: String
$syncWithGit: Boolean
) {
BranchCreate(
data: {
name: $name
description: $description
origin_branch: $originBranch
branched_from: $branchedFrom
sync_with_git: $syncWithGit
}
) {
ok
object {
id
name
description
origin_branch
branched_from
sync_with_git
}
}
}
"""

BRANCHED_FROM_ERROR = "branched_from input is deprecated and cannot be set, it will be the create time of the branch."


@dataclass
class RejectedInputTestCase:
name: str
"""Descriptive name for the test scenario."""

branch_name: str
"""Name of the branch the mutation attempts to create."""

variables: dict[str, Any]
"""Optional BranchCreate input variables sent alongside the branch name."""

expected_message: str
"""The exact GraphQL error message the mutation must return."""


REJECTED_INPUT_TEST_CASES: list[RejectedInputTestCase] = [
RejectedInputTestCase(
name="branched_from_timestamp_rejected",
branch_name="own-branched-from",
variables={"branchedFrom": "2020-01-01T00:00:00.000Z"},
expected_message=BRANCHED_FROM_ERROR,
),
RejectedInputTestCase(
name="branched_from_empty_string_rejected",
branch_name="empty-branched-from",
variables={"branchedFrom": ""},
expected_message=BRANCHED_FROM_ERROR,
),
RejectedInputTestCase(
name="origin_branch_other_than_default_rejected",
branch_name="other-origin-branch",
variables={"originBranch": "not-the-default-branch"},
expected_message="origin_branch must be 'main'",
),
RejectedInputTestCase(
name="origin_branch_empty_string_rejected",
branch_name="empty-origin-branch",
variables={"originBranch": ""},
expected_message="origin_branch must be 'main'",
),
]


class TestBranchCreateInputValidation(TestInfrahubApp):
@pytest.mark.parametrize(
"test_case",
[pytest.param(tc, id=tc.name) for tc in REJECTED_INPUT_TEST_CASES],
)
async def test_server_owned_input_is_rejected(
self,
db: InfrahubDatabase,
default_branch: Branch,
register_core_models_schema: SchemaBranch,
session_admin: AccountSession,
client: InfrahubClient,
service: InfrahubServices,
test_case: RejectedInputTestCase,
) -> None:
"""branched_from and origin_branch are decided by the server, so a client-supplied value is an input error."""
result = await graphql_mutation(
query=BRANCH_CREATE,
db=db,
service=service,
branch=default_branch,
account_session=session_admin,
variables={"name": test_case.branch_name} | test_case.variables,
)

assert result.errors is not None
assert len(result.errors) == 1
assert result.errors[0].message == test_case.expected_message

with pytest.raises(BranchNotFoundError):
await Branch.get_by_name(db=db, name=test_case.branch_name)

@pytest.mark.parametrize(
("branch_name", "variables"),
[
pytest.param("omitted-optional-input", {}, id="optional_input_omitted"),
pytest.param(
"null-optional-input",
{"description": None, "originBranch": None, "branchedFrom": None, "syncWithGit": None},
id="optional_input_explicitly_null",
),
],
)
async def test_unset_optional_input_falls_back_to_defaults(
self,
db: InfrahubDatabase,
default_branch: Branch,
register_core_models_schema: SchemaBranch,
session_admin: AccountSession,
client: InfrahubClient,
service: InfrahubServices,
branch_name: str,
variables: dict[str, Any],
) -> None:
"""An explicit null says no more than omitting the field, so both must land on the server defaults."""
result = await graphql_mutation(
query=BRANCH_CREATE,
db=db,
service=service,
branch=default_branch,
account_session=session_admin,
variables={"name": branch_name} | variables,
)

assert result.errors is None
assert result.data
assert result.data["BranchCreate"]["ok"] is True

branch = await Branch.get_by_name(db=db, name=branch_name)
assert isinstance(branch.description, str)
assert not branch.description
assert branch.origin_branch == default_branch.name
assert branch.sync_with_git is True
assert isinstance(branch.branched_from, str)
assert branch.branched_from


class TestBranchCreate(TestInfrahubApp):
async def test_branch_create(
Expand Down
Loading
Loading