Skip to content
Draft
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
49 changes: 49 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Checks for pull requests and main: the Go build, vet and tests, the license
# headers, and the Python client's unit tests. Release publishing lives in
# release.yaml.
name: ci

on:
pull_request:
push:
branches: [main]

permissions:
contents: read

jobs:
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

Check failure on line 32 in .github/workflows/ci.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

zizmor/unpinned-uses

unpinned action reference: action is not pinned to a hash (required by blanket policy)

Check failure on line 32 in .github/workflows/ci.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

unpinned-uses

ci.yaml:32: unpinned action reference: action is not pinned to a hash (required by blanket policy)
- uses: actions/setup-go@v5

Check failure on line 33 in .github/workflows/ci.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

zizmor/unpinned-uses

unpinned action reference: action is not pinned to a hash (required by blanket policy)

Check failure on line 33 in .github/workflows/ci.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

unpinned-uses

ci.yaml:33: unpinned action reference: action is not pinned to a hash (required by blanket policy)
with:
go-version-file: go.mod
- run: go build ./...
- run: go vet ./...
- run: go test ./...
- run: hack/verify/boilerplate.sh

python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

Check failure on line 44 in .github/workflows/ci.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

zizmor/unpinned-uses

unpinned action reference: action is not pinned to a hash (required by blanket policy)

Check failure on line 44 in .github/workflows/ci.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

unpinned-uses

ci.yaml:44: unpinned action reference: action is not pinned to a hash (required by blanket policy)
- uses: actions/setup-python@v5

Check failure on line 45 in .github/workflows/ci.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

zizmor/unpinned-uses

unpinned action reference: action is not pinned to a hash (required by blanket policy)

Check failure on line 45 in .github/workflows/ci.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

unpinned-uses

ci.yaml:45: unpinned action reference: action is not pinned to a hash (required by blanket policy)
with:
python-version: "3.12"
- run: python -m pip install -e 'clients/python[dev]'
- run: python -m pytest clients/python/tests -q
133 changes: 133 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Publishes a release: the ate-env-api and ate-env-guest images to GitHub
# Container Registry, pinned by digest and signed, and the ate-env CLI with
# those digests baked in as its defaults, so a release binary deploys without
# naming any image of this repo. Runs on a v* tag, or by hand for an existing
# tag (to republish). See docs/release.md.
name: release

on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
tag:
description: "Existing tag to publish images and assets for (e.g. v0.1.0)"
required: true
type: string

permissions:
contents: write # release assets and notes
packages: write # ghcr.io
id-token: write # cosign keyless signing

env:
# ghcr.io/<org>/<repo>/<image>; the images are linked to this repository.
IMAGE_PREFIX: ghcr.io/${{ github.repository }}
KO_DEFAULTPLATFORMS: linux/amd64,linux/arm64

jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Resolve the tag
id: tag
run: echo "tag=${{ inputs.tag || github.ref_name }}" >> "$GITHUB_OUTPUT"

Check failure on line 48 in .github/workflows/release.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

zizmor/template-injection

code injection via template expansion: may expand into attacker-controllable code

Check failure on line 48 in .github/workflows/release.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

zizmor/template-injection

code injection via template expansion: may expand into attacker-controllable code

Check failure on line 48 in .github/workflows/release.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

template-injection

release.yaml:48: code injection via template expansion: may expand into attacker-controllable code

Check failure on line 48 in .github/workflows/release.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

template-injection

release.yaml:48: code injection via template expansion: may expand into attacker-controllable code

- uses: actions/checkout@v4

Check failure on line 50 in .github/workflows/release.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

zizmor/unpinned-uses

unpinned action reference: action is not pinned to a hash (required by blanket policy)

Check failure on line 50 in .github/workflows/release.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

unpinned-uses

release.yaml:50: unpinned action reference: action is not pinned to a hash (required by blanket policy)
with:
ref: ${{ steps.tag.outputs.tag }}
fetch-depth: 0

