Skip to content

feat: publish this repo as a GitHub Action - #49

Open
rossrdme wants to merge 12 commits into
mainfrom
feat/github-action
Open

feat: publish this repo as a GitHub Action#49
rossrdme wants to merge 12 commits into
mainfrom
feat/github-action

Conversation

@rossrdme

@rossrdme rossrdme commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds a real, publishable GitHub Action (mirroring readmeio/rdme's own approach) so customer workflows can reference this repo directly — e.g. uses: readmeio/cli@v1 with readme: lint — instead of npx -y @readme/cli <command>, which re-resolves against npm on every single run with no way to pin a tested version. (A stale npm-resolved version is exactly what caused a real incident during manual testing of this: it deleted 4 live pages on a customer's synced branch.)
  • lint, oas:validate, and oas:sync now each publish structured GitHub Actions step outputs (has-errors, skipped-count, total-errors, etc. — see action.yml for the full list) directly from their own run(), gated on the GITHUB_ACTIONS env var GitHub sets on every runner. This also benefits anyone still calling the CLI directly via npx in their own workflow, not just Action users.
  • oas:sync now fails (non-zero exit) when it skips writing a page, matching lint and oas:validate, which already failed on a real problem on their own. Previously oas:sync always exited 0 regardless, leaving it to whoever wrapped it in CI to notice and fail on that themselves. This command is still alpha, so this felt like the right time to fix the inconsistency rather than carry it forward. This is a deliberate breaking behavior change.
  • Two small bundler-safety refactors (lint.js's package.json read, utils/lint.js's validator discovery) — both were doing runtime, path-relative-to-import.meta.url lookups that break once bundled into a single file; switched to static imports with identical runtime behavior.
  • New CI job (build-gha) that rebuilds the committed dist-gha/index.js bundle and fails if it's out of sync with the source — otherwise nothing would catch someone editing src/gha.js (or anything it imports) without re-running npm run build:gha.
  • Adds a "GitHub Action" section to the README documenting usage.

