Drive Dependabot relock through check: --no-onboard gate, --no-narrow, re-pin write fixes - #36
Drive Dependabot relock through check: --no-onboard gate, --no-narrow, re-pin write fixes#36nodeselector wants to merge 16 commits into
check: --no-onboard gate, --no-narrow, re-pin write fixes#36Conversation
Add a new `update` cobra subcommand that relocks a single targeted action (`--action <owner/repo>@<ref>`) across a repo's workflows + lockfile, driven by Dependabot via capture3. Built fresh on the existing resolve/pin/lockfile engine that powers `check`. Behavior: - Resolves only the target action's bumped ref and its transitive subtree; untargeted deps keep their existing pins verbatim (closure splice). - `--write` mutates the working tree (YAML `uses:` ref + lockfile pin), byte-deterministic and working-tree-only. Without it, computes the diff. - Refuses to onboard new workflows unconditionally; un-onboarded targeted workflows are skipped with an `onboarding-required` finding (partial success). `--no-onboard`/`--no-interactive` accepted for Dependabot compatibility. - Emits three always-on arrays (`updated`, `workflows`, `findings`) plus `cli_version`, `lockfile_version`, `valid`. Tri-state exit codes reuse `check`'s errSilent: 0 clean, 1 blocking findings (stdout still valid JSON), 2 tool failure. - Impostor target (SHA on no branch) is a blocking finding, not a failure. - Mixed-case YAML refs preserve author casing while keying the lockfile on the canonical lowercase NWO. Tests: pin unit tests, lockfile closure helper, and 7 httpmock integration tests (clean relock, onboarding partial success, no-op, dry-run, missing flag, impostor, mixed-case) including raw-JSON contract assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Validate the consumer's diff-hygiene requirement without changing the writer. The single canonical writer (marshalDeterministic via Save) already produces a minimal diff on canonical input — a targeted bump only touches the changed pin and its changed transitive closure. - internal/lockfile: TestState_BumpYieldsMinimalDiff (untouched entries stay byte-identical across a one-dep bump), TestState_SaveFormatIsStable (exact-bytes golden guarding canonical-form drift), TestState_BumpTransitiveRemoval (orphan GC'd, cross-workflow shared transitive preserved byte-identically, dropped subgraph fully removed). - internal/pin: TestSpliceClosure_SharedTransitiveExistingPinWins (a moved-tag shared transitive keeps its existing pin — one stable entry per ref, no churn of untargeted deps). - cmd/gh-actions-pin: TestUpdateCommand_BumpPullsInNewTransitive (end-to-end add of a new transitive; transitive add does not create a second updated[] entry). No production change: heal-mode full regen is kept (diff-minimal for canonical lockfiles; older non-canonical lockfiles get a one-time healing reformat), and the targeted-subtree resolve is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The long help claimed the selector gates the updated diff, but WriteUpdateJSON emits all three arrays plus version/valid fields unconditionally. Make the docs honest: the selector is accepted for symmetry with check but gates nothing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Apply the Tier 1 + Tier 2 fixes from the six-reviewer audit of the `update` command. No change to the three-array JSON contract Dependabot consumes. Tier 1 (correctness): - Enforce the relock invariant. PlanUpdate now refuses a SHA-shaped --action ref up front (would write a sha-as-ref instead of pinning) and verifies each spliced closure pins the target at the new ref + resolved SHA, surfacing a blocking finding (per spec #9) rather than a silent exit-0 no-op. Changes are recorded only after a workflow passes verify, so updated[] never advertises a bump that was skipped. - Make CommitUpdate atomic w.r.t. the lockfile. Workflow YAML is staged to sibling temp files, the lockfile is saved (atomic temp+rename), then the temps are promoted with rename. No workflow file is mutated until the lockfile is durable, so a staging/save failure leaves the tree untouched. Promotion preserves the destination's mode and follows symlinks, so a 0600 or symlinked workflow keeps its identity. (Moved to update_commit.go.) - Add Remediation to impostorFinding; reword onboarding-required to "pin this workflow first"; correct the OnboardingRequired category comment to say refusal is unconditional (--no-onboard does not gate it). - Sort findings[] deterministically at the JSON boundary. Tier 2 (polish): - Case-fold the existing-SHA lookup so non-canonical NWO casing can't read as a phantom change. - workflows[] lists only files whose YAML was actually rewritten (a lockfile-only re-pin is omitted); invisible to Dependabot's new-ref path. - Fix user-facing text: exit-status JSON caveat, --json usage string, and a distinct "no changes applied" summary when a run is blocked vs a clean no-op. Surface ExtractActionRefs warnings on stderr in non-JSON mode. - Move renderUpdateSummary into format as PresentUpdateSummary; replace containsStr with slices.Contains; drop the unused fieldsCSV param from WriteUpdateJSON. Tests: relock-invariant (sha-shaped target is blocking, no write), atomic write-failure consistency, mode preservation, and verifyTargetPin/looksLikeSHA units. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add `gh-actions-pin outdated`, a read-only companion to `update`. For each action recorded in the lockfile it reports the newest ref available, preserving the precision of the current ref (major float v5 -> highest major-form v6; full pin v5.1.0 -> highest full v6.1.2), allowing cross-major upgrades, and offering strictly-greater versions only. The picker mirrors dependabot-core's github_actions UpdateChecker so an offered ref matches the ref an eventual Dependabot PR would land. Never writes. current_ref/current_sha come straight from the lock entry and are never re-resolved (keeps the CLI's report aligned with the recorded lock and preserves churn-free convergence with Dependabot). Only network work is tag listing; available_sha is the tag's peeled commit SHA. Output: always-on available_updates[] (bare SHAs, omitempty current_sha, precision enum major|minor|full|sha) alongside cli_version/lockfile_version. Tri-state exit optimized for a dev terminal: 1 = updates available (npm `outdated` convention, via errSilent), 0 = clean, 2 = tool failure. Tokenless fails at tag-list with exit 2 + empty stdout, matching check/update. Pagination is a parity requirement, not a nicety: core enumerates all tags via git ls-remote, so ghapi.ListTags now pages (per_page=100, 50-page cap) to avoid under-offering on >100-tag repos. - internal/discover: pure picker engine (precision classify, strict-greater, same-precision-then-coarser fallback) + table tests. - internal/ghapi: paginate ListTags + pagination test. - cmd/gh-actions-pin: outdated command, format.AvailableUpdate + WriteOutdatedJSON, PresentOutdatedSummary, root registration, command-level golden tests (major-float offer, full-pin offer, up-to-date no-op, bad selector usage error). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the standalone read-only `outdated` verb with an interactive version picker on `update`, and split the relock flags. - `--action owner/repo@<current>` now names the dependency and its CURRENT pinned ref (the precision anchor), and `--target <next>` is the destination ref. This inverts the old meaning where `--action`'s ref was the destination. - With no `--target` on an interactive terminal, `update` lists the newer versions available for the action (semver-desc, non-semver tags in release order) and relocks to the picked ref. Non-interactive with no `--target` is a usage failure (exit 2), never a hang or auto-pick. - `--write` is removed: `update` writes by default. The picker UI renders to stderr so `update --json` stdout stays pure JSON. Engine, JSON output (updated/workflows/findings), and tri-state exit codes are unchanged. The `internal/discover` engine is repurposed from a single-offer picker to `Candidates`, a ranked next-version list; the paginated tag listing is retained for picker parity. The relock argv for downstream callers moves from `update --action name@<target> --write` to `update --action name@<current> --target <next>`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The interactive picker defaulted to the highest semver tag overall, so a major-form float (e.g. @v5) defaulted to the highest full tag (v6.1.2) when dependabot-core would land the highest major-FORM tag (v6). A dev hitting Enter then wrote a ref that core's next run would flip back — avoidable churn. Add discover.CorePick, mirroring core's GitCommitChecker two-step precision rule: 1. among candidates of the same precision (dot-segment count) as the current ref, take the semver-max; 2. only if that is empty, take the global semver-max over candidates of lower-or-equal precision. Candidates are gated to core-versions: same prefix, v-prefixed or dotted (core's VERSION_REGEX rejects bare numerics like "5"), strictly greater, stable unless the current ref is itself a prerelease. pickTargetRef now seeds the picker's default with CorePick so the default choice equals the eventual-PR ref; the full candidate list is unchanged, so a dev can still deliberately pick a higher-precision tag. The relock contract and JSON output are untouched (the VM path always passes --target and never reaches the picker). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
An unreadable .github/workflows/actions.lock (corrupt YAML, unknown field, or a dependency entry missing a required key) was silently treated as empty and overwritten on the next save, degrading every workflow to onboarding-required/not-pinned with no diagnostic. LoadState now returns ErrCorruptLockfile instead of swallowing the parse error. A recovery policy in the command layer decides what to do: interactive check fix-mode offers to delete and recreate the lockfile; non-interactive/CI/--no-interactive/read-only (--no-fix) and update all fail with a clear, actionable error (exit 2, empty stdout, file kept). update never offers deletion because it only relocks already-onboarded workflows, so a delete would make it a silent no-op. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…-end Add a confirmFactoryHook seam so command tests can drive the interactive delete-and-recreate path without a TTY, and cover it end-to-end: - confirm-yes: corrupt lock is deleted and the workflow is fully re-pinned, writing a fresh valid lockfile with the real resolved SHA + owner/repo IDs (not the corrupt fake), exit 0. - confirm-no: run fails loudly and the unreadable lockfile is left byte-identical. This closes the duck-flagged gap: the recovery policy was only unit-tested in isolation; now the whole check fix-mode recovery -> re-pin -> write path is exercised against the httpmock harness. Confirms the recovered lock is rebuilt correctly (the reported empty-record/no-lock anomaly does not reproduce under deterministic conditions). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
dependabot-core will own workflow-YAML rewriting and call `check` to re-pin, so the standalone `update` relock verb and its interactive version picker are removed. Deletes update.go, the pin/update engine, internal/discover, and the update JSON/terminal surfaces. Preserved (already part of the validated contract): the corrupt-lockfile recovery path (ErrCorruptLockfile, lockrecovery), State.HasWorkflow, and the OnboardingRequired finding — the last two now back the upcoming `check --no-onboard` gate. --no-onboard/--no-interactive stay registered as persistent flags on the root check invocation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add --no-narrow flag to preserve mutable version refs (e.g. v4) in the lock comment instead of narrowing them to full patch tags (v4.2.1). Once a dep is locked with an imprecise tag, subsequent re-pins (e.g. on ref-moved) respect that choice by checking the existing lockfile for mutable refs before narrowing. This makes the precision sticky without requiring --no-narrow on every run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Wire --no-onboard to actually gate onboarding on the `check` path (it was previously accepted but ignored). After diagnosis, rewrite each not-pinned finding to onboarding-required and drop its ref from the workflow's action set so Plan/Commit never resolves or pins it. Already-tracked actions whose ref was bumped surface as ref-changed and still re-pin — the onboarding boundary is per workflow, so the existing not-pinned/ref-changed split already distinguishes "new" from "bumped". Refused entries force a blocking (errSilent → exit 1) run with well-formed JSON on stdout; they never escalate to a tool failure. Render onboarding-required as an alerted category in terminal output. Drop the now-unused State.HasWorkflow (its doc referenced the removed `upgrade` command). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A ref-bump or newly-added action in an already-tracked workflow failed to write with "branch is required in lockfile metadata". The lockfile read path (parserlock.Pin) drops branch/tag, so a carried Verified dep arrives at State.Set branchless. Set rejected the whole write instead of falling back to the branch already recorded on disk for that unchanged pin. Set now preserves Branch/Tag from the existing on-disk entry when an unchanged pin arrives branchless, and only errors when a genuinely new pin has no branch. This unblocks the consumer's check-based re-pin happy path (Dependabot rewrites uses: vN->vN+1, then runs check to re-pin). Adds a lockfile unit regression test and a command-level --no-onboard --no-narrow ref-bump write test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A fix-mode re-pin carried every inventory entry forward as a Verified pin, including stale ones (lockfile pins the workflow no longer references via uses:). So a Dependabot version bump left the prior pin behind: the workflow listed BOTH the old and new ref, the orphaned dependency entry never GC'd, and the run was not idempotent-convergent. check even emitted the stale finding, then ignored it. planWorkflow now prunes inventory entries matching a stale finding before building Verified entries, so the orphaned pin drops from workflows[path] and Save's GC removes the now-unreferenced dependencies[] entry. This also closes a supply-chain gap: the old SHA is no longer authorized for a workflow that no longer uses it. The combined --no-onboard --no-narrow ref-bump write test now asserts the stale ref and orphaned SHA are gone after the re-pin. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
⚠️ Human review recommended
It changes core lockfile read/write semantics and check command behavior (including recovery flows) in ways that benefit from final human validation across real-world repos and CI environments.
Pull request overview
This PR updates gh-actions-pin to support Dependabot-driven “relock” flows by routing re-pinning through check (instead of a dedicated update subcommand), adding operational gates (--no-onboard, --no-narrow), and fixing correctness issues in the lockfile read/write paths so relocks converge and remain idempotent.
Changes:
- Make
checksuitable for Dependabot relock:--no-onboardnow enforces a hard “no new entries” gate, and--no-narrowpreserves mutable tag precision. - Fix lockfile robustness and convergence: surface corrupt lockfiles explicitly (with controlled recovery), preserve branch/tag metadata for unchanged pins, and prune stale pins during re-pin planning.
- Add/expand tests covering onboarding refusal behavior, corrupt lockfile recovery behavior, branch preservation, minimal-diff guarantees, and transitive GC behavior.
File summaries
| File | Description |
|---|---|
| internal/pipeline/checks/category.go | Updates schema/category docs for onboarding-required under check --no-onboard. |
| internal/pin/plan.go | Adds NoNarrow, prunes stale inventory during planning, and respects prior mutable-ref precision. |
| internal/lockfile/state.go | Introduces ErrCorruptLockfile, stops swallowing parse errors, and preserves branch/tag metadata on unchanged pins. |
| internal/lockfile/state_test.go | Adds coverage for corrupt lockfile surfacing, preserved branch/tag on unchanged pins, GC/minimal-diff behaviors, and writer stability. |
| cmd/gh-actions-pin/root.go | Adds persistent --no-onboard / --no-interactive flags and corrupt-lockfile recovery wiring in newRun. |
| cmd/gh-actions-pin/check.go | Implements --no-narrow, applies --no-onboard gating, and integrates lockfile recovery policy into check. |
| cmd/gh-actions-pin/pin_summary.go | Extends terminal summary behavior to account for onboarding refusals. |
| cmd/gh-actions-pin/onboard_gate.go | Adds the --no-onboard gate implementation that rewrites findings and drops refused refs from planning. |
| cmd/gh-actions-pin/lockrecovery.go | Adds interactive/non-interactive corrupt lockfile recovery policy. |
| cmd/gh-actions-pin/lockrecovery_test.go | Tests the recovery policy behavior across interactive/headless/read-only cases. |
| cmd/gh-actions-pin/no_onboard_command_test.go | Tests --no-onboard semantics for new actions, new workflows, and ref bumps (including write behavior). |
| cmd/gh-actions-pin/corrupt_lock_command_test.go | Tests corrupt lockfile behavior for check in read-only and fix modes, including delete-and-recreate confirmation. |
| cmd/gh-actions-pin/format/terminal.go | Includes onboarding-required in error rendering and alert classification. |
| go.mod | Adds indirect module requirements needed by new/updated CLI/test dependencies. |
| go.sum | Updates module checksums corresponding to the new indirect requirements. |
Copilot's findings
- Files reviewed: 14/15 changed files
- Comments generated: 0
Note
Your feedback helps us improve the quality of this feature.
Please use 👍 or 👎 to tell us whether this assessment is correct.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Fix-mode dropped an impostor-flagged dep from a workflow's pin set whenever a co-located bump forced that workflow to be rewritten, shrinking the list toward [] and trivially passing runner enforcement. Re-add the existing on-disk pin (kept direct) so the blocking finding + exit 1 still stand but the pin is never silently lost. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Why
Dependabot-core needs to re-pin a repo's
actions.lockafter it rewrites a workflow'suses:ref (e.g.@v4->@v5). The design splits cleanly: dependabot-core owns the YAMLuses:rewrite, then callscheckto re-pin the lock to match. Driving the relock throughcheck(rather than a single-target command) means it reads each workflow's actual ref, so divergent per-workflow refs survive as individual re-pins instead of being flattened to one floating target. This PR makescheckhonor that contract and fixes two re-pin write-path bugs found while validating it end-to-end against the real binary.What changed
--no-onboardis now a real gate oncheck(was a documented no-op). It refuses to onboard new workflows or new actions: auses:ref with no lockfile entry for its own workflow is rewritten to anonboarding-requiredfinding and skipped, instead of being silently added. A tracked action whose ref was bumped still re-pins (ref-changed), which is the case every Dependabot bump hits. Partial success holds: tracked workflows re-pin and save atomically, refused ones surface as findings, and the run exits 1 with well-formed JSON (not a tool failure).--no-narrowrides along oncheckso a chosen ref precision (e.g.@v6) is not silently narrowed to@v6.2.1.Corrupt-lockfile recovery. A corrupt lock fails loud (exit 2, empty stdout) in CI / non-interactive / read-only paths instead of being silently overwritten, and offers an interactive delete-and-recreate when a TTY is present.
Two re-pin write-path fixes found via credentialed end-to-end runs:
branch/tag, so a carried verified dep reachedState.Setbranchless and the strict branch guard rejected the whole write (branch is required in lockfile metadata).Setnow reuses the branch already recorded on disk for an unchanged pin; only a genuinely new pin with no branch errors.planWorkflownow prunes inventory entries matching astalefinding before building verified entries; the existing GC then drops the orphaneddependencies[]entry.Notes for reviewers
NoOnboardplumbing.onboarding-requiredisseverity:error->valid:false-> exit 1, but the run still saves the onboarded subset. Thedependencyfield on those findings is always present and alwaysowner/repo@ref(no:sha), which the consumer keys on.dependencies[]entry when no workflow references it, so a cross-workflow shared-version bump prunes per workflow rather than globally. Intended.CliEngine#relock(convergent, idempotent, clean read-back). Full Go suite,go vet, andgofmtare green.