Skip to content
Open
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
16 changes: 16 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,22 @@ updates:
- minor
- patch

# Slack bot release image (both Chainguard Node stages are digest-pinned).
- package-ecosystem: docker
directory: "/sdk/typescript/examples/slack-bot/docker"
schedule:
interval: weekly
open-pull-requests-limit: 10
commit-message:
prefix: "chore(deps)"
labels:
- dependencies
groups:
docker-minor-patch:
update-types:
- minor
- patch

# SHA-pinned GitHub Actions (bumps the pin SHA + its `# vX.Y.Z` comment).
- package-ecosystem: github-actions
directory: "/"
Expand Down
72 changes: 72 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,78 @@ jobs:
- name: Test Slack bot example (offline, spawns mecated --mock)
run: task slack-bot:test

- name: Build hardened Slack bot image (no push)
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: docker build --load --tag mecatl-slack-bot:ci --secret id=npm_token,env=NODE_AUTH_TOKEN --file sdk/typescript/examples/slack-bot/docker/bot.Dockerfile sdk/typescript/examples/slack-bot

- name: Validate Slack bot runtime image
Comment thread
JAORMX marked this conversation as resolved.
run: |
# Use the image's real ENTRYPOINT and CMD. A missing app token makes
# dist/index.js fail before Socket Mode can contact Slack, while its
# module imports still prove the compiled entrypoint and runtime deps load.
mkdir -p .scratch
if docker run --rm \
--env SLACK_BOT_TOKEN=ci-test-token \
--env MECATL_GRPC_ADDRESS=127.0.0.1:1 \
mecatl-slack-bot:ci > .scratch/runtime-image.log 2>&1; then
cat .scratch/runtime-image.log
echo "container unexpectedly started without SLACK_APP_TOKEN" >&2
exit 1
fi
grep --fixed-strings "Missing required environment variable: SLACK_APP_TOKEN" .scratch/runtime-image.log

# Exercise the bridge compiled into the final image against a real,
# offline mecated --mock. Socket Mode itself requires Slack's network
# service and credentials, so this intentionally stops at the bot's
# lowest independently testable integration boundary.
mkdir -p .scratch/slack-image-workspace
bin/mecated serve --mock \
--workspace="$PWD/.scratch/slack-image-workspace" \
--ready-file="$PWD/.scratch/slack-image-ready.json" \
--grpc-addr=127.0.0.1:0 \
--http-addr= \
--metrics-addr= \
--no-soul \
--no-user-model \
--no-scheduler \
--flight-recorder=false \
> .scratch/slack-image-mecated.log 2>&1 &
daemon_pid=$!
trap 'kill "$daemon_pid" 2>/dev/null || true; wait "$daemon_pid" 2>/dev/null || true' EXIT
for _ in {1..500}; do
test -s .scratch/slack-image-ready.json && break
if ! kill -0 "$daemon_pid" 2>/dev/null; then
cat .scratch/slack-image-mecated.log
exit 1
fi
sleep 0.02
done
test -s .scratch/slack-image-ready.json
grpc_address=$(jq --exit-status --raw-output '.grpc_address' .scratch/slack-image-ready.json)
docker run --rm --network host \
--env BRIDGE_BASE_URL="http://$grpc_address" \
--entrypoint /usr/bin/node \
mecatl-slack-bot:ci --input-type=module --eval '
import { MecatlBridge } from "./dist/bridge.js";
const bridge = new MecatlBridge({ baseUrl: process.env.BRIDGE_BASE_URL });
try {
const result = await bridge.handlePrompt("ci:final-image", "hello");
const expected = "Mock provider: no real model is configured. Set OPENAI_API_KEY for live use.";
if (result.text !== expected || result.stopReason !== "end_turn" || !result.sessionId) {
throw new Error(`unexpected bridge result: ${JSON.stringify(result)}`);
}
} finally {
await bridge.close();
}
'

docker run --rm --entrypoint /usr/bin/node mecatl-slack-bot:ci --input-type=module --eval '
import { existsSync } from "node:fs";
const forbidden = ["/bin/sh", "/usr/bin/corepack", "/usr/bin/pnpm", "node_modules/tsx", "node_modules/typescript", "node_modules/vitest", "node_modules/@biomejs"];
if (process.getuid() === 0 || forbidden.some(existsSync)) process.exit(1);
Comment thread
JAORMX marked this conversation as resolved.
'

