feat(config): load repo defaults from .ghaurc / ghau.config.* / package.json ghau key#13
Conversation
…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.
There was a problem hiding this comment.
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.tswith schema validation, config discovery, anddefineConfig. - 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. |
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`).
There was a problem hiding this comment.
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.tsconfigs 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 exampleghau.config.mjswith 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';
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.
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.
There was a problem hiding this comment.
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.*; onlyghau.config.jsonis 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
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.
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.
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).
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.
There was a problem hiding this comment.
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
workflowsDirvalue. A Windows-style config value such as..\escapewould be shown with backslashes, unlike the rest of the user-facing path output in this loader and the project convention established bysrc/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
workflowsDirat an in-repo symlink whose target is outside the repo/config directory.scanWorkflowsusesreaddir/stat, which follow directory symlinks, and--writewould 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;
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.
There was a problem hiding this comment.
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 likeC:\wfwill 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.`,
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.
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.
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`.
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.
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`).
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.
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.
Summary
First half of the v1.1.0 plan: implement the config-file feature that
docs/guide/config-file.mdhas been documenting since the initial release. The docs (and thecosmiconfig+zoddeps inpackage.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
ghaupicks it up. Supported filenames:.ghaurc,.ghaurc.json,.ghaurc.yaml,.ghaurc.yml,ghau.config.json, or aghaukey inpackage.json.{ "target": "minor", "rejects": ["docker://**"], "failOnOutdated": true }Schema (strictly validated by zod; unknown keys rejected):
A relative
workflowsDiris resolved against the config file's directory, so a repo-level.ghaurc.jsonkeeps pointing at<repo-root>/.github/workflowsregardless of which subdirectory you invokeghaufrom.Data-only by design
Executable config formats (
.js,.cjs,.mjs,.ts) are intentionally not supported. Allowing them would letghauexecute repository-controlled JavaScript during config discovery — and in the composite Action path,GITHUB_TOKENis already inprocess.envby the time the CLI starts, so a checked-inghau.config.mjsfrom an attacker-controlled PR could read or exfiltrate it even thoughtokenis not part of the config schema. Keeping the config surface data-only eliminates that vector. Seedocs/guide/config-file.mdfor the full security rationale.Files
src/core/config.ts(cosmiconfig + zod loader),tests/unit/core/config.test.ts(18 tests),tests/unit/cli.test.ts(22 tests formergeOptions+isInvokedDirectly),.changeset/feat-config-file.md(minor bump).src/cli.ts(load + merge +isInvokedDirectlyfix 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), plusCLAUDE.md+.github/copilot-instructions.md(meta-lesson on bidirectional doc drift), andaction.yml(target default + npx form fix + isInvokedDirectly-related selftest implications).Test plan
.ghaurc.jsonin a fresh checkout and confirmpnpm dev -- --verbose --jsonlogs the config path on stderr.