Skip to content

feat(config): load repo defaults from .ghaurc / ghau.config.* / package.json ghau key#13

Merged
ylabonte merged 18 commits into
mainfrom
feat/config-file-loading
May 15, 2026
Merged

feat(config): load repo defaults from .ghaurc / ghau.config.* / package.json ghau key#13
ylabonte merged 18 commits into
mainfrom
feat/config-file-loading

Conversation

@ylabonte
Copy link
Copy Markdown
Owner

@ylabonte ylabonte commented May 15, 2026

Summary

First half of the v1.1.0 plan: implement the config-file feature that docs/guide/config-file.md has been documenting since the initial release. The docs (and the cosmiconfig + zod deps in package.json) were aspirational — no source file backed them. This PR writes the loader, wires it into the CLI, and reconciles the docs with what actually ships.

Effective options now resolve as CLI flag → config file → built-in default. Tokens are deliberately not loadable from config — they belong in env vars or gh auth token.

What's new (user-visible)

Drop a data-only config file at the repo root and ghau picks it up. Supported filenames: .ghaurc, .ghaurc.json, .ghaurc.yaml, .ghaurc.yml, ghau.config.json, or a ghau key in package.json.

{
  "target": "minor",
  "rejects": ["docker://**"],
  "failOnOutdated": true
}

Schema (strictly validated by zod; unknown keys rejected):

interface GhauConfig {
  target?: 'latest' | 'major' | 'minor' | 'patch' | 'greatest';
  filters?: string[];
  rejects?: string[];
  workflowsDir?: string;
  allowBranchPin?: boolean;
  failOnOutdated?: boolean;
}

A relative workflowsDir is resolved against the config file's directory, so a repo-level .ghaurc.json keeps pointing at <repo-root>/.github/workflows regardless of which subdirectory you invoke ghau from.

Data-only by design

Executable config formats (.js, .cjs, .mjs, .ts) are intentionally not supported. Allowing them would let ghau execute repository-controlled JavaScript during config discovery — and in the composite Action path, GITHUB_TOKEN is already in process.env by the time the CLI starts, so a checked-in ghau.config.mjs from an attacker-controlled PR could read or exfiltrate it even though token is not part of the config schema. Keeping the config surface data-only eliminates that vector. See docs/guide/config-file.md for the full security rationale.

Files

  • New: src/core/config.ts (cosmiconfig + zod loader), tests/unit/core/config.test.ts (18 tests), tests/unit/cli.test.ts (22 tests for mergeOptions + isInvokedDirectly), .changeset/feat-config-file.md (minor bump).
  • Modified: src/cli.ts (load + merge + isInvokedDirectly fix for symlinked invocations), src/index.ts (re-exports), tests/integration/cli.test.ts (+5 e2e cases), docs/guide/config-file.md (reconciled with implementation + security callout), docs/guide/quickstart.md (Repo-level defaults section), docs/guide/use-as-action.md (target-precedence + changes-output limitation callout), docs/reference/cli.md + README.md (exit-code table extended), plus CLAUDE.md + .github/copilot-instructions.md (meta-lesson on bidirectional doc drift), and action.yml (target default + npx form fix + isInvokedDirectly-related selftest implications).

Test plan

  • CI green on this PR (216 tests, four gates).
  • Manually: drop a .ghaurc.json in a fresh checkout and confirm pnpm dev -- --verbose --json logs the config path on stderr.
  • Manually: drop a malformed config and confirm exit 2 with the error message.
  • Self-check workflow: known-red until 1.1.0 publishes (root cause: the published 1.0.0 has a separate `invokedDirectly` bug fixed in this PR; the Action wrapper pulls 1.0.0 from npm). Heals on the next push after 1.1.0 publishes.

…ackage.json ghau key

Implements the config-file feature that `docs/guide/config-file.md`
has been documenting since the initial release — the docs (and the
`cosmiconfig` + `zod` deps in `package.json`) were aspirational; no
source file backed them. This PR closes that gap by writing the
loader, wiring it into the CLI, and reconciling the docs with what
actually ships.

Core (new):
  • `src/core/config.ts` — cosmiconfig + zod loader. Strict schema:
    target, filters, rejects, workflowsDir, allowBranchPin,
    failOnOutdated. Unknown keys rejected with a clean error
    pointing at the offending file and field. Exports
    `GhauConfigSchema`, `GhauConfig`, `LoadedConfig`, `loadConfig`,
    and the `defineConfig` identity helper for TypeScript users.
  • `src/index.ts` — re-exports `loadConfig`, `defineConfig`,
    `GhauConfigSchema`, `GhauConfig`, `LoadedConfig`.

CLI wiring:
  • `src/cli.ts` — search for a config file at startup; merge
    config values into options. Precedence: CLI flag > config
    file > built-in default. Determined via
    `program.getOptionValueSource()` so flags with Commander
    defaults still let the config win when the user didn't pass
    the flag. Malformed configs exit 2 with the loader's formatted
    error. Verbose mode logs the discovered config path. Token
    auth is intentionally NOT config-loadable.

Tests:
  • `tests/unit/core/config.test.ts` — 15 tests covering: each
    file location, package.json `ghau` key vs no key, schema
    rejections (unknown keys, wrong types, out-of-enum, empty
    strings in arrays), empty-object accept, defineConfig identity.
  • `tests/integration/cli.test.ts` — 5 new cases: verbose Config:
    log, config values feed pipeline, CLI overrides config,
    malformed config exits 2.

Docs:
  • `docs/guide/config-file.md` — reconciled with reality.
    `.gaurc` (no 'h', a leftover from the pre-rename) corrected to
    `.ghaurc` throughout. Type `GauConfig` renamed `GhauConfig`.
    Added a 'What's not configurable' section spelling out the
    CLI-only options (token, write/interactive flow, color, etc.).
  • `README.md` — new 'Configuration' section between Exit codes
    and Use as a GitHub Action, with a one-line `.ghaurc.json`
    example and a link to the guide page.
  • `docs/guide/quickstart.md` — new 'Repo-level defaults' section
    before Authentication.

Meta-lesson (per user request, bundled here rather than deferred):
  • `CLAUDE.md` and `.github/copilot-instructions.md` — the
    'Documentation surfaces' lens added in 22b3cc8 catches code
    → doc drift, but missed the reverse case where docs describe
    a feature the code doesn't ship. Added a paragraph spelling
    out 'Doc surfaces drift in both directions' with this very
    feature as the worked example.