build:
name: Build
needs: changes
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -397,12 +397,12 @@ jobs:
subject-digest: ${{ steps.build.outputs.image_digest }}
push-to-registry: true

# Not a Go binary, so this doesn't go through ko like the jobs above — a
# plain docker/build-push-action build of the existing dev/Compose
# Dockerfile. The bot's TypeScript source still lives under
# Not a Go binary, so this doesn't go through ko like the jobs above. The
# multi-stage Dockerfile builds the bot, then copies only production artifacts
# into its digest-pinned Chainguard runtime image. The bot's source lives under
# sdk/typescript/examples/ and isn't a published npm package (see
# ../sdk/typescript/examples/slack-bot/README.md); only this container
# image is released, from the same v* tag as mecated/mecatui/mecak8s.
# ../sdk/typescript/examples/slack-bot/README.md); only this container image is
# released from the same v* tag as the Go images.
publish-slack-bot:
name: Build, publish, sign, attest Slack bot example image
runs-on: ubuntu-24.04
Expand Down
9 changes: 9 additions & 0 deletions sdk/typescript/.dockerignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Keeps the Slack bot demo image's build context (examples/slack-bot/docker-compose.yml)
# small and avoids pnpm choking on a locally-built node_modules/dist getting copied in.
# Never send local runtime configuration or private-key credentials to the builder.
**/node_modules
**/dist
**/.api-extractor-temp
**/.env
**/.env.local
**/.env.*.local
**/.npmrc
**/*.pem
Comment thread
JAORMX marked this conversation as resolved.
**/*.key
**/*.p12
**/*.pfx
9 changes: 6 additions & 3 deletions sdk/typescript/examples/slack-bot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,9 +219,12 @@ Wait for `mecatl Slack bot is running (Socket Mode)` in the log, same as
step 3, then verify per step 4 above.

Notes:
- Both Dockerfiles (`docker/mecated.Dockerfile`, `docker/bot.Dockerfile`)
are **local dev/demo images only** — not the project's official release
artifact (that's `ko`, see `.ko.yaml`).
- `docker/bot.Dockerfile` is also the source of the official
`ghcr.io/stacklok/mecatl/slack-bot` release image. It builds the linked SDK
and bot in a digest-pinned Chainguard development stage, then runs the
compiled bot as non-root on a production-only, shell-less Chainguard stage.
`docker/mecated.Dockerfile` remains a local dev/demo image; official mecatl
server images are built with `ko` (see `.ko.yaml`).
- Neither service publishes a port to the host — the bot only needs
outbound Socket Mode, and `mecated`'s gRPC port only needs to be reached
by the bot over the internal compose network. mecated logs a WARN about
Expand Down
2 changes: 1 addition & 1 deletion sdk/typescript/examples/slack-bot/biome.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.11/schema.json",
"files": {
"includes": ["**"]
"includes": ["**", "!dist"]
},
"formatter": {
"enabled": true,
Expand Down
4 changes: 2 additions & 2 deletions sdk/typescript/examples/slack-bot/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Runs the whole Slack bot demo: a real `mecated` plus the bot, wired
# together on an internal-only Docker network (neither port is published to
# the host — the bot reaches Slack outbound over Socket Mode; nothing needs
# to reach in). Both Dockerfiles under docker/ are local dev/demo images,
# not the project's official release artifact — see their header comments.
# to reach in). The bot Dockerfile is shared with the official release image;
# the mecated Dockerfile is local-dev-only — see their header comments.
#
# Usage: copy .env.example to .env, fill it in, then `docker compose up --build`.
services:
Expand Down
65 changes: 26 additions & 39 deletions sdk/typescript/examples/slack-bot/docker/bot.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,48 +22,35 @@
# .npmrc (see that file's own comment), so this can't just be an ARG
# substituted into the checked-in file.
#
# NOT a hardened base image (deliberately, for now). node:24-slim is Debian
# + a shell + apt + a full toolchain, which is exactly what this single-stage
# build needs: corepack/pnpm/build run INSIDE the final image below, and a
# hardened runtime image (Chainguard's default tag, Docker Hardened Images'
# default tag) is minimal/distroless-style and almost certainly has none of
# that. Adopting one is a multi-stage rewrite, not a one-line FROM swap:
# a builder stage on a "-dev"/full variant (has the shell + toolchain) would
# produce dist/ + a pruned node_modules, then a second, minimal runtime
# stage would COPY just those artifacts in and run `node dist/index.js`
# directly, no corepack/pnpm/shell needed at runtime at all.
#
# Two hardened options were evaluated, favoring Chainguard when this gets
# picked up:
# - cgr.dev/chainguard/node: free, no registry login to pull. mecatl
# already depends on this vendor for every OTHER released image -
# mecated/mecak8s/mecatui build on cgr.dev/chainguard/static (.ko.yaml)
# - so adopting it here is zero new vendor relationship and zero new CI
# credentials. The one real constraint: only the `latest`/`latest-dev`
# tags are pullable for free; a specific pinned version tag (`node:24`,
# etc.) requires contacting Chainguard's sales. Workaround, matching
# .ko.yaml's own existing pattern: pin the DIGEST of `latest` at build
# time, not the moving tag - reproducible, no sales conversation needed.
# - Docker Hardened Images (docker.com/products/hardened-images): also
# genuinely free (Apache 2.0, no paywalled catalog) and has a Node.js
# image, but pulling from dhi.io requires `docker login dhi.io` with a
# Docker account even on the free tier - a new credential that would
# need to be provisioned and stored as a GitHub secret in this repo's
# CI. Marketed as "change one line in your Dockerfile"; the registry
# login requirement is the part that pitch leaves out.
#
# Tracked as a deliberate follow-up, not blocking: stacklok/mecatl#1054's
# PR description has the same writeup for anyone picking this up.
FROM node:24-slim
RUN corepack enable
# Chainguard's free Node images expose moving latest tags, so both stages are
# pinned by multi-architecture digest, matching the convention in .ko.yaml.
FROM cgr.dev/chainguard/node@sha256:dcb7cf99cf3eaf95bad12812e4233a2b534e464a277611287c3392d2171d662c AS builder

WORKDIR /app
COPY . .
RUN --mount=type=secret,id=npm_token,required=true \
COPY --chown=65532:65532 . .
RUN --mount=type=secret,id=npm_token,required=true,uid=65532 \
export NODE_AUTH_TOKEN="$(cat /run/secrets/npm_token)" \
&& export NPM_CONFIG_USERCONFIG=/tmp/npmrc-build-only \
&& printf '//npm.pkg.github.com/:_authToken=%s\n' "$NODE_AUTH_TOKEN" > "$NPM_CONFIG_USERCONFIG" \
&& pnpm install --frozen-lockfile \
&& rm -f "$NPM_CONFIG_USERCONFIG"
&& corepack pnpm@11.25.0 install --frozen-lockfile \
&& rm -f "$NPM_CONFIG_USERCONFIG" \
&& corepack pnpm@11.25.0 run build

FROM builder AS production-dependencies
RUN corepack pnpm@11.25.0 prune --prod

FROM cgr.dev/chainguard/node@sha256:753a66014b1310b8f93c76d4cac41d039958b9a86dd44a245289d6cb85455582
Comment thread
JAORMX marked this conversation as resolved.

# The current runtime image includes BusyBox. Remove its single executable (all
# applet links, including /bin/sh, then become inert) before dropping privileges.
USER 0
RUN ["/bin/busybox", "rm", "/bin/busybox"]
USER 65532

WORKDIR /app
COPY --from=production-dependencies /app/package.json ./package.json
COPY --from=production-dependencies /app/dist ./dist
COPY --from=production-dependencies /app/node_modules ./node_modules

CMD ["pnpm", "run", "start"]
ENTRYPOINT ["/usr/bin/node"]
CMD ["dist/index.js"]
3 changes: 2 additions & 1 deletion sdk/typescript/examples/slack-bot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"format": "biome check --write .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"start": "tsx src/index.ts",
"build": "node --input-type=module --eval \"import { rmSync } from 'node:fs'; rmSync('dist', { recursive: true, force: true });\" && tsc -p tsconfig.build.json",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts"
},
"dependencies": {
Expand Down
11 changes: 11 additions & 0 deletions sdk/typescript/examples/slack-bot/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false,
"noEmit": false,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"],
"exclude": ["test/**/*.ts"]
}
Loading