- uses: actions/setup-go@v5

Check failure on line 55 in .github/workflows/release.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

unpinned-uses

release.yaml:55: unpinned action reference: action is not pinned to a hash (required by blanket policy)
with:
go-version-file: go.mod

- uses: ko-build/setup-ko@v0.9

Check failure on line 59 in .github/workflows/release.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

unpinned-uses

release.yaml:59: unpinned action reference: action is not pinned to a hash (required by blanket policy)

- uses: sigstore/cosign-installer@v3

Check failure on line 61 in .github/workflows/release.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

unpinned-uses

release.yaml:61: unpinned action reference: action is not pinned to a hash (required by blanket policy)

- name: Build and push the images
id: images
env:
TAG: ${{ steps.tag.outputs.tag }}
run: |
set -euo pipefail
api=$(KO_DOCKER_REPO="${IMAGE_PREFIX}/ate-env-api" ko build --bare --tags "${TAG},latest" ./cmd/ate-env-api)
guest=$(KO_DOCKER_REPO="${IMAGE_PREFIX}/ate-env-guest" ko build --bare --tags "${TAG},latest" ./cmd/ate-env-guest)
echo "api=${api}" >> "$GITHUB_OUTPUT"
echo "guest=${guest}" >> "$GITHUB_OUTPUT"
echo "ate-env-api: ${api}"
echo "ate-env-guest: ${guest}"

