From 6b6b9e7d0167f45fe610a19b5e5e27ecf69526cd Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Mon, 30 Mar 2026 18:53:40 +0000 Subject: [PATCH 1/6] feat: add utility functions with unit tests Add formatCurrency, formatDate, and storeSlugs utilities in src/utils/ with 21 vitest unit tests covering standard and edge cases. Co-Authored-By: Paperclip --- src/utils/__tests__/formatCurrency.test.ts | 33 ++++++++++++ src/utils/__tests__/formatDate.test.ts | 62 ++++++++++++++++++++++ src/utils/__tests__/storeSlugs.test.ts | 46 ++++++++++++++++ src/utils/formatCurrency.ts | 10 ++++ src/utils/formatDate.ts | 34 ++++++++++++ src/utils/storeSlugs.ts | 13 +++++ 6 files changed, 198 insertions(+) create mode 100644 src/utils/__tests__/formatCurrency.test.ts create mode 100644 src/utils/__tests__/formatDate.test.ts create mode 100644 src/utils/__tests__/storeSlugs.test.ts create mode 100644 src/utils/formatCurrency.ts create mode 100644 src/utils/formatDate.ts create mode 100644 src/utils/storeSlugs.ts diff --git a/src/utils/__tests__/formatCurrency.test.ts b/src/utils/__tests__/formatCurrency.test.ts new file mode 100644 index 0000000..cb7cbfa --- /dev/null +++ b/src/utils/__tests__/formatCurrency.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { formatCurrency } from '../formatCurrency'; + +describe('formatCurrency', () => { + it('formats 0 cents as $0.00', () => { + expect(formatCurrency(0)).toBe('$0.00'); + }); + + it('formats 199 cents as $1.99', () => { + expect(formatCurrency(199)).toBe('$1.99'); + }); + + it('formats 10000 cents as $100.00', () => { + expect(formatCurrency(10000)).toBe('$100.00'); + }); + + it('handles negative values', () => { + expect(formatCurrency(-500)).toBe('-$5.00'); + }); + + it('handles large numbers', () => { + expect(formatCurrency(99999999)).toBe('$999,999.99'); + }); + + it('supports custom locale', () => { + expect(formatCurrency(1999, 'de-DE', 'EUR')).toContain('19,99'); + }); + + it('supports custom currency', () => { + const result = formatCurrency(1000, 'en-US', 'EUR'); + expect(result).toContain('10.00'); + }); +}); diff --git a/src/utils/__tests__/formatDate.test.ts b/src/utils/__tests__/formatDate.test.ts new file mode 100644 index 0000000..8e291d4 --- /dev/null +++ b/src/utils/__tests__/formatDate.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { formatDate } from '../formatDate'; + +describe('formatDate', () => { + describe('short style', () => { + it('formats an ISO date string', () => { + const result = formatDate('2024-03-15', 'short'); + expect(result).toMatch(/Mar 15, 2024/); + }); + + it('formats a Date object', () => { + const result = formatDate(new Date('2024-03-15'), 'short'); + expect(result).toMatch(/Mar 15, 2024/); + }); + }); + + describe('long style', () => { + it('formats with weekday and full month name', () => { + const result = formatDate('2024-03-15', 'long'); + expect(result).toMatch(/Friday/); + expect(result).toMatch(/March/); + }); + }); + + describe('relative style', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns "just now" for very recent dates', () => { + const now = new Date('2024-01-01T12:00:00Z'); + vi.setSystemTime(now); + const result = formatDate(new Date('2024-01-01T11:59:59Z'), 'relative'); + expect(result).toBe('just now'); + }); + + it('returns minutes ago', () => { + const now = new Date('2024-01-01T12:00:00Z'); + vi.setSystemTime(now); + const result = formatDate(new Date('2024-01-01T11:45:00Z'), 'relative'); + expect(result).toBe('15m ago'); + }); + + it('returns hours ago', () => { + const now = new Date('2024-01-01T12:00:00Z'); + vi.setSystemTime(now); + const result = formatDate(new Date('2024-01-01T09:00:00Z'), 'relative'); + expect(result).toBe('3h ago'); + }); + + it('returns days ago', () => { + const now = new Date('2024-01-05T12:00:00Z'); + vi.setSystemTime(now); + const result = formatDate(new Date('2024-01-01T12:00:00Z'), 'relative'); + expect(result).toBe('4d ago'); + }); + }); +}); diff --git a/src/utils/__tests__/storeSlugs.test.ts b/src/utils/__tests__/storeSlugs.test.ts new file mode 100644 index 0000000..9a06429 --- /dev/null +++ b/src/utils/__tests__/storeSlugs.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest'; +import { getStore, getStoreName, STORE_SLUGS } from '../storeSlugs'; + +describe('storeSlugs', () => { + describe('STORE_SLUGS constant', () => { + it('contains meijer, kroger, and target', () => { + expect(STORE_SLUGS).toHaveProperty('meijer'); + expect(STORE_SLUGS).toHaveProperty('kroger'); + expect(STORE_SLUGS).toHaveProperty('target'); + }); + }); + + describe('getStore', () => { + it('returns store data for known slug', () => { + const store = getStore('meijer'); + expect(store).toEqual({ + name: 'Meijer', + color: '#e31837', + icon: '/icons/stores/meijer.svg', + }); + }); + + it('returns null for unknown slug', () => { + expect(getStore('unknown-store')).toBeNull(); + }); + + it('is case insensitive', () => { + expect(getStore('KROGER')).toBeTruthy(); + expect(getStore('Target')).toBeTruthy(); + }); + }); + + describe('getStoreName', () => { + it('returns store name for known slug', () => { + expect(getStoreName('kroger')).toBe('Kroger'); + }); + + it('returns raw slug for unknown store', () => { + expect(getStoreName('unknown-store')).toBe('unknown-store'); + }); + + it('is case insensitive', () => { + expect(getStoreName('TARGET')).toBe('Target'); + }); + }); +}); diff --git a/src/utils/formatCurrency.ts b/src/utils/formatCurrency.ts new file mode 100644 index 0000000..22d63db --- /dev/null +++ b/src/utils/formatCurrency.ts @@ -0,0 +1,10 @@ +export function formatCurrency( + cents: number, + locale = 'en-US', + currency = 'USD' +): string { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + }).format(cents / 100); +} diff --git a/src/utils/formatDate.ts b/src/utils/formatDate.ts new file mode 100644 index 0000000..f726cee --- /dev/null +++ b/src/utils/formatDate.ts @@ -0,0 +1,34 @@ +export function formatDate( + date: string | Date, + style: 'short' | 'long' | 'relative' = 'short' +): string { + const d = typeof date === 'string' ? new Date(date) : date; + + if (style === 'short') { + return d.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + } + + if (style === 'long') { + return d.toLocaleDateString('en-US', { + weekday: 'long', + month: 'long', + day: 'numeric', + year: 'numeric', + }); + } + + // relative + const diff = Date.now() - d.getTime(); + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; +} diff --git a/src/utils/storeSlugs.ts b/src/utils/storeSlugs.ts new file mode 100644 index 0000000..4fef82f --- /dev/null +++ b/src/utils/storeSlugs.ts @@ -0,0 +1,13 @@ +export const STORE_SLUGS: Record = { + meijer: { name: 'Meijer', color: '#e31837', icon: '/icons/stores/meijer.svg' }, + kroger: { name: 'Kroger', color: '#0033a0', icon: '/icons/stores/kroger.svg' }, + target: { name: 'Target', color: '#cc0000', icon: '/icons/stores/target.svg' }, +}; + +export function getStore(slug: string) { + return STORE_SLUGS[slug.toLowerCase()] ?? null; +} + +export function getStoreName(slug: string): string { + return getStore(slug)?.name ?? slug; +} From d5ee743d84065cbecf40cb07023815fe61821a38 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Mon, 30 Mar 2026 20:13:56 +0000 Subject: [PATCH 2/6] fix(deploy): include alembic in API Docker image Adds alembic.ini and alembic/ directory to the production API image so alembic upgrade head can run in-cluster as an init container. Also carries migration 003 (make hashed_password nullable) from PR #66. Co-Authored-By: Paperclip --- api/Dockerfile | 2 ++ ...003_make_users_hashed_password_nullable.py | 26 +++++++++++++++++++ common/src/cartsnitch_common/models/user.py | 2 +- 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 api/alembic/versions/003_make_users_hashed_password_nullable.py diff --git a/api/Dockerfile b/api/Dockerfile index bb5d3bd..8eef88d 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -16,6 +16,8 @@ WORKDIR /app RUN adduser --system --group --uid 1000 app COPY --from=build /install /usr/local COPY src/ ./src/ +COPY alembic.ini ./ +COPY alembic/ ./alembic/ USER 1000 EXPOSE 8000 diff --git a/api/alembic/versions/003_make_users_hashed_password_nullable.py b/api/alembic/versions/003_make_users_hashed_password_nullable.py new file mode 100644 index 0000000..8aec2bc --- /dev/null +++ b/api/alembic/versions/003_make_users_hashed_password_nullable.py @@ -0,0 +1,26 @@ +"""Make users.hashed_password nullable. + +Better-Auth inserts users without hashed_password (passwords live in the +accounts table). This column is now purely optional. + +Revision ID: 003_make_users_hashed_password_nullable +Revises: 002_better_auth_tables +Create Date: 2026-03-30 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "003_make_users_hashed_password_nullable" +down_revision = "002_better_auth_tables" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=True) + + +def downgrade() -> None: + op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=False) diff --git a/common/src/cartsnitch_common/models/user.py b/common/src/cartsnitch_common/models/user.py index 5e35e5a..4382a08 100644 --- a/common/src/cartsnitch_common/models/user.py +++ b/common/src/cartsnitch_common/models/user.py @@ -21,7 +21,7 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): __tablename__ = "users" email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) - hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True) display_name: Mapped[str | None] = mapped_column(String(100)) email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") image: Mapped[str | None] = mapped_column(Text, nullable=True) From 4f42247bf2f8e9849d3dfb162cbd9bf142cefe5d Mon Sep 17 00:00:00 2001 From: "cartsnitch-engineer[bot]" <269717931+cartsnitch-engineer[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:20:07 +0000 Subject: [PATCH 3/6] docs: add UAT runbook v1 Merges docs/uat-runbook into main. UAT Runbook v1 authored by Savannah Savings (CTO). QA and CTO approved. Co-Authored-By: Paperclip --- docs/uat-runbook.md | 151 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/uat-runbook.md diff --git a/docs/uat-runbook.md b/docs/uat-runbook.md new file mode 100644 index 0000000..787c5b1 --- /dev/null +++ b/docs/uat-runbook.md @@ -0,0 +1,151 @@ +# CartSnitch UAT Runbook v1 + +**Version:** 1.0 +**Author:** Savannah Savings, CTO +**Date:** 2026-03-30 +**Effective:** Immediately upon Phase 1 completion + +--- + +## 1. Defect Severity Classification + +Every defect discovered during UAT **must** be classified by severity and priority before triage. + +### Severity Levels + +| Severity | Definition | Examples | +|----------|-----------|----------| +| **S1 — Critical** | Blocks all users from completing a core journey. System is down, data is lost, or security is breached. | Login page crashes for all users; purchase data deleted; auth tokens exposed in response | +| **S2 — High** | Blocks a major user flow for a significant portion of users. Core feature is broken but workarounds may exist. | Registration fails for email addresses with `+` character; price alerts never trigger; store comparison shows wrong prices | +| **S3 — Medium** | Feature is degraded but usable. User can complete the journey with friction. | Date formatting shows raw ISO string instead of friendly date; slow page load (>5s) on product detail; search results not sorted correctly | +| **S4 — Low** | Cosmetic issue, minor UI inconsistency, or edge case with minimal user impact. | Button text truncated on narrow screens; extra whitespace in footer; tooltip shows on hover but not on focus | + +### Priority Levels + +Priority determines **when** the defect must be fixed. Priority is set by the CTO based on severity, business impact, and sprint capacity. + +| Priority | SLA | When to Use | +|----------|-----|------------| +| **P0 — Fix Now** | Triage within 1 hour, fix deployed within 4 hours | S1 defects, any security vulnerability, data integrity issues | +| **P1 — Fix This Sprint** | Triage within 4 hours, fix in current sprint | S2 defects blocking upcoming release, S1 defects with viable workaround | +| **P2 — Fix Next Sprint** | Triage within 24 hours, scheduled for next sprint | S3 defects, S2 defects with easy workarounds | +| **P3 — Backlog** | Triage within 48 hours, prioritized against backlog | S4 defects, minor improvements, nice-to-haves | + +### Defect Report Template + +Every defect filed during UAT must include: + +``` +**Title:** [Short description] +**Severity:** S1/S2/S3/S4 +**Priority:** P0/P1/P2/P3 (set by CTO at triage) +**Journey:** [Which user journey — J1 through J10] +**Environment:** [Dev / Prod, deployed image tag] +**Steps to Reproduce:** +1. Navigate to ... +2. Click ... +3. Enter ... +**Expected Result:** ... +**Actual Result:** ... +**Screenshots/Logs:** [Attach or link] +**Browser/Device:** [e.g., Chromium 124, mobile viewport 390x844] +``` + +--- + +## 2. UAT Entry Criteria + +UAT **must not begin** until ALL of the following are satisfied. Checkout Charlie verifies these before opening the UAT gate. + +| # | Criterion | Verified By | +|---|-----------|------------| +| E1 | CI pipeline passes on the merged commit (lint, type-check, unit tests, build) | GitHub Actions (automated) | +| E2 | Docker image is built and pushed to GHCR with a CalVer tag | GitHub Actions (automated) | +| E3 | Dev environment is deployed and accessible at `cartsnitch.dev.farh.net` | Flux reconciliation + health check | +| E4 | All Playwright E2E tests pass in CI | GitHub Actions (automated) | +| E5 | No open S1/S2 defects from previous UAT cycle | Checkout Charlie (manual check) | +| E6 | PR has been reviewed and approved by QA (Checkout Charlie) and CTO (Savannah Savings) | GitHub PR approvals | +| E7 | PR has been merged to main by CEO (Coupon Carl) | GitHub merge event | +| E8 | Acceptance criteria for the feature/change are documented in the Paperclip issue | Checkout Charlie (manual check) | + +**If any entry criterion is not met**, UAT is blocked. Checkout Charlie must comment on the Paperclip issue specifying which criteria failed and assign back to the responsible party. + +--- + +## 3. UAT Exit Criteria + +UAT is **complete** only when ALL of the following are satisfied. Rollback Rhonda verifies these before signing off. + +| # | Criterion | Verified By | +|---|-----------|------------| +| X1 | All 10 critical user journeys (J1-J10) have been executed | Rollback Rhonda (full regression) | +| X2 | Zero open S1 (Critical) defects | Defect tracker | +| X3 | Zero open S2 (High) defects, OR CTO has granted a documented exception | Defect tracker + CTO sign-off | +| X4 | All S3/S4 defects are logged and triaged (not necessarily fixed) | Defect tracker | +| X5 | 100% test execution rate -- every test case was run, none skipped | Rollback Rhonda's UAT report | +| X6 | Accessibility scan (axe-core) reports zero critical violations | Automated in E2E suite | +| X7 | Lighthouse performance score >= 50, accessibility score >= 90 | Lighthouse CI | +| X8 | Written sign-off from Rollback Rhonda confirming all criteria met | Paperclip comment on issue | + +**If any exit criterion is not met**, the release is blocked. Rollback Rhonda must: +1. File defects for all failures using the Defect Report Template above. +2. Comment on the Paperclip issue specifying which exit criteria failed. +3. Assign back to CTO for triage and redistribution. + +--- + +## 4. UAT Execution Procedure + +### 4.1 Pre-UAT (Checkout Charlie) + +1. Verify all entry criteria (E1-E8) are met. +2. Comment on the Paperclip issue: "UAT gate open -- all entry criteria verified." +3. Assign to Rollback Rhonda with status todo. + +### 4.2 UAT Execution (Rollback Rhonda) + +1. **Full regression run** -- execute ALL 10 user journeys against cartsnitch.dev.farh.net. No partial runs. No exceptions. +2. For each journey, verify: + - All interactive elements respond correctly (buttons, forms, links, toggles) + - State transitions are correct (auth state, data mutations, navigation) + - Error states are handled gracefully (invalid input, network failures) + - Accessibility scan passes (axe-core integrated in Playwright) +3. Log results for each journey: PASS / FAIL with details. +4. File defects immediately for any failures. +5. Complete the UAT report with execution results. + +### 4.3 Post-UAT Sign-Off + +1. If all exit criteria (X1-X8) are met: + - Rollback Rhonda posts sign-off comment: "UAT PASSED -- all exit criteria met." + - Production promotion is automated via Flux on UAT pass. +2. If any exit criterion fails: + - Rollback Rhonda posts failure comment with specific failures. + - CTO triages defects and redistributes to engineers. + - After fixes are merged, UAT restarts from 4.1 (full cycle). + +--- + +## 5. Critical User Journeys Reference + +| ID | Journey | Key Interactions | +|----|---------|-----------------| +| J1 | Registration -> Login -> Dashboard | Form submission, auth state, redirect | +| J2 | Login -> Browse Products -> View Detail -> Price Chart | Search, navigation, data visualization | +| J3 | Login -> Purchases -> Purchase Detail -> Product Link | List navigation, detail view, cross-linking | +| J4 | Login -> Connect Store Account -> Verify Connection | OAuth flow, external integration | +| J5 | Login -> Create Price Alert -> View -> Delete Alert | CRUD operations, confirmation dialogs | +| J6 | Login -> Browse Coupons -> Copy Code | Clipboard interaction, toast feedback | +| J7 | Login -> Settings -> Toggle Preferences -> Sign Out | Checkbox toggles, theme switch, session termination | +| J8 | Login -> Store Comparison -> Compare Prices | Data comparison, sorting, price display | +| J9 | Forgot Password Flow | Email input, validation, redirect | +| J10 | Unauth Access -> Redirect to Login | Route protection, redirect behavior | + +--- + +## 6. Revision History + +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 1.0 | 2026-03-30 | Savannah Savings | Initial runbook -- defect taxonomy, entry/exit criteria, execution procedure | + From 14ba9d0b8273ecf61f5c630136b03b4cfb7e6992 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Mon, 30 Mar 2026 20:43:05 +0000 Subject: [PATCH 4/6] feat(ci): add receiptwitness build job to monorepo CI --- .github/workflows/ci.yml | 56 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2f0e55..a23b975 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,7 @@ env: REGISTRY: ghcr.io IMAGE_NAME: cartsnitch/cartsnitch AUTH_IMAGE_NAME: cartsnitch/auth + RECEIPTWITNESS_IMAGE_NAME: cartsnitch/receiptwitness jobs: lint: @@ -161,9 +162,57 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-and-push-receiptwitness: + runs-on: runners-cartsnitch + needs: [lint, test] + outputs: + calver_tag: ${{ steps.calver.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Generate CalVer tag + id: calver + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + DATE_TAG=$(date -u +%Y.%m.%d) + EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1) + if [ -z "$EXISTING" ]; then VERSION="$DATE_TAG" + elif [ "$EXISTING" = "v${DATE_TAG}" ]; then VERSION="${DATE_TAG}.2" + else BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//"); VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"; fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Log in to GHCR + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }} + tags: | + type=sha,prefix=sha- + type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push receiptwitness image + uses: docker/build-push-action@v6 + with: + context: . + file: ./receiptwitness/Dockerfile + push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + deploy-dev: runs-on: runners-cartsnitch - needs: [build-and-push, build-and-push-auth] + needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness] if: github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - name: Generate GitHub App token @@ -189,11 +238,12 @@ jobs: - name: Install kustomize uses: imranismail/setup-kustomize@v2 - - name: Update dev overlay image tag + - name: Update dev overlay image tags run: | cd infra/apps/overlays/dev kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }} kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }} + kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ needs.build-and-push-receiptwitness.outputs.calver_tag }} - name: Commit and push to infra run: | @@ -201,5 +251,5 @@ jobs: git config user.name "cartsnitch-ci[bot]" git config user.email "cartsnitch-ci[bot]@users.noreply.github.com" git add apps/overlays/dev/kustomization.yaml - git commit -m "ci(dev): update cartsnitch and auth images to ${{ needs.build-and-push.outputs.calver_tag }}" + git commit -m "ci(dev): update cartsnitch, auth, and receiptwitness images" git push origin main From e9eb9cf489a3000fd6a09a1615f57d779a07b796 Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Mon, 30 Mar 2026 20:43:05 +0000 Subject: [PATCH 5/6] feat(ci): add receiptwitness build job to monorepo CI --- receiptwitness/Dockerfile | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/receiptwitness/Dockerfile b/receiptwitness/Dockerfile index bb6300d..8fead61 100644 --- a/receiptwitness/Dockerfile +++ b/receiptwitness/Dockerfile @@ -3,24 +3,21 @@ FROM python:3.12-slim AS build WORKDIR /app -# git is required to install cartsnitch-common from GitHub; build-essential and -# libpq-dev are needed to compile any C-extension wheels (e.g. psycopg2 fallback) +# build-essential and libpq-dev are needed to compile any C-extension wheels +# (e.g. psycopg2 fallback). No git needed — common/ is copied from the repo root. RUN apt-get update && apt-get install -y --no-install-recommends \ - git \ libpq-dev \ build-essential \ && rm -rf /var/lib/apt/lists/* -COPY pyproject.toml ./ -COPY src/ ./src/ +# Build context is the repo root. These paths are relative to the root. +COPY receiptwitness/pyproject.toml ./ +COPY receiptwitness/src/ ./src/ +COPY common/ ./common/ -# cartsnitch-common is not on PyPI — install it directly from GitHub, then -# install the rest of the package dependencies in a single resolver pass so -# pip can satisfy the cartsnitch-common>=0.1.0 constraint declared in -# pyproject.toml without hitting PyPI for it. -RUN pip install --no-cache-dir --prefix=/install \ - "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b" \ - . +# Install from the local common/ (cartsnitch-common>=0.1.0 in pyproject.toml +# will be satisfied by the local package) then install receiptwitness itself. +RUN pip install --no-cache-dir --prefix=/install ./common/ . # Stage 2: Production image with Playwright + Chromium FROM python:3.12-slim AS prod @@ -51,7 +48,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN adduser --system --group --uid 1000 app COPY --from=build /install /usr/local -COPY src/ ./src/ +COPY receiptwitness/src/ ./src/ # Install Playwright Chromium browser (runs as root; /opt/playwright is world-readable) RUN PLAYWRIGHT_BROWSERS_PATH=/opt/playwright playwright install chromium From ea8dcad398c1954b2f2396151f7e3088754ac40b Mon Sep 17 00:00:00 2001 From: Barcode Betty Date: Mon, 30 Mar 2026 21:14:43 +0000 Subject: [PATCH 6/6] chore: remove polyrepo CI workflow leftovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete nested .github/workflows/ci.yml files from api/ and receiptwitness/ directories. These workflows were from the polyrepo era and reference the deleted cartsnitch/common repo. They do not execute as GitHub Actions (not at repo root) and are confusing. No functional change — the monorepo CI is defined at .github/workflows/. Co-Authored-By: Paperclip --- api/.github/workflows/ci.yml | 164 ----------------------- receiptwitness/.github/workflows/ci.yml | 168 ------------------------ 2 files changed, 332 deletions(-) delete mode 100644 api/.github/workflows/ci.yml delete mode 100644 receiptwitness/.github/workflows/ci.yml diff --git a/api/.github/workflows/ci.yml b/api/.github/workflows/ci.yml deleted file mode 100644 index 5c61bb7..0000000 --- a/api/.github/workflows/ci.yml +++ /dev/null @@ -1,164 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: write - packages: write - -env: - REGISTRY: ghcr.io - IMAGE_NAME: cartsnitch/api - -jobs: - lint: - runs-on: runners-cartsnitch - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - run: pip install ruff - - name: Ruff lint - run: ruff check . - - name: Ruff format check - run: ruff format --check . - - typecheck: - runs-on: runners-cartsnitch - continue-on-error: true - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y libpq-dev build-essential - - name: Install cartsnitch-common from GitHub - run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git" - - run: pip install -e ".[dev]" mypy - - name: Type check - run: mypy src/cartsnitch_api - - test: - runs-on: runners-cartsnitch - services: - postgres: - image: postgres:15-alpine - credentials: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - env: - POSTGRES_USER: cartsnitch - POSTGRES_PASSWORD: cartsnitch_test - POSTGRES_DB: cartsnitch_test - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - redis: - image: redis:7-alpine - credentials: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - CARTSNITCH_DATABASE_URL: postgresql+asyncpg://cartsnitch:cartsnitch_test@localhost:5432/cartsnitch_test - CARTSNITCH_REDIS_URL: redis://localhost:6379/0 - CARTSNITCH_JWT_SECRET_KEY: test-secret-do-not-use-in-prod - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y libpq-dev build-essential - - name: Install cartsnitch-common from GitHub - run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git" - - run: pip install -e ".[dev]" - - name: Run tests - run: pytest --tb=short -q - - build-and-push: - runs-on: runners-cartsnitch - needs: [lint, test] - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Generate CalVer tag - id: calver - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: | - DATE_TAG=$(date -u +%Y.%m.%d) - EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1) - if [ -z "$EXISTING" ]; then - VERSION="$DATE_TAG" - elif [ "$EXISTING" = "v${DATE_TAG}" ]; then - VERSION="${DATE_TAG}.2" - else - BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//") - VERSION="${DATE_TAG}.$((BUILD_NUM + 1))" - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "CalVer tag: $VERSION" - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Log in to GHCR - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=sha,prefix=sha- - type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - target: prod - - - name: Create git tag - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: | - git tag "v${{ steps.calver.outputs.version }}" - git push origin "v${{ steps.calver.outputs.version }}" \ No newline at end of file diff --git a/receiptwitness/.github/workflows/ci.yml b/receiptwitness/.github/workflows/ci.yml deleted file mode 100644 index 785af69..0000000 --- a/receiptwitness/.github/workflows/ci.yml +++ /dev/null @@ -1,168 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: write - packages: write - -env: - REGISTRY: ghcr.io - IMAGE_NAME: cartsnitch/receiptwitness - -jobs: - lint: - runs-on: runners-cartsnitch - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - name: Install cartsnitch-common from GitHub - run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b" - - run: pip install ruff - - name: Ruff lint - run: ruff check . - - name: Ruff format check - run: ruff format --check . - - typecheck: - runs-on: runners-cartsnitch - continue-on-error: true - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - name: Install cartsnitch-common from GitHub - run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b" - - run: pip install -e ".[dev]" mypy - - name: Type check - run: mypy src/receiptwitness - - test: - runs-on: runners-cartsnitch - services: - postgres: - image: postgres:15-alpine - credentials: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - env: - POSTGRES_USER: cartsnitch - POSTGRES_PASSWORD: cartsnitch_test - POSTGRES_DB: cartsnitch_test - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - redis: - image: redis:7-alpine - credentials: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - DATABASE_URL: postgresql://cartsnitch:cartsnitch_test@localhost:5432/cartsnitch_test - REDIS_URL: redis://localhost:6379/0 - ENCRYPTION_KEY: dGVzdC1lbmNyeXB0aW9uLWtleS0xMjM0NTY3ODk= - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - name: Install cartsnitch-common from GitHub - run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b" - - run: pip install -e ".[dev]" - - name: Install Playwright browsers - run: playwright install chromium --with-deps - - name: Run tests - run: pytest --tb=short -q - - build-and-push: - runs-on: runners-cartsnitch - needs: [lint, test] - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Generate CalVer tag - id: calver - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: | - DATE_TAG=$(date -u +%Y.%m.%d) - EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1) - if [ -z "$EXISTING" ]; then - VERSION="$DATE_TAG" - elif [ "$EXISTING" = "v${DATE_TAG}" ]; then - VERSION="${DATE_TAG}.2" - else - BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//") - VERSION="${DATE_TAG}.$((BUILD_NUM + 1))" - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "CalVer tag: $VERSION" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=sha,prefix=sha- - type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }} - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - target: prod - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Create git tag - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: | - git tag "v${{ steps.calver.outputs.version }}" - git push origin "v${{ steps.calver.outputs.version }}"