Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 70 additions & 4 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
35 changes: 34 additions & 1 deletion docs/component-manifest-v1.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down
2 changes: 1 addition & 1 deletion scripts/generate_component_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}$")
Expand Down
57 changes: 51 additions & 6 deletions scripts/published-artifact-acceptance.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import argparse
import json
import os
import re
import shlex
import stat
import subprocess
Expand All @@ -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]
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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] = {}
Expand Down Expand Up @@ -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():
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions scripts/verify_component_manifest_provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}$")
Expand Down Expand Up @@ -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]] = {}
Expand Down
Loading
Loading