diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0c26083a..98c60443 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -140,8 +140,65 @@ jobs: path: release-assets/ if-no-files-found: error + build-agent-notify-native: + name: agent-notify native (${{ matrix.platform }}) + needs: validate-release + if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v') + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - platform: linux-amd64 + goos: linux + goarch: amd64 + - platform: linux-arm64 + goos: linux + goarch: arm64 + - platform: darwin-amd64 + goos: darwin + goarch: amd64 + - platform: darwin-arm64 + goos: darwin + goarch: arm64 + - platform: windows-amd64 + goos: windows + goarch: amd64 + defaults: + run: + working-directory: stations/notify + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v5 + with: + go-version: stable + cache: false + - name: Build agent-notify binary with exact release metadata + env: + CGO_ENABLED: '0' + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + AGENT_NOTIFY_TAG: ${{ github.ref_name }} + AGENT_NOTIFY_COMMIT: ${{ github.sha }} + run: | + mkdir -p "$GITHUB_WORKSPACE/release-assets" + suffix="" + if [ "$GOOS" = windows ]; then suffix=.exe; fi + # Tag version without the leading v, the full release commit SHA, and + # one UTC build timestamp shared by every ldflags -X injection so the + # five platform assets report identical release metadata instead of + # the dev/unknown/unknown defaults a bare `go build` leaves behind. + VERSION="${AGENT_NOTIFY_TAG#v}" + BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + go build -trimpath -ldflags "-X main.version=${VERSION} -X main.commit=${AGENT_NOTIFY_COMMIT} -X main.buildDate=${BUILD_DATE} -s -w" -o "$GITHUB_WORKSPACE/release-assets/agent-notify-${{ matrix.platform }}$suffix" ./cmd/agent-notify + - uses: actions/upload-artifact@v4 + with: + name: agent-notify-${{ matrix.platform }} + path: release-assets/ + if-no-files-found: error + assemble-release: - needs: [build-rust-native, build-go-native] + needs: [build-rust-native, build-go-native, build-agent-notify-native] runs-on: ubuntu-latest permissions: contents: write @@ -158,19 +215,23 @@ jobs: with: pattern: go-* path: downloaded/go + - uses: actions/download-artifact@v4 + with: + pattern: agent-notify-* + path: downloaded/agent-notify - name: Generate and validate complete release inventory env: TAG: ${{ github.ref_name }} COMMIT: ${{ github.sha }} run: | mkdir release-assets - test "$(find downloaded -type f | wc -l)" -eq 20 + test "$(find downloaded -type f | wc -l)" -eq 25 test -z "$(find downloaded -type f -printf '%f\n' | sort | uniq -d)" find downloaded -type f -exec cp {} release-assets/ \; python scripts/generate_component_manifest.py --tag "$TAG" --commit "$COMMIT" \ --assets-dir release-assets --output release-assets/component-manifest-v1.json \ --checksums-output release-assets/checksums.txt - test "$(find release-assets -maxdepth 1 -type f | wc -l)" -eq 22 + test "$(find release-assets -maxdepth 1 -type f | wc -l)" -eq 27 - uses: actions/attest@v4 with: subject-path: | @@ -194,6 +255,11 @@ jobs: release-assets/sessionfind-darwin-amd64 release-assets/sessionfind-darwin-arm64 release-assets/sessionfind-windows-amd64.exe + release-assets/agent-notify-linux-amd64 + release-assets/agent-notify-linux-arm64 + release-assets/agent-notify-darwin-amd64 + release-assets/agent-notify-darwin-arm64 + release-assets/agent-notify-windows-amd64.exe release-assets/component-manifest-v1.json - uses: actions/upload-artifact@v4 with: @@ -285,7 +351,7 @@ jobs: run: | mkdir release-assets gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir release-assets - test "$(find release-assets -maxdepth 1 -type f | wc -l)" -eq 22 + test "$(find release-assets -maxdepth 1 -type f | wc -l)" -eq 27 (cd release-assets && sha256sum --check checksums.txt) python scripts/verify_component_manifest_provenance.py --manifest release-assets/component-manifest-v1.json while read -r _digest asset; do diff --git a/docs/component-manifest-v1.schema.json b/docs/component-manifest-v1.schema.json index 283efc7f..3f16a9cc 100644 --- a/docs/component-manifest-v1.schema.json +++ b/docs/component-manifest-v1.schema.json @@ -26,8 +26,41 @@ }, "components": { "type": "object", - "required": ["graphtrail", "graphtrail-mcp", "miseledger", "sessionfind"], + "required": ["agent-notify", "graphtrail", "graphtrail-mcp", "miseledger", "sessionfind"], "properties": { + "agent-notify": { + "allOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["component_revision", "source", "executable", "assets"], + "properties": { + "component_revision": {"$ref": "#/$defs/graphtrail_component_revision"}, + "executable": {"const": "agent-notify"}, + "source": {}, + "assets": {} + } + }, + { + "oneOf": [ + { + "properties": { + "source": {"$ref": "#/$defs/unpublished_source"}, + "assets": {"$ref": "#/$defs/empty_assets"} + }, + "required": ["source", "assets"] + }, + { + "properties": { + "source": {"$ref": "#/$defs/published_source"}, + "assets": {"$ref": "#/$defs/published_assets"} + }, + "required": ["source", "assets"] + } + ] + } + ] + }, "graphtrail": { "allOf": [ { diff --git a/scripts/generate_component_manifest.py b/scripts/generate_component_manifest.py index 351550df..dccd21dc 100644 --- a/scripts/generate_component_manifest.py +++ b/scripts/generate_component_manifest.py @@ -11,7 +11,7 @@ from typing import Any -COMPONENT_IDS = ("graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") +COMPONENT_IDS = ("agent-notify", "graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") SUPPORTED_PLATFORMS = ("linux-amd64", "linux-arm64", "darwin-amd64", "darwin-arm64", "windows-amd64") REPOSITORY = "escoffier-labs/brigade" _COMMIT = re.compile(r"^[0-9a-f]{40}$") diff --git a/scripts/published-artifact-acceptance.py b/scripts/published-artifact-acceptance.py index 6e1bf8f3..a529af1c 100644 --- a/scripts/published-artifact-acceptance.py +++ b/scripts/published-artifact-acceptance.py @@ -6,6 +6,7 @@ import argparse import json import os +import re import shlex import stat import subprocess @@ -17,12 +18,20 @@ from typing import Any, Callable, Mapping, Sequence -COMPONENT_IDS = ("graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") +COMPONENT_IDS = ("agent-notify", "graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") SUPPORTED_PLATFORMS = ("linux-amd64", "linux-arm64", "darwin-amd64", "darwin-arm64", "windows-amd64") REPOSITORY = "escoffier-labs/brigade" PYPI_PROJECT_URL = "https://pypi.org/pypi/brigade-cli/json" PYPI_AVAILABILITY_TIMEOUT_SECONDS = 6 * 60 PYPI_POLL_INTERVAL_SECONDS = 5 +# agent-notify ldflags inject main.version, main.commit, and main.buildDate. +# A bare `go build` leaves "dev" / "unknown" / "unknown"; published release +# assets must report the exact Brigade release version, a hex git SHA (the +# release build injects the full github.sha, but a short SHA is also valid), +# and a UTC build timestamp shaped like YYYY-MM-DDTHH:MM:SSZ. +_PLACEHOLDER_METADATA = {"dev", "unknown"} +_COMMIT_SHA_RE = re.compile(r"^[0-9a-f]{7,40}$") +_BUILD_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") Runner = Callable[..., subprocess.CompletedProcess[str]] JsonFetcher = Callable[[str], Any] BytesFetcher = Callable[[str], bytes] @@ -111,7 +120,7 @@ def verify_release_assets( raise AcceptanceError("release component manifest has no components object") components = manifest["components"] if set(components) != set(COMPONENT_IDS): - raise AcceptanceError("release component manifest must contain exactly four components") + raise AcceptanceError("release component manifest must contain exactly five components") expected: dict[str, tuple[str, str]] = {} native_paths: dict[str, dict[str, Path]] = {component: {} for component in COMPONENT_IDS} @@ -150,7 +159,7 @@ def verify_release_assets( raise AcceptanceError(f"could not fetch release checksums.txt: {exc}") from exc expected_checksum_names = set(expected) | {"component-manifest-v1.json"} if set(checksums) != expected_checksum_names: - raise AcceptanceError("checksums.txt must cover exactly all 20 native assets and component-manifest-v1.json") + raise AcceptanceError("checksums.txt must cover exactly all 25 native assets and component-manifest-v1.json") if checksums.get("component-manifest-v1.json") != _sha256_bytes(manifest_bytes): raise AcceptanceError("release manifest digest does not match checksums.txt") (release_dir / "component-manifest-v1.json").write_bytes(manifest_bytes) @@ -248,7 +257,7 @@ def validate_component_report(report: Any, managed_bin: Path) -> dict[str, Path] raise AcceptanceError("component report did not contain a components list") components = report["components"] if len(components) != len(COMPONENT_IDS): - raise AcceptanceError(f"expected exactly 4 components, got {len(components)}") + raise AcceptanceError(f"expected exactly 5 components, got {len(components)}") root = managed_bin.resolve() managed_paths: dict[str, Path] = {} @@ -285,8 +294,38 @@ def validate_component_report(report: Any, managed_bin: Path) -> dict[str, Path] return managed_paths +def validate_agent_notify_version_payload(payload: Any, version: str) -> None: + """Require agent-notify version JSON to carry the exact release metadata. + + A bare `go build` leaves main.version/main.commit/main.buildDate at their + `dev`/`unknown`/`unknown` defaults. Published release assets must report the + requested Brigade release version, a hex git SHA (the release build injects + the full github.sha, but a short SHA is also accepted), and a UTC build + timestamp. `dev`/`unknown` placeholders are rejected for every field. + """ + if not isinstance(payload, dict): + raise AcceptanceError("agent-notify smoke returned a non-object version payload") + actual_version = payload.get("version") + if not isinstance(actual_version, str) or not actual_version: + raise AcceptanceError("agent-notify smoke JSON missing version field") + if actual_version in _PLACEHOLDER_METADATA: + raise AcceptanceError(f"agent-notify version must not report dev/unknown metadata: {actual_version!r}") + if actual_version != version: + raise AcceptanceError(f"agent-notify version mismatch: expected {version!r}, got {actual_version!r}") + commit = payload.get("commit") + if not isinstance(commit, str) or commit in _PLACEHOLDER_METADATA or not _COMMIT_SHA_RE.match(commit): + raise AcceptanceError("agent-notify commit must be a hex git SHA (short or full SHA), not 'unknown'") + build_date = payload.get("build_date") + if not isinstance(build_date, str) or build_date in _PLACEHOLDER_METADATA or not _BUILD_DATE_RE.match(build_date): + raise AcceptanceError("agent-notify build_date must be a UTC timestamp (YYYY-MM-DDTHH:MM:SSZ), not 'unknown'") + + def smoke_managed_components( - managed_paths: Mapping[str, Path], *, runner: Runner = subprocess.run, env: Mapping[str, str] | None = None + managed_paths: Mapping[str, Path], + *, + version: str, + runner: Runner = subprocess.run, + env: Mapping[str, str] | None = None, ) -> None: graphtrail = run_checked([managed_paths["graphtrail"], "--version"], runner=runner, env=env) if not graphtrail.stdout.strip(): @@ -311,6 +350,12 @@ def smoke_managed_components( line.strip().startswith("sessionfind ") for line in sessionfind.stdout.splitlines() ): raise AcceptanceError("sessionfind smoke produced no help text") + agent_notify = run_checked([managed_paths["agent-notify"], "version", "--json"], runner=runner, env=env) + try: + agent_notify_payload = json.loads(agent_notify.stdout) + except json.JSONDecodeError as exc: + raise AcceptanceError("agent-notify smoke returned malformed JSON") from exc + validate_agent_notify_version_payload(agent_notify_payload, version) def smoke_rosetta_darwin_amd64(native_paths: Mapping[str, Mapping[str, Path]], *, runner: Runner) -> None: @@ -399,7 +444,7 @@ def run_acceptance(version: str, *, runner: Runner = subprocess.run, rosetta_dar raise AcceptanceError("brigade version --components --json returned malformed JSON") from exc managed_paths = validate_component_report(report, managed_bin_path(data_home, profile)) verify_managed_component_digests(release["manifest"], managed_paths, host_platform_key()) - smoke_managed_components(managed_paths, runner=runner, env=env) + smoke_managed_components(managed_paths, version=version, runner=runner, env=env) if rosetta_darwin_amd64: smoke_rosetta_darwin_amd64(release["native_paths"], runner=runner) finally: diff --git a/scripts/verify_component_manifest_provenance.py b/scripts/verify_component_manifest_provenance.py index 9bd83f9e..da553ab5 100644 --- a/scripts/verify_component_manifest_provenance.py +++ b/scripts/verify_component_manifest_provenance.py @@ -19,7 +19,7 @@ ROOT = Path(__file__).resolve().parent.parent DEFAULT_MANIFEST = ROOT / "src/brigade/templates/components/manifest-v1.json" REPOSITORY = "escoffier-labs/brigade" -COMPONENT_IDS = ("graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") +COMPONENT_IDS = ("agent-notify", "graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") SUPPORTED_PLATFORMS = ("linux-amd64", "linux-arm64", "darwin-amd64", "darwin-arm64", "windows-amd64") USER_AGENT = "brigade-component-manifest-provenance/1.0" _SHA256 = re.compile(r"^[0-9a-f]{64}$") @@ -136,7 +136,7 @@ def verify_manifest(manifest_path: Path, *, fetch: FetchFn = default_fetch) -> l if not isinstance(components, dict): return ["component manifest field 'components' must be an object"] if set(components) != set(COMPONENT_IDS): - errors.append("component manifest must contain exactly graphtrail, graphtrail-mcp, miseledger, sessionfind") + errors.append("component manifest must contain exactly " + ", ".join(COMPONENT_IDS)) tag: str | None = None expected_native: dict[str, dict[str, Any]] = {} diff --git a/scripts/windows-native-acceptance.ps1 b/scripts/windows-native-acceptance.ps1 index f52bb607..8e23976f 100644 --- a/scripts/windows-native-acceptance.ps1 +++ b/scripts/windows-native-acceptance.ps1 @@ -329,7 +329,7 @@ function Assert-ReleaseManifestAndAssets { $checksums[$Matches[2]] = $Matches[1] } $expected = @() - foreach ($componentId in @("graphtrail", "graphtrail-mcp", "miseledger", "sessionfind")) { + foreach ($componentId in @("agent-notify", "graphtrail", "graphtrail-mcp", "miseledger", "sessionfind")) { $component = $manifest.components.$componentId if (-not $component -or $component.source.repository -ne "escoffier-labs/brigade" -or $component.source.release_tag -ne $tag) { throw "release manifest component $componentId does not point to escoffier-labs/brigade@$tag" @@ -347,8 +347,8 @@ function Assert-ReleaseManifestAndAssets { } } } - if ($expected.Count -ne 20 -or $checksums.Count -ne 21 -or -not $checksums.ContainsKey("component-manifest-v1.json")) { - throw "release checksums must contain exactly 20 native assets and component-manifest-v1.json" + if ($expected.Count -ne 25 -or $checksums.Count -ne 26 -or -not $checksums.ContainsKey("component-manifest-v1.json")) { + throw "release checksums must contain exactly 25 native assets and component-manifest-v1.json" } if ((Get-FileHash -Algorithm SHA256 -LiteralPath $manifestPath).Hash.ToLowerInvariant() -ne $checksums["component-manifest-v1.json"]) { throw "release manifest digest mismatch" @@ -362,7 +362,7 @@ function Assert-ManagedComponentDigests { $Report, [string]$ManagedBin ) - foreach ($componentId in @("graphtrail", "graphtrail-mcp", "miseledger", "sessionfind")) { + foreach ($componentId in @("agent-notify", "graphtrail", "graphtrail-mcp", "miseledger", "sessionfind")) { $path = Get-ManagedExecutablePath -Report $Report -ComponentId $componentId -ManagedBin $ManagedBin $expected = $Manifest.components.$componentId.assets."windows-amd64".sha256 if ((Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash.ToLowerInvariant() -ne $expected) { @@ -392,18 +392,92 @@ function Assert-OperatorDoctorReady { } } +function Get-UnpublishedComponentIds { + param([string]$ManifestPath) + # Source-mode acceptance runs against the bundled compatibility manifest, + # which intentionally carries not-yet-released components with an empty + # asset matrix so the loader accepts the manifest before the first release + # pins real native assets. Those entries are the only components source + # acceptance may skip: a component with declared assets must still install + # and smoke, even if the report labels it "unsupported". + # + # Windows PowerShell 5.1 treats an empty JSON object as a truthy + # PSCustomObject whose .PSObject.Properties.Count is unreliable unless the + # Properties collection is forced through @(). Without that wrapper, + # agent-notify's empty assets map is never classified as unpublished and the + # post-setup health assertion fails with unsupported-component-platform. + if (-not (Test-Path -LiteralPath $ManifestPath)) { + throw "bundled compatibility manifest not found at $ManifestPath" + } + $manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json + $unpublished = [System.Collections.Generic.List[string]]::new() + foreach ($property in $manifest.components.PSObject.Properties) { + $assets = $property.Value.assets + if ($null -eq $assets -or @($assets.PSObject.Properties).Count -eq 0) { + $unpublished.Add([string]$property.Name) + } + } + # Write-Output -NoEnumerate keeps a single empty-asset id as a one-element + # string[] instead of unrolling to a scalar that foreach would iterate by char. + Write-Output -NoEnumerate $unpublished.ToArray() +} + function Assert-AllComponentsHealthy { - param($Report) - if ($Report.components.Count -ne 4) { - throw "expected 4 components, got $($Report.components.Count)" + param( + $Report, + [string[]]$Skippable = @() + ) + if ($Report.components.Count -ne 5) { + throw "expected 5 components, got $($Report.components.Count)" } + $skippableSet = @{} + foreach ($id in $Skippable) { $skippableSet[$id] = $true } foreach ($component in $Report.components) { + if ($skippableSet.ContainsKey($component.component_id)) { + # The bundled compatibility manifest carries this component with no + # pinned assets, so source setup skips it and the report must show + # "unsupported". A healthy status here would mean setup installed an + # unpublished component, which is a contract violation. + if ($component.status -ne "unsupported") { + throw "skippable component $($component.component_id) must be unsupported in source mode, got $($component.status): $($component.detail)" + } + continue + } if ($component.status -ne "healthy") { throw "component $($component.component_id) is $($component.status): $($component.detail)" } } } +function Assert-AgentNotifyVersionMetadata { + param( + $Payload, + [string]$Version + ) + # A bare `go build` leaves main.version/main.commit/main.buildDate at their + # dev/unknown/unknown defaults. Published release assets must report the + # requested Brigade release version, a hex git SHA (the release build + # injects the full github.sha, but a short SHA is also valid), and a UTC + # build timestamp. This only runs in pypi mode; source mode skips + # unpublished components (agent-notify has no pinned assets in the bundled + # compatibility manifest), so the skip behavior is preserved. + if (-not $Payload -or -not $Payload.version) { + throw "agent-notify smoke JSON missing version field" + } + if ($Payload.version -eq "dev" -or $Payload.version -eq "unknown") { + throw "agent-notify version must not report dev/unknown metadata: $($Payload.version)" + } + if ($Version -and $Payload.version -ne $Version) { + throw "agent-notify version mismatch: expected $Version, got $($Payload.version)" + } + if (-not $Payload.commit -or $Payload.commit -eq "unknown" -or $Payload.commit -notmatch '^[0-9a-f]{7,40}$') { + throw "agent-notify commit must be a hex git SHA (short or full SHA), not 'unknown'" + } + if (-not $Payload.build_date -or $Payload.build_date -eq "unknown" -or $Payload.build_date -notmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$') { + throw "agent-notify build_date must be a UTC timestamp (YYYY-MM-DDTHH:MM:SSZ), not 'unknown'" + } +} + function Get-ManagedExecutablePath { param( $Report, @@ -509,8 +583,20 @@ try { & brigade --version if ($LASTEXITCODE -ne 0) { throw "brigade --version failed" } + # Source-mode acceptance runs against the bundled compatibility manifest, + # which carries not-yet-released components with empty assets. Compute that + # empty-asset set before both setup invocations so online and offline setup + # are only expected to install published components; post-setup health and + # smoke assertions reuse the same set. Published/release acceptance never + # skips anything and keeps bare setup (every published component). + [string[]]$unpublishedIds = @() if ($InstallMode -eq "source") { + $bundledManifestPath = Join-Path $RepoRoot "src\brigade\templates\components\manifest-v1.json" + [string[]]$unpublishedIds = @(Get-UnpublishedComponentIds -ManifestPath $bundledManifestPath) + Write-Step "brigade setup (online)" + # Standalone manifest + published_component_ids omits empty-asset entries + # such as agent-notify; do not add flags that would request them. & brigade setup --manifest-source standalone if ($LASTEXITCODE -ne 0) { throw "brigade setup failed" } @@ -529,11 +615,13 @@ try { } $report = Get-ComponentReport -StderrRoot $acceptRoot - Assert-AllComponentsHealthy $report + Assert-AllComponentsHealthy -Report $report -Skippable $unpublishedIds $managedBin = Join-Path $env:LOCALAPPDATA "brigade\bin" if ($InstallMode -eq "pypi") { Assert-ManagedComponentDigests -Manifest $releaseManifest -Report $report -ManagedBin $managedBin } + $requiredIds = @("agent-notify", "graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") | + Where-Object { $unpublishedIds -notcontains $_ } $graphtrailExe = Get-ManagedExecutablePath -Report $report -ComponentId "graphtrail" -ManagedBin $managedBin $graphtrailMcpExe = Get-ManagedExecutablePath -Report $report -ComponentId "graphtrail-mcp" -ManagedBin $managedBin $miseledgerExe = Get-ManagedExecutablePath -Report $report -ComponentId "miseledger" -ManagedBin $managedBin @@ -543,6 +631,17 @@ try { if ($LASTEXITCODE -ne 0 -or $mcpResponse -notmatch '"jsonrpc"') { throw "graphtrail-mcp absolute-path smoke failed" } & $sessionfindExe --help | Out-Null if ($LASTEXITCODE -ne 0) { throw "sessionfind absolute-path smoke failed" } + if ($requiredIds -contains "agent-notify") { + $agentNotifyExe = Get-ManagedExecutablePath -Report $report -ComponentId "agent-notify" -ManagedBin $managedBin + $agentNotifyVersion = & $agentNotifyExe version --json + if ($LASTEXITCODE -ne 0) { throw "agent-notify absolute-path smoke failed" } + try { + $agentNotifyPayload = $agentNotifyVersion | ConvertFrom-Json + } catch { + throw "agent-notify absolute-path smoke returned malformed JSON" + } + Assert-AgentNotifyVersionMetadata -Payload $agentNotifyPayload -Version $BrigadeVersion + } $workRepo = Join-Path $acceptRoot "repo" New-Item -ItemType Directory -Force -Path $workRepo | Out-Null diff --git a/src/brigade/add.py b/src/brigade/add.py index 89f29573..c26a522b 100644 --- a/src/brigade/add.py +++ b/src/brigade/add.py @@ -118,8 +118,9 @@ def run(target: Path, station: str, *, install_manifest: bool = False) -> int: if tool.detect(): print(f" [skip] {tool.name} already installed") else: - print(f" [install] {tool.name}: {' '.join(tool.install_args)}") - r = managed.proc.run(tool.install_args, timeout=300) + install_command = tool.install_command() + print(f" [install] {tool.name}: {' '.join(install_command)}") + r = managed.proc.run(install_command, timeout=300) if r.code != 0: print(f" [fail] {tool.name} install exited {r.code}: {r.stderr.strip()[:120]}", file=sys.stderr) rc = 1 diff --git a/src/brigade/component_bins.py b/src/brigade/component_bins.py index d484da42..8fe9635a 100644 --- a/src/brigade/component_bins.py +++ b/src/brigade/component_bins.py @@ -24,6 +24,7 @@ # Engine name -> env var override honored before any other resolution step. ENV_OVERRIDES = { + "agent-notify": "AGENT_NOTIFY_BIN", "graphtrail": "GRAPHTRAIL_BIN", "graphtrail-mcp": "GRAPHTRAIL_MCP_BIN", "miseledger": "MISELEDGER_BIN", @@ -32,6 +33,7 @@ # Pre-consolidation install locations still honored as a last resort. _LEGACY_RELATIVE = { + "agent-notify": (Path("go") / "bin" / "agent-notify",), "graphtrail": (Path(".cargo") / "bin" / "graphtrail",), "graphtrail-mcp": (Path(".cargo") / "bin" / "graphtrail-mcp",), "miseledger": (Path(".local") / "bin" / "miseledger",), diff --git a/src/brigade/component_install.py b/src/brigade/component_install.py index 8937388f..a7018436 100644 --- a/src/brigade/component_install.py +++ b/src/brigade/component_install.py @@ -150,9 +150,9 @@ def build_setup_plan( platform: str, roots: SetupRoots, ) -> list[SetupPlanAction]: - """Build a deterministic dry-run/install plan for every known component.""" + """Build a deterministic dry-run/install plan for every published component.""" plan: list[SetupPlanAction] = [] - for component_id in component_manifest.KNOWN_COMPONENT_IDS: + for component_id in component_manifest.published_component_ids(manifest): asset = component_manifest.resolve_asset(manifest, component_id, platform) component = manifest.components[component_id] cache_path = component_paths.cached_asset_path( @@ -499,9 +499,13 @@ def _invoke_smoke_runner( ) -def _validate_smoke_managed_paths(managed_paths: Mapping[str, str]) -> dict[str, Path]: +def _validate_smoke_managed_paths( + managed_paths: Mapping[str, str], + *, + expected_components: Sequence[str] = _SMOKE_COMPONENT_IDS, +) -> dict[str, Path]: keys = set(managed_paths) - expected = set(_SMOKE_COMPONENT_IDS) + expected = set(expected_components) if keys != expected: missing = sorted(expected - keys) extra = sorted(keys - expected) @@ -515,7 +519,7 @@ def _validate_smoke_managed_paths(managed_paths: Mapping[str, str]) -> dict[str, ) resolved: dict[str, Path] = {} - for component_id in _SMOKE_COMPONENT_IDS: + for component_id in expected_components: raw = managed_paths[component_id] path = Path(raw) if not path.is_absolute(): @@ -622,19 +626,52 @@ def _smoke_sessionfind( raise ComponentInstallError(f"sessionfind smoke failed: {path} --help produced no help text") +def _smoke_agent_notify( + path: Path, + run: Callable[..., subprocess.CompletedProcess[str]], +) -> None: + argv = [str(path), "version", "--json"] + try: + completed = _invoke_smoke_runner(run, argv) + except subprocess.TimeoutExpired as exc: + raise ComponentInstallError(f"agent-notify smoke timed out after {_SMOKE_TIMEOUT_SECONDS}s") from exc + except OSError as exc: + raise ComponentInstallError(f"agent-notify smoke failed to run {path}: {exc}") from exc + + if completed.returncode != 0: + raise ComponentInstallError(f"agent-notify smoke failed: {path} version --json exited {completed.returncode}") + stdout = (completed.stdout or "").strip() + if not stdout: + raise ComponentInstallError(f"agent-notify smoke failed: {path} version --json produced empty stdout") + try: + payload = json.loads(stdout) + except json.JSONDecodeError as exc: + raise ComponentInstallError(f"agent-notify smoke failed: {path} returned malformed JSON") from exc + if not isinstance(payload, dict) or not payload.get("version"): + raise ComponentInstallError(f"agent-notify smoke failed: {path} JSON missing version field") + + +_SMOKE_DISPATCH: dict[str, Callable[[Path, Callable[..., subprocess.CompletedProcess[str]]], None]] = { + "agent-notify": _smoke_agent_notify, + "graphtrail": _smoke_graphtrail, + "graphtrail-mcp": _smoke_graphtrail_mcp, + "miseledger": _smoke_miseledger, + "sessionfind": _smoke_sessionfind, +} + + def run_post_install_smoke( managed_paths: Mapping[str, str], *, runner: Callable[..., subprocess.CompletedProcess[str]] | None = None, + expected_components: Sequence[str] = _SMOKE_COMPONENT_IDS, ) -> None: """Run post-install smoke checks using only absolute managed executable paths.""" - paths = _validate_smoke_managed_paths(managed_paths) + paths = _validate_smoke_managed_paths(managed_paths, expected_components=expected_components) run = runner if runner is not None else _default_smoke_runner - _smoke_graphtrail(paths["graphtrail"], run) - _smoke_graphtrail_mcp(paths["graphtrail-mcp"], run) - _smoke_miseledger(paths["miseledger"], run) - _smoke_sessionfind(paths["sessionfind"], run) + for component_id in expected_components: + _SMOKE_DISPATCH[component_id](paths[component_id], run) def _load_rollback_state( @@ -648,11 +685,6 @@ def _load_rollback_state( state = component_state.load_installed_state(path) if state is None: raise ComponentInstallError(f"invalid {label} installed state: {path}") - expected_components = set(component_manifest.KNOWN_COMPONENT_IDS) - if set(state.components) != expected_components: - raise ComponentInstallError( - f"{label} installed state requires exactly {len(expected_components)} components: {path}" - ) if state.platform != platform: raise ComponentInstallError( f"{label} installed state platform {state.platform!r} does not match host {platform!r}" @@ -660,13 +692,82 @@ def _load_rollback_state( return state +def _ordered_component_ids(state: component_state.InstalledState) -> tuple[str, ...]: + """Return the state's component ids in :data:`KNOWN_COMPONENT_IDS` order.""" + present = set(state.components) + return tuple(cid for cid in component_manifest.KNOWN_COMPONENT_IDS if cid in present) + + +def _validate_rollback_component_sets( + current: component_state.InstalledState, + previous: component_state.InstalledState, +) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Return ``(restore_ids, remove_ids)`` for a rollback transaction. + + Component sets may differ: a four-component prior install upgraded with + agent-notify rolls back by restoring the previous set and removing only + managed binaries introduced by the current transaction. + """ + current_ids = set(current.components) + previous_ids = set(previous.components) + known = set(component_manifest.KNOWN_COMPONENT_IDS) + if not previous_ids or not previous_ids <= known: + raise ComponentInstallError("previous installed state lists unknown or no components; cannot roll back") + if not current_ids or not current_ids <= known: + raise ComponentInstallError("current installed state lists unknown or no components; cannot roll back") + restore_ids = _ordered_component_ids(previous) + remove_ids = tuple( + component_id + for component_id in component_manifest.KNOWN_COMPONENT_IDS + if component_id in current_ids and component_id not in previous_ids + ) + return restore_ids, remove_ids + + +def _managed_binary_belongs_to_transaction( + path: Path, + record: component_state.InstalledComponentRecord, +) -> bool: + """Return True when ``path`` is the managed binary from ``record``.""" + if Path(record.executable) != path: + return False + if not path.is_file(): + return False + try: + verify_cached_asset(path, byte_size=record.byte_size, sha256=record.sha256) + except ComponentInstallError: + return False + return True + + +def _remove_transaction_managed_binaries( + *, + data_root: str, + current: component_state.InstalledState, + remove_ids: Sequence[str], +) -> None: + """Remove managed binaries introduced by the current install transaction. + + Only deletes the managed path under the user data root when it matches the + current installed record. External PATH or env-override binaries are never + touched. + """ + for component_id in remove_ids: + record = current.components[component_id] + managed_path = Path(component_paths.managed_executable_path(data_root, component_id)) + if not _managed_binary_belongs_to_transaction(managed_path, record): + continue + managed_path.unlink() + + def _rollback_cache_paths( state: component_state.InstalledState, *, cache_root: str, + component_ids: Sequence[str], ) -> dict[str, Path]: cache_paths: dict[str, Path] = {} - for component_id in component_manifest.KNOWN_COMPONENT_IDS: + for component_id in component_ids: record = state.components[component_id] try: cache_path = component_paths.cached_asset_path( @@ -699,9 +800,10 @@ def _setup_rollback( label="previous", platform=platform, ) - cache_paths = _rollback_cache_paths(previous_state, cache_root=roots.cache_root) + restore_ids, remove_ids = _validate_rollback_component_sets(current_state, previous_state) + cache_paths = _rollback_cache_paths(previous_state, cache_root=roots.cache_root, component_ids=restore_ids) - for component_id in component_manifest.KNOWN_COMPONENT_IDS: + for component_id in restore_ids: record = previous_state.components[component_id] verify_cached_asset( cache_paths[component_id], @@ -711,17 +813,20 @@ def _setup_rollback( managed_paths = { component_id: Path(component_paths.managed_executable_path(roots.data_root, component_id)) - for component_id in component_manifest.KNOWN_COMPONENT_IDS + for component_id in restore_ids } - managed_snapshots = _snapshot_managed_executables(list(managed_paths.values())) + remove_paths = [ + Path(component_paths.managed_executable_path(roots.data_root, component_id)) for component_id in remove_ids + ] + managed_snapshots = _snapshot_managed_executables([*managed_paths.values(), *remove_paths]) state_snapshots = _snapshot_managed_executables([current_state_path, previous_state_path]) try: - for component_id in component_manifest.KNOWN_COMPONENT_IDS: + for component_id in restore_ids: materialize_executable( cache_path=cache_paths[component_id], managed_path=managed_paths[component_id], ) - for component_id in component_manifest.KNOWN_COMPONENT_IDS: + for component_id in restore_ids: record = previous_state.components[component_id] verify_cached_asset( managed_paths[component_id], @@ -731,6 +836,12 @@ def _setup_rollback( run_post_install_smoke( {component_id: str(path) for component_id, path in managed_paths.items()}, runner=runner, + expected_components=restore_ids, + ) + _remove_transaction_managed_binaries( + data_root=roots.data_root, + current=current_state, + remove_ids=remove_ids, ) component_state.write_installed_state(current_state_path, previous_state) component_state.write_installed_state(previous_state_path, current_state) @@ -906,6 +1017,7 @@ def setup_native_components( run_post_install_smoke( {component_id: str(path) for component_id, path in managed_paths.items()}, runner=runner, + expected_components=component_manifest.published_component_ids(manifest), ) next_state = component_state.InstalledState( schema_version=component_state.SCHEMA_VERSION, diff --git a/src/brigade/component_manifest.py b/src/brigade/component_manifest.py index 14d966b7..00a3722b 100644 --- a/src/brigade/component_manifest.py +++ b/src/brigade/component_manifest.py @@ -21,12 +21,17 @@ "windows-amd64", ) KNOWN_COMPONENT_IDS: tuple[str, ...] = ( + "agent-notify", "graphtrail", "graphtrail-mcp", "miseledger", "sessionfind", ) -UNPUBLISHED_COMPONENT_IDS: frozenset[str] = frozenset() +# Components registered in KNOWN_COMPONENT_IDS but not yet shipped from a Brigade +# release. The bundled compatibility manifest carries them with empty assets so +# the loader accepts the manifest before the first release that publishes them; +# `brigade setup` skips them until a release manifest pins real native assets. +UNPUBLISHED_COMPONENT_IDS: frozenset[str] = frozenset({"agent-notify"}) _SHA256 = re.compile(r"^[0-9a-f]{64}$") _GIT_SHA = re.compile(r"^[0-9a-f]{40}$") _PLATFORM = re.compile(r"^(linux|darwin|windows)-(amd64|arm64)$") @@ -170,6 +175,20 @@ def resolve_asset(manifest: ComponentManifest, component_id: str, platform: str) return asset +def published_component_ids(manifest: ComponentManifest) -> tuple[str, ...]: + """Return the known components that have pinned native assets in ``manifest``. + + Order follows :data:`KNOWN_COMPONENT_IDS`; unpublished components (empty + asset matrix) are excluded so setup, smoke, and rollback exact-set logic + operate only on components a release actually ships. + """ + return tuple( + component_id + for component_id in KNOWN_COMPONENT_IDS + if component_id in manifest.components and manifest.components[component_id].assets + ) + + def platform_key(*, system: str | None = None, machine: str | None = None) -> str: os_names = {"linux": "linux", "darwin": "darwin", "windows": "windows"} arch_names = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64"} diff --git a/src/brigade/managed.py b/src/brigade/managed.py index 0bc8c699..7e18d5e5 100644 --- a/src/brigade/managed.py +++ b/src/brigade/managed.py @@ -40,10 +40,21 @@ class ManagedTool: wire: Callable[[DoctorContext], List[CheckResult]] # lay config; returns notes doctor: Callable[[DoctorContext], List[CheckResult]] # health via proc surfaces: Tuple[MachineSurface, ...] = () + # Optional resolver that returns the install argv chosen for this tool at + # call time (e.g. `brigade setup` for release-pinned components vs a source + # install command for pre-release components). When unset, install_args is + # used verbatim. + install_resolver: Optional[Callable[[], List[str]]] = None def detect(self) -> bool: return component_bins.resolve(self.command) is not None + def install_command(self) -> List[str]: + """Return the argv used to install this tool, resolving per-context if needed.""" + if self.install_resolver is not None: + return self.install_resolver() + return list(self.install_args) + # ---- adapters ------------------------------------------------------------- @@ -260,6 +271,40 @@ def _agent_notify_wire(ctx: DoctorContext) -> List[CheckResult]: ] +def _agent_notify_install_command() -> List[str]: + """Choose the agent-notify install argv from release-vs-source context. + + Released CLIs start automatic manifest selection at the bundled compatibility + manifest (where agent-notify has empty assets). That bundled published-set + must not decide the install route: `brigade setup` loads the exact release + manifest (checksums, attestations, pinned assets) via the same auto path as + every other managed component. + + `go install @latest` is reserved for an explicit source-install context + where manifest selection is not the bundled release path and agent-notify + is not published, so no release component can exist. Never treat a missing + or unreadable manifest as a release-asset failure that falls back to + `go install` on a released CLI. + """ + from . import component_install, component_manifest + + source_install = ["go", "install", "github.com/escoffier-labs/agent-notify/cmd/agent-notify@latest"] + # Released CLI: auto setup resolves the release manifest. Do not consult the + # bundled compatibility manifest's published-component set. + if component_install.uses_bundled_compatibility_manifest(): + return ["brigade", "setup"] + + # Explicit source / non-bundled manifest: setup only when agent-notify is + # published there; otherwise no release component can exist. + try: + manifest = component_manifest.load() + except ValueError: + return source_install + if "agent-notify" in component_manifest.published_component_ids(manifest): + return ["brigade", "setup"] + return source_install + + def _graphtrail_doctor(ctx: DoctorContext) -> List[CheckResult]: """Health-check GraphTrail for the target workspace (optional station).""" name = "graphtrail (code graph)" @@ -464,7 +509,8 @@ def _token_glace_wire(ctx: DoctorContext) -> List[CheckResult]: station="notifications", command="agent-notify", summary="private operator notifications for agent events", - install_args=["go", "install", "github.com/escoffier-labs/agent-notify/cmd/agent-notify@latest"], + install_args=["brigade", "setup"], + install_resolver=_agent_notify_install_command, wire=_agent_notify_wire, doctor=_agent_notify_doctor, surfaces=( diff --git a/src/brigade/notifications_cmd.py b/src/brigade/notifications_cmd.py index c0b97594..db618982 100644 --- a/src/brigade/notifications_cmd.py +++ b/src/brigade/notifications_cmd.py @@ -8,7 +8,7 @@ from pathlib import Path from typing import Any, cast -from . import proc, toml_compat as tomllib +from . import component_bins, proc, toml_compat as tomllib CHANNEL_ENVS = { "discord": ("DISCORD_WEBHOOK_URL",), @@ -51,7 +51,8 @@ def _profile_args(profile: str | None) -> list[str]: def _doctor_argv(profile: str | None) -> list[str]: - return ["agent-notify", "doctor", "--json", "--skip-network", *_profile_args(profile)] + binary = component_bins.resolve("agent-notify") or "agent-notify" + return [binary, "doctor", "--json", "--skip-network", *_profile_args(profile)] def _failure_class(code: int) -> str | None: @@ -190,7 +191,7 @@ def _checks_from_status(payload: dict[str, Any]) -> list[dict[str, Any]]: def _status_payload(profile: str | None = None) -> dict[str, Any]: - binary = proc.which("agent-notify") + binary = component_bins.resolve("agent-notify") if binary is None: return { "installed": False, @@ -422,7 +423,7 @@ def _event_payload( health_payload = health(target, profile=profile) selected_profile = health_payload.get("profile") effective_profile = selected_profile if isinstance(selected_profile, str) else profile - argv = ["agent-notify", "send", *_profile_args(effective_profile)] + argv = list(component_bins.resolve_argv(["agent-notify", "send", *_profile_args(effective_profile)])) return { "target": str(target), "event_id": event_id, diff --git a/src/brigade/templates/components/manifest-v1.json b/src/brigade/templates/components/manifest-v1.json index 09744934..8f1ead86 100644 --- a/src/brigade/templates/components/manifest-v1.json +++ b/src/brigade/templates/components/manifest-v1.json @@ -10,6 +10,12 @@ "windows-amd64" ], "components": { + "agent-notify": { + "component_revision": "c0e69dae7ab36ca0aba76ca7ee3669707214a0dd", + "source": {"repository": "escoffier-labs/brigade"}, + "executable": "agent-notify", + "assets": {} + }, "graphtrail": { "component_revision": "64fcd2f9ec37f33e286708845a92e6cfa4abf3bb", "source": {"repository": "escoffier-labs/graphtrail", "release_tag": "v0.4.0"}, diff --git a/tests/component_install_helpers.py b/tests/component_install_helpers.py index 01222db0..5eb556f0 100644 --- a/tests/component_install_helpers.py +++ b/tests/component_install_helpers.py @@ -51,6 +51,11 @@ def smoke_stub_script(name: str) -> str: ' print("sessionfind ...")\n' " raise SystemExit(0)\nraise SystemExit(1)\n" ) + if name == "agent-notify": + return ( + '#!/usr/bin/env python3\nimport json, sys\nif sys.argv[1:] == ["version", "--json"]:\n' + ' print(json.dumps({"version": "test 0.1.0"}))\n raise SystemExit(0)\nraise SystemExit(1)\n' + ) raise ValueError(name) @@ -73,7 +78,7 @@ def fixture_asset_name(component_id: str, *, platform: str) -> str: return base -def test_component_revision(component_id: str) -> str: +def fixture_component_revision(component_id: str) -> str: return GRAPHTRAIL_SHA @@ -111,7 +116,7 @@ def write_test_manifest(path: Path, *, brigade_version: str) -> component_manife "download_url": asset.download_url, } components[component_id] = { - "component_revision": test_component_revision(component_id), + "component_revision": fixture_component_revision(component_id), "source": {"repository": FIXTURE_REPOSITORY, "release_tag": "fixture"}, "executable": component_id, "assets": assets, diff --git a/tests/test_add.py b/tests/test_add.py index 4367d587..1e780744 100644 --- a/tests/test_add.py +++ b/tests/test_add.py @@ -63,6 +63,47 @@ def fake_run(args, **kw): assert not any(a[:1] in (["pipx"], ["npm"], ["pip"]) for a in calls) +def test_add_skips_agent_notify_when_component_bins_resolves(monkeypatch, tmp_target, capsys): + """Preserve component_bins resolution order: do not reinstall when already found.""" + calls = [] + monkeypatch.setattr( + managed.component_bins, + "resolve", + lambda name, **kw: "/managed/agent-notify" if name == "agent-notify" else None, + ) + + def fake_run(args, **kw): + calls.append(args) + return managed.proc.Result(0, "", "") + + monkeypatch.setattr(managed.proc, "run", fake_run) + rc = add_mod.run(target=tmp_target, station="notifications") + out = capsys.readouterr().out + assert rc == 0 + assert "[skip] agent-notify already installed" in out + assert not any(args[:2] == ["brigade", "setup"] for args in calls) + assert not any(args[:2] == ["go", "install"] for args in calls) + + +def test_add_agent_notify_uses_install_command_resolver(monkeypatch, tmp_target, capsys): + calls = [] + monkeypatch.setattr(managed.component_bins, "resolve", lambda name, **kw: None) + + def fake_run(args, **kw): + calls.append(args) + return managed.proc.Result(0, "", "") + + monkeypatch.setattr(managed.proc, "run", fake_run) + tool = managed.resolve("agent-notify") + assert tool is not None + expected = tool.install_command() + rc = add_mod.run(target=tmp_target, station="agent-notify") + out = capsys.readouterr().out + assert rc == 0 + assert expected in calls + assert f"[install] agent-notify: {' '.join(expected)}" in out + + def test_add_skills_explains_builtin_and_skillet_paths(tmp_target, capsys): rc = add_mod.run(target=tmp_target, station="skills") out = capsys.readouterr().out diff --git a/tests/test_ci_workflow.py b/tests/test_ci_workflow.py index f8a38a15..cdc7672f 100644 --- a/tests/test_ci_workflow.py +++ b/tests/test_ci_workflow.py @@ -1,4 +1,5 @@ from pathlib import Path +import json import re import subprocess @@ -316,8 +317,15 @@ def test_windows_native_acceptance_source_setup_uses_standalone_manifest_online_ source = re.search(r'if \(\$InstallMode -eq "source"\) \{(?P.*?)\n \}', setup, re.DOTALL) assert source is not None - assert "& brigade setup --manifest-source standalone" in source.group("body") - assert "& brigade setup --offline --manifest-source standalone" in source.group("body") + body = source.group("body") + # Empty-asset ids are derived before either setup invocation so online and + # offline share the published-only expectation set. + unpublished_at = body.index("$unpublishedIds = @(Get-UnpublishedComponentIds -ManifestPath $bundledManifestPath)") + online_at = body.index('Write-Step "brigade setup (online)"') + offline_at = body.index('Write-Step "brigade setup --offline"') + assert unpublished_at < online_at < offline_at + assert "& brigade setup --manifest-source standalone" in body + assert "& brigade setup --offline --manifest-source standalone" in body def test_windows_native_acceptance_pypi_setup_keeps_exact_manifest_default_and_digest_check(): @@ -523,3 +531,174 @@ def test_windows_native_acceptance_brigade_version_regex_matches_cli_output(): match = re.match(pattern, line.strip()) assert match is not None assert match.group(1).strip() == expected + + +def test_windows_native_acceptance_source_mode_skips_only_bundled_unpublished_components(): + """Source acceptance derives the skip set from the bundled compatibility + manifest's empty-asset entries, not from the component report's status, so a + component with declared assets that reports "unsupported" still fails.""" + script = (ROOT / "scripts/windows-native-acceptance.ps1").read_text() + manifest = json.loads((ROOT / "src/brigade/templates/components/manifest-v1.json").read_text()) + + unpublished = [component_id for component_id, record in manifest["components"].items() if not record.get("assets")] + published = [component_id for component_id, record in manifest["components"].items() if record.get("assets")] + assert "agent-notify" in unpublished + assert set(unpublished) == {"agent-notify"} + assert published, "at least one published component must remain strictly required" + + unpublished_fn = _extract_powershell_function(script, "Get-UnpublishedComponentIds") + assert "function Get-UnpublishedComponentIds" in unpublished_fn + assert "ConvertFrom-Json" in unpublished_fn + # Force Properties through @(...) so Windows PowerShell 5.1 reports Count 0 + # for empty "assets": {} maps (bare .Count is unreliable and dropped agent-notify). + assert "@($assets.PSObject.Properties).Count -eq 0" in unpublished_fn + assert "Write-Output -NoEnumerate" in unpublished_fn + # The skip set is read from the bundled manifest on disk, not from the + # component report, so an unsupported component with declared assets is + # never treated as skippable. + assert "$Report" not in unpublished_fn + + main = script[script.index("$acceptRoot = $null") :] + source_block = main[main.index('if ($InstallMode -eq "source") {') : main.index("$report = Get-ComponentReport")] + assert 'Join-Path $RepoRoot "src\\brigade\\templates\\components\\manifest-v1.json"' in source_block + assert ( + "[string[]]$unpublishedIds = @(Get-UnpublishedComponentIds -ManifestPath $bundledManifestPath)" in source_block + ) + # Empty-asset omission applies to both setup requests: ids are computed + # before online and offline standalone setup, then reused for health/smoke. + assert source_block.index( + "[string[]]$unpublishedIds = @(Get-UnpublishedComponentIds -ManifestPath $bundledManifestPath)" + ) < source_block.index('Write-Step "brigade setup (online)"') + assert source_block.index('Write-Step "brigade setup (online)"') < source_block.index( + 'Write-Step "brigade setup --offline"' + ) + assert "& brigade setup --manifest-source standalone" in source_block + assert "& brigade setup --offline --manifest-source standalone" in source_block + assert "[string[]]$unpublishedIds = @()" in main + assert "Assert-AllComponentsHealthy -Report $report -Skippable $unpublishedIds" in main + + healthy_fn = _extract_powershell_function(script, "Assert-AllComponentsHealthy") + assert "[string[]]$Skippable = @()" in healthy_fn + assert "$skippableSet" in healthy_fn + assert 'if ($component.status -ne "unsupported")' in healthy_fn + assert 'if ($component.status -ne "healthy")' in healthy_fn + + # The agent-notify absolute-path smoke is gated on the computed required set + # so source mode skips an unpublished agent-notify instead of invoking a + # missing managed binary. + assert '$requiredIds = @("agent-notify", "graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") |' in main + assert "Where-Object { $unpublishedIds -notcontains $_ }" in main + assert 'if ($requiredIds -contains "agent-notify") {' in main + agent_block = main[main.index('if ($requiredIds -contains "agent-notify") {') :] + assert ( + 'Get-ManagedExecutablePath -Report $report -ComponentId "agent-notify" -ManagedBin $managedBin' in agent_block + ) + assert "& $agentNotifyExe version --json" in agent_block + + +def test_windows_native_acceptance_source_setup_command_contract_omits_empty_asset_components(): + """Source-mode setup must request only published components: both online and + offline use standalone manifest selection (which omits empty-asset entries + via published_component_ids) after deriving that same empty-asset set for + post-setup assertions. Nonempty published assets stay required.""" + script = (ROOT / "scripts/windows-native-acceptance.ps1").read_text() + manifest = json.loads((ROOT / "src/brigade/templates/components/manifest-v1.json").read_text()) + unpublished = [component_id for component_id, record in manifest["components"].items() if not record.get("assets")] + published = [component_id for component_id, record in manifest["components"].items() if record.get("assets")] + assert unpublished == ["agent-notify"] + assert "graphtrail" in published + + main = script[script.index("$acceptRoot = $null") :] + source_block = main[main.index('if ($InstallMode -eq "source") {') : main.index("$report = Get-ComponentReport")] + online_cmd = "& brigade setup --manifest-source standalone" + offline_cmd = "& brigade setup --offline --manifest-source standalone" + assert source_block.count(online_cmd) == 1 + assert source_block.count(offline_cmd) == 1 + # No alternate manifest path or component-selection flag that could request + # unpublished agent-notify or hide a published component failure. + assert "--manifest " not in source_block + assert "Get-UnpublishedComponentIds" in source_block + assert "@($assets.PSObject.Properties).Count -eq 0" in _extract_powershell_function( + script, "Get-UnpublishedComponentIds" + ) + + +def test_windows_native_acceptance_pypi_setup_command_contract_is_strict(): + """PyPI/released mode must issue bare setup online and offline (every + published component) and never populate the empty-asset skip set.""" + script = (ROOT / "scripts/windows-native-acceptance.ps1").read_text() + main = script[script.index("$acceptRoot = $null") :] + setup = main[main.index("[string[]]$unpublishedIds = @()") : main.index("$report = Get-ComponentReport")] + published = re.search(r"else \{(?P.*?)\n \}", setup, re.DOTALL) + assert published is not None + body = published.group("body") + assert re.search(r"(?m)^ & brigade setup$", body) + assert re.search(r"(?m)^ & brigade setup --offline$", body) + assert "--manifest-source" not in body + assert "Get-UnpublishedComponentIds" not in body + # Default empty skip set is only replaced inside the source branch. + assert setup.index("[string[]]$unpublishedIds = @()") < setup.index('if ($InstallMode -eq "source") {') + source_block = setup[setup.index('if ($InstallMode -eq "source") {') : setup.index("else {")] + assert "Get-UnpublishedComponentIds" in source_block + + +def test_windows_native_acceptance_pypi_mode_never_skips_agent_notify(): + """Published/release acceptance keeps the strict five-component contract: + no skip set, agent-notify must install, digest-check, and smoke by absolute + managed path.""" + script = (ROOT / "scripts/windows-native-acceptance.ps1").read_text() + main = script[script.index("$acceptRoot = $null") :] + + # The skip set is only populated in source mode; pypi mode leaves it empty. + setup = main[main.index("[string[]]$unpublishedIds = @()") : main.index("$report = Get-ComponentReport")] + source_block = setup[setup.index('if ($InstallMode -eq "source") {') : setup.index("else {")] + pypi_block = setup[setup.index("else {") :] + assert "[string[]]$unpublishedIds = @()" in setup + assert "Get-UnpublishedComponentIds" in source_block + assert "Get-UnpublishedComponentIds" not in pypi_block + + assert "Assert-ManagedComponentDigests -Manifest $releaseManifest -Report $report -ManagedBin $managedBin" in main + digest_fn = _extract_powershell_function(script, "Assert-ManagedComponentDigests") + assert '"agent-notify"' in digest_fn + assert '"graphtrail", "graphtrail-mcp", "miseledger", "sessionfind"' in digest_fn + + release_fn = _extract_powershell_function(script, "Assert-ReleaseManifestAndAssets") + for component_id in ("agent-notify", "graphtrail", "graphtrail-mcp", "miseledger", "sessionfind"): + assert component_id in release_fn + assert ( + 'foreach ($platform in @("linux-amd64", "linux-arm64", "darwin-amd64", "darwin-arm64", "windows-amd64"))' + in release_fn + ) + + +def test_windows_native_acceptance_pypi_mode_rejects_bare_agent_notify_metadata(): + """Published acceptance must reject the dev/unknown/unknown defaults a bare + `go build` leaves behind and require the exact Brigade release version plus a + hex git SHA (full or short) and a UTC build timestamp. Source-mode skip + behavior is untouched: the validation only runs inside the required-ids + agent-notify block, which source mode never enters for an unpublished + agent-notify.""" + script = (ROOT / "scripts/windows-native-acceptance.ps1").read_text() + validator = _extract_powershell_function(script, "Assert-AgentNotifyVersionMetadata") + assert "function Assert-AgentNotifyVersionMetadata" in validator + assert "[string]$Version" in validator + assert '$Payload.version -eq "dev"' in validator + assert '$Payload.version -eq "unknown"' in validator + assert "$Payload.version -ne $Version" in validator + assert "$Payload.commit -eq " in validator + assert "$Payload.commit -notmatch '^[0-9a-f]{7,40}$'" in validator + assert "$Payload.build_date -eq " in validator + assert r"$Payload.build_date -notmatch '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$'" in validator + + main = script[script.index("$acceptRoot = $null") :] + agent_block = main[main.index('if ($requiredIds -contains "agent-notify") {') :] + assert "Assert-AgentNotifyVersionMetadata -Payload $agentNotifyPayload -Version $BrigadeVersion" in agent_block + # The old loose "version field exists" check is gone. + assert "if (-not $agentNotifyPayload.version)" not in agent_block + # Source-mode skip behavior is preserved: the unpublished-ids derivation and + # the required-ids gating are unchanged. + source_block = main[main.index('if ($InstallMode -eq "source") {') : main.index("$report = Get-ComponentReport")] + assert ( + "[string[]]$unpublishedIds = @(Get-UnpublishedComponentIds -ManifestPath $bundledManifestPath)" in source_block + ) + assert "Where-Object { $unpublishedIds -notcontains $_ }" in main diff --git a/tests/test_component_bins.py b/tests/test_component_bins.py index 6fd300db..f6c439a0 100644 --- a/tests/test_component_bins.py +++ b/tests/test_component_bins.py @@ -133,3 +133,55 @@ def test_resolve_argv_passes_through_absolute_and_unknown(tmp_path): assert component_bins.resolve_argv(absolute) == list(absolute) unknown = ("some-tool", "run") assert component_bins.resolve_argv(unknown) == list(unknown) + + +def test_resolve_agent_notify_prefers_env_override(tmp_path, monkeypatch): + override = _write_executable(tmp_path / "override" / "agent-notify") + managed = _write_executable(tmp_path / "managed" / "agent-notify") + _write_installed_state(tmp_path / "data", {"agent-notify": managed}) + env = _env(tmp_path, AGENT_NOTIFY_BIN=str(override)) + assert component_bins.resolve("agent-notify", env=env) == str(override) + + +def test_resolve_agent_notify_broken_override_does_not_fall_through(tmp_path, monkeypatch): + managed = _write_executable(tmp_path / "managed" / "agent-notify") + _write_installed_state(tmp_path / "data", {"agent-notify": managed}) + monkeypatch.setenv("PATH", str(managed.parent)) + env = _env(tmp_path, AGENT_NOTIFY_BIN=str(tmp_path / "nope")) + assert component_bins.resolve("agent-notify", env=env) is None + + +def test_resolve_agent_notify_prefers_managed_over_legacy_go_bin(tmp_path, monkeypatch): + managed = _write_executable(tmp_path / "managed" / "agent-notify") + _write_executable(tmp_path / "go" / "bin" / "agent-notify") + _write_installed_state(tmp_path / "data", {"agent-notify": managed}) + monkeypatch.setenv("PATH", str(tmp_path / "elsewhere")) + env = _env(tmp_path, PATH=str(tmp_path / "empty")) + assert component_bins.resolve("agent-notify", env=env) == str(managed) + + +def test_resolve_agent_notify_falls_back_to_legacy_go_bin(tmp_path, monkeypatch): + legacy = _write_executable(tmp_path / "go" / "bin" / "agent-notify") + monkeypatch.setenv("HOME", str(tmp_path / "host-home")) + monkeypatch.setenv("PATH", str(tmp_path / "elsewhere")) + env = _env(tmp_path, PATH=str(tmp_path / "empty")) + assert component_bins.resolve("agent-notify", env=env) == str(legacy) + + +def test_resolve_agent_notify_falls_back_to_supplied_path(tmp_path, monkeypatch): + on_path = _write_executable(tmp_path / "pathdir" / "agent-notify") + monkeypatch.setenv("PATH", str(tmp_path / "elsewhere")) + env = _env(tmp_path, PATH=str(on_path.parent)) + assert component_bins.resolve("agent-notify", env=env) == str(on_path) + + +def test_resolve_argv_rewrites_agent_notify_head(tmp_path, monkeypatch): + managed = _write_executable(tmp_path / "managed" / "agent-notify") + _write_installed_state(tmp_path / "data", {"agent-notify": managed}) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data")) + assert component_bins.resolve_argv(("agent-notify", "version", "--json")) == [ + str(managed), + "version", + "--json", + ] diff --git a/tests/test_component_install.py b/tests/test_component_install.py index df811596..83da3fb8 100644 --- a/tests/test_component_install.py +++ b/tests/test_component_install.py @@ -34,7 +34,9 @@ from tests.component_install_helpers import ( FakeOpener, + FIXTURE_REPOSITORY, all_fixture_payloads, + fixture_component_revision, fixture_payload, linux_env, smoke_stub_script, @@ -43,7 +45,8 @@ write_verified_cache, ) -_SMOKE_COMPONENTS = ("graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") +_SMOKE_COMPONENTS = component_manifest.KNOWN_COMPONENT_IDS +_FOUR_ENGINE_IDS = tuple(cid for cid in component_manifest.KNOWN_COMPONENT_IDS if cid != "agent-notify") def _write_managed_stub(tmp_path, component_id: str, *, script: str | None = None) -> str: @@ -148,22 +151,25 @@ def _rollback_state( revision: str, version: str, executable: str | None = None, + component_ids: tuple[str, ...] | None = None, ) -> component_state.InstalledState: roots = resolve_roots(env=env, system="linux") + selected = component_ids if component_ids is not None else component_manifest.KNOWN_COMPONENT_IDS components: dict[str, component_state.InstalledComponentRecord] = {} - for component_id in component_manifest.KNOWN_COMPONENT_IDS: + for component_id in selected: payload = _rollback_payload(component_id, version=version) sha256 = hashlib.sha256(payload).hexdigest() asset_name = f"{component_id}-rollback-{version}" cache_path = Path(component_paths.cached_asset_path(roots.cache_root, sha256, asset_name)) write_verified_cache(cache_path, payload=payload) + managed = component_paths.managed_executable_path(roots.data_root, component_id) components[component_id] = component_state.InstalledComponentRecord( component_revision=f"fixture-{version}", asset_name=asset_name, byte_size=len(payload), sha256=sha256, download_url=f"https://example.invalid/components/{asset_name}", - executable=executable or f"/untrusted/{component_id}", + executable=executable or managed, ) return component_state.InstalledState( schema_version=component_state.SCHEMA_VERSION, @@ -180,15 +186,18 @@ def _seed_rollback_pair( *, revision_a: str = "fixture-a", revision_b: str = "fixture-b", + previous_ids: tuple[str, ...] | None = None, + current_ids: tuple[str, ...] | None = None, ) -> tuple[component_state.InstalledState, component_state.InstalledState]: roots = resolve_roots(env=env, system="linux") - previous = _rollback_state(env, revision=revision_a, version="previous") - current = _rollback_state(env, revision=revision_b, version="current") + previous = _rollback_state(env, revision=revision_a, version="previous", component_ids=previous_ids) + current = _rollback_state(env, revision=revision_b, version="current", component_ids=current_ids) component_state.write_installed_state(Path(component_paths.installed_state_path(roots.data_root)), current) component_state.write_installed_state( Path(component_paths.installed_previous_state_path(roots.data_root)), previous ) - for component_id, path in _managed_paths(env).items(): + for component_id in current.components: + path = Path(component_paths.managed_executable_path(roots.data_root, component_id)) path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(_rollback_payload(component_id, version="current")) path.chmod(0o700) @@ -534,7 +543,7 @@ def test_resolve_roots_uses_xdg_paths(tmp_path): assert roots.cache_root == env["XDG_CACHE_HOME"] -def test_build_setup_plan_lists_all_four_components(tmp_path): +def test_build_setup_plan_lists_all_five_components(tmp_path): manifest_path = tmp_path / "manifest-v1.json" manifest = write_test_manifest(manifest_path, brigade_version=brigade.__version__) roots = resolve_roots(env=linux_env(tmp_path), system="linux") @@ -1009,20 +1018,22 @@ def test_setup_rollback_rejects_missing_or_invalid_state_without_mutation(tmp_pa assert {component_id: path.read_bytes() for component_id, path in paths.items()} == managed_before -def test_setup_rollback_rejects_partial_component_state_without_mutation(tmp_path): +def test_setup_rollback_rejects_unknown_component_state_without_mutation(tmp_path): env = linux_env(tmp_path) previous, _current = _seed_rollback_pair(env) current_path, previous_path = _rollback_state_paths(env) state_before = current_path.read_bytes() - partial = component_state.InstalledState( + unknown = component_state.InstalledState( schema_version=previous.schema_version, brigade_version=previous.brigade_version, manifest_revision=previous.manifest_revision, platform=previous.platform, installed_at=previous.installed_at, - components={"graphtrail": previous.components["graphtrail"]}, + components={ + "not-a-component": previous.components["graphtrail"], + }, ) - component_state.write_installed_state(previous_path, partial) + component_state.write_installed_state(previous_path, unknown) previous_before = previous_path.read_bytes() paths = _managed_paths(env) managed_before = {component_id: path.read_bytes() for component_id, path in paths.items()} @@ -1033,6 +1044,142 @@ def test_setup_rollback_rejects_partial_component_state_without_mutation(tmp_pat assert {component_id: path.read_bytes() for component_id, path in paths.items()} == managed_before +def test_setup_rollback_four_to_five_upgrade_restores_prior_and_removes_managed_agent_notify(tmp_path): + env = linux_env(tmp_path) + previous, current = _seed_rollback_pair( + env, + previous_ids=_FOUR_ENGINE_IDS, + current_ids=component_manifest.KNOWN_COMPONENT_IDS, + ) + current_path, previous_path = _rollback_state_paths(env) + roots = resolve_roots(env=env, system="linux") + notify_path = Path(component_paths.managed_executable_path(roots.data_root, "agent-notify")) + assert notify_path.is_file() + + assert setup_native_components(rollback=True, env=env) == 0 + + restored = component_state.load_installed_state(current_path) + swapped = component_state.load_installed_state(previous_path) + assert restored == previous + assert swapped == current + assert set(restored.components) == set(_FOUR_ENGINE_IDS) + assert "agent-notify" not in restored.components + assert not notify_path.exists() + for component_id in _FOUR_ENGINE_IDS: + path = Path(component_paths.managed_executable_path(roots.data_root, component_id)) + assert path.read_bytes() == _rollback_payload(component_id, version="previous") + assert path.stat().st_mode & 0o777 == 0o755 + + +def test_setup_rollback_four_to_five_never_removes_external_path_agent_notify(tmp_path): + env = linux_env(tmp_path) + previous, current = _seed_rollback_pair( + env, + previous_ids=_FOUR_ENGINE_IDS, + current_ids=component_manifest.KNOWN_COMPONENT_IDS, + ) + roots = resolve_roots(env=env, system="linux") + managed_notify = Path(component_paths.managed_executable_path(roots.data_root, "agent-notify")) + path_dir = tmp_path / "path-bin" + path_dir.mkdir() + path_notify = path_dir / "agent-notify" + path_notify.write_bytes(b"external-path-agent-notify") + path_notify.chmod(0o700) + # Current transaction points at PATH, not the managed path. + record = current.components["agent-notify"] + altered = component_state.InstalledState( + schema_version=current.schema_version, + brigade_version=current.brigade_version, + manifest_revision=current.manifest_revision, + platform=current.platform, + installed_at=current.installed_at, + components={ + **{cid: current.components[cid] for cid in _FOUR_ENGINE_IDS}, + "agent-notify": component_state.InstalledComponentRecord( + component_revision=record.component_revision, + asset_name=record.asset_name, + byte_size=record.byte_size, + sha256=record.sha256, + download_url=record.download_url, + executable=str(path_notify), + ), + }, + ) + component_state.write_installed_state(Path(component_paths.installed_state_path(roots.data_root)), altered) + managed_notify.unlink() + + assert setup_native_components(rollback=True, env=env) == 0 + assert path_notify.read_bytes() == b"external-path-agent-notify" + assert not managed_notify.exists() + + +def test_setup_rollback_four_to_five_never_removes_env_override_agent_notify(tmp_path): + env = linux_env(tmp_path) + _seed_rollback_pair( + env, + previous_ids=_FOUR_ENGINE_IDS, + current_ids=component_manifest.KNOWN_COMPONENT_IDS, + ) + roots = resolve_roots(env=env, system="linux") + managed_notify = Path(component_paths.managed_executable_path(roots.data_root, "agent-notify")) + override = tmp_path / "override" / "agent-notify" + override.parent.mkdir() + override.write_bytes(b"env-override-agent-notify") + override.chmod(0o700) + + assert setup_native_components(rollback=True, env=env) == 0 + # Managed transaction binary is removed; env-override binary is never touched. + assert not managed_notify.exists() + assert override.read_bytes() == b"env-override-agent-notify" + + +def test_setup_rollback_four_to_five_leaves_foreign_managed_path_untouched(tmp_path): + env = linux_env(tmp_path) + previous, current = _seed_rollback_pair( + env, + previous_ids=_FOUR_ENGINE_IDS, + current_ids=component_manifest.KNOWN_COMPONENT_IDS, + ) + roots = resolve_roots(env=env, system="linux") + managed_notify = Path(component_paths.managed_executable_path(roots.data_root, "agent-notify")) + foreign = b"foreign-managed-path-bytes-not-in-transaction" + managed_notify.write_bytes(foreign) + managed_notify.chmod(0o700) + # Record still claims the managed path, but bytes no longer match the transaction. + assert current.components["agent-notify"].executable == str(managed_notify) + + assert setup_native_components(rollback=True, env=env) == 0 + assert managed_notify.read_bytes() == foreign + + +def test_setup_rollback_four_to_five_smoke_failure_restores_added_managed_binary(tmp_path): + env = linux_env(tmp_path) + _seed_rollback_pair( + env, + previous_ids=_FOUR_ENGINE_IDS, + current_ids=component_manifest.KNOWN_COMPONENT_IDS, + ) + current_path, previous_path = _rollback_state_paths(env) + state_before = (current_path.read_bytes(), previous_path.read_bytes()) + roots = resolve_roots(env=env, system="linux") + notify_path = Path(component_paths.managed_executable_path(roots.data_root, "agent-notify")) + notify_before = notify_path.read_bytes() + engine_before = { + component_id: Path(component_paths.managed_executable_path(roots.data_root, component_id)).read_bytes() + for component_id in _FOUR_ENGINE_IDS + } + + def failed_smoke(argv, **_kwargs): + return subprocess.CompletedProcess(argv, 1, stdout="", stderr="boom") + + assert setup_native_components(rollback=True, env=env, runner=failed_smoke) == 1 + assert (current_path.read_bytes(), previous_path.read_bytes()) == state_before + assert notify_path.read_bytes() == notify_before + for component_id, payload in engine_before.items(): + path = Path(component_paths.managed_executable_path(roots.data_root, component_id)) + assert path.read_bytes() == payload + + def test_setup_rollback_rejects_host_platform_mismatch_without_mutation(tmp_path): env = linux_env(tmp_path) previous, _current = _seed_rollback_pair(env) @@ -1144,7 +1291,7 @@ def test_setup_rollback_twice_toggles_back_to_original_current_state(tmp_path): assert path.read_bytes() == _rollback_payload(component_id, version="current") -def test_setup_install_writes_all_four_managed_files_and_state_after_smoke(tmp_path, monkeypatch): +def test_setup_install_writes_all_five_managed_files_and_state_after_smoke(tmp_path, monkeypatch): env, _manifest_path = _install_fixture_manifest(tmp_path, monkeypatch) opener = FakeOpener(all_fixture_payloads()) @@ -1164,6 +1311,90 @@ def test_setup_install_writes_all_four_managed_files_and_state_after_smoke(tmp_p assert state.components[component_id].executable == str(managed_path) +def _write_unpublished_notify_manifest(path: Path, *, brigade_version: str) -> component_manifest.ComponentManifest: + """Manifest with the 4 native engines published and agent-notify unpublished.""" + components: dict[str, object] = {} + for component_id in component_manifest.KNOWN_COMPONENT_IDS: + if component_id == "agent-notify": + components[component_id] = { + "component_revision": fixture_component_revision(component_id), + "source": {"repository": FIXTURE_REPOSITORY}, + "executable": "agent-notify", + "assets": {}, + } + continue + assets: dict[str, object] = {} + for platform in component_manifest.SUPPORTED_PLATFORMS: + _, byte_size, sha256 = fixture_payload(component_id, platform=platform) + asset = manifest_asset_fixture(component_id, platform=platform) + assets[platform] = { + "asset_name": asset.asset_name, + "byte_size": byte_size, + "sha256": sha256, + "download_url": asset.download_url, + } + components[component_id] = { + "component_revision": fixture_component_revision(component_id), + "source": {"repository": FIXTURE_REPOSITORY, "release_tag": "fixture"}, + "executable": component_id, + "assets": assets, + } + path.write_text( + json.dumps( + { + "schema_version": 1, + "brigade_version": brigade_version, + "manifest_revision": "fixture", + "supported_platforms": list(component_manifest.SUPPORTED_PLATFORMS), + "components": components, + } + ) + ) + return component_manifest.load(path) + + +def test_build_setup_plan_skips_unpublished_agent_notify(tmp_path, monkeypatch): + env = linux_env(tmp_path) + manifest_path = tmp_path / "manifest-v1.json" + manifest = _write_unpublished_notify_manifest(manifest_path, brigade_version=brigade.__version__) + monkeypatch.setattr(component_manifest, "manifest_path", lambda: manifest_path) + monkeypatch.setattr(component_manifest, "platform_key", lambda **_kwargs: "linux-amd64") + roots = resolve_roots(env=env, system="linux") + + plan = build_setup_plan(manifest, platform="linux-amd64", roots=roots) + + # agent-notify is a known component carried unpublished (empty asset matrix); + # setup must skip it and emit the deterministic 4-action batch for each of + # the four published native engines, in KNOWN_COMPONENT_IDS order. + per_component = ("verify-cache", "download", "materialize", "smoke") + expected_published = ["graphtrail", "graphtrail-mcp", "miseledger", "sessionfind"] + expected_ids = [component_id for component_id in expected_published for _ in per_component] + assert [action.component_id for action in plan] == expected_ids + assert [action.action for action in plan] == list(per_component) * len(expected_published) + assert "agent-notify" not in {action.component_id for action in plan} + + +def test_setup_install_skips_unpublished_agent_notify_and_installs_four(tmp_path, monkeypatch): + env = linux_env(tmp_path) + manifest_path = tmp_path / "manifest-v1.json" + _write_unpublished_notify_manifest(manifest_path, brigade_version=brigade.__version__) + monkeypatch.setattr(component_manifest, "manifest_path", lambda: manifest_path) + monkeypatch.setattr(component_manifest, "platform_key", lambda **_kwargs: "linux-amd64") + opener = FakeOpener(all_fixture_payloads()) + + assert setup_native_components(env=env, opener=opener) == 0 + + roots = resolve_roots(env=env, system="linux") + state_path = Path(component_paths.installed_state_path(roots.data_root)) + state = component_state.load_installed_state(state_path) + assert state is not None + expected = {"graphtrail", "graphtrail-mcp", "miseledger", "sessionfind"} + assert set(state.components) == expected + assert "agent-notify" not in state.components + notify_path = Path(component_paths.managed_executable_path(roots.data_root, "agent-notify")) + assert not notify_path.exists() + + def test_setup_state_is_absent_while_smoke_runs(tmp_path, monkeypatch): env, _manifest_path = _install_fixture_manifest(tmp_path, monkeypatch) roots = resolve_roots(env=env, system="linux") @@ -1426,11 +1657,12 @@ def test_run_post_install_smoke_invokes_absolute_paths_only(tmp_path): run_post_install_smoke(managed, runner=_recording_runner(calls)) assert {cmd[0] for cmd, _kwargs in calls} == set(managed.values()) - assert calls[0][0] == [managed["graphtrail"], "--version"] - assert calls[1][0] == [managed["graphtrail-mcp"]] - assert "input" in calls[1][1] - assert calls[2][0] == [managed["miseledger"], "version"] - assert calls[3][0] == [managed["sessionfind"], "--help"] + assert calls[0][0] == [managed["agent-notify"], "version", "--json"] + assert calls[1][0] == [managed["graphtrail"], "--version"] + assert calls[2][0] == [managed["graphtrail-mcp"]] + assert "input" in calls[2][1] + assert calls[3][0] == [managed["miseledger"], "version"] + assert calls[4][0] == [managed["sessionfind"], "--help"] def test_run_post_install_smoke_rejects_relative_paths(tmp_path): @@ -1442,7 +1674,7 @@ def test_run_post_install_smoke_rejects_relative_paths(tmp_path): def test_run_post_install_smoke_rejects_wrong_component_set(tmp_path): managed = {"graphtrail": _write_managed_stub(tmp_path, "graphtrail")} - with pytest.raises(ComponentInstallError, match="exactly 4 managed paths"): + with pytest.raises(ComponentInstallError, match="exactly 5 managed paths"): run_post_install_smoke(managed) diff --git a/tests/test_component_manifest.py b/tests/test_component_manifest.py index 486ff3a3..58415476 100644 --- a/tests/test_component_manifest.py +++ b/tests/test_component_manifest.py @@ -11,6 +11,7 @@ MISELEDGER_BASE = "https://github.com/escoffier-labs/miseledger/releases/download/v0.6.0/" GRAPHTRAIL_BASE = "https://github.com/escoffier-labs/graphtrail/releases/download/v0.4.0/" +AGENT_NOTIFY_BASE = "https://github.com/escoffier-labs/agent-notify/releases/download/v0.1.0/" GRAPHTRAIL_SHA = "64fcd2f9ec37f33e286708845a92e6cfa4abf3bb" GRAPHTRAIL_V040_ASSETS = [ @@ -159,6 +160,44 @@ ), ] +AGENT_NOTIFY_V010_ASSETS = [ + ( + "agent-notify", + "darwin-amd64", + "agent-notify-darwin-amd64", + 11659776, + "1111111111111111111111111111111111111111111111111111111111111111", + ), + ( + "agent-notify", + "darwin-arm64", + "agent-notify-darwin-arm64", + 10784066, + "2222222222222222222222222222222222222222222222222222222222222222", + ), + ( + "agent-notify", + "linux-amd64", + "agent-notify-linux-amd64", + 11445238, + "3333333333333333333333333333333333333333333333333333333333333333", + ), + ( + "agent-notify", + "linux-arm64", + "agent-notify-linux-arm64", + 10315773, + "4444444444444444444444444444444444444444444444444444444444444444", + ), + ( + "agent-notify", + "windows-amd64", + "agent-notify-windows-amd64.exe", + 11620032, + "5555555555555555555555555555555555555555555555555555555555555555", + ), +] + def _minimal_known_component( *, @@ -220,6 +259,24 @@ def _full_graphtrail_assets(component_id: str) -> dict: return assets +def _agent_notify_asset(asset_name: str, byte_size: int, sha256: str) -> dict: + return { + "asset_name": asset_name, + "byte_size": byte_size, + "sha256": sha256, + "download_url": AGENT_NOTIFY_BASE + asset_name, + } + + +def _full_agent_notify_assets(component_id: str) -> dict: + assets: dict = {} + for comp, platform, asset_name, byte_size, sha256 in AGENT_NOTIFY_V010_ASSETS: + if comp != component_id: + continue + assets[platform] = _agent_notify_asset(asset_name, byte_size, sha256) + return assets + + def _write_manifest(tmp_path: Path, **overrides) -> Path: payload = { "schema_version": 1, @@ -227,6 +284,13 @@ def _write_manifest(tmp_path: Path, **overrides) -> Path: "manifest_revision": "2026-07-18", "supported_platforms": list(component_manifest.SUPPORTED_PLATFORMS), "components": { + "agent-notify": _minimal_known_component( + component_revision=GRAPHTRAIL_SHA, + repository="escoffier-labs/agent-notify", + release_tag="v0.1.0", + executable="agent-notify", + assets=_full_agent_notify_assets("agent-notify"), + ), "graphtrail": _minimal_known_component( component_revision=GRAPHTRAIL_SHA, repository="escoffier-labs/graphtrail", @@ -318,6 +382,25 @@ def test_bundled_manifest_pins_graphtrail_to_git_sha(): assert all(ch in "0123456789abcdef" for ch in revision) +def test_bundled_manifest_carries_agent_notify_as_unpublished_prerelease(): + manifest = component_manifest.load(allow_standalone_legacy_revisions=True) + assert "agent-notify" in manifest.components + agent_notify = manifest.components["agent-notify"] + assert agent_notify.executable == "agent-notify" + assert agent_notify.assets == {} + assert agent_notify.source.release_tag is None + assert agent_notify.source.repository == "escoffier-labs/brigade" + assert "agent-notify" in component_manifest.UNPUBLISHED_COMPONENT_IDS + assert "agent-notify" not in component_manifest.published_component_ids(manifest) + published = component_manifest.published_component_ids(manifest) + assert published == ( + "graphtrail", + "graphtrail-mcp", + "miseledger", + "sessionfind", + ) + + def test_schema_contract_requires_exact_platform_order_and_known_components(): schema = json.loads((Path(__file__).resolve().parents[1] / "docs/component-manifest-v1.schema.json").read_text()) supported = schema["properties"]["supported_platforms"] @@ -518,7 +601,7 @@ def test_manifest_ignores_unknown_components_with_deterministic_diagnostic(tmp_p assert "future-tool" not in manifest.components assert manifest.unknown_component_diagnostics == ( "component manifest lists unknown component 'future-tool'; known components: " - "graphtrail, graphtrail-mcp, miseledger, sessionfind", + "agent-notify, graphtrail, graphtrail-mcp, miseledger, sessionfind", ) @@ -534,7 +617,7 @@ def test_manifest_ignores_unknown_components_regardless_of_value_shape(tmp_path, assert "future-tool" not in manifest.components assert manifest.unknown_component_diagnostics == ( "component manifest lists unknown component 'future-tool'; known components: " - "graphtrail, graphtrail-mcp, miseledger, sessionfind", + "agent-notify, graphtrail, graphtrail-mcp, miseledger, sessionfind", ) diff --git a/tests/test_component_manifest_provenance.py b/tests/test_component_manifest_provenance.py index 9822dde6..060c6751 100644 --- a/tests/test_component_manifest_provenance.py +++ b/tests/test_component_manifest_provenance.py @@ -18,7 +18,7 @@ API = f"https://api.github.com/repos/{REPOSITORY}/releases/tags/{TAG}" REF_API = f"https://api.github.com/repos/{REPOSITORY}/git/ref/tags/{TAG}" TAG_API = f"https://api.github.com/repos/{REPOSITORY}/git/tags/" -COMPONENTS = ("graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") +COMPONENTS = ("agent-notify", "graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") PLATFORMS = ("linux-amd64", "linux-arm64", "darwin-amd64", "darwin-arm64", "windows-amd64") diff --git a/tests/test_component_report.py b/tests/test_component_report.py index e6fde348..fce78d34 100644 --- a/tests/test_component_report.py +++ b/tests/test_component_report.py @@ -27,9 +27,9 @@ from tests.component_install_helpers import ( FakeOpener, all_fixture_payloads, + fixture_component_revision, fixture_payload, linux_env, - test_component_revision as fixture_component_revision, write_test_manifest, ) diff --git a/tests/test_doctor.py b/tests/test_doctor.py index b424ed85..b9f6b77e 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -611,9 +611,19 @@ def test_doctor_checks_codex_inbox_when_selected(tmp_target: Path, capsys): includes=[], ) install_selection(tmp_target, sel) - doctor_mod.run(tmp_target) - out = capsys.readouterr().out - assert ".codex/memory-handoffs" in out + capsys.readouterr() # drain install output before the doctor run + + # Assert the production contract directly via structured output: a selected + # Codex harness must produce an OK handoff-inbox check that names the real + # .codex/memory-handoffs path. Do not depend on compact-output truncation + # or on check ordering, which the managed-tool additions can shift. + doctor_mod.run(tmp_target, json_output=True) + payload = json.loads(capsys.readouterr().out) + inbox_checks = {check["name"]: check for check in payload["checks"] if check["name"].startswith("handoff: codex")} + assert "handoff: codex inbox" in inbox_checks + codex_inbox = inbox_checks["handoff: codex inbox"] + assert codex_inbox["status"] == doctor_mod.OK + assert ".codex/memory-handoffs" in codex_inbox["detail"] def test_doctor_reports_default_wired_skills_for_selected_harnesses(tmp_target: Path, capsys): diff --git a/tests/test_managed.py b/tests/test_managed.py index fe7763e9..1441eb57 100644 --- a/tests/test_managed.py +++ b/tests/test_managed.py @@ -513,6 +513,86 @@ def test_standalone_content_guard_is_not_a_managed_install(): assert {tool.name for tool in managed.for_station("guard")} == {"plating"} -def test_agent_notify_install_args_use_escoffier_labs(): +def test_agent_notify_install_args_route_release_installs_through_brigade_setup(): t = managed.resolve("agent-notify") - assert "github.com/escoffier-labs/agent-notify/cmd/agent-notify@latest" in " ".join(t.install_args) + # Release installs go through the managed component resolver, not `go install`. + assert t.install_args == ["brigade", "setup"] + # The source-install fallback is carried by the resolver, not install_args. + assert t.install_resolver is not None + + +def test_agent_notify_install_command_uses_brigade_setup_for_released_cli(monkeypatch): + """Released CLIs must not consult the bundled compatibility published-set.""" + from brigade import component_install, component_manifest + + monkeypatch.setattr(component_install, "uses_bundled_compatibility_manifest", lambda: True) + + def _raise(_manifest): + raise AssertionError("released CLI must not read bundled published_component_ids") + + monkeypatch.setattr(component_manifest, "published_component_ids", _raise) + monkeypatch.setattr( + component_manifest, + "load", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("released CLI must not load compatibility manifest")), + ) + t = managed.resolve("agent-notify") + assert t.install_command() == ["brigade", "setup"] + + +def test_agent_notify_install_command_uses_go_install_for_source_context(monkeypatch): + """Source installs with no published agent-notify use go install only.""" + from brigade import component_install, component_manifest + + monkeypatch.setattr(component_install, "uses_bundled_compatibility_manifest", lambda: False) + monkeypatch.setattr( + component_manifest, + "published_component_ids", + lambda _manifest: ("graphtrail", "graphtrail-mcp", "miseledger", "sessionfind"), + ) + monkeypatch.setattr(component_manifest, "load", lambda *a, **k: object()) + t = managed.resolve("agent-notify") + command = t.install_command() + assert command == ["go", "install", "github.com/escoffier-labs/agent-notify/cmd/agent-notify@latest"] + + +def test_agent_notify_install_command_uses_brigade_setup_when_source_manifest_publishes(monkeypatch): + from brigade import component_install, component_manifest + + monkeypatch.setattr(component_install, "uses_bundled_compatibility_manifest", lambda: False) + published = ("agent-notify", "graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") + monkeypatch.setattr( + component_manifest, + "published_component_ids", + lambda _manifest: published, + ) + monkeypatch.setattr(component_manifest, "load", lambda *a, **k: object()) + t = managed.resolve("agent-notify") + assert t.install_command() == ["brigade", "setup"] + + +def test_agent_notify_install_command_released_cli_never_falls_back_to_go_install(monkeypatch): + """Released CLI always routes through setup; never go install on manifest failure.""" + from brigade import component_install, component_manifest + + monkeypatch.setattr(component_install, "uses_bundled_compatibility_manifest", lambda: True) + monkeypatch.setattr( + component_manifest, + "load", + lambda *a, **k: (_ for _ in ()).throw(ValueError("bundled manifest unreadable")), + ) + t = managed.resolve("agent-notify") + assert t.install_command() == ["brigade", "setup"] + + +def test_agent_notify_install_command_source_uses_go_install_when_manifest_unreadable(monkeypatch): + from brigade import component_install, component_manifest + + monkeypatch.setattr(component_install, "uses_bundled_compatibility_manifest", lambda: False) + monkeypatch.setattr( + component_manifest, + "load", + lambda *a, **k: (_ for _ in ()).throw(ValueError("source manifest unreadable")), + ) + t = managed.resolve("agent-notify") + assert t.install_command() == ["go", "install", "github.com/escoffier-labs/agent-notify/cmd/agent-notify@latest"] diff --git a/tests/test_notifications_cmd.py b/tests/test_notifications_cmd.py index 7d0ae95d..269181c0 100644 --- a/tests/test_notifications_cmd.py +++ b/tests/test_notifications_cmd.py @@ -29,7 +29,7 @@ def _configured_doctor_result(profile: str = "operator") -> notifications_cmd.pr def test_notifications_status_reports_missing_agent_notify(monkeypatch, tmp_target, capsys): - monkeypatch.setattr(notifications_cmd.proc, "which", lambda cmd: None) + monkeypatch.setattr(notifications_cmd.component_bins, "resolve", lambda name, **kw: None) rc = notifications_cmd.status(target=tmp_target, json_output=True) out = capsys.readouterr().out @@ -67,7 +67,7 @@ def fake_run(args, timeout=30.0, env=None, cwd=None, stdin=None): stderr="", ) - monkeypatch.setattr(notifications_cmd.proc, "which", lambda cmd: "/usr/bin/agent-notify") + monkeypatch.setattr(notifications_cmd.component_bins, "resolve", lambda name, **kw: "/usr/bin/agent-notify") monkeypatch.setattr(notifications_cmd.proc, "run", fake_run) rc = notifications_cmd.status(target=tmp_target, profile="operator", json_output=True) @@ -76,7 +76,7 @@ def fake_run(args, timeout=30.0, env=None, cwd=None, stdin=None): assert rc == 0 assert seen == { - "args": ["agent-notify", "doctor", "--json", "--skip-network", "--profile", "operator"], + "args": ["/usr/bin/agent-notify", "doctor", "--json", "--skip-network", "--profile", "operator"], "timeout": 30.0, "stdin": None, } @@ -99,7 +99,7 @@ def fake_run(args, timeout=30.0, env=None, cwd=None, stdin=None): stderr=f"provider request failed: {sentinel_url}", ) - monkeypatch.setattr(notifications_cmd.proc, "which", lambda cmd: "/usr/bin/agent-notify") + monkeypatch.setattr(notifications_cmd.component_bins, "resolve", lambda name, **kw: "/usr/bin/agent-notify") monkeypatch.setattr(notifications_cmd.proc, "run", fake_run) assert notifications_cmd.status(target=tmp_target, profile="operator", json_output=True) == 0 @@ -117,7 +117,7 @@ def fake_run(args, timeout=30.0, env=None, cwd=None, stdin=None): def test_notifications_setup_plan_prints_hook_snippets(monkeypatch, tmp_target, capsys): - monkeypatch.setattr(notifications_cmd.proc, "which", lambda cmd: None) + monkeypatch.setattr(notifications_cmd.component_bins, "resolve", lambda name, **kw: None) rc = notifications_cmd.setup_plan(target=tmp_target, profile="agent-stop") out = capsys.readouterr().out @@ -136,13 +136,13 @@ def fake_run(args, timeout=30.0, env=None, cwd=None, stdin=None): seen["args"] = args return notifications_cmd.proc.Result(code=2, stdout="{}", stderr="ignored") - monkeypatch.setattr(notifications_cmd.proc, "which", lambda cmd: "/usr/bin/agent-notify") + monkeypatch.setattr(notifications_cmd.component_bins, "resolve", lambda name, **kw: "/usr/bin/agent-notify") monkeypatch.setattr(notifications_cmd.proc, "run", fake_run) assert notifications_cmd.setup_plan(target=tmp_target, profile="operator", json_output=True) == 0 payload = json.loads(capsys.readouterr().out) - assert seen["args"] == ["agent-notify", "doctor", "--json", "--skip-network", "--profile", "operator"] + assert seen["args"] == ["/usr/bin/agent-notify", "doctor", "--json", "--skip-network", "--profile", "operator"] assert payload["doctor_probe"] == { "configured": False, "probe_exit_code": 2, @@ -151,7 +151,7 @@ def fake_run(args, timeout=30.0, env=None, cwd=None, stdin=None): def test_notifications_health_is_read_only_when_missing(monkeypatch, tmp_target): - monkeypatch.setattr(notifications_cmd.proc, "which", lambda cmd: None) + monkeypatch.setattr(notifications_cmd.component_bins, "resolve", lambda name, **kw: None) payload = notifications_cmd.health(tmp_target) @@ -167,7 +167,7 @@ def test_notifications_health_is_read_only_when_missing(monkeypatch, tmp_target) def test_notifications_surface_in_center_work_and_daily(monkeypatch, tmp_target, capsys): tmp_target.mkdir() - monkeypatch.setattr(notifications_cmd.proc, "which", lambda cmd: None) + monkeypatch.setattr(notifications_cmd.component_bins, "resolve", lambda name, **kw: None) center_payload = center_cmd.status_payload(tmp_target) assert center_payload["notifications"]["status"] == "manual" @@ -191,7 +191,7 @@ def fake_run(args, timeout=30.0, env=None, cwd=None, stdin=None): calls.append(args) return _configured_doctor_result() - monkeypatch.setattr(notifications_cmd.proc, "which", lambda cmd: "/usr/bin/agent-notify") + monkeypatch.setattr(notifications_cmd.component_bins, "resolve", lambda name, **kw: "/usr/bin/agent-notify") monkeypatch.setattr(notifications_cmd.proc, "run", fake_run) rc = notifications_cmd.event_record( @@ -232,7 +232,7 @@ def fake_run(args, timeout=30.0, env=None, cwd=None, stdin=None): seen["stdin"] = stdin return notifications_cmd.proc.Result(code=0, stdout="sent", stderr="") - monkeypatch.setattr(notifications_cmd.proc, "which", lambda cmd: "/usr/bin/agent-notify") + monkeypatch.setattr(notifications_cmd.component_bins, "resolve", lambda name, **kw: "/usr/bin/agent-notify") monkeypatch.setattr(notifications_cmd.proc, "run", fake_run) assert ( @@ -265,7 +265,7 @@ def fake_run(args, timeout=30.0, env=None, cwd=None, stdin=None): assert payload["sent"] is True assert payload["send_exit_code"] == 0 assert payload["send_failure_class"] is None - assert seen["args"] == ["agent-notify", "send", "--profile", "operator"] + assert seen["args"] == ["/usr/bin/agent-notify", "send", "--profile", "operator"] assert seen["stdin"] == ( b'{"body":"Brigade CI passed.","level":"success","source":"ci","tags":["ci-green"],"title":"CI green"}\n' ) @@ -293,7 +293,7 @@ def fake_run(args, timeout=30.0, env=None, cwd=None, stdin=None): seen["stdin"] = stdin return notifications_cmd.proc.Result(code=exit_code, stdout="ignored", stderr="ignored") - monkeypatch.setattr(notifications_cmd.proc, "which", lambda cmd: "/usr/bin/agent-notify") + monkeypatch.setattr(notifications_cmd.component_bins, "resolve", lambda name, **kw: "/usr/bin/agent-notify") monkeypatch.setattr(notifications_cmd.proc, "run", fake_run) rc = notifications_cmd.event_record( @@ -329,7 +329,7 @@ def fake_run(args, timeout=30.0, env=None, cwd=None, stdin=None): stderr=f"authorization={sentinel_token}", ) - monkeypatch.setattr(notifications_cmd.proc, "which", lambda cmd: "/usr/bin/agent-notify") + monkeypatch.setattr(notifications_cmd.component_bins, "resolve", lambda name, **kw: "/usr/bin/agent-notify") monkeypatch.setattr(notifications_cmd.proc, "run", fake_run) assert ( diff --git a/tests/test_publish_workflow.py b/tests/test_publish_workflow.py index 260b3e6f..4fa2e9e9 100644 --- a/tests/test_publish_workflow.py +++ b/tests/test_publish_workflow.py @@ -63,6 +63,11 @@ def test_publish_workflow_builds_the_complete_native_matrix_then_attests_and_rel "graphtrail-mcp-windows-amd64.exe", "miseledger-darwin-arm64", "sessionfind-linux-arm64", + "agent-notify-linux-amd64", + "agent-notify-linux-arm64", + "agent-notify-darwin-amd64", + "agent-notify-darwin-arm64", + "agent-notify-windows-amd64.exe", "component-manifest-v1.json", ): assert subject in text @@ -147,3 +152,60 @@ def test_publish_release_reruns_compare_existing_assets_then_upload_only_missing assert 'gh release create "$TAG" release-assets/* --repo "$GITHUB_REPOSITORY" --verify-tag' in section assert "--target" not in section assert "--clobber" not in section + + +def test_publish_workflow_builds_five_agent_notify_binaries_with_release_metadata_ldflags(): + text = (ROOT / ".github" / "workflows" / "publish.yml").read_text() + section = text[text.index(" build-agent-notify-native:") : text.index(" assemble-release:")] + + assert "needs: validate-release" in section + assert "if: github.ref_type == 'tag' && startsWith(github.ref_name, 'v')" in section + # Exactly five platform targets, one binary each. + assert section.count("- platform: ") == 5 + for platform in ( + "linux-amd64", + "linux-arm64", + "darwin-amd64", + "darwin-arm64", + "windows-amd64", + ): + assert f"- platform: {platform}" in section + assert "working-directory: stations/notify" in section + assert "CGO_ENABLED: '0'" in section + assert "go build -trimpath" in section + assert "./cmd/agent-notify" in section + # Release metadata injection: ldflags with the three -X main.* fields plus + # the trimpath and size-stripping flags (-s -w) preserved. A bare `go build` + # would leave dev/unknown/unknown, so the workflow must inject ldflags. + assert "-ldflags" in section + assert "-X main.version=" in section + assert "-X main.commit=" in section + assert "-X main.buildDate=" in section + assert " -s -w" in section + # Version is the tag without the leading v; commit is the full release SHA; + # build date is one UTC timestamp computed via `date -u`. + assert "${{ github.ref_name }}" in section + assert "${AGENT_NOTIFY_TAG#v}" in section + assert "${{ github.sha }}" in section + assert "AGENT_NOTIFY_COMMIT" in section + assert "date -u +%Y-%m-%dT%H:%M:%SZ" in section + assert "actions/upload-artifact@v4" in section + assert "if-no-files-found: error" in section + + +def test_publish_workflow_assemble_release_counts_25_native_assets_and_27_release_files(): + text = (ROOT / ".github" / "workflows" / "publish.yml").read_text() + assemble = text[text.index(" assemble-release:") : text.index(" create-release:")] + + assert 'test "$(find downloaded -type f | wc -l)" -eq 25' in assemble + assert 'test "$(find release-assets -maxdepth 1 -type f | wc -l)" -eq 27' in assemble + assert "pattern: agent-notify-*" in assemble + # assemble-release waits on the agent-notify native job. + needs_line = next(line for line in assemble.splitlines() if line.strip().startswith("needs:")) + assert "build-agent-notify-native" in needs_line + + +def test_publish_release_gate_requires_27_release_assets(): + text = (ROOT / ".github" / "workflows" / "publish.yml").read_text() + gate = text[text.index(" release-asset-gate:") : text.index(" build-and-publish:")] + assert 'test "$(find release-assets -maxdepth 1 -type f | wc -l)" -eq 27' in gate diff --git a/tests/test_published_artifact_acceptance.py b/tests/test_published_artifact_acceptance.py index 0f85be6e..5f0718ab 100644 --- a/tests/test_published_artifact_acceptance.py +++ b/tests/test_published_artifact_acceptance.py @@ -36,14 +36,14 @@ def _healthy_report(managed_bin): return { "components": [ _component(component_id, managed_bin / component_id) - for component_id in ("graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") + for component_id in ("agent-notify", "graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") ] } def _write_managed_binaries(managed_bin): managed_bin.mkdir(parents=True) - for component_id in ("graphtrail", "graphtrail-mcp", "miseledger", "sessionfind"): + for component_id in ("agent-notify", "graphtrail", "graphtrail-mcp", "miseledger", "sessionfind"): executable = managed_bin / component_id executable.write_text("#!/bin/sh\nexit 0\n") executable.chmod(0o755) @@ -109,13 +109,13 @@ def test_component_report_rejects_missing_or_unhealthy_component(acceptance_modu acceptance_module.validate_component_report(report, managed_bin) -def test_component_report_requires_exactly_four_components(acceptance_module, tmp_path): +def test_component_report_requires_exactly_five_components(acceptance_module, tmp_path): managed_bin = tmp_path / "xdg-data" / "brigade" / "bin" _write_managed_binaries(managed_bin) report = _healthy_report(managed_bin) report["components"].pop() - with pytest.raises(acceptance_module.AcceptanceError, match="exactly 4"): + with pytest.raises(acceptance_module.AcceptanceError, match="exactly 5"): acceptance_module.validate_component_report(report, managed_bin) @@ -197,10 +197,18 @@ def runner(argv, **kwargs): return subprocess.CompletedProcess(argv, 0, '{"jsonrpc":"2.0","id":1,"result":{}}', "") if Path(argv[0]).name == "sessionfind": return subprocess.CompletedProcess(argv, 0, "usage: sessionfind", "") + if Path(argv[0]).name == "agent-notify": + return subprocess.CompletedProcess( + argv, + 0, + '{"version":"acceptance","commit":"abc123def456","build_date":"2026-07-22T13:29:00Z"}', + "", + ) return subprocess.CompletedProcess(argv, 0, "ok", "") acceptance_module.smoke_managed_components( {component_id: managed_bin / component_id for component_id in acceptance_module.COMPONENT_IDS}, + version="acceptance", runner=runner, ) @@ -216,10 +224,18 @@ def runner(argv, **kwargs): return subprocess.CompletedProcess(argv, 0, '{"jsonrpc":"2.0","id":1,"result":{}}', "") if Path(argv[0]).name == "sessionfind": return subprocess.CompletedProcess(argv, 0, "\n sessionfind query [PATH]...\n", "") + if Path(argv[0]).name == "agent-notify": + return subprocess.CompletedProcess( + argv, + 0, + '{"version":"acceptance","commit":"abc123def456","build_date":"2026-07-22T13:29:00Z"}', + "", + ) return subprocess.CompletedProcess(argv, 0, "ok", "") acceptance_module.smoke_managed_components( {component_id: managed_bin / component_id for component_id in acceptance_module.COMPONENT_IDS}, + version="acceptance", runner=runner, ) @@ -233,11 +249,19 @@ def runner(argv, **kwargs): return subprocess.CompletedProcess(argv, 0, '{"jsonrpc":"2.0","id":1,"result":{}}', "") if Path(argv[0]).name == "sessionfind": return subprocess.CompletedProcess(argv, 0, "commands available", "no help text") + if Path(argv[0]).name == "agent-notify": + return subprocess.CompletedProcess( + argv, + 0, + '{"version":"acceptance","commit":"abc123def456","build_date":"2026-07-22T13:29:00Z"}', + "", + ) return subprocess.CompletedProcess(argv, 0, "ok", "") with pytest.raises(acceptance_module.AcceptanceError, match="sessionfind smoke produced no help text"): acceptance_module.smoke_managed_components( {component_id: managed_bin / component_id for component_id in acceptance_module.COMPONENT_IDS}, + version="acceptance", runner=runner, ) @@ -291,7 +315,7 @@ def test_release_asset_verification_requires_one_tag_and_verifies_all_native_byt ) assert set(verified["native_paths"]) == set(acceptance_module.COMPONENT_IDS) - assert len(list((tmp_path / "release-assets").iterdir())) == 21 + assert len(list((tmp_path / "release-assets").iterdir())) == 26 def test_release_asset_verification_marks_posix_assets_executable_but_not_windows( @@ -366,3 +390,99 @@ def test_managed_digest_verification_rejects_binary_not_from_release_manifest(ac acceptance_module.verify_managed_component_digests( manifest, {name: managed / name for name in acceptance_module.COMPONENT_IDS}, "linux-amd64" ) + + +def _agent_notify_payload(version="1.2.3", commit="abc123def456", build_date="2026-07-22T13:29:00Z"): + return {"version": version, "commit": commit, "build_date": build_date} + + +def test_validate_agent_notify_version_payload_accepts_full_sha_without_requiring_short(acceptance_module): + full_sha = "a" * 40 + payload = _agent_notify_payload(commit=full_sha) + # Must not raise: the release build injects the full github.sha, so a short + # SHA must not be required. + acceptance_module.validate_agent_notify_version_payload(payload, "1.2.3") + + +def test_validate_agent_notify_version_payload_accepts_short_sha(acceptance_module): + acceptance_module.validate_agent_notify_version_payload(_agent_notify_payload(commit="abc123d"), "1.2.3") + + +def test_validate_agent_notify_version_payload_rejects_bare_build_dev_unknown_defaults(acceptance_module): + """A bare `go build` leaves dev/unknown/unknown; every placeholder field is rejected.""" + for field, bad_value, matcher in ( + ("version", "dev", "dev/unknown"), + ("version", "unknown", "dev/unknown"), + ("commit", "unknown", "commit"), + ("build_date", "unknown", "build_date"), + ): + payload = _agent_notify_payload() + payload[field] = bad_value + with pytest.raises(acceptance_module.AcceptanceError, match=matcher): + acceptance_module.validate_agent_notify_version_payload(payload, "1.2.3") + + +def test_validate_agent_notify_version_payload_rejects_version_mismatch(acceptance_module): + payload = _agent_notify_payload(version="1.2.4") + with pytest.raises(acceptance_module.AcceptanceError, match="version mismatch"): + acceptance_module.validate_agent_notify_version_payload(payload, "1.2.3") + + +def test_validate_agent_notify_version_payload_rejects_missing_version_field(acceptance_module): + payload = _agent_notify_payload() + del payload["version"] + with pytest.raises(acceptance_module.AcceptanceError, match="missing version field"): + acceptance_module.validate_agent_notify_version_payload(payload, "1.2.3") + + +def test_validate_agent_notify_version_payload_rejects_non_hex_commit(acceptance_module): + payload = _agent_notify_payload(commit="not-a-sha") + with pytest.raises(acceptance_module.AcceptanceError, match="commit"): + acceptance_module.validate_agent_notify_version_payload(payload, "1.2.3") + + +def test_validate_agent_notify_version_payload_rejects_non_utc_build_date(acceptance_module): + payload = _agent_notify_payload(build_date="2026-07-22 13:29:00") + with pytest.raises(acceptance_module.AcceptanceError, match="build_date"): + acceptance_module.validate_agent_notify_version_payload(payload, "1.2.3") + + +def test_smoke_managed_components_rejects_agent_notify_bare_build_output(acceptance_module, tmp_path): + """The smoke would pass on the current bare build (dev/unknown/unknown); it must fail.""" + managed_bin = tmp_path / "xdg-data" / "brigade" / "bin" + _write_managed_binaries(managed_bin) + + def runner(argv, **kwargs): + if Path(argv[0]).name == "graphtrail-mcp": + return subprocess.CompletedProcess(argv, 0, '{"jsonrpc":"2.0","id":1,"result":{}}', "") + if Path(argv[0]).name == "sessionfind": + return subprocess.CompletedProcess(argv, 0, "usage: sessionfind", "") + if Path(argv[0]).name == "agent-notify": + # Bare `go build` reports the dev/unknown/unknown defaults. + return subprocess.CompletedProcess( + argv, + 0, + '{"version":"dev","commit":"unknown","build_date":"unknown"}', + "", + ) + return subprocess.CompletedProcess(argv, 0, "ok", "") + + with pytest.raises(acceptance_module.AcceptanceError): + acceptance_module.smoke_managed_components( + {component_id: managed_bin / component_id for component_id in acceptance_module.COMPONENT_IDS}, + version="1.2.3", + runner=runner, + ) + + +def test_smoke_managed_components_requires_version_keyword(acceptance_module, tmp_path): + """smoke_managed_components must thread the release version through so a bare-build + agent-notify cannot slip past with a placeholder version.""" + managed_bin = tmp_path / "xdg-data" / "brigade" / "bin" + _write_managed_binaries(managed_bin) + + import inspect + + signature = inspect.signature(acceptance_module.smoke_managed_components) + assert "version" in signature.parameters + assert signature.parameters["version"].kind == inspect.Parameter.KEYWORD_ONLY diff --git a/tests/test_release_manifest_generator.py b/tests/test_release_manifest_generator.py index b204046c..60231331 100644 --- a/tests/test_release_manifest_generator.py +++ b/tests/test_release_manifest_generator.py @@ -13,7 +13,7 @@ ROOT = Path(__file__).resolve().parents[1] SCRIPT = ROOT / "scripts" / "generate_component_manifest.py" -COMPONENTS = ("graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") +COMPONENTS = ("agent-notify", "graphtrail", "graphtrail-mcp", "miseledger", "sessionfind") PLATFORMS = ("linux-amd64", "linux-arm64", "darwin-amd64", "darwin-arm64", "windows-amd64")