No behavior change for users without a config file — ghau works
exactly as it did in 1.0.0. With a config file, CLI flags still
win when explicitly passed.
@ylabonte ylabonte requested a review from Copilot May 15, 2026 02:08
@ylabonte ylabonte self-assigned this May 15, 2026
@ylabonte ylabonte added the enhancement New feature or request label May 15, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR implements repository config-file support for ghau, wiring a new cosmiconfig/zod loader into the CLI so config defaults can sit between explicit flags and built-in defaults.

Changes:

  • Adds src/core/config.ts with schema validation, config discovery, and defineConfig.
  • Merges loaded config into CLI options and re-exports config APIs.
  • Updates tests, docs, and changeset entries for the new configuration feature.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/core/config.ts Adds config schema, loader, and TypeScript helper.
src/cli.ts Loads config and merges it with CLI options.
src/index.ts Re-exports config APIs and types.
tests/unit/core/config.test.ts Adds unit coverage for config loading and validation.
tests/integration/cli.test.ts Adds CLI-level config behavior tests.
README.md Documents repo-level configuration.
docs/guide/quickstart.md Adds a quickstart section for config defaults.
docs/guide/config-file.md Reconciles the full config-file guide with implemented behavior.
CLAUDE.md Adds guidance about bidirectional doc drift.
.github/copilot-instructions.md Mirrors the doc-drift guidance.
.changeset/feat-config-file.md Adds a minor-version changeset for config-file support.

Comment thread README.md Outdated
Comment thread docs/guide/quickstart.md Outdated
Comment thread src/core/config.ts Outdated
Comment thread src/cli.ts Outdated
Comment thread README.md Outdated
ylabonte added 3 commits May 15, 2026 04:35
Five findings from Copilot's first review pass:

- **JSON fences with `// filename` comments** (README.md, quickstart.md). JSON
  can't contain comments, so a user copy-pasting the snippet into
  `.ghaurc.json` hit a parse error. Moved the filename out of the fence as a
  preceding heading.

- **`ghau.config.ts` advertised but not actually loadable** (src/core/config.ts).
  cosmiconfig v9 doesn't ship a TypeScript loader; adding `jiti` /
  `cosmiconfig-typescript-loader` would have been the alternative. Chose the
  simpler path for v1.1.0: drop `.ts` from searchPlaces, document `.mjs` as
  the typed-config shape, leave a comment in `src/core/config.ts` and a
  callout box in the guide explaining the choice. defineConfig still works
  in `.mjs` (via JSDoc/@ts-check). Changeset, README, and quickstart
  updated to use `.mjs` in the defineConfig example.

- **Merge logic was integration-tested only for non-defaulted options**
  (src/cli.ts). The `getOptionValueSource`-gated path for `target`,
  `allowBranchPin`, `failOnOutdated` had no direct coverage. Extracted the
  merge into an exported `mergeOptions(program, config)` function. Added
  `tests/unit/cli.test.ts` with 15 unit tests covering: config-wins-on-empty
  for each defaulted option, CLI-wins-on-explicit for each, pass-through of
  non-mergeable options (write/interactive/commit/json/verbose/token).

- **Composite Action's `target` default silently masked config-file values**
  (action.yml). Previously, `target` defaulted to `'latest'` and was passed
  unconditionally as `--target "$GHAU_TARGET"`, so a repo with
  `target: 'minor'` in `.ghaurc` was overridden to `latest` when run via
  the Action without an explicit input. Now: default is empty string,
  the script conditionally appends `--target` only when non-empty
  (matching the existing pattern for `workflows`/`filter`/`reject`).
  Action users who want a hard guarantee against config drift can set
  `with: { target: minor }` explicitly. README inputs table + use-as-action
  guide updated to spell out the new semantics.

No behavior change for users who don't have a config file. With a config
file: existing CLI behavior unchanged; Action callers now inherit the
config's `target` instead of being silently overridden.
Latent bug in `action.yml`'s composite-action invocation that has
been hidden since day one because every prior self-test run hit
the "is github-actions-updater@1.0.0 published?" probe and skipped
with `::warning::` before reaching the actual `npx` command. Now
that 1.0.0 is on npm, the selftest finally executes the line —
and fails immediately with:

  sh: 1: ghau: not found
  ##[error]Process completed with exit code 127.

Root cause: `npx <package>` resolves the binary to invoke by
deriving its name from the package name. Our package is
`github-actions-updater` and our only binary is `ghau` — they
don't match, so npx tries to run something it doesn't have.

The documented fix is the explicit `npx -p <package> <binary> [args]`
form, which decouples the package-to-install from the
binary-to-run. Same security posture (args still flow through
the pre-validated `args` array; the binary name is a hardcoded
literal), and additionally clearer-at-the-call-site about which
bin we mean.

This was a latent issue with the 1.0.0 action wrapper too — anyone
using `uses: ylabonte/github-actions-updater@v1` today would have
hit the same failure. The fix lands here in 1.1.0, but note the
floating `v1` tag will pick it up automatically once 1.1.0
publishes.
Real latent bug shipped in 1.0.0 (and contributing to PR #13's red
selftest). The previous `invokedDirectly` check in `src/cli.ts`
used two heuristics:

  import.meta.url === `file://${process.argv[1] ?? ''}`
  || process.argv[1]?.endsWith('cli.js')
  || process.argv[1]?.endsWith('cli.ts')

Both fail when the CLI is invoked through the normal install path:

  $ ./node_modules/.bin/ghau --version

Node's argv[1] is the symlink path (`.../node_modules/.bin/ghau`),
NOT the resolved `dist/cli.js`. The exact-equality check fails
because `import.meta.url` is the realpath; the endsWith checks
fail because the symlink is named `ghau`, not `cli.js`. Result:
`main()` is never called, the CLI silently exits 0.

