diff --git a/.claude/commands/ci-write-release-notes.md b/.claude/commands/ci-write-release-notes.md index 449df8fc..a5ce8dcb 100644 --- a/.claude/commands/ci-write-release-notes.md +++ b/.claude/commands/ci-write-release-notes.md @@ -9,40 +9,44 @@ with a comprehensive summary. The release tags are provided in the `RELEASE_TAGS` environment variable as a space-separated list. -Tag format: `{app-name}-{version}` where version follows CalVer pattern `YYYY.MM.DD-N` +Tag format: `{bot-name}-{version}` where version follows CalVer pattern `YYYY.MM.DD-N` -Example: `curator-app-2025.10.16-1` +Example: `quoter-bot-2026.08.04-1` -Loop through each tag and extract the app name and version. Skip any tags that don't match the +Loop through each tag and extract the bot name and version. Skip any tags that don't match the expected pattern. -### Step 2: Analyze Each App +### Step 2: Analyze Each Bot For each release tag: -1. **Find the previous release tag** for that app: +1. **Find the previous release tag** for that bot: ```bash - git tag -l "{app}-*" --sort=-version:refname | head -5 + git tag -l "{bot}-*" --sort=-version:refname | grep -Fxv -- "$RELEASE_TAG" | head -5 ``` -2. **Compare the diff** between the newly-published tag and the previous one: + Exclude the release currently being rewritten (`$RELEASE_TAG`): it already exists locally when + this command runs and must not be selected as its own comparison baseline. + +2. **Compare the diff** between the newly-published tag and the previous one. Bots assemble their + behavior from the shared `packages/*` workspace, so include it alongside the bot's own tree: ```bash - git diff {previous-tag}...{new-tag} -- packages/{bot} + git diff {previous-tag}...{new-tag} -- bots/{bot} packages ``` 3. **Get commit messages** in the release range for context: ```bash - git log {previous-tag}...{new-tag} --oneline -- packages/{bot} + git log {previous-tag}...{new-tag} --oneline -- bots/{bot} packages ``` 4. **Extract PR numbers** from commit messages: ```bash # Get PR numbers from merge commits and PR references - git log {previous-tag}...{new-tag} --oneline -- packages/{bot} | \ + git log {previous-tag}...{new-tag} --oneline -- bots/{bot} packages | \ grep -oE '#[0-9]+' | \ sort -u ``` diff --git a/.dockerignore b/.dockerignore index fc30802f..2e93f705 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,21 @@ **/*.log **/.env **/.env.* +# Env files under any name (`docker run --env-file` accepts arbitrary filenames such as +# market-making.env) hold secrets like MAKER_PRIVATE_KEY and must never enter the build context. +**/*.env +# Encrypted keystore files (the documented maker.json example and any keystore-named JSON) hold the +# maker key material and must never bake into an image via `COPY bots`. +**/maker.json +**/*keystore*.json +# No non-example YAML enters the build context: market-making configuration is YAML, may hold a +# private key, and `--config` accepts any operator-chosen filename — not just the default +# market-making.yaml. Images need no YAML at runtime; committed *.example.* templates stay +# copyable, and the root pnpm manifests are re-included because `pnpm install --frozen-lockfile` +# needs them. +**/*.yaml +**/*.yml +!**/*.example.yaml +!**/*.example.yml +!pnpm-lock.yaml +!pnpm-workspace.yaml diff --git a/.github/workflows/claude-write-release-notes.yml b/.github/workflows/claude-write-release-notes.yml new file mode 100644 index 00000000..6a7b8f9c --- /dev/null +++ b/.github/workflows/claude-write-release-notes.yml @@ -0,0 +1,68 @@ +name: Claude write release notes + +# Ported from morpho-apps: rewrites the GitHub-generated notes of freshly created bot releases with +# a Claude-authored summary, following the repo command .claude/commands/ci-write-release-notes.md. +# Triggered by the repository_dispatch that tag-releases.yml sends after creating releases. Manual +# re-run for a tag: +# gh api repos/morpho-org/morpho-bots/dispatches --method POST \ +# --field event_type=write-release-notes \ +# --field 'client_payload[release_tags]=quoter-bot-2026.08.04-1' +# +# The Claude step is skipped — not failed — while ANTHROPIC_API_KEY is not configured, so releasing +# keeps working before that secret exists; the GitHub-generated notes simply remain. Slack: +# release-slack-notify.yml already announced the release at publish time; to re-announce the +# rewritten notes, run that workflow manually with its `tag` input. (morpho-apps instead posts to +# per-app Slack channels from this workflow; this repo's single release channel makes that +# redundant.) + +on: + repository_dispatch: + types: [write-release-notes] + +jobs: + write-release-notes: + runs-on: ubuntu-latest + permissions: + contents: write + id-token: write + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + + - name: Fetch all tags + run: git fetch --tags --prune --force + + # The secrets context is unavailable in job/step `if` expressions, so presence is probed in a + # step and exported as an output. + - name: Check Claude credentials + id: credentials + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + set -euo pipefail + if [ -n "$ANTHROPIC_API_KEY" ]; then + echo "available=true" >> "$GITHUB_OUTPUT" + else + echo "available=false" >> "$GITHUB_OUTPUT" + echo "ANTHROPIC_API_KEY is not configured; keeping the GitHub-generated release notes." + fi + + - name: Write with Claude + if: steps.credentials.outputs.available == 'true' + uses: anthropics/claude-code-action@657fb7c9c986158a19624b357bcbc8c6deb83598 # v1 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + github_token: ${{ secrets.GITHUB_TOKEN }} + # show_full_output stays off (unlike the morpho-apps original): the action's own docs + # reserve it for debugging in non-sensitive environments, and this job holds an API key + # and a write token while allowing Bash — a full transcript could retain + # credential-bearing tool output in the Actions logs. + prompt: /ci-write-release-notes + allowed_bots: 'github-actions[bot]' + claude_args: | + --allowedTools Bash,Read,Glob,Grep + env: + # description: "Space-separated list of release tags to update" + RELEASE_TAGS: ${{ github.event.client_payload.release_tags }} diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index d2df1bf6..0d92a56b 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -101,9 +101,48 @@ jobs: github_environment: crossed-books-prod ref: ${{ github.sha }} - Quoter-bot: + # Preflight BEFORE the Railway deploy: a labeled quoter-bot merge must carry a new CalVer + # package version and usable App credentials, or production would update while the GitHub + # release, Docker Hub image, and Slack announcement never come to exist. Failing here leaves + # production untouched and the operator fixes the version bump or credentials, then re-labels. + Quoter-bot-preflight: needs: Select if: ${{ needs.Select.outputs.quoter_bot == 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ github.sha }} + - name: Validate release version and tag availability + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + version="$(node -p "require('./bots/quoter-bot/package.json').version")" + echo "$version" | grep -Eq '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[1-9][0-9]*$' \ + || { echo "quoter-bot package version must use CalVer YYYY.MM.DD-N (got: $version) — bump it in the release PR" >&2; exit 1; } + if gh release view "quoter-bot-$version" >/dev/null 2>&1; then + echo "release quoter-bot-$version already exists — bump the package version in the release PR" >&2 + exit 1 + fi + # A bare git tag with no release also blocks: `gh release create --target` is ignored for + # a pre-existing tag, so the release would attach to that old commit and the image would + # be built from it instead of this merge. + if git ls-remote --exit-code origin "refs/tags/quoter-bot-$version" >/dev/null 2>&1; then + echo "git tag quoter-bot-$version already exists without a release — delete the stale tag or bump the package version" >&2 + exit 1 + fi + # Proving the App credentials mint BEFORE deploying: Release-quoter-bot refuses the + # default-token fallback (its releases cannot trigger the image publish), so missing + # credentials must stop the flow while production is still untouched. + - name: Mint app installation token + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + with: + app-id: ${{ secrets.GIT_BOT_CLIENT_ID }} + private-key: ${{ secrets.GIT_BOT_PRIVATE_KEY }} + + Quoter-bot: + needs: Quoter-bot-preflight uses: ./.github/workflows/deploy-quoter-bot-production.yml secrets: inherit with: @@ -199,22 +238,35 @@ jobs: runs-on: ubuntu-latest permissions: contents: write - env: - GH_TOKEN: ${{ github.token }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: ref: ${{ github.sha }} fetch-depth: 0 + # Unlike the Railway-only bots above, a quoter-bot release must FIRE downstream `release` + # workflows: deploy-quoter-bot.yml publishes the Docker Hub image and then announces on + # Slack. Events created with the default GITHUB_TOKEN never trigger workflows, so mint the + # same App installation token tag-releases.yml uses — and FAIL here rather than fall back to + # the default token, which would mint a release that can never grow its operator image. The + # preflight job already proved these credentials mint, so a failure here is transient; re-run + # this job once it clears. + - name: Mint app installation token + id: app-token + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + with: + app-id: ${{ secrets.GIT_BOT_CLIENT_ID }} + private-key: ${{ secrets.GIT_BOT_PRIVATE_KEY }} - name: Create release env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} BOT: quoter-bot SHA: ${{ github.sha }} run: | set -euo pipefail - date="$(date -u +%Y.%m.%d)" - n=$(( $(git tag -l "${BOT}-${date}-*" | wc -l) + 1 )) - tag="${BOT}-${date}-${n}" + version="$(node -p "require('./bots/quoter-bot/package.json').version")" + echo "$version" | grep -Eq '^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[1-9][0-9]*$' \ + || { echo "quoter-bot package version must use CalVer YYYY.MM.DD-N" >&2; exit 1; } + tag="${BOT}-${version}" # `|| true` guards against SIGPIPE aborting the job under `set -o pipefail` (git is # killed when head closes the pipe early once there are many tags). prev="$(git tag -l "${BOT}-*" --sort=-version:refname | head -n 1 || true)" diff --git a/.github/workflows/deploy-quoter-bot.yml b/.github/workflows/deploy-quoter-bot.yml new file mode 100644 index 00000000..0dd983ae --- /dev/null +++ b/.github/workflows/deploy-quoter-bot.yml @@ -0,0 +1,157 @@ +name: Deploy quoter-bot + +# Publishes the quoter-bot bot image to Docker Hub when a `quoter-bot-*` GitHub release is +# published (repo CalVer convention: `quoter-bot-YYYY.MM.DD-N`), or on manual dispatch. Unlike +# the Railway bots (deploy-bot.yml), "deploy" here means publish only: operators pull and run the +# image themselves (see bots/quoter-bot/README.md), so there is no service to restart. The Slack +# announcement comes AFTER a successful publish: release-slack-notify.yml skips quoter-bot +# release events and the final step here re-enters it through its manual `tag` input once every +# image tag is pushed — so an announced release always has its image. +# +# Releases come from tag-releases.yml (a merged PR bumping the bot's package.json version to a new +# CalVer value) or from deploy-production.yml's Release-quoter-bot job after a successful +# `release-quoter-bot`-labeled Railway deploy. Both create the release with a GitHub App +# installation token precisely so this workflow fires — GitHub never runs workflows for events +# raised with the default GITHUB_TOKEN. A user-created release (`gh release create` or the releases +# UI) triggers identically. workflow_dispatch stays as the escape hatch for re-publishing, e.g. +# `gh workflow run deploy-quoter-bot.yml -f tag=latest`. +# +# A release publish builds the tagged commit and pushes `` plus `git-`. The +# highest non-prerelease CalVer release also moves `latest`; backfilled older releases and +# prereleases never do. A dispatch builds the dispatched ref and pushes the `tag` input (default +# `latest`) plus `git-`. +# +# Credentials live in the `quoter-bot-dockerhub` GitHub Environment (distinct from +# `quoter-bot-production`, which holds the Railway deploy credentials): secrets +# DOCKERHUB_USERNAME and DOCKERHUB_TOKEN (a Docker Hub access token, write scope) plus variable +# DOCKERHUB_REPOSITORY (`/`, e.g. `morphoorg/quoter-bot-bot`). In the +# environment's deployment branches/tags policy allow branch `main` AND tags matching +# `quoter-bot-*` — release runs execute on the tag ref, so a branch-only policy rejects them. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Movable primary tag to publish alongside the immutable git- tag + required: false + type: string + default: latest + +permissions: + contents: read + +concurrency: + # Serialize publishes so two runs can't interleave their pushes of the movable tag. + group: deploy-quoter-bot + cancel-in-progress: false + +jobs: + Publish: + # Releases are repo-wide (the Railway bots cut `blue-liq-*` etc.); only quoter-bot tags + # concern this image. Other releases simply skip this job. + if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.event.release.tag_name, 'quoter-bot-') }} + runs-on: ubuntu-latest + environment: quoter-bot-dockerhub + permissions: + contents: read + # `gh workflow run` for the post-publish Slack announcement. + actions: write + steps: + # On a release event this checks out the tagged commit (github.sha is the tag's commit), so + # the image is built from exactly the released tree, not main HEAD. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + # The token reaches docker via stdin only — never argv, never a workflow-file literal. + - name: Login to Docker Hub + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + set -euo pipefail + : "${DOCKERHUB_USERNAME:?set secret DOCKERHUB_USERNAME on the quoter-bot-dockerhub environment}" + : "${DOCKERHUB_TOKEN:?set secret DOCKERHUB_TOKEN on the quoter-bot-dockerhub environment}" + printf '%s' "$DOCKERHUB_TOKEN" | docker login --username "$DOCKERHUB_USERNAME" --password-stdin + + # The build context is the repo root so the pnpm workspace (packages/*) resolves — see + # bots/quoter-bot/Dockerfile. The docker release tag is the git tag verbatim, so the + # image, git tag, and GitHub release cross-reference with zero transformation. + - name: Build and push + env: + REPOSITORY: ${{ vars.DOCKERHUB_REPOSITORY }} + EVENT: ${{ github.event_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + PRERELEASE: ${{ github.event.release.prerelease }} + DISPATCH_TAG: ${{ inputs.tag || 'latest' }} + SHA: ${{ github.sha }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + : "${REPOSITORY:?set variable DOCKERHUB_REPOSITORY on the quoter-bot-dockerhub environment}" + # Docker Hub `/` only. A dotted (or `localhost`) first component would be + # read by docker as a registry host and silently push somewhere other than Docker Hub. + echo "$REPOSITORY" | grep -Eq '^[a-z0-9]+((_|__|-+)[a-z0-9]+)*/[a-z0-9]+(([._]|__|-+)[a-z0-9]+)*$' \ + || { echo "DOCKERHUB_REPOSITORY must be a lowercase Docker Hub / repository" >&2; exit 1; } + if [ "$EVENT" = "release" ]; then + CALVER_PATTERN="^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[1-9][0-9]*$" + # The release tag must equal quoter-bot- of the tagged commit: + # a mistyped or version-skewed release would otherwise publish an image (and move + # `latest`) whose tag disagrees with `mm --version` inside it. + package_version="$(node -p "require('./bots/quoter-bot/package.json').version")" + if ! [[ "$package_version" =~ $CALVER_PATTERN ]]; then + echo "package version $package_version is not CalVer (expected YYYY.MM.DD-N) — refusing to publish release image tags" >&2 + exit 1 + fi + expected="quoter-bot-${package_version}" + if [ "$RELEASE_TAG" != "$expected" ]; then + echo "release tag $RELEASE_TAG does not match the tagged commit's package version ($expected) — delete the release and cut it from a matching version bump" >&2 + exit 1 + fi + tags=("$RELEASE_TAG") + # `latest` tracks the highest stable CalVer release, not the most recently published + # release. This prevents a later backfill of an older release from downgrading it. + if [ "$PRERELEASE" != "true" ]; then + highest_stable_tag="$( + gh api --paginate "repos/$GITHUB_REPOSITORY/releases?per_page=100" \ + --jq '.[] | select(.draft == false and .prerelease == false) | .tag_name' \ + | grep -E '^quoter-bot-[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[1-9][0-9]*$' \ + | sort -V \ + | tail -n 1 + )" + if [ "$RELEASE_TAG" = "$highest_stable_tag" ]; then + tags+=("latest") + else + echo "Not moving latest: $highest_stable_tag is newer than backfilled release $RELEASE_TAG" + fi + fi + else + if [[ "$DISPATCH_TAG" == quoter-bot-* || "$DISPATCH_TAG" == git-* ]]; then + echo "refusing to overwrite immutable release or commit tag $DISPATCH_TAG from a manual dispatch" >&2 + exit 1 + fi + tags=("$DISPATCH_TAG") + fi + tags+=("git-${SHA:0:7}") + build_args=() + for tag in "${tags[@]}"; do + echo "$tag" | grep -Eq '^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$' \ + || { echo "invalid docker tag: $tag" >&2; exit 1; } + build_args+=(--tag "$REPOSITORY:$tag") + done + echo "Building ${tags[*]} from $SHA…" + docker build --file bots/quoter-bot/Dockerfile.release "${build_args[@]}" . + for tag in "${tags[@]}"; do + docker push "$REPOSITORY:$tag" + echo "Published docker.io/$REPOSITORY:$tag" + done + + # Announce only now that every image tag exists. release-slack-notify.yml deliberately skips + # quoter-bot release events; its manual `tag` input re-enters it here. workflow_dispatch + # fired with the default GITHUB_TOKEN does start workflows (unlike tag/release events). + - name: Announce release on Slack + if: github.event_name == 'release' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: gh workflow run release-slack-notify.yml -f tag="$RELEASE_TAG" diff --git a/.github/workflows/release-slack-notify.yml b/.github/workflows/release-slack-notify.yml index 2a094568..771c6276 100644 --- a/.github/workflows/release-slack-notify.yml +++ b/.github/workflows/release-slack-notify.yml @@ -18,6 +18,10 @@ env: jobs: Notify: + # quoter-bot releases are announced by deploy-quoter-bot.yml AFTER their Docker image is + # pushed — it re-enters this workflow through the `tag` dispatch input — so their release event + # is skipped here: announcing at publish time could advertise an image that failed to build. + if: ${{ github.event_name == 'workflow_dispatch' || !startsWith(github.event.release.tag_name, 'quoter-bot-') }} runs-on: ubuntu-latest steps: - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 diff --git a/.github/workflows/tag-releases.yml b/.github/workflows/tag-releases.yml new file mode 100644 index 00000000..a6119c12 --- /dev/null +++ b/.github/workflows/tag-releases.yml @@ -0,0 +1,253 @@ +name: Tag releases + +# Ported from morpho-apps' tag-releases.yml: merging a PR to main that bumps an allowlisted bot's +# package.json `version` to a new CalVer value (YYYY.MM.DD-N) creates that bot's GitHub release +# `-`. The release is created with a GitHub App installation token so it FIRES +# downstream `release` workflows — deploy-quoter-bot.yml publishes the image and the +# post-publish step announces it; tag/release events created with the default GITHUB_TOKEN are +# intentionally blocked by GitHub from triggering further workflows. +# +# ONLY quoter-bot releases this way. The Railway bots (blue-liquidation, midnight-liquidation, +# midnight-crossed-books) release through deploy-production.yml's release-* label flow, which cuts +# their `-*` tag strictly AFTER a successful production deploy — a version-bump release here +# would announce a production release that was never deployed. Extending the allowlist below (and +# the paths filter) is a deliberate decision, not a directory rename away. Quoter-bot's own +# label flow (Release-quoter-bot in deploy-production.yml, after its Railway deploy) coexists +# with this version-bump path: both mint the App token and both trigger the image publish. A merge +# carrying BOTH a version bump and the release-quoter-bot label yields entirely to the label +# flow (see the deploy-label check below) so one merge never races itself into two same-day tags, +# and the already-exists guard keeps any remaining origin overlap from double-creating a tag. +# Version changes are detected against the pre-push baseline (github.event.before), not HEAD~1, +# so a bump buried in a multi-commit push is still released. +# +# Adaptations from the morpho-apps original: apps/* → bots/*, depot runners → ubuntu-latest, an +# allowlist instead of a static-version skip list, and initial notes are GitHub-generated from the +# bot's previous tag instead of a placeholder; the dispatched Claude workflow then rewrites the +# notes in place (morpho-apps posts to Slack only after that rewrite). +# +# An allowlisted version bump that is not CalVer fails the run loud — before any release is +# created — so drive-by bumps cannot slip through unreleased. Requires org App credentials +# GIT_BOT_CLIENT_ID / GIT_BOT_PRIVATE_KEY (the same pair morpho-apps uses). + +on: + push: + branches: [main] + paths: + - 'bots/quoter-bot/package.json' + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + create-releases: + runs-on: ubuntu-latest + permissions: + contents: read + # The deploy-label lookup reads the merged PR's labels. + pull-requests: read + outputs: + release_tags: ${{ steps.create.outputs.tags }} + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 0 + + - name: Fetch all tags + run: git fetch --tags --prune --force + + # Avoid requiring the release GitHub App credentials for package metadata changes that do not + # bump the version. The full create step repeats this comparison before any side effect. + - name: Detect quoter-bot version bump + id: version + env: + BEFORE_SHA: ${{ github.event.before }} + run: | + set -euo pipefail + if [ -n "$BEFORE_SHA" ] && [ "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ] \ + && git cat-file -e "$BEFORE_SHA" 2>/dev/null; then + prev_commit="$BEFORE_SHA" + else + prev_commit="$(git rev-parse HEAD~1)" + fi + current_version="$(jq -r .version bots/quoter-bot/package.json)" + previous_version="$(git show "$prev_commit:bots/quoter-bot/package.json" 2>/dev/null | jq -r .version || echo '')" + if [ -n "$current_version" ] && [ "$current_version" != "null" ] \ + && [ "$current_version" != "$previous_version" ]; then + echo "bumped=true" >> "$GITHUB_OUTPUT" + else + echo "bumped=false" >> "$GITHUB_OUTPUT" + fi + + # A merge that carries the `release-quoter-bot` label is owned by deploy-production.yml: + # it cuts the release only AFTER the Railway deploy succeeds, and that release publishes the + # image identically. Running here too would race it — a release/image before the deploy + # settles, or two same-day tags for one commit — so this flow yields to the label flow. + - name: Check deploy label + id: label + if: steps.version.outputs.bumped == 'true' + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + # Labels of the PR whose merge produced this commit. + labels="$(gh api "repos/$REPO/commits/$SHA/pulls" --jq '.[].labels[].name')" + if echo "$labels" | grep -qx 'release-quoter-bot'; then + echo "deploy_labeled=true" >> "$GITHUB_OUTPUT" + else + echo "deploy_labeled=false" >> "$GITHUB_OUTPUT" + fi + + # Mint a GitHub App installation token so the release/tag events fire downstream workflows + # (image publish, Slack notify). See the header for why the default GITHUB_TOKEN cannot. + - name: Mint app installation token + id: app-token + if: steps.version.outputs.bumped == 'true' && steps.label.outputs.deploy_labeled != 'true' + uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4 + with: + app-id: ${{ secrets.GIT_BOT_CLIENT_ID }} + private-key: ${{ secrets.GIT_BOT_PRIVATE_KEY }} + + - name: Check and create releases + id: create + if: steps.version.outputs.bumped == 'true' && steps.label.outputs.deploy_labeled != 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + DEPLOY_LABELED: ${{ steps.label.outputs.deploy_labeled }} + BEFORE_SHA: ${{ github.event.before }} + run: | + set -euo pipefail + + # CalVer pattern: YYYY.MM.DD-N (N must be >= 1) + CALVER_PATTERN="^[0-9]{4}\.[0-9]{2}\.[0-9]{2}-[1-9][0-9]*$" + # Bots that release via version bumps. The Railway bots are deliberately absent: their + # releases are cut by deploy-production.yml only after a successful deploy (see header). + RELEASE_BUMP_BOTS="quoter-bot" + + # Versions are compared against the pre-push main SHA, not HEAD~1: a multi-commit push + # (batched or rebase merge) may carry the bump in an earlier commit, where HEAD~1 would + # silently see no change. Fall back to HEAD~1 when the event baseline is absent or + # unreachable (e.g. a forced history rewrite). + PREV_COMMIT="" + if [ -n "$BEFORE_SHA" ] && [ "$BEFORE_SHA" != "0000000000000000000000000000000000000000" ] \ + && git cat-file -e "$BEFORE_SHA" 2>/dev/null; then + PREV_COMMIT="$BEFORE_SHA" + else + PREV_COMMIT=$(git rev-parse HEAD~1) + echo "Push baseline unavailable; falling back to HEAD~1 ($PREV_COMMIT)" + fi + + invalid_versions="" + pending_releases="" + + # Phase 1: collect and validate every bumped version. No release is created until the + # whole push validates — releases fire downstream image/Slack workflows, so one bad bump + # must not leave partial release side effects behind a run that fails loud. + for bot_dir in bots/*/; do + if [ -f "${bot_dir}package.json" ]; then + bot_name=$(basename "$bot_dir") + + current_version=$(jq -r .version "${bot_dir}package.json") + prev_version=$(git show "${PREV_COMMIT}:${bot_dir}package.json" 2>/dev/null | jq -r .version || echo "") + + if [ "$current_version" != "$prev_version" ] && [ -n "$current_version" ] && [ "$current_version" != "null" ]; then + if [[ " $RELEASE_BUMP_BOTS " != *" $bot_name "* ]]; then + echo "Skipping $bot_name ($current_version): releases for it are cut by deploy-production.yml after deploy" + continue + fi + if [ "$DEPLOY_LABELED" = "true" ]; then + echo "Skipping $bot_name ($current_version): release-quoter-bot label present — deploy-production.yml cuts the release after its Railway deploy" + continue + fi + if [[ $current_version =~ $CALVER_PATTERN ]]; then + pending_releases="${pending_releases}${pending_releases:+ }${bot_name}:${current_version}" + else + echo "Invalid CalVer format for $bot_name: $current_version (expected YYYY.MM.DD-N)" + invalid_versions="${invalid_versions}${bot_name}: ${current_version}\n" + fi + fi + fi + done + + if [ -n "$invalid_versions" ]; then + echo -e "\nERROR: invalid CalVer format in the following version bumps (no releases created):" + echo -e "$invalid_versions" + echo -e "\nExpected format: YYYY.MM.DD-N (e.g., 2026.08.04-1)" + exit 1 + fi + + created_tags="" + + # Phase 2: create the fully validated releases. Bot directory names never contain `:`, + # so `bot:version` entries split unambiguously. + for entry in $pending_releases; do + bot_name="${entry%%:*}" + current_version="${entry#*:}" + tag_name="${bot_name}-${current_version}" + echo "Processing release: $tag_name" + + if gh release view "$tag_name" &>/dev/null; then + existing_target="$(gh release view "$tag_name" --json targetCommitish --jq .targetCommitish)" + if [ "$existing_target" = "$GITHUB_SHA" ]; then + echo "Release already exists at this commit: $tag_name (skipping)" + else + echo "ERROR: release $tag_name already targets $existing_target, not this commit ($GITHUB_SHA) — bump the version" >&2 + exit 1 + fi + elif git ls-remote --exit-code origin "refs/tags/$tag_name" >/dev/null 2>&1; then + # `gh release create --target` is ignored for a pre-existing tag: the release would + # attach to the stale tag's commit and publish an image built from it, leaving this + # push's bump unreleased. Ambiguous leftover state needs an operator decision. + echo "ERROR: git tag $tag_name exists without a release — delete the stale tag or bump the version" >&2 + exit 1 + else + # `|| true` guards against SIGPIPE aborting the job under `set -o pipefail` + # (git is killed when head closes the pipe early once there are many tags). + prev_tag="$(git tag -l "${bot_name}-*" --sort=-version:refname | head -n 1 || true)" + # Target the exact triggering commit, not `main`: the branch pointer is resolved + # server-side at API-call time, so a commit landing between the push and this call + # would silently ship code the release never reviewed. + gh release create "$tag_name" \ + --title "${bot_name} v${current_version}" \ + --target "$GITHUB_SHA" \ + --generate-notes \ + ${prev_tag:+--notes-start-tag "$prev_tag"} + + echo "Created release: $tag_name" + created_tags="${created_tags}${created_tags:+ }${tag_name}" + fi + done + + if [ -n "$created_tags" ]; then + echo -e "\nSuccessfully created releases: ${created_tags}" + echo "tags=${created_tags}" >> "$GITHUB_OUTPUT" + else + echo -e "\nNo new releases created (all versions unchanged or already released)" + echo "tags=" >> "$GITHUB_OUTPUT" + fi + + # repository_dispatch events fired with the default GITHUB_TOKEN DO trigger workflows (unlike + # tag/release events), so no App token is needed here. + dispatch-write-release-notes: + needs: create-releases + if: needs.create-releases.outputs.release_tags != '' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Dispatch write-release-notes event + env: + GH_TOKEN: ${{ github.token }} + TAGS: ${{ needs.create-releases.outputs.release_tags }} + run: | + set -euo pipefail + gh api "repos/${{ github.repository }}/dispatches" \ + --method POST \ + --field event_type='write-release-notes' \ + --field "client_payload[release_tags]=$TAGS" diff --git a/.gitignore b/.gitignore index 4ef03bcc..ce2b1627 100644 --- a/.gitignore +++ b/.gitignore @@ -4,24 +4,32 @@ node_modules .pnpm-store -# Local env files +# Local env files (any name usable with `docker run --env-file` may hold the maker key) .env .env.* +*.env !.env.example -# Local quoter-bot configuration (examples remain trackable) -**/quoter-bot.yaml -**/quoter-bot.yml +# Encrypted keystore files hold the maker key material; the documented maker.json example and any +# keystore-named JSON must never be committed. Keep keystores outside the repository tree. +**/maker.json +**/*keystore*.json + +# Local quoter-bot configuration under any quoter-bot-named variant (`--config` accepts arbitrary +# filenames and the file may hold the maker key). Workflow files and the committed examples remain +# trackable. Name custom configs with "quoter-bot" in the filename, or keep them outside the +# repository tree entirely. +**/*quoter-bot*.yaml +**/*quoter-bot*.yml +# Legacy pre-rename local configuration can still contain private keys after upgrading. +**/*market-making*.yaml +**/*market-making*.yml +!.github/** !**/quoter-bot.example.yaml !**/quoter-bot.example.yml - -# Legacy local configuration can still contain private keys after upgrading. -**/market-making.yaml -**/market-making.yml !**/market-making.example.yaml !**/market-making.example.yml - # Testing coverage diff --git a/CLAUDE.md b/CLAUDE.md index 5de65d13..d5f793dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -154,9 +154,11 @@ This is a **pnpm workspaces monorepo** housing off-chain Morpho curator bots: liquidators build viem clients and drive a block-watcher + per-tick runner loop that discovers positions, reads fresh on-chain state, sizes/simulates a liquidation, and broadcasts only simulation-ok transactions through an in-process pending-tx queue. No bot imports another bot. - Each bot owns its own operator surface — `README.md`, `Dockerfile`, `docker-compose.yml`, and - `scripts/deploy-railway.ts` — so it ships as its own image and - deploys independently. `bots/blue-liquidation` and `bots/midnight-liquidation` are the live + Each bot owns its own operator surface — `README.md`, `Dockerfile`, `docker-compose.yml`, and a + deploy path (`scripts/deploy-railway.ts` per bot for the Railway instances; the + `deploy-quoter-bot` GitHub Actions workflow additionally publishes quoter-bot's image to + Docker Hub for operator-run deployments) — so it ships as its own image and deploys + independently. `bots/blue-liquidation` and `bots/midnight-liquidation` are the live liquidators; `bots/quoter-bot` is the Midnight maker bot (setup checks, position bootstrap, ladder quoting, combined monitoring); `bots/midnight-crossed-books` resolves crossed Midnight books; `bots/kill-switch` is a proposal bot (docs only). diff --git a/bots/blue-liquidation/Dockerfile b/bots/blue-liquidation/Dockerfile index c496f9de..53e0afa3 100644 --- a/bots/blue-liquidation/Dockerfile +++ b/bots/blue-liquidation/Dockerfile @@ -5,6 +5,7 @@ # indexer/database sidecar to build. Node only: pnpm installs, esbuild bundles, node runs. FROM node:24.14.1-slim ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +ENV HUSKY=0 # The container's environment holds a funded liquidator EOA key, so nothing here may run as root — # the previous oven/bun base ended with `USER bun`, and the node images define `node` (uid 1000) but diff --git a/bots/midnight-crossed-books/Dockerfile b/bots/midnight-crossed-books/Dockerfile index eb154cf5..b91ad87c 100644 --- a/bots/midnight-crossed-books/Dockerfile +++ b/bots/midnight-crossed-books/Dockerfile @@ -5,6 +5,7 @@ # Node only: pnpm installs, esbuild bundles, node runs. FROM node:24.14.1-slim ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +ENV HUSKY=0 # The container's environment holds a funded EOA key, so nothing here may run as root — the previous # oven/bun base ended with `USER bun`, and the node images define `node` (uid 1000) but do not switch diff --git a/bots/midnight-liquidation/Dockerfile b/bots/midnight-liquidation/Dockerfile index d4284f4f..f1b42732 100644 --- a/bots/midnight-liquidation/Dockerfile +++ b/bots/midnight-liquidation/Dockerfile @@ -5,6 +5,7 @@ # API, so there is no indexer/database sidecar to build. Node only: pnpm installs, esbuild bundles. FROM node:24.14.1-slim ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +ENV HUSKY=0 # The container's environment holds a funded liquidator EOA key, so nothing here may run as root — # the previous oven/bun base ended with `USER bun`, and the node images define `node` (uid 1000) but diff --git a/bots/quoter-bot/Dockerfile b/bots/quoter-bot/Dockerfile index aa81cdba..75e2e0be 100644 --- a/bots/quoter-bot/Dockerfile +++ b/bots/quoter-bot/Dockerfile @@ -3,6 +3,9 @@ # packages resolve. Node only: pnpm installs, esbuild bundles, node runs. FROM node:24.14.1-slim ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +# HUSKY=0 keeps the root `prepare` script from installing hooks during image installs; HOME lets +# corepack and pnpm write their user-level caches under the node user. +ENV HUSKY=0 ENV HOME=/home/node # Runtime configuration may include a funded maker key. The entrypoint starts as root only long diff --git a/bots/quoter-bot/Dockerfile.release b/bots/quoter-bot/Dockerfile.release new file mode 100644 index 00000000..9666bb43 --- /dev/null +++ b/bots/quoter-bot/Dockerfile.release @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1 +# Operator/distribution image for the quoter-bot. The build context MUST be the repository +# root so workspace packages resolve. pnpm installs, esbuild bundles, and Node runs the published CLI. +FROM node:24.14.1-slim +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +ENV HUSKY=0 + +# Runtime configuration may include a funded maker key, so the process must not run as root. +# Corepack writes its pnpm shim to /usr/local/bin; enable it before dropping privileges. The /state +# mount persists offer-group ownership across container recreations. +RUN corepack enable pnpm +RUN mkdir -p /repo /state && chown node:node /repo /state +WORKDIR /repo +USER node + +# Manifests first keep the install layer cacheable. `corepack install` fetches the pnpm version pinned +# in package.json#packageManager. +COPY --chown=node:node package.json pnpm-workspace.yaml pnpm-lock.yaml ./ +RUN corepack install + +# All workspace manifests and sources are needed to resolve links and build the bot bundle. +COPY --chown=node:node packages ./packages +COPY --chown=node:node bots ./bots +RUN pnpm install --frozen-lockfile +RUN pnpm --filter @morpho-org/quoter-bot run build + +# Expose the `mm` CLI so Compose and docker run can supply any documented subcommand. +ENV XDG_STATE_HOME=/state +WORKDIR /repo/bots/quoter-bot +ENTRYPOINT ["node", "dist/src/index.js"] +CMD ["start", "--verbose"] diff --git a/bots/quoter-bot/README.md b/bots/quoter-bot/README.md index 66070dde..293c57b2 100644 --- a/bots/quoter-bot/README.md +++ b/bots/quoter-bot/README.md @@ -776,3 +776,179 @@ The interactive launcher owns install and build process trees portably: Linux an process groups, while Windows uses non-shell task-tree termination. Press Ctrl-C to stop; `SIGINT` and `SIGTERM` perform bounded server shutdown, terminate owned process trees, and remove the temporary fresh build. Cleanup failures are reported and produce a nonzero exit. + +## Docker + +The bot ships as a standalone Docker image whose entrypoint is the `mm` CLI itself, so every command +and flag documented above is available as the container command; the default command is the verbose +combined monitor (`start --verbose`). This section covers operator-run containers and the Docker Hub +distribution; the Morpho-run Railway instance is documented under [Deploy](#deploy). +Configuration follows the exact precedence documented under [Configuration](#configuration): +environment variables passed to the container override values from a mounted YAML file, and either +source alone is sufficient. The build context must be the repo root so the pnpm workspace +(`packages/*`) resolves. The repo-root `.dockerignore` keeps every non-example YAML and `.env` +file out of the build context — whatever filename `--config` points at — so a local configuration +holding a private key is never baked into an image. + +The image pins `XDG_STATE_HOME=/state`, where the bot persists its durable offer-group ownership +records. Writer deployments (`start`, `bootstrap`, `ladder`) must mount a volume at `/state` so +that state outlives the container — the compose file below does this automatically. A recreated +container without it forgets which live on-chain offer groups the bot owns, treats its own offers +as foreign, and cannot clean them up. Read-only inspections of an existing deployment should mount +the same `/state` volume so readiness and ladder/bootstrap reconstruction observe the writer's +persisted ownership records; standalone read-only checks that are not inspecting an existing writer +can run without it. + +### Build + +```sh +# From the repo root. +docker build -f bots/quoter-bot/Dockerfile.release -t market-making-bot . +``` + +On Apple Silicon add `--platform linux/amd64` when the image is destined for x86 servers, or keep +the host platform for purely local runs. + +### Run with environment variables + +Pass any subset of the variables documented under +[Environment variables](#environment-variables): + +```sh +docker run --rm \ + -e CHAIN_ID=8453 \ + -e RPC_URL=https://base-rpc.example \ + -e REFERENCE_RPC_URL=https://base-archive-rpc.example \ + -e MAKER_ADDRESS=0x1111111111111111111111111111111111111111 \ + -e MIDNIGHT_ADDRESS=0x2222222222222222222222222222222222222222 \ + -e LOAN_ASSET_ADDRESS=0x3333333333333333333333333333333333333333 \ + -e RATIFIER_ADDRESS=0x4444444444444444444444444444444444444444 \ + -e MARKET_IDS=0x5555555555555555555555555555555555555555555555555555555555555555 \ + -e REFERENCE_MARKET_ID=0x7777777777777777777777777777777777777777777777777777777777777777 \ + -e NATIVE_RESERVE_WEI=10000000000000000 \ + -e MAXIMUM_LEND_EXPOSURE_ASSETS=10000000000 \ + -e MORPHO_API_BASE_URL=https://api.example \ + -e ROUTER_API_BASE_URL=https://router.example \ + market-making-bot --readonly setup-check +``` + +`docker run --env-file ` works with a file in [`.env.example`](./.env.example) syntax. Every +line present in the file counts as a set variable — a `NAME=` line with an empty value overrides +the YAML counterpart with emptiness and fails validation — so list only the variables to supply. +Keep the file outside the repository tree (like `/etc/quoter-bot.env` below): it holds the +maker key, and only `.env*`/`*.env`-style names inside the tree are `.dockerignore`d out of image +builds. + +### Run with a YAML file + +Mount the configuration read-only and select it explicitly: + +```sh +docker run --rm \ + -v "$PWD/bots/quoter-bot/quoter-bot.yaml:/config/quoter-bot.yaml:ro" \ + market-making-bot --config /config/quoter-bot.yaml --readonly setup-check +``` + +Both sources combine freely — for example, keep `identity.makerPrivateKey` out of the file and add +`-e MAKER_PRIVATE_KEY=0x…` only for write-mode commands. The container works from +`/repo/bots/quoter-bot`, so a file mounted at `/repo/bots/quoter-bot/quoter-bot.yaml` is +also picked up by default discovery without `--config`. When keeping a custom-named config inside +the repository tree, include `market-making` in its filename — only such names (and no non-example +YAML at all, docker-side) are ignored by git, so an arbitrary `prod.yaml` holding the maker key +could be committed by mistake. + +### docker compose + +[`docker-compose.yml`](./docker-compose.yml) runs the combined `start` monitor from a YAML file +next to it plus optional environment overrides: + +```sh +cd bots/quoter-bot +cp quoter-bot.example.yaml quoter-bot.yaml # then edit values; chmod 600 +docker compose up --build --detach +docker compose logs --follow +``` + +- The compose file bind-mounts `./quoter-bot.yaml` read-only and fails loud when it is missing. +- Every supported environment variable is declared as a null passthrough entry: it reaches the + container only when the invoking shell sets it, so unset variables never mask YAML values. Export + overrides before starting, e.g. `export MAKER_PRIVATE_KEY=0x…`. +- For an encrypted keystore, set `KEYSTORE_HOST_PATH` to the host file and set `KEYSTORE_PATH` to + `/run/secrets/quoter-bot-keystore.json`; keep that host file outside the repository tree, or + name it `maker.json` / with `keystore` in a `.json` filename — the only keystore patterns + `.gitignore` and `.dockerignore` exclude from commits and image builds ("`COPY bots`" would bake + any other in-tree name into published images). Compose bind-mounts that file read-only at the latter + container path. `KEYSTORE_HOST_PATH` is a Compose-only interpolation variable and is not passed to + the bot. +- `stop_grace_period` defaults to `15m` so shutdown cleanup — drain the in-flight cycle, then + cancel owned offers serially with each receipt bounded by `TRANSACTION_RECEIPT_TIMEOUT_MS` + (default 3 minutes, max 15) — can finish before compose escalates to SIGKILL. Export + `STOP_GRACE_PERIOD` to raise it for long receipt timeouts or many owned groups; `docker compose +stop` delivers the same graceful SIGTERM the CLI handles everywhere else. + +### Publish to Docker Hub + +Publishing is release-driven. A GitHub release whose tag starts with `quoter-bot-` (repo CalVer +convention: `quoter-bot-YYYY.MM.DD-N`) triggers the `Deploy market-making` workflow +([`.github/workflows/deploy-quoter-bot.yml`](../../.github/workflows/deploy-quoter-bot.yml)), +which builds the **tagged commit** from the repo root on an `ubuntu-latest` (`linux/amd64`) runner +and pushes the release tag verbatim (immutable), `git-` for the built commit, and `latest` +(moved only when the release is the highest stable CalVer version). Backfilled older releases and +prereleases leave `latest` unchanged. The Slack announcement is sent by the publish workflow only after every image tag is pushed — the repo-wide +release notifier deliberately skips quoter-bot release events — so an announced release always +has its image. + +To release, bump `version` in [`package.json`](./package.json) to the new CalVer value inside the +PR (for example `2026.08.04-1`; increment the trailing `-N` for further same-day releases). On +merge to `main`, [`tag-releases.yml`](../../.github/workflows/tag-releases.yml) — ported from +morpho-apps — creates the `quoter-bot-` GitHub release with generated notes, using a +GitHub App token precisely so the release event fires the publish workflow (GitHub never runs +workflows for events raised with the default `GITHUB_TOKEN`), then dispatches +[`claude-write-release-notes.yml`](../../.github/workflows/claude-write-release-notes.yml) to +rewrite the notes into a reviewed summary. A non-CalVer version bump fails the run loud. A +`release-quoter-bot`-labeled merge produces a release the same way through +`deploy-production.yml` after its [Railway deploy](#deploy) succeeds, so that release also +publishes an image. + +Creating the release directly also works and publishes identically: + +```sh +gh release create "quoter-bot-$(node -p "require('./bots/quoter-bot/package.json').version")" --target "$(git rev-parse HEAD)" --generate-notes +``` + +Manual dispatch remains available as the escape hatch and for re-publishing; it builds the +dispatched ref (defaults to `main` HEAD) and pushes the `tag` input (default `latest`) plus +`git-`: + +```sh +gh workflow run deploy-quoter-bot.yml -f tag=latest +``` + +One-time repository setup: create the `quoter-bot-dockerhub` GitHub Environment holding the +publish configuration (distinct from `quoter-bot-production`, which holds the [Railway +deploy](#deploy) credentials). In its deployment branches/tags policy allow branch `main` **and** +tags matching `market-making-*` — release runs execute on the tag ref, so a branch-only policy +rejects them, while the tag pattern keeps the token unreachable from arbitrary PR branches. The +release +automation additionally needs the org GitHub App credentials `GIT_BOT_CLIENT_ID` / +`GIT_BOT_PRIVATE_KEY` (the same pair morpho-apps uses) available to this repository, and +optionally `ANTHROPIC_API_KEY` — without it the notes-rewrite step skips cleanly and the +GitHub-generated notes remain. + +| Environment entry | Kind | Requirement and behavior | +| ---------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DOCKERHUB_REPOSITORY` | Variable | Required lowercase `/` Docker Hub repository, e.g. `morphoorg/market-making-bot`. Registry hosts and embedded tags are rejected. | +| `DOCKERHUB_USERNAME` | Secret | Required Docker Hub account with write access to the repository. | +| `DOCKERHUB_TOKEN` | Secret | Required Docker Hub access token (write scope); it reaches `docker login` via stdin and never appears in argv or workflow logs. | + +A deployed host then runs the published image with the exact parametrization documented above — +substitute a `quoter-bot-YYYY.MM.DD-N` release tag for `latest` to pin an immutable version. The +named volume keeps offer-group ownership across re-pulls and recreations: + +```sh +docker run --pull always --detach --restart unless-stopped \ + --stop-timeout 900 \ + --env-file /etc/quoter-bot.env \ + -v quoter-bot-state:/state \ + /:latest start +``` diff --git a/bots/quoter-bot/docker-compose.yml b/bots/quoter-bot/docker-compose.yml index b9c79094..14060b13 100644 --- a/bots/quoter-bot/docker-compose.yml +++ b/bots/quoter-bot/docker-compose.yml @@ -1,36 +1,82 @@ -# Runs the combined setup, bootstrap, and ladder monitor from the package-owned production image. +# Runs the quoter-bot combined monitor (`mm start --verbose`). Copy quoter-bot.example.yaml to +# quoter-bot.yaml next to this file (gitignored, chmod 600) and edit it; every environment variable +# exported in the invoking shell overrides its YAML counterpart. The build context is the repo root +# so the pnpm workspace (packages/*) resolves — see Dockerfile.release. services: bot: build: context: ../.. - dockerfile: bots/quoter-bot/Dockerfile + dockerfile: bots/quoter-bot/Dockerfile.release + command: ['--config', '/config/quoter-bot.yaml', 'start', '--verbose'] + volumes: + - type: bind + source: ./quoter-bot.yaml + target: /config/quoter-bot.yaml + read_only: true + bind: + # Fail loud when quoter-bot.yaml is missing instead of mounting an empty directory. + create_host_path: false + # Optional encrypted keystore. Set KEYSTORE_HOST_PATH to the host file and set the bot's + # KEYSTORE_PATH to /run/secrets/quoter-bot-keystore.json. /dev/null keeps non-keystore + # deployments source-compatible without exposing another host path inside the container. + - type: bind + source: ${KEYSTORE_HOST_PATH:-/dev/null} + target: /run/secrets/quoter-bot-keystore.json + read_only: true + bind: + create_host_path: false + # Durable offer-group ownership state (XDG_STATE_HOME=/state, set by the Dockerfile). It must + # outlive the container: recreating without it makes the bot forget which live on-chain offer + # groups it owns, so it treats its own offers as foreign and cannot clean them up. + - type: volume + source: quoter-bot-state + target: /state + # Null-valued entries pass a variable through ONLY when the invoking shell sets it. Never use + # `${VAR:-}` defaults here: they set empty strings, and any SET variable — even empty — replaces + # the YAML value and fails validation with "Missing required env var". environment: - CHAIN_ID: ${CHAIN_ID:-8453} - RPC_URL: ${RPC_URL:?set RPC_URL} - REFERENCE_RPC_URL: ${REFERENCE_RPC_URL:-} - MAKER_PRIVATE_KEY: ${MAKER_PRIVATE_KEY:?set MAKER_PRIVATE_KEY} - MAKER_ADDRESS: ${MAKER_ADDRESS:?set MAKER_ADDRESS} - MIDNIGHT_ADDRESS: ${MIDNIGHT_ADDRESS:?set MIDNIGHT_ADDRESS} - LOAN_ASSET_ADDRESS: ${LOAN_ASSET_ADDRESS:?set LOAN_ASSET_ADDRESS} - RATIFIER_ADDRESS: ${RATIFIER_ADDRESS:?set RATIFIER_ADDRESS} - MORPHO_API_BASE_URL: ${MORPHO_API_BASE_URL:?set MORPHO_API_BASE_URL} - ROUTER_API_BASE_URL: ${ROUTER_API_BASE_URL:?set ROUTER_API_BASE_URL} - MARKET_IDS: ${MARKET_IDS:?set MARKET_IDS} - REFERENCE_MARKET_ID: ${REFERENCE_MARKET_ID:-} - V0_OFFER_GROUP_IDS: ${V0_OFFER_GROUP_IDS:-} - NATIVE_RESERVE_WEI: ${NATIVE_RESERVE_WEI:?set NATIVE_RESERVE_WEI} - MAXIMUM_LEND_EXPOSURE_ASSETS: ${MAXIMUM_LEND_EXPOSURE_ASSETS:?set MAXIMUM_LEND_EXPOSURE_ASSETS} - REQUEST_TIMEOUT_MS: ${REQUEST_TIMEOUT_MS:-10000} - TRANSACTION_RECEIPT_TIMEOUT_MS: ${TRANSACTION_RECEIPT_TIMEOUT_MS:-180000} - BOOTSTRAP_MARKETS: ${BOOTSTRAP_MARKETS:?set BOOTSTRAP_MARKETS} - LADDER_MARKETS: ${LADDER_MARKETS:?set LADDER_MARKETS} - BETTERSTACK_SOURCE_TOKEN: ${BETTERSTACK_SOURCE_TOKEN:-} - BETTERSTACK_INGESTING_HOST: ${BETTERSTACK_INGESTING_HOST:-} - BETTERSTACK_HEARTBEAT_URL: ${BETTERSTACK_HEARTBEAT_URL:-} - XDG_STATE_HOME: /state + CHAIN_ID: + RPC_URL: + REFERENCE_RPC_URL: + MAKER_PRIVATE_KEY: + # Signer sources beyond the raw key: encrypted keystore or AWS KMS (see README Run section). + KEY_STORAGE_METHOD: + KEYSTORE_PATH: + KEYSTORE_PASSWORD: + KEYSTORE_INTERACTIVE: + AWS_KMS_KEY_ID: + AWS_REGION: + # Standard AWS SDK environment credentials for KMS. Session tokens are optional, but must be + # forwarded when temporary STS credentials are used. + AWS_ACCESS_KEY_ID: + AWS_SECRET_ACCESS_KEY: + AWS_SESSION_TOKEN: + MAKER_ADDRESS: + MIDNIGHT_ADDRESS: + LOAN_ASSET_ADDRESS: + RATIFIER_ADDRESS: + MARKET_IDS: + REFERENCE_MARKET_ID: + NATIVE_RESERVE_WEI: + MAXIMUM_LEND_EXPOSURE_ASSETS: + MORPHO_API_BASE_URL: + ROUTER_API_BASE_URL: + V0_OFFER_GROUP_IDS: + REQUEST_TIMEOUT_MS: + TRANSACTION_RECEIPT_TIMEOUT_MS: + BOOTSTRAP_MARKETS: + LADDER_MARKETS: + # Optional Better Stack shipping/heartbeat; both shipping values must be set together. + BETTERSTACK_SOURCE_TOKEN: + BETTERSTACK_INGESTING_HOST: + BETTERSTACK_HEARTBEAT_URL: + # SIGTERM triggers graceful shutdown: the monitors drain the in-flight cycle, then cancel owned + # offers on-chain serially and wait for each receipt, bounded per transaction by + # TRANSACTION_RECEIPT_TIMEOUT_MS (default 3m, max 15m). Compose escalates to SIGKILL when the + # grace period lapses, cutting cleanup off — raise STOP_GRACE_PERIOD beyond the default when + # configuring long receipt timeouts or many owned groups. + stop_grace_period: ${STOP_GRACE_PERIOD:-15m} restart: unless-stopped - volumes: - - quoter-bot-state:/state volumes: quoter-bot-state: diff --git a/bots/quoter-bot/src/application/version.service.ts b/bots/quoter-bot/src/application/version.service.ts index a46d5915..16250201 100644 --- a/bots/quoter-bot/src/application/version.service.ts +++ b/bots/quoter-bot/src/application/version.service.ts @@ -1,10 +1,13 @@ +import packageJson from '../../package.json' with { type: 'json' } + /** Application service: exposes the bot's version through the CLI adapter. */ export class VersionService { - /** The bot's own release version. Hardcoded until a real release process exists. */ - private static readonly VERSION = '0.0.0' - - /** Returns the bot release version. @returns Stable semantic version text. */ + /** + * Returns the bot release version. + * @returns The package.json `version` — the same value the CalVer release tags are cut from, so + * `mm --version` inside a published image matches its `quoter-bot-` release. + */ getVersion(): string { - return VersionService.VERSION + return packageJson.version } } diff --git a/bots/quoter-bot/test/application/version.service.test.ts b/bots/quoter-bot/test/application/version.service.test.ts index f2352dc7..159a1b27 100644 --- a/bots/quoter-bot/test/application/version.service.test.ts +++ b/bots/quoter-bot/test/application/version.service.test.ts @@ -1,9 +1,10 @@ import { describe, expect, test } from 'vitest' +import packageJson from '../../package.json' with { type: 'json' } import { VersionService } from '../../src/application/version.service' describe('VersionService', () => { - test('returns the hardcoded bot version', () => { - expect(new VersionService().getVersion()).toBe('0.0.0') + test('returns the package.json version the release tags are cut from', () => { + expect(new VersionService().getVersion()).toBe(packageJson.version) }) }) diff --git a/bots/quoter-bot/test/container-release-artifacts.test.ts b/bots/quoter-bot/test/container-release-artifacts.test.ts new file mode 100644 index 00000000..e5dcb066 --- /dev/null +++ b/bots/quoter-bot/test/container-release-artifacts.test.ts @@ -0,0 +1,213 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, test } from 'vitest' + +const packageRoot = resolve(import.meta.dirname, '..') +const repositoryRoot = resolve(packageRoot, '../..') + +describe('quoter-bot container release artifacts', () => { + test('cuts labeled production releases from the package version', () => { + const workflow = readFileSync( + resolve(repositoryRoot, '.github/workflows/deploy-production.yml'), + 'utf8' + ) + const quoterBotRelease = workflow.slice(workflow.indexOf(' Release-quoter-bot:')) + + expect(quoterBotRelease).toContain( + `version="$(node -p "require('./bots/quoter-bot/package.json').version")"` + ) + expect(quoterBotRelease).toContain('tag="${BOT}-${version}"') + expect(quoterBotRelease).not.toContain('date="$(date -u +%Y.%m.%d)"') + }) + + test('documents repo-root manual releases from the bot package version and commit', () => { + const readme = readFileSync(resolve(packageRoot, 'README.md'), 'utf8') + + expect(readme).toContain( + `gh release create "quoter-bot-$(node -p "require('./bots/quoter-bot/package.json').version")" --target "$(git rev-parse HEAD)" --generate-notes` + ) + }) + + test('excludes the release being rewritten from release-note baselines', () => { + const instructions = readFileSync( + resolve(repositoryRoot, '.claude/commands/ci-write-release-notes.md'), + 'utf8' + ) + + expect(instructions).toContain( + 'git tag -l "{bot}-*" --sort=-version:refname | grep -Fxv -- "$RELEASE_TAG" | head -5' + ) + }) + + test('documents that only the highest stable release moves latest', () => { + const readme = readFileSync(resolve(packageRoot, 'README.md'), 'utf8').replace(/\s+/g, ' ') + + expect(readme).toContain( + '`latest` (moved only when the release is the highest stable CalVer version)' + ) + expect(readme).not.toContain('`latest` (moved unless the release is marked a prerelease)') + }) + + test('documents shared state for read-only deployment inspections', () => { + const readme = readFileSync(resolve(packageRoot, 'README.md'), 'utf8').replace(/\s+/g, ' ') + + expect(readme).toContain( + 'Read-only inspections of an existing deployment should mount the same `/state` volume' + ) + expect(readme).not.toContain('Read-only commands need no state volume.') + }) + + test('keeps the complete operator configuration reference', () => { + const readme = readFileSync(resolve(packageRoot, 'README.md'), 'utf8') + + expect(readme).not.toContain('[OUTPUT TRUNCATED') + expect(readme).toContain('### Environment variables') + expect(readme).toContain('### Better Stack observability') + expect(readme).toContain('### YAML schema') + }) + + test('allows the GitHub Actions bot to rewrite release notes', () => { + const workflow = readFileSync( + resolve(repositoryRoot, '.github/workflows/claude-write-release-notes.yml'), + 'utf8' + ) + + expect(workflow).toContain("allowed_bots: 'github-actions[bot]'") + }) + + test('prevents manual dispatches from moving immutable release and commit tags', () => { + const workflow = readFileSync( + resolve(repositoryRoot, '.github/workflows/deploy-quoter-bot.yml'), + 'utf8' + ) + + expect(workflow).toContain( + 'if [[ "$DISPATCH_TAG" == quoter-bot-* || "$DISPATCH_TAG" == git-* ]]' + ) + expect(workflow).toContain('refusing to overwrite immutable release or commit tag') + }) + + test('disables Husky in every workspace Docker install', () => { + for (const dockerfilePath of [ + 'bots/blue-liquidation/Dockerfile', + 'bots/quoter-bot/Dockerfile', + 'bots/quoter-bot/Dockerfile.release', + 'bots/midnight-crossed-books/Dockerfile', + 'bots/midnight-liquidation/Dockerfile' + ]) { + const dockerfile = readFileSync(resolve(repositoryRoot, dockerfilePath), 'utf8') + + expect(dockerfile).toContain('ENV HUSKY=0') + expect(dockerfile).toContain('RUN pnpm install --frozen-lockfile') + } + }) + + test('validates release CalVer before publishing the operator image', () => { + const workflow = readFileSync( + resolve(repositoryRoot, '.github/workflows/deploy-quoter-bot.yml'), + 'utf8' + ) + + expect(workflow).toContain(`CALVER_PATTERN="^[0-9]{4}\\.[0-9]{2}\\.[0-9]{2}-[1-9][0-9]*$"`) + expect(workflow).toContain('[[ "$package_version" =~ $CALVER_PATTERN ]]') + }) + + test('accepts an existing release only when it targets the current commit', () => { + const workflow = readFileSync( + resolve(repositoryRoot, '.github/workflows/tag-releases.yml'), + 'utf8' + ) + + expect(workflow).toContain( + `existing_target="$(gh release view "$tag_name" --json targetCommitish --jq .targetCommitish)"` + ) + expect(workflow).toContain('[ "$existing_target" = "$GITHUB_SHA" ]') + }) + + test('exposes the built Node CLI while persisting writer state', () => { + const dockerfile = readFileSync(resolve(packageRoot, 'Dockerfile.release'), 'utf8') + const compose = readFileSync(resolve(packageRoot, 'docker-compose.yml'), 'utf8') + const publishWorkflow = readFileSync( + resolve(repositoryRoot, '.github/workflows/deploy-quoter-bot.yml'), + 'utf8' + ) + + expect(dockerfile).toContain('RUN mkdir -p /repo /state') + expect(dockerfile).toContain('RUN pnpm --filter @morpho-org/quoter-bot run build') + expect(dockerfile).not.toContain('pnpm -r --if-present run build') + expect(dockerfile).toContain('ENV XDG_STATE_HOME=/state') + expect(dockerfile).toContain('ENTRYPOINT ["node", "dist/src/index.js"]') + expect(dockerfile).toContain('CMD ["start", "--verbose"]') + expect(dockerfile).not.toContain('oven/bun') + expect(compose).toContain('dockerfile: bots/quoter-bot/Dockerfile.release') + expect(publishWorkflow).toContain('--file bots/quoter-bot/Dockerfile.release') + }) + + test('mounts an optional host keystore at the documented container path', () => { + const compose = readFileSync(resolve(packageRoot, 'docker-compose.yml'), 'utf8') + + expect(compose).toContain('source: ${KEYSTORE_HOST_PATH:-/dev/null}') + expect(compose).toContain('target: /run/secrets/quoter-bot-keystore.json') + expect(compose).toContain('read_only: true') + }) + + test('passes standard AWS credentials through Compose for KMS signers', () => { + const compose = readFileSync(resolve(packageRoot, 'docker-compose.yml'), 'utf8') + + expect(compose).toContain('AWS_ACCESS_KEY_ID:') + expect(compose).toContain('AWS_SECRET_ACCESS_KEY:') + expect(compose).toContain('AWS_SESSION_TOKEN:') + }) + + test('moves latest only for the highest stable quoter-bot CalVer release', () => { + const workflow = readFileSync( + resolve(repositoryRoot, '.github/workflows/deploy-quoter-bot.yml'), + 'utf8' + ) + + expect(workflow).toContain('highest_stable_tag=') + expect(workflow).toContain('[ "$RELEASE_TAG" = "$highest_stable_tag" ]') + expect(workflow).not.toContain('[ "$PRERELEASE" = "true" ] || tags+=("latest")') + }) + + test('fails closed on deploy-label lookup errors', () => { + const workflow = readFileSync( + resolve(repositoryRoot, '.github/workflows/tag-releases.yml'), + 'utf8' + ) + const labelLookup = workflow.slice( + workflow.indexOf('- name: Check deploy label'), + workflow.indexOf('- name: Mint app installation token') + ) + + expect(labelLookup).toContain('gh api "repos/$REPO/commits/$SHA/pulls"') + expect(labelLookup).not.toContain('|| true') + }) + + test('mints an app token only after detecting a pending version bump', () => { + const workflow = readFileSync( + resolve(repositoryRoot, '.github/workflows/tag-releases.yml'), + 'utf8' + ) + const detectIndex = workflow.indexOf('- name: Detect quoter-bot version bump') + const mintIndex = workflow.indexOf('- name: Mint app installation token') + + expect(detectIndex).toBeGreaterThan(-1) + expect(mintIndex).toBeGreaterThan(detectIndex) + expect( + workflow.slice(mintIndex, workflow.indexOf('- name: Check and create releases')) + ).toContain( + "if: steps.version.outputs.bumped == 'true' && steps.label.outputs.deploy_labeled != 'true'" + ) + }) + + test('gives detached docker runs the full graceful shutdown window', () => { + const readme = readFileSync(resolve(packageRoot, 'README.md'), 'utf8') + const detachedRun = readme.slice( + readme.indexOf('docker run --pull always'), + readme.indexOf('\n```', readme.indexOf('docker run --pull always')) + ) + + expect(detachedRun).toContain('--stop-timeout 900') + }) +}) diff --git a/bots/quoter-bot/test/infrastructure/cli/cli.test.ts b/bots/quoter-bot/test/infrastructure/cli/cli.test.ts index 8a1c2150..3376a7a9 100644 --- a/bots/quoter-bot/test/infrastructure/cli/cli.test.ts +++ b/bots/quoter-bot/test/infrastructure/cli/cli.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test, vi } from 'vitest' import type { LadderTransactionSubmittedEvent } from '../../../src/application/ladder/ladder-verbose' +import packageJson from '../../../package.json' with { type: 'json' } import { PositionBootstrapHaltedError } from '../../../src/application/bootstrap/position-bootstrap-halted.error' import { PositionBootstrapMonitorHaltedError } from '../../../src/application/bootstrap/position-bootstrap-monitor-halted.error' import { OfferInvalidationFailedError } from '../../../src/application/invalidation/offer-invalidation-failed.error' @@ -71,15 +72,15 @@ const expectHumanFailure = (stderr: string[], message: string, details: unknown) } describe('Cli', () => { - test('quoter-bot --version returns 0.0.0', async () => { - expect(await cli().run(['--version'])).toBe('0.0.0') + test('quoter-bot --version returns the package.json version', async () => { + expect(await cli().run(['--version'])).toBe(packageJson.version) }) test('entrypoint --version succeeds without loading runtime setup environment', async () => { const { exitCode, stdout, stderr } = await runEntrypointWith(['--version']) expect(exitCode).toBe(0) - expect(stdout.trim()).toBe('0.0.0') + expect(stdout.trim()).toBe(packageJson.version) expect(stderr).toBe('') }) @@ -87,7 +88,7 @@ describe('Cli', () => { const { exitCode, stdout, stderr } = await runEntrypointWith(['--json', '--version']) expect(exitCode).toBe(0) - expect(JSON.parse(stdout)).toBe('0.0.0') + expect(JSON.parse(stdout)).toBe(packageJson.version) expect(stderr).toBe('') }) @@ -134,7 +135,7 @@ describe('Cli', () => { }) test('quoter-bot -v is an alias for --version', async () => { - expect(await cli().run(['-v'])).toBe('0.0.0') + expect(await cli().run(['-v'])).toBe(packageJson.version) }) test('rejects an unknown command', async () => { diff --git a/bots/quoter-bot/test/scripts/railway.utils.test.ts b/bots/quoter-bot/test/scripts/railway.utils.test.ts index dc6a1db0..d41019c2 100644 --- a/bots/quoter-bot/test/scripts/railway.utils.test.ts +++ b/bots/quoter-bot/test/scripts/railway.utils.test.ts @@ -336,8 +336,10 @@ describe('Railway CLI output parsing', () => { test('allows Compose deployments to omit inactive reference configuration', () => { const compose = readFileSync(new URL('../../docker-compose.yml', import.meta.url), 'utf8') - expect(compose).toContain('REFERENCE_RPC_URL: ${REFERENCE_RPC_URL:-}') - expect(compose).toContain('REFERENCE_MARKET_ID: ${REFERENCE_MARKET_ID:-}') + expect(compose).toContain(' REFERENCE_RPC_URL:\n') + expect(compose).toContain(' REFERENCE_MARKET_ID:\n') + expect(compose).not.toContain('REFERENCE_RPC_URL: ${REFERENCE_RPC_URL:-}') + expect(compose).not.toContain('REFERENCE_MARKET_ID: ${REFERENCE_MARKET_ID:-}') }) test('reads the newest complete deployment and rejects incomplete output', () => {