Test plan

  • Full existing test suite passes (npm test — 181/181).
  • npm run build:gha produces a working, self-contained bundle; verified dist-gha/ has no diff after a fresh rebuild (what the new CI job checks).
  • Verified all three commands end-to-end from a directory with no ambient node_modules (simulating a real Actions checkout): success paths, warning paths, and real hard-failure paths (a real invalid OAS spec, a real lint error) — confirmed GITHUB_OUTPUT gets the expected structured fields with correct delimiter framing in every case.
  • Live-tested on real GitHub Actions infrastructure (not just local simulation) against a real docs repo with real OpenAPI specs, via a disposable mirror repo standing in for this branch (since it isn't merged/tagged yet). This caught a real bug local testing missed — gha-pre.js relied on GITHUB_ACTION_PATH, which came back undefined on an actual runner — fixed by switching to import.meta.dirname, which also now genuinely matches how readmeio/rdme's own pre-step locates itself. Re-ran after the fix: oas:validate, oas:sync, and lint all completed correctly, including lint correctly catching and failing on a real duplicate-slug issue in the test content.

Follow-ups (not blocking this PR)

  • Tag a release (e.g. v1) once merged, so readmeio/cli@v1 actually resolves — none of the docs here work until that happens.

lint.js's package.json read and utils/lint.js's validator discovery both
relied on paths computed relative to import.meta.url at runtime -- fine
for normal CLI use, but broken once this code is relocated into a single
bundled file (e.g. by ncc for the upcoming GitHub Action entrypoint).
Switched to a static package.json import attribute and static imports of
every validator module, preserving identical runtime behavior for normal
CLI usage.
Adds a real, publishable GitHub Action (mirroring readmeio/rdme's
approach) so customer workflows can reference this repo directly --
e.g. `uses: readmeio/cli@v1` with `readme: lint` -- instead of
`npx -y @readme/cli <command>`, which re-resolves against the npm
registry on every run and has no way to pin a tested version.

- src/gha.js: dedicated entrypoint that imports the lint, oas:validate,
  and oas:sync commands directly rather than going through src/cli.js's
  runtime directory scan (which a bundler can't follow statically).
  Reads the `readme` input (INPUT_README) as a plain command string.
- action.yml: node24 JS action, mirroring rdme's action.yml shape.
- dist-gha/: single-file bundle produced by `npm run build:gha`
  (ncc build src/gha.js -o dist-gha), committed so the action works
  straight off a git ref with no install step -- same pattern rdme
  uses for its own dist-gha/run.cjs.
- src/gha-pre.js: the action's `pre` step. @readme/markdown (pulled in
  by the components validator for a single helper, mdxishTags) is a
  ~25MB package with 50+ dependencies including tailwindcss/postcss,
  and bundling it whole breaks on its embedded CSS/asset references.
  Rather than vendoring that tree into this repo, this step installs
  just that one package into the action's own directory
  (GITHUB_ACTION_PATH) immediately before dist-gha/index.js runs, so
  plain Node module resolution finds it. Verified end-to-end (pre then
  main) from a directory with no ambient node_modules, simulating a
  real Actions runner checkout.
- package.json: add @vercel/ncc as a devDependency and a build:gha
  script.
Adds a `readme` output (mirroring rdme's own action.yml) so a workflow
step can inspect what a command printed -- e.g.
`contains(steps.sync.outputs.readme, 'Skipped')` -- the same way the
existing customer workflows currently do it themselves by piping
`npx ... | tee sync-output.txt` and grepping the file.

Commands call process.exit() directly on failure, which would skip
right past any output-writing code placed after `await entry.run(...)`
-- so capturing has to happen via a temporary console.log/error
+ process.exit patch in gha.js itself, not a simple return value.
Verified both the normal-completion path and the process.exit(1) path
actually flush to GITHUB_OUTPUT, using a real hard OAS validation
error to force the latter.

The delimiter written to GITHUB_OUTPUT is randomized per write rather
than a fixed string like "EOF" -- the captured value is arbitrary CLI
output that can echo back content from a customer's own docs/OAS
files, and a guessable delimiter there would let that content
prematurely close the heredoc and inject extra key=value pairs into
the outputs file.
Supersedes the previous commit's approach. That one worked by
monkey-patching console.log/error and process.exit inside gha.js to
capture a command's printed text and hand it back as one big string
output -- functional, but it threw away structure the CLI already had
and made a workflow re-derive facts (like "did oas:sync skip a page?")
by grepping rendered prose for a specific word.

lint() and oas-validate.js's validateOas()/validateOasFiles() were
already pure, side-effect-free functions returning fully structured
data -- no console output, no process.exit -- built for exactly this
"call me programmatically" case. oas-sync.js was the outlier: syncOas()
is pure, but its printing was only ever inlined in run(), not factored
out the way validateOasFiles() already is. Extracted that into
printSyncResults(), mirroring the existing pattern, so the CLI and the
Action can both use it without duplicating it.

In an Action, process.exit(1) on failure isn't a problem to route
around -- it's exactly what makes the step fail. Each command's run()
now just writes its own already-known results (has-errors,
skipped-count, etc.) to GITHUB_OUTPUT, via a new shared helper
(utils/gha-output.js) that detects GITHUB_ACTIONS and no-ops
everywhere else, right before doing whatever it was already about to
do. No interception needed anywhere, and gha.js goes back to being as
simple as it was originally -- it just calls run().

This also means a customer who runs `npx @readme/cli oas:sync`
directly in their own workflow, without adopting the published Action
at all, gets the same real outputs.

Verified end-to-end from a directory with no ambient node_modules
(same isolated-environment test as before): lint's has-errors/results,
oas:validate's has-errors/total-errors/total-warnings/total-valid/
file-count, and oas:sync's added-count/deleted-count/skipped-count/
skipped all come back correctly, with proper GITHUB_OUTPUT delimiter
framing, for both the normal-exit and process.exit(1) paths.
oas:sync was the odd one out among the three commands: lint and
oas:validate both exit non-zero the moment they find a real problem,
but oas:sync always exited 0 regardless of whether it skipped writing
a page, leaving CI wrappers to notice and fail on that themselves (the
existing customer workflows do this with a grep, and the pattern only
protects whoever remembers to copy it).

A skip already represents something worth a human looking at -- either
a spec-crafted path trying to escape reference/, or sync's own
bookkeeping not matching what's actually on disk -- so it should fail
the same way a real lint or OAS validation error does, by default,
without needing to be opted into. oas:sync is still marked alpha, so
this is a fine time to fix the inconsistency rather than carry it
forward.

Also adds a has-errors output (mirroring lint and oas:validate) so
this is now symmetric across all three commands.
dist-gha/index.js is committed (see action.yml) so the Action works
straight off a git ref with no install step. Nothing previously caught
someone editing src/gha.js (or anything it imports) without re-running
npm run build:gha -- the Action would just silently keep running
whatever was last committed. New build-gha CI job rebuilds it and
fails if that produces a diff from what's checked in.

Also adds a GitHub Action section to the README, since nothing
documented how to actually use this repo as one.
…name

Caught by an actual live GitHub Actions run, not local testing: on a
real runner, GITHUB_ACTION_PATH came back undefined for this pre step,
crashing it immediately (TypeError: path.join received undefined) --
which meant @readme/markdown never got installed, which in turn made
every subsequent dist-gha/index.js invocation fail with
ERR_MODULE_NOT_FOUND regardless of which command it ran, since the
bundle's static import graph needs to resolve @readme/markdown just to
load the file at all, before any command dispatch happens.

Local testing didn't catch this because it worked by manually setting
GITHUB_ACTION_PATH before invoking the script -- which papered over
the exact gap that broke on real infrastructure.

import.meta.dirname doesn't depend on the runner setting anything: it's
just "where is this file," resolved from the module itself. This is
also the same approach readmeio/rdme's own pre-step
(bin/write-gha-pjson.js) already uses, so this now actually mirrors it
rather than just resembling it.
@greptile-apps

greptile-apps Bot commented Sep 4, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
src/gha.js Dispatches the supported Action commands, strictly validates trailing options, and publishes has-errors for dispatch and thrown failures; the previously reported parser and fallback-output defects are fixed.
src/commands/oas-validate.js Publishes aggregate validation outputs across normal, empty, and missing-directory paths, including the previously omitted failure state.
src/commands/oas-sync.js Aggregates synchronization outputs, publishes them on controlled exit paths, and now fails when generated pages are skipped.
src/utils/gha-output.js Synchronously appends delimiter-framed Action outputs when running in GitHub Actions.
src/utils/lint.js Replaces runtime validator discovery with an explicit static validator registry suitable for the bundled Action.
src/gha-pre.js Installs the externally bundled Markdown dependency into the Action directory before execution.
action.yml Defines the publishable Node Action, supported input, runtime entrypoints, and structured output contract.
.github/workflows/ci.yml Rebuilds the committed Action distribution and fails CI when generated output has drifted.
dist-gha/index.js Contains the committed bundled Action implementation, including the incremental parser and failure-output fixes.

Reviews (6): Last reviewed commit: "chore: trigger re-review" | Re-trigger Greptile

Comment thread src/gha.js Outdated
…ing them

Flagged by Greptile on PR #49. parseInput() collected every --flag off
the readme input string, but nothing ever checked those against the
command's actual supported flags -- so a typo like
"oas:validate --derefrence" silently ran with dereference off, with no
error, no warning, nothing. A customer would have no way to know their
workflow wasn't doing what they typed, and oas:validate would happily
report broken external $refs as valid.

Now any flag not in the command's supported list fails loudly with a
clear ::error:: annotation, the same way an unknown command already
did.
… too

Flagged on PR #49: oas-validate.js and oas-sync.js both call
process.exit(1) when reference/ doesn't exist, but that happened
before either command ever reached its writeGithubActionsOutputs call
-- so a downstream workflow step checking has-errors (even one guarded
with if: always(), specifically to still run after a failure) would
see an empty string, not 'true', on exactly the failure path where
distinguishing "this command failed" matters most.

Both now write has-errors: 'true' (with the rest of their fields
zeroed, since nothing was actually checked) before exiting. Also notes
this "outputs are set on every exit path" invariant directly in
action.yml, next to has-errors, so it stays visible next to the
contract it documents rather than living only in a commit message.
Comment thread src/gha.js Outdated
…flag

Follow-up to the earlier unknown-option fix, flagged again on PR #49:
that fix only validated tokens that already started with "--" --
anything shaped differently, like a short option ("-d" instead of
"--dereference") or any other malformed token, was filtered out before
ever reaching that check and silently discarded. "oas:validate -d" or
"lint -f" would run with default options and no indication anything
was wrong, the exact same class of bug as before, just via a path the
first fix didn't cover.

Rewritten so every trailing token is checked against the command's
supported flags directly, instead of pre-filtering by shape first --
anything that isn't exactly "--<a supported flag>" is now rejected.
Verified oas:validate -d, lint -f, and the earlier
oas:validate --derefrence case all fail loudly, and that valid input
(lint, oas:validate --dereference) still works exactly as before.
Comment thread src/gha.js
Comment on lines +89 to +92
run().catch((err) => {
console.error(err);
process.exit(1);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Thrown failures omit outputs

When validation or synchronization throws before its command-specific output call, this rejection handler exits without publishing fallback outputs, causing downstream if: always() steps to receive empty has-errors and command-specific values despite the Action's every-failure-path output contract.

Knowledge Base Used:

Flagged on PR #49: the earlier fixes made oas-validate.js and
oas-sync.js publish outputs on every controlled exit path they know
about (a real validation failure, a missing reference/ directory), but
gha.js's own top-level catch -- for a genuinely unexpected thrown
error, as opposed to a command's own deliberate process.exit -- just
logged and exited without publishing anything. That's exactly the
"every-failure-path" contract action.yml documents for has-errors,
just via a path none of the per-command fixes could reach, since by
definition the command never got anywhere near its own output call.

Also covers gha.js's own two early-exit paths (unknown command, unknown
option) the same way, for the same reason -- any exit this file
produces should leave has-errors set, not just the ones a command
itself is responsible for.

Verified with a real forced throw (reference/ existing as a file
instead of a directory, so it passes the existence check but throws
ENOTDIR partway through) -- has-errors: true now reaches GITHUB_OUTPUT
before the process exits, where before this fix nothing would have
been written at all.
@rossrdme

rossrdme commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

@erunion here's the work to make it so it can be a published Github Action

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant