Skip to content

Let the engine socket be somewhere other than /var/run/docker.sock (#… #186

Let the engine socket be somewhere other than /var/run/docker.sock (#…

Let the engine socket be somewhere other than /var/run/docker.sock (#… #186

name: Publish release
# Merging a release PR is the trigger. Nothing here is dispatched by hand, so a published release is
# always a reviewed commit on main, and the tag is created by this workflow rather than by a person
# with a terminal.
on:
push:
branches: [main]
permissions:
contents: read
jobs:
# Every push to main runs this, and almost none of them are releases. This job decides which, and
# it decides from the merged pull request rather than from the commit message, because a commit
# message is something anybody can write.
metadata:
name: classify
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
is_release: ${{ steps.release.outputs.is_release }}
version: ${{ steps.release.outputs.version }}
steps:
- id: pull-request
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const { data: pulls } =
await github.rest.repos.listPullRequestsAssociatedWithCommit({
owner, repo, commit_sha: context.sha,
});
const pull = pulls.find((candidate) =>
candidate.base.ref === "main" &&
candidate.merged_at !== null &&
candidate.merge_commit_sha === context.sha
);
if (!pull) {
// Not a merge commit of a reviewed PR. That is most pushes; it is not an error.
core.setOutput("head_ref", "");
core.setOutput("trusted", "false");
return;
}
// A release branch name is not enough on its own: a fork can open a PR from a branch
// with any name it likes. The branch must be in this repository and the PR must carry
// the label, which only somebody with write access can add.
const trusted =
/^release\/publish\/v\d+\.\d+\.\d+$/.test(pull.head.ref) &&
pull.head.repo?.full_name === `${owner}/${repo}` &&
pull.labels.some((label) => label.name === "release");
core.setOutput("head_ref", pull.head.ref);
core.setOutput("trusted", String(trusted));
- id: release
name: Decide
env:
HEAD_REF: ${{ steps.pull-request.outputs.head_ref }}
TRUSTED: ${{ steps.pull-request.outputs.trusted }}
run: |
set -euo pipefail
if [ "$TRUSTED" != true ]; then
echo "is_release=false" >> "$GITHUB_OUTPUT"
echo "Not a release commit."
exit 0
fi
version="${HEAD_REF#release/publish/}"
echo "is_release=true" >> "$GITHUB_OUTPUT"
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Releasing $version"
# The version in the tree has to agree with the branch that is publishing it. They are written by
# the same workflow, so disagreement means something was edited by hand after review.
verify:
name: verify
needs: metadata
if: needs.metadata.outputs.is_release == 'true'
runs-on: ubuntu-latest
outputs:
components: ${{ steps.components.outputs.components }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
- env:
VERSION: ${{ needs.metadata.outputs.version }}
run: |
set -euo pipefail
test "v$(bun -e 'console.log(require("./package.json").version)')" = "$VERSION"
grep -q "^## ${VERSION#v}$" CHANGELOG.md || {
echo "::error::CHANGELOG.md has no section for ${VERSION#v}."
exit 1
}
# The services published alongside the image above. Read from the tree rather than written
# here, because CI checks the same file when it proves those Dockerfiles still build, so the
# set that is tested and the set that is published cannot drift apart.
- id: components
run: echo "components=$(jq -c . .github/published-images.json)" >> "$GITHUB_OUTPUT"
# The same checks CI runs, against the commit being published. This is the gate: nothing is built
# or tagged unless they pass here, on this exact tree.
checks:
needs: [metadata, verify]
if: needs.metadata.outputs.is_release == 'true'
uses: $/.github/workflows/ci.yml
permissions:
contents: read
# One image, built once. Everything downstream refers to it by digest, so what was tested is what
# is deployed and there is no second build to disagree with the first.
image:
name: image
needs: [metadata, verify, checks]
if: needs.metadata.outputs.is_release == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# The attestation is signed with the workflow's own OIDC identity, so there is no key to hold
# and the signature says which workflow, repository and commit produced the image.
id-token: write
attestations: write
outputs:
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
push: true
# The version, the commit, and a moving latest. The version tag is the one to deploy; the
# commit tag is how you find out what a running image actually contains.
tags: |
ghcr.io/copilotkit/openbot:${{ needs.metadata.outputs.version }}
ghcr.io/copilotkit/openbot:${{ github.sha }}
ghcr.io/copilotkit/openbot:latest
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: true
sbom: true
# BuildKit's own attestations above travel inside the image. This one is the record GitHub
# holds, and it is what `gh attestation verify oci://ghcr.io/copilotkit/openbot:vX.Y.Z
# -R CopilotKit/OpenBot` checks before anybody deploys it. Bound to the digest, never a tag,
# because a tag can be moved to point at something else afterwards.
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ghcr.io/copilotkit/openbot
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
# The images the installer pulls instead of building. `docker-compose.yml` builds these from
# source on every machine, which needs a toolchain and several minutes a desktop install does not
# have. Published here, from the same commit as the image above, so a deployment and a laptop run
# the same code.
#
# Two architectures, because the machines are laptops: arm64 Macs and amd64 everything else. Built
# on native runners rather than under QEMU. Emulated `bun install` and `vite build` are a known
# source of release-day flakiness, and arm64 runners are free to a public repository, so emulation
# would be the slower and less reliable option at no saving.
component-images:
name: ${{ matrix.image }} ${{ matrix.platform.arch }}
needs: [metadata, verify, checks]
if: needs.metadata.outputs.is_release == 'true'
runs-on: ${{ matrix.platform.runner }}
permissions:
contents: read
packages: write
strategy:
# One image failing should not hide whether the others build, and a half-finished run leaves
# nothing deployable: these builds are pushed untagged, and the tags are written by the job
# below only once both architectures of an image exist.
fail-fast: false
matrix:
image: ${{ fromJSON(needs.verify.outputs.components) }}
platform:
- arch: amd64
runner: ubuntu-latest
- arch: arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Pushed by digest and deliberately untagged. A tag written here would name one architecture,
# and the two jobs for one image would race to own it, so the last to finish would decide what
# the tag meant.
#
# zstd, not gzip. A pull is already a compressed transfer, so this is not about compressing
# something that was not; it is a better algorithm for the same job. Measured on
# `agent-computer`, the image that matters: 962 MB becomes 886 MB, and it inflates several
# times faster, which is worth more than the 8% on 2 GB of Chromium. `force-compression`
# is what reaches the layers that came from somebody else's registry, which is where almost
# all of the bytes are; without it only our own thin layers change and the saving rounds to
# nothing. The cost is that these images no longer share layers with a gzip pull of the same
# base, and that a client which cannot read zstd cannot read them, which is why this is here
# and not on the `openbot` image above: that one is pulled by servers with whatever they have,
# and these are pulled by an installer that ships Podman.
- id: push
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: ${{ matrix.image }}/Dockerfile
platforms: linux/${{ matrix.platform.arch }}
# `oci-mediatypes=true` because zstd layers have no Docker-schema2 media type to be
# described by. It is buildx's default for this exporter and is stated rather than
# assumed, since the push fails without it and the reason would not be obvious.
outputs: type=image,name=ghcr.io/copilotkit/openbot-${{ matrix.image }},push-by-digest=true,name-canonical=true,push=true,oci-mediatypes=true,compression=zstd,force-compression=true
# Scoped per image and per architecture. One shared scope would have ten builds
# overwriting each other's cache and none of them reading their own.
cache-from: type=gha,scope=${{ matrix.image }}-${{ matrix.platform.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.image }}-${{ matrix.platform.arch }}
provenance: true
sbom: true
# A matrix job's outputs are not addressable by the jobs that consume them, so the digest
# travels as a file. One artifact per image and architecture, because same-named artifacts
# from different matrix legs collide.
- name: Record the digest
env:
DIGEST: ${{ steps.push.outputs.digest }}
ARCH: ${{ matrix.platform.arch }}
run: |
set -euo pipefail
[[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
mkdir -p digests
echo "$DIGEST" > "digests/$ARCH"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digest-${{ matrix.image }}-${{ matrix.platform.arch }}
path: digests/
retention-days: 1
# Two per-architecture images become one reference. Whatever pulls it, a laptop or a cluster, names
# the manifest list and gets its own architecture without being told which one it is.
component-manifests:
name: ${{ matrix.image }} manifest
needs: [metadata, verify, checks, component-images]
if: needs.metadata.outputs.is_release == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
# Same identity and the same reason as the image above: the attestation says which workflow,
# repository and commit produced this, and there is no key to hold.
id-token: write
attestations: write
strategy:
fail-fast: false
matrix:
image: ${{ fromJSON(needs.verify.outputs.components) }}
steps:
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: digest-${{ matrix.image }}-*
path: digests
merge-multiple: true
- id: merge
name: Write the tags over both architectures
env:
REPOSITORY: ghcr.io/copilotkit/openbot-${{ matrix.image }}
VERSION: ${{ needs.metadata.outputs.version }}
COMMIT: ${{ github.sha }}
run: |
set -euo pipefail
# Both architectures or neither. A manifest list holding one of them installs on half the
# machines and looks exactly like one holding both until somebody's laptop says
# "no matching manifest".
refs=()
for arch in amd64 arm64; do
digest="$(cat "digests/$arch")"
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]
refs+=("$REPOSITORY@$digest")
done
docker buildx imagetools create \
--tag "$REPOSITORY:$VERSION" \
--tag "$REPOSITORY:$COMMIT" \
--tag "$REPOSITORY:latest" \
"${refs[@]}"
# The list's own digest, which is what anything downstream pins. Read back from the
# registry rather than derived here, and checked, so a template that stops returning a
# digest fails now instead of writing something unusable into the release.
digest="$(docker buildx imagetools inspect "$REPOSITORY:$VERSION" --format '{{.Manifest.Digest}}')"
[[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]
echo "digest=$digest" >> "$GITHUB_OUTPUT"
- uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ghcr.io/copilotkit/openbot-${{ matrix.image }}
subject-digest: ${{ steps.merge.outputs.digest }}
push-to-registry: true
# For the same reason the digests above travel as files: a matrix cannot hand a value to a
# later job.
- name: Record the manifest
env:
NAME: ${{ matrix.image }}
REPOSITORY: ghcr.io/copilotkit/openbot-${{ matrix.image }}
DIGEST: ${{ steps.merge.outputs.digest }}
run: |
set -euo pipefail
mkdir -p manifests
jq -n --arg name "$NAME" --arg repository "$REPOSITORY" --arg digest "$DIGEST" \
'{name: $name, repository: $repository, digest: $digest}' > "manifests/$NAME.json"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: manifest-${{ matrix.image }}
path: manifests/
retention-days: 1
# The tag and the release, last, so nothing is announced that was not built. The manifest is the
# useful artefact: it pins the digest, so a deploy or a rollback names an exact image rather than a
# tag somebody could move.
github-release:
name: tag and release
needs: [metadata, verify, checks, image, component-manifests]
if: needs.metadata.outputs.is_release == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
# No credential is left in the runner: the tag is created through the API below rather than
# with `git push`, so nothing here needs one, and no later step or action can read one.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: manifest-*
path: manifests
merge-multiple: true
- name: Write the image manifest
env:
VERSION: ${{ needs.metadata.outputs.version }}
DIGEST: ${{ needs.image.outputs.digest }}
COMMIT: ${{ github.sha }}
COMPONENTS: ${{ needs.verify.outputs.components }}
run: |
set -euo pipefail
[[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
# Every image this release published, or the file is not written. A missing entry is a
# service whatever reads this would quietly build from source instead, which is the thing
# publishing them was for, and it would only be noticed on somebody's laptop.
expected="$(jq -r 'length' <<< "$COMPONENTS")"
found="$(find manifests -name '*.json' -type f | wc -l | tr -d ' ')"
if [ "$found" != "$expected" ]; then
echo "::error::Expected $expected component manifests, found $found."
exit 1
fi
components="$(jq -s 'map({
(.name): {
repository: .repository,
digest: .digest,
reference: (.repository + "@" + .digest),
}
}) | add' manifests/*.json)"
# `jq`, not `bun`: this job deliberately checks out without credentials and installs no
# toolchain, so reaching for the repository's runtime here is a step that was never taken.
# It was, and the tag was never cut: the manifest step died on `bun: command not found`
# after the image had already been pushed, which is the one point in the release where a
# failure leaves a published image with nothing pointing at it.
jq -n \
--arg version "$VERSION" \
--arg digest "$DIGEST" \
--arg commit "$COMMIT" \
--arg repository "ghcr.io/copilotkit/openbot" \
--argjson components "$components" \
'{
version: $version,
commit: $commit,
images: ({
openbot: {
repository: $repository,
digest: $digest,
reference: ($repository + "@" + $digest),
},
} + $components),
}' > container-images.json
cat container-images.json
- name: Tag and publish
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ needs.metadata.outputs.version }}
run: |
set -euo pipefail
# The notes are the section a person wrote, not a list of commits.
awk -v v="## ${VERSION#v}" '$0==v{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md > notes.md
test -s notes.md
if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$VERSION" >/dev/null 2>&1; then
echo "$VERSION is already tagged; only refreshing the release."
else
gh api "repos/$GITHUB_REPOSITORY/git/refs" \
-f "ref=refs/tags/$VERSION" -f "sha=$GITHUB_SHA" >/dev/null
fi
if gh release view "$VERSION" >/dev/null 2>&1; then
gh release edit "$VERSION" --title "$VERSION" --notes-file notes.md
gh release upload "$VERSION" container-images.json --clobber
else
gh release create "$VERSION" container-images.json \
--title "$VERSION" --notes-file notes.md
fi