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
133 changes: 133 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ on:
pull_request:
push:
branches: [main]
# Callable, so publishing a release runs these same checks against the release commit rather than
# trusting that they ran somewhere earlier.
workflow_call:

# Least privilege by default. Jobs that need more must declare it locally.
permissions:
Expand Down Expand Up @@ -83,3 +86,133 @@ jobs:
bun-version: 1.3.14
- run: bun install --frozen-lockfile
- run: bun run build

# Migration files and the schema they were generated from, checked against each other. A snapshot
# that has drifted from the schema produces a migration nobody wrote, applied to somebody's
# database on their next deploy. Neither command needs a running database, only the config.
migrations:
name: migrations
runs-on: ubuntu-latest
env:
# drizzle.config.ts refuses to load without it. Nothing here connects.
DATABASE_URL: postgres://openbot:openbot@localhost:5432/openbot
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14
- run: bun install --frozen-lockfile
# Collisions and gaps between the migration files themselves.
- run: bunx drizzle-kit check --config=drizzle.config.ts
working-directory: server
# And the other direction: a schema change with no migration written for it. `generate` writes a
# file when it finds one, so the tree being dirty afterwards is the failure.
- name: Schema has no unwritten migration
working-directory: server
run: |
set -euo pipefail
bunx drizzle-kit generate --config=drizzle.config.ts --name=ci_drift_probe
if [ -n "$(git status --porcelain drizzle)" ]; then
echo "::error::The schema has changed without a migration. Run drizzle-kit generate and commit it."
git status --porcelain drizzle
exit 1
fi

# The image is the artefact people deploy, and almost nothing about whether it works is visible to
# the checks above. A dangling symlink, a supervised service that exits, a missing binary: all of
# them typecheck, lint and test perfectly.
image:
name: image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
# Loaded rather than pushed: this runs on pull requests, including from forks, and it proves
# the image builds without granting anything the ability to publish one.
- uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
load: true
tags: openbot:ci
cache-from: type=gha
cache-to: type=gha,mode=max
# Building it says the layers resolve. Running it says the supervision tree comes up and stays
# up, which is a different claim and the one that has broken before.
- name: The image boots and serves
run: |
set -euo pipefail
# Placeholders, not secrets. `loadConfig` refuses to start without Intelligence and a
# licence configured, but it only checks that they are present and well-formed; nothing is
# contacted at start-up and /api/capabilities reads config alone. So this proves the image
# boots and serves without needing a licence, which is the part CI cannot have: a licence
# is bound to the machine it was issued for. Whether Intelligence actually answers is what
# the smoke journey checks, on a real deployment.
docker run -d --name openbot-ci -p 3001:3001 \
-e EMBEDDED_POSTGRES=on \
-e KEY_ENCRYPTION_KEY="$(openssl rand -base64 32)" \
-e TRUSTED_ORIGINS=http://localhost:3001 \
-e OPENBOT_DEV_NO_AUTH=1 \
-e MANAGED_AGENT_AG_UI_URL=http://127.0.0.1:4201/ag-ui \
-e INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai \
-e INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai \
-e INTELLIGENCE_API_KEY=ci-not-a-real-key \
-e COPILOTKIT_LICENSE_TOKEN=ci-not-a-real-licence \
openbot:ci
for attempt in $(seq 1 150); do
if curl -fsS http://localhost:3001/api/capabilities >/dev/null 2>&1; then
echo "answered after ${attempt}s"
exit 0
fi
if [ -z "$(docker ps -q -f name=openbot-ci)" ]; then
echo "::error::The container exited before it answered."
docker logs openbot-ci
exit 1
fi
sleep 1
done
echo "::error::No answer on /api/capabilities within 150s."
docker logs openbot-ci
exit 1
- name: Supervision tree is stable, not respawning
run: |
set -euo pipefail
# A supervised service that exits is restarted forever. That looks healthy from outside for
# as long as something else is answering, so the log is where it shows.
sleep 15
if docker logs openbot-ci 2>&1 | grep -Eic 'restarting|respawn' | grep -qv '^0$'; then
echo "::error::A supervised service is restarting."
docker logs openbot-ci 2>&1 | grep -Ei 'restarting|respawn' | head -20
exit 1
fi
test -n "$(docker ps -q -f name=openbot-ci)" || {
echo "::error::The container is no longer running after 15s."
docker logs openbot-ci
exit 1
}
- if: always()
run: docker rm -f openbot-ci >/dev/null 2>&1 || true

# One check for branch protection to require. A new job above is covered by this without anybody
# remembering to add it to a list, and a job that was skipped for the wrong reason is not a pass.
verify:
name: verify
runs-on: ubuntu-latest
if: always()
needs: [static, test, build, migrations, image]
steps:
- name: Require every check
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
set -euo pipefail
echo "$RESULTS"
for result in $RESULTS; do
case "$result" in
success|skipped) ;;
*) echo "::error::A required check reported $result"; exit 1 ;;
esac
done
216 changes: 216 additions & 0 deletions .github/workflows/publish-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
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
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 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 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]
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
- name: Write the image manifest
env:
VERSION: ${{ needs.metadata.outputs.version }}
DIGEST: ${{ needs.image.outputs.digest }}
COMMIT: ${{ github.sha }}
run: |
set -euo pipefail
[[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
bun -e '
const [version, digest, commit] = process.argv.slice(-3);
const repository = "ghcr.io/copilotkit/openbot";
console.log(JSON.stringify({
version, commit,
images: { openbot: { repository, digest, reference: `${repository}@${digest}` } },
}, null, 2));
' -- "$VERSION" "$DIGEST" "$COMMIT" > 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
Loading