Repro (local, before fix): `npx -y -p github-actions-updater@1 ghau
--version` returns exit 0 with no output. The CI's `sh: 1: ghau:
not found` is the Linux surface of the same broken assumption.

Fix: replace the heuristic chain with `isInvokedDirectly(metaUrl,
entryPath)`, exported from `src/cli.ts` so it's directly testable.
The new implementation resolves BOTH sides to canonical paths via
`fs.realpathSync` before comparing, so symlinked invocation matches
the target file regardless of the symlink's name. Direct
invocations still work; imported-from-tests still returns false
(argv[1] points at the vitest binary, not at cli.ts).

Added 5 unit tests in `tests/unit/cli.test.ts` covering:
  - undefined/empty entryPath
  - direct invocation (argv[1] === target)
  - symlinked invocation (the .bin/ghau case that was broken)
  - unrelated argv[1] (test runner)
  - non-existent argv[1] (broken symlink / bad arg)

Verified locally: `npm install <fresh-tarball>` followed by
`./node_modules/.bin/ghau --json --workflows /tmp/empty` now
produces the expected JSON output and exits 0.

Note on the floating `v1` tag: once 1.1.0 publishes, `v1` updates
to point at the fixed action.yml (from the earlier commit), but
the ACTION wrapper invokes npm-published 1.0.0 by default, so
the action will pull the still-broken CLI from the registry. The
fix only takes full effect once 1.1.0 is on npm and the floating
version default rolls forward (or users explicitly pin `version: 1.1`).
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 11 comments.

Comments suppressed due to low confidence (1)

src/core/config.ts:94

  • This doc comment still tells users to author ghau.config.ts, but .ts configs were removed from the supported search places above. That makes the public helper documentation contradict the loader; update the example text to the supported typed path (for example ghau.config.mjs with JS type checking) so generated declarations do not advertise an unsupported config file.
/**
 * Identity helper for TS users authoring `ghau.config.ts` with type-safety:
 *
 * ```ts
 * import { defineConfig } from 'github-actions-updater';

Comment thread tests/unit/cli.test.ts Outdated
Comment thread src/core/config.ts Outdated
Comment thread src/core/config.ts
Comment thread src/core/config.ts Outdated
Comment thread src/cli.ts
Comment thread .changeset/feat-config-file.md Outdated
Comment thread src/cli.ts Outdated
Comment thread src/core/config.ts Outdated
Comment thread README.md Outdated
Comment thread src/core/config.ts Outdated
Eleven findings from Copilot's second pass. Grouping the fixes:

**Real bugs (3):**

- `tests/unit/cli.test.ts` referenced `__filename`, which is undefined
  in the ESM test suite (`type: module`). Replaced with `import.meta.url`.
  Vitest 4 happens to polyfill it for legacy compat, which is why the
  test passed locally; cold ESM execution would `ReferenceError`.

- A relative `workflowsDir` in a config file was resolved against
  `process.cwd()` instead of the config file's directory. So invoking
  `ghau` from `repo/packages/app` with a repo-level `.ghaurc.json`
  containing `workflowsDir: ".github/workflows"` scanned
  `repo/packages/app/.github/workflows`, not the repo-level path. Fixed
  in `loadConfig`: relative `workflowsDir` is now resolved against
  `dirname(loaded.filepath)`. Two new unit tests cover the relative
  and absolute cases.

- cosmiconfig's default `stopDir` prevented the search from walking
  past arbitrary boundaries (manifested as `null` when the test config
  lived in a parent dir of the search start). Set `stopDir: '/'` so the
  search reliably walks to the filesystem root.

**Security (1, significant):**

- `.ghaurc.mjs` / `.ghaurc.cjs` / `.ghaurc.js` / `ghau.config.{js,cjs,mjs}`
  let `ghau` execute repository-controlled JavaScript during config
  discovery. In the composite Action path, `GITHUB_TOKEN` is already in
  `process.env` by the time the CLI starts — so a checked-in
  `ghau.config.mjs` from an attacker-controlled PR could read or exfiltrate
  it, even though `token` is not in the schema. Mitigation: dropped
  executable formats from `searchPlaces` entirely. Supported set is now
  data-only: `package.json` `ghau` field, `.ghaurc`, `.ghaurc.json`,
  `.ghaurc.yaml`, `.ghaurc.yml`, `ghau.config.json`. The `defineConfig`
  helper became useless without executable formats and is removed from
  the package exports.

**Coverage / consistency (5):**

- Added explicit test that executable formats are not loaded (defense
  in depth against future regressions).
- Added a `.ghaurc.yaml` test (the YAML loader path had no coverage).
- POSIX-normalized the verbose `Config:` log line in `cli.ts` and the
  loader's malformed-config error in `config.ts` (via `toPosixPath`)
  so the user-visible path is stable on Windows.
- README exit-codes table extended to mention the new "exit 2 on
  malformed config" path.
- Cross-platform path normalization assertion in tests verifies the
  error message contains no backslashes.

**Docs / cleanup (2):**

- Documented the Action `changes`-output limitation when `workflowsDir`
  lives only in a config file (the Action's diff scope still uses
  `GHAU_WORKFLOWS` env). Proper fix — surfacing the effective
  `workflowsDir` from CLI JSON output and consuming it in the Action
  script — deferred to v1.2. Workaround documented: pass
  `with: workflows: ...` explicitly when relying on `changes` for gating.
- Swept stale `.ts` / `.mjs` / `defineConfig` references across
  README, `docs/guide/config-file.md`, `docs/guide/quickstart.md`, and
  the changeset entry. The config-file guide now leads with the
  data-only rationale via a warning callout.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated 5 comments.

Comment thread src/core/config.ts
Comment thread README.md
Comment thread docs/guide/quickstart.md
Comment thread docs/guide/use-as-action.md Outdated
Comment thread src/core/config.ts Outdated
Four stale-prose findings from Copilot's third pass on PR #13.
All cleanup of round-2 leftovers; no code-path changes.

- `docs/reference/cli.md` — exit-code table's row for `2` updated
  to mention the new malformed-config-rejection path (the table
  sits OUTSIDE the autogen block, so no `pnpm docs:gen-cli` needed).
- `docs/guide/quickstart.md` — same exit-code update for the
  matching table on the quickstart page.
- `docs/guide/use-as-action.md` — replaced the wildcard
  `.ghaurc / ghau.config.*` reference (which overstated supported
  filenames) with a precise list of data-only config files plus a
  pointer to the config-file guide. Avoids misleading Action users
  toward `ghau.config.yaml` / `.ts` / etc., none of which are
  actually picked up.
- `src/core/config.ts` — the `track [issue link]` placeholder in
  the data-only-rationale comment block ships unchanged into
  `dist/core/config.js` because `removeComments: false` preserves
  JSDoc for the published API surface. Rephrased to "file an
  issue if you have a use case" — no broken link reference.