- name: Sign the images (keyless, bound to this workflow's identity)
run: cosign sign --yes "${{ steps.images.outputs.api }}" "${{ steps.images.outputs.guest }}"

Check failure on line 77 in .github/workflows/release.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

zizmor/template-injection

code injection via template expansion: may expand into attacker-controllable code

Check failure on line 77 in .github/workflows/release.yaml

View workflow job for this annotation

GitHub Actions / zizmor-output

zizmor/template-injection

code injection via template expansion: may expand into attacker-controllable code

- name: Build the CLI with the image digests as defaults
env:
TAG: ${{ steps.tag.outputs.tag }}
API_IMAGE: ${{ steps.images.outputs.api }}
GUEST_IMAGE: ${{ steps.images.outputs.guest }}
run: |
set -euo pipefail
mkdir -p dist
ldflags="-s -w -X main.version=${TAG} -X main.defaultAPIImage=${API_IMAGE} -X main.defaultGuestImage=${GUEST_IMAGE}"
for os in linux darwin; do
for arch in amd64 arm64; do
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath -ldflags "$ldflags" \
-o "dist/ate-env_${TAG}_${os}_${arch}" ./cmd/ate-env
done
done
{
echo "ate-env-api=${API_IMAGE}"
echo "ate-env-guest=${GUEST_IMAGE}"
} > "dist/images_${TAG}.txt"
(cd dist && sha256sum ./* > "SHA256SUMS_${TAG}.txt")
ls -l dist

- name: Publish the release assets and record the digests in the notes
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.tag.outputs.tag }}
API_IMAGE: ${{ steps.images.outputs.api }}
GUEST_IMAGE: ${{ steps.images.outputs.guest }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
if ! gh release view "$TAG" >/dev/null 2>&1; then
gh release create "$TAG" --title "$TAG" --generate-notes
fi
gh release upload "$TAG" dist/* --clobber
existing=$(gh release view "$TAG" --json body --jq .body)
# Replace a previous images block (republish) rather than append twice.
existing=$(printf '%s\n' "$existing" | awk '/^## Images$/{skip=1} skip&&/^## /&&!/^## Images$/{skip=0} !skip')
{
printf '%s\n\n' "$existing"
echo "## Images"
echo
echo "Pinned by digest and signed with cosign (keyless, GitHub Actions OIDC):"
echo
echo "- \`${API_IMAGE}\`"
echo "- \`${GUEST_IMAGE}\`"
echo
echo "The \`ate-env\` binaries attached to this release default \`manifest --api-image\` and \`manifest template --guest-image\` to these digests."
echo
echo '```bash'
echo "cosign verify --certificate-oidc-issuer https://token.actions.githubusercontent.com \\"
echo " --certificate-identity-regexp '^https://github.com/${REPO}/' ${GUEST_IMAGE}"
echo '```'
} > notes.md
gh release edit "$TAG" --notes-file notes.md
2 changes: 1 addition & 1 deletion .ko.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@
# limitations under the License.

baseImageOverrides:
github.com/agent-substrate/env/cmd/ate-env-guest: bash:latest
github.com/agent-substrate/env/cmd/ate-env-guest: bash:5.3@sha256:61962062d969cb46dfc2bad061d36342406fa485f64f246aa7e95693ca07df1f
15 changes: 14 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,24 @@
GOOGLE_CLOUD_PROJECT ?= $(shell gcloud config get-value project 2>/dev/null)
ATE_ENV_IMAGE_REPO ?= gcr.io/$(GOOGLE_CLOUD_PROJECT)

.PHONY: build install test vet clean images python-protos python-test verify-boilerplate
# Baked into the ate-env CLI by build-cli (release.yaml does the same with the
# published digests): `ate-env --version`, and the defaults of manifest
# --api-image and manifest template --guest-image.
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
ATE_ENV_API_IMAGE ?=
ATE_ENV_GUEST_IMAGE ?=
CLI_LDFLAGS := -X main.version=$(VERSION) -X main.defaultAPIImage=$(ATE_ENV_API_IMAGE) -X main.defaultGuestImage=$(ATE_ENV_GUEST_IMAGE)

.PHONY: build build-cli install test vet clean images python-protos python-test verify-boilerplate

build:
go build ./...

# Build bin/ate-env with the version and, if ATE_ENV_API_IMAGE /
# ATE_ENV_GUEST_IMAGE are set, image defaults baked in.
build-cli:
go build -trimpath -ldflags "$(CLI_LDFLAGS)" -o bin/ate-env ./cmd/ate-env

# Install ate-env and ate-env-api to $GOBIN (or $GOPATH/bin).
install:
go install ./cmd/...
Expand Down
36 changes: 31 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,34 @@ while this project adds the environment-shaped API on top.

## Installation

Download the `ate-env` binary for your platform from the
[latest release](https://github.com/agent-substrate/env/releases/latest). Release
binaries know the digests of the `ate-env-api` and `ate-env-guest` images
published with that release, so the commands below need no image flags for
this repo's images. Building from source works too, but then `--api-image` and
`--guest-image` must be given:

```bash
go install github.com/agent-substrate/env/cmd/ate-env@latest
```

## Images

Each release publishes two images to GitHub Container Registry, multi-platform,
signed, and meant to be used by digest:

| image | runs |
|---|---|
| `ghcr.io/agent-substrate/env/ate-env-api` | the API service, in the cluster |
| `ghcr.io/agent-substrate/env/ate-env-guest` | inside every environment actor |

The digests are in the release notes and in the `images_<tag>.txt` asset; the
release's `ate-env` binary defaults to them. The worker image
(`--worker-image`, `ateom-gvisor`) comes from the
[Substrate repo](https://github.com/agent-substrate/substrate) and must match the
Substrate version on your cluster. See [docs/release.md](docs/release.md) for
verification, pinning and how releases are cut.

## Quickstart

Prerequisites: a cluster with [Agent Substrate](https://github.com/agent-substrate/substrate)
Expand All @@ -46,10 +70,11 @@ installed and a snapshots bucket.
Deploy the namespace, worker pool, and API service:

```bash
export GOOGLE_CLOUD_PROJECT=$(gcloud config get-value project)
# With a release binary the API image defaults to the release's; the worker
# image is ateom-gvisor built from the Substrate repo at your cluster's version.
ate-env manifest \
--api-image gcr.io/$GOOGLE_CLOUD_PROJECT/ate-env-api@sha256:0952ad3fa121597c5ff2943b701f6f0968ba51fdd93b0985d1e831d7cad804a4 \
--worker-image gcr.io/$GOOGLE_CLOUD_PROJECT/ateom-gvisor-715889664656de67e44382a8d6ab981d@sha256:0e69688125a167ffd62ab084a9ab1a50e3f06e9107b36dcb01c3fb3ac0b23fcb | kubectl apply -f -
--worker-image <registry>/ateom-gvisor@sha256:<digest> | kubectl apply -f -
# From a source build, add: --api-image ghcr.io/agent-substrate/env/ate-env-api@sha256:<digest>

# Ensure that the pods are running:
kubectl get pods -n ate-env
Expand All @@ -60,9 +85,10 @@ kubectl get pods -n ate-env
Substrate manages ActorTemplates directly in its control plane rather than Kubernetes CRDs. Use `ate-env manifest template` to generate the Substrate ActorTemplate manifest:

```bash
# With a release binary the guest image defaults to the release's.
ate-env manifest template \
--guest-image gcr.io/$GOOGLE_CLOUD_PROJECT/ate-env-guest@sha256:47f18ee80fbdc4aa86ca7bccb78c37add6314ca278b38b88641eb49757921b73 \
--snapshots-bucket gs://$GOOGLE_CLOUD_PROJECT/ate-env/ | kubectl-ate create actor-template -f -
--snapshots-bucket <object-storage-url> | kubectl-ate create actor-template -f -
# From a source build, add: --guest-image ghcr.io/agent-substrate/env/ate-env-guest@sha256:<digest>
```

Then create and use an environment:
Expand Down
1 change: 1 addition & 0 deletions cmd/ate-env/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ Common environment commands:
ate-env <id> shell <cmdline> Run a shell command line in the environment`,
SilenceUsage: true,
SilenceErrors: true,
Version: version,
}

root.AddCommand(newManifestCommand())
Expand Down
21 changes: 14 additions & 7 deletions cmd/ate-env/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,15 @@ type manifestConfig struct {
}

func (c *manifestConfig) resolveImages() error {
if c.workerImage == "" || c.apiImage == "" {
return errors.New(`--api-image and --worker-image (or --ateom-image) are required; use the
digest-pinned images published by the latest release (the README
quickstart records them), or build and push your own.`)
if c.workerImage == "" {
return errors.New(`--worker-image (or --ateom-image) is required: the worker image is
ateom-gvisor from the Substrate repo and must match the Substrate version
deployed on your cluster; build it there with ko (see the README).`)
}
if c.apiImage == "" {
return errors.New(`--api-image is required when ate-env is built from source; release
binaries default it to the ate-env-api image published with the release
(see docs/release.md), or build and push your own with make images.`)
}
return nil
}
Expand All @@ -68,7 +73,9 @@ type templateConfig struct {

func (c *templateConfig) resolveImages() error {
if c.guestImage == "" {
return errors.New("--guest-image is required; use the digest-pinned ate-env-guest image")
return errors.New(`--guest-image is required when ate-env is built from source; release
binaries default it to the ate-env-guest image published with the release
(see docs/release.md), or build and push your own with make images.`)
}
if c.snapshotsBucket == "" {
return errors.New("--snapshots-bucket is required; use an object-storage bucket (e.g. gs://bucket/prefix/)")
Expand Down Expand Up @@ -108,7 +115,7 @@ the "template" subcommand: ate-env manifest template`,
cmd.Flags().StringVar(&mCfg.template, "template", apiservice.DefaultTemplate, "ActorTemplate name")
cmd.Flags().StringVar(&mCfg.workerImage, "worker-image", "", "digest-pinned worker image for the worker pool, e.g. ateom-gvisor built from the Substrate repo")
cmd.Flags().StringVar(&mCfg.workerImage, "ateom-image", "", "alias for --worker-image")
cmd.Flags().StringVar(&mCfg.apiImage, "api-image", "", "digest-pinned ate-env-api image for the API service")
cmd.Flags().StringVar(&mCfg.apiImage, "api-image", defaultAPIImage, imageDefaultHelp("digest-pinned ate-env-api image for the API service", defaultAPIImage))
cmd.Flags().Int32Var(&mCfg.apiReplicas, "api-replicas", 1, "number of API service replicas")
cmd.Flags().Int32Var(&mCfg.apiPort, "api-port", 7777, "port the ate-env-api service listens on")
cmd.Flags().StringVar(&mCfg.workerPool, "workerpool", "", "WorkerPool name (defaults to <template>-workerpool)")
Expand Down Expand Up @@ -141,7 +148,7 @@ It prints YAML to stdout without touching the cluster.`,

cmd.Flags().StringVar(&tCfg.template, "template", apiservice.DefaultTemplate, "ActorTemplate name")
cmd.Flags().StringVar(&tCfg.atespace, "atespace", apiservice.DefaultAtespace, "Substrate atespace for the ActorTemplate")
cmd.Flags().StringVar(&tCfg.guestImage, "guest-image", "", "digest-pinned ate-env-guest image (repo@sha256:...)")
cmd.Flags().StringVar(&tCfg.guestImage, "guest-image", defaultGuestImage, imageDefaultHelp("digest-pinned ate-env-guest image (repo@sha256:...)", defaultGuestImage))
cmd.Flags().StringSliceVar(&tCfg.guestCommand, "guest-command", []string{"/ko-app/ate-env-guest"}, "guest container entrypoint")
cmd.Flags().StringVar(&tCfg.snapshotsBucket, "snapshots-bucket", "", "object-storage bucket (with optional prefix) for actor snapshots, e.g. gs://bucket/prefix/")

Expand Down
36 changes: 36 additions & 0 deletions cmd/ate-env/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,39 @@ func TestManifestTemplateCommandExecution(t *testing.T) {
t.Errorf("expected guest image and storage location in output:\n%s", out)
}
}

const relHex64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"

func TestImageFlagsDefaultToReleaseImages(t *testing.T) {
// A source build has no defaults and the flags are required.
cmd := newManifestTemplateCommand()
if got := cmd.Flags().Lookup("guest-image").DefValue; got != "" {
t.Errorf("source build guest-image default = %q, want empty", got)
}
cfg := templateConfig{template: "t", snapshotsBucket: "gs://b/"}
if err := cfg.resolveImages(); err == nil || !strings.Contains(err.Error(), "built from source") {
t.Errorf("missing --guest-image should name the source-build case, got %v", err)
}

// A release build carries the digests; the flags default to them.
oldAPI, oldGuest := defaultAPIImage, defaultGuestImage
defaultAPIImage = "ghcr.io/example/ate-env-api@sha256:" + relHex64
defaultGuestImage = "ghcr.io/example/ate-env-guest@sha256:" + relHex64
t.Cleanup(func() { defaultAPIImage, defaultGuestImage = oldAPI, oldGuest })

if got := newManifestTemplateCommand().Flags().Lookup("guest-image").DefValue; got != defaultGuestImage {
t.Errorf("guest-image default = %q, want the release image", got)
}
if got := newManifestCommand().Flags().Lookup("api-image").DefValue; got != defaultAPIImage {
t.Errorf("api-image default = %q, want the release image", got)
}
usage := newManifestCommand().Flags().Lookup("api-image").Usage
if !strings.Contains(usage, "published with this release") {
t.Errorf("api-image help should say the default is the release image, got %q", usage)
}
// The worker image stays required: it comes from the Substrate repo.
m := manifestConfig{apiImage: defaultAPIImage}
if err := m.resolveImages(); err == nil || !strings.Contains(err.Error(), "Substrate") {
t.Errorf("missing --worker-image should point at the Substrate repo, got %v", err)
}
}
Loading
Loading