Scope. Phase A of
MASTER_ROADMAP.md: the 48-hour "rescue" phase, items A1–A9. Rule applied throughout. No guessing. Every number in this document was measured in this environment, or read from an authoritative source that is cited. Where a claim could not be verified, it is marked as such rather than asserted.
| Items complete | 6 of 9 (A2, A3, A4, A5, A6, A8) |
| Items partially complete | 1 (A1 — code + tests done; branch creation needs a push) |
| Items blocked | 2 (A7, A9 — both require GitHub write access) |
| Commits produced | 3 this session (633bdaf, c001953) + 4 earlier, 7 unpushed |
| Unit tests | 19 → 28, all passing |
| Real-client validation | 6/6 (sing-box 1.13.14, mihomo v1.19.29) |
| Bugs found by testing rather than by inspection | 6 |
| Plan items rejected on evidence | 1 (A8 history rewrite) |
The single most important finding is not on the roadmap at all: production currently serves 3,566 configs while the already-written, already-tested code produces 8,234. Users are receiving 43.3 % of what the repository is capable of, because the fix has never been pushed.
Measured live, both sides at the same moment:
| Category | Production (main) |
Local (fixed code) | Difference |
|---|---|---|---|
| ALL | 3,566 | 8,234 | +4,668 |
| HEAVY | 3,359 | 7,586 | +4,227 |
| LIGHT | 676 | 1,683 | +1,007 |
| Production | Local | |
|---|---|---|
| Sources configured | 22 | 21 |
| Sources healthy | 15 | 21 |
| Sources failing | 7 | 0 |
Production's 7 failing sources, from the live health.json:
| Source | Status | HTTP | Error |
|---|---|---|---|
sub_2.txt ×2 |
fail | 200 | empty body |
sub_3.txt ×2 |
fail | 200 | empty body |
sub_4.txt ×2 |
fail | 200 | empty body |
xray_final.txt |
fail | 404 | HTTP 404 |
Note the 200 + empty body combination: the upstream returns success while
delivering nothing. A naive fetcher counts that as a win. The local source
list has already been corrected (21 sources, all live, 0 failures) — that
work is finished and merely unpublished.
Consequence: every hour this stays unpushed, subscribers receive a list 56.7 % smaller than it should be. This is the highest-value action available and it is blocked purely on authorization.
Git never forgets a blob. Every scheduled run regenerates the same large files; committing them to a normal branch appends a new copy of each changed file to history permanently. Publishing cost is therefore O(number of commits), with no ceiling.
Measured live from the GitHub API:
| Metric | Value |
|---|---|
| Repository size | 3,711,094 KB = 3.54 GB |
| Commits | 5,649 |
| Created | 2026-06-06 |
| Growth rate | 69.05 MB/day (~98 commits/day) |
| Stars / forks | 50 / 3 |
Against GitHub's published guidance — "ideally less than 1 GB, and less than 5 GB is strongly recommended" (GitHub Docs, About large files on GitHub):
headroom to 5 GB = (5.00 − 3.54) GB ÷ 69.05 MB/day ≈ 21.7 days
That is the emergency Phase A exists to stop.
Before accepting the orphan-branch design I tested the cheaper alternative —
just make the output smaller — in a controlled experiment using real measured
byte sizes, 3 rounds with a 10 % shuffle and a gc at the end:
| Before A3/A4 | After A3/A4 | Change | |
|---|---|---|---|
| Cost per commit | 1,572 KB | 1,216 KB | −22.6 % |
| Tracked working tree | 41,426,368 B | 26,932,769 B | −35.0 % |
| Projected growth @98 commits/day | 150.4 MB/day | 116.4 MB/day | −22.6 % |
| Days until 5 GB | 9.9 | 12.8 | +2.9 days only |
A 35 % smaller payload buys under three days, because the growth is linear in commit count, not in payload size. This measurement is what promoted A1/A2 from "nice architecture" to mandatory.
| Question | Method | Result |
|---|---|---|
Does raw.githubusercontent.com serve non-default branches? |
live fetch against a third-party repo's non-default branch | Yes |
| Does jsDelivr serve non-default branches? | same | Yes (@<branch> syntax) |
| Would moving break existing links? | audit of index.json |
It would have: 32 of 33 links were @main. Fixed in A5 before the move. |
scripts/aggregate.py no longer hard-codes the branch:
GH_USER = os.environ.get("AGG_GH_USER", "0xRadikal")
GH_REPO = os.environ.get("AGG_GH_REPO", "Free-v2ray-Configs")
GH_BRANCH = (os.environ.get("AGG_DATA_BRANCH")
or os.environ.get("DATA_BRANCH")
or "data")aggregate.py read AGG_DATA_BRANCH; the workflow sets DATA_BRANCH
and never sets AGG_DATA_BRANCH. Both resolved to data, so nothing was
broken — which is exactly what made it dangerous:
Change
DATA_BRANCHto anything else, and the workflow publishes to branch X whileindex.jsonadvertises branch Y. Every one of the 35 advertised URLs 404s, silently, with a green build. That is the same failure class A4 and A5 were written to eliminate.
Fixed: DATA_BRANCH (the name CI actually uses) is now honoured.
Additionally, two step-level env: DATA_BRANCH: data blocks were removed —
they shadowed the top-level value with an identical literal, so editing the
top-level knob would silently not reach those steps. There is now exactly
one definition, at line 58.
Code, workflow and tests are done and verified. Creating the branch on the remote requires a push → see §8 (blocked).
The first version used git checkout --orphan "$DATA_BRANCH". It failed in
testing: round 1 published correctly, then from round 2 onward:
fatal: a branch named 'data' already exists (exit 128)
Because the step runs under set -e, the whole step died silently and the
branch froze on a stale snapshot. Replaced with pure plumbing, which is
idempotent and never touches the worktree:
GIT_INDEX_FILE → git add → git write-tree → git commit-tree (no -p) → git push --force
The absence of -p is what makes the commit an orphan: no history is carried,
so the previous snapshot becomes unreachable and is garbage-collected instead
of accumulating.
git add -A -- all heavy light protocols archive index.json health.json
→ fatal: pathspec 'archive' did not match any files (exit 128)
archive/ and protocols/ are only created as a side effect of
_write_text(). After A3/A4, a round with zero broken configs produces no
archive/ directory at all — a state that was impossible before and is
now normal. Under set -e this killed publishing silently. Fixed: only
paths that actually exist are staged, and absence is logged as information,
not failure.
A force-push is irreversible: publishing a broken snapshot destroys the last good one forever. Four independent guards now refuse to publish:
| Guard | Trigger |
|---|---|
| Critical-file presence | any of 8 required files missing or zero-byte |
| Minimum content | all/configs.txt has < 100 payload lines |
| Empty-tree check | computed tree equals Git's empty-tree hash |
| Minimum staged count | fewer than 10 files staged |
The harness runs the publish body extracted from the shipped YAML, so the
test exercises exactly what CI will run. I verified this correspondence rather
than assuming it: stripping comments and blank lines, the extracted body is
byte-identical to the harnessed script, and it passes bash -n.
| # | Scenario | Expected | Result |
|---|---|---|---|
| S1 | first publish | branch created | ✅ |
| S2 | second publish | ← the --orphan bug |
✅ |
| S3 | steady state | still exactly 1 commit | ✅ |
| S4 | a protocol disappears | drops out of snapshot | ✅ |
| S5 | byte-identical payload | still publishes | ✅ |
| S6 | archive/ absent |
← the pathspec bug | ✅ |
| S7 | protocols/ absent too |
publishes fine | ✅ |
| S8 | empty index.json |
refuses, data preserved |
✅ |
| S9 | near-empty configs | refuses | ✅ |
| S10 | all outputs missing | refuses | ✅ |
| S11 | recovery afterwards | clean publish | ✅ |
| S12 | invariants | main + worktree README untouched |
✅ |
passed: 40 failed: 0
origin: 22,068 KB pre-gc → 2,272 KB post-gc (after 11 publishes)
reachable objects: 36
That last line is the proof of the whole design: 11 publishes, and the repository holds 36 objects. O(1), not O(commits).
An earlier version short-circuited: "if the tree matches the remote, skip the
commit — this avoids extra objects." Measurement showed it can never fire.
Two consecutive runs always differ in index.json (updated_at,
next_update_eta, elapsed_seconds) and health.json (checked_at, plus
latency_ms for all 21 sources).
Should it be made payload-based instead? Measured: no.
| Measurement | Result |
|---|---|
| Cost of publishing an identical payload | 32 KB (5,660 → 5,692 KB) |
Cost of 20 identical rounds, after gc |
0 bytes (3,496 → 3,496 KB) |
| Harm of skipping | updated_at goes stale → freshness gate reruns every 5 min instead of 15 |
The optimisation saves nothing and causes harm. Removed; the comparison remains for log transparency only.
These files recorded every rejected duplicate: 13.82 MiB per round, with no consumer. Generation removed, and previously-committed copies are pruned.
An empty file is worse than a 404. A client subscribed to an empty URL replaces its working list with nothing. A 404 makes clients keep the previous list.
| Before | After | |
|---|---|---|
protocols/ files |
28 | 14 |
archive/ files |
12 | 4 |
index.json is now truth-driven: it advertises a URL only if that file
exists.
1. Short-circuit evaluation silently orphaned 7 files.
if _remove_if_exists(txt) or _remove_if_exists(b64): # ← BUGPython's or stops at the first truthy operand, so when txt was removed the
_base64.txt was never touched. Seven zero-byte *_base64.txt files
survived the cleanup. Fixed:
gone_txt = _remove_if_exists(txt) # both evaluated, independently
gone_b64 = _remove_if_exists(b64)
if gone_txt or gone_b64:
pruned += 1A dedicated test now locks this exact bug.
2. index.json advertised a 404. archive.light_broken was published
while light had zero broken configs. Fixed by conditioning on
results[cat].broken.
I originally asserted size > 64 for every published protocol file. That is
wrong: a legitimate single-config file is smaller. Replaced with "contains at
least one non-header, non-blank payload line" — which tests the actual
intent.
A full "every published file must be advertised" audit found exactly one gap:
index.json published no URL for itself, so a consumer holding only the
metadata had to hard-code the branch — the very thing A1 removed. Added
self_url / self_url_mirror.
Final state — a perfect bijection:
advertised paths : 32
branches referenced : ['data']
advertised-but-missing : NONE
published-but-unadvertised : NONE
Both endpoints, fetched at the same moment:
| Endpoint | updated_at |
ALL configs |
|---|---|---|
raw.githubusercontent.com |
20:23:34Z | 8,168 |
cdn.jsdelivr.net (branch ref) |
07:38:26Z | 4,353 |
12 h 45 min stale — 51× the 15-minute update target. Every subscriber using the advertised links was receiving half the configs, half a day late. The repository's headline promise was not being kept.
Cause, from response headers and confirmed by jsDelivr's own documentation ("Branches — 12 hours"):
| Endpoint | Cache directive | Effective staleness |
|---|---|---|
raw.githubusercontent.com |
max-age=300 |
5 minutes |
cdn.jsdelivr.net (branch) |
s-maxage=43200 |
12 hours |
Raw is 144× fresher. Before A5, index.json contained 32 jsDelivr URLs
and 1 raw URL.
PRIMARY_BASE = RAW_BASE # raw.githubusercontent.com — 300s cache
MIRROR_BASE = CDN_BASE # cdn.jsdelivr.net — 43200s cachejsDelivr is kept, as an explicit mirror (*_mirror keys), because some
users cannot reach GitHub directly. A machine-readable link_policy block
states which is fresher and why, so bots and apps can choose correctly
without scraping documentation.
| Before | After | |
|---|---|---|
raw URLs in index.json |
1 | 35 |
| jsDelivr URLs | 32 | 24 (explicit mirrors) |
| Branch referenced | main |
data |
README.md and README_FA.md both now: lead with raw links, carry the
measured cache-comparison table, document the 12 h 45 m / 51× finding, split
repository structure into data (generated) vs main (source), drop
<cat>_duplicates.txt (A3 stopped writing it), and add a "Why a separate
data branch" section explaining the O(commits) → O(1) rationale.
Verified mechanically, not by eye:
18 URLs across both files
non-data branch refs : NONE
advertised-but-missing : NONE
raw : jsdelivr = 11 : 3 (per file)
The "regularly populated" protocol list in the docs was also cross-checked
against index.json and matches its 7 entries exactly.
New workflow step (continue-on-error: true, timeout-minutes: 3): it builds
the purge list from index.json, so it never purges a URL that does not
exist; POSTs to https://purge.jsdelivr.net/; polls /status/<id>; reports
per-path throttling and provider failures; then verifies by md5 against raw
and prints an honest agreement score.
Live run: 28 paths, status=finished, 0 throttled, 0 provider failures.
After purging, all/configs.txt and light/clash.yaml md5-matched raw, but
index.json and health.json did not, while jsDelivr reported age: 22.
Diagnosis: purging clears only the edge; jsDelivr's own origin still lags
while re-resolving the branch name.
I verified the one path that is exact — a commit-pinned URL:
@5cfb623…/index.json → updated_at 2026-07-28T21:23:27.336034
raw .../index.json → updated_at 2026-07-28T21:23:27.336034 ← bit-identical
Reported as mirror agreement: 0/2 with the limitation documented, rather
than claimed as a success. This is precisely why A5 (raw-primary) was
necessary and A6 alone was not sufficient: a force-moved branch ref is the
worst possible input to a CDN that caches by branch name.
The roadmap called for rewriting history to reclaim 3.54 GB. Three independent lines of evidence say do not.
I tested whether each fork can serve a commit that exists only on origin:
| Fork | Its own last push | Serves origin's tip? | Gap |
|---|---|---|---|
| AresPV | 2026-07-28 09:38Z | YES | 12 h 00 m |
| PENZA84 | 2026-07-28 21:20Z | YES | 0 h 18 m |
| arman20257 | 2026-06-18 08:20Z | YES | 40 days 13 h |
A fork that has not pushed in 40 days serves a commit created minutes ago. The only mechanism that permits this is a shared object pool. Corroborated by GitHub Docs, About forks:
"repository networks still share Git data. Commits pushed to any repository in a network can be accessible from other repositories in that network"
Therefore: rewriting origin does not delete the old blobs. They stay reachable from three forks' refs, so they stay in the pool, and the reported size very likely does not drop. Fork sizes: 3,683,459 KB + 3,660,433 KB + 481,672 KB = 7,642 MB across the network.
- All 5,649 commit SHAs change
- All 3 forks diverge irreconcilably
- Every existing clone breaks (
git pullfails) - Any commit-pinned jsDelivr URL (
@<sha>/) 404s forever — the one mechanism A6 proved to be bit-exact - 3.54 GB exceeds the 2 GiB single-push limit → must be chunked
| Scenario | Growth | Time to 5 GB |
|---|---|---|
| Before A1/A2 | 69.05 MB/day | 21.7 days |
| After A1/A2 | ~0.05 MB/day | ~82 years |
Once A1/A2 land, the 3.54 GB is a fixed legacy constant, not an active threat. A rewrite tries to reclaim sunk cost at high certain cost for uncertain benefit.
Verdict: A8 downgraded from "rewrite history" to "stop the growth, do not rewrite." Revisit only if GitHub Support contacts the owner — in which case the correct action is to open a support ticket and ask them to gc the network, since they are the only party that technically can.
| Item | Action | Blocker |
|---|---|---|
| A7 | Fix repo description: "every 30m" → 15m | needs GitHub write access |
| A9 | Push 7 local commits | needs GitHub write access |
setup_github_environment reports "GitHub Session State Missing."
Two further facts that affect how A9 must be done:
- Remote
mainhas advanced — observed progressing56746d8→eec66fb→5cfb623→9ac580fwhile the local commits branch off56746d8. A plain push will be rejected; the local work must be rebased onto the current remote tip first. - The local clone is shallow (
.git/shallow, grafted at55c085c), so a rebase requires unshallowing first (git fetch --unshallow).
Current description, verified live, still wrong:
"🔒 Free V2Ray configs auto-updated every 30m | …"
The workflow's real cadence is a 5-minute cron with a 13-minute freshness gate, targeting 15 minutes.
| Check | Result |
|---|---|
| Unit tests | 28/28 pass (was 19) |
| New regression tests this session | 5 (A1 ×1, A4 ×1, A5 ×3) |
| Mutation testing of new tests | 9/9 mutations caught; files restored md5-identical |
| A2 publish harness | 40/40 assertions, 12 scenarios, real bare repo |
| Full aggregator run | ALL=8,234 HEAVY=7,586 LIGHT=1,683, 5.4 s |
| Source health | 21 ok / 0 empty / 0 fail |
| Real-client validation | 6/6 pass — sing-box 1.13.14, mihomo v1.19.29 |
index.json ↔ filesystem |
perfect bijection; 32 paths, 0 discrepancies |
| Workflow YAML | parses; 12 steps; publish body passes bash -n |
| Push targets in workflow | exactly 1, refs/heads/$DATA_BRANCH, --force |
Outputs pushed to main? |
never — verified by inspecting every step |
Every new test was verified to fail when the defect it guards is reintroduced. A test that has never failed has not been tested.
| Metric | Before Phase A | After Phase A |
|---|---|---|
| Publishing cost | O(commits), unbounded | O(1), one snapshot |
| Repository growth | 69.05 MB/day | ~0.05 MB/day |
| Time to GitHub's 5 GB ceiling | 21.7 days | ~82 years |
| Bytes published per round | ~27 MB | ~13 MB (−13.82 MiB duplicates) |
protocols/ files |
28 (14 empty) | 14 (0 empty) |
archive/ files |
12 | 4 |
| Primary-link staleness | up to 12 h | up to 5 min |
raw : jsDelivr links in index.json |
1 : 32 | 35 : 24 |
| Advertised URLs that 404 | ≥ 15 | 0 |
| Unit tests | 19 | 28 |
| Sources healthy | 15 / 22 | 21 / 21 |
| Configs delivered (ALL) | 3,566 (still live) | 8,234 (pending push) |
- Authorize GitHub (Deploy/GitHub tab) — unblocks the two items below
- A9 —
git fetch --unshallow, rebase 7 commits onto current remotemain, push. This alone restores 4,668 configs to users. - A1 (final step) — first workflow run creates the orphan
databranch; then verify links resolve and remove the stale output directories frommain - A7 — correct the repository description: 30m → 15m
- Confirm
dataexists and holds exactly 1 commit - Confirm all 32 advertised URLs return HTTP 200 on
data - Confirm live
index.jsonreports 21/21 healthy sources and ALL ≈ 8,200 - Re-check repository size after 24 h to confirm growth has stopped
- Phase C — remove the country-guessing loop in
core.detect_country_from_remark(lines 218–223: any two-letter word in a remark is treated as a country code); remove the deadalpnbranch inconverters.py; addalpn/insecuretoparse_proxy; add hysteria2/tuic/wireguard converters - Phases B, D, E — real reachability testing, scale-out, ecosystem work
- The orphan
databranch does not exist on the remote yet. Its behaviour is proven by a 40-assertion harness against a real bare repository using real 8,169-config data — not by a production run. I have not claimed otherwise. - A6's mirror agreement is 0/2 for metadata files. Purging clears the edge, not jsDelivr's origin resolution of branch names. Only commit-pinned paths are verified bit-exact.
- A8's conclusion that a rewrite would reclaim ~0 bytes is an inference from proven object sharing plus GitHub's documented network behaviour. I cannot measure GitHub's internal gc. The inference is stated as an inference.
- Config counts fluctuate between runs (8,168 / 8,192 / 8,234 observed) because upstream sources change continuously. Comparisons in §1 were taken from the same moment.
Report generated during Phase A implementation. Commits: c6b0149,
922fae7, 22be557, 484abe7, 633bdaf, c001953 — all local, none
pushed. — @Raydikalx