mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
The workflow_dispatch `ref` input is meant to preview an unmerged branch,
and the only ref guaranteed to carry this workflow after merge is master —
so the expected way to run it is dispatch-from-master with `ref` set to the
branch. But on a workflow_dispatch dispatched from master, `github.ref` is
the default branch, so `type=raw,value=latest,enable={{is_default_branch}}`
still fired: a preview image would move `latest` and poison every host that
pulls it. `type=semver` was likewise ungated (a dispatch from a v* tag would
have minted a release tag pointing at the previewed ref).
Gate both off for workflow_dispatch so a manual run can only ever produce
the `sha-<short>` tag. The `latest` gate now checks the default branch
explicitly instead of `{{is_default_branch}}` so it can also exclude the
dispatch case; on push:master and tag:v* the emitted tags are unchanged.
Tested: js-yaml and ruby YAML both parse the file; grep confirms both the
self-hosted and cloud meta blocks are gated symmetrically.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
405 lines
18 KiB
YAML
405 lines
18 KiB
YAML
name: Docker
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- "master"
|
|
tags:
|
|
- "v*"
|
|
# Manual builds so an unmerged ref can be previewed before it lands. This
|
|
# publishes a SHA-tagged image and no `latest`/`v*` (release) tag — it is a
|
|
# review image, not a release — and the deploy tooling accepts the resulting
|
|
# `sha-<short>` tag as an ordinary app ref, so nothing downstream changes.
|
|
# Deliberately not the release workflow: that one publishes a stable `v*`
|
|
# release, a far bigger act than building a preview image.
|
|
workflow_dispatch:
|
|
inputs:
|
|
ref:
|
|
description: "Branch, tag, or commit SHA to build. Defaults to the ref this run was dispatched on."
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
|
|
# Serialise builds per ref without killing an in-flight one: a newer push
|
|
# supersedes only the pending slot, so the image build that is already
|
|
# running always finishes and publishes.
|
|
concurrency:
|
|
group: docker-${{ github.ref }}
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
build-and-push:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 60
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v7
|
|
with:
|
|
# push/tag builds resolve to github.sha — the checkout default, so
|
|
# their behaviour is unchanged. A manual run may override it with the
|
|
# `ref` input to build an unmerged branch, tag, or commit.
|
|
ref: ${{ inputs.ref || github.sha }}
|
|
# Full history and tags so `git describe` below can compute the
|
|
# release version to stamp into the image.
|
|
fetch-depth: 0
|
|
|
|
# Pin every downstream reference to the exact commit that was checked
|
|
# out. On push/tag builds this equals github.sha; on a manual run it is
|
|
# the head of the `ref` input, which can differ from github.sha (the ref
|
|
# the dispatch ran on). The SHA image tag and the commit stamped into the
|
|
# image are both derived from this, so a manual build always tags the
|
|
# commit it actually built.
|
|
- name: Resolve build ref
|
|
id: resolve-ref
|
|
run: |
|
|
set -euo pipefail
|
|
sha="$(git rev-parse HEAD)"
|
|
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
|
|
echo "short_sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
|
|
echo "Building ${GITHUB_REF} at ${sha}"
|
|
|
|
# `.git` is dockerignored, so a running image cannot derive its own
|
|
# version and otherwise reports the source package.json placeholder in
|
|
# analytics and the debug panel. Compute it here from the pristine
|
|
# checkout (real CalVer drift from the nearest release tag) and pass it
|
|
# into both builds. Empty when no release tag is reachable — the server
|
|
# then keeps its existing fallbacks.
|
|
- name: Compute build version
|
|
id: build-version
|
|
run: |
|
|
set -euo pipefail
|
|
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
|
|
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
|
echo "Stamping build version: ${version:-<none>}"
|
|
|
|
# ISO week stamp for the Dockerfile's tool layer: the layer caches
|
|
# across commits and re-pulls the @latest CLI tools when the week rolls
|
|
# over, instead of on every build.
|
|
- name: Compute tool cache epoch
|
|
id: tools-epoch
|
|
run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Setup pnpm
|
|
uses: pnpm/action-setup@v6
|
|
with:
|
|
version: 9.15.4
|
|
run_install: false
|
|
|
|
# No dependency cache here: this workflow publishes release images, and
|
|
# restoring a shared Actions cache into the build inputs would let a
|
|
# poisoned cache entry reach the published artifact.
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v7
|
|
with:
|
|
node-version: 20
|
|
|
|
- name: Refresh lockfile for Docker build context
|
|
run: |
|
|
set -euo pipefail
|
|
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
|
|
|
changed="$(git status --porcelain)"
|
|
if [ -z "$changed" ]; then
|
|
echo "Lockfile already matches package metadata."
|
|
exit 0
|
|
fi
|
|
|
|
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
|
|
echo "Unexpected files changed during lockfile refresh:"
|
|
echo "$changed"
|
|
exit 1
|
|
fi
|
|
|
|
echo "Using refreshed pnpm-lock.yaml in the Docker build context."
|
|
|
|
- name: Free runner disk
|
|
run: |
|
|
set -euo pipefail
|
|
echo "Disk before cleanup:"
|
|
df -h
|
|
|
|
pnpm store prune || true
|
|
sudo apt-get clean || true
|
|
sudo rm -rf \
|
|
/usr/share/dotnet \
|
|
/usr/share/swift \
|
|
/usr/local/lib/android \
|
|
/usr/local/share/boost \
|
|
/usr/local/share/powershell \
|
|
/opt/ghc \
|
|
/opt/hostedtoolcache/CodeQL \
|
|
/opt/hostedtoolcache/PyPy \
|
|
/opt/hostedtoolcache/Ruby || true
|
|
docker system prune -af || true
|
|
|
|
echo "Disk after cleanup:"
|
|
df -h
|
|
|
|
- name: Login to GitHub Container Registry
|
|
uses: docker/login-action@v4
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.repository_owner }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@v4
|
|
|
|
# Deployment tooling reads these labels from the registry to verify an
|
|
# image's schema expectations against a migrator before deploying it,
|
|
# without pulling the image. The server refuses to start when the
|
|
# database is missing bundled migrations, so orchestrators need a cheap
|
|
# way to check image/migrator compatibility up front.
|
|
- name: Compute schema migration labels
|
|
id: schema
|
|
run: |
|
|
set -euo pipefail
|
|
last=$(ls packages/db/src/migrations/*.sql | sed 's|.*/||' | LC_ALL=C sort | tail -1)
|
|
count=$(ls packages/db/src/migrations/*.sql | wc -l | tr -d ' ')
|
|
echo "last=${last}" >> "$GITHUB_OUTPUT"
|
|
echo "count=${count}" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Docker meta
|
|
id: meta
|
|
uses: docker/metadata-action@v6
|
|
with:
|
|
images: ghcr.io/${{ github.repository }}
|
|
tags: |
|
|
# `latest` and `v*` are release pointers. Gate them off for manual
|
|
# (workflow_dispatch) runs — even one dispatched from master — so a
|
|
# preview build can never move `latest` or mint a release tag. The
|
|
# explicit default-branch check replaces `{{is_default_branch}}`,
|
|
# which is also true on a dispatch from master and would otherwise
|
|
# leak `latest` onto a preview image.
|
|
type=raw,value=latest,enable=${{ github.ref_name == github.event.repository.default_branch && github.event_name != 'workflow_dispatch' }}
|
|
type=semver,pattern={{version}},enable=${{ github.event_name != 'workflow_dispatch' }}
|
|
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name != 'workflow_dispatch' }}
|
|
# push/tag builds keep the stock sha tag (sha-<short> of github.sha).
|
|
# A manual run may build a ref other than github.sha, so it tags the
|
|
# resolved commit instead — same sha-<short> format, correct commit.
|
|
type=sha,enable=${{ github.event_name != 'workflow_dispatch' }}
|
|
type=raw,value=sha-${{ steps.resolve-ref.outputs.short_sha }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
|
labels: |
|
|
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
|
|
io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }}
|
|
|
|
- name: Build and push
|
|
uses: docker/build-push-action@v7
|
|
with:
|
|
context: .
|
|
# Pin the self-hosted image to the production stage explicitly:
|
|
# the Dockerfile now declares a later `cloud` stage, and without a
|
|
# target the default would silently become that stage.
|
|
target: production
|
|
build-args: |
|
|
PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }}
|
|
PAPERCLIP_BUILD_COMMIT=${{ steps.resolve-ref.outputs.sha }}
|
|
CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }}
|
|
platforms: linux/amd64,linux/arm64
|
|
push: true
|
|
# Registry-backed BuildKit cache instead of type=gha: the Actions
|
|
# cache is capped at 10GB per repo, and two multi-arch mode=max jobs
|
|
# evict each other, so most builds ran effectively cold. The cache
|
|
# ref lives in ghcr next to the image and is written only by this
|
|
# workflow (docker.yml runs on master/tag pushes, never on PRs).
|
|
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
|
|
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
|
|
tags: ${{ steps.meta.outputs.tags }}
|
|
labels: ${{ steps.meta.outputs.labels }}
|
|
|
|
# The cloud variant carries built bundled plugins for managed deployments
|
|
# (see the `cloud` stage in the Dockerfile). It runs as its own job with no
|
|
# `needs:` on the stock publish above, so the two builds run in parallel and
|
|
# a failure or slow build in one never gates, delays, or skips the other.
|
|
# Both jobs share only the single top-level concurrency slot. Each job is a
|
|
# separate runner, so this one carries its own copy of the prep steps
|
|
# (checkout through schema labels) — the accepted cost of that isolation.
|
|
build-and-push-cloud:
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 60
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v7
|
|
with:
|
|
# push/tag builds resolve to github.sha — the checkout default, so
|
|
# their behaviour is unchanged. A manual run may override it with the
|
|
# `ref` input to build an unmerged branch, tag, or commit.
|
|
ref: ${{ inputs.ref || github.sha }}
|
|
# Full history and tags so `git describe` below can compute the
|
|
# release version to stamp into the image.
|
|
fetch-depth: 0
|
|
|
|
# Pin every downstream reference to the exact commit that was checked
|
|
# out. On push/tag builds this equals github.sha; on a manual run it is
|
|
# the head of the `ref` input, which can differ from github.sha (the ref
|
|
# the dispatch ran on). The SHA image tag and the commit stamped into the
|
|
# image are both derived from this, so a manual build always tags the
|
|
# commit it actually built.
|
|
- name: Resolve build ref
|
|
id: resolve-ref
|
|
run: |
|
|
set -euo pipefail
|
|
sha="$(git rev-parse HEAD)"
|
|
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
|
|
echo "short_sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
|
|
echo "Building ${GITHUB_REF} at ${sha}"
|
|
|
|
# `.git` is dockerignored, so a running image cannot derive its own
|
|
# version and otherwise reports the source package.json placeholder in
|
|
# analytics and the debug panel. Compute it here from the pristine
|
|
# checkout (real CalVer drift from the nearest release tag) and pass it
|
|
# into the build. Empty when no release tag is reachable — the server
|
|
# then keeps its existing fallbacks.
|
|
- name: Compute build version
|
|
id: build-version
|
|
run: |
|
|
set -euo pipefail
|
|
version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)"
|
|
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
|
echo "Stamping build version: ${version:-<none>}"
|
|
|
|
# ISO week stamp for the Dockerfile's tool layer: the layer caches
|
|
# across commits and re-pulls the @latest CLI tools when the week rolls
|
|
# over, instead of on every build.
|
|
- name: Compute tool cache epoch
|
|
id: tools-epoch
|
|
run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Setup pnpm
|
|
uses: pnpm/action-setup@v6
|
|
with:
|
|
version: 9.15.4
|
|
run_install: false
|
|
|
|
# No dependency cache here: this workflow publishes release images, and
|
|
# restoring a shared Actions cache into the build inputs would let a
|
|
# poisoned cache entry reach the published artifact.
|
|
- name: Setup Node.js
|
|
uses: actions/setup-node@v7
|
|
with:
|
|
node-version: 20
|
|
|
|
- name: Refresh lockfile for Docker build context
|
|
run: |
|
|
set -euo pipefail
|
|
pnpm install --lockfile-only --ignore-scripts --no-frozen-lockfile
|
|
|
|
changed="$(git status --porcelain)"
|
|
if [ -z "$changed" ]; then
|
|
echo "Lockfile already matches package metadata."
|
|
exit 0
|
|
fi
|
|
|
|
if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then
|
|
echo "Unexpected files changed during lockfile refresh:"
|
|
echo "$changed"
|
|
exit 1
|
|
fi
|
|
|
|
echo "Using refreshed pnpm-lock.yaml in the Docker build context."
|
|
|
|
- name: Free runner disk
|
|
run: |
|
|
set -euo pipefail
|
|
echo "Disk before cleanup:"
|
|
df -h
|
|
|
|
pnpm store prune || true
|
|
sudo apt-get clean || true
|
|
sudo rm -rf \
|
|
/usr/share/dotnet \
|
|
/usr/share/swift \
|
|
/usr/local/lib/android \
|
|
/usr/local/share/boost \
|
|
/usr/local/share/powershell \
|
|
/opt/ghc \
|
|
/opt/hostedtoolcache/CodeQL \
|
|
/opt/hostedtoolcache/PyPy \
|
|
/opt/hostedtoolcache/Ruby || true
|
|
docker system prune -af || true
|
|
|
|
echo "Disk after cleanup:"
|
|
df -h
|
|
|
|
- name: Login to GitHub Container Registry
|
|
uses: docker/login-action@v4
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.repository_owner }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Set up Docker Buildx
|
|
uses: docker/setup-buildx-action@v4
|
|
|
|
# Deployment tooling reads these labels from the registry to verify an
|
|
# image's schema expectations against a migrator before deploying it,
|
|
# without pulling the image. The server refuses to start when the
|
|
# database is missing bundled migrations, so orchestrators need a cheap
|
|
# way to check image/migrator compatibility up front.
|
|
- name: Compute schema migration labels
|
|
id: schema
|
|
run: |
|
|
set -euo pipefail
|
|
last=$(ls packages/db/src/migrations/*.sql | sed 's|.*/||' | LC_ALL=C sort | tail -1)
|
|
count=$(ls packages/db/src/migrations/*.sql | wc -l | tr -d ' ')
|
|
echo "last=${last}" >> "$GITHUB_OUTPUT"
|
|
echo "count=${count}" >> "$GITHUB_OUTPUT"
|
|
|
|
# Published under the same tag set with a `-cloud` suffix
|
|
# (sha-<short>-cloud, latest-cloud, <version>-cloud).
|
|
- name: Docker meta (cloud)
|
|
id: meta-cloud
|
|
uses: docker/metadata-action@v6
|
|
with:
|
|
images: ghcr.io/${{ github.repository }}
|
|
flavor: |
|
|
suffix=-cloud,onlatest=true
|
|
tags: |
|
|
# `latest` and `v*` are release pointers. Gate them off for manual
|
|
# (workflow_dispatch) runs — even one dispatched from master — so a
|
|
# preview build can never move `latest` or mint a release tag. The
|
|
# explicit default-branch check replaces `{{is_default_branch}}`,
|
|
# which is also true on a dispatch from master and would otherwise
|
|
# leak `latest` onto a preview image.
|
|
type=raw,value=latest,enable=${{ github.ref_name == github.event.repository.default_branch && github.event_name != 'workflow_dispatch' }}
|
|
type=semver,pattern={{version}},enable=${{ github.event_name != 'workflow_dispatch' }}
|
|
type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name != 'workflow_dispatch' }}
|
|
# push/tag builds keep the stock sha tag (sha-<short> of github.sha).
|
|
# A manual run may build a ref other than github.sha, so it tags the
|
|
# resolved commit instead — same sha-<short> format, correct commit.
|
|
type=sha,enable=${{ github.event_name != 'workflow_dispatch' }}
|
|
type=raw,value=sha-${{ steps.resolve-ref.outputs.short_sha }},enable=${{ github.event_name == 'workflow_dispatch' }}
|
|
labels: |
|
|
io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }}
|
|
io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }}
|
|
|
|
- name: Build and push (cloud)
|
|
uses: docker/build-push-action@v7
|
|
with:
|
|
context: .
|
|
target: cloud
|
|
# Space-separated sandbox-provider directory names to build into
|
|
# the variant; add here when managed deployments need another.
|
|
build-args: |
|
|
CLOUD_BUNDLED_PLUGINS=daytona
|
|
PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }}
|
|
PAPERCLIP_BUILD_COMMIT=${{ steps.resolve-ref.outputs.sha }}
|
|
CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }}
|
|
# amd64 only, unlike the self-hosted image above: the cloud variant
|
|
# is consumed exclusively by managed-deployment hosts, which run
|
|
# amd64. The QEMU-emulated arm64 half dominated this job's wall
|
|
# clock, and dropping it roughly halves time-to-deployable-image.
|
|
platforms: linux/amd64
|
|
push: true
|
|
# Registry-backed BuildKit cache, separate ref from the self-hosted
|
|
# job so the two parallel builds never clobber each other's cache
|
|
# manifest (see the rationale on the job above).
|
|
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud
|
|
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud,mode=max
|
|
tags: ${{ steps.meta-cloud.outputs.tags }}
|
|
labels: ${{ steps.meta-cloud.outputs.labels }}
|