Repository navigation
feat(repo-health): add repo health action #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,80 @@ | ||
| # repo-health | ||
|
|
||
| Fails unless the repository keeps the practices every repository is expected to | ||
| keep. Every check is on by default. The only way past one is to set that | ||
| check's own `<check>-skip` input to the reason, so a repository that does not | ||
| meet a practice says so in its workflow rather than by leaving the check out. | ||
|
|
||
| ## Usage | ||
|
|
||
| ```yaml | ||
| name: Repo health | ||
|
|
||
| on: | ||
| pull_request: | ||
| schedule: | ||
| - cron: '0 9 1 * *' # monthly, so a repository that has not changed is still checked | ||
| workflow_dispatch: | ||
|
|
||
| permissions: {} | ||
|
|
||
| jobs: | ||
| repo-health: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 5 | ||
| permissions: | ||
| contents: read | ||
| steps: | ||
| - uses: actions/checkout@<sha> # vX.Y.Z | ||
| with: | ||
| persist-credentials: false | ||
|
|
||
| - uses: TrogonStack/github-actions/actions/repo-health@<sha> # vX.Y.Z | ||
| ``` | ||
|
|
||
| The workflow decides when it runs. The action only reads the checkout, so it | ||
| gives the same answer however often it is called. | ||
|
|
||
| ### Skipping a check | ||
|
|
||
| ```yaml | ||
| - uses: TrogonStack/github-actions/actions/repo-health@<sha> # vX.Y.Z | ||
| with: | ||
| citation-cff-skip: internal tooling, nothing to cite | ||
| ``` | ||
|
|
||
| ## Checks | ||
|
|
||
| | Check | Passes when | | ||
| | --- | --- | | ||
| | `citation-cff` | `CITATION.cff` sits at the repository root under that exact name, with the top-level keys CFF 1.2.0 requires: `cff-version`, `message`, `title`, `authors`. | | ||
|
|
||
| ## Inputs | ||
|
|
||
| | Input | Default | Description | | ||
| | --- | --- | --- | | ||
| | `directory` | `github.workspace` | Root of the checked-out repository. | | ||
| | `citation-cff-skip` | empty | Reason to skip `citation-cff`. Empty runs it. | | ||
|
|
||
| ## Outputs | ||
|
|
||
| | Output | Description | | ||
| | --- | --- | | ||
| | `failed` | JSON array of the checks that failed and were not skipped. | | ||
|
|
||
| ## Fixed behaviour | ||
|
|
||
| These are not inputs, on purpose. | ||
|
|
||
| - Every check runs unless skipped. A check added in a later version applies to | ||
| every consumer as they upgrade, which is the point of keeping them here. | ||
| - Each check has its own inputs, named `<check>-<option>`, rather than one | ||
| shared list. A misspelled input is then flagged by actionlint and by the | ||
| runner instead of quietly switching nothing off. | ||
| - A skip set to a bare `true` or `false` fails the step. The value is the | ||
| reason, because a waiver that does not say why cannot be reviewed. | ||
| - A skipped check still runs. While it fails, its reason is printed as a | ||
| warning. Once it passes, the warning asks for the skip to be removed. | ||
| - `citation-cff` checks top-level keys, not the full CFF schema. A schema | ||
| validator is a dependency, and a dependency here means a bundler and a | ||
| committed `dist/`. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| name: Repo health | ||
| description: >- | ||
| Fails unless the repository meets the practices every repository is expected | ||
| to keep, such as a CITATION.cff. Each check is on by default and is skipped | ||
| only through its own input, with a reason. | ||
| author: TrogonStack | ||
|
|
||
| inputs: | ||
| directory: | ||
| description: >- | ||
| Root of the checked-out repository. The step reads files, so the | ||
| repository has to be checked out first. | ||
| required: false | ||
| default: ${{ github.workspace }} | ||
| citation-cff-skip: | ||
| description: >- | ||
| Reason to skip the `citation-cff` check. Empty runs it. A bare `true` or | ||
| `false` fails the step, so a waiver always says why. A skipped check still | ||
| runs and warns once it passes. | ||
| required: false | ||
| default: '' | ||
|
|
||
| outputs: | ||
| failed: | ||
| description: JSON array of the checks that failed and were not skipped. | ||
|
|
||
| runs: | ||
| using: node24 | ||
| main: lib/main.mjs |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| // A stand-in for the functions this action uses from `@actions/core`, | ||
| // carrying their names and their behaviour. The package itself is ESM-only and | ||
| // reaches `undici` through `@actions/http-client`, so depending on it would mean | ||
| // a bundler and a committed `dist/`, and the tests would then exercise something | ||
| // other than the file the runner executes. | ||
|
|
||
| import crypto from "node:crypto"; | ||
| import fs from "node:fs"; | ||
| import { EOL } from "node:os"; | ||
|
|
||
| // The runner uppercases an input name and replaces spaces, and nothing else, so | ||
| // `pools-json` arrives as INPUT_POOLS-JSON and not INPUT_POOLS_JSON. | ||
| export function getInput(name, options = {}) { | ||
| const value = process.env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`] ?? ""; | ||
|
|
||
| if (options.required && !value) { | ||
| throw new Error(`Input required and not supplied: ${name}`); | ||
| } | ||
|
|
||
| return options.trimWhitespace === false ? value : value.trim(); | ||
| } | ||
|
|
||
| // Workflow commands end at a newline, so a message carrying one would close the | ||
| // annotation and log the remainder as its own line. | ||
| function escapeData(value) { | ||
| return String(value).replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A"); | ||
| } | ||
|
|
||
| export function info(message) { | ||
| process.stdout.write(`${message}${EOL}`); | ||
| } | ||
|
|
||
| export function error(message) { | ||
| process.stdout.write(`::error::${escapeData(message)}${EOL}`); | ||
| } | ||
|
|
||
| export function warning(message) { | ||
| process.stdout.write(`::warning::${escapeData(message)}${EOL}`); | ||
| } | ||
|
|
||
| export function setFailed(message) { | ||
| // Not `process.exit`, which can truncate output still buffered on stdout. | ||
| process.exitCode = 1; | ||
| error(message); | ||
| } | ||
|
|
||
| export function setOutput(name, value) { | ||
| const file = process.env.GITHUB_OUTPUT; | ||
|
|
||
| if (!file) { | ||
| throw new Error("Unable to find environment variable for file command OUTPUT"); | ||
| } | ||
|
|
||
| // The delimited form, because a value holding a newline would otherwise be | ||
| // read as the start of the next output. | ||
| const delimiter = `ghadelimiter_${crypto.randomUUID()}`; | ||
|
|
||
| if (name.includes(delimiter) || String(value).includes(delimiter)) { | ||
| throw new Error(`Unexpected input: name and value should not contain the delimiter`); | ||
| } | ||
|
|
||
| fs.appendFileSync(file, `${name}<<${delimiter}${EOL}${value}${EOL}${delimiter}${EOL}`, { | ||
| encoding: "utf8", | ||
| }); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| import fs from "node:fs"; | ||
| import path from "node:path"; | ||
|
|
||
| import { error, getInput, info, setFailed, setOutput, warning } from "./core.mjs"; | ||
|
|
||
| // Thrown for anything a caller can fix by editing their workflow, so main can | ||
| // report it as a GitHub error annotation rather than a stack trace. | ||
| export class InputError extends Error {} | ||
|
|
||
| // The keys CFF 1.2.0 requires. Checked by top-level key rather than against the | ||
| // full schema, because a schema validator is a dependency, and a dependency | ||
| // here means a bundler and a committed `dist/`. | ||
| const CITATION_KEYS = ["cff-version", "message", "title", "authors"]; | ||
|
|
||
| const TOP_LEVEL_KEY = /^([A-Za-z][A-Za-z0-9-]*)\s*:/; | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| function checkCitation(directory) { | ||
| // GitHub reads the file from the root under this exact name, and a | ||
| // case-insensitive filesystem would accept `citation.cff` that GitHub ignores. | ||
| if (!fs.readdirSync(directory).includes("CITATION.cff")) { | ||
| return [ | ||
| "No CITATION.cff at the repository root. GitHub reads it from there to offer \"Cite this repository\". See https://citation-file-format.github.io.", | ||
| ]; | ||
| } | ||
|
|
||
| const keys = new Set( | ||
| fs | ||
| .readFileSync(path.join(directory, "CITATION.cff"), "utf8") | ||
| .split(/\r?\n/) | ||
| .map((line) => TOP_LEVEL_KEY.exec(line)?.[1]) | ||
| .filter(Boolean), | ||
| ); | ||
| const missing = CITATION_KEYS.filter((key) => !keys.has(key)); | ||
| if (missing.length > 0) { | ||
| return [`CITATION.cff is missing the required ${missing.map((key) => `\`${key}\``).join(", ")}.`]; | ||
| } | ||
| return []; | ||
| } | ||
|
|
||
| // Every check runs unless its own `<check>-skip` input says why not, so a new | ||
| // check reaches every consumer on their next upgrade instead of waiting to be | ||
| // switched on. Options are inputs per check, rather than one shared list, so | ||
| // actionlint and the runner flag a misspelled one. | ||
| export const CHECKS = [{ id: "citation-cff", run: checkCitation }]; | ||
|
|
||
| // A bare boolean says the check is off without saying why, which is the one | ||
| // thing a waiver has to carry. | ||
| const NOT_A_REASON = /^(true|false|yes|no|on|off|1|0)$/i; | ||
|
|
||
| export function skipReason(id, value) { | ||
| if (value === "") return undefined; | ||
| if (NOT_A_REASON.test(value)) { | ||
| throw new InputError(`\`${id}-skip\` is \`${value}\`. Write the reason the check is skipped instead.`); | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| // A skipped check still runs, so a waiver that is no longer needed is reported | ||
| // rather than left to hide the next regression. | ||
| export function run({ inputs }) { | ||
| const reasons = new Map(CHECKS.map((check) => [check.id, skipReason(check.id, inputs.checks[check.id]?.skip ?? "")])); | ||
|
|
||
| if (!fs.existsSync(inputs.directory) || !fs.statSync(inputs.directory).isDirectory()) { | ||
| throw new InputError( | ||
| `No directory at ${inputs.directory}. Check the repository out before this step, or point \`directory\` at it.`, | ||
| ); | ||
| } | ||
|
|
||
| return CHECKS.map((check) => ({ | ||
| id: check.id, | ||
| problems: check.run(inputs.directory), | ||
| skipReason: reasons.get(check.id), | ||
| })); | ||
| } | ||
|
|
||
| export function main() { | ||
| try { | ||
| const results = run({ | ||
| inputs: { | ||
| directory: getInput("directory"), | ||
| checks: Object.fromEntries(CHECKS.map((check) => [check.id, { skip: getInput(`${check.id}-skip`) }])), | ||
| }, | ||
| }); | ||
|
|
||
| const failed = []; | ||
| for (const { id, problems, skipReason: reason } of results) { | ||
| if (reason !== undefined) { | ||
| if (problems.length === 0) { | ||
| warning(`${id} is skipped but passes. Remove \`${id}-skip\`.`); | ||
| } else { | ||
| warning(`${id} skipped: ${reason}`); | ||
| } | ||
| continue; | ||
| } | ||
|
|
||
| if (problems.length === 0) { | ||
| info(`${id} passed.`); | ||
| continue; | ||
| } | ||
|
|
||
| failed.push(id); | ||
| for (const problem of problems) error(`${id}: ${problem}`); | ||
| } | ||
|
|
||
| setOutput("failed", JSON.stringify(failed)); | ||
|
|
||
| if (failed.length > 0) { | ||
| setFailed( | ||
| `Failed: ${failed.join(", ")}. Fix them, or skip one by setting its \`<check>-skip\` input to the reason.`, | ||
| ); | ||
| } | ||
| } catch (thrown) { | ||
| setFailed(thrown.message); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| import { main } from "./index.mjs"; | ||
|
|
||
| await main(); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.