Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions .github/workflows/project-status.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
name: Project status

# Regenerates one account's status badges, STATUS.md and profile section from
# its own status.json, then reports what has drifted from reality.
#
# Each account calls this from its own hub, so a privacykey badge is served
# from a privacykey repo and no credential reaches across identities.
#
# Caller:
# jobs:
# status:
# uses: privacykey/gh-workflows/.github/workflows/project-status.yml@v1
# secrets:
# status-token: ${{ secrets.STATUS_TOKEN }}

on:
workflow_call:
inputs:
aggregate:
description: >-
Comma-separated hubs whose listed repos also appear in this hub's
rendered section, e.g. "privacykey/.github,adamXbot/.github". Read
over plain HTTPS with no token — every hub is public and already
withholds its private repos.
type: string
required: false
default: ''
target:
description: 'Markdown file carrying the STATUS markers.'
type: string
required: false
default: 'README.md'
collapsed:
description: >-
Render the section inside a <details> block. True on a personal
profile, where this is one section among many; false on an org
profile, where the project list is the page.
type: boolean
required: false
default: true
drift:
description: 'Check claimed tiers against reality and open a report PR.'
type: boolean
required: false
default: true
secrets:
status-token:
description: >-
Read-only token for THIS account only. Needed to see private repos;
without it the drift check is skipped and says so. Deliberately not
cross-account — a leak should cost one identity, not three.
required: false

permissions:
contents: write
pull-requests: write

jobs:
build:
name: Regenerate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Check out the shared status action
uses: actions/checkout@v4
with:
repository: privacykey/gh-workflows
ref: v1
path: .status-action
sparse-checkout: actions/project-status

- uses: actions/setup-node@v4
with:
node-version: '22'

# Fails on a malformed status.json before writing anything, so a bad edit
# can never leave the hub half-regenerated.
- name: Build
run: |
node .status-action/actions/project-status/build.mjs \
--hub "$GITHUB_WORKSPACE" \
--target "${{ inputs.target }}" \
--collapsed "${{ inputs.collapsed }}" \
${{ inputs.aggregate && format('--aggregate ''{0}''', inputs.aggregate) || '' }}

- name: Commit if anything changed
run: |
rm -rf .status-action
if [ -z "$(git status --porcelain)" ]; then
echo "Nothing to commit."
exit 0
fi
git config --local user.email "actions@noreply.github.com"
git config --local user.name "github-actions[bot]"
git add badges STATUS.md "${{ inputs.target }}"
git commit -m ":clipboard: Regenerate project status"
git push

drift:
name: Check against reality
runs-on: ubuntu-latest
needs: build
if: inputs.drift && github.event_name != 'push'
steps:
- uses: actions/checkout@v4

- name: Check out the shared status action
uses: actions/checkout@v4
with:
repository: privacykey/gh-workflows
ref: v1
path: .status-action
sparse-checkout: actions/project-status

- uses: actions/setup-node@v4
with:
node-version: '22'

- name: Derive findings
id: run
env:
STATUS_TOKEN: ${{ secrets.status-token }}
run: |
if [ -z "$STATUS_TOKEN" ]; then
echo "::warning::status-token not set — private repos can't be read, so drift is skipped."
exit 0
fi
node .status-action/actions/project-status/drift.mjs --hub "$GITHUB_WORKSPACE" > /tmp/report.md
cat /tmp/report.md >> "$GITHUB_STEP_SUMMARY"
if grep -qE 'thing\(s\) to fix|second look' /tmp/report.md; then
echo "found=true" >> "$GITHUB_OUTPUT"
fi

# One PR, force-updated in place. Skipping a week costs nothing — the next
# run rewrites it to current reality instead of stacking up a backlog.
- name: Open or update the report PR
if: steps.run.outputs.found == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH=chore/status-report
rm -rf .status-action
git config --local user.email "actions@noreply.github.com"
git config --local user.name "github-actions[bot]"
git checkout -B "$BRANCH"
mkdir -p .github/status
cp /tmp/report.md .github/status/report.md
git add .github/status/report.md
git commit -m ":clipboard: Status report"
git push -f origin "$BRANCH"
if gh pr view "$BRANCH" --json number >/dev/null 2>&1; then
gh pr edit "$BRANCH" --body-file /tmp/report.md
else
gh pr create --base "${{ github.event.repository.default_branch }}" --head "$BRANCH" \
--title "Project status — things to fix" --body-file /tmp/report.md
fi
189 changes: 189 additions & 0 deletions actions/project-status/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
#!/usr/bin/env node
// Regenerates one account's status artefacts from its own status.json:
// badges/<repo>.json shields /endpoint payloads (public + listed only)
// STATUS.md the uncollapsed link target every badge points at
// <target>.md the collapsed section, between the STATUS markers
//
// Each account owns its own hub, so a privacykey badge is served from a
// privacykey repo and no credential ever has to reach across identities.
// Tier definitions live here rather than in each hub, so three copies of the
// same promise can't drift apart.
//
// node build.mjs --hub <dir> write
// node build.mjs --hub <dir> --check verify only
// node build.mjs --hub <dir> --aggregate a/b,c/d also pull in other hubs
//
// --aggregate fetches other hubs' status.json over plain HTTPS. It needs no
// token: every hub lives in a public repo and each already withholds its
// private repos, so aggregating cannot surface anything not already published.

