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
4 changes: 4 additions & 0 deletions .github/release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@
"actions/sticky-comment": {
"component": "sticky-comment",
"initial-version": "0.0.1"
},
"actions/repo-health": {
"component": "repo-health",
"initial-version": "0.0.1"
}
},
"plugins": [
Expand Down
3 changes: 2 additions & 1 deletion .github/release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"actions/stale": "0.0.2",
"actions/runs-on-selector": "0.0.1",
"actions/terragrunt/report": "0.0.1",
"actions/sticky-comment": "0.0.1"
"actions/sticky-comment": "0.0.1",
"actions/repo-health": "0.0.0"
}
80 changes: 80 additions & 0 deletions actions/repo-health/README.md
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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
- 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/`.
29 changes: 29 additions & 0 deletions actions/repo-health/action.yml
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
65 changes: 65 additions & 0 deletions actions/repo-health/lib/core.mjs
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",
});
}
115 changes: 115 additions & 0 deletions actions/repo-health/lib/index.mjs
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*:/;
Comment thread
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);
}
}
3 changes: 3 additions & 0 deletions actions/repo-health/lib/main.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { main } from "./index.mjs";

await main();
Loading
Loading