A fifth comment asked for the PR description to be updated
(it still mentioned `ghau.config.ts` and `defineConfig`); that's
GitHub metadata, not code, and lands separately via `gh pr edit`
after this push.

No changeset — prose-only cleanup within an unreleased feature.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

action.yml:126

  • This comment also overstates the supported config names with ghau.config.*; only ghau.config.json is discovered. Keeping the Action script comments precise matters here because executable config formats were intentionally excluded for security reasons.
        # `--target` is conditional so a `target` value in the user's repo config
        # (`.ghaurc` / `ghau.config.*`) is not masked by the Action's pass-through.
        # When the input is empty, we let the CLI fall back to its own default or

Comment thread tests/integration/cli.test.ts Outdated
Comment thread action.yml Outdated
Comment thread README.md Outdated
Comment thread tests/unit/core/config.test.ts
Comment thread src/cli.ts
Five comments. Grouping:

**Real precedence bug (1):**

- `src/cli.ts` shipped boolean config-mergeable options (`allowBranchPin`,
  `failOnOutdated`) with only positive CLI forms. A config setting either
  to `true` couldn't be overridden back to `false` from the CLI for a
  one-off run — silently violating the documented "CLI flag > config
  file" precedence. Fixed by adding Commander negative forms:

    --no-allow-branch-pin    → sets allowBranchPin = false (source: cli)
    --no-fail-on-outdated    → sets failOnOutdated = false (source: cli)

  `mergeOptions` needs no changes — it already respects
  `getOptionValueSource() === 'cli'` for both directions. Two new unit
  tests in `tests/unit/cli.test.ts` cover the negative override path.
  Regenerated `docs/reference/cli.md` to include the new options.

**Test flake fix (1):**

- The "CLI flags override config values" integration test let
  `actions/checkout@v3` reach the resolver unmocked — a real
  unauthenticated GitHub API request that hangs/flakes under CI rate
  limiting. Dropped the test and replaced with a comment pointing at
  the comprehensive unit-level coverage in `tests/unit/cli.test.ts`
  (every config-mergeable option, both directions, offline).

**Documentation consistency (3):**

- `action.yml` lines 22 + 124-125: source comments still used the
  wildcard `.ghaurc / ghau.config.*`. Replaced with the precise
  enumeration of supported data-only filenames.
- `README.md` `workflows` row: showed `.github/workflows` as the
  default, but `action.yml` has an empty default now (matching the
  same pattern we fixed for `target`). Updated to `_(none)_` with the
  "defer to CLI/config" wording.
- `tests/unit/core/config.test.ts`: "does NOT load executable
  formats" test promised `.cjs` coverage but only created `.js` +
  `.mjs` fixtures. Added a `.cjs` fixture so all three extensions
  named in the test name have a file on disk.

No changeset — the only user-visible change is the new --no-* CLI
flags, which are reachable via the same v1.1.0 config-file changeset
entry ("CLI flags override config values" is part of the precedence
feature already documented there). Net: 217 tests pass, up from 216;
four gates green.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 3 comments.

Comment thread src/core/config.ts
Comment thread tests/unit/core/config.test.ts Outdated
Comment thread src/core/config.ts Outdated
Three comments. Grouping:

**Real precedence bug at the Action level (1):**

- The composite Action exposed `allow-branch-pin: false` and
  `fail-on-outdated: false` inputs that were indistinguishable from
  "omitted" because the script only appended a CLI flag when the input
  string was `"true"`. Result: an Action caller couldn't override a
  config-set `true` back to `false` for a single workflow run — the
  same asymmetry we fixed at the CLI level in round 4, just one layer
  up. Fixed by making both inputs tri-state (parallel to `target`):

      empty (default)  → script appends nothing; defer to CLI/config
      'true'           → script appends --allow-branch-pin / --fail-on-outdated
      'false'          → script appends --no-allow-branch-pin / --no-fail-on-outdated
      other            → `::warning::` and ignored

  README inputs table + `docs/guide/use-as-action.md` highlight bullet
  updated to spell out the tri-state semantics; the precedence is now
  symmetric all the way from Action input → CLI flag → config file.

**Coverage gap (1):**

- `tests/unit/core/config.test.ts` claimed `.ghaurc` auto-detection of
  both JSON and YAML but only exercised the JSON path. Split into two
  tests — one with JSON content, one with YAML content (both written
  to the no-extension `.ghaurc` filename) — so the YAML loader path
  is verified independently.

**Cross-platform consistency (1):**

- cosmiconfig's parser errors (invalid JSON/YAML in a discovered
  config file) threw before reaching the schema-validation block in
  `loadConfig`, so on Windows the error surfaced with native
  backslashes despite the codebase's convention of POSIX-normalizing
  any human-readable path via `toPosixPath`. Wrapped
  `explorer.search()` in try/catch; rethrows a clean
  `Invalid ghau config: ...` Error with backslashes replaced and the
  original error preserved on `.cause` for upstream consumers. Added a
  unit test that feeds malformed JSON to `loadConfig` and asserts the
  error message contains no backslashes.

Net: 219 tests pass, up from 217; four gates green.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 15 out of 15 changed files in this pull request and generated 4 comments.

Comment thread CLAUDE.md
Comment thread .github/copilot-instructions.md
Comment thread docs/guide/config-file.md
Comment thread action.yml Outdated
Four comments. Grouping:

**Security — workflow-command injection (1):**

- `action.yml`'s `::error::` (version validation) and the two
  `::warning::` lines (round-5's tri-state `allow-branch-pin` /
  `fail-on-outdated` invalid-value paths) interpolated
  attacker-controllable inputs directly into the `::name::value`
  workflow-command form. A value containing CR (`%0D`) or LF (`%0A`)
  could inject additional workflow commands — e.g. a value like
  `x%0A::error::injected` would emit a fake `::error::` line
  attributed to our step.

  Added a `gha_escape()` helper at the top of the run-script that
  encodes `%`, CR, LF (in that order — `%` first because the others
  use `%` in their encodings) and routed all three workflow-command
  lines through it. The version-validation path was technically safe
  because the regex allowlist excludes the dangerous characters
  upstream, but we escape there too for defense in depth and pattern
  uniformity.

**Exit-code contract stale in three more places (3):**

- `CLAUDE.md`, `.github/copilot-instructions.md`, and
  `docs/guide/ci-integration.md` all still described exit 2 as "every
  resolution errored" only. The new malformed-config-rejection path
  (round 2) also returns 2. Updated all three to match the
  README/quickstart/cli.md wording. Mildly ironic that the
  "Documentation surfaces" lens itself lived in one of the stale
  files; folded the lesson back into the lens.

