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
121 changes: 104 additions & 17 deletions .github/workflows/publish-ghcr-platform.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ jobs:
# here from the actual diff of the push. Anything that prevents computing
# that diff (a brand-new branch, a force push, a manual dispatch) falls back
# to building - a spurious build is cheap, a silently skipped one is not.
#
# A build-relevant push whose tree was already built is not rebuilt either:
# main only moves through the promotion pull request (next -> main), so its
# merge commit carries the exact tree of a next head that build, verify and
# publish already tagged as sha-<short>. That tag is reused and retagged by
# the publish job instead of spending two 8-core runners on an identical
# image. Any tree that no published tag matches falls back to a full build.
changes:
if: >-
(github.repository == 'chatbotkit/platform' || startsWith(github.repository, 'chatbotkit/platform-')) &&
Expand All @@ -43,6 +50,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
build: ${{ steps.diff.outputs.build }}
reuse: ${{ steps.reuse.outputs.reuse }}
steps:
- uses: actions/checkout@v7

Expand Down Expand Up @@ -70,6 +78,72 @@ jobs:
echo "build=false" >> "$GITHUB_OUTPUT"
fi

# @note reuse is decided once for the single flavor; a second flavor
# needs a per-flavor lookup and output here
- name: Resolve image names
if: steps.diff.outputs.build == 'true'
id: image
env:
FLAVOR: community
REPOSITORY_NAME: ${{ github.event.repository.name }}
REPOSITORY_OWNER: ${{ github.repository_owner }}
run: |
owner="${REPOSITORY_OWNER,,}"
repository="${REPOSITORY_NAME,,}"
stack="${REGISTRY}/${owner}/${repository}-${FLAVOR}"
echo "application=${stack}-app" >> "$GITHUB_OUTPUT"
echo "initializer=${stack}-init" >> "$GITHUB_OUTPUT"

- name: Set up Docker Buildx
if: steps.diff.outputs.build == 'true'
uses: docker/setup-buildx-action@v4

- name: Log in to GitHub Container Registry
if: steps.diff.outputs.build == 'true'
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# @note candidates are the pushed commit (a fast-forward or a re-run)
# and its parents (the promotion merge, whose second parent is the next
# head). A candidate counts only when its tree is byte-identical to the
# pushed tree and both component images carry its sha- tag, which only
# publish creates and only after verify passed
- name: Find a published image of the same tree
if: steps.diff.outputs.build == 'true'
id: reuse
env:
APPLICATION_IMAGE: ${{ steps.image.outputs.application }}
INITIALIZER_IMAGE: ${{ steps.image.outputs.initializer }}
run: |
tree=$(git rev-parse "${GITHUB_SHA}^{tree}")

# @note the checkout is shallow, so parents are read from the raw
# commit object; rev-list would report the grafted commit as a root
parents=$(git cat-file -p "$GITHUB_SHA" | awk '/^parent /{print $2}')

for candidate in "$GITHUB_SHA" $parents; do
if [ "$candidate" != "$GITHUB_SHA" ]; then
git fetch --quiet --depth=1 origin "$candidate" || continue
fi

[ "$(git rev-parse "${candidate}^{tree}")" = "$tree" ] || continue

short="${candidate:0:7}"

if docker buildx imagetools inspect "${APPLICATION_IMAGE}:sha-${short}" >/dev/null 2>&1 \
&& docker buildx imagetools inspect "${INITIALIZER_IMAGE}:sha-${short}" >/dev/null 2>&1
then
echo "Reusing images built from ${candidate}"
echo "reuse=${short}" >> "$GITHUB_OUTPUT"
exit 0
fi
done

echo "reuse=" >> "$GITHUB_OUTPUT"

