Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .abcd/work/DECISIONS.md

Large diffs are not rendered by default.

55 changes: 36 additions & 19 deletions .github/workflows/auto-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,13 @@ name: auto-release
# Only the NEWEST dated version is ever tagged: older CHANGELOG versions are left
# alone, because tagging them at the current HEAD would mis-point an immutable
# tag at the wrong code. Idempotent: when the newest version is already tagged AND
# its GitHub Release exists, `detect` reports need_tag=need_release=false and
# nothing runs, so an ordinary (non-release) push to main does nothing. If the tag
# exists but its Release is MISSING (e.g. a transient publish failure), `detect`
# sets need_release=true and re-invokes `release` ALONE — built from the tagged
# commit (release_ref), never the moved-on HEAD — so a flaky publish never
# permanently wedges the version.
# its GitHub Release is published and complete, `detect` reports
# need_tag=need_release=false and nothing runs, so an ordinary (non-release) push
# to main does nothing. If the tag exists but its Release is MISSING OR
# INCOMPLETE — absent, still a draft, or short of its five assets, as a transient
# publish failure leaves it — `detect` sets need_release=true and re-invokes
# `release` ALONE — built from the tagged commit (release_ref), never the
# moved-on HEAD — so a flaky publish never permanently wedges the version.
on:
push:
branches: [main]
Expand Down Expand Up @@ -81,21 +82,37 @@ jobs:
tag="v$version"
if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then
# The tag already exists, so it is NEVER moved (immutable). Re-release
# ONLY when its GitHub Release is missing: a transient publish failure
# must not permanently wedge the version. Build the re-release FROM the
# ONLY when its GitHub Release is missing or incomplete: a transient
# publish failure must not permanently wedge the version, and a
# half-published one must not pass for done. Build the re-release FROM the
# tagged commit, not the current (moved-on) main HEAD — resolve the tag
# to its immutable commit SHA and hand it to release.yml as `ref`.
echo "need_tag=false" >> "$GITHUB_OUTPUT"
# "Released" means released AND complete, not merely present. A run
# that dies part-way through publishing leaves a Release that EXISTS
# — draft, or missing some of its assets — and a bare existence check
# calls that done, so the version stays half-published while every
# post-publish gate in release.yml is silently skipped. Require
# isDraft=false and the full asset set: the four cross-compiled
# binaries plus checksums.txt. The comparison is >= 5, not == 5, so
# an asset attached by hand cannot make a complete release look
# incomplete and re-release it on every subsequent push to main.
#
# Deliberate fail-open: ANY non-zero from `gh release view` (a true 404
# or a transient rate-limit/auth blip) counts as "Release missing" and
# re-releases. Safe — `gh release create` (release.yml) has no --clobber,
# so a false positive just errors on the existing release and the
# success-gated prune never runs: one red run, no data loss. Parsing the
# error to isolate a real 404 was rejected — it would hinge on gh's
# wording and could misclassify a genuine missing-Release, re-wedging the
# very failure this heals.
if gh release view "$tag" >/dev/null 2>&1; then
echo "$tag is tagged and released; nothing to do."
# or a transient rate-limit/auth blip), and any output that is not a
# literal "true", counts as "Release missing or incomplete" and
# re-releases. Safe — release.yml's publish step is idempotent: it
# re-uploads the assets with --clobber and re-asserts the title,
# notes, draft state and prerelease flag when the Release already
# exists, so a false positive republishes the same bytes and re-runs
# the post-publish gates instead of erroring. Parsing the error to
# isolate a real 404 was rejected — it would hinge on gh's wording and
# could misclassify a genuine missing-Release, re-wedging the very
# failure this heals.
released="$(gh release view "$tag" --json isDraft,assets \
--jq '((.isDraft | not) and ((.assets | length) >= 5))' 2>/dev/null || true)"
if [ "$released" = "true" ]; then
echo "$tag is tagged and fully released; nothing to do."
echo "need_release=false" >> "$GITHUB_OUTPUT"
else
commit="$(git rev-parse --verify "$tag^{commit}")"
Expand Down Expand Up @@ -130,10 +147,10 @@ jobs:
[ -n "$rl_fails" ] || rl_fails=0
attempts=$((ar_fails + rl_fails))
if [ "$attempts" -ge 3 ]; then
echo "::error::$tag is tagged but has no GitHub Release after $attempts failed release runs — NOT retrying again. The tagged commit fails the release gate deterministically; see docs/how-to/cutting-a-release.md (Manual / recovery flow)."
echo "::error::$tag is tagged but has no complete GitHub Release after $attempts failed release runs — NOT retrying again. The tagged commit fails the release gate deterministically; see docs/how-to/cutting-a-release.md (Manual / recovery flow)."
echo "need_release=false" >> "$GITHUB_OUTPUT"
else
echo "$tag is tagged but has NO GitHub Release ($attempts prior failed attempt(s)); re-releasing from $commit."
echo "$tag is tagged but has no complete GitHub Release ($attempts prior failed attempt(s)); re-releasing from $commit."
echo "need_release=true" >> "$GITHUB_OUTPUT"
echo "release_ref=$commit" >> "$GITHUB_OUTPUT"
fi
Expand Down
26 changes: 19 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,17 @@ jobs:
run: go build ./...

# `go build ./...` compiles only the host target, but a release ships all
# four Makefile TARGETS — and the first `make build` on the release path
# runs only AFTER auto-release has pushed the (immutable) tag, so a
# target-specific break would make that version permanently unreleasable.
# Cross-compile every target here, pre-tag. Output is gitignored, so the
# gen-docs staleness check above is unaffected; one leg suffices because
# cross-compilation is host-independent.
# four Makefile TARGETS, so cross-compile every one of them here. This
# catches a target-specific break on PR and branch runs — before the
# commit that would promote a version reaches main. It does NOT gate the
# tag: auto-release runs on the same main push, concurrently with this
# job, and tags within seconds; nothing sequences the two. release.yml's
# own `verify` re-runs `make build` post-tag, so a break that slips
# through spends the version number — see the spent-version recovery in
# docs/how-to/cutting-a-release.md (Manual / recovery flow).
# Output is gitignored, so the gen-docs staleness check above is
# unaffected; one leg suffices because cross-compilation is
# host-independent.
- name: Cross-compile every release target (make build)
if: matrix.os == 'ubuntu-latest'
run: make build
Expand Down Expand Up @@ -236,4 +241,11 @@ jobs:
# ferry is public, so zizmor uploads its SARIF to Code Scanning, surfacing
# findings in the Security tab; the job also still gates on those findings.
# The upload needs security-events: write from the job permissions above.
advanced-security: true
# A pull_request run from a FORK carries a read-only token, so the upload
# would 403 and redden a job the contributor has no way to fix. Disable
# the upload — and only the upload — on fork PRs: zizmor still runs and
# still fails the job on its findings, so the gate is unchanged; the
# findings simply appear in the job log rather than the Security tab.
# The expression is true for every non-PR event (push, schedule) and for
# a PR whose head repo IS this repo (a branch PR from a collaborator).
advanced-security: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
30 changes: 28 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -415,14 +415,40 @@ jobs:
# contain a hyphen (`v1.2.3+2026-01-01`) and is not a prerelease. A plain
# vX.Y.Z tag leaves the array empty, so the command is byte-identical to
# a normal release's.
#
# IDEMPOTENT by branch, because three gates run AFTER this step
# (attestation verify, fresh-download checksum verify, prune) and any of
# them can go red on a transient blip. Recovery is "re-run failed jobs",
# which replays this step against a release that already exists — a bare
# `gh release create` answers that with a 422 and the run stays wedged
# red forever. So: if the Release is already there, re-upload the assets
# (--clobber, since the rebuilt binaries are byte-identical only if the
# build is reproducible) and re-assert its title, notes, draft state and
# prerelease flag, leaving the post-publish gates free to run again. The
# create arm keeps --verify-tag; the upload arm cannot use it, but that
# arm only ever touches a Release GitHub already bound to this tag.
run: |
set -euo pipefail
pre=()
case "${TAG%%+*}" in
*-*) pre=(--prerelease) ;;
esac
gh release create "$TAG" bin/ferry-* bin/checksums.txt \
--verify-tag --title "$TAG" --notes-file "$NOTES_FILE" "${pre[@]}"
if gh release view "$TAG" >/dev/null 2>&1; then
echo "release $TAG already exists — re-uploading assets and re-asserting its metadata (re-run of a failed post-publish step)."
gh release upload "$TAG" bin/ferry-* bin/checksums.txt --clobber
# `gh release edit` spells the prerelease flag as --prerelease=BOOL,
# so translate the create arm's presence/absence array into an
# explicit true/false — an existing release must never silently keep
# a stale flag.
prerelease=false
[ "${#pre[@]}" -eq 0 ] || prerelease=true
gh release edit "$TAG" \
--draft=false --title "$TAG" --notes-file "$NOTES_FILE" \
--prerelease="$prerelease"
else
gh release create "$TAG" bin/ferry-* bin/checksums.txt \
--verify-tag --title "$TAG" --notes-file "$NOTES_FILE" "${pre[@]}"
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Expand Down
3 changes: 3 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ paths = [
# A fake PEM fixture proving the pre-commit secret gate still scans a file's
# content after consuming a rename entry's origin path field.
'''cmd/sync_rename_scan_test\.go$''',
# Fake key/token fixtures proving the pre-commit secret gate never follows
# symlinks, skips gitlinks, and scans only what `git add -A` would stage.
'''cmd/sync_round9_test\.go$''',
# v0.7.0 config-plugin secret tests: credential-shaped fixtures that exercise
# the git/tmux/npm token recognisers and prove a literal token never reaches
# the shared repo. Fake tokens only; every non-test file stays fully scanned.
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ Without `FERRY_BIN` the eval suite skips every behavioural test and passes.
CI (`.github/workflows/ci.yml`) runs gofmt, build, the cross-compile of every
release target, vet, `go test ./...`, race tests on the internal packages, the
CLI-reference staleness check, the consistency lint, and the full eval suite
against real Linux and macOS binaries.
against real Linux and macOS binaries, plus a full-history secret scan
(gitleaks) and an audit of the workflows themselves (zizmor) — both blocking,
with no local equivalent.

`make gen-docs` regenerates the committed CLI reference under
`docs/reference/cli/` — run it after any command, flag, or help-text change,
Expand Down
105 changes: 105 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,111 @@ called out in a **Breaking** section. See

### Fixed

- **The pre-commit secret scan never reads through a repo symlink.**
`ferry sync`'s changed-file scan opened paths with a symlink-following
read, so an untracked or modified symlink in the config repo — for
example one pointing at `~/.ssh/id_ed25519` — was read in full,
breaching the `~/.ssh` boundary and false-blocking the sync. Changed
paths are now Lstat-gated and non-regular entries are skipped: git
commits a symlink as its link text, which the push-range blob scan
already covers.
- **`ferry sync` no longer wedges on directory-shaped status entries.**
A dirty submodule or an untracked symlink-to-directory arrives without
the trailing slash the scan used to spot directories, so the fail-closed
read aborted every sync with advice ("re-run once the file is readable")
a directory can never satisfy. Such entries are skipped — a gitlink's
content never enters the push range.
- **The untracked-directory secret scan matches what `git add -A`
stages.** The walk over a collapsed untracked directory read every file
beneath it, including gitignored files and nested repositories' `.git`
internals, so a token in a file git would never commit blocked the sync
with no way forward. The directory is now enumerated with
`git ls-files --others --exclude-standard`, scanning exactly the
stageable set; a failed enumeration still aborts.
- **A shared terminal capture behind a local overlay converges.**
Accepting a terminal preference domain to shared wrote the shared
plist, but the per-machine overlay from an earlier local capture kept
winning both the drift comparison and `apply`, so the domain was
re-offered forever and the shared bytes did nothing. A shared accept
now removes the superseded overlay and says so; a symlinked overlay is
left untouched.
- **A secret-routed terminal capture stops reporting permanent drift.**
Capture writes a `{{ferry.secret …}}` placeholder into the repo plist,
but `status` and `capture` compared the raw placeholder bytes against
the live export, so the domain read as drifted forever — and re-prompted
the secret gate on every capture — while `apply`, which renders
placeholders, considered it in sync. Both now render the repo side
through the secret store before comparing (falling back to the raw
compare when the store or ref is unavailable), and the placeholder file
round-trips byte-exactly.
- **Peer-dependency warnings no longer disable the npm-globals domain.**
`npm ls -g` writes diagnostics to stderr while still emitting the full
JSON tree on stdout; the dump parsed the two streams fused, so any
non-zero exit was a hard error despite the documented tolerance. The
listing is parsed from stdout alone, and a genuine failure carries
npm's own stderr in the error instead of a bare exit status.
- **`ferry diff` predicts the empty-over-substantial refusal.** The
data-loss guard that aborts `apply` when a near-empty repo file would
replace a substantial live file was invisible to the preview, which
showed a plain "would update". The plan renders such items as "would
refuse" and counts them in the summary, and when the guard does abort
an apply, a closing notice states that the changes reported earlier in
the run were rolled back.
- **Repos ferry creates gitignore the per-machine dependency overlay.**
`deps/Brewfile.<os>.local` is documented as belonging to one machine
only, but the generated `.gitignore` covered only `ferry.local.toml`
and `local/`, so `ferry sync` committed the overlay, every other
machine installed it via `apply --deps`, and `bundle export` carried
it. The ignore list covers the overlay on every init route, with the
`init --github` pre-create gate model rendered from the same pattern
set. An overlay committed before the rule existed stays tracked until
`git rm --cached` untracks it — documented, alongside the deliberate
`git add -f` escape, in the configuration reference.
- **A red release run after publishing is recoverable.** The publish
step errored on re-run once the Release existed, so a failed
post-publish check (asset verification, retention prune) left the run
permanently red — while re-running the whole auto-release run saw the
release as done and quietly skipped the bypassed checks. Publishing is
idempotent (a re-run re-uploads the assets and re-asserts the release
metadata), and auto-release treats a version as released only when its
Release is published and carries the full asset set. The release
how-to documents post-publish recovery and corrects the claim that a
failed check fails the release.
- **The by-hand checksum recipe stamps the version.** The documented
`make build VERSION=…` + `make checksums` pair silently rebuilt the
binaries without the version stamp before hashing (the `checksums`
target's build prerequisite is phony and always re-runs), so a
hand-published release shipped dev-stamped binaries under a
self-consistent manifest. The recipe is the single
`make checksums VERSION=vX.Y.Z` invocation, and the Makefile comment
states the rebuild behaviour.
- **`install.sh` reports a failed binary download.** The download ran
silenced under `set -e`, so a missing release asset ended the install
with no output after the "downloading…" line; it fails with a message
naming the asset.
- **CI's workflow audit passes on fork pull requests.** The SARIF
upload needs a writable token, which a fork PR never gets; the upload
is skipped there while the audit itself still runs and gates.
- **Guided apply is truthful about conflicts and skip-always targets.**
The walkthrough prompted "yes to apply" for conflict items it never
overwrites; the listing and details view now state that confirming
does not overwrite a conflict (`ferry capture` or `apply --force`
resolves it), and the commands reference lists the conflict as the
fourth risky class. A clean skip-always target no longer prints a
skip line on every run and is counted as in sync.
- **Docs corrected against the code.** The compatibility contract names
restore snapshots as the second version-independent store (the
refuse-a-newer-file rule does not gate `restore --undo`); the
configuration reference states the iTerm2 global allowlist is compiled
into ferry, lists all eight kept categories, and gives the committed-
plist route for carrying extra keys; the single-branch `main`
constraint on `ferry sync` is stated in the reference, the tutorial,
and the command help; "route-1/route-2" jargon is replaced with plain
language; ssh.md states the `~/.ssh` invariant universally including
the cargo-store guard; the scaffold help names exactly what it
creates; AGENTS.md's CI list names the gitleaks and zizmor gates; the
tutorial distinguishes local drift from a conflict.

- **A cargo store under `~/.ssh` is refused.** The `[work] store` path is
hand-configured, and every other configurable path ferry writes through — the
repo path, `bundle import --out` — is guarded against resolving into `~/.ssh`
Expand Down
Loading