**Generalized lessons from rounds 4–6 (CLAUDE.md + copilot mirror):**

Added three new "Common pitfalls" entries that abstract over the
recurring patterns of the last three rounds:

- **Workflow-command injection** — escape `%`, CR, LF before
  interpolating user input into `::name::value` lines.
- **The lens drifts too** — when updating an exit-code or other
  invariant, grep the OLD wording across `docs/`, `CLAUDE.md`, the
  copilot mirror, and `ci-integration.md`. The lens lives in this
  file but doesn't enumerate every surface.
- **Precedence claims need bidirectional verification at every
  layer.** A "CLI > config" claim is only true if the CLI can flip a
  config-true back to false, AND the composite Action's tri-state
  input forwards that choice. Round 4 fixed the CLI direction
  (`--no-*`), round 5 fixed the Action direction (tri-state).
  Whenever you ship a precedence invariant, write the test for both
  directions, at every layer.

No changeset — the security escape and the docs are all internal
consistency fixes; no behavior change visible to npm consumers or
the overwhelming majority of Action callers (only callers passing
invalid input values see a difference in how the warning renders).
@ylabonte ylabonte requested a review from Copilot May 15, 2026 14:35
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Comment thread .github/copilot-instructions.md
Comment thread src/core/config.ts
Comment thread README.md
Three comments. One real security finding, one doc-consistency, one repeat hallucination.

**Security — path traversal via config `workflowsDir` (1):**

The schema previously accepted any non-empty string for `workflowsDir`. The merge step only resolved relative paths against the config file's directory; absolute paths flowed through unchanged, and `..`-traversing relatives could compute outside the config dir after `path.resolve`. A repo-controlled config is reachable by anyone who can land a PR, so a malicious `.ghaurc.json` with `workflowsDir: "/etc/..."` or `workflowsDir: "../../escape"` could steer `scanWorkflows` (and, with `--write`, `applyUpdates`) outside the repo — a real attack surface.

Fix: in `loadConfig`, after schema validation, enforce containment:
  - Absolute paths in `workflowsDir` are rejected with a clear error pointing users at `--workflows` on the CLI for the legitimate use case.
  - After `path.resolve(configDir, workflowsDir)`, compute `path.relative(configDir, resolved)`; if it equals `".."` or starts with `"../"`, reject as an escape attempt with the resolved path in the error.

Updated existing absolute-passthrough test → absolute-rejection test. Added: `..`-escape rejection test, and an `subdir/../wf` test that proves benign `..` segments resolving back inside the dir still pass (containment check works on the RESOLVED path, not the literal string).

Changeset entry extended with the security restriction + rationale, so the 1.1.0 release notes call it out.

**Doc consistency (1):**

The README's outputs table's `changes` row didn't carry the workflowsDir-config under-counting limitation that `action.yml` and `docs/guide/use-as-action.md` already mention. Synced.

**Hallucination, round 3 (1):**

Copilot is on the third pass of claiming a `custom-instructions/repo/.github/copilot-instructions.md` duplicate exists — this time fabricating a specific quote ("its exit-code line still says `2 = every resolution errored`") from a file that doesn't exist. Verified again via `find` + repo-wide grep: zero hits. The round-7 and round-8 replies explicitly asked Copilot to point at the alleged path; neither follow-up did. Treating as a stable hallucination; final firm reply explains the verification was repeated, points back at the previous unanswered asks, and resolves.

Net: 222 tests (+2), four gates green.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (2)

src/core/config.ts:151

  • The escaping-path error mixes normalized paths with the raw workflowsDir value. A Windows-style config value such as ..\escape would be shown with backslashes, unlike the rest of the user-facing path output in this loader and the project convention established by src/utils/paths.ts:3-6; normalize the displayed value here as well.
        `Invalid ghau config in ${toPosixPath(result.filepath)}:\n` +
          `  workflowsDir: '${config.workflowsDir}' resolves outside the config file's directory ` +
          `(${toPosixPath(resolved)}). Repo configs may only point at directories inside the repo.`,

src/core/config.ts:154

  • This containment check is purely lexical, so a repo-controlled config can still point workflowsDir at an in-repo symlink whose target is outside the repo/config directory. scanWorkflows uses readdir/stat, which follow directory symlinks, and --write would then operate on files outside the intended tree. Resolve and validate the real path of the configured directory (and the config directory) before accepting it, or reject symlinked workflow directories for config-provided paths.
    const resolved = path.resolve(configDir, config.workflowsDir);
    const relative = path.relative(configDir, resolved);
    if (relative === '..' || relative.startsWith('..' + path.sep)) {
      throw new Error(
        `Invalid ghau config in ${toPosixPath(result.filepath)}:\n` +
          `  workflowsDir: '${config.workflowsDir}' resolves outside the config file's directory ` +
          `(${toPosixPath(resolved)}). Repo configs may only point at directories inside the repo.`,
      );
    }
    config.workflowsDir = resolved;

Comment thread src/core/config.ts Outdated
Comment thread .github/copilot-instructions.md
Comment thread .changeset/feat-config-file.md
Comment thread src/core/types.ts
Comment thread src/core/config.ts
Five comments. Four real (including a Windows-specific security follow-up), one repeat hallucination.

**Security — Windows drive-relative path bypass (1):**

The round-9 `workflowsDir` containment check missed a Windows-specific escape vector: `path.isAbsolute('C:foo')` returns `false` on Windows (drive-RELATIVE, not absolute), but `path.resolve(configDir, 'C:foo')` would land on the current dir of the named drive — outside the config tree. `path.relative` between paths on different drives returns an absolute string, not a `..`-prefixed one, so the round-9 guard let that escape through.

Fix: three checks, each catching a class the others miss:
  (1) `path.isAbsolute` for POSIX absolutes + Windows drive-absolutes (`C:\foo`).
  (2) `/^[A-Za-z]:/` for Windows drive-RELATIVE (`C:foo`, `D:foo`). Rejected on POSIX too: configs are checked in and need to be portable.
  (3) Post-resolve: `relative === '..' || relative.startsWith('..' + sep) || path.isAbsolute(relative)`. The new `path.isAbsolute(relative)` term catches the Windows different-drive case where `path.relative` returns an absolute target path.

