diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b03d0cc9..8578d8e1 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: @@ -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 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml new file mode 100644 index 00000000..c69c71ba --- /dev/null +++ b/.github/workflows/publish-release.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..a00b6522 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,158 @@ +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: 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. + - 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 "- [ ] 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 + 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 + + # 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: ${{ secrets.GITHUB_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 c19cfef8..4659b05c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ 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.** 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 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 new file mode 100644 index 00000000..7528469e --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,99 @@ +# 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 | + +`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. + +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. + +**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 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. diff --git a/package.json b/package.json index 1b28ec55..d202dbbf 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",