diff --git a/.github/tests/starchart_refresh_contract_test.py b/.github/tests/starchart_refresh_contract_test.py new file mode 100644 index 0000000..1454c19 --- /dev/null +++ b/.github/tests/starchart_refresh_contract_test.py @@ -0,0 +1,203 @@ +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github/workflows/starchart-refresh.yml" + + +class StarchartRefreshContractTest(unittest.TestCase): + def test_reusable_workflow_shape_and_narrow_permissions(self): + workflow = self.read_workflow() + + for expected in ( + " workflow_call:\n", + " branch:\n", + " required: true\n", + " output-path:\n", + " default: docs/assets/star-history.svg\n", + " max-pages:\n", + " type: number\n", + "permissions: {}", + " runs-on: ubuntu-24.04", + " timeout-minutes: 10", + "uses: step-security/harden-runner@", + "egress-policy: block", + "api.github.com:443", + "github.com:443", + ): + self.assertIn(expected, workflow) + + # contents: write is the whole point of this workflow, but it must be + # the ONLY elevated scope. A second write scope here would be a + # commit-back job that can also move issues, releases, or packages. + job_scopes = re.findall(r"^ (\w[\w-]*): write", workflow, re.MULTILINE) + self.assertEqual(job_scopes, ["contents"]) + + def test_generator_is_embedded_rather_than_fetched_at_run_time(self): + """The caller pins this file by SHA. Anything resolved at run time + escapes that pin, so the generator lives inline and the only network + reads are GitHub's own API.""" + workflow = self.read_workflow() + + self.assertIn("node --input-type=module - <<'GENERATOR'", workflow) + self.assertIn("application/vnd.github.star+json", workflow) + self.assertIn("https://api.github.com/", workflow) + + # No second repository checkout, and no curl/wget/npm pulling code in. + self.assertEqual(workflow.count("actions/checkout@"), 1) + self.assertNotIn("repository: CodesWhat/.github", workflow) + for forbidden in ("curl ", "wget ", "npx ", "npm install", "pip install"): + self.assertNotIn(forbidden, workflow) + + def test_untrusted_input_is_read_from_the_environment(self): + """Caller-controlled values reach the script as env vars, never as + ${{ }} interpolated into a shell or JavaScript body.""" + workflow = self.read_workflow() + + for expected in ( + "TARGET_REPO: ${{ github.repository }}", + "OUTPUT_PATH: ${{ inputs.output-path }}", + "MAX_PAGES: ${{ inputs.max-pages }}", + "TARGET_BRANCH: ${{ inputs.branch }}", + "const repo = process.env.TARGET_REPO", + "const out = process.env.OUTPUT_PATH", + ): + self.assertIn(expected, workflow) + + generator = workflow.split("<<'GENERATOR'", 1)[1].split("GENERATOR", 1)[0] + self.assertNotIn("${{", generator) + + def test_chart_is_self_contained_with_no_external_references(self): + """A committed artifact that reaches out at render time would + reintroduce exactly the silent failure this replaced.""" + workflow = self.read_workflow() + + self.assertIn(" maxPages)", workflow) + self.assertNotIn("Math.min(Math.ceil(total / 100), maxPages)", workflow) + self.assertNotIn("::warning::capping", workflow) + + def test_too_few_stars_is_a_clean_exit_not_a_failure(self): + """A young repo having one star is a real state, not a broken build. + Reporting red there trains people to ignore the signal.""" + workflow = self.read_workflow() + + self.assertIn("if (stars.length < 2)", workflow) + self.assertIn("process.exit(0)", workflow) + self.assertNotIn("process.exit(1)", workflow) + self.assertNotIn("process.exit(2)", workflow) + + def test_embedded_generator_is_valid_javascript(self): + """This workflow never runs in this repository, so a syntax error in + the heredoc would first surface in a consumer's scheduled job. Parse + it here instead.""" + node = shutil.which("node") + if node is None: + self.skipTest("node is not available") + + source = self.read_generator() + with tempfile.TemporaryDirectory() as tmp: + script = Path(tmp) / "generator.mjs" + script.write_text(source) + result = subprocess.run( + [node, "--check", str(script)], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + + def test_workflow_pins_actions_and_is_run_by_standards_validation(self): + workflow = self.read_workflow() + actions = re.findall(r"^\s+uses: ([^\s#]+)", workflow, re.MULTILINE) + + self.assertTrue(actions) + for action in actions: + self.assertRegex(action, r"^[^@]+@[0-9a-f]{40}$") + + validation = (ROOT / ".github/workflows/standards-validation.yml").read_text() + self.assertIn("starchart_refresh_contract_test.py", validation) + + def read_workflow(self): + self.assertTrue(WORKFLOW.is_file(), f"missing workflow: {WORKFLOW}") + return WORKFLOW.read_text() + + def read_generator(self): + """Recover the generator exactly as the shell will see it: YAML strips + the run block's base indentation, so a heredoc body that only looks + right in the file can still reach node malformed.""" + workflow = self.read_workflow() + opener = "node --input-type=module - <<'GENERATOR'\n" + self.assertIn(opener, workflow) + + indent = " " * (len(workflow.split(opener)[0].rsplit("\n", 1)[-1])) + self.assertTrue(indent, "expected the run block to be indented") + + body = workflow.split(opener, 1)[1].split(f"\n{indent}GENERATOR", 1)[0] + return "\n".join( + line[len(indent):] if line.startswith(indent) else line + for line in body.split("\n") + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/standards-validation.yml b/.github/workflows/standards-validation.yml index 0ea5ad0..3966ebe 100644 --- a/.github/workflows/standards-validation.yml +++ b/.github/workflows/standards-validation.yml @@ -53,6 +53,7 @@ jobs: python3 .github/tests/greptile_config_contract_test.py python3 .github/tests/quality_report_contract_test.py python3 .github/tests/reusable_ci_contract_test.py + python3 .github/tests/starchart_refresh_contract_test.py - name: Lint Markdown run: | diff --git a/.github/workflows/starchart-refresh.yml b/.github/workflows/starchart-refresh.yml new file mode 100644 index 0000000..8517969 --- /dev/null +++ b/.github/workflows/starchart-refresh.yml @@ -0,0 +1,232 @@ +name: Star Chart Refresh + +# Regenerates a repository's star-history chart as a first-party SVG and +# commits it back. The chart is a committed artifact on purpose: it needs no +# secret and no request at render time, so it cannot fail silently the way a +# live route or a third-party embed can (standards/readme-shape.md). +# +# Callers declare their own triggers and pin this file by full commit SHA: +# +# on: +# schedule: [{cron: "17 6 * * 1"}] +# workflow_dispatch: +# permissions: {} +# jobs: +# starchart: +# permissions: +# contents: write +# uses: CodesWhat/.github/.github/workflows/starchart-refresh.yml@ +# with: +# branch: dev/v1.7 +# +# The generator is embedded rather than checked out from a second repository +# so that the caller's SHA pin covers every line of behaviour, with nothing +# resolved at run time. + +on: + workflow_call: + inputs: + branch: + description: Branch to read and commit the refreshed chart to. Never main under the strict release flow. + required: true + type: string + output-path: + description: Path the SVG is written to, relative to the repository root. + required: false + default: docs/assets/star-history.svg + type: string + max-pages: + description: Safety cap on stargazer pages fetched (100 stars per page). + required: false + default: 100 + type: number + +permissions: {} + +jobs: + refresh: + name: Star Chart Refresh + runs-on: ubuntu-24.04 + timeout-minutes: 10 + concurrency: + group: starchart-refresh-${{ github.repository }}-${{ inputs.branch }} + cancel-in-progress: false + permissions: + contents: write # Commit the regenerated SVG back to the caller's branch. + + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: block + allowed-endpoints: > + api.github.com:443 + github.com:443 + + - name: Reject a protected branch as the commit-back target + env: + TARGET_BRANCH: ${{ inputs.branch }} + run: | + set -euo pipefail + # Omitting a default only prevents omission. Under the strict + # release flow nothing commits back to a default branch, and a + # named rejection here beats a confusing push rejection at the end + # of the job, or none at all on a repo that forgot its ruleset. + case "${TARGET_BRANCH#refs/heads/}" in + main | master | HEAD | "") + echo "::error::refusing to commit the chart to '$TARGET_BRANCH'; pass an integration branch such as dev/v1.7" >&2 + exit 1 + ;; + esac + + - name: Check out the caller at the target branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.branch }} + # Kept, unlike everywhere else in this org: the whole job is a + # commit-back, so the pushing credential has to survive checkout. + persist-credentials: true # zizmor: ignore[artipacked] + + - name: Generate the star-history SVG + env: + # Read via the environment, never interpolated into the script body. + TARGET_REPO: ${{ github.repository }} + OUTPUT_PATH: ${{ inputs.output-path }} + MAX_PAGES: ${{ inputs.max-pages }} + GITHUB_API_TOKEN: ${{ github.token }} + run: | + node --input-type=module - <<'GENERATOR' + import { mkdirSync, writeFileSync } from 'node:fs' + import { dirname, isAbsolute, relative, resolve } from 'node:path' + + const repo = process.env.TARGET_REPO + const out = process.env.OUTPUT_PATH + const maxPages = Number(process.env.MAX_PAGES) + const auth = process.env.GITHUB_API_TOKEN + const workspace = process.env.GITHUB_WORKSPACE + + // Reading through the environment stops script injection but not + // path traversal. A write outside the checkout is worse than an + // error: the commit step finds nothing staged and reports success. + const target = resolve(workspace, out) + if (isAbsolute(out) || relative(workspace, target).startsWith('..')) { + throw new Error(`output-path must stay inside the repository: ${out}`) + } + if (!Number.isInteger(maxPages) || maxPages < 1) { + throw new Error(`max-pages must be a positive integer, got: ${process.env.MAX_PAGES}`) + } + + const api = async (path, accept) => { + const res = await fetch(`https://api.github.com/${path}`, { + headers: { + accept, + authorization: `Bearer ${auth}`, + 'user-agent': 'codeswhat-starchart', + 'x-github-api-version': '2022-11-28', + }, + }) + if (!res.ok) throw new Error(`GET ${path} -> ${res.status} ${res.statusText}`) + return res.json() + } + + const total = (await api(`repos/${repo}`, 'application/vnd.github+json')).stargazers_count + const pages = Math.ceil(total / 100) + // Truncating is a hard failure, not a warning. A chart drawn from + // the first N pages is a partial history rendered as a whole one, + // and a log line nobody reads is how that ships unnoticed. + if (pages > maxPages) { + throw new Error( + `${repo} has ${total} stars needing ${pages} pages but max-pages is ${maxPages}; ` + + 'raise the cap rather than publishing a truncated history', + ) + } + + const stars = [] + for (let p = 1; p <= pages; p++) { + const page = await api( + `repos/${repo}/stargazers?per_page=100&page=${p}`, + 'application/vnd.github.star+json', + ) + for (const s of page) if (s.starred_at) stars.push(new Date(s.starred_at).getTime()) + } + stars.sort((a, b) => a - b) + + // Too few points to plot is a real state for a young repo, not a + // failure. Leaving the previous SVG in place beats committing an + // empty chart or reporting red on nothing being wrong. + if (stars.length < 2) { + console.log(`::notice::${repo} has ${stars.length} star(s); leaving the chart untouched`) + process.exit(0) + } + + const W = 800, H = 400, P = { t: 30, r: 30, b: 45, l: 60 } + const [t0, t1] = [stars[0], stars.at(-1)] + const x = (t) => P.l + ((t - t0) / (t1 - t0 || 1)) * (W - P.l - P.r) + const y = (n) => H - P.b - (n / stars.length) * (H - P.t - P.b) + + const pts = stars.map((t, i) => `${x(t).toFixed(1)},${y(i + 1).toFixed(1)}`) + const line = `M ${x(t0).toFixed(1)},${y(0).toFixed(1)} L ${pts.join(' L ')}` + const area = `${line} L ${x(t1).toFixed(1)},${(H - P.b).toFixed(1)} L ${x(t0).toFixed(1)},${(H - P.b).toFixed(1)} Z` + + const fmt = (ms) => new Date(ms).toISOString().slice(0, 7) + const ticks = 4 + const xLabels = Array.from({ length: ticks + 1 }, (_, i) => { + const t = t0 + ((t1 - t0) * i) / ticks + // Anchor the end labels inward so they can't clip the viewBox edges. + const anchor = i === 0 ? 'start' : i === ticks ? 'end' : 'middle' + return `${fmt(t)}` + }).join('\n ') + const yLabels = Array.from({ length: ticks + 1 }, (_, i) => { + const n = Math.round((stars.length * i) / ticks) + return ` + ${n}` + }).join('\n ') + + // Write the path that was validated, not the raw input. In Actions + // the working directory is the workspace so they agree, but a + // check that doesn't govern the write is decoration. + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, ` + Star history for ${repo} + + + ${repo} · ${stars.length} stars + + ${yLabels} + + + + + ${xLabels} + + + `) + console.log(`${repo}: ${stars.length} stars, ${pages} API call(s) -> ${out}`) + GENERATOR + + - name: Commit the chart only when it actually changed + env: + OUTPUT_PATH: ${{ inputs.output-path }} + TARGET_BRANCH: ${{ inputs.branch }} + run: | + set -euo pipefail + # --porcelain rather than `git diff`, which reports clean for a + # path that is new and therefore still untracked on first run. + if [ -z "$(git status --porcelain -- "$OUTPUT_PATH")" ]; then + echo "::notice::chart unchanged; nothing to commit" + exit 0 + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -- "$OUTPUT_PATH" + git commit -m "chore(docs): refresh the star-history chart" + git push origin "HEAD:$TARGET_BRANCH" diff --git a/REPOSITORY_ONBOARDING.md b/REPOSITORY_ONBOARDING.md index f0bfd3b..896dda6 100644 --- a/REPOSITORY_ONBOARDING.md +++ b/REPOSITORY_ONBOARDING.md @@ -215,6 +215,19 @@ Add these only when the behavior exists: with a clear enforced or advisory threshold. - [ ] Translation synchronization only for a repository with a translation source of truth and configured provider credentials. +- [ ] Star-history chart refresh for a public repository whose README carries a + Star History section. Call this repository's `starchart-refresh.yml` at a + pinned full commit SHA from a thin caller on `schedule` plus + `workflow_dispatch`, granting the job `contents: write` and passing the + active integration branch as `branch`. It regenerates a first-party SVG from + GitHub's stargazer timestamps and commits it only when the chart actually + changed. The chart is a committed artifact rather than a live route or a + third-party embed on purpose: it needs no secret and makes no request at + render time, so a stale one is visible and a missing one is a visibly broken + image, where a route that loses its credential serves a plausible placeholder + at HTTP 200 indefinitely. Do not embed `star-history.com` or `warpchart.dev`; + both are retired organization-wide, and adopting the chart means removing + what it replaced in the same change. ### Qlty