Added a `C:wf` rejection test. The threat-model comment in `loadConfig` got a numbered breakdown of all three checks so future maintainers don't collapse them by accident.

**Coverage configuration (1):**

`src/core/types.ts` was excluded from coverage as "types-only, no executable code." That comment stopped being true in round 8 when `TARGETS` moved here as a runtime export. Removed the exclusion; coverage stays well above thresholds (98.16% lines, 87.6% branches).

**POSIX path normalization in error messages (1):**

The two `workflowsDir`-rejection error messages interpolated `config.workflowsDir` in native form alongside an already-normalized `result.filepath`. Inconsistent on Windows. Both now route through `toPosixPath`.

**Changeset narrowing (1):**

The round-8 bullet claimed `workflows` Action input default changed from `'.github/workflows'` to empty. It didn't — `workflows` was always `default: ''` in 1.0.0; only `target` had its default changed. Split into two bullets so the changelog accurately describes what shifted vs. what was just docs-clarification. User-approved.

**Hallucination, round 4 (1):**

Copilot is back on the `custom-instructions/repo/.github/copilot-instructions.md` claim, now interpreting our "only Copilot guidance in the repo" statement (the very rephrasing meant to defuse this in round 8) as evidence that the duplicate must exist somewhere. Final firm reply will explain the verification methodology one more time and resolve; not engaging again if it returns in round 11.

Net: 223 tests (+1), four gates green, coverage above thresholds.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.

Comments suppressed due to low confidence (1)

src/core/config.ts:161

  • This still won't POSIX-normalize Windows-style input when running on POSIX: toPosixPath() only replaces the current platform separator (src/utils/paths.ts:11-14), and this branch deliberately rejects drive-qualified values on every platform. A config value like C:\wf will therefore be reported with backslashes in Linux/macOS output; normalize backslashes in the user-supplied value explicitly before interpolating it.
        `Invalid ghau config in ${toPosixPath(result.filepath)}:\n` +
          `  workflowsDir: must be a path relative to the config file's directory; ` +
          `got '${toPosixPath(config.workflowsDir)}'. ` +
          `Use the --workflows CLI flag if you really need to point at an absolute path.`,

Comment thread .github/copilot-instructions.md
Comment thread src/core/config.ts Outdated
Comment thread docs/guide/config-file.md
Three comments. Two real (one a security follow-up to my own round-10 fix), one repeat hallucination.

**Security — platform-portable absolute-path rejection (1):**

The round-10 fix used native `path.isAbsolute()`, which on POSIX returns `false` for Windows-rooted forms (`\\server\share`, `C:\foo`, `\foo`). A checked-in config with those values would pass POSIX's absolute check, fall through to `path.resolve` which treats them as literal subpaths on POSIX (no escape locally), but become real escapes if anyone ran the project on a Windows runner.

Fix: add `path.win32.isAbsolute()` alongside native `path.isAbsolute()` so all platform-absolute forms are rejected regardless of which OS the config is loaded on. The numbered-comment threat model in `loadConfig` got a fourth bullet explaining the portability invariant. Two new tests: UNC (`\\server\share`) and Windows-drive-absolute (`C:\wf`) rejection on POSIX.

**Documentation gap (1):**

The round-9/10 path-validation rules were implemented but not user-documented. Added a `:::warning Containment is enforced:::` callout to `docs/guide/config-file.md` after the existing "Relative paths" section, spelling out which forms get rejected (POSIX absolute, Windows drive-absolute, Windows-rooted, UNC, drive-relative, `..`-escaping) and pointing users at `--workflows` for the absolute-path use case. Users now learn the constraint before hitting exit-2.

**Hallucination, round 5 (1):**

Fifth iteration of the same `custom-instructions/repo/.github/copilot-instructions.md` fabrication. The round-10 reply committed to silent resolution on a fifth recurrence. Honoring that: this thread is resolved without a reply. If Copilot lands a genuinely new finding on the same file later it'll get evaluated on its own merits; the specific fabrication has had four rounds of explicit responses asking for evidence and zero follow-through.

Net: 225 tests (+2), four gates green.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 5 comments.

Comment thread src/core/config.ts
Comment thread .github/copilot-instructions.md
Comment thread src/core/config.ts
Comment thread vitest.config.ts Outdated
Comment thread tests/unit/core/config.test.ts Outdated
ylabonte added 2 commits May 15, 2026 18:20
Five Copilot threads, four real, one hallucination (round 6).

**Symlink containment (security, config.ts:178).** The previous lexical
relative-path check was bypass-able: a repo-controlled `ln -s ../outside
wf` plus `workflowsDir: wf` passed the relative check (literal `wf` is
inside configDir), but `fs.readdir`/`readFile`/write all follow symlinks,
so `ghau --write` could rewrite files outside the repo. Now: after the
lexical check, realpath both configDir and the resolved workflowsDir
(walking up to the longest existing prefix when the target doesn't
exist yet), and re-verify the relative-path containment on the realpaths.
In-tree symlinks remain allowed; only symlinks whose realpath escapes
the config tree are rejected. Tests cover both the reject and the accept
cases.

**Error-message backslash leak (config.ts:173).** `toPosixPath` only
swaps the native separator, so on POSIX an error rejecting a Windows-
form value (`C:\wf`, `\\server\share`) printed the value with literal
backslashes — confusing for a portability complaint that's specifically
about a Windows-rooted form. New `normalizeForMessage` helper does an
unconditional backslash → slash swap when interpolating the rejected
value, regardless of host OS. New test pins this directly.

**`\wf` single-backslash-rooted test (config.test.ts:152).** The
existing Windows-rooted test only exercised UNC (`\\server\share`); a
regression in the win32 absolute check that only handled UNC would
silently leave `\wf` reachable. Added a dedicated fixture so each
Windows-absolute form fails its own test.

**Coverage-exclusion narrowing (vitest.config.ts:15).** `src/cli.ts`
was excluded as a "thin entrypoint", but this PR added unit-tested
runtime logic there (`mergeOptions`, `isInvokedDirectly`). Removed the
file-wide exclusion; replaced it with surgical `c8 ignore` markers on
`main`, `runCommit`, and the bootstrap line — the parts that are
integration-tested via subprocess spawns (coverage doesn't follow into
subprocesses). The unit-tested pure pieces now count toward the
threshold. Coverage report confirms `src/cli.ts` at 90/95.45/100/93.75,
overall thresholds comfortable.

**Hallucination round 6 (copilot-instructions.md:6, silent resolve).**
Sixth iteration of the same "stale Copilot-instructions copy" pattern;
this time pointing at `custom-instructions/repo/.github/copilot-
instructions.md`. Verified non-existence via `find` + `git ls-files`.
Round-10 commitment was silent-resolve on recurrence; honoring that.

229 tests (was 225), all four gates green.
The v1 → v3 jump tracks the action's current major (released 2026-03-14;
v3.2.0 is the latest as of this commit). Behaviorally compatible for our
trusted-publishing flow — same inputs (`app-id`, `private-key`), same
outputs (`token`). The major bumps in between were release-tooling and
internal refactors, not contract changes.

No changeset entry: CI-only, not user-visible.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.

Comment thread action.yml
Comment thread action.yml
Comment thread src/core/config.ts
ylabonte added 2 commits May 15, 2026 18:51
Three Copilot findings, all real.

**Action back-compat for 1.0.x version pins (action.yml:188-199).** The
new `--no-allow-branch-pin` and `--no-fail-on-outdated` CLI flags ship
in 1.1.0. The previous tri-state Action input handling forwarded `false`
to the CLI unconditionally, so a caller pinning `version: '1.0.0'` plus
the input `'false'` would have crashed the older CLI with "unknown
option". In pre-1.1 versions, `false` was already the CLI's built-in
default for both options, i.e. effectively a no-op, so the regression
window was specifically for callers who set the input explicitly.

Fix: detect version specs matching the literal `1.0.x` family (`1.0`,
`1.0.0`, `1.0.5`, `1.0.0-rc.1`, `1.0.0+build`) and skip the negative
flag with a workflow `::warning::` explaining the disconnect. Any
version spec other than `1.0.x` — `1`, `latest`, `1.1.0+`, future
majors, dist-tags — is assumed to support the flag, which becomes true
the moment 1.1.0 publishes. The input descriptions and the changeset
entry now document the back-compat behavior.

**Windows-style relative traversal in `workflowsDir`
(src/core/config.ts:215).** A checked-in `workflowsDir: '..\\outside'`
passed all prior containment checks on POSIX: not platform-absolute,
not Windows-drive-absolute, not drive-relative, and `path.resolve` on
POSIX treats the value as a literal filename containing a backslash
(`<configDir>/..\outside`), so the relative-path check sees no escape.
On Windows the same value IS a `..`-escape and lands outside the
config tree. The same checked-in config quietly meant two different
things on Linux CI vs. a Windows runner — exactly the portability
failure the cross-platform absolute checks were designed to prevent.

Fix: extend the cross-platform rule by rejecting any literal backslash
in `workflowsDir`. Workflow directories in real repos don't contain
backslashes, so this is a safe default and keeps configs platform-
invariant. Two new tests cover `..\outside` (leading traversal) and
`subdir\nested\wf` (nested separator).

231 tests (was 229). The polling logger at /tmp/ghau-pr13-poll.sh
keeps running in the background; ping me to act on the next batch.
The previous narrow rule (`.claude/settings.local.json`) covered one
file; the previous commit accidentally tracked `.claude/scheduled_tasks.lock`,
a Claude Code session-state artifact that's per-clone and per-session.
Broaden the rule to the whole `.claude/` directory so any future state
files (plans, caches, locks) stay local by default. Existing tracked
copies are removed with `git rm --cached`.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 19 out of 20 changed files in this pull request and generated no new comments.

@ylabonte ylabonte merged commit 06ff6e0 into main May 15, 2026
17 of 18 checks passed
ylabonte added a commit that referenced this pull request May 15, 2026
Five findings from Copilot's first review pass:

- **JSON fences with `// filename` comments** (README.md, quickstart.md). JSON
  can't contain comments, so a user copy-pasting the snippet into
  `.ghaurc.json` hit a parse error. Moved the filename out of the fence as a
  preceding heading.

- **`ghau.config.ts` advertised but not actually loadable** (src/core/config.ts).
  cosmiconfig v9 doesn't ship a TypeScript loader; adding `jiti` /
  `cosmiconfig-typescript-loader` would have been the alternative. Chose the
  simpler path for v1.1.0: drop `.ts` from searchPlaces, document `.mjs` as
  the typed-config shape, leave a comment in `src/core/config.ts` and a
  callout box in the guide explaining the choice. defineConfig still works
  in `.mjs` (via JSDoc/@ts-check). Changeset, README, and quickstart
  updated to use `.mjs` in the defineConfig example.

- **Merge logic was integration-tested only for non-defaulted options**
  (src/cli.ts). The `getOptionValueSource`-gated path for `target`,
  `allowBranchPin`, `failOnOutdated` had no direct coverage. Extracted the
  merge into an exported `mergeOptions(program, config)` function. Added
  `tests/unit/cli.test.ts` with 15 unit tests covering: config-wins-on-empty
  for each defaulted option, CLI-wins-on-explicit for each, pass-through of
  non-mergeable options (write/interactive/commit/json/verbose/token).

- **Composite Action's `target` default silently masked config-file values**
  (action.yml). Previously, `target` defaulted to `'latest'` and was passed
  unconditionally as `--target "$GHAU_TARGET"`, so a repo with
  `target: 'minor'` in `.ghaurc` was overridden to `latest` when run via
  the Action without an explicit input. Now: default is empty string,
  the script conditionally appends `--target` only when non-empty
  (matching the existing pattern for `workflows`/`filter`/`reject`).
  Action users who want a hard guarantee against config drift can set
  `with: { target: minor }` explicitly. README inputs table + use-as-action
  guide updated to spell out the new semantics.

No behavior change for users who don't have a config file. With a config
file: existing CLI behavior unchanged; Action callers now inherit the
config's `target` instead of being silently overridden.
ylabonte added a commit that referenced this pull request May 15, 2026
Real latent bug shipped in 1.0.0 (and contributing to PR #13's red
selftest). The previous `invokedDirectly` check in `src/cli.ts`
used two heuristics:

  import.meta.url === `file://${process.argv[1] ?? ''}`
  || process.argv[1]?.endsWith('cli.js')
  || process.argv[1]?.endsWith('cli.ts')

Both fail when the CLI is invoked through the normal install path:

  $ ./node_modules/.bin/ghau --version

