Repository navigation
refactor(flows)!: one owner per refusal, read, and write; one wording for both faces - #208
Conversation
File size check0 over a hard cap (fails), 13 warning(s).
Split the file, wrap the line, shorten or exempt the comment, or list the path in 4 managed file(s) skipped; repo-platform owns them. |
305b872 to
dc7d044
Compare
cec1326 to
2446523
Compare
6fa7474 to
4225379
Compare
2446523 to
2a4c8ef
Compare
2a4c8ef to
733fc59
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The shared writer can silently delete an existing user-owned .tmp sibling.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Centralizes configuration refusal, file I/O, section selection, and error rendering across the action, CLI, and library.
Changes:
- Adds capability-aware parsing,
RunEnd, and shared problem wording. - Consolidates settings reads and atomic replacement writes.
- Standardizes
SectionSelectionusage and updates tests/docs.
File summaries
| File | Description |
|---|---|
test/sections/contract.test.ts |
Updates retry wording. |
test/report/issue-report.test.ts |
Updates warnings and selection API. |
test/report/delivery.test.ts |
Updates artifact warning and selection API. |
test/report/artifact-report.test.ts |
Updates artifact warning expectations. |
test/problem.test.ts |
Covers unified problem messages. |
test/github/graphql.test.ts |
Updates transport retry wording. |
test/github/api.test.ts |
Updates API failure wording. |
test/flows/single.test.ts |
Removes flow-level uploader checks. |
test/flows/merge.test.ts |
Tests staging-path collision protection. |
test/flows/library.test.ts |
Updates library failure wording. |
test/flows/inputs.test.ts |
Supplies action capabilities. |
test/flows/execute.test.ts |
Tests RunEnd behavior. |
test/flows/deliver.test.ts |
Updates shared failure wording. |
test/engine/secrets.test.ts |
Uses SectionSelection. |
test/engine/orchestrate.test.ts |
Migrates validation selections. |
test/engine/layers.test.ts |
Uses all-section selection. |
test/engine/check-purity.test.ts |
Migrates fixture validation. |
test/e2e/oracle.ts |
Migrates oracle validation. |
test/e2e/generators.ts |
Migrates generator validation. |
test/e2e/generators.test.ts |
Migrates generator tests. |
test/docs/settings-examples.ts |
Migrates example validation. |
test/docs/guides.test.ts |
Migrates guide validation. |
test/cli/program.test.ts |
Updates unified CLI messages. |
test/cli/inputs.test.ts |
Tests capability-aware parsing. |
test/cli/init.test.ts |
Updates init refusal wording. |
test/cli/execution.test.ts |
Supplies action capabilities. |
test/action/artifact.test.ts |
Updates artifact retry wording. |
src/sections/secret_scanning_custom_patterns/secret_scanning_custom_patterns.test.ts |
Migrates section validation. |
src/sections/actions_secrets/actions_secrets.test.ts |
Migrates section validation. |
src/report/issue-report.ts |
Makes retry advice face-neutral. |
src/report/delivery.ts |
Replaces uploader refusal with invariant. |
src/report/artifact-report.ts |
Makes retry advice face-neutral. |
src/problem.ts |
Adds problems, roles, and unified wording. |
src/internal.ts |
Exports internal I/O helpers. |
src/index.ts |
Exports new public run types. |
src/github/api.ts |
Unifies API retry wording. |
src/flows/snapshot.ts |
Uses the shared replacement writer. |
src/flows/single.ts |
Removes duplicate uploader validation. |
src/flows/settings-write.ts |
Adds shared staged file replacement. |
src/flows/multi.ts |
Centralizes file reading and validation. |
src/flows/merge.ts |
Adds staged writes and collision checks. |
src/flows/library.ts |
Passes SectionSelection directly. |
src/flows/layers.ts |
Standardizes all-section validation. |
src/flows/inputs.ts |
Adds capabilities and init destination parsing. |
src/flows/execute.ts |
Returns structured run results. |
src/flows/deliver.ts |
Removes duplicate uploader checks and renderer hook. |
src/engine/snapshot.ts |
Uses all-section selection. |
src/engine/orchestrate.ts |
Accepts SectionSelection. |
src/cli/program.ts |
Uses shared parsing, rendering, and crash I/O. |
src/cli/init.ts |
Uses shared parsing and replacement writes. |
src/cli/commands.ts |
Removes CLI-specific problem rendering. |
src/action/run.ts |
Adapts to RunEnd. |
src/action/inputs.ts |
Declares artifact capability. |
docs/upgrading/v2-to-v3.md |
Documents new breaking changes. |
docs/reference/library.md |
Documents revised public APIs. |
docs/playbooks/teams-not-collaborators.md |
Clarifies undeclared team behavior. |
Review details
- Files reviewed: 56/56 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
733fc59 to
74a6f3c
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Path alias handling and the shared writer introduce reproducible false refusals, filename-limit failures, and metadata regressions.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 59/59 changed files
- Comments generated: 4
- Review effort level: Balanced
74a6f3c to
53bcd5d
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The public artifact path can still crash, replacement writes can widen permissions, and merge misclassifies a leaf symlink as an input collision.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
src/flows/settings-write.ts:65
- Replacing an existing file resets its permission bits to the process default. For example, with umask
022,gsac init --forceon a mode-0600settings file creates this staging file as0644and renames it over the protected file; repeated merge writes have the same regression, and settings can contain sensitive values. Create the staging file with the existing regular destination's mode (while retaining the documented symlink behavior), rather than always usingopenSync's default mode.
fd = openSync(staging, "wx");
src/flows/merge.ts:30
- Following the destination's leaf symlink makes a safe merge fail as if it would overwrite a layer. If
merged.ymlis a symlink to therepo.ymllayer, both calls returnrepo.ymland this emitsmerged-file-is-layer, althoughwriteReplacingrenames over themerged.ymllink and leavesrepo.ymluntouched. Compute the destination identity using the canonical parent plus its leaf for symlinks, while retaining real-path comparison for regular files and case aliases, so this guard matches the writer's landing semantics.
const landing = canonicalPath(cfg.mergedFile);
const index = cfg.settingsFiles.findIndex((layer) => canonicalPath(layer) === landing);
- Files reviewed: 59/59 changed files
- Comments generated: 1
- Review effort level: Balanced
… for both faces
The internal one-owner items of the v3 rule sweep:
- parseConfig takes the face's RunCapabilities and owns the artifact-channel
refusal (input-artifact-unsupported); the CLI pre-check, requireUploader,
and artifact-uploader-missing go.
- executeRun returns RunEnd {exitCode, fatal?}; the describe hook and the
CLI's rewording (describeCliProblem) go, since the shared prose is now
face-neutral (token, repository, section names, re-run, the report channels).
- multi's central repos-dir file is read through readSettingsFile under the
new central-file role, so its prose has one owner.
- writeReplacing() (flows/settings-write.ts) stages and renames for the
snapshot, the merged file, and init alike.
- SectionSelection is the one "no allowlist" carrier: validateSettingsDoc,
validateSettings, foldLayers, and the engine snapshot take it.
- init reads settings-file through parseSnapshotFileConfig / snapshotFileDestination;
its own LIST_SEPARATOR and is-list problem go.
- The CLI's crash line goes through cliIo like every other error line.
… the teams knob does to undeclared teams The playbook no longer says an undeclared team is left untouched: it is kept and noted under the default, and the wrapped delete form revokes the direct grants the file does not name. The upgrade page counts its two silent breaks and says the per-run notes cover undeclared DIRECT grants, with organization-level access noted only under delete.
…er's <file>.tmp sibling is never unlinked writeReplacing staged at `<path>.tmp` and removed whatever sat there first: `gsac init --settings-file settings.yml` with a `settings.yml.tmp` of the user's deleted it and succeeded, and the merge did the same for a sibling that was not one of its layers. The staging name now carries the pid and random bytes, and only that file is removed on failure; an existing path there fails the write instead. The merge's staging-sibling arm of the collision guard and the `staging` field on merged-file-is-layer go with it: there is no fixed sibling to collide with.
…folded onto one file fail the second loudly An operator link inside the snapshot-dir (`out/bob -> out/alice`) carried alice/r and bob/r to one file; the second write won and both reported success. snapshotFilePath now records every landing this run writes, by the target that claimed it, and refuses a second claim naming both targets and the path. With it: the writer's header says a symlink destination is replaced by the rename (the link, never its referent), and the one-use OneFileReader alias is inlined.
…r form claims the leaf the rename reaches Three findings of the codex round on the writer and the claim: - `wx` refusing an existing staging file (another writer's, in the pid-and-random corner) reached the catch, which removed that file; the write's own creation now gates the removal. - The claim key was the destination's referent, so a destination that is itself a link to an older file claimed the old file, the rename replaced the link, and a second target through an owner link landed unclaimed on the new file. The claim is now the directory as the filesystem names it plus the leaf as spelled: the file the rename reaches. - The refusal named the earlier target's slug and the landing path through the second target's channel, which may be public; it now names the earlier target through the redaction plan's display and never the landing. The message says "claimed", since a claim outlives a first target whose snapshot then failed.
… the result switch is exhaustive A private repository under on-missing-permission: warn with one denied section ended `partial` and printed nothing, where the shown repository prints the section's skipped line. emitRedactedResult now switches over every run result (a missing arm fails to compile) and prints `<display>: partial - <sections> (<code>)` with the details-hidden note.
… the dir form claims the referent too - The staging file is opened `wx` first and owned from that moment, so a write that fails after the create (disk full) still removes its own file, and a create that fails on another writer's file removes nothing. - The claim covers both names a file has: the referent as the filesystem names it now (which is the first target's on-disk leaf once it exists, catching a leaf spelled in another case on a case-insensitive filesystem) and the leaf the rename reaches (catching a leaf that was a link).
53bcd5d to
0abc12a
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The merge collision guard can overwrite a layer read through a final symlink.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 59/59 changed files
- Comments generated: 1
- Review effort level: Balanced
…on and keeps the file's mode; the guards compare every name the write lands on Findings on the shared writer and the two guards over it: - The staging name was the destination's leaf plus a suffix, so a leaf at NAME_MAX made the write fail; it is now `.gsac-<pid>-<hex>.tmp` in the destination's directory, spelled as the caller spelled it (a `link/..` segment stays the OS's to resolve, the same way for both names, so the rename never crosses a filesystem). - Replacing through a new file dropped an existing 0600 file to the umask; the staging file takes the existing regular destination's permission bits before the rename (ownership cannot be kept without privileges and is not). - The merge guard compared the destination's referent alone, refusing `out.yml -> layer.yml` though the rename replaces the link and leaves the layer intact; the snapshot claim reserved a destination link's referent too, refusing `old/r` after `alice/r` whose leaf linked to old/r.yml. Both now use landingNames(): the rename target (the directory as the filesystem names it plus the leaf as spelled) and, unless the leaf is a link, the referent as the filesystem names it now, which on a case-insensitive filesystem is the existing file's own spelling. The snapshot claims the rename target and looks up every landing name, in either target order.
…e read follows or the rename reaches The guard compared spellings, and a layer read through a link kept slipping past it: `alias.yml -> repo.yml` with the merge written to repo.yml changed what the next run reads; a chain `alias -> out -> repo` written to out.yml did the same; a layer under a directory link written over the link made it unreadable; a case alias of a layer's link replaced the link. The comparison is by identity now: the entry the rename replaces (the leaf under its resolved parent, unfollowed) against every entry each layer's read follows (each component, every link hop, the final file). The writer's header states the promise and its scope: deliberate evasion (hardlinks, mounts, races) is out.
0abc12a to
77e31c1
Compare
There was a problem hiding this comment.
🟡 Changes recommended
The v2-to-v3 guide incorrectly states that snapshot writes were previously performed in place.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 59/59 changed files
- Comments generated: 1
- Review effort level: Balanced
|
Agreed: the old snapshot writer already staged through |
Before
The action printed a different line for the same problem (
Set the "token" input on the action step (or export GITHUB_TOKEN)), and the command line refusedprivate-report: artifactin a pre-check of its own while the flows re-checked for the uploader.After
What changed
Every refusal, read, and write has one owner, and both faces print one wording per problem.
parseConfigtakesRunCapabilitiesand owns the artifact-channel refusal; the CLI pre-check and the flows' uploader check are gone.describehook are gone;executeRunreturnsRunEnd { exitCode, fatal? }.readSettingsFileunder acentral-filerole.writeReplacingfor the snapshot, the merged file, and init. It stages under a short name of its own beside the destination (.gsac-<pid>-<hex>.tmp, openedwx), keeps an existing file's mode, and never unlinks a user's file; the dir-form snapshot claims each file once, so two targets folded onto one file by an operator link fail the second.SectionSelectionreplaces every bareReadonlySet; init reads its destination through the library.cliIo, like every other error line.Proof
bun run checkgreen (3468 unit tests);bun run test:e2e260/260;build:checkzero drift;build:lib+ package smoke ok. Accounting against main (git diff origin/main...HEAD --numstat; source =src/minus*.test.ts,mock.ts,generators.ts, scenarios): source +482 -302 (net +180), tests +546 -185 (net +361), docs +40 -9 (net +31). Source grew because the one writer took over staging ownership, the identity-based layer guard, and the aliased-target claim, replacing three in-place writers and the pre-existing last-writer-wins.Technical details
Based on main eac7de6 (#206, #207, #203 merged). Upgrade entries:
docs/upgrading/v2-to-v3.mdsections 22 (wording) and 23 (library). Ten commits: the PR commit, the docs commit carried over from #206's branch (the teams playbook and upgrade-page wording), the writer's unique staging name, the lead's gate folds, the redacted-partial line, two codex fold rounds on the writer and the claim, the fold of the second Copilot round (four threads) with its own codex rounds, and the identity-based merge guard (the third Copilot round's one thread).Decisions
parseConfig(read, env, capabilities)owns the artifact refusal (input-artifact-unsupported); the CLI pre-check,requireUploader, andartifact-uploader-missingare deleted.openReportChannelkeeps a BUG throw for a face that declares the capability but hands no uploaderdescribedescribeCliProblemis deleted; with no face wording left, thedescribehook goes too andexecuteRunreturnsRunEnd(the CLI's--jsonenvelope reads the fatal problem from it). Threadingdescribethrough multi's per-target path therefore has nothing to thread;describeProblemstaysSettingsFileRolegainscentral-file; a YAML syntax error in a repos-dir file now reads through the same role instead of a separatecannot parselinewriteReplacinginsrc/flows/settings-write.tsopens.gsac-<pid>-<8 hex>.tmpin the destination's directory (spelled as the caller spelled it, so alink/..segment resolves the same way for both names and the rename never crosses a filesystem; a fixed short name, so a destination leaf at NAME_MAX still writes) withwx, owns it from that open (only a file it created is removed on failure), copies an existing regular destination's permission bits, writes, closes, renames. Ownership is not carried (needs privileges). A destination that is a symlink is replaced by the rename (the link, never its referent)dev:ino), never by name:renameEntry(dest)is the entry the rename replaces (the leaf under its OS-resolved parent, a link at the leaf unfollowed; null for a new entry),readEntries(layer)is every entry the read follows (each component, every link hop of a chain, the final file; a 64-hop budget bounds cycles). Refuse when a layer's set holds the replaced entry.out.yml -> layer.ymlas the destination is admitted (the link is replaced, the layer stands);alias.yml -> repo.ymlas a layer withrepo.ymlas the destination, a chain, a directory link the layer is read through, a case alias of the layer or of its link, and a layer that IS the destination link are refused. Scope, in the writer's header: an input layer is never the destination, under any name the read follows or the rename reaches; deliberate evasion (hardlinks, mounts, races) is out of scopelandingNames(path)in settings-write.ts: the rename target (the directory as the filesystem names it plus the leaf as spelled) and, unless the leaf is a link, the referent as the filesystem names it now (on a case-insensitive filesystem, an existing file's own spelling). The dir-form snapshot claims each rename target and refuses a later target whose landing names hit a claim, in either target order; the refusal names the earlier target through the redaction plan's display, never the landing pathemitRedactedResultswitches exhaustively overRunOutcome(aneverdefault);partialprints<display>: partial - <skipped> (<code>). <details hidden>where it printed nothingSectionSelectionreplaces every bareReadonlySet<SectionKey>parseSnapshotFileConfig(read, env, "settings-file")refuses a list separator withinput-settings-file-is-list(mode: "init", its own wording); init'sLIST_SEPARATORandinit-settings-file-is-listare deletedRunCapabilitiesandRunEndare public (inparseConfig's andexecuteRun's signatures, with library-page rows);writeReplacingandsnapshotFileDestinationare the CLI's and sit insrc/internal.tsProof detail
parseConfigrefuses the artifact channel for{ artifactUpload: false }and admits it fortrue; the CLI/action equivalence table drives both through it;executeRun's fatal problem comes back beside the exit code.merged.yml.tmplayer present and leaves it intact; the snapshot leaves a user'ssnapshot.yml.tmpdirectory intact and no staging file behind; a rename failure removes the run's own staging file; a 0600 merged file stays 0600 under umask 022; a 255-character leaf writes;link/../out.ymlstages beside the resolved destination (the test makes the lexical directory read-only, so a staging file placed there fails the run; the author ran the negative control with thejoin()spelling and the test failed).out.yml -> repo.yml, refuses a layeralias.yml -> repo.ymlwithrepo.ymlas the destination, the chainalias -> out -> repowithout.ymlas the destination, a layerfolder/repo.ymlthroughfolder -> .withfolderas the destination,REPO.YMLoverrepo.ymlandALIAS.YMLover the layer linkalias.ymlon a case-insensitive filesystem (probe-gated), and a layer that is the destination link; snapshot refuses the second of two targets folded by an owner link (a leaf that was a link, both orders of a case alias), admitsold/rafteralice/rwhose leaf linked toold/r.yml.runSinglewith a private repository,labelsdenied 403 underwarn: resultpartial, one lineprivate repository #1: partial - labels (403). <note>, plus the unit case intest/flows/redact.test.ts.Line accounting
commands.ts,init.ts),requireUploader(deliver.ts), the inline central read (multi.ts), three in-place writers (snapshot.ts,merge.ts). Growth: the shared writer withcanonicalPath,renameTarget,landingNames, and the identity walkreadEntries/renameEntry(settings-write.ts), the per-run claim (snapshot.ts),RunCapabilitiesand the destination reader (inputs.ts),RunEnd(execute.ts), the exhaustive result switch (redact.ts), the artifact prose (problem.ts)link/.., and rename tests, the guard and claim tests (nine link and case shapes), the redacted-partial line, the artifact refusal,RunEnd, thecentral-fileand init wordings; the rest areSectionSelection.ALLand wording updates on main's test fakesRunEnd/RunCapabilities/central-filerows, and the teams playbook and upgrade-page wordingemitRedactedResult's missingpartialarm (a redacted private repository underwarnwith one denied section printed nothing); the writer's<file>.tmpunlink and umask mode drop, pre-existing in the snapshot writer, fixed for every caller at once.artifactUpload: truewith no uploader reaches the BUG throw for a library caller (the capability should carry the uploader; a later PR); an init-specificsettings.yml.tmpregression (all three callers share one writer); clearingfdbeforecloseSync; on Windows only, alink\..destination spelling (Windows collapses it lexically before following the link, the walk follows the link first) and a 64-bit NTFS file id folded to a 53-bit Number (two distinct files could read as one and refuse a safe destination), both under the header's out-of-scope clause, neither with a concrete input a CLI user hits today; a case alias where the first snapshot destination already exists in another on-disk case; the sections' ownre-run the workflowstrings (src/sections/**,src/main.ts) are another builder's.<merged-file>.tmp), round 2 one blocking (the guard compared lexically) plus one literal, round 3 clean; one clean round on the replay onto refactor(library)!: a documented public entry and an internal one; one naming family per layer #205 and chore(test): one generator registry behind build:check, one Io capture fake, one ROOT, conventions stated #200; one clean round on the docs commit and the replay onto main 05e6d41. Writer and claim: round 1 three blocking (wxEEXIST removed another writer's file; a leaf link changed the claim key; the refusal named a possibly private earlier target through a public channel), round 2 two blocking (a case-aliased leaf; the ownership flag set after the write) plus one comment, round 3 clean. Second Copilot fold: round 4 two blocking (a case-aliased merge destination; staging under cwd for alink/..destination), round 5 one blocking (a layer that is the destination link) plus a test that could not fail, round 6 one comment, then clean. Identity guard: one round, 2 Windows-only findings recorded (above), 0 POSIX findings; the reviewer traced the walk against OS resolution on macOS and confirmed the relative-cwd and link-target handling. The lead's gate review: 0 blocking, 4 non-blocking, all folded..tmpsibling unlink, the mode drop, NAME_MAX, the two false refusals on link destinations, the layer read through a link to the destination, the teams docs and upgrade-page wording), 1 rejected as recorded (the capability carrying the uploader); all resolved.executeRun's return type,parseConfig's third argument, andvalidateSettings's option type are the v3 library breaks listed in upgrade section 23; the sections' remedy strings are out of scope here.BEGIN_COMMIT_OVERRIDE
refactor(flows)!: one owner per refusal, read, and write; one wording for both faces
BREAKING CHANGE:
parseConfig(read, env, capabilities)owns theprivate-report: artifactrefusal (input-artifact-unsupported);artifact-uploader-missingand the flows' uploader check are gone.executeRunreturnsRunEnd { exitCode, fatal? }andRunDepshas nodescribe;failRun(io, problem)takes no wording hook.validateSettingsDocandvalidateSettingstake aSectionSelectionwhere they took aReadonlySet<SectionKey>.parseSnapshotFileConfig(read, env, "settings-file")reads the destination itself;SettingsFileRolegainscentral-file.The action and the command line print one wording per problem; remedies name the input and its flag and say "re-run" without "the workflow".
END_COMMIT_OVERRIDE