Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/migrate-staging.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Apply pending migrations to STAGING when they land on main.
#
# Why this exists: nothing else applies them. The backend image's CMD is a bare
# uvicorn, there is no Procfile/release step, main.py's lifespan does not
# migrate, and the Supabase GitHub integration reads `supabase/migrations/`
# (the CLI convention) which this repo does not use — its migrations are raw
# DDL under backend/db/migrations/ applied by db/migrate.py against a
# `schema_migrations` ledger. So a merge shipped code whose schema had not
# moved, and someone had to remember to run the migration by hand.
#
# STAGING ONLY, deliberately. `main` deploys the staging environment; prod is a
# separate `production` branch promotion, and auto-applying irreversible DDL to
# prod on merge is a different risk decision. This runner has no down
# migrations.
name: Migrate (staging)

on:
push:
branches: [main]
paths:
- "backend/db/migrations/**"
workflow_dispatch:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

concurrency:
# Never let two runs apply DDL to the same database at once.
group: migrate-staging
cancel-in-progress: false

jobs:
migrate:
runs-on: ubuntu-latest
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add timeouts to bound a hung run against an unreachable staging database.

Neither the job nor the psycopg.connect() call at Line 56 sets a timeout. GitHub Actions defaults to a 360-minute job timeout when timeout-minutes is unset. Because cancel-in-progress: false (Line 27) serializes runs in the migrate-staging group, a hung connection (e.g., staging DB unreachable or blocked by a lock) can occupy the concurrency slot for up to 6 hours, delaying every subsequent migration run queued behind it.

Set an explicit timeout-minutes on the job and a connect_timeout on the psycopg connection to fail fast instead of hanging.

⏱️ Proposed fix to bound execution time
 jobs:
migrate:
+ timeout-minutes: 15
runs-on: ubuntu-latest
- with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:+ with psycopg.connect(os.environ["SUPABASE_DB_URL"], connect_timeout=10) as c:

Also applies to: 56-56

🧰 Tools
🪛 zizmor (1.28.0)

[warning] 30-87: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block

(excessive-permissions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/migrate-staging.yml around lines 30 - 31, Update the
migrate job to set an explicit timeout-minutes value, and update the
psycopg.connect call to include a connect_timeout value. Keep the existing
migration flow and concurrency settings unchanged while ensuring both the
overall job and database connection fail within bounded periods.

# workflow_dispatch lets you pick ANY branch containing this file, so
# without this a migration could be applied to shared staging straight from
# an unmerged branch — bypassing review. Worse than the bypass: the
# filename lands in the ledger, so if the file is then edited before merge
# (easy, since it was only "tested"), the merge never re-applies it and
# staging silently diverges from the canonical file with no pending/orphan
# signal to catch it. That is exactly the immutability rule CLAUDE.md
# states — migrations are immutable once applied.
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install the runner's only dependency
# Range matches backend/requirements.txt so this can't drift onto a
# psycopg major the app has never run against.
run: pip install "psycopg[binary]>=3.2,<4"

- name: Preflight — report ledger drift instead of pushing through it
id: preflight
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
if [ -z "${SUPABASE_DB_URL}" ]; then
echo "::notice::STAGING_SUPABASE_DB_URL is not set — skipping. Add the secret (the DIRECT connection string, port 5432, not the pooler) to enable."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
cd backend
python - <<'PY'
import os, sys, pathlib, psycopg
files = sorted(p.name for p in pathlib.Path("db/migrations").glob("*.sql"))
with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:
exists = c.execute(
"SELECT to_regclass('public.schema_migrations') IS NOT NULL"
).fetchone()[0]
if not exists:
print("::error::schema_migrations does not exist on this database. "
"Applying now would treat all migrations as pending and fail "
"recreating existing objects. Reconcile with `python -m db.migrate "
"--baseline` against a verified-current schema first (issue #317).")
sys.exit(1)
recorded = {r[0] for r in c.execute("SELECT filename FROM schema_migrations").fetchall()}
pending = [f for f in files if f not in recorded]
orphans = sorted(recorded - set(files))
print(f"on disk: {len(files)} | recorded: {len(recorded)} | pending: {len(pending)}")
for p in pending:
print(f" pending: {p}")
if orphans:
# Recorded-but-absent means the ledger and the repo disagree about
# history — the #317 shape. Applying more on top compounds it.
for o in orphans:
print(f"::error::recorded but not in repo: {o}")
sys.exit(1)
PY

- name: Apply
if: steps.preflight.outputs.skip != 'true'
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
cd backend
python -m db.migrate
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/migrate-staging.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Apply pending migrations to STAGING when they land on main.
#
# Why this exists: nothing else applies them. The backend image's CMD is a bare
# uvicorn, there is no Procfile/release step, main.py's lifespan does not
# migrate, and the Supabase GitHub integration reads `supabase/migrations/`
# (the CLI convention) which this repo does not use — its migrations are raw
# DDL under backend/db/migrations/ applied by db/migrate.py against a
# `schema_migrations` ledger. So a merge shipped code whose schema had not
# moved, and someone had to remember to run the migration by hand.
#
# STAGING ONLY, deliberately. `main` deploys the staging environment; prod is a
# separate `production` branch promotion, and auto-applying irreversible DDL to
# prod on merge is a different risk decision. This runner has no down
# migrations.
name: Migrate (staging)

on:
push:
branches: [main]
paths:
- "backend/db/migrations/**"
workflow_dispatch:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

concurrency:
# Never let two runs apply DDL to the same database at once.
group: migrate-staging
cancel-in-progress: false

jobs:
migrate:
runs-on: ubuntu-latest
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add timeouts to bound a hung run against an unreachable staging database.

Neither the job nor the psycopg.connect() call at Line 56 sets a timeout. GitHub Actions defaults to a 360-minute job timeout when timeout-minutes is unset. Because cancel-in-progress: false (Line 27) serializes runs in the migrate-staging group, a hung connection (e.g., staging DB unreachable or blocked by a lock) can occupy the concurrency slot for up to 6 hours, delaying every subsequent migration run queued behind it.

Set an explicit timeout-minutes on the job and a connect_timeout on the psycopg connection to fail fast instead of hanging.

⏱️ Proposed fix to bound execution time
 jobs:
migrate:
+ timeout-minutes: 15
runs-on: ubuntu-latest
- with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:+ with psycopg.connect(os.environ["SUPABASE_DB_URL"], connect_timeout=10) as c:

Also applies to: 56-56

🧰 Tools
🪛 zizmor (1.28.0)

[warning] 30-87: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block

(excessive-permissions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/migrate-staging.yml around lines 30 - 31, Update the
migrate job to set an explicit timeout-minutes value, and update the
psycopg.connect call to include a connect_timeout value. Keep the existing
migration flow and concurrency settings unchanged while ensuring both the
overall job and database connection fail within bounded periods.

# workflow_dispatch lets you pick ANY branch containing this file, so
# without this a migration could be applied to shared staging straight from
# an unmerged branch — bypassing review. Worse than the bypass: the
# filename lands in the ledger, so if the file is then edited before merge
# (easy, since it was only "tested"), the merge never re-applies it and
# staging silently diverges from the canonical file with no pending/orphan
# signal to catch it. That is exactly the immutability rule CLAUDE.md
# states — migrations are immutable once applied.
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install the runner's only dependency
# Range matches backend/requirements.txt so this can't drift onto a
# psycopg major the app has never run against.
run: pip install "psycopg[binary]>=3.2,<4"

- name: Preflight — report ledger drift instead of pushing through it
id: preflight
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
if [ -z "${SUPABASE_DB_URL}" ]; then
echo "::notice::STAGING_SUPABASE_DB_URL is not set — skipping. Add the secret (the DIRECT connection string, port 5432, not the pooler) to enable."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
cd backend
python - <<'PY'
import os, sys, pathlib, psycopg
files = sorted(p.name for p in pathlib.Path("db/migrations").glob("*.sql"))
with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:
exists = c.execute(
"SELECT to_regclass('public.schema_migrations') IS NOT NULL"
).fetchone()[0]
if not exists:
print("::error::schema_migrations does not exist on this database. "
"Applying now would treat all migrations as pending and fail "
"recreating existing objects. Reconcile with `python -m db.migrate "
"--baseline` against a verified-current schema first (issue #317).")
sys.exit(1)
recorded = {r[0] for r in c.execute("SELECT filename FROM schema_migrations").fetchall()}
pending = [f for f in files if f not in recorded]
orphans = sorted(recorded - set(files))
print(f"on disk: {len(files)} | recorded: {len(recorded)} | pending: {len(pending)}")
for p in pending:
print(f" pending: {p}")
if orphans:
# Recorded-but-absent means the ledger and the repo disagree about
# history — the #317 shape. Applying more on top compounds it.
for o in orphans:
print(f"::error::recorded but not in repo: {o}")
sys.exit(1)
PY

- name: Apply
if: steps.preflight.outputs.skip != 'true'
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
cd backend
python -m db.migrate
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/migrate-staging.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Apply pending migrations to STAGING when they land on main.
#
# Why this exists: nothing else applies them. The backend image's CMD is a bare
# uvicorn, there is no Procfile/release step, main.py's lifespan does not
# migrate, and the Supabase GitHub integration reads `supabase/migrations/`
# (the CLI convention) which this repo does not use — its migrations are raw
# DDL under backend/db/migrations/ applied by db/migrate.py against a
# `schema_migrations` ledger. So a merge shipped code whose schema had not
# moved, and someone had to remember to run the migration by hand.
#
# STAGING ONLY, deliberately. `main` deploys the staging environment; prod is a
# separate `production` branch promotion, and auto-applying irreversible DDL to
# prod on merge is a different risk decision. This runner has no down
# migrations.
name: Migrate (staging)

on:
push:
branches: [main]
paths:
- "backend/db/migrations/**"
workflow_dispatch:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

concurrency:
# Never let two runs apply DDL to the same database at once.
group: migrate-staging
cancel-in-progress: false

jobs:
migrate:
runs-on: ubuntu-latest
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add timeouts to bound a hung run against an unreachable staging database.

Neither the job nor the psycopg.connect() call at Line 56 sets a timeout. GitHub Actions defaults to a 360-minute job timeout when timeout-minutes is unset. Because cancel-in-progress: false (Line 27) serializes runs in the migrate-staging group, a hung connection (e.g., staging DB unreachable or blocked by a lock) can occupy the concurrency slot for up to 6 hours, delaying every subsequent migration run queued behind it.

Set an explicit timeout-minutes on the job and a connect_timeout on the psycopg connection to fail fast instead of hanging.

⏱️ Proposed fix to bound execution time
 jobs:
migrate:
+ timeout-minutes: 15
runs-on: ubuntu-latest
- with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:+ with psycopg.connect(os.environ["SUPABASE_DB_URL"], connect_timeout=10) as c:

Also applies to: 56-56

🧰 Tools
🪛 zizmor (1.28.0)

[warning] 30-87: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block

(excessive-permissions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/migrate-staging.yml around lines 30 - 31, Update the
migrate job to set an explicit timeout-minutes value, and update the
psycopg.connect call to include a connect_timeout value. Keep the existing
migration flow and concurrency settings unchanged while ensuring both the
overall job and database connection fail within bounded periods.

# workflow_dispatch lets you pick ANY branch containing this file, so
# without this a migration could be applied to shared staging straight from
# an unmerged branch — bypassing review. Worse than the bypass: the
# filename lands in the ledger, so if the file is then edited before merge
# (easy, since it was only "tested"), the merge never re-applies it and
# staging silently diverges from the canonical file with no pending/orphan
# signal to catch it. That is exactly the immutability rule CLAUDE.md
# states — migrations are immutable once applied.
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install the runner's only dependency
# Range matches backend/requirements.txt so this can't drift onto a
# psycopg major the app has never run against.
run: pip install "psycopg[binary]>=3.2,<4"

- name: Preflight — report ledger drift instead of pushing through it
id: preflight
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
if [ -z "${SUPABASE_DB_URL}" ]; then
echo "::notice::STAGING_SUPABASE_DB_URL is not set — skipping. Add the secret (the DIRECT connection string, port 5432, not the pooler) to enable."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
cd backend
python - <<'PY'
import os, sys, pathlib, psycopg
files = sorted(p.name for p in pathlib.Path("db/migrations").glob("*.sql"))
with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:
exists = c.execute(
"SELECT to_regclass('public.schema_migrations') IS NOT NULL"
).fetchone()[0]
if not exists:
print("::error::schema_migrations does not exist on this database. "
"Applying now would treat all migrations as pending and fail "
"recreating existing objects. Reconcile with `python -m db.migrate "
"--baseline` against a verified-current schema first (issue #317).")
sys.exit(1)
recorded = {r[0] for r in c.execute("SELECT filename FROM schema_migrations").fetchall()}
pending = [f for f in files if f not in recorded]
orphans = sorted(recorded - set(files))
print(f"on disk: {len(files)} | recorded: {len(recorded)} | pending: {len(pending)}")
for p in pending:
print(f" pending: {p}")
if orphans:
# Recorded-but-absent means the ledger and the repo disagree about
# history — the #317 shape. Applying more on top compounds it.
for o in orphans:
print(f"::error::recorded but not in repo: {o}")
sys.exit(1)
PY

- name: Apply
if: steps.preflight.outputs.skip != 'true'
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
cd backend
python -m db.migrate
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/migrate-staging.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Apply pending migrations to STAGING when they land on main.
#
# Why this exists: nothing else applies them. The backend image's CMD is a bare
# uvicorn, there is no Procfile/release step, main.py's lifespan does not
# migrate, and the Supabase GitHub integration reads `supabase/migrations/`
# (the CLI convention) which this repo does not use — its migrations are raw
# DDL under backend/db/migrations/ applied by db/migrate.py against a
# `schema_migrations` ledger. So a merge shipped code whose schema had not
# moved, and someone had to remember to run the migration by hand.
#
# STAGING ONLY, deliberately. `main` deploys the staging environment; prod is a
# separate `production` branch promotion, and auto-applying irreversible DDL to
# prod on merge is a different risk decision. This runner has no down
# migrations.
name: Migrate (staging)

on:
push:
branches: [main]
paths:
- "backend/db/migrations/**"
workflow_dispatch:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

concurrency:
# Never let two runs apply DDL to the same database at once.
group: migrate-staging
cancel-in-progress: false

jobs:
migrate:
runs-on: ubuntu-latest
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add timeouts to bound a hung run against an unreachable staging database.

Neither the job nor the psycopg.connect() call at Line 56 sets a timeout. GitHub Actions defaults to a 360-minute job timeout when timeout-minutes is unset. Because cancel-in-progress: false (Line 27) serializes runs in the migrate-staging group, a hung connection (e.g., staging DB unreachable or blocked by a lock) can occupy the concurrency slot for up to 6 hours, delaying every subsequent migration run queued behind it.

Set an explicit timeout-minutes on the job and a connect_timeout on the psycopg connection to fail fast instead of hanging.

⏱️ Proposed fix to bound execution time
 jobs:
migrate:
+ timeout-minutes: 15
runs-on: ubuntu-latest
- with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:+ with psycopg.connect(os.environ["SUPABASE_DB_URL"], connect_timeout=10) as c:

Also applies to: 56-56

🧰 Tools
🪛 zizmor (1.28.0)

[warning] 30-87: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block

(excessive-permissions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/migrate-staging.yml around lines 30 - 31, Update the
migrate job to set an explicit timeout-minutes value, and update the
psycopg.connect call to include a connect_timeout value. Keep the existing
migration flow and concurrency settings unchanged while ensuring both the
overall job and database connection fail within bounded periods.

# workflow_dispatch lets you pick ANY branch containing this file, so
# without this a migration could be applied to shared staging straight from
# an unmerged branch — bypassing review. Worse than the bypass: the
# filename lands in the ledger, so if the file is then edited before merge
# (easy, since it was only "tested"), the merge never re-applies it and
# staging silently diverges from the canonical file with no pending/orphan
# signal to catch it. That is exactly the immutability rule CLAUDE.md
# states — migrations are immutable once applied.
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install the runner's only dependency
# Range matches backend/requirements.txt so this can't drift onto a
# psycopg major the app has never run against.
run: pip install "psycopg[binary]>=3.2,<4"

- name: Preflight — report ledger drift instead of pushing through it
id: preflight
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
if [ -z "${SUPABASE_DB_URL}" ]; then
echo "::notice::STAGING_SUPABASE_DB_URL is not set — skipping. Add the secret (the DIRECT connection string, port 5432, not the pooler) to enable."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
cd backend
python - <<'PY'
import os, sys, pathlib, psycopg
files = sorted(p.name for p in pathlib.Path("db/migrations").glob("*.sql"))
with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:
exists = c.execute(
"SELECT to_regclass('public.schema_migrations') IS NOT NULL"
).fetchone()[0]
if not exists:
print("::error::schema_migrations does not exist on this database. "
"Applying now would treat all migrations as pending and fail "
"recreating existing objects. Reconcile with `python -m db.migrate "
"--baseline` against a verified-current schema first (issue #317).")
sys.exit(1)
recorded = {r[0] for r in c.execute("SELECT filename FROM schema_migrations").fetchall()}
pending = [f for f in files if f not in recorded]
orphans = sorted(recorded - set(files))
print(f"on disk: {len(files)} | recorded: {len(recorded)} | pending: {len(pending)}")
for p in pending:
print(f" pending: {p}")
if orphans:
# Recorded-but-absent means the ledger and the repo disagree about
# history — the #317 shape. Applying more on top compounds it.
for o in orphans:
print(f"::error::recorded but not in repo: {o}")
sys.exit(1)
PY

- name: Apply
if: steps.preflight.outputs.skip != 'true'
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
cd backend
python -m db.migrate
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/migrate-staging.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Apply pending migrations to STAGING when they land on main.
#
# Why this exists: nothing else applies them. The backend image's CMD is a bare
# uvicorn, there is no Procfile/release step, main.py's lifespan does not
# migrate, and the Supabase GitHub integration reads `supabase/migrations/`
# (the CLI convention) which this repo does not use — its migrations are raw
# DDL under backend/db/migrations/ applied by db/migrate.py against a
# `schema_migrations` ledger. So a merge shipped code whose schema had not
# moved, and someone had to remember to run the migration by hand.
#
# STAGING ONLY, deliberately. `main` deploys the staging environment; prod is a
# separate `production` branch promotion, and auto-applying irreversible DDL to
# prod on merge is a different risk decision. This runner has no down
# migrations.
name: Migrate (staging)

on:
push:
branches: [main]
paths:
- "backend/db/migrations/**"
workflow_dispatch:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

concurrency:
# Never let two runs apply DDL to the same database at once.
group: migrate-staging
cancel-in-progress: false

jobs:
migrate:
runs-on: ubuntu-latest
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add timeouts to bound a hung run against an unreachable staging database.

Neither the job nor the psycopg.connect() call at Line 56 sets a timeout. GitHub Actions defaults to a 360-minute job timeout when timeout-minutes is unset. Because cancel-in-progress: false (Line 27) serializes runs in the migrate-staging group, a hung connection (e.g., staging DB unreachable or blocked by a lock) can occupy the concurrency slot for up to 6 hours, delaying every subsequent migration run queued behind it.

Set an explicit timeout-minutes on the job and a connect_timeout on the psycopg connection to fail fast instead of hanging.

⏱️ Proposed fix to bound execution time
 jobs:
migrate:
+ timeout-minutes: 15
runs-on: ubuntu-latest
- with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:+ with psycopg.connect(os.environ["SUPABASE_DB_URL"], connect_timeout=10) as c:

Also applies to: 56-56

🧰 Tools
🪛 zizmor (1.28.0)

[warning] 30-87: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block

(excessive-permissions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/migrate-staging.yml around lines 30 - 31, Update the
migrate job to set an explicit timeout-minutes value, and update the
psycopg.connect call to include a connect_timeout value. Keep the existing
migration flow and concurrency settings unchanged while ensuring both the
overall job and database connection fail within bounded periods.

# workflow_dispatch lets you pick ANY branch containing this file, so
# without this a migration could be applied to shared staging straight from
# an unmerged branch — bypassing review. Worse than the bypass: the
# filename lands in the ledger, so if the file is then edited before merge
# (easy, since it was only "tested"), the merge never re-applies it and
# staging silently diverges from the canonical file with no pending/orphan
# signal to catch it. That is exactly the immutability rule CLAUDE.md
# states — migrations are immutable once applied.
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install the runner's only dependency
# Range matches backend/requirements.txt so this can't drift onto a
# psycopg major the app has never run against.
run: pip install "psycopg[binary]>=3.2,<4"

- name: Preflight — report ledger drift instead of pushing through it
id: preflight
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
if [ -z "${SUPABASE_DB_URL}" ]; then
echo "::notice::STAGING_SUPABASE_DB_URL is not set — skipping. Add the secret (the DIRECT connection string, port 5432, not the pooler) to enable."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
cd backend
python - <<'PY'
import os, sys, pathlib, psycopg
files = sorted(p.name for p in pathlib.Path("db/migrations").glob("*.sql"))
with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:
exists = c.execute(
"SELECT to_regclass('public.schema_migrations') IS NOT NULL"
).fetchone()[0]
if not exists:
print("::error::schema_migrations does not exist on this database. "
"Applying now would treat all migrations as pending and fail "
"recreating existing objects. Reconcile with `python -m db.migrate "
"--baseline` against a verified-current schema first (issue #317).")
sys.exit(1)
recorded = {r[0] for r in c.execute("SELECT filename FROM schema_migrations").fetchall()}
pending = [f for f in files if f not in recorded]
orphans = sorted(recorded - set(files))
print(f"on disk: {len(files)} | recorded: {len(recorded)} | pending: {len(pending)}")
for p in pending:
print(f" pending: {p}")
if orphans:
# Recorded-but-absent means the ledger and the repo disagree about
# history — the #317 shape. Applying more on top compounds it.
for o in orphans:
print(f"::error::recorded but not in repo: {o}")
sys.exit(1)
PY

- name: Apply
if: steps.preflight.outputs.skip != 'true'
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
cd backend
python -m db.migrate
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/migrate-staging.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Apply pending migrations to STAGING when they land on main.
#
# Why this exists: nothing else applies them. The backend image's CMD is a bare
# uvicorn, there is no Procfile/release step, main.py's lifespan does not
# migrate, and the Supabase GitHub integration reads `supabase/migrations/`
# (the CLI convention) which this repo does not use — its migrations are raw
# DDL under backend/db/migrations/ applied by db/migrate.py against a
# `schema_migrations` ledger. So a merge shipped code whose schema had not
# moved, and someone had to remember to run the migration by hand.
#
# STAGING ONLY, deliberately. `main` deploys the staging environment; prod is a
# separate `production` branch promotion, and auto-applying irreversible DDL to
# prod on merge is a different risk decision. This runner has no down
# migrations.
name: Migrate (staging)

on:
push:
branches: [main]
paths:
- "backend/db/migrations/**"
workflow_dispatch:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

concurrency:
# Never let two runs apply DDL to the same database at once.
group: migrate-staging
cancel-in-progress: false

jobs:
migrate:
runs-on: ubuntu-latest
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add timeouts to bound a hung run against an unreachable staging database.

Neither the job nor the psycopg.connect() call at Line 56 sets a timeout. GitHub Actions defaults to a 360-minute job timeout when timeout-minutes is unset. Because cancel-in-progress: false (Line 27) serializes runs in the migrate-staging group, a hung connection (e.g., staging DB unreachable or blocked by a lock) can occupy the concurrency slot for up to 6 hours, delaying every subsequent migration run queued behind it.

Set an explicit timeout-minutes on the job and a connect_timeout on the psycopg connection to fail fast instead of hanging.

⏱️ Proposed fix to bound execution time
 jobs:
migrate:
+ timeout-minutes: 15
runs-on: ubuntu-latest
- with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:+ with psycopg.connect(os.environ["SUPABASE_DB_URL"], connect_timeout=10) as c:

Also applies to: 56-56

🧰 Tools
🪛 zizmor (1.28.0)

[warning] 30-87: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block

(excessive-permissions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/migrate-staging.yml around lines 30 - 31, Update the
migrate job to set an explicit timeout-minutes value, and update the
psycopg.connect call to include a connect_timeout value. Keep the existing
migration flow and concurrency settings unchanged while ensuring both the
overall job and database connection fail within bounded periods.

# workflow_dispatch lets you pick ANY branch containing this file, so
# without this a migration could be applied to shared staging straight from
# an unmerged branch — bypassing review. Worse than the bypass: the
# filename lands in the ledger, so if the file is then edited before merge
# (easy, since it was only "tested"), the merge never re-applies it and
# staging silently diverges from the canonical file with no pending/orphan
# signal to catch it. That is exactly the immutability rule CLAUDE.md
# states — migrations are immutable once applied.
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install the runner's only dependency
# Range matches backend/requirements.txt so this can't drift onto a
# psycopg major the app has never run against.
run: pip install "psycopg[binary]>=3.2,<4"

- name: Preflight — report ledger drift instead of pushing through it
id: preflight
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
if [ -z "${SUPABASE_DB_URL}" ]; then
echo "::notice::STAGING_SUPABASE_DB_URL is not set — skipping. Add the secret (the DIRECT connection string, port 5432, not the pooler) to enable."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
cd backend
python - <<'PY'
import os, sys, pathlib, psycopg
files = sorted(p.name for p in pathlib.Path("db/migrations").glob("*.sql"))
with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:
exists = c.execute(
"SELECT to_regclass('public.schema_migrations') IS NOT NULL"
).fetchone()[0]
if not exists:
print("::error::schema_migrations does not exist on this database. "
"Applying now would treat all migrations as pending and fail "
"recreating existing objects. Reconcile with `python -m db.migrate "
"--baseline` against a verified-current schema first (issue #317).")
sys.exit(1)
recorded = {r[0] for r in c.execute("SELECT filename FROM schema_migrations").fetchall()}
pending = [f for f in files if f not in recorded]
orphans = sorted(recorded - set(files))
print(f"on disk: {len(files)} | recorded: {len(recorded)} | pending: {len(pending)}")
for p in pending:
print(f" pending: {p}")
if orphans:
# Recorded-but-absent means the ledger and the repo disagree about
# history — the #317 shape. Applying more on top compounds it.
for o in orphans:
print(f"::error::recorded but not in repo: {o}")
sys.exit(1)
PY

- name: Apply
if: steps.preflight.outputs.skip != 'true'
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
cd backend
python -m db.migrate
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/migrate-staging.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Apply pending migrations to STAGING when they land on main.
#
# Why this exists: nothing else applies them. The backend image's CMD is a bare
# uvicorn, there is no Procfile/release step, main.py's lifespan does not
# migrate, and the Supabase GitHub integration reads `supabase/migrations/`
# (the CLI convention) which this repo does not use — its migrations are raw
# DDL under backend/db/migrations/ applied by db/migrate.py against a
# `schema_migrations` ledger. So a merge shipped code whose schema had not
# moved, and someone had to remember to run the migration by hand.
#
# STAGING ONLY, deliberately. `main` deploys the staging environment; prod is a
# separate `production` branch promotion, and auto-applying irreversible DDL to
# prod on merge is a different risk decision. This runner has no down
# migrations.
name: Migrate (staging)

on:
push:
branches: [main]
paths:
- "backend/db/migrations/**"
workflow_dispatch:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

concurrency:
# Never let two runs apply DDL to the same database at once.
group: migrate-staging
cancel-in-progress: false

jobs:
migrate:
runs-on: ubuntu-latest
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add timeouts to bound a hung run against an unreachable staging database.

Neither the job nor the psycopg.connect() call at Line 56 sets a timeout. GitHub Actions defaults to a 360-minute job timeout when timeout-minutes is unset. Because cancel-in-progress: false (Line 27) serializes runs in the migrate-staging group, a hung connection (e.g., staging DB unreachable or blocked by a lock) can occupy the concurrency slot for up to 6 hours, delaying every subsequent migration run queued behind it.

Set an explicit timeout-minutes on the job and a connect_timeout on the psycopg connection to fail fast instead of hanging.

⏱️ Proposed fix to bound execution time
 jobs:
migrate:
+ timeout-minutes: 15
runs-on: ubuntu-latest
- with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:+ with psycopg.connect(os.environ["SUPABASE_DB_URL"], connect_timeout=10) as c:

Also applies to: 56-56

🧰 Tools
🪛 zizmor (1.28.0)

[warning] 30-87: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block

(excessive-permissions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/migrate-staging.yml around lines 30 - 31, Update the
migrate job to set an explicit timeout-minutes value, and update the
psycopg.connect call to include a connect_timeout value. Keep the existing
migration flow and concurrency settings unchanged while ensuring both the
overall job and database connection fail within bounded periods.

# workflow_dispatch lets you pick ANY branch containing this file, so
# without this a migration could be applied to shared staging straight from
# an unmerged branch — bypassing review. Worse than the bypass: the
# filename lands in the ledger, so if the file is then edited before merge
# (easy, since it was only "tested"), the merge never re-applies it and
# staging silently diverges from the canonical file with no pending/orphan
# signal to catch it. That is exactly the immutability rule CLAUDE.md
# states — migrations are immutable once applied.
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install the runner's only dependency
# Range matches backend/requirements.txt so this can't drift onto a
# psycopg major the app has never run against.
run: pip install "psycopg[binary]>=3.2,<4"

- name: Preflight — report ledger drift instead of pushing through it
id: preflight
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
if [ -z "${SUPABASE_DB_URL}" ]; then
echo "::notice::STAGING_SUPABASE_DB_URL is not set — skipping. Add the secret (the DIRECT connection string, port 5432, not the pooler) to enable."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
cd backend
python - <<'PY'
import os, sys, pathlib, psycopg
files = sorted(p.name for p in pathlib.Path("db/migrations").glob("*.sql"))
with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:
exists = c.execute(
"SELECT to_regclass('public.schema_migrations') IS NOT NULL"
).fetchone()[0]
if not exists:
print("::error::schema_migrations does not exist on this database. "
"Applying now would treat all migrations as pending and fail "
"recreating existing objects. Reconcile with `python -m db.migrate "
"--baseline` against a verified-current schema first (issue #317).")
sys.exit(1)
recorded = {r[0] for r in c.execute("SELECT filename FROM schema_migrations").fetchall()}
pending = [f for f in files if f not in recorded]
orphans = sorted(recorded - set(files))
print(f"on disk: {len(files)} | recorded: {len(recorded)} | pending: {len(pending)}")
for p in pending:
print(f" pending: {p}")
if orphans:
# Recorded-but-absent means the ledger and the repo disagree about
# history — the #317 shape. Applying more on top compounds it.
for o in orphans:
print(f"::error::recorded but not in repo: {o}")
sys.exit(1)
PY

- name: Apply
if: steps.preflight.outputs.skip != 'true'
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
cd backend
python -m db.migrate
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions .github/workflows/migrate-staging.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
# Apply pending migrations to STAGING when they land on main.
#
# Why this exists: nothing else applies them. The backend image's CMD is a bare
# uvicorn, there is no Procfile/release step, main.py's lifespan does not
# migrate, and the Supabase GitHub integration reads `supabase/migrations/`
# (the CLI convention) which this repo does not use — its migrations are raw
# DDL under backend/db/migrations/ applied by db/migrate.py against a
# `schema_migrations` ledger. So a merge shipped code whose schema had not
# moved, and someone had to remember to run the migration by hand.
#
# STAGING ONLY, deliberately. `main` deploys the staging environment; prod is a
# separate `production` branch promotion, and auto-applying irreversible DDL to
# prod on merge is a different risk decision. This runner has no down
# migrations.
name: Migrate (staging)

on:
push:
branches: [main]
paths:
- "backend/db/migrations/**"
workflow_dispatch:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

concurrency:
# Never let two runs apply DDL to the same database at once.
group: migrate-staging
cancel-in-progress: false

jobs:
migrate:
runs-on: ubuntu-latest
Comment on lines +30 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add timeouts to bound a hung run against an unreachable staging database.

Neither the job nor the psycopg.connect() call at Line 56 sets a timeout. GitHub Actions defaults to a 360-minute job timeout when timeout-minutes is unset. Because cancel-in-progress: false (Line 27) serializes runs in the migrate-staging group, a hung connection (e.g., staging DB unreachable or blocked by a lock) can occupy the concurrency slot for up to 6 hours, delaying every subsequent migration run queued behind it.

Set an explicit timeout-minutes on the job and a connect_timeout on the psycopg connection to fail fast instead of hanging.

⏱️ Proposed fix to bound execution time
 jobs:
migrate:
+ timeout-minutes: 15
runs-on: ubuntu-latest
- with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:+ with psycopg.connect(os.environ["SUPABASE_DB_URL"], connect_timeout=10) as c:

Also applies to: 56-56

🧰 Tools
🪛 zizmor (1.28.0)

[warning] 30-87: overly broad permissions (excessive-permissions): default permissions used due to no permissions: block

(excessive-permissions)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In @.github/workflows/migrate-staging.yml around lines 30 - 31, Update the
migrate job to set an explicit timeout-minutes value, and update the
psycopg.connect call to include a connect_timeout value. Keep the existing
migration flow and concurrency settings unchanged while ensuring both the
overall job and database connection fail within bounded periods.

# workflow_dispatch lets you pick ANY branch containing this file, so
# without this a migration could be applied to shared staging straight from
# an unmerged branch — bypassing review. Worse than the bypass: the
# filename lands in the ledger, so if the file is then edited before merge
# (easy, since it was only "tested"), the merge never re-applies it and
# staging silently diverges from the canonical file with no pending/orphan
# signal to catch it. That is exactly the immutability rule CLAUDE.md
# states — migrations are immutable once applied.
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install the runner's only dependency
# Range matches backend/requirements.txt so this can't drift onto a
# psycopg major the app has never run against.
run: pip install "psycopg[binary]>=3.2,<4"

- name: Preflight — report ledger drift instead of pushing through it
id: preflight
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
if [ -z "${SUPABASE_DB_URL}" ]; then
echo "::notice::STAGING_SUPABASE_DB_URL is not set — skipping. Add the secret (the DIRECT connection string, port 5432, not the pooler) to enable."
echo "skip=true" >> "$GITHUB_OUTPUT"
exit 0
fi
cd backend
python - <<'PY'
import os, sys, pathlib, psycopg
files = sorted(p.name for p in pathlib.Path("db/migrations").glob("*.sql"))
with psycopg.connect(os.environ["SUPABASE_DB_URL"]) as c:
exists = c.execute(
"SELECT to_regclass('public.schema_migrations') IS NOT NULL"
).fetchone()[0]
if not exists:
print("::error::schema_migrations does not exist on this database. "
"Applying now would treat all migrations as pending and fail "
"recreating existing objects. Reconcile with `python -m db.migrate "
"--baseline` against a verified-current schema first (issue #317).")
sys.exit(1)
recorded = {r[0] for r in c.execute("SELECT filename FROM schema_migrations").fetchall()}
pending = [f for f in files if f not in recorded]
orphans = sorted(recorded - set(files))
print(f"on disk: {len(files)} | recorded: {len(recorded)} | pending: {len(pending)}")
for p in pending:
print(f" pending: {p}")
if orphans:
# Recorded-but-absent means the ledger and the repo disagree about
# history — the #317 shape. Applying more on top compounds it.
for o in orphans:
print(f"::error::recorded but not in repo: {o}")
sys.exit(1)
PY

- name: Apply
if: steps.preflight.outputs.skip != 'true'
env:
SUPABASE_DB_URL: ${{ secrets.STAGING_SUPABASE_DB_URL }}
run: |
cd backend
python -m db.migrate
Loading