Skip to content

Sync pricing

Sync pricing #37

Workflow file for this run

name: Sync pricing
# The feature this project exists for: prices refresh themselves.
#
# degraded run -> the catalog is held back, the manifest records why, and
# the job fails so it is visible
# anything else -> committed straight to main, which redeploys the site
#
# **There is no human gate on publication, deliberately.** There used to be: a
# diff that tripped a sanity rule was diverted into a rolling pull request and
# waited to be reviewed. That guarded against publishing one bad price and
# traded away the claim the whole site rests on. On 2026-08-21 a flagged diff
# opened #80; nothing reached main for two days, and the header went on saying
# "sources checked 2026-08-20" while the sources had in fact been read every
# morning. A date that lags because nobody has read a diff is the worse
# failure of the two, because a flagged price is *already* marked disputed in
# the UI and on its generated page, and a stale date is marked nowhere at all.
#
# So a new flag now publishes with everything else and files an issue beside
# it. The review still happens; it has stopped holding the queue.
#
# Actions are pinned to commit SHAs, not tags: a tag is mutable, and this
# workflow has write access to the branch the site deploys from.
on:
schedule:
# 06:00 America/Chicago, give or take daylight saving.
- cron: '0 11 * * *'
workflow_dispatch:
permissions:
contents: write
# Issues, not pull requests. Nothing here opens a PR any more, and a token
# that can still do it is a gate somebody can reintroduce by accident.
issues: write
# To dispatch the deploy for a commit this job pushes — see the last step.
actions: write
concurrency:
group: sync-pricing
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: 22
cache: npm
- name: Install reviewed dependencies only
run: |
npm ci --ignore-scripts
npm rebuild esbuild
- name: Fetch, merge and validate pricing
id: sync
run: npm run sync:pricing
# A model arriving or leaving changes how many pages the build emits, and
# that count is written into prose in three files. They were maintained by
# hand, so the morning `moonshot-kimi-k3` appeared — 2026-08-22 — the sync
# published a correct catalogue and then failed its own deploy on
# `check:pages`, describing 161 pages while building 162.
#
# Before validation, so a run that cannot publish does not leave the
# documentation rewritten for a catalogue nobody will see.
- name: Bring the generated-page counts with it
if: steps.sync.outputs.degraded != 'true'
run: npx tsx scripts/check-pages.ts --fix
# The published data must satisfy the same schema the app enforces at
# load, before anything is committed — not after.
- name: Validate the merged catalog
if: steps.sync.outputs.degraded != 'true'
run: npx tsx scripts/validate-catalog.ts
# A run that moved no price still read every source, and that reading is
# the product. `isEmpty` deliberately ignores `lastVerified`, so a stable
# morning produced a catalog that is materially identical but genuinely
# re-verified — and publishing nothing froze both `generatedAt` and the
# per-model `lastVerified` dates until something substantive moved. The
# site then *understated* its own freshness, which is the one claim this
# project exists to make. On 2026-08-05 this path ran and published
# nothing; the next morning's dates jumped 08-04 -> 08-06, skipping a day
# that had in fact been checked.
#
# Both files are committed: the manifest records that the check happened,
# the catalog carries the dates it happened on.
- name: Publish a clean run that changed no prices
id: publish-stable
if: steps.sync.outputs.degraded != 'true' && steps.sync.outputs.changed != 'true'
run: |
echo "Sources stable — recording the verification."
git config user.name "promptspend-bot"
git config user.email "bot@users.noreply.github.com"
git add public/data/pricing.json public/data/sync-status.json README.md docs/PAGES.md src/lib/seo/pages.ts
if git diff --staged --quiet; then
echo "Nothing staged — nothing to record."
else
git commit -m "chore: re-verified, no price changes"
git push
echo "pushed=true" >> "$GITHUB_OUTPUT"
fi
# A degraded run still publishes its health manifest: "we tried and it
# went wrong" is information visitors are entitled to.
- name: Record a degraded run
id: publish-degraded
if: steps.sync.outputs.degraded == 'true'
run: |
git config user.name "promptspend-bot"
git config user.email "bot@users.noreply.github.com"
git add public/data/sync-status.json
if git diff --staged --quiet; then
echo "Nothing staged — the manifest is unchanged."
else
git commit -m "chore: record degraded pricing sync"
git push
echo "pushed=true" >> "$GITHUB_OUTPUT"
fi
# Every non-degraded change, flagged or not. `needs_review` no longer
# decides *whether* to publish — only whether to say something afterwards,
# in the step below. It stays in the commit subject so `git log` still
# distinguishes a morning that tripped a rule from one that did not.
- name: Commit the day's updates
id: publish-updates
if: >-
steps.sync.outputs.degraded != 'true' &&
steps.sync.outputs.changed == 'true'
env:
SYNC_SUMMARY: ${{ steps.sync.outputs.summary }}
NEEDS_REVIEW: ${{ steps.sync.outputs.needs_review }}
run: |
git config user.name "promptspend-bot"
git config user.email "bot@users.noreply.github.com"
git add public/data/pricing.json public/data/sync-status.json docs/pricing-changelog.md README.md docs/PAGES.md src/lib/seo/pages.ts
note=""
if [ "$NEEDS_REVIEW" = "true" ]; then note=" (flagged)"; fi
git commit -m "data: $SYNC_SUMMARY$note"
git push
echo "pushed=true" >> "$GITHUB_OUTPUT"
# After the push, not instead of it. The data is already live by the time
# this runs; this is the note saying a row in it disagrees with itself.
#
# One rolling issue, commented rather than replaced each morning — the
# discipline the freshness monitor next door already uses, for the same
# reason: a bot that files a duplicate daily is a bot people mute. It
# does not close itself either, because unlike a stale catalog a flag
# does not clear on its own; somebody resolves it.
- name: Note a new review flag, without holding the data back
if: >-
steps.sync.outputs.degraded != 'true' &&
steps.sync.outputs.needs_review == 'true'
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
SUMMARY: ${{ steps.sync.outputs.summary }}
NEW_REVIEW: ${{ steps.sync.outputs.new_review }}
MODEL_COUNT: ${{ steps.sync.outputs.model_count }}
FLAGGED_COUNT: ${{ steps.sync.outputs.flagged_count }}
run: |
set -euo pipefail
title='Pricing rows flagged for review'
body=$(cat <<EOF
Today's sync published a change that tripped a sanity rule. **The data is already live** — this is a note to check it, not a gate in front of it.
**Newly flagged:** $NEW_REVIEW
**Summary:** $SUMMARY
**Models tracked:** $MODEL_COUNT
**Currently flagged:** $FLAGGED_COUNT
A model is flagged when the two independent sources disagree by more than 20%, when a published price moved by more than 50% in a single day, or when it stopped appearing upstream. Every flagged row is marked disputed in the UI and on its generated page, so a visitor sees the doubt rather than inheriting it silently.
Check the flagged rows against the vendor's own pricing page. If the vendor's number is authoritative, add it to \`data/pricing-overrides.json\` with its \`verifiedUrl\` — not by editing \`pricing.json\`, which the next sync overwrites.
Long-standing flags do not comment here every morning; only a reason that was not already recorded does.
EOF
)
existing=$(gh issue list --state open --search "$title in:title" --json number --jq '.[0].number // empty')
if [ -n "$existing" ]; then
gh issue comment "$existing" --body "$body"
echo " commented on issue #$existing"
else
gh issue create --title "$title" --body "$body" --label pricing --label needs-review >/dev/null
echo " opened a new review issue"
fi
# A push made with GITHUB_TOKEN deliberately does not start another
# workflow run, so a commit from the steps above lands on main and the
# deploy that `on: push` would normally fire never happens. The catalog is
# then current in the repository and stale on the site — the one failure
# this project cannot afford, and a silent one: the sync goes green.
#
# It stayed hidden because the direct-commit paths had never run. Review
# suppression keyed on a reason string containing live figures, so
# `needs_review` was true every morning and every sync went down the pull
# request path instead; merging that PR is a push from a *user*, which does
# fire the deploy. `data: 4 review-state` on 2026-08-09 was the first
# bot-authored commit in 183, and it did not deploy.
#
# Dispatched rather than a `workflow_run` trigger, and only when something
# was actually pushed: Pages keys a deployment by commit SHA and rejects a
# second one for a SHA already deployed, so firing on every sync would turn
# a quiet morning into a red deploy.
#
# Since the review gate was removed this step carries more weight than it
# used to. A flagged diff no longer reaches the site by way of somebody
# merging a pull request — there is no user push in the loop at all any
# more — so if this dispatch fails, nothing else publishes the catalog.
# The freshness monitor is what notices.
- name: Deploy and notify what this run published
if: >-
steps.publish-stable.outputs.pushed == 'true' ||
steps.publish-degraded.outputs.pushed == 'true' ||
steps.publish-updates.outputs.pushed == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
echo "Pushed a commit — dispatching the workflows GITHUB_TOKEN will not trigger on its own."
gh workflow run deploy.yml --ref main
gh workflow run notify-alerts.yml --ref main
# Last, so the manifest above is committed first: a degraded run should
# still be loud.
- name: Fail the run if the sources were degraded
if: steps.sync.outputs.degraded == 'true'
run: |
echo "::error::Pricing sync was degraded — see public/data/sync-status.json for the reasons."
exit 1
# After the failing step on purpose, with always(), so the ping reflects
# the run's real verdict.
#
# A degraded sync reports /fail: unlike the freshness monitor next door,
# this workflow has no alerting channel of its own beyond a red square in
# a tab nobody opens daily — which is precisely how 2026-08-04 went
# unnoticed. Silence reports the other half: a sync that stopped running
# at all, including GitHub pausing the schedule after ~60 idle days.
- name: Report to Healthchecks
if: always()
env:
HC_PING_KEY: ${{ secrets.HC_PING_KEY }}
JOB_STATUS: ${{ job.status }}
run: |
if [ -z "${HC_PING_KEY:-}" ]; then
echo "HC_PING_KEY not configured - skipping ping."
exit 0
fi
suffix=""
if [ "$JOB_STATUS" != "success" ]; then suffix="/fail"; fi
# Never fail the job over its own monitoring.
curl -fsS -m 10 --retry 3 \
"https://hc-ping.com/${HC_PING_KEY}/promptspend-sync-pricing${suffix}" \
|| echo "Healthchecks ping failed - not failing the run over it."