From f3d189f798175663797bc563478053ef15b4c8b8 Mon Sep 17 00:00:00 2001 From: RaviTejaKNTS Date: Mon, 2 Nov 2026 04:53:56 +0530 Subject: [PATCH 1/4] Fix self-host app refresh queues, source hosts, and catalog discovery --- .../self-host-apps-discovery-catalogs.yml | 1 + .../self-host-apps-refresh-source-hosts.yml | 1 + .../self-host-apps-refresh-technical.yml | 3 + .../self-host-apps/discover-app-catalogs.mjs | 10 +- scripts/self-host-apps/lib/job-summary.mjs | 14 ++ scripts/self-host-apps/qa-gate.mjs | 5 +- scripts/self-host-apps/refresh-github.mjs | 125 ++++++++++------ .../refresh-install-options.mjs | 2 + .../refresh-primary-technical-facts.mjs | 8 +- .../self-host-apps/refresh-source-host.mjs | 43 ++++-- stack127_docs/self-host-apps-data.md | 15 ++ ...231800_github_project_refresh_attempts.sql | 27 ++++ .../refresh-automations.test.mjs | 139 ++++++++++++++++++ 13 files changed, 332 insertions(+), 61 deletions(-) create mode 100644 scripts/self-host-apps/lib/job-summary.mjs create mode 100644 supabase/migrations/20261101231800_github_project_refresh_attempts.sql create mode 100644 tests/self-host-apps/refresh-automations.test.mjs diff --git a/.github/workflows/self-host-apps-discovery-catalogs.yml b/.github/workflows/self-host-apps-discovery-catalogs.yml index 9e19b9bb..eca95994 100644 --- a/.github/workflows/self-host-apps-discovery-catalogs.yml +++ b/.github/workflows/self-host-apps-discovery-catalogs.yml @@ -37,6 +37,7 @@ jobs: --out scripts/self-host-apps/batches/${{ github.run_id }}-catalog-discovery.json --write - name: Classify exact duplicates and review matches + if: always() run: npm run self-host-apps:dedupe-candidates -- --limit 30000 - name: Upload discovery report if: always() diff --git a/.github/workflows/self-host-apps-refresh-source-hosts.yml b/.github/workflows/self-host-apps-refresh-source-hosts.yml index bc8bf83e..4ff29d0d 100644 --- a/.github/workflows/self-host-apps-refresh-source-hosts.yml +++ b/.github/workflows/self-host-apps-refresh-source-hosts.yml @@ -31,4 +31,5 @@ jobs: - name: Refresh GitLab facts run: npm run self-host-apps:refresh-gitlab -- --published-only --limit 500 --max-age-hours 24 - name: Refresh Codeberg facts + if: always() run: npm run self-host-apps:refresh-codeberg -- --published-only --limit 500 --max-age-hours 24 diff --git a/.github/workflows/self-host-apps-refresh-technical.yml b/.github/workflows/self-host-apps-refresh-technical.yml index 3007a963..aa8b8be7 100644 --- a/.github/workflows/self-host-apps-refresh-technical.yml +++ b/.github/workflows/self-host-apps-refresh-technical.yml @@ -38,8 +38,11 @@ jobs: npm run self-host-apps:refresh-primary-technical-facts -- ${{ (github.event_name == 'schedule' || inputs.refresh_all == true) && '--refresh-all' || '' }} --concurrency 16 + --max-failure-percent 5 --out scripts/self-host-apps/batches/${{ github.run_id }}-technical-refresh.json - name: Inspect install destinations + # Keep dry-run: the builder infers registry links/support levels and replaces + # stored options; these changes require review before applying. if: always() run: npm run self-host-apps:refresh-install-options -- --limit 5000 - name: Run publication quality gate diff --git a/scripts/self-host-apps/discover-app-catalogs.mjs b/scripts/self-host-apps/discover-app-catalogs.mjs index 95eba9f0..ee3cf1c0 100644 --- a/scripts/self-host-apps/discover-app-catalogs.mjs +++ b/scripts/self-host-apps/discover-app-catalogs.mjs @@ -12,6 +12,7 @@ import { parseYunoHostAppsToml, } from "./lib/catalog-adapters.mjs"; import { withClient } from "./lib/db.mjs"; +import { pathToFileURL } from "node:url"; const catalogs = { truenas: { @@ -27,7 +28,7 @@ const catalogs = { docker_hub: { source: "docker_hub", sourceUrl: "https://hub.docker.com/search", - searchUrl: "https://hub.docker.com/v2/search/repositories/", + searchUrl: "https://registry.hub.docker.com/v2/search/repositories/", queries: ["self-hosted", "selfhosted", "homelab", "home-server", "nas", "media-server", "homeassistant"], }, unraid: { @@ -80,12 +81,13 @@ async function fetchResponse(url, options = {}) { throw lastError ?? new Error(`Failed to fetch ${url}`); } -async function fetchJson(url) { +export async function fetchJson(url) { + const isGithubApi = new URL(url).origin === "https://api.github.com"; const response = await fetchResponse(url, { headers: { Accept: "application/vnd.github+json, application/json", "User-Agent": "stack127-self-host-app-catalog-discovery/1.0", - ...(process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}), + ...(isGithubApi && process.env.GITHUB_TOKEN ? { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` } : {}), }, }); return response.json(); @@ -346,7 +348,7 @@ async function main() { if (Object.keys(report.errors).length > 0) process.exitCode = 1; } -main().catch((error) => { +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((error) => { console.error(error.stack ?? error.message); process.exit(1); }); diff --git a/scripts/self-host-apps/lib/job-summary.mjs b/scripts/self-host-apps/lib/job-summary.mjs new file mode 100644 index 00000000..4f64ed22 --- /dev/null +++ b/scripts/self-host-apps/lib/job-summary.mjs @@ -0,0 +1,14 @@ +import { appendFileSync } from "node:fs"; + +function cell(value) { + return String(value ?? "").replace(/[\r\n]+/g, " ").replace(/&/g, "&").replace(//g, ">").replace(/\|/g, "|"); +} + +export function jobSummary(title, counts, failures = []) { + if (!process.env.GITHUB_STEP_SUMMARY) return; + const lines = [`## ${cell(title)}`, "", ...Object.entries(counts).map(([name, count]) => `- ${cell(name)}: ${cell(count)}`)]; + if (failures.length) { + lines.push("", "| Record | Failure |", "| --- | --- |", ...failures.map((failure) => `| ${cell(failure.repository ?? failure.slug)} | ${cell(failure.error)} |`)); + } + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${lines.join("\n")}\n\n`); +} diff --git a/scripts/self-host-apps/qa-gate.mjs b/scripts/self-host-apps/qa-gate.mjs index b9343c63..22d64e0d 100644 --- a/scripts/self-host-apps/qa-gate.mjs +++ b/scripts/self-host-apps/qa-gate.mjs @@ -1,11 +1,13 @@ #!/usr/bin/env node import { writeFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; import { parseArgs, numberArg } from "./lib/args.mjs"; import { withClient } from "./lib/db.mjs"; import { validateSummary } from "./lib/summary-rules.mjs"; import { isManagedMediaUrl } from "../stack127/media/storage.mjs"; import { hasCompleteResourceEstimate } from "./lib/resource-review.mjs"; +import { jobSummary } from "./lib/job-summary.mjs"; const dockerHighRiskPattern = /(^|\/)(\.github|\.ci|\.devcontainer|dev|vendor|test|tests|testing|ci|e2e(?:[-_][^/]*)?|fixtures?|examples?|docker-compose-examples|test-infrastructure|tests_integration)(\/|$)|devcontainer|dev-container|compose\.dev|docker-compose\.dev|docker-compose[-.]ci|compose[-.]ci|example|gitpod|\.ddev/i; @@ -165,11 +167,12 @@ async function main() { }; if (args.out) { - mkdirSync(path.dirname(args.out), { recursive: true }); + mkdirSync(dirname(args.out), { recursive: true }); writeFileSync(args.out, `${JSON.stringify(report, null, 2)}\n`); } console.log(JSON.stringify(report, null, 2)); + jobSummary("Publication QA", { Published: report.published, Blocked: report.blocked_count, "Excluded published": report.excluded_published_count }); } main().catch((error) => { diff --git a/scripts/self-host-apps/refresh-github.mjs b/scripts/self-host-apps/refresh-github.mjs index 046f454b..17b6ee58 100644 --- a/scripts/self-host-apps/refresh-github.mjs +++ b/scripts/self-host-apps/refresh-github.mjs @@ -5,6 +5,8 @@ import { withClient } from "./lib/db.mjs"; import { execFileSync } from "node:child_process"; import { parseRepo } from "./lib/github.mjs"; import { licenseTypeFromSpdx, sourceModelFromSpdx } from "./lib/schema.mjs"; +import { pathToFileURL } from "node:url"; +import { jobSummary } from "./lib/job-summary.mjs"; const githubGraphqlUrl = "https://api.github.com/graphql"; let cachedGithubToken; @@ -131,31 +133,42 @@ function repositoryQuery(rows) { return `query Stack127SelfHostGitHubRefresh {${fields}}`; } -async function githubGraphql(query) { +export async function githubGraphql(query) { const token = githubToken(); if (!token) throw new Error("GITHUB_TOKEN is required for scheduled GitHub fact refresh."); - const response = await fetch(githubGraphqlUrl, { - method: "POST", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - "User-Agent": "stack127-self-host-apps", - }, - body: JSON.stringify({ query }), - }); - - const payload = await response.json(); - if (!response.ok || payload.errors?.length) { - const message = payload.errors?.map((error) => error.message).join("; ") ?? `GitHub ${response.status}`; - throw new Error(message); - } + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const response = await fetch(githubGraphqlUrl, { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "stack127-self-host-apps", + }, + body: JSON.stringify({ query }), + signal: AbortSignal.timeout(30_000), + }); + + const payload = await response.json(); + if (!response.ok || !payload.data || payload.errors?.some((error) => !error.path?.length)) { + const message = payload.errors?.map((error) => error.message).join("; ") ?? `GitHub ${response.status}`; + const error = new Error(message); + error.retryable = response.status === 429 || response.status >= 500; + throw error; + } - return payload.data; + // GitHub returns successful aliases beside errors for deleted/private repos. + return payload; + } catch (error) { + if (attempt === 2 || error.retryable === false) throw error; + await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt)); + } + } } -async function rowsToRefresh(client, args) { +export async function rowsToRefresh(client, args) { if (args.repo) return [{ slug: args.slug ?? args.repo, source_code_url: args.repo }]; const limit = numberArg(args, "limit", 500); const params = []; @@ -178,11 +191,11 @@ async function rowsToRefresh(client, args) { const result = await client.query( ` - select app.slug, app.source_code_url, app.github_owner, app.github_repo + select app.slug, app.source_code_url, app.github_owner, app.github_repo, app.github_project_id from public.self_host_apps app left join public.github_projects project on project.id = app.github_project_id ${where} - order by project.checked_at nulls first, app.slug + order by greatest(project.last_attempt_at, project.checked_at) nulls first, app.slug ${args.all ? "" : `limit $${params.length + 1}`} `, args.all ? params : [...params, limit], @@ -190,7 +203,7 @@ async function rowsToRefresh(client, args) { return result.rows; } -async function writeRepositoryFacts(client, row, repository, checkedAt, dryRun) { +export async function writeRepositoryFacts(client, row, repository, checkedAt, dryRun) { if (!repository) throw new Error("repository not found or inaccessible"); const owner = String(row.github_owner ?? "").trim().toLowerCase(); @@ -254,11 +267,11 @@ async function writeRepositoryFacts(client, row, repository, checkedAt, dryRun) stars, forks, watchers, open_issues, primary_language, languages, topics, created_at, updated_at, pushed_at, default_branch, repo_size_kb, archived, disabled, is_fork, is_template, latest_release, latest_release_at, - last_commit_at, license_spdx_id, checked_at, source_url + last_commit_at, license_spdx_id, checked_at, source_url, last_attempt_at, consecutive_failures ) values ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14::text[], $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, - $25, $26, $27, $28, $29 + $25, $26, $27, $28, $29, $28, 0 ) on conflict (owner, repo) do update set full_name = excluded.full_name, @@ -287,6 +300,8 @@ async function writeRepositoryFacts(client, row, repository, checkedAt, dryRun) last_commit_at = excluded.last_commit_at, license_spdx_id = excluded.license_spdx_id, checked_at = excluded.checked_at, + last_attempt_at = excluded.last_attempt_at, + consecutive_failures = 0, source_url = excluded.source_url, row_updated_at = now() returning id @@ -348,16 +363,43 @@ async function writeRepositoryFacts(client, row, repository, checkedAt, dryRun) } } +export async function writeFailureAttempt(client, row, attemptedAt, dryRun) { + if (dryRun) return; + const owner = String(row.github_owner ?? "").trim().toLowerCase(); + const repo = String(row.github_repo ?? "").trim().replace(/\.git$/i, "").toLowerCase(); + if (!owner || !repo) { + if (!row.github_project_id) throw new Error(`Cannot record failed attempt for ${row.slug}: missing repository identity`); + await client.query(`update public.github_projects set last_attempt_at = $2, consecutive_failures = consecutive_failures + 1 where id = $1`, [row.github_project_id, attemptedAt]); + return; + } + const result = await client.query(` + insert into public.github_projects (owner, repo, full_name, repository_url, last_attempt_at, consecutive_failures) + values ($1, $2, $1 || '/' || $2, $3, $4, 1) + on conflict (owner, repo) do update set + last_attempt_at = excluded.last_attempt_at, + consecutive_failures = github_projects.consecutive_failures + 1 + returning id + `, [owner, repo, `https://github.com/${owner}/${repo}`, attemptedAt]); + // Even a previously unlinked repository must move to the back of the queue. + // Do not change checked_at or any app fact/verification date on failure. + await client.query(`update public.self_host_apps set github_project_id = $2 where slug = $1 and github_project_id is distinct from $2::uuid`, [row.slug, result.rows[0].id]); +} + async function main() { const args = parseArgs(); - const batchSize = Math.min(numberArg(args, "batch-size", 40), 80); + const batchSize = Math.max(1, Math.min(numberArg(args, "batch-size", 40), 80)); const checkedAt = new Date().toISOString(); await withClient(async (client) => { const rows = await rowsToRefresh(client, args); let refreshed = 0; - let failed = 0; + const failures = []; + async function failed(row, error) { + failures.push({ slug: row.slug, repository: row.source_code_url, error: error.message }); + console.warn(`warning ${row.slug}: ${error.message}`); + await writeFailureAttempt(client, row, checkedAt, Boolean(args["dry-run"])); + } for (let index = 0; index < rows.length; index += batchSize) { const selectedBatch = rows.slice(index, index + batchSize); @@ -370,51 +412,46 @@ async function main() { row.github_repo = repo.repo; batch.push(row); } catch (error) { - failed += 1; - console.warn(`warning ${row.slug}: ${error.message}`); + await failed(row, error); } } if (batch.length === 0) continue; - let data; + let payload; try { - data = await githubGraphql(repositoryQuery(batch)); + payload = await githubGraphql(repositoryQuery(batch)); } catch (error) { console.warn(`warning batch ${index / batchSize + 1}: ${error.message}`); for (const row of batch) { - try { - const singleData = await githubGraphql(repositoryQuery([row])); - await writeRepositoryFacts(client, row, singleData.repo0, checkedAt, Boolean(args["dry-run"])); - refreshed += 1; - console.log(`Refreshed GitHub facts: ${row.slug}`); - } catch (singleError) { - failed += 1; - console.warn(`warning ${row.slug}: ${singleError.message}`); - } + await failed(row, error); } continue; } for (const [batchIndex, row] of batch.entries()) { try { - const repository = data[`repo${batchIndex}`]; + const alias = `repo${batchIndex}`; + const repository = payload.data[alias]; + const errors = payload.errors?.filter((error) => error.path?.[0] === alias); + if (errors?.length) throw new Error(errors.map((error) => error.message).join("; ")); await writeRepositoryFacts(client, row, repository, checkedAt, Boolean(args["dry-run"])); refreshed += 1; console.log(`Refreshed GitHub facts: ${row.slug}`); } catch (error) { - failed += 1; - console.warn(`warning ${row.slug}: ${error.message}`); + await failed(row, error); } } } if (args["dry-run"]) console.log("Dry run OK. No GitHub facts committed."); - console.log(`GitHub refresh complete: ${refreshed} refreshed, ${failed} failed.`); + console.log(`GitHub refresh complete: ${refreshed} refreshed, ${failures.length} failed.`); + jobSummary("GitHub facts", { Selected: rows.length, Refreshed: refreshed, Failed: failures.length, "Dry run": Boolean(args["dry-run"]) }, failures); + if (failures.length) process.exitCode = 1; }, args["database-url"]); } -main().catch((error) => { +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((error) => { console.error(error.message); process.exit(1); }); diff --git a/scripts/self-host-apps/refresh-install-options.mjs b/scripts/self-host-apps/refresh-install-options.mjs index e7a8c0f7..9d7d5559 100644 --- a/scripts/self-host-apps/refresh-install-options.mjs +++ b/scripts/self-host-apps/refresh-install-options.mjs @@ -5,6 +5,7 @@ import { parseArgs, numberArg } from "./lib/args.mjs"; import { withClient } from "./lib/db.mjs"; import { buildInstallOptions } from "./lib/install-options.mjs"; import { installMethods, installOptionsToRow } from "./lib/streamlined-storage.mjs"; +import { jobSummary } from "./lib/job-summary.mjs"; function rowValues(row, options, checkedAt) { const stored = installOptionsToRow(options, { @@ -109,6 +110,7 @@ async function main() { }, args["database-url"]); console.log(JSON.stringify(report, null, 2)); + jobSummary("Install destinations", { Mode: report.mode, Apps: report.apps, "Apps with options": report.apps_with_options, Options: report.options }); } main().catch((error) => { diff --git a/scripts/self-host-apps/refresh-primary-technical-facts.mjs b/scripts/self-host-apps/refresh-primary-technical-facts.mjs index 4f59b1ad..0ad55d63 100644 --- a/scripts/self-host-apps/refresh-primary-technical-facts.mjs +++ b/scripts/self-host-apps/refresh-primary-technical-facts.mjs @@ -5,6 +5,7 @@ import { parseArgs } from "./lib/args.mjs"; import { parseComposeFacts } from "./lib/catalog-facts.mjs"; import { withClient } from "./lib/db.mjs"; import { mapConcurrent } from "./lib/http.mjs"; +import { jobSummary } from "./lib/job-summary.mjs"; import { dockerHubImage, likelyAppImageFromCompose, @@ -157,6 +158,8 @@ async function collect(app) { async function main() { const args = parseArgs(); + const maxFailurePercent = Number(args["max-failure-percent"] ?? 5); + if (!Number.isFinite(maxFailurePercent) || maxFailurePercent < 0 || maxFailurePercent > 100) throw new Error("--max-failure-percent must be between 0 and 100"); if (args["apply-reviewed"] || args.apply) throw new Error("This command collects evidence only; --apply-reviewed and --apply are not supported. Apply source-reviewed field corrections with apply-qa-overrides.mjs."); // --refresh-all intentionally rechecks every published app, including rows with an existing audit status. const refreshAll = Boolean(args["refresh-all"]); @@ -213,6 +216,7 @@ async function main() { mode: "evidence_only", selected: rows.length, passed: passed.length, + failed_count: failed.length, failed, facts: factCounts, requirements: passed @@ -225,7 +229,9 @@ async function main() { }; if (args.out) await writeFile(args.out, `${JSON.stringify(report, null, 2)}\n`); console.log(JSON.stringify(report, null, 2)); - if (failed.length > 0) process.exitCode = 1; + jobSummary("Technical evidence (review required; no facts written)", { Selected: rows.length, Readable: passed.length, Unreadable: failed.length, "Allowed unreadable percentage": maxFailurePercent }, failed); + if (failed.length) console.warn(`Unreadable primary sources: ${failed.length}/${rows.length}; see the report and job summary.`); + if (failed.length && (passed.length === 0 || failed.length / rows.length * 100 > maxFailurePercent)) process.exitCode = 1; } main().catch((error) => { diff --git a/scripts/self-host-apps/refresh-source-host.mjs b/scripts/self-host-apps/refresh-source-host.mjs index 7ce596b9..50f12335 100644 --- a/scripts/self-host-apps/refresh-source-host.mjs +++ b/scripts/self-host-apps/refresh-source-host.mjs @@ -3,15 +3,20 @@ import { parseArgs, numberArg } from "./lib/args.mjs"; import { withClient } from "./lib/db.mjs"; import { licenseTypeFromSpdx } from "./lib/schema.mjs"; +import { pathToFileURL } from "node:url"; +import { jobSummary } from "./lib/job-summary.mjs"; const supportedHosts = new Set(["gitlab", "codeberg"]); function repositoryPath(value, host) { const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password) throw new Error(`Invalid ${host} repository URL`); const parts = url.pathname.split("/").filter(Boolean).map((part) => part.replace(/\.git$/i, "")); if (host === "gitlab") { const separator = parts.indexOf("-"); - return (separator >= 2 ? parts.slice(0, separator) : parts).join("/"); + const repositoryParts = separator >= 2 ? parts.slice(0, separator) : parts; + if (repositoryParts.length < 2) throw new Error("Invalid gitlab repository URL"); + return repositoryParts.join("/"); } if (parts.length < 2) throw new Error(`Invalid ${host} repository URL`); return `${parts[0]}/${parts[1]}`; @@ -20,16 +25,29 @@ function repositoryPath(value, host) { async function fetchJson(url, token) { const headers = { Accept: "application/json", "User-Agent": "stack127-source-refresh/1.0" }; if (token) headers.Authorization = `Bearer ${token}`; - const response = await fetch(url, { headers }); - if (!response.ok) throw new Error(`${response.status}: ${url}`); - return response.json(); + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + // Refuse redirects so credentials cannot cross instance boundaries. + const response = await fetch(url, { headers, redirect: "error", signal: AbortSignal.timeout(20_000) }); + if (!response.ok) { + const error = new Error(`${response.status}: ${url}`); + error.status = response.status; + throw error; + } + return await response.json(); + } catch (error) { + if (attempt === 2 || (error.status && ![408, 429].includes(error.status) && error.status < 500)) throw error; + await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** attempt)); + } + } } async function optionalJson(url, token) { try { return await fetchJson(url, token); - } catch { - return null; + } catch (error) { + if (error.status === 404) return null; + throw error; } } @@ -48,11 +66,12 @@ function normalizedLicense(value) { return license; } -async function gitlabFacts(row) { +export async function gitlabFacts(row) { const path = repositoryPath(row.source_code_url, "gitlab"); const id = encodeURIComponent(path); - const token = process.env.GITLAB_TOKEN ?? ""; - const base = `https://gitlab.com/api/v4/projects/${id}`; + const origin = new URL(row.source_code_url).origin; + const token = origin === "https://gitlab.com" ? process.env.GITLAB_TOKEN ?? "" : ""; + const base = `${origin}/api/v4/projects/${id}`; const [project, languages, release, commits] = await Promise.all([ fetchJson(`${base}?license=true`, token), optionalJson(`${base}/languages`, token), @@ -243,11 +262,13 @@ async function main() { } } - console.log(JSON.stringify({ host, dry_run: Boolean(args["dry-run"]), selected: rows.length, refreshed, failures }, null, 2)); + console.log(JSON.stringify({ host, dry_run: Boolean(args["dry-run"]), selected: rows.length, refreshed, failed: failures.length, failures }, null, 2)); + jobSummary(`${host} facts`, { Selected: rows.length, Refreshed: refreshed, Failed: failures.length, "Dry run": Boolean(args["dry-run"]) }, failures); + if (failures.length) process.exitCode = 1; }, args["database-url"]); } -main().catch((error) => { +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((error) => { console.error(error); process.exit(1); }); diff --git a/stack127_docs/self-host-apps-data.md b/stack127_docs/self-host-apps-data.md index e7e09150..0ce4b73f 100644 --- a/stack127_docs/self-host-apps-data.md +++ b/stack127_docs/self-host-apps-data.md @@ -60,6 +60,13 @@ The `options` JSON list keeps labels, support levels, image references, source l `self_host_apps.github_project_id` and `terminal_apps.github_project_id` are nullable links. Duplicate app rows can point to the same repository, so the repository table isn't unique per app. +`checked_at` is the last successful GitHub fact refresh. `last_attempt_at` records +successful and failed attempts, and `consecutive_failures` resets after success. +The refresh queue orders by the later of the attempt and successful check dates, +so inaccessible repositories move behind older work without appearing fresh. +A failed repository with no project row gets a linked placeholder with a null +`checked_at`. Failed repository URLs appear in the Actions job summary. + Other providers keep using the existing `source_*` columns until a provider table is worth adding. That's a planned transition, not a second source of truth for self-host apps. ## Evidence and QA @@ -68,6 +75,14 @@ Other providers keep using the existing `source_*` columns until a provider tabl They still let QA tell unchecked data apart from an official source that published no value. +The Monday technical refresh collects evidence only. It lists all unreadable +sources in the artifact and job summary, tolerates up to 5% unreadable rows, +and fails if that limit is exceeded or every selected source is unreadable. +Install destinations stay in dry-run mode: the builder infers registry links +and support levels, and applying replaces existing options. Review the proposed +links and support levels before running an explicit apply. Use +`apply-qa-overrides.mjs` for reviewed editorial fact corrections. + ## Reviewed artifact cleanup The cleanup manifest records reviewed helper containers, image variants, and duplicate app rows. Preview it before a change: diff --git a/supabase/migrations/20261101231800_github_project_refresh_attempts.sql b/supabase/migrations/20261101231800_github_project_refresh_attempts.sql new file mode 100644 index 00000000..6f45ba6d --- /dev/null +++ b/supabase/migrations/20261101231800_github_project_refresh_attempts.sql @@ -0,0 +1,27 @@ +begin; + +alter table public.github_projects + add column last_attempt_at timestamptz, + add column consecutive_failures integer not null default 0 + check (consecutive_failures >= 0); + +comment on column public.github_projects.last_attempt_at is + 'Last GitHub fetch attempt, successful or failed; checked_at remains the last successful fact refresh.'; +comment on column public.github_projects.consecutive_failures is + 'Consecutive failed GitHub fetches, reset only after a successful fact refresh.'; + +create index github_projects_refresh_attempt_idx + on public.github_projects (greatest(last_attempt_at, checked_at) nulls first); + +-- Keep provider metadata private. Existing backend RLS policies still apply. +revoke all on public.github_projects from anon, authenticated; +grant select, insert, update on public.github_projects to service_role; +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'stack127_app') then + grant select, insert, update on public.github_projects to stack127_app; + end if; +end; +$$; + +commit; diff --git a/tests/self-host-apps/refresh-automations.test.mjs b/tests/self-host-apps/refresh-automations.test.mjs new file mode 100644 index 00000000..0d79aed6 --- /dev/null +++ b/tests/self-host-apps/refresh-automations.test.mjs @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { gitlabFacts } from "../../scripts/self-host-apps/refresh-source-host.mjs"; +import { githubGraphql, writeFailureAttempt } from "../../scripts/self-host-apps/refresh-github.mjs"; +import { fetchJson } from "../../scripts/self-host-apps/discover-app-catalogs.mjs"; + +function env(t, key, value) { + const previous = process.env[key]; + process.env[key] = value; + t.after(() => { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + }); +} + +// Mock remote APIs only; importing a CLI must not connect to a database. +test("nested GitLab repositories use their own instance without the gitlab.com token", async (t) => { + env(t, "GITLAB_TOKEN", "gitlab-com-test-token"); + const calls = []; + t.mock.method(globalThis, "fetch", async (url, options) => { + calls.push({ url, options }); + if (url.endsWith("?license=true")) return Response.json({ + id: 42, path_with_namespace: "tpo/applications/mullvad-browser", star_count: 12, + license: { spdx_identifier: "MPL-2.0" }, + }); + if (url.endsWith("/languages")) return Response.json({ C: 100 }); + if (url.endsWith("/releases/permalink/latest")) return Response.json({}, { status: 404 }); + return Response.json([{ committed_date: "2026-10-31T00:00:00Z" }]); + }); + const facts = await gitlabFacts({ source_code_url: "https://gitlab.torproject.org/tpo/applications/mullvad-browser.git/-/tree/main" }); + assert.equal(facts.source_full_name, "tpo/applications/mullvad-browser"); + assert.equal(facts.source_stars, 12); + assert.equal(facts.source_latest_release, null); + assert.equal(calls.length, 4); + for (const { url, options } of calls) { + assert.ok(url.startsWith("https://gitlab.torproject.org/api/v4/projects/tpo%2Fapplications%2Fmullvad-browser")); + assert.equal(options.headers.Authorization, undefined); + assert.equal(options.redirect, "error"); + } + calls.length = 0; + await gitlabFacts({ source_code_url: "https://gitlab.com/tpo/applications/mullvad-browser/" }); + assert.ok(calls.every(({ options }) => options.headers.Authorization === "Bearer gitlab-com-test-token")); +}); + +test("catalog requests send a GitHub credential only to the GitHub API", async (t) => { + env(t, "GITHUB_TOKEN", "github-test-token"); + const calls = []; + t.mock.method(globalThis, "fetch", async (url, options) => { + calls.push({ url, options }); + return Response.json({ results: [] }); + }); + await fetchJson("https://registry.hub.docker.com/v2/search/repositories/?query=self-hosted"); + await fetchJson("https://api.github.com/repos/example/app/contents"); + assert.equal(calls[0].options.headers.Authorization, undefined); + assert.equal(calls[1].options.headers.Authorization, "Bearer github-test-token"); +}); + +test("one inaccessible GraphQL repository does not discard successful repositories", async (t) => { + env(t, "GITHUB_TOKEN", "github-test-token"); + const payload = { + data: { repo0: { nameWithOwner: "example/available" }, repo1: null }, + errors: [{ path: ["repo1"], message: "Could not resolve repository example/deleted" }], + }; + const fetch = t.mock.method(globalThis, "fetch", async () => Response.json(payload)); + assert.deepEqual(await githubGraphql("query { repo0: repository(owner: \"example\", name: \"available\") { nameWithOwner } }"), payload); + assert.equal(fetch.mock.callCount(), 1); +}); + +test("a dry-run failure does not write attempt metadata or link an app", async () => { + await writeFailureAttempt({ query() { assert.fail("Dry run attempted a database write"); } }, { + slug: "example", github_owner: "example", github_repo: "deleted", + }, "2026-11-01T00:00:00Z", true); +}); + +async function cliFixture(t) { + const directory = await mkdtemp(join(tmpdir(), "stack127-refresh-test-")); + t.after(() => rm(directory, { recursive: true, force: true })); + const hook = join(directory, "mock-db.mjs"); + await writeFile(hook, ` + import { registerHooks } from 'node:module'; + registerHooks({ load(url, context, nextLoad) { + if (url.endsWith('/scripts/self-host-apps/lib/db.mjs')) return { + format: 'module', shortCircuit: true, + source: 'export async function withClient(callback) { return callback({query: async (sql) => ({ rows: sql.includes("count(*)") ? [{count:0}] : JSON.parse(process.env.TEST_ROWS) })}); }' + }; + return nextLoad(url, context); + }}); + globalThis.fetch = async (url) => String(url).includes('api.github.com/graphql') + ? Response.json({data:{repo0:null},errors:[{path:['repo0'],message:'Repository is inaccessible'}]}) + : String(url).includes('unreadable') + ? new Response('', {status:404}) : new Response('Run this app on your server.'); + `); + return { directory, run(script, rows, args = []) { + return spawnSync(process.execPath, ["--import", pathToFileURL(hook).href, script, ...args], { + encoding: "utf8", env: { ...process.env, TEST_ROWS: JSON.stringify(rows), GITHUB_TOKEN: "github-test-token", GITHUB_STEP_SUMMARY: join(directory, "summary.md") }, + }); + } }; +} + +test("technical evidence keeps useful output with 5% unreadable sources but rejects broader failures", async (t) => { + const { directory, run } = await cliFixture(t); + for (const [failures, selected, status] of [[1, 20, 0], [2, 20, 1], [1, 1, 1]]) { + const rows = Array.from({ length: selected }, (_, i) => ({ + slug: `app-${i}`, name: `App ${i}`, website_url: `https://${i < failures ? "unreadable" : "readable"}.example/${i}`, + })); + const out = join(directory, `evidence-${failures}-${selected}.json`); + const result = run("scripts/self-host-apps/refresh-primary-technical-facts.mjs", rows, ["--out", out]); + assert.equal(result.status, status, result.stderr); + const report = JSON.parse(await readFile(out, "utf8")); + assert.equal(report.selected, selected); + assert.equal(report.passed, selected - failures); + assert.equal(report.failed_count, failures); + assert.equal(report.records.length, selected - failures); + } +}); + +test("publication QA writes its report into a new output directory", async (t) => { + const { directory, run } = await cliFixture(t); + const out = join(directory, "new", "qa.json"); + const result = run("scripts/self-host-apps/qa-gate.mjs", [], ["--out", out]); + assert.equal(result.status, 0, result.stderr); + assert.equal(JSON.parse(await readFile(out, "utf8")).published, 0); +}); + + +test("GitHub dry runs name failed repositories and failure counts in the job summary", async (t) => { + const { directory, run } = await cliFixture(t); + const result = run("scripts/self-host-apps/refresh-github.mjs", [], ["--repo", "example/deleted", "--dry-run"]); + assert.equal(result.status, 1, result.stderr); + const summary = await readFile(join(directory, "summary.md"), "utf8"); + assert.match(summary, /Failed: 1/); + assert.match(summary, /example\/deleted/); + assert.match(summary, /Repository is inaccessible/); +}); From e831ee354d449d32583146df7b8fc2b9d28920a1 Mon Sep 17 00:00:00 2001 From: RaviTejaKNTS Date: Mon, 2 Nov 2026 04:54:01 +0530 Subject: [PATCH 2/4] Correct directory automation cadence and retired job descriptions --- stack127_docs/monitoring.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/stack127_docs/monitoring.json b/stack127_docs/monitoring.json index 57061ef2..d6ae34e1 100644 --- a/stack127_docs/monitoring.json +++ b/stack127_docs/monitoring.json @@ -13,7 +13,7 @@ {"key": "stack127-vps/publisher-names", "group": "Ad networks", "name": "Publisher business names", "about": "Every Wednesday, refreshes publisher and business names from the official seller lists."}, {"key": "stack127-vps/traffic", "group": "Ad networks", "name": "Publisher traffic groups", "about": "On the 5th of each month, loads the latest Chrome traffic data (CrUX) to group publishers by size."}, {"key": "stack127-vps/backup", "group": "Ad networks", "name": "Ad networks data backup (OVH)", "about": "Makes a consistent daily copy of the ad networks database on the Stack127 VPS and keeps 7 copies.", "impact": "Recent ad network data could be lost."}, - {"key": "homelab/stack127-ad-network-backup", "group": "Ad networks", "name": "Ad networks data backup (homelab)", "about": "Backs up the old homelab copy of the ad networks database and keeps 7 copies. Kept until the move to OVH is confirmed."}, + {"key": "homelab/stack127-ad-network-backup", "group": "Ad networks", "name": "Ad networks data backup (homelab)", "about": "Retired after the move to OVH; the homelab backup timer is disabled and does not make backups."}, {"key": "homelab/stack127-ad-network-discovery", "group": "Ad networks", "name": "Find new publishers (old homelab job)", "about": "The old homelab version of publisher discovery. Switched off after the move to OVH."}, {"key": "github/stack127labs/stack127/ad-network-discovery.yml", "group": "Ad networks", "name": "Preview publisher discovery", "about": "A manual dry run that previews new publisher domains without saving anything."}, {"key": "github/stack127labs/stack127/ad-network-verification.yml", "group": "Ad networks", "name": "Preview publisher checks", "about": "A manual dry run that previews ad network checks without saving anything."}, @@ -22,13 +22,13 @@ {"key": "github/stack127labs/stack127/self-host-apps-discovery-directories.yml", "group": "Self-host apps", "name": "Find apps in directories", "about": "Once a month, looks for self-hostable apps in public directories."}, {"key": "github/stack127labs/stack127/self-host-apps-discovery-github.yml", "group": "Self-host apps", "name": "Find apps on GitHub", "about": "Every day, searches GitHub for new self-hostable apps."}, {"key": "github/stack127labs/stack127/self-host-apps-discovery-lists.yml", "group": "Self-host apps", "name": "Find apps in curated lists", "about": "Every Sunday, reads maintained awesome-lists for new self-hostable apps."}, - {"key": "github/stack127labs/stack127/self-host-apps-refresh-github.yml", "group": "Self-host apps", "name": "Refresh GitHub facts", "about": "Twice a day, refreshes stars, releases and activity for apps hosted on GitHub."}, - {"key": "github/stack127labs/stack127/self-host-apps-refresh-source-hosts.yml", "group": "Self-host apps", "name": "Refresh GitLab and Codeberg facts", "about": "Every day, refreshes facts for apps hosted on GitLab and Codeberg."}, - {"key": "github/stack127labs/stack127/self-host-apps-refresh-technical.yml", "group": "Self-host apps", "name": "Collect technical details", "about": "Every Monday, collects install methods, languages and other technical details for each app."}, + {"key": "github/stack127labs/stack127/self-host-apps-refresh-github.yml", "group": "Self-host apps", "name": "Refresh GitHub facts", "about": "Twice a day, attempts up to 500 GitHub app refreshes (about a 5-day full cycle), keeps failed attempts separate from successful check dates, and lists failures in the job summary."}, + {"key": "github/stack127labs/stack127/self-host-apps-refresh-source-hosts.yml", "group": "Self-host apps", "name": "Refresh GitLab and Codeberg facts", "about": "Every day, refreshes facts from each app’s own GitLab server and from Codeberg, and lists partial failures in the job summary while marking the job failed."}, + {"key": "github/stack127labs/stack127/self-host-apps-refresh-technical.yml", "group": "Self-host apps", "name": "Collect technical details", "about": "Every Monday, collects technical evidence for review, previews install destinations without saving, and runs publication QA; reports unreadable sources and fails above 5% (or if none are readable), without writing editorial facts."}, {"key": "stack127-vps/stack127-cache-worker", "group": "Site cache", "name": "Page cache worker", "about": "Every 30 seconds on the OVH server, checks for changed pages, refreshes ad-network heading counts when their data changes, and starts a static page rebuild when edits are waiting.", "impact": "Edits do not reach the live site."}, {"key": "github/stack127labs/stack127/stack127-cache-maintenance.yml", "group": "Site cache", "name": "Cache maintenance", "about": "Every hour at minute 17, checks cache health and starts a static page rebuild when page refreshes are overdue."}, {"key": "github/stack127labs/stack127/stack127-frontend-refresh.yml", "group": "Site cache", "name": "Rebuild static pages", "about": "Rebuilds the static Cloudflare pages when content edits are waiting. Started by the cache system or by hand."}, - {"key": "github/stack127labs/stack127/stack127-ci.yml", "group": "Releases and checks", "name": "Pull request checks", "about": "Runs the core tests and one full build on every pull request."}, + {"key": "github/stack127labs/stack127/stack127-ci.yml", "group": "Releases and checks", "name": "Pull request checks", "about": "Runs core tests on eligible code pull requests and manual runs, with the full production build only for trusted repository branches; docs, skills and records can be skipped."}, {"key": "github/stack127labs/stack127/stack127-release-e2e.yml", "group": "Releases and checks", "name": "Release to production", "about": "Deploys merged code to the OVH server and Cloudflare, then confirms the live site runs the new commit.", "impact": "Merged changes do not go live."}, {"key": "github/stack127labs/stack127/stack127-publish-pages.yml", "group": "Releases and checks", "name": "Publish pages", "about": "Publishes selected tool and directory pages to production."}, {"key": "stack127-vps/stack127-db-backup", "group": "Production database", "name": "Database backup (OVH)", "about": "Every day at 02:40 UTC, backs up the production database on the OVH server. A failure opens a GitHub issue.", "impact": "Recent production data could be lost."}, From 2122c657d7654a99706178a1e3276d9f98bfe9fd Mon Sep 17 00:00:00 2001 From: RaviTejaKNTS Date: Mon, 2 Nov 2026 04:59:54 +0530 Subject: [PATCH 3/4] Keep GitHub refresh safe before retry migration and test queue recovery --- scripts/self-host-apps/refresh-github.mjs | 137 ++++++++++-------- ...231800_github_project_refresh_attempts.sql | 2 + .../refresh-automations.test.mjs | 28 +++- .../refresh-github-database.test.mjs | 132 +++++++++++++++++ 4 files changed, 234 insertions(+), 65 deletions(-) create mode 100644 tests/self-host-apps/refresh-github-database.test.mjs diff --git a/scripts/self-host-apps/refresh-github.mjs b/scripts/self-host-apps/refresh-github.mjs index 17b6ee58..c8a6719e 100644 --- a/scripts/self-host-apps/refresh-github.mjs +++ b/scripts/self-host-apps/refresh-github.mjs @@ -168,7 +168,18 @@ export async function githubGraphql(query) { } } -export async function rowsToRefresh(client, args) { +export async function hasRefreshAttemptColumns(client) { + const result = await client.query(` + select count(*) = 2 as available + from pg_attribute + where attrelid = 'public.github_projects'::regclass + and attname in ('last_attempt_at', 'consecutive_failures') + and not attisdropped + `); + return result.rows[0].available; +} + +export async function rowsToRefresh(client, args, retryMetadataAvailable = true) { if (args.repo) return [{ slug: args.slug ?? args.repo, source_code_url: args.repo }]; const limit = numberArg(args, "limit", 500); const params = []; @@ -195,7 +206,7 @@ export async function rowsToRefresh(client, args) { from public.self_host_apps app left join public.github_projects project on project.id = app.github_project_id ${where} - order by greatest(project.last_attempt_at, project.checked_at) nulls first, app.slug + order by ${retryMetadataAvailable ? "greatest(project.last_attempt_at, project.checked_at)" : "project.checked_at"} nulls first, app.slug ${args.all ? "" : `limit $${params.length + 1}`} `, args.all ? params : [...params, limit], @@ -203,7 +214,7 @@ export async function rowsToRefresh(client, args) { return result.rows; } -export async function writeRepositoryFacts(client, row, repository, checkedAt, dryRun) { +export async function writeRepositoryFacts(client, row, repository, checkedAt, dryRun, retryMetadataAvailable = true) { if (!repository) throw new Error("repository not found or inaccessible"); const owner = String(row.github_owner ?? "").trim().toLowerCase(); @@ -267,11 +278,11 @@ export async function writeRepositoryFacts(client, row, repository, checkedAt, d stars, forks, watchers, open_issues, primary_language, languages, topics, created_at, updated_at, pushed_at, default_branch, repo_size_kb, archived, disabled, is_fork, is_template, latest_release, latest_release_at, - last_commit_at, license_spdx_id, checked_at, source_url, last_attempt_at, consecutive_failures + last_commit_at, license_spdx_id, checked_at, source_url${retryMetadataAvailable ? ", last_attempt_at, consecutive_failures" : ""} ) values ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14::text[], $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, - $25, $26, $27, $28, $29, $28, 0 + $25, $26, $27, $28, $29${retryMetadataAvailable ? ", $28, 0" : ""} ) on conflict (owner, repo) do update set full_name = excluded.full_name, @@ -300,8 +311,7 @@ export async function writeRepositoryFacts(client, row, repository, checkedAt, d last_commit_at = excluded.last_commit_at, license_spdx_id = excluded.license_spdx_id, checked_at = excluded.checked_at, - last_attempt_at = excluded.last_attempt_at, - consecutive_failures = 0, + ${retryMetadataAvailable ? "last_attempt_at = excluded.last_attempt_at, consecutive_failures = 0," : ""} source_url = excluded.source_url, row_updated_at = now() returning id @@ -363,8 +373,8 @@ export async function writeRepositoryFacts(client, row, repository, checkedAt, d } } -export async function writeFailureAttempt(client, row, attemptedAt, dryRun) { - if (dryRun) return; +export async function writeFailureAttempt(client, row, attemptedAt, dryRun, retryMetadataAvailable = true) { + if (dryRun || !retryMetadataAvailable) return; const owner = String(row.github_owner ?? "").trim().toLowerCase(); const repo = String(row.github_repo ?? "").trim().replace(/\.git$/i, "").toLowerCase(); if (!owner || !repo) { @@ -385,70 +395,77 @@ export async function writeFailureAttempt(client, row, attemptedAt, dryRun) { await client.query(`update public.self_host_apps set github_project_id = $2 where slug = $1 and github_project_id is distinct from $2::uuid`, [row.slug, result.rows[0].id]); } -async function main() { - const args = parseArgs(); +export async function refreshGithub(client, args) { const batchSize = Math.max(1, Math.min(numberArg(args, "batch-size", 40), 80)); const checkedAt = new Date().toISOString(); - await withClient(async (client) => { - const rows = await rowsToRefresh(client, args); + // Actions can pick up main before the independently deployed migration lands. + // Detect once per run and keep the old writer/queue usable until both columns exist. + const retryMetadataAvailable = await hasRefreshAttemptColumns(client); + if (!retryMetadataAvailable) console.warn("GitHub retry metadata unavailable (migration pending); using checked_at ordering and skipping failed-attempt writes."); + const rows = await rowsToRefresh(client, args, retryMetadataAvailable); + + let refreshed = 0; + const failures = []; + async function failed(row, error) { + failures.push({ slug: row.slug, repository: row.source_code_url, error: error.message }); + console.warn(`warning ${row.slug}: ${error.message}`); + await writeFailureAttempt(client, row, checkedAt, Boolean(args["dry-run"]), retryMetadataAvailable); + } - let refreshed = 0; - const failures = []; - async function failed(row, error) { - failures.push({ slug: row.slug, repository: row.source_code_url, error: error.message }); - console.warn(`warning ${row.slug}: ${error.message}`); - await writeFailureAttempt(client, row, checkedAt, Boolean(args["dry-run"])); - } + for (let index = 0; index < rows.length; index += batchSize) { + const selectedBatch = rows.slice(index, index + batchSize); + const batch = []; - for (let index = 0; index < rows.length; index += batchSize) { - const selectedBatch = rows.slice(index, index + batchSize); - const batch = []; - - for (const row of selectedBatch) { - try { - const repo = parseRepo(row.source_code_url); - row.github_owner = repo.owner; - row.github_repo = repo.repo; - batch.push(row); - } catch (error) { - await failed(row, error); - } + for (const row of selectedBatch) { + try { + const repo = parseRepo(row.source_code_url); + row.github_owner = repo.owner; + row.github_repo = repo.repo; + batch.push(row); + } catch (error) { + await failed(row, error); } + } - if (batch.length === 0) continue; - let payload; + if (batch.length === 0) continue; + let payload; - try { - payload = await githubGraphql(repositoryQuery(batch)); - } catch (error) { - console.warn(`warning batch ${index / batchSize + 1}: ${error.message}`); - for (const row of batch) { - await failed(row, error); - } - continue; + try { + payload = await githubGraphql(repositoryQuery(batch)); + } catch (error) { + console.warn(`warning batch ${index / batchSize + 1}: ${error.message}`); + for (const row of batch) { + await failed(row, error); } + continue; + } - for (const [batchIndex, row] of batch.entries()) { - try { - const alias = `repo${batchIndex}`; - const repository = payload.data[alias]; - const errors = payload.errors?.filter((error) => error.path?.[0] === alias); - if (errors?.length) throw new Error(errors.map((error) => error.message).join("; ")); - await writeRepositoryFacts(client, row, repository, checkedAt, Boolean(args["dry-run"])); - refreshed += 1; - console.log(`Refreshed GitHub facts: ${row.slug}`); - } catch (error) { - await failed(row, error); - } + for (const [batchIndex, row] of batch.entries()) { + try { + const alias = `repo${batchIndex}`; + const repository = payload.data[alias]; + const errors = payload.errors?.filter((error) => error.path?.[0] === alias); + if (errors?.length) throw new Error(errors.map((error) => error.message).join("; ")); + await writeRepositoryFacts(client, row, repository, checkedAt, Boolean(args["dry-run"]), retryMetadataAvailable); + refreshed += 1; + console.log(`Refreshed GitHub facts: ${row.slug}`); + } catch (error) { + await failed(row, error); } } + } - if (args["dry-run"]) console.log("Dry run OK. No GitHub facts committed."); - console.log(`GitHub refresh complete: ${refreshed} refreshed, ${failures.length} failed.`); - jobSummary("GitHub facts", { Selected: rows.length, Refreshed: refreshed, Failed: failures.length, "Dry run": Boolean(args["dry-run"]) }, failures); - if (failures.length) process.exitCode = 1; - }, args["database-url"]); + if (args["dry-run"]) console.log("Dry run OK. No GitHub facts committed."); + console.log(`GitHub refresh complete: ${refreshed} refreshed, ${failures.length} failed.`); + jobSummary("GitHub facts", { Selected: rows.length, Refreshed: refreshed, Failed: failures.length, "Dry run": Boolean(args["dry-run"]), "Retry metadata available": retryMetadataAvailable }, failures); + return { selected: rows.length, refreshed, failed: failures.length, failures, retryMetadataAvailable }; +} + +async function main() { + const args = parseArgs(); + const report = await withClient((client) => refreshGithub(client, args), args["database-url"]); + if (report.failed) process.exitCode = 1; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch((error) => { diff --git a/supabase/migrations/20261101231800_github_project_refresh_attempts.sql b/supabase/migrations/20261101231800_github_project_refresh_attempts.sql index 6f45ba6d..ea3352c8 100644 --- a/supabase/migrations/20261101231800_github_project_refresh_attempts.sql +++ b/supabase/migrations/20261101231800_github_project_refresh_attempts.sql @@ -1,4 +1,6 @@ begin; +set local lock_timeout = '10s'; +set local statement_timeout = '120s'; alter table public.github_projects add column last_attempt_at timestamptz, diff --git a/tests/self-host-apps/refresh-automations.test.mjs b/tests/self-host-apps/refresh-automations.test.mjs index 0d79aed6..10f176ce 100644 --- a/tests/self-host-apps/refresh-automations.test.mjs +++ b/tests/self-host-apps/refresh-automations.test.mjs @@ -6,7 +6,7 @@ import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { pathToFileURL } from "node:url"; import { gitlabFacts } from "../../scripts/self-host-apps/refresh-source-host.mjs"; -import { githubGraphql, writeFailureAttempt } from "../../scripts/self-host-apps/refresh-github.mjs"; +import { refreshGithub, writeFailureAttempt } from "../../scripts/self-host-apps/refresh-github.mjs"; import { fetchJson } from "../../scripts/self-host-apps/discover-app-catalogs.mjs"; function env(t, key, value) { @@ -60,14 +60,32 @@ test("catalog requests send a GitHub credential only to the GitHub API", async ( assert.equal(calls[1].options.headers.Authorization, "Bearer github-test-token"); }); -test("one inaccessible GraphQL repository does not discard successful repositories", async (t) => { +test("one inaccessible GraphQL repository does not discard successful repository writes", async (t) => { env(t, "GITHUB_TOKEN", "github-test-token"); const payload = { - data: { repo0: { nameWithOwner: "example/available" }, repo1: null }, + data: { repo0: { nameWithOwner: "example/available", stargazerCount: 42 }, repo1: null }, errors: [{ path: ["repo1"], message: "Could not resolve repository example/deleted" }], }; const fetch = t.mock.method(globalThis, "fetch", async () => Response.json(payload)); - assert.deepEqual(await githubGraphql("query { repo0: repository(owner: \"example\", name: \"available\") { nameWithOwner } }"), payload); + const writes = []; + let schemaChecks = 0; + const client = { async query(sql, values) { + if (sql.includes("from pg_attribute")) { schemaChecks++; return { rows: [{ available: true }] }; } + if (sql.includes("select app.slug")) return { rows: ["available", "deleted"].map(slug => ({ + slug, source_code_url: `https://github.com/example/${slug}`, + })) }; + writes.push({ sql, values }); + return { rows: [{ id: "00000000-0000-0000-0000-000000000001" }] }; + } }; + const report = await refreshGithub(client, {}); + assert.equal(report.refreshed, 1); + assert.equal(report.failed, 1); + assert.ok(writes.some(({ sql, values }) => sql.includes("insert into public.github_projects") + && values[0] === "example" && values[1] === "available" && values[7] === 42), + "The successful alias must persist its stars, even when another repository fails"); + assert.ok(writes.some(({ sql, values }) => sql.includes("update public.self_host_apps app") && values[0] === "available"), + "The successful repository must also update its app link"); + assert.equal(schemaChecks, 1); assert.equal(fetch.mock.callCount(), 1); }); @@ -86,7 +104,7 @@ async function cliFixture(t) { registerHooks({ load(url, context, nextLoad) { if (url.endsWith('/scripts/self-host-apps/lib/db.mjs')) return { format: 'module', shortCircuit: true, - source: 'export async function withClient(callback) { return callback({query: async (sql) => ({ rows: sql.includes("count(*)") ? [{count:0}] : JSON.parse(process.env.TEST_ROWS) })}); }' + source: 'export async function withClient(callback) { return callback({query: async (sql) => ({ rows: sql.includes("from pg_attribute") ? [{available:true}] : sql.includes("count(*)") ? [{count:0}] : JSON.parse(process.env.TEST_ROWS) })}); }' }; return nextLoad(url, context); }}); diff --git a/tests/self-host-apps/refresh-github-database.test.mjs b/tests/self-host-apps/refresh-github-database.test.mjs new file mode 100644 index 00000000..ebe79521 --- /dev/null +++ b/tests/self-host-apps/refresh-github-database.test.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import pg from "pg"; +import { resolveDatabaseUrl } from "../../scripts/stack127/tool-pages/lib.mjs"; +import { + hasRefreshAttemptColumns, rowsToRefresh, writeFailureAttempt, writeRepositoryFacts, refreshGithub, +} from "../../scripts/self-host-apps/refresh-github.mjs"; + +// Opt in with SELF_HOST_APPS_TEST_ENVIRONMENT=development. Like the isolated +// niche-seed test, execute the real SQL against transaction-local temporary +// tables only. No public rows, migrations, triggers or grants are changed. +const databaseTest = { skip: process.env.SELF_HOST_APPS_TEST_ENVIRONMENT !== "development" }; + +async function fixture(t, work) { + const client = new pg.Client({ connectionString: resolveDatabaseUrl({ environment: "development" }), + connectionTimeoutMillis: 15000, query_timeout: 30000 }); + try { + await client.connect(); + await client.query("begin"); + await client.query("set local statement_timeout = '30s'"); + await client.query(` + create temporary table github_projects_fixture + (like public.github_projects including defaults including constraints including indexes) on commit drop; + alter table github_projects_fixture drop column if exists last_attempt_at cascade, + drop column if exists consecutive_failures; + create temporary table self_host_apps_fixture ( + slug text primary key, source_code_url text, github_owner text, github_repo text, github_project_id uuid, + source_code_host text default 'github', published boolean default true, + source_model public.self_host_app_source_model default 'unknown', + license text, license_spdx_id text, license_type public.self_host_app_license_type default 'unknown', + maintenance_status text, data_source_urls text[], data_checked_at timestamptz, data_confidence text, updated_at timestamptz + ) on commit drop; + `); + let schemaChecks = 0; + const isolated = { query(sql, params) { + if (sql.includes("from pg_attribute")) schemaChecks++; + return client.query(sql.replaceAll("public.github_projects", "pg_temp.github_projects_fixture") + .replaceAll("public.self_host_apps", "pg_temp.self_host_apps_fixture") + .replaceAll("github_projects.consecutive_failures", "github_projects_fixture.consecutive_failures"), params); + } }; + await work(client, isolated, () => schemaChecks); + } finally { + await client.query("rollback").catch(() => {}); + await client.end(); + } +} + +const oldCheck = "2026-10-01T00:00:00.000Z"; +const attempt = "2026-11-01T12:00:00.000Z"; +const recoveredAt = "2026-11-01T13:00:00.000Z"; + +async function seed(client) { + await client.query(` + insert into github_projects_fixture (owner,repo,full_name,repository_url,checked_at) values + ('example','failed','example/failed','https://github.com/example/failed',$1), + ('example','older','example/older','https://github.com/example/older','2026-10-02T00:00:00Z'); + `, [oldCheck]); + await client.query(` + insert into self_host_apps_fixture (slug,source_code_url,github_owner,github_repo,github_project_id) + select repo,repository_url,owner,repo,id from github_projects_fixture; + insert into self_host_apps_fixture (slug,source_code_url,github_owner,github_repo) + values ('unlinked','https://github.com/example/unlinked','example','unlinked'); + `); +} + +test("failed repositories move behind older work without claiming fresh facts, link missing projects, and recover", databaseTest, async (t) => { + await fixture(t, async (client, isolated) => { + // Reproduce the old schema first, then add the migration's retry fields. + assert.equal(await hasRefreshAttemptColumns(isolated), false); + await client.query(`alter table github_projects_fixture add column last_attempt_at timestamptz, + add column consecutive_failures integer not null default 0 check (consecutive_failures >= 0)`); + assert.equal(await hasRefreshAttemptColumns(isolated), true); + await seed(client); + const queue = async () => (await rowsToRefresh(isolated, { "published-only": true })).map(row => row.slug); + const state = async repo => (await client.query(`select checked_at,last_attempt_at,consecutive_failures,stars + from github_projects_fixture where repo=$1`, [repo])).rows[0]; + const apps = Object.fromEntries((await rowsToRefresh(isolated, {})).map(row => [row.slug, row])); + assert.deepEqual(await queue(), ["unlinked", "failed", "older"]); + + await writeFailureAttempt(isolated, apps.failed, attempt, false); + assert.deepEqual(await queue(), ["unlinked", "older", "failed"]); + assert.equal((await state("failed")).checked_at.toISOString(), oldCheck); + assert.equal((await state("failed")).last_attempt_at.toISOString(), attempt); + assert.equal((await state("failed")).consecutive_failures, 1); + await writeFailureAttempt(isolated, apps.failed, recoveredAt, false); + assert.equal((await state("failed")).consecutive_failures, 2); + assert.equal((await state("failed")).checked_at.toISOString(), oldCheck); + + await writeFailureAttempt(isolated, apps.unlinked, attempt, false); + const linked = (await client.query(`select a.github_project_id,p.id,p.checked_at,p.consecutive_failures + from self_host_apps_fixture a join github_projects_fixture p on p.owner=a.github_owner and p.repo=a.github_repo + where a.slug='unlinked'`)).rows[0]; + assert.equal(linked.github_project_id, linked.id); + assert.equal(linked.checked_at, null); + assert.equal(linked.consecutive_failures, 1); + assert.equal((await queue())[0], "older"); + + await writeRepositoryFacts(isolated, apps.failed, { nameWithOwner: "example/failed", stargazerCount: 42 }, recoveredAt, false); + const recovered = await state("failed"); + assert.equal(recovered.consecutive_failures, 0); + assert.equal(recovered.checked_at.toISOString(), recoveredAt); + assert.equal(recovered.last_attempt_at.toISOString(), recoveredAt); + assert.equal(recovered.stars, 42); + }); +}); + +test("an unmigrated schema checks once, keeps the old queue, and writes successful mixed GraphQL results", databaseTest, async (t) => { + const previousToken = process.env.GITHUB_TOKEN; + process.env.GITHUB_TOKEN = "github-test-token"; + t.after(() => { + if (previousToken === undefined) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = previousToken; + }); + await fixture(t, async (client, isolated, schemaChecks) => { + await seed(client); + t.mock.method(globalThis, "fetch", async () => Response.json({ + data: { repo0: null, repo1: { nameWithOwner: "example/failed", stargazerCount: 99 } }, + errors: [{ path: ["repo0"], message: "Repository example/unlinked is inaccessible" }], + })); + const report = await refreshGithub(isolated, { limit: 2, "published-only": true }); + assert.equal(report.retryMetadataAvailable, false); + assert.equal(report.selected, 2); + assert.equal(report.refreshed, 1); + assert.equal(report.failed, 1); + assert.equal(schemaChecks(), 1); + const project = (await client.query("select stars,checked_at from github_projects_fixture where repo='failed'")).rows[0]; + assert.equal(project.stars, 99); + assert.ok(project.checked_at > new Date(oldCheck)); + assert.equal((await client.query("select github_project_id from self_host_apps_fixture where slug='unlinked'")).rows[0].github_project_id, null); + assert.deepEqual((await rowsToRefresh(isolated, {}, false)).map(row => row.slug), ["unlinked", "older", "failed"]); + }); +}); From 502439773f81b06d4f4f093c0bbdc1b4b2cddebd Mon Sep 17 00:00:00 2001 From: RaviTejaKNTS Date: Mon, 2 Nov 2026 05:16:53 +0530 Subject: [PATCH 4/4] Refresh shared GitHub facts and maintenance for published CLI apps --- .../self-host-apps-refresh-github.yml | 6 +- scripts/cli-apps/build-curated-batch.mjs | 12 +-- scripts/cli-apps/lib/maintenance.mjs | 12 +++ scripts/self-host-apps/refresh-github.mjs | 67 +++++++++++--- stack127_docs/monitoring.json | 2 +- stack127_docs/self-host-apps-data.md | 16 ++++ tests/pages/cli-apps/maintenance.test.mjs | 20 +++++ .../refresh-automations.test.mjs | 4 +- .../refresh-github-database.test.mjs | 88 +++++++++++++++++++ 9 files changed, 197 insertions(+), 30 deletions(-) create mode 100644 scripts/cli-apps/lib/maintenance.mjs create mode 100644 tests/pages/cli-apps/maintenance.test.mjs diff --git a/.github/workflows/self-host-apps-refresh-github.yml b/.github/workflows/self-host-apps-refresh-github.yml index 10b01a8f..84fc22f2 100644 --- a/.github/workflows/self-host-apps-refresh-github.yml +++ b/.github/workflows/self-host-apps-refresh-github.yml @@ -1,4 +1,4 @@ -name: Refresh self-host app GitHub facts +name: Refresh self-host and CLI app GitHub facts on: schedule: @@ -6,11 +6,11 @@ on: workflow_dispatch: inputs: limit: - description: "Maximum rows to refresh. Use 0 with all=true." + description: "Maximum unique repositories to refresh. Use 0 with all=true." default: "500" required: false all: - description: "Refresh every matching row instead of using limit." + description: "Refresh every matching repository instead of using limit." default: false required: false type: boolean diff --git a/scripts/cli-apps/build-curated-batch.mjs b/scripts/cli-apps/build-curated-batch.mjs index dffb11b4..df007890 100644 --- a/scripts/cli-apps/build-curated-batch.mjs +++ b/scripts/cli-apps/build-curated-batch.mjs @@ -8,17 +8,7 @@ import path from "node:path"; import { parseArgs } from "./lib/args.mjs"; import { curated } from "./curated-map.mjs"; -function maintenanceFrom(github, override) { - if (override) return override.toLowerCase(); - if (github?.archived) return "archived"; - if (!github?.pushed_at) return "unknown"; - const pushedAt = new Date(github.pushed_at).getTime(); - const ageDays = (Date.now() - pushedAt) / (1000 * 60 * 60 * 24); - if (Number.isNaN(ageDays)) return "unknown"; - if (ageDays < 110) return "active"; - if (ageDays < 395) return "slow"; - return "stale"; -} +import { maintenanceFrom } from "./lib/maintenance.mjs"; async function main() { const args = parseArgs(); diff --git a/scripts/cli-apps/lib/maintenance.mjs b/scripts/cli-apps/lib/maintenance.mjs new file mode 100644 index 00000000..8191ea3e --- /dev/null +++ b/scripts/cli-apps/lib/maintenance.mjs @@ -0,0 +1,12 @@ +export function maintenanceFrom(github, override, now = Date.now()) { + if (override) return override.toLowerCase(); + if (github?.archived) return "archived"; + if (!github?.pushed_at) return "unknown"; + const pushedAt = new Date(github.pushed_at).getTime(); + const ageDays = (now - pushedAt) / (1000 * 60 * 60 * 24); + if (Number.isNaN(ageDays)) return "unknown"; + if (ageDays < 110) return "active"; + if (ageDays < 395) return "slow"; + return "stale"; +} + diff --git a/scripts/self-host-apps/refresh-github.mjs b/scripts/self-host-apps/refresh-github.mjs index c8a6719e..a1656a67 100644 --- a/scripts/self-host-apps/refresh-github.mjs +++ b/scripts/self-host-apps/refresh-github.mjs @@ -7,6 +7,7 @@ import { parseRepo } from "./lib/github.mjs"; import { licenseTypeFromSpdx, sourceModelFromSpdx } from "./lib/schema.mjs"; import { pathToFileURL } from "node:url"; import { jobSummary } from "./lib/job-summary.mjs"; +import { maintenanceFrom as cliMaintenanceFrom } from "../cli-apps/lib/maintenance.mjs"; const githubGraphqlUrl = "https://api.github.com/graphql"; let cachedGithubToken; @@ -183,29 +184,59 @@ export async function rowsToRefresh(client, args, retryMetadataAvailable = true) if (args.repo) return [{ slug: args.slug ?? args.repo, source_code_url: args.repo }]; const limit = numberArg(args, "limit", 500); const params = []; - let where = "where (app.source_code_host = 'github' or app.source_code_url like 'https://github.com/%')"; + const selfHostConditions = ["(app.github_project_id is not null or app.source_code_host = 'github' or app.source_code_url like 'https://github.com/%')"]; + const cliConditions = ["app.published = true"]; if (args.slug) { params.push(args.slug); - where += " and app.slug = $1"; - } - if (args["published-only"]) { - where += " and app.published = true"; + selfHostConditions.push(`app.slug = $${params.length}`); + cliConditions.push(`app.slug = $${params.length}`); } + if (args["published-only"]) selfHostConditions.push("app.published = true"); if (args["source-model"]) { params.push(args["source-model"]); - where += ` and app.source_model = $${params.length}::public.self_host_app_source_model`; + selfHostConditions.push(`app.source_model::text = $${params.length}`); + cliConditions.push(`app.source_model::text = $${params.length}`); } + let ageCondition = "true"; if (args["max-age-hours"]) { params.push(`${numberArg(args, "max-age-hours", 12)} hours`); - where += ` and (project.checked_at is null or project.checked_at <= now() - $${params.length}::interval)`; + ageCondition = `(project.checked_at is null or project.checked_at <= now() - $${params.length}::interval)`; } const result = await client.query( ` - select app.slug, app.source_code_url, app.github_owner, app.github_repo, app.github_project_id - from public.self_host_apps app + with memberships as ( + select app.slug, app.github_project_id, app.source_code_url as repository_url, app.slug as self_host_slug + from public.self_host_apps app + where ${selfHostConditions.join(" and ")} + union all + select app.slug, project.id, project.repository_url, null::text + from public.terminal_apps app + join public.github_projects project on project.id = app.github_project_id + where ${cliConditions.join(" and ")} + ), resolved as ( + select member.slug, member.self_host_slug, + coalesce(linked.id, existing.id) as github_project_id, + coalesce(linked.repository_url, existing.repository_url, member.repository_url) as source_code_url, + coalesce(linked.owner, existing.owner, lower(parsed.parts[1])) as github_owner, + coalesce(linked.repo, existing.repo, lower(regexp_replace(parsed.parts[2], '[.]git$', '', 'i'))) as github_repo + from memberships member + cross join lateral (select regexp_match(member.repository_url, '^https://github[.]com/([^/]+)/([^/#?]+)', 'i') as parts) parsed + left join public.github_projects linked on linked.id = member.github_project_id + left join public.github_projects existing on linked.id is null + and existing.owner = lower(parsed.parts[1]) + and existing.repo = lower(regexp_replace(parsed.parts[2], '[.]git$', '', 'i')) + ), repositories as ( + select min(slug) as slug, github_project_id, min(source_code_url) as source_code_url, + min(github_owner) as github_owner, min(github_repo) as github_repo, + coalesce(array_agg(distinct self_host_slug) filter (where self_host_slug is not null), '{}') as self_host_slugs + from resolved + group by github_project_id, case when github_project_id is null then coalesce(github_owner || '/' || github_repo, lower(source_code_url)) end + ) + select app.* + from repositories app left join public.github_projects project on project.id = app.github_project_id - ${where} + where ${ageCondition} order by ${retryMetadataAvailable ? "greatest(project.last_attempt_at, project.checked_at)" : "project.checked_at"} nulls first, app.slug ${args.all ? "" : `limit $${params.length + 1}`} `, @@ -366,10 +397,20 @@ export async function writeRepositoryFacts(client, row, repository, checkedAt, d updated_at = now() from public.github_projects project where project.owner = $8 and project.repo = $9 - and app.slug = $1 + and app.slug = any($1::text[]) `, - [row.slug, facts.license, facts.license_type, facts.maintenance_status, facts.source_model, facts.repository_url, checkedAt, facts.owner, facts.repo], + [row.self_host_slugs ?? [row.slug], facts.license, facts.license_type, facts.maintenance_status, facts.source_model, facts.repository_url, checkedAt, facts.owner, facts.repo], ); + // A normal terminal_apps UPDATE lets the existing statement trigger touch + // /cli-apps and enqueue cli_apps revalidation; identical values do no work. + await client.query(` + update public.terminal_apps app + set maintenance_status = $3::public.terminal_app_maintenance_status + from public.github_projects project + where app.github_project_id = project.id and project.owner = $1 and project.repo = $2 + and app.published = true + and app.maintenance_status is distinct from $3::public.terminal_app_maintenance_status + `, [facts.owner, facts.repo, cliMaintenanceFrom({ archived: repository.isArchived, pushed_at: repository.pushedAt }, null, new Date(checkedAt).getTime())]); } } @@ -392,7 +433,7 @@ export async function writeFailureAttempt(client, row, attemptedAt, dryRun, retr `, [owner, repo, `https://github.com/${owner}/${repo}`, attemptedAt]); // Even a previously unlinked repository must move to the back of the queue. // Do not change checked_at or any app fact/verification date on failure. - await client.query(`update public.self_host_apps set github_project_id = $2 where slug = $1 and github_project_id is distinct from $2::uuid`, [row.slug, result.rows[0].id]); + await client.query(`update public.self_host_apps set github_project_id = $2 where slug = any($1::text[]) and github_project_id is distinct from $2::uuid`, [row.self_host_slugs ?? [row.slug], result.rows[0].id]); } export async function refreshGithub(client, args) { diff --git a/stack127_docs/monitoring.json b/stack127_docs/monitoring.json index d6ae34e1..f0e647dc 100644 --- a/stack127_docs/monitoring.json +++ b/stack127_docs/monitoring.json @@ -22,7 +22,7 @@ {"key": "github/stack127labs/stack127/self-host-apps-discovery-directories.yml", "group": "Self-host apps", "name": "Find apps in directories", "about": "Once a month, looks for self-hostable apps in public directories."}, {"key": "github/stack127labs/stack127/self-host-apps-discovery-github.yml", "group": "Self-host apps", "name": "Find apps on GitHub", "about": "Every day, searches GitHub for new self-hostable apps."}, {"key": "github/stack127labs/stack127/self-host-apps-discovery-lists.yml", "group": "Self-host apps", "name": "Find apps in curated lists", "about": "Every Sunday, reads maintained awesome-lists for new self-hostable apps."}, - {"key": "github/stack127labs/stack127/self-host-apps-refresh-github.yml", "group": "Self-host apps", "name": "Refresh GitHub facts", "about": "Twice a day, attempts up to 500 GitHub app refreshes (about a 5-day full cycle), keeps failed attempts separate from successful check dates, and lists failures in the job summary."}, + {"key": "github/stack127labs/stack127/self-host-apps-refresh-github.yml", "group": "Self-host apps", "name": "Refresh GitHub facts", "about": "Twice a day, attempts up to 500 unique GitHub projects linked to self-host and published CLI apps (about a 5-day full cycle), updates CLI maintenance from activity, and lists failures without marking failed facts fresh."}, {"key": "github/stack127labs/stack127/self-host-apps-refresh-source-hosts.yml", "group": "Self-host apps", "name": "Refresh GitLab and Codeberg facts", "about": "Every day, refreshes facts from each app’s own GitLab server and from Codeberg, and lists partial failures in the job summary while marking the job failed."}, {"key": "github/stack127labs/stack127/self-host-apps-refresh-technical.yml", "group": "Self-host apps", "name": "Collect technical details", "about": "Every Monday, collects technical evidence for review, previews install destinations without saving, and runs publication QA; reports unreadable sources and fails above 5% (or if none are readable), without writing editorial facts."}, {"key": "stack127-vps/stack127-cache-worker", "group": "Site cache", "name": "Page cache worker", "about": "Every 30 seconds on the OVH server, checks for changed pages, refreshes ad-network heading counts when their data changes, and starts a static page rebuild when edits are waiting.", "impact": "Edits do not reach the live site."}, diff --git a/stack127_docs/self-host-apps-data.md b/stack127_docs/self-host-apps-data.md index 0ce4b73f..b63ef2cf 100644 --- a/stack127_docs/self-host-apps-data.md +++ b/stack127_docs/self-host-apps-data.md @@ -60,6 +60,22 @@ The `options` JSON list keeps labels, support levels, image references, source l `self_host_apps.github_project_id` and `terminal_apps.github_project_id` are nullable links. Duplicate app rows can point to the same repository, so the repository table isn't unique per app. +The shared GitHub refresh combines self-host apps with published CLI apps, +selects each linked project once, and preserves every self-host membership for +updates. Unlinked self-host apps can join an existing matching GitHub project; +unlinked CLI apps stay outside this job until their repository links are reviewed. +The scheduled batch is 500 unique repositories twice a day. On 2026-11-02, +4,429 self-host projects plus 248 CLI-only projects made 4,677 total: 10 batches, +or about five days for one full pass. This is an attempt cycle; inaccessible +repositories remain stale and appear in the failure summary. + +CLI maintenance uses the same helper as `scripts/cli-apps/build-curated-batch.mjs`: +archived first, unknown when the push date is missing or invalid, active below +110 days, slow below 395 days, then stale. Scheduled refreshes derive it from +GitHub without a curated override. Only changed maintenance values are written. +The existing terminal-app and GitHub-project triggers touch `/cli-apps` and queue +its cache refresh; the job does not write public page dates by hand. + `checked_at` is the last successful GitHub fact refresh. `last_attempt_at` records successful and failed attempts, and `consecutive_failures` resets after success. The refresh queue orders by the later of the attempt and successful check dates, diff --git a/tests/pages/cli-apps/maintenance.test.mjs b/tests/pages/cli-apps/maintenance.test.mjs new file mode 100644 index 00000000..5e9b27a8 --- /dev/null +++ b/tests/pages/cli-apps/maintenance.test.mjs @@ -0,0 +1,20 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { maintenanceFrom } from "../../../scripts/cli-apps/lib/maintenance.mjs"; + +const now = Date.parse("2026-11-02T00:00:00Z"); +const daysAgo = days => new Date(now - days * 86_400_000).toISOString(); + +test("CLI maintenance uses the pipeline's push-age boundaries, missing data, and archive priority", () => { + for (const [days, expected] of [[0, "active"], [109.99, "active"], [110, "slow"], [394.99, "slow"], [395, "stale"], [730, "stale"]]) { + assert.equal(maintenanceFrom({ pushed_at: daysAgo(days) }, null, now), expected, `${days} days`); + } + assert.equal(maintenanceFrom(null, null, now), "unknown"); + assert.equal(maintenanceFrom({ pushed_at: "invalid" }, null, now), "unknown"); + assert.equal(maintenanceFrom({ archived: true, pushed_at: daysAgo(1) }, null, now), "archived"); + assert.equal(maintenanceFrom({ archived: true }, null, now), "archived"); +}); + +test("curated CLI batches retain the existing explicit-override behavior", () => { + assert.equal(maintenanceFrom({ archived: true }, "SLOW", now), "slow"); +}); diff --git a/tests/self-host-apps/refresh-automations.test.mjs b/tests/self-host-apps/refresh-automations.test.mjs index 10f176ce..65950356 100644 --- a/tests/self-host-apps/refresh-automations.test.mjs +++ b/tests/self-host-apps/refresh-automations.test.mjs @@ -71,7 +71,7 @@ test("one inaccessible GraphQL repository does not discard successful repository let schemaChecks = 0; const client = { async query(sql, values) { if (sql.includes("from pg_attribute")) { schemaChecks++; return { rows: [{ available: true }] }; } - if (sql.includes("select app.slug")) return { rows: ["available", "deleted"].map(slug => ({ + if (sql.includes("with memberships as")) return { rows: ["available", "deleted"].map(slug => ({ slug, source_code_url: `https://github.com/example/${slug}`, })) }; writes.push({ sql, values }); @@ -83,7 +83,7 @@ test("one inaccessible GraphQL repository does not discard successful repository assert.ok(writes.some(({ sql, values }) => sql.includes("insert into public.github_projects") && values[0] === "example" && values[1] === "available" && values[7] === 42), "The successful alias must persist its stars, even when another repository fails"); - assert.ok(writes.some(({ sql, values }) => sql.includes("update public.self_host_apps app") && values[0] === "available"), + assert.ok(writes.some(({ sql, values }) => sql.includes("update public.self_host_apps app") && values[0].includes("available")), "The successful repository must also update its app link"); assert.equal(schemaChecks, 1); assert.equal(fetch.mock.callCount(), 1); diff --git a/tests/self-host-apps/refresh-github-database.test.mjs b/tests/self-host-apps/refresh-github-database.test.mjs index ebe79521..3eecfdd0 100644 --- a/tests/self-host-apps/refresh-github-database.test.mjs +++ b/tests/self-host-apps/refresh-github-database.test.mjs @@ -30,12 +30,18 @@ async function fixture(t, work) { license text, license_spdx_id text, license_type public.self_host_app_license_type default 'unknown', maintenance_status text, data_source_urls text[], data_checked_at timestamptz, data_confidence text, updated_at timestamptz ) on commit drop; + create temporary table terminal_apps ( + slug text primary key, github_project_id uuid, published boolean default true, + source_model text default 'open_source', + maintenance_status public.terminal_app_maintenance_status default 'unknown' + ) on commit drop; `); let schemaChecks = 0; const isolated = { query(sql, params) { if (sql.includes("from pg_attribute")) schemaChecks++; return client.query(sql.replaceAll("public.github_projects", "pg_temp.github_projects_fixture") .replaceAll("public.self_host_apps", "pg_temp.self_host_apps_fixture") + .replaceAll("public.terminal_apps", "pg_temp.terminal_apps") .replaceAll("github_projects.consecutive_failures", "github_projects_fixture.consecutive_failures"), params); } }; await work(client, isolated, () => schemaChecks); @@ -130,3 +136,85 @@ test("an unmigrated schema checks once, keeps the old queue, and writes successf assert.deepEqual((await rowsToRefresh(isolated, {}, false)).map(row => row.slug), ["unlinked", "older", "failed"]); }); }); + + +test("the shared queue unions published CLI projects, deduplicates memberships, and preserves ownership", databaseTest, async (t) => { + await fixture(t, async (client, isolated) => { + await client.query(`alter table github_projects_fixture add column last_attempt_at timestamptz, + add column consecutive_failures integer not null default 0`); + await seed(client); + await client.query(` + insert into github_projects_fixture (owner,repo,full_name,repository_url) values + ('example','cli-only','example/cli-only','https://github.com/example/cli-only'), + ('example','draft-only','example/draft-only','https://github.com/example/draft-only'), + ('example','unused','example/unused','https://github.com/example/unused'); + insert into pg_temp.terminal_apps (slug,github_project_id,published) + select 'shared-cli',id,true from github_projects_fixture where repo='failed'; + insert into pg_temp.terminal_apps (slug,github_project_id,published) + select 'older',id,true from github_projects_fixture where repo='cli-only'; + insert into pg_temp.terminal_apps (slug,github_project_id,published) + select 'cli-alias',id,true from github_projects_fixture where repo='cli-only'; + insert into pg_temp.terminal_apps (slug,github_project_id,published) + select 'draft-cli',id,false from github_projects_fixture where repo='draft-only'; + insert into self_host_apps_fixture (slug,source_code_url,github_owner,github_repo) + values ('also-failed','https://github.com/example/failed.git','example','failed'); + `); + const rows = await rowsToRefresh(isolated, { "published-only": true, all: true }); + assert.equal(rows.length, 4); + assert.equal(rows.filter(row => row.github_repo === "failed").length, 1); + assert.deepEqual(rows.find(row => row.github_repo === "failed").self_host_slugs.sort(), ["also-failed", "failed"]); + assert.equal(rows.filter(row => row.github_repo === "cli-only").length, 1); + assert.ok(!rows.some(row => ["draft-only", "unused"].includes(row.github_repo))); + const cliOnly = rows.find(row => row.github_repo === "cli-only"); + assert.deepEqual(cliOnly.self_host_slugs, []); + const selfHostBefore = (await client.query("select * from self_host_apps_fixture where slug='older'")).rows[0]; + await writeRepositoryFacts(isolated, cliOnly, { nameWithOwner: "example/cli-only", stargazerCount: 70, pushedAt: recoveredAt }, recoveredAt, false); + assert.deepEqual((await client.query("select * from self_host_apps_fixture where slug='older'")).rows[0], selfHostBefore, + "A CLI slug must not update an unrelated self-host app with the same slug"); + assert.deepEqual((await client.query("select maintenance_status from pg_temp.terminal_apps where published and slug in ('older','cli-alias')")).rows.map(row => row.maintenance_status), ["active", "active"]); + const shared = rows.find(row => row.github_repo === "failed"); + await writeFailureAttempt(isolated, shared, attempt, false); + const retryQueue = (await rowsToRefresh(isolated, { "published-only": true, all: true })).map(row => row.github_repo); + assert.ok(retryQueue.indexOf("failed") > retryQueue.indexOf("older")); + assert.equal((await client.query("select count(*)::int as count from self_host_apps_fixture where slug in ('also-failed','failed') and github_project_id=$1", [shared.github_project_id])).rows[0].count, 2); + }); +}); + +test("CLI maintenance changes trigger the public-date touch and cache queue once, while identical values do not", databaseTest, async (t) => { + await fixture(t, async (client, isolated) => { + await seed(client); + // Reuse the installed trigger's routing logic, redirecting only its side + // effects to temporary event rows. Public dates and cache queues stay untouched. + const definition = (await client.query("select pg_get_functiondef('public.stack127_touch_directory_records()'::regprocedure) as definition")).rows[0].definition; + await client.query(` + create temporary table cli_refresh_events (kind text,slug text) on commit drop; + create function pg_temp.record_cli_touch(slug text,reason text) returns void language sql as + 'insert into pg_temp.cli_refresh_events values (''date'',slug)'; + create function pg_temp.record_cli_cache(entity text,slug text,reason text) returns void language sql as + 'insert into pg_temp.cli_refresh_events values (entity,slug)'; + `); + await client.query(definition.replaceAll("public.stack127_touch_directory_records", "pg_temp.stack127_touch_directory_records") + .replaceAll("public.stack127_touch_page", "pg_temp.record_cli_touch") + .replaceAll("public.stack127_enqueue_revalidation", "pg_temp.record_cli_cache")); + await client.query(` + create trigger test_cli_maintenance_update after update on pg_temp.terminal_apps + referencing old table as old_rows new table as new_rows + for each statement execute function pg_temp.stack127_touch_directory_records(); + insert into pg_temp.terminal_apps (slug,github_project_id) + select 'test-cli',id from github_projects_fixture where repo='failed'; + `); + const row = (await rowsToRefresh(isolated, { slug: "failed" }, false))[0]; + const repository = { nameWithOwner: "example/failed", stargazerCount: 5, pushedAt: recoveredAt }; + const refresh = () => writeRepositoryFacts(isolated, row, repository, recoveredAt, false, false); + await refresh(); + assert.deepEqual((await client.query("select * from cli_refresh_events order by kind")).rows, + [{ kind: "cli_apps", slug: "cli-apps" }, { kind: "date", slug: "cli-apps" }]); + await client.query("truncate cli_refresh_events"); + await refresh(); + assert.equal((await client.query("select count(*)::int as count from cli_refresh_events")).rows[0].count, 0); + repository.isArchived = true; + await refresh(); + assert.equal((await client.query("select maintenance_status from pg_temp.terminal_apps where slug='test-cli'")).rows[0].maintenance_status, "archived"); + assert.equal((await client.query("select count(*)::int as count from cli_refresh_events")).rows[0].count, 2); + }); +});