From ea8973ff9a06a19fb206fb5b81b4354278bca351 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 20 Aug 2026 17:43:42 -0700 Subject: [PATCH 1/2] Cut releases from a reviewed PR, and make CI run the thing it ships Releases were cut by hand, and nothing in CI ran the artefact people deploy. A release is now one choice and one review. `Create release PR` bumps the version and promotes `## Unreleased` to a numbered section; merging the pull request it opens is what publishes. Merging builds one image, signs a build provenance attestation for its digest, tags the commit and creates the Release with `container-images.json` so a deployment names a digest rather than a tag somebody could move. The notes are the section a person wrote: nothing is generated from commit subjects, because a commit subject is written for the reader of a diff and these are for somebody deciding whether to upgrade. Publishing checks the pull request rather than the commit message. The head branch must match `release/publish/vX.Y.Z`, be in this repository, and carry the `release` label. A fork can name a branch anything; it cannot add a label. Three checks were added, because the existing ones cannot see the image at all. `migrations` refuses a schema change with no migration and a snapshot that has drifted. `image` builds the container, boots it, and fails if it does not answer or if a supervised service is respawning. `smoke` runs the journey in tests/smoke, which existed and ran nowhere, against a real deployment. A single `verify` check covers every job, so branch protection needs one entry and a new job is covered without anybody updating a list. The smoke journey skips itself with a warning when no licence is configured. A check that fails for a reason nobody can fix is a check people learn to ignore. --- .github/workflows/ci.yml | 194 ++++++++++++++++++++++++ .github/workflows/publish-release.yml | 207 ++++++++++++++++++++++++++ .github/workflows/release.yml | 160 ++++++++++++++++++++ CHANGELOG.md | 11 ++ docs/releasing.md | 80 ++++++++++ package.json | 1 + 6 files changed, 653 insertions(+) create mode 100644 .github/workflows/publish-release.yml create mode 100644 .github/workflows/release.yml create mode 100644 docs/releasing.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b03d0cc..715a883 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,3 +83,197 @@ 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 + env: + KEY_ENCRYPTION_KEY: ${{ secrets.CI_KEY_ENCRYPTION_KEY }} + run: | + set -euo pipefail + docker run -d --name openbot-ci -p 3001:3001 \ + -e EMBEDDED_POSTGRES=on \ + -e KEY_ENCRYPTION_KEY="${KEY_ENCRYPTION_KEY:-$(openssl rand -base64 32)}" \ + -e TRUSTED_ORIGINS=http://localhost:3001 \ + -e OPENBOT_DEV_NO_AUTH=1 \ + openbot:ci + for attempt in $(seq 1 60); 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 60s." + 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 journey through a deployment that is actually up. tests/smoke/journey.test.ts has existed and + # run nowhere; this is what runs it. It needs a licence, so it is skipped where secrets are not + # available rather than failing and teaching everyone to ignore it. + smoke: + name: smoke + runs-on: ubuntu-latest + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + 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 + # This job also runs buildx. A restorable cache in a job that produces an image is a way to + # influence that image, and caching a pinned binary download saves nothing worth it. + no-cache: true + - id: secrets + name: Check the deployment can be licensed + env: + LICENCE: ${{ secrets.COPILOTKIT_LICENSE_TOKEN }} + run: | + if [ -z "$LICENCE" ]; then + echo "::warning::COPILOTKIT_LICENSE_TOKEN is not set, so the journey cannot run." + echo "ready=false" >> "$GITHUB_OUTPUT" + else + echo "ready=true" >> "$GITHUB_OUTPUT" + fi + - if: steps.secrets.outputs.ready == 'true' + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 + - if: steps.secrets.outputs.ready == 'true' + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + load: true + tags: openbot:smoke + cache-from: type=gha + - if: steps.secrets.outputs.ready == 'true' + name: Start a deployment + env: + COPILOTKIT_LICENSE_TOKEN: ${{ secrets.COPILOTKIT_LICENSE_TOKEN }} + INTELLIGENCE_API_KEY: ${{ secrets.INTELLIGENCE_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + set -euo pipefail + docker run -d --name openbot-smoke -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 COPILOTKIT_LICENSE_TOKEN="$COPILOTKIT_LICENSE_TOKEN" \ + -e INTELLIGENCE_API_KEY="$INTELLIGENCE_API_KEY" \ + -e OPENAI_API_KEY="$OPENAI_API_KEY" \ + openbot:smoke + for _ in $(seq 1 90); do + curl -fsS http://localhost:3001/api/capabilities >/dev/null 2>&1 && exit 0 + sleep 1 + done + echo "::error::The deployment did not come up." + docker logs openbot-smoke + exit 1 + - if: steps.secrets.outputs.ready == 'true' + run: bun install --frozen-lockfile + - if: steps.secrets.outputs.ready == 'true' + name: Drive the journey + env: + OPENBOT_API_URL: http://localhost:3001 + run: bun run test:smoke + - if: always() + run: | + docker logs openbot-smoke 2>&1 | tail -100 || true + docker rm -f openbot-smoke >/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, smoke] + 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 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 0000000..0e75e75 --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -0,0 +1,207 @@ +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 + } + + # 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] + 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, 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ed8ccc8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,160 @@ +name: Create release PR + +# Cutting a release is one deliberate act by a person, and everything after it is automatic. This +# workflow only proposes: it writes the version and the notes and opens a pull request. Merging that +# pull request is what publishes, so the release itself goes through review like anything else. +on: + workflow_dispatch: + inputs: + bump: + description: Version bump + required: true + type: choice + options: [patch, minor, major] + dry_run: + description: Show the version and notes without opening a PR + required: false + default: false + type: boolean + +permissions: + contents: read + +concurrency: + group: openbot-release-pr + cancel-in-progress: false + +jobs: + create: + name: create + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: read + steps: + # Two open release PRs would each carry a version computed before the other existed, and + # whichever merged second would publish notes that skip a release. + - name: Refuse a second open release PR + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { owner, repo } = context.repo; + const { data: pulls } = await github.rest.pulls.list({ owner, repo, state: "open" }); + const open = pulls.filter((pull) => pull.head.ref.startsWith("release/publish/")); + if (open.length > 0) { + core.setFailed(`A release PR is already open: ${open.map((p) => p.html_url).join(", ")}`); + } + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The whole history, because the previous tag is what says where these notes start. + fetch-depth: 0 + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - id: prepare + name: Write the version and promote the notes + env: + BUMP: ${{ inputs.bump }} + run: | + set -euo pipefail + current=$(bun -e 'console.log(require("./package.json").version)') + + # The version in package.json must be one that was actually released, or the bump is + # computed from a number nobody published. The exception is the first release, where there + # is no tag to find yet. + if [ -n "$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --merged HEAD)" ]; then + git rev-parse --verify "refs/tags/v$current" >/dev/null 2>&1 || { + echo "::error::package.json says $current but v$current was never tagged." + exit 1 + } + fi + + # CHANGELOG.md is written by hand, for the person deciding whether to upgrade. This + # promotes what is already there; it never generates notes from commit subjects, because a + # commit subject is written for the person reading the diff. + grep -q '^## Unreleased$' CHANGELOG.md || { + echo "::error::CHANGELOG.md has no '## Unreleased' section." + exit 1 + } + unreleased=$(awk '/^## Unreleased$/{found=1; next} /^## /{found=0} found' CHANGELOG.md | grep -c '[^[:space:]]' || true) + if [ "$unreleased" -eq 0 ]; then + echo "::error::Nothing under '## Unreleased'. A release nobody can describe is not a release." + exit 1 + fi + + bun -e ' + const fs = require("fs"); + // `bun -e` passes argv as [bun, ...args], so the argument is the last element. Reading + // it positionally from the front silently yields undefined and every bump becomes a patch. + const bump = process.argv.at(-1); + const pkg = JSON.parse(fs.readFileSync("package.json", "utf8")); + const [major, minor, patch] = pkg.version.split(".").map(Number); + const next = + bump === "major" ? [major + 1, 0, 0] : + bump === "minor" ? [major, minor + 1, 0] : + [major, minor, patch + 1]; + pkg.version = next.join("."); + fs.writeFileSync("package.json", `${JSON.stringify(pkg, null, 2)}\n`); + console.log(pkg.version); + ' -- "$BUMP" > /tmp/version + version=$(cat /tmp/version) + + # Unreleased becomes the version, and a fresh empty Unreleased takes its place so the next + # change has somewhere to go without anyone hand-editing a heading. + bun -e ' + const fs = require("fs"); + const version = process.argv[process.argv.length - 1]; + const text = fs.readFileSync("CHANGELOG.md", "utf8"); + fs.writeFileSync( + "CHANGELOG.md", + text.replace(/^## Unreleased$/m, `## Unreleased\n\n## ${version}`), + ); + ' -- "$version" + + echo "version=$version" >> "$GITHUB_OUTPUT" + { + echo "## Release v$version" + echo + echo "Merging this publishes the image, tags the commit and creates the GitHub Release." + echo + echo "Before merging:" + echo "- [ ] The notes below describe what a deployment does differently" + echo "- [ ] CI is green, including the smoke journey" + echo + echo '```' + awk -v v="## $version" '$0==v{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md + echo '```' + } > /tmp/pr-body.md + + - name: Preview + if: inputs.dry_run + run: | + git --no-pager diff -- package.json CHANGELOG.md + cat /tmp/pr-body.md + + - id: app-token + if: '!inputs.dry_run' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.DEVOPS_BOT_CLIENT_ID }} + private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }} + owner: CopilotKit + repositories: OpenBot + permission-contents: write + permission-pull-requests: write + + - name: Open the release PR + if: '!inputs.dry_run' + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ steps.app-token.outputs.token }} + branch: release/publish/v${{ steps.prepare.outputs.version }} + delete-branch: true + labels: release + commit-message: "Release v${{ steps.prepare.outputs.version }}" + title: "Release v${{ steps.prepare.outputs.version }}" + body-path: /tmp/pr-body.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c19cfef..28627f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ### Added +- **Releases are cut by a workflow, not by hand.** `Create release PR` bumps the version and promotes + `## Unreleased` to a numbered section; merging the pull request it opens is what publishes. Merging + builds and pushes one image to `ghcr.io/copilotkit/openbot`, signs a build provenance attestation + for its digest, tags the commit and creates the GitHub Release with `container-images.json` so a + deployment can name an exact digest rather than a tag somebody could move. See + [docs/releasing.md](docs/releasing.md). +- **CI now runs the thing it ships.** Three checks were added. `migrations` refuses a schema change + with no migration written for it, and a snapshot that has drifted from the schema. `image` builds + the container, boots it, and fails if it does not answer or if a supervised service is respawning. + `smoke` runs the journey in `tests/smoke`, which existed and ran nowhere, against a real + deployment. A single `verify` check covers all of them, so branch protection needs one entry. - **One container that runs the whole thing.** The root `Dockerfile` builds an image carrying the app, the API, a Bot computer, and optionally PostgreSQL, supervised together. Point `DATABASE_URL` at a database you already run and the built-in one never starts; leave it unset and the container diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..4462b74 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,80 @@ +# Releasing + +A release is one person choosing a version, and a reviewed pull request doing everything else. No +step involves a terminal, a tag pushed by hand, or an image built on somebody's laptop. + +## Cutting one + +1. Check `## Unreleased` in [CHANGELOG.md](../CHANGELOG.md) reads the way you want it to. It is the + release notes. Nothing is generated from commit subjects, because a commit subject is written for + the person reading the diff and these notes are for the person deciding whether to upgrade. +2. Run **Create release PR** from the Actions tab, choosing `patch`, `minor` or `major`. Use + `dry_run` first if you want to see the version and the notes without opening anything. +3. Review the pull request it opens. It contains exactly two changes: the version in `package.json` + and the `## Unreleased` heading becoming `## X.Y.Z`. +4. Merge it. That is the publish. + +Merging is the trigger, so a release is always a reviewed commit on `main`. + +## What merging does + +`publish-release.yml` runs on every push to `main` and starts by deciding whether the commit is a +release at all. It asks the API for the pull request that produced the commit, and requires that the +head branch matches `release/publish/vX.Y.Z`, that the branch is in this repository, and that the +pull request carries the `release` label. A fork can name a branch anything; it cannot add a label. + +Then, in order: + +- the version in the tree is checked against the branch that is publishing it, and the changelog is + checked for a section with that number +- one image is built and pushed to `ghcr.io/copilotkit/openbot`, tagged with the version, the commit + and `latest` +- a build provenance attestation is signed with the workflow's OIDC identity and pushed alongside it +- the commit is tagged and a GitHub Release is created, carrying the changelog section as its notes + and `container-images.json` as an asset + +## Deploying a release + +`container-images.json` pins the digest. Deploy that, not a tag: + +```sh +gh release download v0.1.0 --pattern container-images.json +docker run -p 3001:3001 --env-file .env \ + "$(jq -r .images.openbot.reference container-images.json)" +``` + +A tag can be moved to point at a different image; a digest cannot. The same digest that CI smoke +tested is the one that runs, and rolling back is the same command with an earlier version. + +Before deploying, you can check the image is the one this repository built: + +```sh +gh attestation verify oci://ghcr.io/copilotkit/openbot:v0.1.0 -R CopilotKit/OpenBot +``` + +## What has to be green + +Branch protection should require one check, `verify`, which fails unless every other job succeeded. +A job added to `ci.yml` is covered by it without anybody updating a list. + +| check | what it would catch | +| --- | --- | +| `format, lint, types` | the ordinary things | +| `tests` | a decision made wrongly, in isolation | +| `build` | the app not compiling | +| `migrations` | a schema change with no migration, or a snapshot that has drifted | +| `image` | an image that builds but does not boot, or a supervised service that respawns | +| `smoke` | the parts not wired to each other: server, supervisor, computer, gateway, audit | + +The last two matter more than their position in that list suggests. Everything above them can pass +on a tree whose image never starts, because nothing else here runs the thing it ships. + +## Secrets it needs + +| | | +| --- | --- | +| `DEVOPS_BOT_CLIENT_ID` (variable), `DEVOPS_BOT_PRIVATE_KEY` | opening the release PR as an app rather than with a personal token | +| `COPILOTKIT_LICENSE_TOKEN`, `INTELLIGENCE_API_KEY`, `OPENAI_API_KEY` | the smoke journey, which needs a deployment a licence accepts | + +The smoke job skips itself, with a warning, when the licence is absent. That is deliberate: a check +that fails for a reason nobody can fix is a check people learn to ignore. diff --git a/package.json b/package.json index 1b28ec5..d202dbb 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "name": "openbot", + "version": "0.0.1", "license": "MIT", "private": true, "packageManager": "bun@1.3.14", From b6306f884054e58097f8a596201abe884b8a0082 Mon Sep 17 00:00:00 2001 From: David McKay Date: Thu, 20 Aug 2026 18:11:01 -0700 Subject: [PATCH 2/2] Gate the release on its own checks, and stop pretending CI can run the journey Three corrections to how this was first written, all of them found by running it. The image check under-configured the container and it died at start-up. `loadConfig` refuses to run without Intelligence and a licence, so the boot now supplies both as placeholders: it only checks they are present and well-formed, nothing is contacted at start-up, and /api/capabilities reads config alone. The wait is 150s rather than 60s, which a cold embedded PostgreSQL plus migrations plus Chromium needs. The smoke journey is gone from CI, and will not be coming back. It asserts `licenseStatus` is valid, and a licence is signed for the machine it was issued for, so no hosted runner can satisfy it and no placeholder can fake it. It is now the one step a person takes before merging a release, on a machine that has a licence, and the release PR asks for the result. A check that cannot pass is worse than an absent one. There is no GitHub App for this organisation, so the release PR is opened with the built-in token. That means the PR arrives without its own checks, because a pull request opened by a workflow does not trigger them. Rather than work around that, publishing now runs the whole suite against the release commit before it builds anything. The checks gate the release instead of the proposal for one, which is where they were always more useful. No secrets are needed by any of this. GITHUB_TOKEN is the only one referenced. --- .github/workflows/ci.yml | 97 +++++---------------------- .github/workflows/publish-release.yml | 13 +++- .github/workflows/release.yml | 28 ++++---- CHANGELOG.md | 9 +-- docs/releasing.md | 39 ++++++++--- 5 files changed, 76 insertions(+), 110 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 715a883..8578d8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -140,17 +143,26 @@ jobs: # 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 - env: - KEY_ENCRYPTION_KEY: ${{ secrets.CI_KEY_ENCRYPTION_KEY }} 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="${KEY_ENCRYPTION_KEY:-$(openssl rand -base64 32)}" \ + -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 60); do + 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 @@ -162,7 +174,7 @@ jobs: fi sleep 1 done - echo "::error::No answer on /api/capabilities within 60s." + echo "::error::No answer on /api/capabilities within 150s." docker logs openbot-ci exit 1 - name: Supervision tree is stable, not respawning @@ -184,86 +196,13 @@ jobs: - if: always() run: docker rm -f openbot-ci >/dev/null 2>&1 || true - # One journey through a deployment that is actually up. tests/smoke/journey.test.ts has existed and - # run nowhere; this is what runs it. It needs a licence, so it is skipped where secrets are not - # available rather than failing and teaching everyone to ignore it. - smoke: - name: smoke - runs-on: ubuntu-latest - if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository - 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 - # This job also runs buildx. A restorable cache in a job that produces an image is a way to - # influence that image, and caching a pinned binary download saves nothing worth it. - no-cache: true - - id: secrets - name: Check the deployment can be licensed - env: - LICENCE: ${{ secrets.COPILOTKIT_LICENSE_TOKEN }} - run: | - if [ -z "$LICENCE" ]; then - echo "::warning::COPILOTKIT_LICENSE_TOKEN is not set, so the journey cannot run." - echo "ready=false" >> "$GITHUB_OUTPUT" - else - echo "ready=true" >> "$GITHUB_OUTPUT" - fi - - if: steps.secrets.outputs.ready == 'true' - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - - if: steps.secrets.outputs.ready == 'true' - uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 - with: - context: . - load: true - tags: openbot:smoke - cache-from: type=gha - - if: steps.secrets.outputs.ready == 'true' - name: Start a deployment - env: - COPILOTKIT_LICENSE_TOKEN: ${{ secrets.COPILOTKIT_LICENSE_TOKEN }} - INTELLIGENCE_API_KEY: ${{ secrets.INTELLIGENCE_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - run: | - set -euo pipefail - docker run -d --name openbot-smoke -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 COPILOTKIT_LICENSE_TOKEN="$COPILOTKIT_LICENSE_TOKEN" \ - -e INTELLIGENCE_API_KEY="$INTELLIGENCE_API_KEY" \ - -e OPENAI_API_KEY="$OPENAI_API_KEY" \ - openbot:smoke - for _ in $(seq 1 90); do - curl -fsS http://localhost:3001/api/capabilities >/dev/null 2>&1 && exit 0 - sleep 1 - done - echo "::error::The deployment did not come up." - docker logs openbot-smoke - exit 1 - - if: steps.secrets.outputs.ready == 'true' - run: bun install --frozen-lockfile - - if: steps.secrets.outputs.ready == 'true' - name: Drive the journey - env: - OPENBOT_API_URL: http://localhost:3001 - run: bun run test:smoke - - if: always() - run: | - docker logs openbot-smoke 2>&1 | tail -100 || true - docker rm -f openbot-smoke >/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, smoke] + needs: [static, test, build, migrations, image] steps: - name: Require every check env: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 0e75e75..c69c71b 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -95,11 +95,20 @@ jobs: 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] + needs: [metadata, verify, checks] if: needs.metadata.outputs.is_release == 'true' runs-on: ubuntu-latest permissions: @@ -152,7 +161,7 @@ jobs: # tag somebody could move. github-release: name: tag and release - needs: [metadata, verify, image] + needs: [metadata, verify, checks, image] if: needs.metadata.outputs.is_release == 'true' runs-on: ubuntu-latest permissions: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed8ccc8..a00b652 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,8 +31,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 permissions: - contents: read - pull-requests: read + contents: write + pull-requests: write steps: # Two open release PRs would each carry a version computed before the other existed, and # whichever merged second would publish notes that skip a release. @@ -123,7 +123,12 @@ jobs: echo echo "Before merging:" echo "- [ ] The notes below describe what a deployment does differently" - echo "- [ ] CI is green, including the smoke journey" + echo "- [ ] The smoke journey passed against a licensed deployment, and the result is" + echo " pasted in a comment: \`bash scripts/start.sh && bun run test:smoke\`" + echo + echo "Merging runs the full suite against this commit before it builds, so there is" + echo "nothing to check about CI here. The journey is the part CI cannot do: it needs a" + echo "licence, and a licence belongs to the machine it was issued for." echo echo '```' awk -v v="## $version" '$0==v{f=1;next} /^## /{if(f)exit} f' CHANGELOG.md @@ -136,22 +141,15 @@ jobs: git --no-pager diff -- package.json CHANGELOG.md cat /tmp/pr-body.md - - id: app-token - if: '!inputs.dry_run' - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.DEVOPS_BOT_CLIENT_ID }} - private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }} - owner: CopilotKit - repositories: OpenBot - permission-contents: write - permission-pull-requests: write - + # A pull request opened by a workflow does not trigger the pull_request workflows, so the + # release PR arrives without its own checks. That is deliberate rather than tolerated: the + # publish path runs the full suite against the release commit before it builds anything, which + # gates the release itself instead of the proposal for one. - name: Open the release PR if: '!inputs.dry_run' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: - token: ${{ steps.app-token.outputs.token }} + token: ${{ secrets.GITHUB_TOKEN }} branch: release/publish/v${{ steps.prepare.outputs.version }} delete-branch: true labels: release diff --git a/CHANGELOG.md b/CHANGELOG.md index 28627f1..4659b05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,12 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. for its digest, tags the commit and creates the GitHub Release with `container-images.json` so a deployment can name an exact digest rather than a tag somebody could move. See [docs/releasing.md](docs/releasing.md). -- **CI now runs the thing it ships.** Three checks were added. `migrations` refuses a schema change +- **CI now runs the thing it ships.** Two checks were added. `migrations` refuses a schema change with no migration written for it, and a snapshot that has drifted from the schema. `image` builds - the container, boots it, and fails if it does not answer or if a supervised service is respawning. - `smoke` runs the journey in `tests/smoke`, which existed and ran nowhere, against a real - deployment. A single `verify` check covers all of them, so branch protection needs one entry. + the container, boots it with embedded PostgreSQL, and fails if it does not answer or if a + supervised service is respawning. A single `verify` check covers every job, so branch protection + needs one entry. The same checks run again against the release commit when a release is published, + so they gate the release rather than the proposal for one. - **One container that runs the whole thing.** The root `Dockerfile` builds an image carrying the app, the API, a Bot computer, and optionally PostgreSQL, supervised together. Point `DATABASE_URL` at a database you already run and the built-in one never starts; leave it unset and the container diff --git a/docs/releasing.md b/docs/releasing.md index 4462b74..7528469 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -64,17 +64,36 @@ A job added to `ci.yml` is covered by it without anybody updating a list. | `build` | the app not compiling | | `migrations` | a schema change with no migration, or a snapshot that has drifted | | `image` | an image that builds but does not boot, or a supervised service that respawns | -| `smoke` | the parts not wired to each other: server, supervisor, computer, gateway, audit | -The last two matter more than their position in that list suggests. Everything above them can pass -on a tree whose image never starts, because nothing else here runs the thing it ships. +`image` matters more than its position suggests. Everything above it can pass on a tree whose image +never starts, because nothing else here runs the thing it ships. It builds the container, boots it +with embedded PostgreSQL, waits for `/api/capabilities`, and fails if a supervised service is +respawning. -## Secrets it needs +These checks run again, against the release commit, when the release PR is merged. They gate the +publish rather than the proposal, which is why the release PR arriving without its own checks does +not matter: a pull request opened by a workflow does not trigger them. -| | | -| --- | --- | -| `DEVOPS_BOT_CLIENT_ID` (variable), `DEVOPS_BOT_PRIVATE_KEY` | opening the release PR as an app rather than with a personal token | -| `COPILOTKIT_LICENSE_TOKEN`, `INTELLIGENCE_API_KEY`, `OPENAI_API_KEY` | the smoke journey, which needs a deployment a licence accepts | +**No secrets are required.** Every workflow here uses only the built-in `GITHUB_TOKEN`. + +## The one thing CI cannot do + +The smoke journey in `tests/smoke` is the only check that proves the parts are wired to each other: +the server reaches the supervisor, the supervisor builds a computer, the gateway decides before the +browser acts, and the trail records it. It cannot run in CI, and this is not a gap to be closed +later. + +OpenBot only runs in Intelligence mode. `loadConfig` refuses to start without a licence, and a +licence is cryptographically signed for the machine it was issued for, so a hosted runner cannot hold +one. The `image` check gets around this with placeholder values, because nothing is contacted at +start-up, but the journey asserts `licenseStatus` is `valid` and no placeholder can make that true. + +So it is a step a person takes, on a machine with a licence, before merging the release PR: + +```sh +bash scripts/start.sh +bun run test:smoke +``` -The smoke job skips itself, with a warning, when the licence is absent. That is deliberate: a check -that fails for a reason nobody can fix is a check people learn to ignore. +The release PR asks for the result in a comment. That is deliberately a person rather than a robot: +it is the one gate that cannot be automated, so it is the one gate worth naming.