-
Notifications
You must be signed in to change notification settings - Fork 1.5k
feat: automate weekly AI model price book refresh #28146
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1195de3
76b0a4b
131b1c1
2eb4944
34ea737
6a2d79d
29aa769
9f5d1df
f553306
9d17c89
e756f5b
4b198c1
a9cc639
b32746f
f06344e
ff7cf11
8ee6bd9
b890e7d
c8f49c4
5ae4a94
4cca010
64a3b55
5284552
127ce34
16d0d65
5697690
915c61b
b279d2e
1d6c51a
71197af
eecf329
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,178 @@ | ||
| # Refreshes the AI Gateway price book from live upstream data (models.dev) | ||
| # once a week and opens a pull request when the generated artifacts change. | ||
| # | ||
| # The price book seeds customer-visible cost numbers, so the refresh is never | ||
| # merged automatically. The workflow only ever proposes a change; a human | ||
| # reviews and merges it. | ||
| # | ||
| # Behavior: | ||
| # - Runs every Thursday. If regeneration produces no diff, the run ends | ||
| # without opening anything. | ||
| # - Reuses a single branch and pull request, force-pushing each week, so at | ||
| # most one refresh PR is open and it always carries the newest snapshot. | ||
| # - Fails loudly when the generator refuses to run, which it does by design | ||
| # when upstream drops a model pinned in overrides.jq or curated in | ||
| # curation.json. Failures are announced in Slack. | ||
| name: aigateway-prices-refresh | ||
|
|
||
| on: | ||
| schedule: | ||
| # 09:00 UTC every Thursday, leaving three business days before Tuesday releases. | ||
| - cron: "0 9 * * 4" | ||
| workflow_dispatch: # allows manual runs for testing | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit [CRF-10]
|
||
|
|
||
| permissions: {} | ||
|
|
||
| concurrency: | ||
| group: aigateway-prices-refresh | ||
|
|
||
| env: | ||
| REFRESH_BRANCH: bot/aigateway-prices-refresh | ||
| PRICES_FILE: coderd/aibridge/prices/data/prices.json | ||
| CATALOG_FILE: site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit [CRF-13]
Options: derive the paths from the Makefile (e.g., print them from a helper target), or add a guard step that fails when either env path is not tracked in HEAD.
|
||
|
|
||
| jobs: | ||
| refresh: | ||
| name: Refresh price book | ||
| runs-on: ubuntu-latest | ||
| permissions: {} | ||
| steps: | ||
| - name: Harden Runner | ||
| uses: step-security/harden-runner@b09bb98e06d4d774595224525879c09bc6e98c40 # v2.20.1 | ||
| with: | ||
| egress-policy: audit | ||
|
|
||
| - name: Checkout | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3 [CRF-6] Checkout has no
The implementation plan explicitly says Two mechanical fixes, either sufficient:
|
||
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | ||
| with: | ||
| persist-credentials: false | ||
|
|
||
| - name: Set up mise tools | ||
| uses: ./.github/actions/setup-mise | ||
| with: | ||
| install-args: "go node pnpm" | ||
|
|
||
| # Needed by catalog generation, which formats its output with biome. | ||
| - name: Install pnpm dependencies | ||
| uses: ./.github/actions/pnpm-install | ||
|
|
||
| - name: Snapshot the current price book | ||
| run: cp "${PRICES_FILE}" "${RUNNER_TEMP}/prices-before.json" | ||
|
|
||
| - name: Regenerate price book and model catalog | ||
| run: make gen/aibridge-prices | ||
|
|
||
| - name: Detect changes | ||
| id: detect | ||
| run: | | ||
| set -euo pipefail | ||
| if git diff --quiet -- "${PRICES_FILE}" "${CATALOG_FILE}"; then | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3 [CRF-3] Catalog-only refresh opens a PR titled "chore: refresh AI model price book" whose body reads "No price changes." (Hisoka P3, Kite P3, Meruem P3, Ryosuke P3, plus Mafuuu Nit, Pariston Note, Razor Note, Zoro Note)
The class of bug is that "did anything change" lives in two places (the git-diff gate and the tool's price-only rendering) and the two can disagree. Two fixes eliminate the gap rather than paper over it:
Option 1 is the more honest fix. Reviewers on this PR are the same for both artifacts, so the routing is fine; the mismatch is cognitive but predictable and repeated weekly.
|
||
| # exit 0 => NO differences => nothing to propose | ||
| echo "Price book already matches upstream; nothing to propose." | ||
| echo "changed=false" >> "$GITHUB_OUTPUT" | ||
| else | ||
| # exit 1 => differences found | ||
| git diff --stat -- "${PRICES_FILE}" "${CATALOG_FILE}" | ||
| echo "changed=true" >> "$GITHUB_OUTPUT" | ||
| fi | ||
|
|
||
| - name: Build pull request body | ||
| if: steps.detect.outputs.changed == 'true' | ||
| run: | | ||
| set -euo pipefail | ||
| go run ./scripts/aibridgepricesdiff \ | ||
| -old "${RUNNER_TEMP}/prices-before.json" \ | ||
| -new "${PRICES_FILE}" > "${RUNNER_TEMP}/summary.md" | ||
|
|
||
| { | ||
| cat "${RUNNER_TEMP}/summary.md" | ||
| echo | ||
| echo "## Review notes" | ||
| echo | ||
| echo "Regenerated by \`make gen/aibridge-prices\` from the live [models.dev](https://models.dev) catalog. Both artifacts come from one snapshot, so they ship together:" | ||
| echo | ||
| echo "- \`${PRICES_FILE}\`" | ||
| echo "- \`${CATALOG_FILE}\`" | ||
| echo | ||
| echo "These are customer-visible cost numbers taken from upstream data, so this PR is never merged automatically. The summary above lists what moved; check the diff for exact figures before approving." | ||
| echo | ||
| echo "Opened automatically by the [aigateway-prices-refresh workflow](${RUN_URL})." | ||
| } > "${RUNNER_TEMP}/body.md" | ||
|
|
||
| cat "${RUNNER_TEMP}/body.md" | ||
| env: | ||
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | ||
|
|
||
| - name: Open or update the refresh pull request | ||
| if: steps.detect.outputs.changed == 'true' | ||
| env: | ||
| # Use cdrci's token instead of the default GITHUB_TOKEN: PRs opened | ||
| # with GITHUB_TOKEN do not trigger workflow runs, so the refresh | ||
| # would arrive without CI signal. | ||
| GH_TOKEN: ${{ secrets.CDRCI_GITHUB_TOKEN }} | ||
| PR_TITLE: "chore: refresh AI model price book" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit [CRF-5] Prose name drift: "AI model price book" here and in the Slack failure text (line 168) versus "AI Gateway price book" in the workflow header (line 1) and the heartbeat text (line 170). (Chopper, Gon, Leorio, Ryosuke)
Change both to "AI Gateway price book".
|
||
| run: | | ||
| set -euo pipefail | ||
|
|
||
| # persist-credentials is disabled on checkout, so authenticate the | ||
| # push explicitly. | ||
| git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" | ||
| git config user.name "github-actions[bot]" | ||
| git config user.email "41898282+github-actions[bot]@users.noreply.github.com" | ||
|
|
||
| git checkout -b "${REFRESH_BRANCH}" | ||
| git add -- "${PRICES_FILE}" "${CATALOG_FILE}" | ||
| git commit -m "${PR_TITLE}" | ||
|
|
||
| # Force-push: the branch is regenerated from the newest upstream | ||
| # snapshot each week, so the previous contents are always stale. | ||
| git push --force origin "refs/heads/${REFRESH_BRANCH}" | ||
|
evgeniy-scherbina marked this conversation as resolved.
|
||
|
|
||
| # REST, not `gh pr`: those go through GraphQL, which needs a read:org | ||
| # scope that cdrci's token lacks. | ||
| owner="${GITHUB_REPOSITORY%%/*}" | ||
| pr_number="$(gh api "repos/${GITHUB_REPOSITORY}/pulls?state=open&base=main&head=${owner}:${REFRESH_BRANCH}" --jq '.[0].number // empty')" | ||
| if [ -n "${pr_number}" ]; then | ||
| gh api --method PATCH "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}" \ | ||
| -f title="${PR_TITLE}" \ | ||
| -f body="$(cat "${RUNNER_TEMP}/body.md")" \ | ||
| --silent | ||
| echo "Updated existing PR #${pr_number}." | ||
| else | ||
| gh api --method POST "repos/${GITHUB_REPOSITORY}/pulls" \ | ||
| -f title="${PR_TITLE}" \ | ||
| -f head="${REFRESH_BRANCH}" \ | ||
| -f base=main \ | ||
| -f body="$(cat "${RUNNER_TEMP}/body.md")" \ | ||
| --jq '.html_url' | ||
| fi | ||
|
|
||
| - name: Send Slack notification | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note [CRF-14] Cancelled runs go silent because the notification step gates on If a scheduled run is manually cancelled or superseded by the concurrency group, no Slack signal is emitted. The workflow's stated intent is "Failures are announced in Slack"; cancellation is not a failure, but for a weekly cron whose whole point is to not be silent, on-call may want to know a run was killed mid-flight instead of finding out at the next heartbeat. Consider
|
||
| if: failure() || steps.detect.outputs.changed == 'false' | ||
| env: | ||
| JOB_STATUS: ${{ job.status }} | ||
| PRICE_BOOK_CHANGED: ${{ steps.detect.outputs.changed }} | ||
| SLACK_WEBHOOK: ${{ secrets.AIGATEWAY_PRICES_SLACK_WEBHOOK }} | ||
| RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} | ||
| run: | | ||
| set -euo pipefail | ||
| if [ -z "${SLACK_WEBHOOK}" ]; then | ||
| echo "::error::AIGATEWAY_PRICES_SLACK_WEBHOOK is not set; the notification could not be sent." | ||
| exit 1 | ||
| fi | ||
|
|
||
| if [ "${JOB_STATUS}" = "failure" ]; then | ||
| # printf, not a double-quoted literal: bash leaves \n as two | ||
| # characters, and jq --arg then escapes the backslash, so Slack | ||
| # would print \n as text instead of breaking the line. | ||
| text="$(printf ':warning: *AI model price book refresh failed.*\nThe generator fails by design when upstream drops a model pinned in scripts/aibridgepricesgen/overrides.jq or curated in curation.json. Logs: %s' "${RUN_URL}")" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 [CRF-4] Slack failure alert asserts one specific root cause for every failure mode in the job. (Leorio P2, Chopper P3, Mafuuu P3, Meruem P3, Pariston Note, Razor Nit) At 2 AM:
The step's Diagnose, don't guess. Name the operation and run URL first; if a leading hypothesis is worth keeping, present it as one common cause, not the cause:
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit [CRF-15] Slack failure text spells one referenced file with its full path and the other without. (Meruem)
|
||
| elif [ "${PRICE_BOOK_CHANGED}" = "false" ]; then | ||
| text=":white_check_mark: *AI Gateway price book refresh completed.* No generated changes were found. Run: ${RUN_URL}" | ||
| else | ||
| echo "::error::Unexpected notification state: status=${JOB_STATUS}, changed=${PRICE_BOOK_CHANGED}" | ||
| exit 1 | ||
| fi | ||
|
|
||
| payload="$(jq -nc --arg text "${text}" '{text: $text}')" | ||
| curl -fsSL -X POST -H 'Content-type: application/json' -d "${payload}" "${SLACK_WEBHOOK}" | ||
| echo "Sent Slack notification" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,3 +40,10 @@ coderd/database/queries/ai_provider_keys.sql @ibetitsmike @johnstcn | |
| coderd/database/queries/aicostcontrol.sql @ibetitsmike @johnstcn | ||
| codersdk/aiproviders.go @ibetitsmike @johnstcn | ||
| codersdk/aiproviders_bedrock.go @ibetitsmike @johnstcn | ||
|
|
||
| # Generated price book and frontend model catalog. The | ||
| # aigateway-prices-refresh workflow proposes updates to both from live | ||
| # models.dev data, and they carry customer-visible cost numbers, so every | ||
| # refresh needs a review from someone who owns them. | ||
| coderd/aibridge/prices/data/prices.json @evgeniy-scherbina @ssncferreira @johnstcn | ||
| site/src/pages/AgentsPage/components/ChatModelAdminPanel/knownModels/knownModelsGenerated.json @evgeniy-scherbina @ssncferreira @johnstcn | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note [CRF-7] New rule silently displaces CODEOWNERS uses last-match-wins. Line 31 ( Likely intentional (the file is generated and the new owners are the pricing-data reviewers), but the surrounding comment does not state the intent and the displacement is invisible in the diff. Confirm this is the intent, and add a line to the comment noting that the frontend owner is deliberately excluded from generated catalog updates.
|
||
|
evgeniy-scherbina marked this conversation as resolved.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| // aibridgepricesdiff renders a human-readable Markdown summary of the | ||
| // difference between two AI Gateway price seed files (the prices.json produced | ||
| // by aibridgepricesgen). | ||
| // | ||
| // The price refresh workflow uses it to fill the pull request body, so | ||
| // a reviewer sees at a glance which models appeared, which disappeared, and | ||
| // which repriced. Exact figures are deliberately left to the pull request | ||
| // diff, which is the source of truth. | ||
| // | ||
| // Usage: | ||
| // | ||
| // aibridgepricesdiff -old <path> -new <path> | ||
| package main | ||
|
|
||
| import ( | ||
| "encoding/json" | ||
| "flag" | ||
| "fmt" | ||
| "io" | ||
| "os" | ||
| "sort" | ||
| "strings" | ||
|
|
||
| "golang.org/x/xerrors" | ||
| ) | ||
|
|
||
| // priceRow mirrors the seed file schema written by aibridgepricesgen. Pointer | ||
| // fields preserve the distinction between "not populated by upstream" (null) | ||
| // and "explicitly zero" (0). | ||
| type priceRow struct { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should probably move this to a shared package, as it is used by And possibly other structs/method. Can be done in a follow-up cleanup PR 👍
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yeah, I can fix it in a follow-up PR
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2 [CRF-2] Fourth verbatim copy of the price-seed schema; two adjacent sync-note comments still say the schema lives in three places. (Netero P2, Robin P2, Zoro P2, Ryosuke P3, Razor P3, Hisoka P4)
Consequence: the weekly refresh renders "No price changes." over a real reprice, and the summary the reviewer is asked to trust is quietly narrower than the diff below it. The tool exists precisely so reviewers see what moved; this bug hazard defeats its purpose on the first schema extension. Two fixes:
Ryosuke's framing: three-comments-in-sync is a rule; one imported type is a fact. @ssncferreira's earlier thread suggested the same structural move as a follow-up; the panel is elevating this to P2 because the consequence (silent "No price changes" on future column additions) is real and customer-visible, and there is no ticket linked to the follow-up commitment. Needs a fix here, a linked ticket, or an explicit human decision to accept the gap.
|
||
| Provider string `json:"provider"` | ||
| Model string `json:"model"` | ||
| InputPrice *int64 `json:"input_price"` | ||
| OutputPrice *int64 `json:"output_price"` | ||
| CacheReadPrice *int64 `json:"cache_read_price"` | ||
| CacheWritePrice *int64 `json:"cache_write_price"` | ||
| } | ||
|
|
||
| // modelKey identifies a model across the two snapshots. | ||
| type modelKey struct { | ||
| provider string | ||
| model string | ||
| } | ||
|
|
||
| func (r priceRow) key() modelKey { | ||
| return modelKey{provider: r.Provider, model: r.Model} | ||
| } | ||
|
|
||
| func (k modelKey) String() string { | ||
| return k.provider + "/" + k.model | ||
| } | ||
|
|
||
| func less(a, b modelKey) bool { | ||
| if a.provider != b.provider { | ||
| return a.provider < b.provider | ||
| } | ||
| return a.model < b.model | ||
| } | ||
|
evgeniy-scherbina marked this conversation as resolved.
|
||
|
|
||
| // diff is the full comparison between two snapshots | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit [CRF-9]
|
||
| type diff struct { | ||
| added []modelKey | ||
| removed []modelKey | ||
| changed []modelKey | ||
| } | ||
|
|
||
| func (d diff) empty() bool { | ||
| return len(d.added) == 0 && len(d.removed) == 0 && len(d.changed) == 0 | ||
| } | ||
|
|
||
| func main() { | ||
| oldPath := flag.String("old", "", "path to the previous prices.json (required)") | ||
| newPath := flag.String("new", "", "path to the refreshed prices.json (required)") | ||
| flag.Parse() | ||
| if err := run(*oldPath, *newPath, os.Stdout); err != nil { | ||
| _, _ = fmt.Fprintf(os.Stderr, "aibridgepricesdiff: %v\n", err) | ||
| os.Exit(1) | ||
| } | ||
| } | ||
|
|
||
| func run(oldPath, newPath string, w io.Writer) error { | ||
| if oldPath == "" || newPath == "" { | ||
| return xerrors.New("-old and -new are both required") | ||
| } | ||
| oldRows, err := readRows(oldPath) | ||
| if err != nil { | ||
| return xerrors.Errorf("read %s: %w", oldPath, err) | ||
| } | ||
| newRows, err := readRows(newPath) | ||
| if err != nil { | ||
| return xerrors.Errorf("read %s: %w", newPath, err) | ||
| } | ||
| _, err = io.WriteString(w, render(compare(oldRows, newRows))) | ||
| return err | ||
| } | ||
|
|
||
| func readRows(path string) ([]priceRow, error) { | ||
| data, err := os.ReadFile(path) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| var rows []priceRow | ||
| if err := json.Unmarshal(data, &rows); err != nil { | ||
| return nil, xerrors.Errorf("parse: %w", err) | ||
| } | ||
| return rows, nil | ||
| } | ||
|
|
||
| // compare classifies every model as added, removed, or changed. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit [CRF-11] The comment says "classifies every model as added, removed, or changed." The function also sorts each output slice by // compare classifies every model as added, removed, or changed, and sorts
// each category by (provider, model) so the rendered summary is stable.
|
||
| func compare(oldRows, newRows []priceRow) diff { | ||
| oldByKey := make(map[modelKey]priceRow, len(oldRows)) | ||
| for _, r := range oldRows { | ||
| oldByKey[r.key()] = r | ||
| } | ||
|
|
||
| var d diff | ||
| seen := make(map[modelKey]struct{}, len(newRows)) | ||
| for _, r := range newRows { | ||
| seen[r.key()] = struct{}{} | ||
| prev, ok := oldByKey[r.key()] | ||
| switch { | ||
| case !ok: | ||
| d.added = append(d.added, r.key()) | ||
| case !samePrices(prev, r): | ||
| d.changed = append(d.changed, r.key()) | ||
| } | ||
| } | ||
| for _, r := range oldRows { | ||
| if _, ok := seen[r.key()]; !ok { | ||
| d.removed = append(d.removed, r.key()) | ||
| } | ||
| } | ||
|
|
||
| for _, keys := range [][]modelKey{d.added, d.removed, d.changed} { | ||
| sort.Slice(keys, func(i, j int) bool { return less(keys[i], keys[j]) }) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit [CRF-8]
slices.SortFunc(keys, func(a, b modelKey) int {
return cmp.Or(cmp.Compare(a.provider, b.provider), cmp.Compare(a.model, b.model))
})
|
||
| } | ||
| return d | ||
| } | ||
|
|
||
| // samePrices reports whether two rows for the same model carry identical | ||
| // prices. A price moving to or from null counts as a change. | ||
| func samePrices(a, b priceRow) bool { | ||
| return equalPrice(a.InputPrice, b.InputPrice) && | ||
| equalPrice(a.OutputPrice, b.OutputPrice) && | ||
| equalPrice(a.CacheReadPrice, b.CacheReadPrice) && | ||
| equalPrice(a.CacheWritePrice, b.CacheWritePrice) | ||
| } | ||
|
|
||
| func equalPrice(a, b *int64) bool { | ||
| if a == nil || b == nil { | ||
| return a == nil && b == nil | ||
| } | ||
| return *a == *b | ||
| } | ||
|
|
||
| // render writes the Markdown summary: counts, then the models in each | ||
| // category. Empty categories are omitted. | ||
| func render(d diff) string { | ||
| var b strings.Builder | ||
| write := func(format string, args ...any) { | ||
| _, _ = fmt.Fprintf(&b, format, args...) | ||
| } | ||
|
|
||
| write("## Price book changes\n\n") | ||
| if d.empty() { | ||
| write("No price changes.\n") | ||
| return b.String() | ||
| } | ||
|
|
||
| write("%s added, %s removed, %s changed.\n", | ||
| plural(len(d.added), "model"), | ||
| plural(len(d.removed), "model"), | ||
| plural(len(d.changed), "model"), | ||
| ) | ||
|
|
||
| renderList := func(heading string, keys []modelKey) { | ||
| if len(keys) == 0 { | ||
| return | ||
| } | ||
| write("\n<details>\n<summary>%s</summary>\n\n", heading) | ||
| for _, k := range keys { | ||
| write("- %s\n", k) | ||
| } | ||
| write("</details>\n") | ||
| } | ||
| renderList("Added", d.added) | ||
| renderList("Removed", d.removed) | ||
| renderList("Changed", d.changed) | ||
|
ssncferreira marked this conversation as resolved.
|
||
|
|
||
| return b.String() | ||
| } | ||
|
|
||
| func plural(n int, noun string) string { | ||
| if n == 1 { | ||
| return fmt.Sprintf("%d %s", n, noun) | ||
| } | ||
| return fmt.Sprintf("%d %ss", n, noun) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit [CRF-12] Header Behavior block does not mention the no-change Slack heartbeat. (Leorio)
The Behavior list says "the run ends without opening anything" and "Failures are announced in Slack." Since commit
915c61be9cthe workflow also posts a Slack heartbeat when nothing changed (line 170::white_check_mark: *AI Gateway price book refresh completed.*). A reader working only from the header would think Slack fires only on failure, then wonder on the first quiet Thursday whether the heartbeat is a bug. Add a bullet: