diff --git a/.github/tests/main_is_released_contract_test.py b/.github/tests/main_is_released_contract_test.py new file mode 100644 index 0000000..c9368b2 --- /dev/null +++ b/.github/tests/main_is_released_contract_test.py @@ -0,0 +1,93 @@ +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github/workflows/main-is-released.yml" + + +class MainIsReleasedContractTest(unittest.TestCase): + def test_reusable_workflow_shape_and_read_only_permissions(self): + workflow = self.read_workflow() + + for expected in ( + " workflow_call:\n", + "permissions: {}", + " runs-on: ubuntu-24.04", + "uses: step-security/harden-runner@", + "egress-policy: block", + "github.com:443", + " contents: read", + ): + self.assertIn(expected, workflow) + + # This check only ever reads. Any write scope here would be a + # scheduled job with credentials to change the thing it audits. + self.assertNotIn(": write", workflow) + self.assertNotIn("persist-credentials: true", workflow) + + def test_the_invariant_is_an_exact_tag_match(self): + workflow = self.read_workflow() + + self.assertIn("git describe --exact-match --tags HEAD", workflow) + # --abbrev=0 alone answers "what tag is nearest", which is true of a + # drifted main too. It may only be used to report inside the failure + # branch, never in the condition that decides pass or fail — so the + # decisive slice is the condition line, not the whole if-block. + decisive = workflow.split("if ! tag=", 1)[1].split("\n", 1)[0] + self.assertIn("--exact-match", decisive) + self.assertNotIn("--abbrev=0", decisive) + + def test_no_tags_is_reported_as_unevaluable_not_as_drift(self): + """A repo with zero tags produces the same 'not tagged' as one that + drifted. Those need different answers, so the measurement proves it + could have worked before its result is trusted.""" + workflow = self.read_workflow() + + self.assertIn('if [ -z "$(git tag)" ]; then', workflow) + self.assertLess( + workflow.index('if [ -z "$(git tag)" ]'), + workflow.index("git describe --exact-match"), + ) + + def test_shallow_checkout_would_break_the_measurement(self): + """describe needs tags and history; a shallow clone fails for the + wrong reason and reads as real drift.""" + workflow = self.read_workflow() + self.assertIn("fetch-depth: 0", workflow) + + def test_a_prerelease_on_main_fails_by_default(self): + """A release candidate on the default branch is the exact drift this + exists to catch: drydock's main sat on v1.7.0-rc.2.""" + workflow = self.read_workflow() + + self.assertIn(" default: false\n", workflow) + self.assertIn('if [ "$ALLOW_PRERELEASE" != "true" ]', workflow) + self.assertIn("ALLOW_PRERELEASE: ${{ inputs.allow-prerelease }}", workflow) + + def test_failures_say_what_to_do_next(self): + workflow = self.read_workflow() + + for expected in ("::error::", "cut a release", "dev branch"): + self.assertIn(expected, workflow) + self.assertIn("set -euo pipefail", workflow) + + 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("main_is_released_contract_test.py", validation) + + def read_workflow(self): + self.assertTrue(WORKFLOW.is_file(), f"missing workflow: {WORKFLOW}") + return WORKFLOW.read_text() + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/tests/starchart_refresh_contract_test.py b/.github/tests/starchart_refresh_contract_test.py index 1454c19..8fb137f 100644 --- a/.github/tests/starchart_refresh_contract_test.py +++ b/.github/tests/starchart_refresh_contract_test.py @@ -20,6 +20,7 @@ def test_reusable_workflow_shape_and_narrow_permissions(self): " required: true\n", " output-path:\n", " default: docs/assets/star-history.svg\n", + " accent:\n", " max-pages:\n", " type: number\n", "permissions: {}", @@ -62,10 +63,12 @@ def test_untrusted_input_is_read_from_the_environment(self): for expected in ( "TARGET_REPO: ${{ github.repository }}", "OUTPUT_PATH: ${{ inputs.output-path }}", + "ACCENT: ${{ inputs.accent }}", "MAX_PAGES: ${{ inputs.max-pages }}", "TARGET_BRANCH: ${{ inputs.branch }}", "const repo = process.env.TARGET_REPO", "const out = process.env.OUTPUT_PATH", + "const accent = process.env.ACCENT", ): self.assertIn(expected, workflow) @@ -78,8 +81,13 @@ def test_chart_is_self_contained_with_no_external_references(self): workflow = self.read_workflow() self.assertIn("-embedded SVG, so a self-theming file shows a + # white card to anyone reading GitHub dark with a light OS. Two files + # and a README is the mechanism that does follow the toggle. + self.assertNotIn("prefers-color-scheme", workflow) for forbidden in (" that gained a fresh light chart and kept a stale dark + one shows two different histories depending on who is looking, and + nothing reports it as wrong.""" + workflow = self.read_workflow() + + self.assertIn("writeFileSync(target, light)", workflow) + self.assertIn("writeFileSync(darkTarget, dark)", workflow) + self.assertIn('DARK_PATH="${OUTPUT_PATH%.svg}-dark.svg"', workflow) + self.assertIn('git add -- "$OUTPUT_PATH" "$DARK_PATH"', workflow) + self.assertIn('git status --porcelain -- "$OUTPUT_PATH" "$DARK_PATH"', workflow) + + # Both derivations strip a .svg suffix, so the input has to have one. + self.assertIn("!out.endsWith('.svg')", workflow) + + def test_the_documented_trigger_is_the_release_cut_not_a_cron(self): + """A committed artifact refreshed on a schedule mutates underneath a + tag, which is what 'main is the released version' forbids.""" + workflow = self.read_workflow() + + example = workflow.split("# on:\n", 1)[1].split("# permissions:", 1)[0] + self.assertIn("release:", example) + self.assertIn("types: [published]", example) + self.assertIn('# accent: "#49bcfb"', workflow) + self.assertNotIn("cron", example) + self.assertNotIn("schedule:", example) + + def test_the_embedded_renderer_names_its_source(self): + """The same renderer exists here and in ops. Hand-copying is how they + drift, so the block is generated and says so.""" + workflow = self.read_workflow() + + self.assertIn("// BEGIN GENERATED FROM ops scripts/starchart/render-chart.mjs", workflow) + self.assertIn("// END GENERATED", workflow) + self.assertIn("splice-into-workflow.mjs", workflow) + self.assertLess( + workflow.index("// BEGIN GENERATED"), + workflow.index("// END GENERATED"), + ) + 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.""" diff --git a/.github/workflows/main-is-released.yml b/.github/workflows/main-is-released.yml new file mode 100644 index 0000000..c4bb47e --- /dev/null +++ b/.github/workflows/main-is-released.yml @@ -0,0 +1,92 @@ +name: Main Is Released + +# Asserts the one invariant behind "main is the released version, not the +# newest work": every commit on main is a tagged release, so an untagged main +# head is itself the alarm (REPOSITORY_ONBOARDING.md section 3). +# +# Callers declare their own triggers and pin this file by full commit SHA: +# +# on: +# schedule: [{cron: "23 7 * * *"}] +# push: +# branches: [main] +# workflow_dispatch: +# permissions: {} +# jobs: +# released: +# uses: CodesWhat/.github/.github/workflows/main-is-released.yml@ +# +# Continuously deployed repositories that never tag (a website, a meta repo) +# should not call this at all rather than calling it with a carve-out input. +# For them "main equals production" is enforced by the deploy, not by a tag. + +on: + workflow_call: + inputs: + allow-prerelease: + description: > + Accept a prerelease tag (-rc.N, -beta.N) as satisfying the invariant. + Defaults false: a release candidate on main is the exact drift this + check exists to catch. + required: false + default: false + type: boolean + +permissions: {} + +jobs: + released: + name: Main Is Released + runs-on: ubuntu-24.04 + timeout-minutes: 5 + permissions: + contents: read # Read main and its tags. + + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: block + allowed-endpoints: > + github.com:443 + + - name: Check out main with tags + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: main + # Tags only travel with full history; a shallow clone would make + # describe fail for the wrong reason and read as real drift. + fetch-depth: 0 + persist-credentials: false + + - name: Assert main points at a release tag + env: + ALLOW_PRERELEASE: ${{ inputs.allow-prerelease }} + run: | + set -euo pipefail + + # Prove the measurement can work before trusting its result. A repo + # with no tags at all reports the same "not tagged" as one that + # drifted, and those need different answers. + if [ -z "$(git tag)" ]; then + echo "::error::no tags in this repository, so the invariant cannot be evaluated; a repository that never releases should not call this workflow" >&2 + exit 1 + fi + + if ! tag="$(git describe --exact-match --tags HEAD 2>/dev/null)"; then + latest="$(git describe --tags --abbrev=0 HEAD 2>/dev/null || echo '')" + ahead="$(git rev-list --count "${latest}..HEAD" 2>/dev/null || echo '?')" + echo "::error::main is not a tagged release. Newest reachable tag is ${latest}, and main is ${ahead} commit(s) past it. Either cut a release or move the unshipped work to a dev branch." >&2 + exit 1 + fi + + case "$tag" in + *-*) + if [ "$ALLOW_PRERELEASE" != "true" ]; then + echo "::error::main points at prerelease ${tag}. Prereleases belong on the dev branch; main carries what users actually run." >&2 + exit 1 + fi + echo "::warning::main points at prerelease ${tag}, accepted because allow-prerelease is set" ;; + esac + + echo "main is released at ${tag}" diff --git a/.github/workflows/standards-validation.yml b/.github/workflows/standards-validation.yml index 3966ebe..425ef4e 100644 --- a/.github/workflows/standards-validation.yml +++ b/.github/workflows/standards-validation.yml @@ -54,6 +54,7 @@ jobs: python3 .github/tests/quality_report_contract_test.py python3 .github/tests/reusable_ci_contract_test.py python3 .github/tests/starchart_refresh_contract_test.py + python3 .github/tests/main_is_released_contract_test.py - name: Lint Markdown run: | diff --git a/.github/workflows/starchart-refresh.yml b/.github/workflows/starchart-refresh.yml index 8517969..3caaf04 100644 --- a/.github/workflows/starchart-refresh.yml +++ b/.github/workflows/starchart-refresh.yml @@ -8,7 +8,8 @@ name: Star Chart Refresh # Callers declare their own triggers and pin this file by full commit SHA: # # on: -# schedule: [{cron: "17 6 * * 1"}] +# release: +# types: [published] # workflow_dispatch: # permissions: {} # jobs: @@ -18,10 +19,19 @@ name: Star Chart Refresh # uses: CodesWhat/.github/.github/workflows/starchart-refresh.yml@ # with: # branch: dev/v1.7 +# accent: "#49bcfb" +# +# The trigger is the release cut, not a cron. A committed artifact refreshed +# on a schedule mutates underneath a tag, which is exactly what "main is the +# released version" forbids. Regenerating at the cut means the chart in a +# released README is as of that release. # # 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. +# resolved at run time. It is written by ops +# scripts/starchart/splice-into-workflow.mjs from that repo's +# render-chart.mjs; edit there and re-splice rather than editing the block +# below by hand. on: workflow_call: @@ -31,10 +41,20 @@ on: required: true type: string output-path: - description: Path the SVG is written to, relative to the repository root. + description: > + Path the light SVG is written to, relative to the repository root. + Must end in .svg; the dark sibling is written alongside it with a + -dark suffix. required: false default: docs/assets/star-history.svg type: string + accent: + description: > + The repository's logo colour as #rrggbb. The dark-theme variant is + derived from it, so there is only one value to keep in sync. The + registry is in ops standards/readme-shape.md. + required: true + type: string max-pages: description: Safety cap on stargazer pages fetched (100 stars per page). required: false @@ -87,11 +107,12 @@ jobs: # commit-back, so the pushing credential has to survive checkout. persist-credentials: true # zizmor: ignore[artipacked] - - name: Generate the star-history SVG + - name: Generate the star-history SVGs env: # Read via the environment, never interpolated into the script body. TARGET_REPO: ${{ github.repository }} OUTPUT_PATH: ${{ inputs.output-path }} + ACCENT: ${{ inputs.accent }} MAX_PAGES: ${{ inputs.max-pages }} GITHUB_API_TOKEN: ${{ github.token }} run: | @@ -101,6 +122,7 @@ jobs: const repo = process.env.TARGET_REPO const out = process.env.OUTPUT_PATH + const accent = process.env.ACCENT const maxPages = Number(process.env.MAX_PAGES) const auth = process.env.GITHUB_API_TOKEN const workspace = process.env.GITHUB_WORKSPACE @@ -112,6 +134,17 @@ jobs: if (isAbsolute(out) || relative(workspace, target).startsWith('..')) { throw new Error(`output-path must stay inside the repository: ${out}`) } + // The dark sibling is derived from this path and the commit step + // derives it again in shell. Both derivations assume the suffix. + if (!out.endsWith('.svg')) { + throw new Error(`output-path must end in .svg: ${out}`) + } + // An accent that isn't a colour renders a chart with no line rather + // than failing, which is the silent-success shape this file exists + // to avoid. Reject it here instead. + if (!/^#[0-9a-fA-F]{6}$/.test(accent ?? '')) { + throw new Error(`accent must be a #rrggbb colour, got: ${accent}`) + } if (!Number.isInteger(maxPages) || maxPages < 1) { throw new Error(`max-pages must be a positive integer, got: ${process.env.MAX_PAGES}`) } @@ -159,58 +192,285 @@ jobs: 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 ') + // BEGIN GENERATED FROM ops scripts/starchart/render-chart.mjs + // Shared renderer: star timestamps -> self-contained SVG. + // Split out from the fetch layer so the identical code can be exercised + // against fixture data without hitting the API. + // + // The visual shape is fixed (standards/readme-shape.md). The only thing a + // repository varies is `accent`, its logo's main colour. + + const W = 900 + const H = 460 + const PLOT = { l: 62, r: 860, top: 76, base: 408 } + const SAMPLE = 10 // px between resampled points along the curve + + const LIGHT = { + card: '#ffffff', + edge: '#d0d7de', + ink: '#1f2328', + muted: '#656d76', + grid: '#eaeef2', + axis: '#d0d7de', + ylabel: '#8c959f', + } + const DARK = { + card: '#0d1117', + edge: '#30363d', + ink: '#e6edf3', + muted: '#8b949e', + grid: '#21262d', + axis: '#30363d', + ylabel: '#6e7681', + } + + const SANS = "-apple-system,'Segoe UI',Helvetica,Arial,sans-serif" + const MONO = 'ui-monospace,SFMono-Regular,Menlo,monospace' + + const NICE = [1, 2, 2.5, 4, 5, 10] + // Pick a y-axis that reads like a human wrote it (50/100/150/200) AND wastes + // as little of the plot as possible. Rounding the step alone isn't enough: + // 239 stars over 4 ticks rounds to 100, which puts the curve in the bottom + // 60% of a chart whose whole job is showing the curve. So search step-and- + // tick-count pairs and keep the smallest ceiling that clears the total. + const niceAxis = (total) => { + const need = Math.max(total * 1.02, 1) + let best = null + for (let ticks = 3; ticks <= 6; ticks += 1) { + for (let mag = 1; mag <= 10 ** 7; mag *= 10) { + for (const n of NICE) { + const step = n * mag + if (!Number.isInteger(step)) continue + const max = step * ticks + if (max < need) continue + if (!best || max < best.max) best = { step, ticks, max } + break + } + } + } + return best + } + + const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>') + const num = (n) => Number(n.toFixed(1)) + + // "Feb 10 - Aug 21, 2026" when the whole history sits in one year, and both + // years spelled out when it doesn't. The axis below carries month names only, + // so this line is the sole place the year appears in the common case. + const range = (a, b) => { + const [d0, d1] = [new Date(a), new Date(b)] + const [y0, y1] = [d0.getUTCFullYear(), d1.getUTCFullYear()] + const day = (d) => `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}` + return y0 === y1 + ? `${day(d0)} – ${day(d1)}, ${y1}` + : `${day(d0)}, ${y0} – ${day(d1)}, ${y1}` + } + + const srgb = (c) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4) + const luminance = (hex) => { + const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) + return 0.2126 * srgb(r) + 0.7152 * srgb(g) + 0.0722 * srgb(b) + } + const contrast = (a, b) => { + const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p) + return (x + 0.05) / (y + 0.05) + } + + const toHsl = (hex) => { + const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255) + const mx = Math.max(r, g, b) + const mn = Math.min(r, g, b) + const l = (mx + mn) / 2 + if (mx === mn) return [0, 0, l] + const d = mx - mn + const s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn) + const h = + mx === r ? (g - b) / d + (g < b ? 6 : 0) : mx === g ? (b - r) / d + 2 : (r - g) / d + 4 + return [h / 6, s, l] + } + const channel = (p, q, t0) => { + const t = (t0 + 1) % 1 + if (t < 1 / 6) return p + (q - p) * 6 * t + if (t < 1 / 2) return q + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6 + return p + } + const toHex = (h, s, l) => { + let rgb = [l, l, l] + if (s) { + const q = l < 0.5 ? l * (1 + s) : l + s - l * s + const p = 2 * l - q + rgb = [channel(p, q, h + 1 / 3), channel(p, q, h), channel(p, q, h - 1 / 3)] + } + return `#${rgb.map((v) => Math.round(v * 255).toString(16).padStart(2, '0')).join('')}` + } + + // A logo colour picked against white can vanish on GitHub's dark surface: + // portwing's #7230d2 scores 2.8:1 there. Rather than make every repository + // record two colours and keep them in sync, derive the dark one by raising + // lightness at a fixed hue and saturation until it clears 6:1 — the band + // drydock's untouched #49bcfb already sits in. Lifting lightness rather than + // blending toward white is what keeps sockguard's orange an orange instead of + // fading it to salmon. + const DARK_TARGET = 6 + const forDark = (accent, surface) => { + const [h, s] = toHsl(accent) + let [, , l] = toHsl(accent) + let out = accent + while (contrast(out, surface) < DARK_TARGET && l < 0.92) { + l += 0.02 + out = toHex(h, s, l) + } + return out + } + + // Resample the step function onto an even x grid, then run a monotone cubic + // (Fritsch-Carlson) through it. A plain cardinal spline overshoots on a curve + // this flat, and an overshoot on a cumulative star count draws a dip that + // never happened. + const smoothPath = (points) => { + const n = points.length + const slope = [] + for (let i = 0; i < n - 1; i += 1) { + slope.push((points[i + 1][1] - points[i][1]) / (points[i + 1][0] - points[i][0])) + } + const tan = [slope[0]] + for (let i = 1; i < n - 1; i += 1) { + tan.push(slope[i - 1] * slope[i] <= 0 ? 0 : (slope[i - 1] + slope[i]) / 2) + } + tan.push(slope[n - 2]) + for (let i = 0; i < n - 1; i += 1) { + if (slope[i] === 0) { + tan[i] = 0 + tan[i + 1] = 0 + continue + } + const a = tan[i] / slope[i] + const b = tan[i + 1] / slope[i] + const s = a * a + b * b + if (s > 9) { + const t = 3 / Math.sqrt(s) + tan[i] = t * a * slope[i] + tan[i + 1] = t * b * slope[i] + } + } + + let d = `M ${num(points[0][0])},${num(points[0][1])}` + for (let i = 0; i < n - 1; i += 1) { + const [x0, y0] = points[i] + const [x1, y1] = points[i + 1] + const h = (x1 - x0) / 3 + d += ` C ${num(x0 + h)},${num(y0 + tan[i] * h)} ${num(x1 - h)},${num(y1 - tan[i + 1] * h)} ${num(x1)},${num(y1)}` + } + return d + } + + const draw = (repo, stars, accent, theme, id) => { + const c = theme === 'dark' ? DARK : LIGHT + const ink = theme === 'dark' ? forDark(accent, c.card) : accent + const total = stars.length + const [t0, t1] = [stars[0], stars.at(-1)] + const { step, ticks, max: yMax } = niceAxis(total) + + const plotW = PLOT.r - PLOT.l + const plotH = PLOT.base - PLOT.top + const x = (t) => PLOT.l + ((t - t0) / (t1 - t0 || 1)) * plotW + const y = (n) => PLOT.base - (n / yMax) * plotH + + // Star i lands at x(stars[i]); between stars the count is flat. Sample that + // step function on an even grid so the spline has uniform spans to work with. + const grid = [] + for (let px = PLOT.l; px < PLOT.r; px += SAMPLE) grid.push(px) + grid.push(PLOT.r) + let seen = 0 + const points = grid.map((px) => { + while (seen < total && x(stars[seen]) <= px) seen += 1 + return [px, y(seen)] + }) + + const linePath = smoothPath(points) + const areaPath = `${linePath} L ${PLOT.r},${PLOT.base} L ${PLOT.l},${PLOT.base} Z` + + const gridLines = Array.from({ length: ticks - 1 }, (_, i) => { + const gy = num(y(step * (i + 1))) + return `` + }).join('') + + const yLabels = Array.from({ length: ticks - 1 }, (_, i) => { + const n = step * (i + 1) + return `${n}` + }).join('') + + const xCount = 5 + const stops = Array.from({ length: xCount }, (_, i) => new Date(t0 + ((t1 - t0) * i) / (xCount - 1))) + // "Feb 26" reads as the 26th of February, and the subtitle already carries + // the years, so month names alone are the default. A history shorter than + // five months puts two stops in one month and prints "Jun Jun Jul Jul Aug", + // so fall back to day precision when the labels collide rather than guess a + // span threshold. Either way a year appears only where one changes. + const name = (d) => MONTHS[d.getUTCMonth()] + const byMonth = stops.map(name) + const withDay = new Set(byMonth).size < byMonth.length + let lastYear = null + const xLabels = stops + .map((d, i) => { + const year = d.getUTCFullYear() + const base = withDay ? `${name(d)} ${d.getUTCDate()}` : name(d) + const label = lastYear !== null && year !== lastYear ? `${base}, ${year}` : base + lastYear = year + const anchor = + i === 0 ? '' : i === xCount - 1 ? ' text-anchor="end"' : ' text-anchor="middle"' + return `${label}` + }) + .join('') + + const [endX, endY] = points.at(-1) + + return ` + Star history for ${esc(repo)} — ${total} stars + + + + + + + + ${esc(repo)} + ${range(t0, t1)} + ${total} + STARS + ${gridLines} + + + + + ${xLabels} + ${yLabels} + + ` + } + + // GitHub's theme toggle does not reach a media query inside an -embedded + // SVG, but it does drive a element in the README. So ship the pair + // and let the markup choose, rather than one file that guesses. + const renderChart = (repo, stars, accent) => ({ + light: draw(repo, stars, accent, 'light', 'sc-light'), + dark: draw(repo, stars, accent, 'dark', 'sc-dark'), + }) + + const darkAccent = (accent) => forDark(accent, DARK.card) + // END GENERATED // 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. + const darkTarget = target.replace(/\.svg$/, '-dark.svg') + const { light, dark } = renderChart(repo, stars, accent) 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}`) + writeFileSync(target, light) + writeFileSync(darkTarget, dark) + console.log(`${repo}: ${stars.length} stars, ${pages} API call(s) -> ${out} + dark`) GENERATOR - name: Commit the chart only when it actually changed @@ -219,14 +479,24 @@ jobs: TARGET_BRANCH: ${{ inputs.branch }} run: | set -euo pipefail + DARK_PATH="${OUTPUT_PATH%.svg}-dark.svg" # --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 + if [ -z "$(git status --porcelain -- "$OUTPUT_PATH" "$DARK_PATH")" ]; then echo "::notice::chart unchanged; nothing to commit" exit 0 fi + # Both or neither. A README that gained a light chart and + # kept a stale dark one shows two different histories depending on + # who is looking, and neither is flagged as wrong. + for path in "$OUTPUT_PATH" "$DARK_PATH"; do + if [ ! -f "$path" ]; then + echo "::error::expected $path to exist after generation" >&2 + exit 1 + fi + done git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -- "$OUTPUT_PATH" + git add -- "$OUTPUT_PATH" "$DARK_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 896dda6..40809ad 100644 --- a/REPOSITORY_ONBOARDING.md +++ b/REPOSITORY_ONBOARDING.md @@ -142,6 +142,44 @@ checks stay non-required, is in the Qlty subsection of section 4. ## 3. Protect `main` +`main` is the released version, not the newest work. It always equals what +users currently have: the GA tag on a versioned product, production on a +continuously deployed site. It is never the GA tag plus merged work that has +not shipped. The next version lives on its own branch, `dev/vX.Y`, which is +the integration target for every feature pull request, and prereleases are +tagged there rather than on `main`. + +The reason is measurement accuracy rather than tidiness. OpenSSF Scorecard, +CodeQL default-branch analysis, Dependabot alerts, README badges, and every +published vulnerability report describe the default branch and nothing else. +Unshipped work on `main` makes all of them describe software no user runs. + +One invariant covers it, and it is mechanical rather than a judgement call: + +```sh +git describe --exact-match origin/main +``` + +Every commit on `main` is a tagged release, so an untagged `main` head is +itself the alarm. Check the tag rather than auditing what a promotion diff +contains. Call this repository's `main-is-released.yml` on a schedule to +enforce it; see section 4. + +`main` advances only through a promotion pull request from `dev/*` or +`maintenance/*` that is tagged on merge. There is no documentation, README, or +generated-asset exception: a second path into `main` is a second thing that +drifts, and an incorrect README is a defect in the released version like any +other. Fix it on a hotfix branch and cut a patch release. For that to stay +honest, a documentation-only release must skip artifact publication — +GoReleaser, signing, deb/rpm, Homebrew, npm — or maintainers will route around +the rule. Generated assets committed to the repository, such as a star-history +chart, regenerate at the release cut rather than on a schedule that commits to +a branch, so they cannot change underneath a tag. + +Adopting this on a repository whose `main` is already ahead of its newest tag +means either cutting a release or resetting `main` to the last released tag. +That is the maintainer's decision, because it changes what every user sees. + Create an active branch ruleset named `Main branch protection`, targeting only the default branch. Its baseline is: @@ -215,19 +253,38 @@ 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. +- [ ] Release-invariant enforcement for a repository that tags releases. Call + this repository's `main-is-released.yml` at a pinned full commit SHA from a + thin caller on `schedule` plus `push` to `main` plus `workflow_dispatch`. The + called workflow declares `contents: read` itself, and a reusable workflow can + only narrow what the caller grants, so the caller needs no `permissions` + block beyond the top-level `permissions: {}` — a job-level grant would only + widen the ceiling it runs under. It asserts the section 3 invariant: an + untagged `main` head fails, and so does a prerelease tag unless the caller + passes `allow-prerelease: true`. A repository that never tags, such as a + continuously deployed site or a meta repository, should not call this at all + rather than call it with a carve-out; for those, "`main` equals production" + is enforced by the deploy. - [ ] 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 + pinned full commit SHA from a thin caller on `release: [published]` 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. + active integration branch as `branch` and the repository's logo colour as + `accent`. It regenerates a first-party SVG pair from GitHub's stargazer + timestamps and commits only when the chart actually changed. The trigger is + the release cut rather than a schedule because a committed artifact + refreshed on a cron mutates underneath a tag, which section 3 forbids. 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. Two files ship, `star-history.svg` and `star-history-dark.svg`, + because GitHub's theme toggle does not reach a media query inside an + ``-embedded SVG but does drive a `` element in the README, so + the markup chooses and the `` stays the fallback for anything that does + not understand ``. 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