import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

const HERE = dirname(fileURLToPath(import.meta.url))
const arg = (name) => {
const i = process.argv.indexOf(name)
return i === -1 ? null : process.argv[i + 1]
}
const HUB = arg('--hub') ?? process.cwd()
const CHECK = process.argv.includes('--check')
const AGGREGATE = (arg('--aggregate') ?? '').split(',').map((s) => s.trim()).filter(Boolean)
const TARGET = arg('--target') ?? 'README.md'
// Collapsed on a personal profile, where this is one section among many.
// Expanded on an org profile, where the project list IS the page.
const COLLAPSED = arg('--collapsed') !== 'false'

const die = (m) => { console.error(`✗ ${m}`); process.exit(1) }

const { tiers, groups } = JSON.parse(readFileSync(join(HERE, 'tiers.json'), 'utf8'))
const hub = JSON.parse(readFileSync(join(HUB, 'status.json'), 'utf8'))

if (!/^\d{4}-\d{2}-\d{2}$/.test(hub.reviewed ?? '')) die('status.json: "reviewed" must be YYYY-MM-DD')
if (!hub.owner) die('status.json: "owner" is required')

const DISTRIBUTIONS = ['open-source', 'appstore-closed', 'public-pending', 'private']

const load = (owner, repos, source) => {
const out = []
for (const [repo, r] of Object.entries(repos)) {
const full = `${owner}/${repo}`
if (!tiers[r.tier]) die(`${full}: unknown tier "${r.tier}"`)
if (!groups[r.group]) die(`${full}: unknown group "${r.group}"`)
if (!DISTRIBUTIONS.includes(r.distribution)) die(`${full}: bad distribution "${r.distribution}"`)
if (typeof r.public !== 'boolean' || typeof r.listed !== 'boolean') die(`${full}: "public" and "listed" must be booleans`)
// The clause that stops an unreleased product reaching a public page.
if (r.listed && !r.public) die(`${full}: listed:true but public:false`)
if (r.tier === 'Fork' && !r.canonical) die(`${full}: Fork requires "canonical"`)
out.push({ owner, repo, full, source, ...r })
}
return out
}

const own = load(hub.owner, hub.repos, null)

// Other hubs contribute their listed repos to the rendered view only. Their
// badges stay theirs — nothing here writes into another account's namespace.
const foreign = []
const RAW = process.env.STATUS_RAW_BASE ?? 'https://raw.githubusercontent.com'
for (const ref of AGGREGATE) {
const url = `${RAW}/${ref}/main/status.json`
const res = await fetch(url)
if (!res.ok) die(`--aggregate ${ref}: ${url} returned HTTP ${res.status}`)
const other = await res.json()
foreign.push(...load(other.owner, other.repos, ref).filter((e) => e.listed))
}

const byTier = (a, b) => tiers[a.tier].order - tiers[b.tier].order || a.repo.localeCompare(b.repo)
const ownListed = own.filter((e) => e.listed)
const allListed = [...ownListed, ...foreign]

// Badges for this account's public, listed repos only. The badges directory is
// browsable, so one file per repo would publish an index of unreleased work.
const outputs = new Map()
for (const e of ownListed) {
outputs.set(`badges/${e.repo}.json`, JSON.stringify({
schemaVersion: 1, label: 'status', message: e.tier,
color: tiers[e.tier].color, style: 'flat', cacheSeconds: 3600,
}, null, 2) + '\n')
}

// ---------------------------------------------------------------- STATUS.md
// Repo names are unique within an account, so a bare `#<repo>` anchor is
// unambiguous here — the collision risk that forced owner-qualified anchors
// under a single combined hub doesn't exist once each account owns its own.
let md = `# Project status\n\n`
+ `What I promise for each ${hub.owner} project, and what I don't. Every repo's\n`
+ `status badge links here.\n\n`
+ `**Tiers last reviewed: ${hub.reviewed}.** Set by hand — it's when I last actually\n`
+ `looked, not when a script last ran.\n\n`
+ `| Tier | What it means |\n| --- | --- |\n`
+ Object.entries(tiers).sort((a, b) => a[1].order - b[1].order)
.filter(([n]) => own.some((e) => e.tier === n))
.map(([n, t]) => `| ${t.emoji} **${n}** | ${t.promise} |`).join('\n')
+ `\n\nPrivate and pre-announcement projects aren't listed here.\n\n---\n\n`

for (const [gk, g] of Object.entries(groups).sort((a, b) => a[1].order - b[1].order)) {
const inGroup = ownListed.filter((e) => e.group === gk).sort(byTier)
if (!inGroup.length) continue
md += `## ${g.title}\n\n`
for (const e of inGroup) {
const t = tiers[e.tier]
md += `### ${e.repo}\n\n${t.emoji} **${e.tier}** — ${t.oneLiner}\n\n${t.promise}\n\n`
if (e.distribution === 'appstore-closed') md += `_Ships on the App Store; source is closed._\n\n`
if (e.submissions) md += `_Submissions are ${e.submissions}._\n\n`
if (e.seekingMaintainer) md += `**Open to a new maintainer** — get in touch.\n\n`
if (e.note) md += `${e.note}\n\n`
md += `<https://github.com/${e.full}>\n\n`
}
}
outputs.set('STATUS.md', md.trimEnd() + '\n')

// ---------------------------------------------------------------- rendered region
// No GitHub alerts in here — they don't render inside a <details> block.
const scope = AGGREGATE.length ? 'every project I maintain' : `every ${hub.owner} project`
let region = COLLAPSED
? `<details>\n <summary><b>📋 Project status</b> — what I promise for ${scope} (reviewed ${hub.reviewed})</summary>\n <p>\n\n`
: `## Projects\n\nWhat I promise for ${scope}, and what I don't. Reviewed ${hub.reviewed}.\n\n`
region += `Each badge links to the full promise. Private and pre-announcement projects aren't listed.\n\n`
+ `| | Tier | What it means |\n| - | - | - |\n`
+ Object.entries(tiers).sort((a, b) => a[1].order - b[1].order)
.filter(([n]) => allListed.some((e) => e.tier === n))
.map(([n, t]) => `| ${t.emoji} | **${n}** | ${t.oneLiner} |`).join('\n')
+ `\n\n`

for (const [gk, g] of Object.entries(groups).sort((a, b) => a[1].order - b[1].order)) {
const inGroup = allListed.filter((e) => e.group === gk).sort(byTier)
if (!inGroup.length) continue
region += `**${g.title}**\n\n| Project | Status | |\n| - | - | - |\n`
for (const e of inGroup) {
const extra = [
e.distribution === 'appstore-closed' ? 'App Store, source closed' : '',
e.seekingMaintainer ? 'maintainer wanted' : '',
e.submissions === 'open' ? 'submissions open' : '',
].filter(Boolean).join(' · ')
region += `| [${e.repo}](https://github.com/${e.full}) | ${tiers[e.tier].emoji} ${e.tier} | ${extra} |\n`
}
region += `\n`
}
region += COLLAPSED ? ` </p>\n</details>` : `See [STATUS.md](STATUS.md) for what each tier promises.`

const START = '<!-- STATUS:START -->'
const END = '<!-- STATUS:END -->'
const targetPath = join(HUB, TARGET)
if (!existsSync(targetPath)) die(`${TARGET} not found in ${HUB}`)
const doc = readFileSync(targetPath, 'utf8')
if (!doc.includes(START) || !doc.includes(END)) die(`${TARGET} is missing the ${START} / ${END} markers`)
outputs.set(TARGET, doc.slice(0, doc.indexOf(START) + START.length) + `\n${region}\n` + doc.slice(doc.indexOf(END)))

// ---------------------------------------------------------------- write / check
if (CHECK) {
const stale = [...outputs].filter(([p, want]) => {
const abs = join(HUB, p)
return !existsSync(abs) || readFileSync(abs, 'utf8') !== want
}).map(([p]) => p)
if (stale.length) {
console.error(`✗ ${stale.length} generated file(s) stale or hand-edited:`)
stale.forEach((p) => console.error(` ${p}`))
process.exit(1)
}
console.log(`✓ ${hub.owner}: all ${outputs.size} generated files match status.json`)
process.exit(0)
}

// Rebuilt from scratch so a repo going private drops its badge rather than
// leaving a stale public one behind.
const badgeDir = join(HUB, 'badges')
if (existsSync(badgeDir)) rmSync(badgeDir, { recursive: true })
for (const [p, content] of outputs) {
const abs = join(HUB, p)
mkdirSync(dirname(abs), { recursive: true })
writeFileSync(abs, content)
}

const counts = {}
for (const e of own) counts[e.tier] = (counts[e.tier] ?? 0) + 1
console.log(`✓ ${hub.owner}: ${own.length} repos, ${ownListed.length} listed`
+ (foreign.length ? `, +${foreign.length} aggregated` : ''))
console.log(` ${Object.entries(counts).sort((a, b) => tiers[a[0]].order - tiers[b[0]].order)
.map(([t, n]) => `${tiers[t].emoji} ${t} ${n}`).join(' ')}`)
Loading