docs+chore: private-copy workflow guide; stop ignoring run manifests - #20
Merged
Conversation
Customers who build fetchers against this framework need a private working copy, but forks of a public repo are permanently public and fork-network commits stay reachable by SHA from upstream forever. Fetcher code carries tenant identifiers and control scope, so a public fork publishes a map of the customer's compliance posture. Documents the private-mirror model instead: mirror the repo into a private repo, track upstream as a plain git remote (public needs no permission grant or fork relationship), and merge our release tags. Key details the guide pins down: - Upstream tags are namespaced into refs/tags/paramify/* on fetch. Tags are one flat namespace, and a colliding customer tag makes `git fetch --tags` print a rejection but exit 0 — so a merge takes their tag while everyone believes they took ours. - `git push --mirror` is marked Paramify-side and run-once, kept out of any runbook the customer holds: a transposed invocation both leaks private branches upstream and deletes upstream refs absent locally. - Mirror verification is a `git ls-remote` diff plus a default-branch check. An unset HEAD on the destination yields a clone with no branch checked out, and the first commit then starts an orphan history — reintroducing the unrelated-histories failure the mirror exists to prevent. - Root manifest.yaml is tracked despite being in .gitignore (ignore rules do not apply to tracked files), so customer edits stage by default and conflict on every sync. Both exits documented. - Published upstream history is append-only from the first mirror onward; any history rewrite permanently diverges every customer copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The guide was written from Paramify's side of the table — it described what "the customer" does, instructed us on what not to hand them, and put `git push --mirror` in front of a reader who was never its audience. Rewritten to address the engineer who actually runs these steps. Substantive changes, not just voice: - Leads with GitHub's repository importer instead of `git push --mirror`. The importer copies full history with no local commands and no way to point a destructive ref-deleting push at the wrong remote. The CLI path stays as a fallback for instances that can't reach external URLs, sequenced through a throwaway bare clone that is deleted before any second remote exists, with the reason spelled out rather than asserted. - Adds a "Questions your security review will ask" table. The privacy argument is the reason this workflow exists, so it belongs in a form a reader can take to their security team: fork visibility, fork-network recoverability, what access Paramify needs, how to revoke it, and where secrets live. - Reframes append-only upstream history as a commitment made *to* the reader, with its consequence for them stated, rather than a note to ourselves. - Renames to docs/private_mirror_workflow.md: every other doc here is snake_case, and "customer fork workflow" names neither the audience's perspective nor the thing being built, which is a mirror and not a fork. Content verified earlier this session stands unchanged: tag namespacing into refs/tags/paramify/*, the fetch-collision exit-0 behavior, the unset-HEAD orphan-history trap, and the tracked-manifest.yaml conflict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
21tmccauley
force-pushed
the
docs/customer-fork-workflow
branch
from
July 28, 2026 19:47
8a45b1b to
10a2bf0
Compare
…est.yaml
`.gitignore` listed /manifest.yaml and /manifests/ while a manifest.yaml was
also tracked. Ignore rules don't apply to already-tracked files, so the entry
was inert: edits to the shipped manifest staged by default, and anyone working
from a copy of this repo hit a conflict at that path on every upgrade.
The ignore was the wrong instinct anyway. A manifest records which evidence you
collect, so under a compliance program it belongs in version control — and it
holds no secret values, because `${env:VAR}` resolves from the environment at
run time, so a manifest names its secrets without containing them.
- Rename the tracked manifest.yaml to example_manifest.yaml (content unchanged;
13 Datadog entries, validates). It reads as the reference sample it always
was, rather than shadowing the path the builder writes to.
- Drop /manifest.yaml and /manifests/ from .gitignore, with a comment saying
why nothing there is ignored.
- Nothing now ships at ./manifest.yaml, so a manifest you create there cannot
collide with ours on merge — which is the point.
No code change. `paramify manifest init`'s ./manifest.yaml default and the
manifests/*.yaml picker convention are untouched; discovery already guarded on
existence, and read_manifest returns an empty manifest for an absent path, so a
fresh clone with no manifest degrades to "no fetchers" rather than an error.
233 tests pass; nothing referenced the tracked file.
Also updates docs/private_mirror_workflow.md, which documented the old conflict
and its two workarounds — both now moot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two related changes: a customer-facing guide for keeping a private copy of this repo, and the manifest-tracking fix that guide turned up.
1.
docs/private_mirror_workflow.mdA guide for teams whose fetcher work can't be public — written to be handed to their engineers directly. First reader is the Epicor CR-26 engagement.
A GitHub fork can't be a private working copy: forks inherit the upstream's visibility and GitHub disables the visibility control on them, and every commit in a fork network stays reachable by SHA from the public upstream forever — deleting the branch, the fork, or force-pushing does not revoke access. That matters here because fetcher code carries tenant and subscription IDs, resource group and endpoint names, and each control's population scope; with narratives alongside it, the boundary's exceptions and limitations too.
So the guide documents a private mirror instead: mirror this repo into a private repo, add ours as a plain read-only remote (public needs no access grant), and merge release tags. A fork becomes optional and, in the recommended path, unnecessary — their Paramify contact already has collaborator access to the private repo and can carry any general-purpose change upstream.
Points verified empirically against a scratch upstream/mirror pair rather than written from memory:
refs/tags/paramify/*on fetch. Tags are one flat namespace. Once a customer cuts their ownv0.4.0-betaand we publishv0.4.0-beta, a plaingit fetch upstream --tagsprints! [rejected] (would clobber existing tag)and exits 0 — the rejection scrolls past, the command looks successful, andgit merge v0.4.0-betatakes their commit while everyone believes they took ours.git ls-remoteref diff plus a default-branch check. Tags being present only proves tags copied. Separately, if the destination repo'sHEADis unset, cloning yields a working copy with no branch checked out and the first commit silently starts an orphan history — the samerefusing to merge unrelated historiesbreakage the mirror exists to prevent. The ref diff does not catch that, which is why both checks are there.git push --mirror.--mirrorforce-writes every ref at the destination and deletes destination refs absent locally, so in a repo with both remotes configured one transposed invocation both leaks private branches and destroys upstream history. The CLI path stays as a fallback, sequenced through a throwaway bare clone deleted before a second remote exists, withgit remote set-url --push upstream no_pushas a further guard.mainin the customer repo. A local pristinemainadvanced with--ff-onlyplus a second working branch was considered and rejected: redundant withupstream/main, which is already an unwritable tracking ref, and it adds a permanent failure mode where one stray commit breaks--ff-onlyforever withreset --hardas the only recovery.2. Run manifests are no longer gitignored
Writing that guide surfaced a real defect.
.gitignorelisted/manifest.yamland/manifests/while amanifest.yamlwas also tracked (it entered the index at c2f1c49). Ignore rules don't apply to already-tracked files, so the entry was inert: edits to the shipped manifest staged by default, and anyone working from a copy of this repo hit a conflict at that path on every upgrade.The ignore was the wrong instinct regardless. A manifest records which evidence you collect, so under a compliance program it belongs in version control — and it holds no secret values, because
${env:VAR}resolves from the environment at run time, so a manifest names its secrets without containing them.manifest.yamltoexample_manifest.yaml— content unchanged (13 Datadog entries;paramify validatereports valid). It now reads as the reference sample it always was instead of shadowing the path the builder writes to..gitignore, with a comment explaining why nothing there is ignored../manifest.yamlanymore, so a manifest you create there cannot collide with ours on merge.No code change.
paramify manifest init's./manifest.yamldefault and themanifests/*.yamlpicker convention are untouched.list_manifestsalready guarded on existence andread_manifestreturns an empty manifest for an absent path, so a fresh clone with no manifest degrades to "no fetchers" rather than an error. 233 tests pass; nothing referenced the tracked file.Upgrade note is in
CHANGELOG.mdunder Unreleased: anyone relying on the trackedmanifest.yamlcopiesexample_manifest.yamlover it or passes--manifestexplicitly.