Let the engine socket be somewhere other than /var/run/docker.sock (#… #186
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |