Skip to content
Merged
70 changes: 60 additions & 10 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ concurrency:
cancel-in-progress: false

jobs:
publish:
prepare:
runs-on: ubuntu-latest
outputs:
target_indexes: ${{ steps.targets.outputs.target_indexes }}
steps:
- uses: actions/checkout@v6

Expand Down Expand Up @@ -50,21 +52,69 @@ jobs:
env:
DATABASE_CONNECTION_STRING: ${{ secrets.DATABASE_CONNECTION_STRING }}

- name: Render README
run: bun run render
- name: Build publish target matrix
id: targets
run: |
node <<'NODE'
const fs = require('node:fs')

const encoded = process.env.SHIPLOG_CONFIG_BASE64
if (!encoded) {
console.error('missing repository variable: SHIPLOG_CONFIG_BASE64')
process.exit(1)
}

const config = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8'))
const targets = config.publish?.targets
if (!Array.isArray(targets) || targets.length === 0) {
console.error('shiplog config must define at least one publish target')
process.exit(1)
}

const indexes = targets.map((_, index) => index)
fs.appendFileSync(process.env.GITHUB_OUTPUT, `target_indexes=${JSON.stringify(indexes)}\n`)
NODE
env:
DATABASE_CONNECTION_STRING: ${{ secrets.DATABASE_CONNECTION_STRING }}
SHIPLOG_CONFIG_BASE64: ${{ vars.SHIPLOG_CONFIG_BASE64 }}

publish:
needs: prepare
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
target_index: ${{ fromJSON(needs.prepare.outputs.target_indexes) }}
steps:
- uses: actions/checkout@v6

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Print rendered README
- name: Write shiplog config
run: |
echo "::group::rendered.md"
cat rendered.md
echo "::endgroup::"
if [ -z "${SHIPLOG_CONFIG_BASE64:-}" ]; then
echo "missing repository variable: SHIPLOG_CONFIG_BASE64" >&2
exit 1
fi
printf '%s' "$SHIPLOG_CONFIG_BASE64" | base64 -d > shiplog.config.json
env:
SHIPLOG_CONFIG_BASE64: ${{ vars.SHIPLOG_CONFIG_BASE64 }}

- name: Wait for database
run: bun run db:wait
env:
DATABASE_CONNECTION_STRING: ${{ secrets.DATABASE_CONNECTION_STRING }}

- name: Export publish token env
run: bun run tokens:export -- --scope publish
run: bun run tokens:export -- --scope publish --target-index "${{ matrix.target_index }}"
env:
SHIPLOG_TOKEN_SECRETS_JSON: ${{ toJSON(secrets) }}

- name: Publish rendered README
run: bun run publish
run: bun run publish -- --target-index "${{ matrix.target_index }}"
env:
DATABASE_CONNECTION_STRING: ${{ secrets.DATABASE_CONNECTION_STRING }}
117 changes: 117 additions & 0 deletions .shiplog/render.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
{
"$schema": "https://shiplog.karanbalani.tech/schemas/render.config.schema.json",
"version": 1,
"queries": {
"intro": {
"mode": "one",
"sql": "WITH account_scope AS (SELECT id, external_login, external_url, external_created_at FROM accounts WHERE provider = 'github' ORDER BY id LIMIT 1), tenure AS (SELECT external_login, external_url, GREATEST(0, EXTRACT(YEAR FROM age(CURRENT_DATE, external_created_at::date))::int) AS github_years FROM account_scope) SELECT external_login AS github_login, COALESCE(external_url, 'https://github.com/' || external_login) AS github_url, CASE WHEN github_years = 1 THEN '1 year' ELSE github_years::text || ' years' END AS github_tenure, github_years::text || '%20years' AS github_years_badge FROM tenure"
},
"snapshot": {
"mode": "many",
"sql": "WITH account_scope AS (SELECT id, external_login FROM accounts WHERE provider = 'github' ORDER BY id LIMIT 1), user_all AS (SELECT COALESCE(SUM(COALESCE(total_commit_contributions, 0)), 0)::bigint AS commits, COALESCE(SUM(COALESCE(total_issue_contributions, 0)), 0)::bigint AS issues, COALESCE(SUM(COALESCE(total_pull_request_contributions, 0)), 0)::bigint AS prs, COALESCE(SUM(COALESCE(total_commit_contributions, 0) + COALESCE(total_issue_contributions, 0) + COALESCE(total_pull_request_contributions, 0) + COALESCE(total_pull_request_review_contributions, 0) + COALESCE(restricted_contributions_count, 0)), 0)::bigint AS total_contributions FROM daily_user_summary s JOIN account_scope a ON a.id = s.account_id), user_recent AS (SELECT COALESCE(SUM(COALESCE(total_commit_contributions, 0)), 0)::bigint AS commits, COALESCE(SUM(COALESCE(total_issue_contributions, 0)), 0)::bigint AS issues, COALESCE(SUM(COALESCE(total_pull_request_contributions, 0)), 0)::bigint AS prs, COALESCE(SUM(COALESCE(total_commit_contributions, 0) + COALESCE(total_issue_contributions, 0) + COALESCE(total_pull_request_contributions, 0) + COALESCE(total_pull_request_review_contributions, 0) + COALESCE(restricted_contributions_count, 0)), 0)::bigint AS total_contributions FROM daily_user_summary s JOIN account_scope a ON a.id = s.account_id WHERE s.activity_on >= CURRENT_DATE - INTERVAL '365 days'), repo_all AS (SELECT COALESCE(SUM(COALESCE(lines_added, 0)), 0)::bigint AS lines_added, COALESCE(SUM(COALESCE(lines_deleted, 0)), 0)::bigint AS lines_removed, COUNT(DISTINCT repository_id)::bigint AS known_repos FROM daily_repository_activity d JOIN account_scope a ON a.id = d.account_id), repo_recent AS (SELECT COALESCE(SUM(COALESCE(lines_added, 0)), 0)::bigint AS lines_added, COALESCE(SUM(COALESCE(lines_deleted, 0)), 0)::bigint AS lines_removed, COUNT(DISTINCT repository_id)::bigint AS known_repos FROM daily_repository_activity d JOIN account_scope a ON a.id = d.account_id WHERE d.activity_on >= CURRENT_DATE - INTERVAL '365 days'), latest_snapshots AS (SELECT repository_id, MAX(captured_on) AS captured_on FROM repository_snapshots GROUP BY repository_id), owned_stars AS (SELECT COALESCE(SUM(COALESCE(rs.star_count, 0)), 0)::bigint AS stars FROM repositories r JOIN latest_snapshots latest ON latest.repository_id = r.id JOIN repository_snapshots rs ON rs.repository_id = latest.repository_id AND rs.captured_on = latest.captured_on JOIN account_scope a ON lower(a.external_login) = lower(r.owner_login) WHERE r.provider = 'github' AND r.redacted = false), owned_recent_stars AS (SELECT COALESCE(SUM(COALESCE(rs.star_count, 0)), 0)::bigint AS stars FROM repositories r JOIN latest_snapshots latest ON latest.repository_id = r.id JOIN repository_snapshots rs ON rs.repository_id = latest.repository_id AND rs.captured_on = latest.captured_on JOIN account_scope a ON lower(a.external_login) = lower(r.owner_login) WHERE r.provider = 'github' AND r.redacted = false AND EXISTS (SELECT 1 FROM daily_repository_activity d WHERE d.repository_id = r.id AND d.account_id = a.id AND d.activity_on >= CURRENT_DATE - INTERVAL '365 days')) SELECT metric, all_time, last_365_days FROM user_all ua, user_recent ur, repo_all ra, repo_recent rr, owned_stars os, owned_recent_stars ors, LATERAL (VALUES ('🔥 Commits', to_char(ua.commits, 'FM999G999G999G990'), to_char(ur.commits, 'FM999G999G999G990')), ('📝 Issues', to_char(ua.issues, 'FM999G999G999G990'), to_char(ur.issues, 'FM999G999G999G990')), ('🔀 PRs', to_char(ua.prs, 'FM999G999G999G990'), to_char(ur.prs, 'FM999G999G999G990')), ('🎉 Total contributions', to_char(ua.total_contributions, 'FM999G999G999G990'), to_char(ur.total_contributions, 'FM999G999G999G990')), ('➕ Lines added', to_char(ra.lines_added, 'FM999G999G999G990'), to_char(rr.lines_added, 'FM999G999G999G990')), ('➖ Lines removed', to_char(ra.lines_removed, 'FM999G999G999G990'), to_char(rr.lines_removed, 'FM999G999G999G990')), ('📦 Known repos', to_char(ra.known_repos, 'FM999G999G999G990'), to_char(rr.known_repos, 'FM999G999G999G990')), ('⭐️ Owned stars', to_char(os.stars, 'FM999G999G999G990'), to_char(ors.stars, 'FM999G999G999G990'))) AS rows(metric, all_time, last_365_days)"
},
"organizations": {
"mode": "many",
"sql": "WITH account_scope AS (SELECT id FROM accounts WHERE provider = 'github' ORDER BY id LIMIT 1), org_rows AS (SELECT COALESCE(o.display_name, o.external_login, r.owner_login) AS organization, COALESCE(o.website_url, 'https://github.com/' || r.owner_login) AS web_url, COUNT(DISTINCT r.id)::bigint AS coverage, COALESCE(SUM(d.commits), 0)::bigint AS commits, COALESCE(SUM(d.prs_opened), 0)::bigint AS prs, COALESCE(SUM(COALESCE(d.lines_added, 0)), 0)::bigint AS lines_added, COALESCE(SUM(COALESCE(d.lines_deleted, 0)), 0)::bigint AS lines_removed FROM daily_repository_activity d JOIN account_scope a ON a.id = d.account_id JOIN repositories r ON r.id = d.repository_id LEFT JOIN organizations o ON o.id = r.organization_id WHERE r.organization_id IS NOT NULL AND d.activity_on >= CURRENT_DATE - INTERVAL '365 days' GROUP BY COALESCE(o.display_name, o.external_login, r.owner_login), COALESCE(o.website_url, 'https://github.com/' || r.owner_login) HAVING COALESCE(SUM(d.commits), 0) + COALESCE(SUM(d.prs_opened), 0) + COALESCE(SUM(COALESCE(d.lines_added, 0)) + SUM(COALESCE(d.lines_deleted, 0)), 0) > 0) SELECT organization, web_url, to_char(coverage, 'FM999G999G999G990') || ' repos' AS coverage, to_char(commits, 'FM999G999G999G990') AS commits, to_char(prs, 'FM999G999G999G990') AS prs, '+' || to_char(lines_added, 'FM999G999G999G990') || ' / -' || to_char(lines_removed, 'FM999G999G999G990') AS lines FROM org_rows ORDER BY commits DESC, prs DESC, organization ASC LIMIT 8"
},
"active_projects": {
"mode": "many",
"sql": "WITH account_scope AS (SELECT id FROM accounts WHERE provider = 'github' ORDER BY id LIMIT 1), project_rows AS (SELECT r.full_name, r.web_url, COALESCE(SUM(d.commits), 0)::bigint AS commits, COALESCE(SUM(COALESCE(d.lines_added, 0)), 0)::bigint AS lines_added, COALESCE(SUM(COALESCE(d.lines_deleted, 0)), 0)::bigint AS lines_removed FROM daily_repository_activity d JOIN account_scope a ON a.id = d.account_id JOIN repositories r ON r.id = d.repository_id WHERE d.activity_on >= CURRENT_DATE - INTERVAL '365 days' AND r.visibility = 'public' AND r.redacted = false GROUP BY r.id, r.full_name, r.web_url HAVING COALESCE(SUM(d.commits), 0) + COALESCE(SUM(COALESCE(d.lines_added, 0)) + SUM(COALESCE(d.lines_deleted, 0)), 0) > 0) SELECT full_name, web_url, to_char(commits, 'FM999G999G999G990') AS commits, to_char(lines_added, 'FM999G999G999G990') AS lines_added, to_char(lines_removed, 'FM999G999G999G990') AS lines_removed FROM project_rows ORDER BY commits DESC, lines_added + lines_removed DESC, full_name ASC LIMIT 10"
}
},
"markdown": [
{
"type": "heading",
"level": 1,
"text": "Hey there! I am {{ profile.displayName }}. 👋"
},
{
"type": "paragraph",
"text": "Joined GitHub {{ intro.github_tenure }} ago."
},
{
"type": "rawMarkdown",
"content": "[![GitHub](https://img.shields.io/badge/GitHub-{{ intro.github_login }}-111111?style=flat-square&logo=github&logoColor=white)]({{ intro.github_url }}) [![GitHub tenure](https://img.shields.io/badge/Joined-{{ intro.github_years_badge }}%20ago-111111?style=flat-square)]({{ intro.github_url }})"
},
{
"type": "heading",
"level": 2,
"text": "📊 Snapshot"
},
{
"type": "table",
"query": "snapshot",
"columns": [
{
"label": "Metric",
"value": "{{ metric }}"
},
{
"label": "All Time",
"value": "{{ all_time }}"
},
{
"label": "Last 365 days",
"value": "{{ last_365_days }}"
}
]
},
{
"type": "heading",
"level": 2,
"text": "🏢 Organization Snapshot",
"visibleWhen": {
"query": "organizations",
"hasRows": true
}
},
{
"type": "table",
"query": "organizations",
"columns": [
{
"label": "Org",
"value": "[{{ organization }}]({{ web_url }})"
},
{
"label": "Coverage",
"value": "{{ coverage }}"
},
{
"label": "Commits",
"value": "{{ commits }}"
},
{
"label": "PRs",
"value": "{{ prs }}"
},
{
"label": "Lines",
"value": "{{ lines }}"
}
],
"visibleWhen": {
"query": "organizations",
"hasRows": true
}
},
{
"type": "heading",
"level": 2,
"text": "🚀 Most Active Public Projects (Last Year)",
"visibleWhen": {
"query": "active_projects",
"hasRows": true
}
},
{
"type": "repeat",
"query": "active_projects",
"template": "[{{ full_name }}]({{ web_url }}) - 🔥 {{ commits }} commits, +{{ lines_added }} -{{ lines_removed }}",
"visibleWhen": {
"query": "active_projects",
"hasRows": true
}
}
]
}
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,8 @@ The maintenance dispatcher reads due `maintenance_tasks`, atomically claims supp

The errors dispatcher prunes generic diagnostic rows from `error_events`. This table is intentionally not workflow state; callers can write full JSON payloads for investigation, and housekeeping keeps the table bounded by age.

The renderer reads `TEMPLATE.md`, queries account-scoped activity from the database, fills generic placeholders, and writes `rendered.md`. It does not overwrite this repository's own `README.md`.
The renderer first tries to fetch `.shiplog/render.json` from a configured publish target, then falls back to this repository's bundled `.shiplog/render.json`. Render configs define read-only SQL queries and Markdown blocks; the renderer runs those queries against the Shiplog database, interpolates the configured block values, appends the required shiplog footer, and writes Markdown. Explicit tests and local calls can still pass a `TEMPLATE.md`-style template for legacy coverage. The renderer does not overwrite this repository's own `README.md`.

The publisher reads `rendered.md`, resolves each configured stable `repositoryId` to its current GitHub `owner/repo`, and writes to the configured `branch` and `path` with the target's `tokenEnv`. When the remote file already matches `rendered.md`, it skips the write so no duplicate README commit is created.
The publisher resolves each configured stable `repositoryId` to its current GitHub `owner/repo`, renders content independently for each target unless explicit fixed content was provided, and writes to the configured `branch` and `path` with the target's `tokenEnv`. When the remote file already matches the rendered content, it skips the write so no duplicate README commit is created.