# @note `next` receives direct pushes, so nothing has vetted the code yet -
# the quality gate runs alongside the image build and blocks publication,
# not the build itself: build only pushes untagged per-architecture digests,
Expand All @@ -95,6 +169,7 @@ jobs:
!cancelled() &&
(github.repository == 'chatbotkit/platform' || startsWith(github.repository, 'chatbotkit/platform-')) &&
needs.changes.outputs.build == 'true' &&
needs.changes.outputs.reuse == '' &&
github.actor != 'github-actions[bot]' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next')
name: Build ${{ matrix.flavor.name }} (${{ matrix.architecture.name }})
Expand Down Expand Up @@ -283,16 +358,18 @@ jobs:
# leaves the build's untagged digests unreachable in the registry and
# publishes nothing. !cancelled() + accepting the skipped verify is
# required: the implicit success() looks at the whole needs chain, and
# verify is skipped on main
# verify is skipped on main. A skipped build is accepted only when the
# changes job found a published sha- tag of the same tree to retag.
if: >-
!cancelled() &&
(github.repository == 'chatbotkit/platform' || startsWith(github.repository, 'chatbotkit/platform-')) &&
needs.build.result == 'success' &&
(needs.build.result == 'success' || (needs.build.result == 'skipped' && needs.changes.outputs.reuse != '')) &&
(needs.verify.result == 'success' || needs.verify.result == 'skipped') &&
github.actor != 'github-actions[bot]' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next')
name: Publish ${{ matrix.flavor.name }}
needs:
- changes
- build
- verify
runs-on: ${{ matrix.flavor.runner }}
Expand Down Expand Up @@ -339,35 +416,45 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}

- name: Download image digests
if: needs.build.result == 'success'
uses: actions/download-artifact@v7
with:
pattern: platform-digests-${{ matrix.flavor.name }}-*
path: ${{ runner.temp }}/platform-digests
merge-multiple: true

