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
4 changes: 3 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
.ruff_cache/
__pycache__/
*.pyc
dist/
# dist/ is intentionally NOT excluded -- the Dockerfile installs the
# release.yml-built wheel from this directory when present (CI flow).
# Local builds with no wheel in dist/ fall back to PyPI install.
build/
docs/
session-transcripts/
Expand Down
69 changes: 27 additions & 42 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
name: Docker

# Triggers, in priority order:
# 1. workflow_run after Release completes successfully -- this is the
# reliable path because the PyPI upload has already returned 200 by
# then, so the pip CDN is closer to consistent.
# 2. push of a v*.*.* tag -- fallback for users who push tags via
# release.yml dispatch or manual scripts. The poll loop below has
# to compensate for CDN propagation lag.
# 3. workflow_dispatch -- manual retries.
# 1. workflow_run after Release completes successfully -- the canonical
# path. We download the wheel artifact that release.yml uploaded and
# install from it inside the container, bypassing PyPI entirely.
# Eliminates the CDN-propagation race that bit v0.4.0 (the runner's
# pip simple-index edge took >10 minutes to see the new wheel even
# though release.yml's publish step had already returned).
# 2. push of a v*.*.* tag -- still wired but uses the workflow_run path
# preferentially. If release.yml is also tag-triggered, GitHub fires
# both; the concurrency group below dedupes them.
# 3. workflow_dispatch -- manual retries. These install from PyPI
# (versioned via the ref) since no wheel artifact is available.
on:
workflow_run:
workflows: ["Release"]
Expand All @@ -26,9 +30,11 @@ permissions:
contents: read
packages: write
id-token: write
actions: read

concurrency:
group: docker-${{ github.ref }}
# Dedupe push-tag + workflow_run firings for the same tag.
group: docker-${{ github.event.workflow_run.head_sha || github.ref }}
cancel-in-progress: true

jobs:
Expand Down Expand Up @@ -99,55 +105,34 @@ jobs:
id: ver
run: |
set -e
# Pick the version from whatever ref triggered the run. We
# pin the wheel install so the container is reproducible per
# release.
ref="${{ steps.ref.outputs.ref }}"
ref="${ref##*/}"
case "$ref" in
v[0-9]*)
echo "version=${ref#v}" >> "$GITHUB_OUTPUT"
;;
*)
# For non-tag dispatches we still try the latest published
# version. The poll step below confirms it's actually
# resolvable through the simple-index, which is what pip
# will use inside docker.
echo "version=" >> "$GITHUB_OUTPUT"
;;
esac

- name: Set up Python for the readiness probe
uses: actions/setup-python@v5
# On workflow_run, download the wheel that release.yml just built
# and uploaded as an artifact (artifact name set in release.yml).
# Skips the PyPI CDN race entirely.
- name: Download wheel artifact from the triggering Release run
if: github.event_name == 'workflow_run'
uses: actions/download-artifact@v4
with:
python-version: "3.13"
name: release-dist
path: dist/
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}

- name: Wait for PyPI simple-index to serve the new wheel
if: steps.ver.outputs.version != ''
- name: Ensure dist/ exists for the docker build context
run: |
set -e
target="${{ steps.ver.outputs.version }}"
echo "Waiting for valanistack==$target to be resolvable via pip's simple-index..."
# Probe via the exact resolver pip will use inside the docker
# build (pip's --dry-run download against the simple index).
# The JSON endpoint at /pypi/<name>/json propagates faster
# than the simple-index, so curl-on-json is misleading; we
# need a positive signal from pip itself.
for i in $(seq 1 60); do
if pip download --quiet --no-deps --dry-run --dest /tmp/pypi-probe \
"valanistack==$target" 2>/dev/null; then
echo "pip can resolve valanistack==$target."
# Buffer to let any straggling CDN edges catch up before
# we kick off the multi-arch docker build (it may hit
# different edges than the runner's pip did).
sleep 30
exit 0
fi
echo " attempt $i: not yet; sleeping 10s"
sleep 10
done
echo "pip never resolved valanistack==$target -- failing the docker build."
exit 1
mkdir -p dist
ls -la dist/ || true

- name: Build + push image
uses: docker/build-push-action@v6
Expand Down
56 changes: 36 additions & 20 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# vstack -- single-image bundle of the Python library + all CLIs (vstack-mcp,
# vstack-api, vstack-config, vstack-upgrade, plus the 34 per-pattern CLIs).
# vstack -- single-image bundle of the Python library + all CLIs (vstack,
# vstack-mcp, vstack-api, vstack-config, vstack-upgrade, vstack-learn,
# vstack-analytics, plus the 34 per-pattern CLIs).
#
# The image is multi-arch by default (the docker.yml workflow builds for
# linux/amd64 and linux/arm64). It is intended as a portable runtime; the
Expand All @@ -11,12 +12,19 @@
# docker run --rm -p 8000:8000 -e ANTHROPIC_API_KEY=... ghcr.io/valani9/vstack:latest \
# vstack-api serve --host 0.0.0.0 --port 8000
# docker run --rm ghcr.io/valani9/vstack:latest vstack --help
#
# Install path selection (the RUN step picks the first match):
# 1. A .whl file under `dist/` in the build context -- preferred. The
# docker.yml workflow downloads the wheel artifact from release.yml
# and drops it under dist/ before invoking buildx. This bypasses
# the PyPI CDN-propagation race entirely.
# 2. A pinned VSTACK_VERSION build arg -- pip install from PyPI at the
# pinned version. Used by manual local builds when no wheel exists
# in the build context.
# 3. The latest PyPI version of valanistack[all] -- final fallback.

FROM python:3.13-slim AS runtime

# Tools installed via uv are reproducible, fast, and don't drag in pip's
# resolver. We still keep `pip` available for `pip install` of plugins
# at runtime (e.g. user-installed Anthropic/OpenAI extras).
RUN useradd --create-home --uid 1000 vstack \
&& apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl tini \
Expand All @@ -30,25 +38,33 @@ ENV PYTHONDONTWRITEBYTECODE=1 \

WORKDIR /opt/vstack

# Install vstack with every optional extra so the resulting image
# supports MCP stdio + REST API + Anthropic / OpenAI / Ollama clients
# out of the box. Pinning to the major version makes the docker tag
# predictable; the CI workflow rebuilds the image on every release.
ARG VSTACK_VERSION
RUN python -m pip install --upgrade pip \
&& if [ -n "$VSTACK_VERSION" ]; then \
pip install "valanistack[all]==$VSTACK_VERSION"; \
else \
pip install "valanistack[all]"; \
fi \
&& mkdir -p "$VSTACK_HOME" && chown -R vstack:vstack "$VSTACK_HOME" /opt/vstack
# Always create dist/ in the image; copy any wheels the build context
# happens to have. When dist/ is absent in the context, the workflow
# pre-creates an empty one with a sentinel file so this COPY succeeds.
COPY dist/ /opt/vstack/dist/

ARG VSTACK_VERSION=""

RUN set -eux; \
python -m pip install --upgrade pip; \
WHEEL=$(ls /opt/vstack/dist/valanistack-*-py3-none-any.whl 2>/dev/null | head -n1 || true); \
if [ -n "$WHEEL" ]; then \
echo "Installing from local wheel: $WHEEL"; \
pip install "${WHEEL}[all]"; \
elif [ -n "$VSTACK_VERSION" ]; then \
echo "Installing valanistack[all]==$VSTACK_VERSION from PyPI"; \
pip install "valanistack[all]==$VSTACK_VERSION"; \
else \
echo "Installing latest valanistack[all] from PyPI"; \
pip install "valanistack[all]"; \
fi; \
rm -rf /opt/vstack/dist; \
mkdir -p "$VSTACK_HOME"; \
chown -R vstack:vstack "$VSTACK_HOME" /opt/vstack

USER vstack

# Expose the API port for `vstack-api serve --host 0.0.0.0`.
EXPOSE 8000

# tini reaps zombie children cleanly when running short-lived CLI
# invocations or when uvicorn is signaled.
ENTRYPOINT ["tini", "--"]
CMD ["vstack-mcp", "serve"]
Loading