CLI logs use `lib/logger.ts`, write to stderr, include ISO timestamps, support log levels, and colorize levels unless `NO_COLOR` is set.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,24 @@ shiplog gives you your own contribution archive and one umbrella for activity ac
- [Config builder](https://shiplog.karanbalani.tech/config-builder/): generate `shiplog.config.json` and the Base64 value for workflows.
- [Example config](shiplog.config.example.json): copy this when creating your own `shiplog.config.json`.
- [Config schema](schemas/shiplog.config.schema.json): full JSON schema for the config file.
- [Fallback render config](.shiplog/render.json): the default README recipe used when a target repo has no `.shiplog/render.json`.
- [Render config schema](schemas/render.config.schema.json): JSON schema for target-repo README rendering.
- [FAQ](docs/FAQ.md): common setup and operations questions.

## What It Does

- Collects GitHub commits, pull requests, reviews, issues, repositories, languages, and organization context.
- Stores activity in Postgres with historical tables and daily rollups.
- Runs scheduled GitHub Actions for freshness, history, integrity repair, housekeeping, and once-daily publishing.
- Lets each publish target define its README in `.shiplog/render.json`, with Shiplog's bundled config and footer as the fallback.
- Records short-lived diagnostic error events in Postgres for workflow investigation.
- Uses stable GitHub IDs so username, organization, and repository renames do not split history.

## Useful Docs

- [Setup guide](SETUP_GUIDE.md): run shiplog in your own fork.
- [Schema](docs/SCHEMA.md): database tables, views, and timestamp semantics.
- [Render config](docs/RENDER_CONFIG.md): target-repo `.shiplog/render.json` format.
- [GitHub mapping](docs/GITHUB_MAPPING.md): how GitHub data maps into shiplog.
- [Conventions](docs/CONVENTIONS.md): project naming and implementation conventions.
- [Contributing](CONTRIBUTING.md): development workflow for changing shiplog itself.
Expand Down
Loading