Sync pricing #40
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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." |