# @note with a reused build the source is the already multi-platform
# sha- manifest list, which imagetools copies under the new tags; a
# fresh build supplies one per-architecture digest per image instead
- name: Create multi-platform image manifests
id: manifest
env:
APPLICATION_IMAGE: ${{ steps.image.outputs.application }}
CHANNEL: ${{ github.ref_name }}
DIGESTS_DIR: ${{ runner.temp }}/platform-digests
INITIALIZER_IMAGE: ${{ steps.image.outputs.initializer }}
REUSE: ${{ needs.changes.outputs.reuse }}
run: |
application_sources=()
for digest_file in "$DIGESTS_DIR"/application/*; do
[ -f "$digest_file" ] || continue
application_sources+=("${APPLICATION_IMAGE}@sha256:$(basename "$digest_file")")
done

initializer_sources=()
for digest_file in "$DIGESTS_DIR"/initializer/*; do
[ -f "$digest_file" ] || continue
initializer_sources+=("${INITIALIZER_IMAGE}@sha256:$(basename "$digest_file")")
done

if [ "${#application_sources[@]}" -ne 2 ] || [ "${#initializer_sources[@]}" -ne 2 ]; then
echo "Expected two architecture digests for each image" >&2
exit 1
if [ -n "$REUSE" ]; then
application_sources=("${APPLICATION_IMAGE}:sha-${REUSE}")
initializer_sources=("${INITIALIZER_IMAGE}:sha-${REUSE}")
else
application_sources=()
for digest_file in "$DIGESTS_DIR"/application/*; do
[ -f "$digest_file" ] || continue
application_sources+=("${APPLICATION_IMAGE}@sha256:$(basename "$digest_file")")
done

initializer_sources=()
for digest_file in "$DIGESTS_DIR"/initializer/*; do
[ -f "$digest_file" ] || continue
initializer_sources+=("${INITIALIZER_IMAGE}@sha256:$(basename "$digest_file")")
done

if [ "${#application_sources[@]}" -ne 2 ] || [ "${#initializer_sources[@]}" -ne 2 ]; then
echo "Expected two architecture digests for each image" >&2
exit 1
fi
fi

short_sha="${GITHUB_SHA:0:7}"
Expand Down
36 changes: 22 additions & 14 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,17 +37,21 @@
# is Garage, spoken to over the plain S3 protocol - see docker/garage/.
# The credentials are the known development values garage-init provisions.
#
# @note presigned upload/download URLs carry this endpoint, so a browser on
# the host needs to resolve it too: add `127.0.0.1 garage` to /etc/hosts to
# use browser-facing file flows with the containerized modes. (When running
# `pnpm dev` on the host instead, point SERVICE_AWS_ENDPOINT at
# http://localhost:3900 in platform/.env and the question does not arise.)
# @note the store is a separate, published service: the application reaches
# it as garage:3900 inside the network, while browsers upload and download
# through presigned URLs minted against STORAGE_PUBLIC_ENDPOINT - the
# store's own name from the browser's point of view. Like the relay and the
# app shells it gets a `*.localhost` name, which browsers resolve to loopback
# with no DNS setup; set STORAGE_URL when the browser reaches the machine by
# another address. (When running `pnpm dev` on the host instead,
# STORAGE_ENDPOINT=http://localhost:3900 in platform/.env serves both roles.)
x-storage-env: &storage-env
SERVICE_AWS_ENDPOINT: http://garage:3900
SERVICE_AWS_REGION: garage
SERVICE_AWS_ACCESS_KEY_ID: GK31e57eba9df26b2e7e1b0eaa
SERVICE_AWS_SECRET_ACCESS_KEY: 9f3c1e2b8a4d5f6071829304a5b6c7d8e9f00112233445566778899aabbccdde
SERVICE_AWS_FORCE_PATH_STYLE: 'true'
STORAGE_ENDPOINT: http://garage:3900
STORAGE_PUBLIC_ENDPOINT: ${STORAGE_URL:-http://cbk-storage.localhost:${STORAGE_PORT:-3900}}
STORAGE_REGION: garage
STORAGE_ACCESS_KEY_ID: GK31e57eba9df26b2e7e1b0eaa
STORAGE_SECRET_ACCESS_KEY: 9f3c1e2b8a4d5f6071829304a5b6c7d8e9f00112233445566778899aabbccdde
STORAGE_FORCE_PATH_STYLE: 'true'
FILE_S3_BUCKET_NAME: file
IMAGE_S3_BUCKET_NAME: image
VIDEO_S3_BUCKET_NAME: video
Expand Down Expand Up @@ -250,10 +254,10 @@ services:
# warnings and errors
RUST_LOG: warn
ports:
# @note published on localhost only, so `pnpm dev` on the host can use
# this same store (SERVICE_AWS_ENDPOINT=http://localhost:3900) without
# building anything
- '127.0.0.1:3900:3900'
# @note published on every interface: browsers talk to the store
# directly through presigned URLs (see x-storage-env), and `pnpm dev`
# on the host uses the same port
- '${STORAGE_PORT:-3900}:3900'
volumes:
- ./docker/garage/garage.toml:/etc/garage.toml:ro
- garage-data:/var/lib/garage
Expand All @@ -275,6 +279,10 @@ services:
environment:
GARAGE_ADMIN_URL: http://garage:3903
GARAGE_ADMIN_TOKEN: dev-admin-token
GARAGE_S3_URL: http://garage:3900
# @note origins allowed to use presigned URLs from a browser; the URLs
# themselves are the access control
STORAGE_CORS_ORIGINS: ${STORAGE_CORS_ORIGINS:-*}
STORAGE_ACCESS_KEY_ID: GK31e57eba9df26b2e7e1b0eaa
STORAGE_SECRET_ACCESS_KEY: 9f3c1e2b8a4d5f6071829304a5b6c7d8e9f00112233445566778899aabbccdde
volumes:
Expand Down
41 changes: 25 additions & 16 deletions docker/distro/community/compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,20 @@
# volume (garage-init provisions it, the application entrypoint sources it);
# set STORAGE_ACCESS_KEY_ID / STORAGE_SECRET_ACCESS_KEY to use a fixed pair.
#
# @note presigned upload/download URLs carry this endpoint, so a browser on
# the host needs to resolve it too: add `127.0.0.1 garage` to /etc/hosts to
# use browser-facing file flows.
# @note the store is a separate, published service: the application reaches
# it as garage:3900 inside the network, while browsers upload and download
# through presigned URLs minted against STORAGE_PUBLIC_ENDPOINT - the
# store's own name from the browser's point of view. Like the relay and the
# app shells it gets a `*.localhost` name, which browsers resolve to loopback
# with no DNS setup; set STORAGE_URL to an address the browser can reach (and
# TLS if the site has it) when that is not the machine itself.
x-storage-env: &storage-env
SERVICE_AWS_ENDPOINT: http://garage:3900
SERVICE_AWS_REGION: garage
SERVICE_AWS_ACCESS_KEY_ID: ${STORAGE_ACCESS_KEY_ID:-}
SERVICE_AWS_SECRET_ACCESS_KEY: ${STORAGE_SECRET_ACCESS_KEY:-}
SERVICE_AWS_FORCE_PATH_STYLE: 'true'
STORAGE_ENDPOINT: http://garage:3900
STORAGE_PUBLIC_ENDPOINT: ${STORAGE_URL:-http://cbk-storage.localhost:${STORAGE_PORT:-3900}}
STORAGE_REGION: garage
STORAGE_ACCESS_KEY_ID: ${STORAGE_ACCESS_KEY_ID:-}
STORAGE_SECRET_ACCESS_KEY: ${STORAGE_SECRET_ACCESS_KEY:-}
STORAGE_FORCE_PATH_STYLE: 'true'
FILE_S3_BUCKET_NAME: file
IMAGE_S3_BUCKET_NAME: image
VIDEO_S3_BUCKET_NAME: video
Expand Down Expand Up @@ -100,7 +105,8 @@ services:
# instead of a .env file, prompted for or set directly:
# docker compose -f oci://... run --rm --no-deps platform setup
# docker compose -f oci://... run --rm --no-deps platform setup OPENROUTER_MODELS_API_KEY=...
# Values given here or in .env win over persisted ones - see
# Values given here or in .env win over persisted ones, and a running
# service restarts itself when the persisted file changes - see
# docker/entrypoint.sh. An override file remains the other route:
# docker compose -f oci://... -f my-override.yml up -d
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
Expand Down Expand Up @@ -174,10 +180,9 @@ services:
# warnings and errors
RUST_LOG: warn
ports:
# @note published on localhost so presigned URLs (which carry the
# garage:3900 endpoint) work from a host browser with the /etc/hosts
# entry described above
- '127.0.0.1:3900:3900'
# @note published on every interface: browsers talk to the store
# directly through presigned URLs (see x-storage-env)
- '${STORAGE_PORT:-3900}:3900'
configs:
- source: garage-config
target: /etc/garage.toml
Expand All @@ -203,6 +208,10 @@ services:
environment:
GARAGE_ADMIN_URL: http://garage:3903
GARAGE_ADMIN_TOKEN: ${GARAGE_ADMIN_TOKEN:-dev-admin-token}
GARAGE_S3_URL: http://garage:3900
# @note origins allowed to use presigned URLs from a browser; the URLs
# themselves are the access control
STORAGE_CORS_ORIGINS: ${STORAGE_CORS_ORIGINS:-*}
STORAGE_ACCESS_KEY_ID: ${STORAGE_ACCESS_KEY_ID:-}
STORAGE_SECRET_ACCESS_KEY: ${STORAGE_SECRET_ACCESS_KEY:-}
volumes:
Expand All @@ -222,8 +231,8 @@ configs:
# Garage (S3-compatible object storage) - single-node configuration.
#
# WARNING: the rpc_secret and admin token default to known development
# values. Neither port is published outside the compose network, but a
# hardened setup overrides them (GARAGE_RPC_SECRET, `openssl rand -hex
# values. Neither the RPC nor the admin port is published outside the
# compose network (the S3 port is), but a hardened setup overrides them (GARAGE_RPC_SECRET, `openssl rand -hex
# 32`, and GARAGE_ADMIN_TOKEN) - and a real deployment almost certainly
# runs a real store with replication rather than this single-node
# layout.
Expand All @@ -239,7 +248,7 @@ configs:
rpc_secret = "${GARAGE_RPC_SECRET:-1799bccfd7411eddcf9ebd316bc1f5287ad12a68094e1c6ac6abde7e6feae1ec}"

[s3_api]
# @note the region is part of every SigV4 signature: SERVICE_AWS_REGION
# @note the region is part of every SigV4 signature: STORAGE_REGION
# must match it, or every request fails authentication
s3_region = "garage"
api_bind_addr = "[::]:3900"
Expand Down
Loading