Node's argv[1] is the symlink path (`.../node_modules/.bin/ghau`),
NOT the resolved `dist/cli.js`. The exact-equality check fails
because `import.meta.url` is the realpath; the endsWith checks
fail because the symlink is named `ghau`, not `cli.js`. Result:
`main()` is never called, the CLI silently exits 0.

Repro (local, before fix): `npx -y -p github-actions-updater@1 ghau
--version` returns exit 0 with no output. The CI's `sh: 1: ghau:
not found` is the Linux surface of the same broken assumption.

Fix: replace the heuristic chain with `isInvokedDirectly(metaUrl,
entryPath)`, exported from `src/cli.ts` so it's directly testable.
The new implementation resolves BOTH sides to canonical paths via
`fs.realpathSync` before comparing, so symlinked invocation matches
the target file regardless of the symlink's name. Direct
invocations still work; imported-from-tests still returns false
(argv[1] points at the vitest binary, not at cli.ts).

Added 5 unit tests in `tests/unit/cli.test.ts` covering:
  - undefined/empty entryPath
  - direct invocation (argv[1] === target)
  - symlinked invocation (the .bin/ghau case that was broken)
  - unrelated argv[1] (test runner)
  - non-existent argv[1] (broken symlink / bad arg)

Verified locally: `npm install <fresh-tarball>` followed by
`./node_modules/.bin/ghau --json --workflows /tmp/empty` now
produces the expected JSON output and exits 0.

Note on the floating `v1` tag: once 1.1.0 publishes, `v1` updates
to point at the fixed action.yml (from the earlier commit), but
the ACTION wrapper invokes npm-published 1.0.0 by default, so
the action will pull the still-broken CLI from the registry. The
fix only takes full effect once 1.1.0 is on npm and the floating
version default rolls forward (or users explicitly pin `version: 1.1`).
ylabonte added a commit that referenced this pull request May 15, 2026
Eleven findings from Copilot's second pass. Grouping the fixes:

**Real bugs (3):**

- `tests/unit/cli.test.ts` referenced `__filename`, which is undefined
  in the ESM test suite (`type: module`). Replaced with `import.meta.url`.
  Vitest 4 happens to polyfill it for legacy compat, which is why the
  test passed locally; cold ESM execution would `ReferenceError`.

- A relative `workflowsDir` in a config file was resolved against
  `process.cwd()` instead of the config file's directory. So invoking
  `ghau` from `repo/packages/app` with a repo-level `.ghaurc.json`
  containing `workflowsDir: ".github/workflows"` scanned
  `repo/packages/app/.github/workflows`, not the repo-level path. Fixed
  in `loadConfig`: relative `workflowsDir` is now resolved against
  `dirname(loaded.filepath)`. Two new unit tests cover the relative
  and absolute cases.

- cosmiconfig's default `stopDir` prevented the search from walking
  past arbitrary boundaries (manifested as `null` when the test config
  lived in a parent dir of the search start). Set `stopDir: '/'` so the
  search reliably walks to the filesystem root.

**Security (1, significant):**

- `.ghaurc.mjs` / `.ghaurc.cjs` / `.ghaurc.js` / `ghau.config.{js,cjs,mjs}`
  let `ghau` execute repository-controlled JavaScript during config
  discovery. In the composite Action path, `GITHUB_TOKEN` is already in
  `process.env` by the time the CLI starts — so a checked-in
  `ghau.config.mjs` from an attacker-controlled PR could read or exfiltrate
  it, even though `token` is not in the schema. Mitigation: dropped
  executable formats from `searchPlaces` entirely. Supported set is now
  data-only: `package.json` `ghau` field, `.ghaurc`, `.ghaurc.json`,
  `.ghaurc.yaml`, `.ghaurc.yml`, `ghau.config.json`. The `defineConfig`
  helper became useless without executable formats and is removed from
  the package exports.

**Coverage / consistency (5):**

- Added explicit test that executable formats are not loaded (defense
  in depth against future regressions).
- Added a `.ghaurc.yaml` test (the YAML loader path had no coverage).
- POSIX-normalized the verbose `Config:` log line in `cli.ts` and the
  loader's malformed-config error in `config.ts` (via `toPosixPath`)
  so the user-visible path is stable on Windows.
- README exit-codes table extended to mention the new "exit 2 on
  malformed config" path.
- Cross-platform path normalization assertion in tests verifies the
  error message contains no backslashes.

**Docs / cleanup (2):**

- Documented the Action `changes`-output limitation when `workflowsDir`
  lives only in a config file (the Action's diff scope still uses
  `GHAU_WORKFLOWS` env). Proper fix — surfacing the effective
  `workflowsDir` from CLI JSON output and consuming it in the Action
  script — deferred to v1.2. Workaround documented: pass
  `with: workflows: ...` explicitly when relying on `changes` for gating.
- Swept stale `.ts` / `.mjs` / `defineConfig` references across
  README, `docs/guide/config-file.md`, `docs/guide/quickstart.md`, and
  the changeset entry. The config-file guide now leads with the
  data-only rationale via a warning callout.
ylabonte added a commit that referenced this pull request May 15, 2026
Four stale-prose findings from Copilot's third pass on PR #13.
All cleanup of round-2 leftovers; no code-path changes.

- `docs/reference/cli.md` — exit-code table's row for `2` updated
  to mention the new malformed-config-rejection path (the table
  sits OUTSIDE the autogen block, so no `pnpm docs:gen-cli` needed).
- `docs/guide/quickstart.md` — same exit-code update for the
  matching table on the quickstart page.
- `docs/guide/use-as-action.md` — replaced the wildcard
  `.ghaurc / ghau.config.*` reference (which overstated supported
  filenames) with a precise list of data-only config files plus a
  pointer to the config-file guide. Avoids misleading Action users
  toward `ghau.config.yaml` / `.ts` / etc., none of which are
  actually picked up.
- `src/core/config.ts` — the `track [issue link]` placeholder in
  the data-only-rationale comment block ships unchanged into
  `dist/core/config.js` because `removeComments: false` preserves
  JSDoc for the published API surface. Rephrased to "file an
  issue if you have a use case" — no broken link reference.

A fifth comment asked for the PR description to be updated
(it still mentioned `ghau.config.ts` and `defineConfig`); that's
GitHub metadata, not code, and lands separately via `gh pr edit`
after this push.

No changeset — prose-only cleanup within an unreleased feature.
@ylabonte ylabonte deleted the feat/config-file-loading branch May 15, 2026 17:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants