igneum/docs/plans/rotation-phase-2.md

23 KiB

Rotation phase 2: the 0.3.6 handover, the deletion of the old folder and key, the fresh repository (5 October 2026)

Internal. Phase 1 (4 October, 19:25 UTC) generated the NEXT intake key and the NEXT downloads token into ~/.config/igneum/log-intake-key.next and ~/.config/igneum/dl-token.next, taught site/api/log.mjs to accept LOG_INTAKE_KEY_NEXT next to LOG_INTAKE_KEY, and staged a second downloads folder dl/<new token>/ with the installers of the day. Phase 2 is this file: the 0.3.6 build carries the new values, every installed 0.3.5 is carried across while the old folder still serves, then the old folder and the old key die, and only then the history is rewritten into a fresh repository (owner's decision, 5 October 2026; docs/plans/history-rewrite.md, option B).

No value is written here. Each is named by its fingerprint, the first 8 hex of sha256 over the trimmed value (tr -d '[:space:]' < ~/.config/igneum/<file> | shasum -a 256 | cut -c1-8; the app logs the same 8 characters):

Value File Fingerprint Where it lives today
old intake key ~/.config/igneum/log-intake-key e2005de8 every installed app's igneum-app.json (0.3.0 to 0.3.5); the site project's LOG_INTAKE_KEY; 6 tracked files until this branch, 8 commits of the history
new intake key ~/.config/igneum/log-intake-key.next 477bb0ef nowhere yet (the site accepts it once LOG_INTAKE_KEY_NEXT is set)
old downloads token ~/.config/igneum/dl-token df66a82c every installed app's manifest URL; dl/<old>/ holds every version 0.1.0 to 0.3.5, the jobs file, the CI inputs; the DL_TOKEN repository secret; 1 commit of the history (docs/plans/morning-2026-10-04.md, masked on this branch)
new downloads token ~/.config/igneum/dl-token.next ed9c4d2e dl/<new>/ with the 0.3.3 installers and the WSL2 zip only, no manifest, no jobs file, no CI inputs

1. What this branch changes (rotation-2)

File Change
packaging/mac/packaged-config.sh no key literal any more. IGNEUM_INTAKE_KEY_FILE and IGNEUM_DL_TOKEN_FILE name the files; each defaults to the .next file when it exists, else the plain file. Prints file names, lengths and fingerprints, never values. --test runs its 23 checks on temporary files
packaging/windows/make-payload.sh sources packaged-config.sh and calls write_packaged_config (it used to sed the key out of that file)
.github/workflows/windows.yml a "packaged configuration" step writes the repository secrets DL_TOKEN, DL_TOKEN_NEXT, LOG_INTAKE_KEY, LOG_INTAKE_KEY_NEXT to the same files under ~/.config/igneum on the runner, so make-payload.sh picks them exactly as on the Mac; LOG_INTAKE_KEY or LOG_INTAKE_KEY_NEXT is now required (the key no longer comes from the tree)
app/igneum-app/src/config.rs Packaged::with_env_overrides() honours the same two variables on a running engine (a developer run, or a package built with the old values); describe() is the new header line config: intake <url> key <fp> (<source>); manifest <url with the token masked> folder <fp> (<source>); fingerprint8, manifest_url_for_token, token_of_manifest_url, read_secret_file; 6 new unit tests
app/igneum-app/src/main.rs, engine.rs the overrides applied at load; the config: line logged right after the IGNEUM-APP header at every engine start (so every upload carries it)
tools/ship-app.mjs --dl-both: a mirror step copies the version's files and the folder-level files into dl/<dl-token.next>/, the manifest step publishes a second manifest there (--dest, --base-url, carrying the first manifest's override, tuning and min_supported_version, compared field by field), one deploy, verify checks both folders; self-test covers the three helpers
tools/logs.mjs --rotation: every app machine's version and header fingerprints against the .next files, exit 1 while any machine is behind; --self-test
infra/gpu-bench/upload.sh, proving/windows-wsl2/prove-block.sh, prove-shard.sh, proto-cuda/windows-app/upload-log.bat, proto-cuda/windows-miner/upload-log.bat the key literal removed: environment (IGNEUM_LOG_KEY or IGNEUM_INTAKE_KEY, which the app's job runner already sets), IGNEUM_INTAKE_KEY_FILE, or igneum-log-key.txt next to the .bat; the tree carries neither value now (git grep of both reads 0 files)
tools/repo/fresh-repo.sh the history rewrite of docs/plans/history-rewrite.md section 2 as one script with the verification greps and the printed push commands (section 6 below)
docs/plans/history-rewrite.md brought over from testnet-prep unchanged, so this branch carries the plan it executes

2. The handover, as designed

An installed app reads igneum-app.json next to its engine (macOS Contents/Resources, Windows the install folder): the manifest URL and the intake key. The OTA path replaces the whole bundle or runs the whole installer, and both carry a new igneum-app.json, so the values travel with the version. Nothing is cached in the app data folder.

Step 0.3.5 on a machine (old folder, old key) 0.3.6 (new folder, new key)
hourly check fetches dl/<old>/igneum-app-latest.json: 0.3.6 is there (published in BOTH folders), signed by the same key fetches dl/<new>/igneum-app-latest.json: itself
download the URL inside the old folder's manifest, dl/<old>/Igneum-Miner-0.3.6.dmg or -Setup-0.3.6.exe (byte-identical to the new folder's copy) nothing
apply the new bundle or installer brings igneum-app.json with the NEW manifest URL and the NEW key
after restart reports to the intake with the new key (LOG_INTAKE_KEY_NEXT accepts it); its header reads key 477bb0ef and folder ed9c4d2e; next check hits the NEW folder the same
jobs igneum-jobs.json is read next to the manifest, so the mirror step copies the jobs file and its signature into the new folder; any job published while both folders live goes to both (section 3d)

The window: every 0.3.5 machine must apply 0.3.6 before the old folder goes. Machines apply in their own minute of the hour and only when the node is synced, so the window is hours, not minutes. The old folder and the old key stay until node tools/logs.mjs --rotation reads 0 behind (section 4). Nothing is deleted on a schedule.

3. The publish, exactly

Everything below runs from the main checkout after this branch is merged to master. DLSITE is the downloads folder (~/.config/igneum/dlsite-dir), OLD and NEW the two tokens read from their files; neither is ever typed.

3a. Before the cut (by hand, once)

# the site must accept both keys (names only are listed; the value is piped from the file)
cd site && npx --yes vercel@latest --global-config ~/.config/igneum/vercel link --scope igneum --project igneum --yes
npx --yes vercel@latest --global-config ~/.config/igneum/vercel env ls --scope igneum        # LOG_INTAKE_KEY must be there; is LOG_INTAKE_KEY_NEXT?
tr -d '[:space:]' < ~/.config/igneum/log-intake-key.next | npx --yes vercel@latest --global-config ~/.config/igneum/vercel env add LOG_INTAKE_KEY_NEXT production --scope igneum
cd .. && git push origin master      # or any production deploy: the function reads the variable at the next deploy
# confirm: a POST with the NEXT key is accepted (200 with an id), the old one still is too
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "x-igneum-key: $(tr -d '[:space:]' < ~/.config/igneum/log-intake-key.next)" -H 'Content-Type: application/json' -d '{"label":"rotation-check","machine":"mac","run_id":"rotation-check","lines":"next key accepted"}' https://igneum-six.vercel.app/api/log

# the Windows build on GitHub needs the same files (the runner writes the secrets to ~/.config/igneum; windows.yml)
gh auth switch --user igneum-labs
tr -d '[:space:]' < ~/.config/igneum/log-intake-key      | gh secret set LOG_INTAKE_KEY      --repo igneum-network/igneum
tr -d '[:space:]' < ~/.config/igneum/log-intake-key.next | gh secret set LOG_INTAKE_KEY_NEXT --repo igneum-network/igneum
tr -d '[:space:]' < ~/.config/igneum/dl-token.next       | gh secret set DL_TOKEN_NEXT       --repo igneum-network/igneum
gh secret list --repo igneum-network/igneum              # DL_TOKEN, DL_TOKEN_NEXT, LOG_INTAKE_KEY, LOG_INTAKE_KEY_NEXT

# the new folder exists and is empty of a manifest (the mirror step fills it)
DLSITE="$(tr -d '[:space:]' < ~/.config/igneum/dlsite-dir)"; NEW="$(tr -d '[:space:]' < ~/.config/igneum/dl-token.next)"
ls "$DLSITE/dl/$NEW"
packaging/mac/packaged-config.sh --test                  # 23 checks

3b. The cut: one command, both folders

node tools/ship-app.mjs 0.3.6 --node <fork worktree> --notes "<one line>" --dl-both --dry-run   # the plan, nothing written
node tools/ship-app.mjs 0.3.6 --node <fork worktree> --notes "<one line>" --dl-both

With --dl-both the steps are: preflight (also: dl-token.next present and different, the folder exists) > bump > inputs > commit and push (the Windows build starts; its payload step runs packaged-config.sh --test and packages the .next values because the _NEXT secrets are set) > ci > fetch (installer and zip into the OLD folder) > dmg (build-dmg.sh packages igneum-app.json from the .next files by default) > copy (DMG into the OLD folder) > mirror (DMG, installer, zip, igneum-windows-ci.json, igneum-jobs.json and .sig, payload-inputs.{zip,json,sha256}, igneum-prove-wsl2.zip into the NEW folder, sha256-checked) > manifest (section 3c, both) > deploy (one) > verify (both folders: HEAD and GET of every file, both manifests byte-identical to the local ones and verifying, every platform URL inside its own folder) > console.

The build itself prints which files it packaged, for example intake key: ~/.config/igneum/log-intake-key.next (31 chars, fingerprint 477bb0ef) and manifest: ~/.config/igneum/dl-token.next (10 chars, fingerprint ed9c4d2e) -> https://dl.igneum.network/dl/<token>/igneum-app-latest.json. A build that says e2005de8 or df66a82c packaged the old values: stop, the .next files were not found.

3c. The same by hand with packaging/ota/publish-manifest.sh (what the manifest step runs)

publish-manifest.sh writes the OLD folder by default (it reads ~/.config/igneum/dl-token); --dest <folder> and --base-url <url> aim it at the NEW folder. With --dest it carries consensus.override, tuning and min_supported_version over from the manifest already in THAT folder, which is none, so the second call must pass what the first manifest carries. --deploy is refused with --dest; one deploy of the whole folder follows. Run by hand, publish-manifest.sh prints the manifest it wrote, URLs included, so the terminal shows the token path (it always has); ship-app.mjs scrubs both tokens from every line, which is the reason to prefer 3b.

DLSITE="$(tr -d '[:space:]' < ~/.config/igneum/dlsite-dir)"
OLD="$(tr -d '[:space:]' < ~/.config/igneum/dl-token)"; NEW="$(tr -d '[:space:]' < ~/.config/igneum/dl-token.next)"
V=0.3.6; NOTES="<one line>"

# 1. the OLD folder (default dest and base): the files are there from fetch and copy
packaging/ota/publish-manifest.sh --version $V --notes "$NOTES" --no-deploy \
  --mac "$DLSITE/dl/$OLD/Igneum-Miner-$V.dmg" --win "$DLSITE/dl/$OLD/Igneum-Miner-Setup-$V.exe"

# 2. the NEW folder: the same bytes copied in, the same override, tuning and min_supported read from the first manifest
cp "$DLSITE/dl/$OLD/Igneum-Miner-$V.dmg" "$DLSITE/dl/$OLD/Igneum-Miner-Setup-$V.exe" "$DLSITE/dl/$OLD/igneum-windows-app.zip" \
   "$DLSITE/dl/$OLD/igneum-windows-ci.json" "$DLSITE/dl/$OLD/igneum-jobs.json" "$DLSITE/dl/$OLD/igneum-jobs.json.sig" \
   "$DLSITE/dl/$OLD/payload-inputs.zip" "$DLSITE/dl/$OLD/payload-inputs.json" "$DLSITE/dl/$OLD/payload-inputs.sha256" \
   "$DLSITE/dl/$OLD/igneum-prove-wsl2.zip" "$DLSITE/dl/$NEW/"
M="$DLSITE/dl/$OLD/igneum-app-latest.json"
OVERRIDE="$(python3 -c 'import json,sys; o=json.load(open(sys.argv[1])).get("consensus",{}).get("override"); print(json.dumps(o,sort_keys=True,separators=(",",":")) if o else "")' "$M")"
MINSUP="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("min_supported_version",""))' "$M")"
python3 -c 'import json,sys; t=json.load(open(sys.argv[1])).get("tuning"); open(sys.argv[2],"w").write(json.dumps(t)) if t else None' "$M" /tmp/tuning-$V.json
packaging/ota/publish-manifest.sh --version $V --notes "$NOTES" --no-deploy \
  --dest "$DLSITE/dl/$NEW" --base-url "https://dl.igneum.network/dl/$NEW" \
  --mac "$DLSITE/dl/$NEW/Igneum-Miner-$V.dmg" --win "$DLSITE/dl/$NEW/Igneum-Miner-Setup-$V.exe" \
  ${OVERRIDE:+--override "$OVERRIDE"} ${MINSUP:+--min-supported "$MINSUP"} $([ -s /tmp/tuning-$V.json ] && echo --tuning /tmp/tuning-$V.json || echo --no-tuning)
rm -f /tmp/tuning-$V.json
# the two manifests must differ only in published_at and the folder inside the URLs
diff <(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); m.pop("published_at"); print(json.dumps(m,sort_keys=True,indent=1).replace(sys.argv[2],"T"))' "$M" "$OLD") \
     <(python3 -c 'import json,sys; m=json.load(open(sys.argv[1])); m.pop("published_at"); print(json.dumps(m,sort_keys=True,indent=1).replace(sys.argv[2],"T"))' "$DLSITE/dl/$NEW/igneum-app-latest.json" "$NEW") && echo "same fields"

# 3. one deploy, then both live checks (each: reachable, byte-identical to the local file, signature verifies)
(cd "$DLSITE" && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes)
packaging/ota/publish-manifest.sh --verify-only
packaging/ota/publish-manifest.sh --verify-only --dest "$DLSITE/dl/$NEW" --base-url "https://dl.igneum.network/dl/$NEW"

3d. Jobs while both folders live

packaging/ota/publish-jobs.sh writes dl/<old>/igneum-jobs.json by default and takes the same --dest and --base-url. Until the old folder is deleted, every add or expire is published twice, the second time with --dest "$DLSITE/dl/$NEW" --base-url "https://dl.igneum.network/dl/$NEW", then one deploy. A job whose zip_url names the old folder keeps working until that folder goes; publish new jobs with URLs in the new folder.

4. Verification: every machine's header shows the new intake path

The engine logs two header lines at every start, and the restart after an OTA apply logs them again, so the latest upload of every machine carries them:

IGNEUM-APP version=0.3.6 machine=<id8> platform=<os> node=<igneumd version>
config: intake https://igneum-six.vercel.app/api/log key 477bb0ef (packaged); manifest https://dl.igneum.network/dl/<token>/igneum-app-latest.json folder ed9c4d2e (packaged)
node tools/logs.mjs --rotation          # one row per app machine: version, key fp, folder fp, sources, state; exit 1 while any is behind
node tools/logs.mjs <run_id> | grep -E 'IGNEUM-APP|config: intake'    # one machine in full

Done means: every row moved (key 477bb0ef, folder ed9c4d2e, version 0.3.6), none OLD, and the unknown rows (a machine whose last upload predates this header, or a machine that has stopped for good) accounted for by name. Today the table shows 6 app machines (three win-, three mac-), all 0.3.5 or older, all unknown because 0.3.5 has no config: line. The console's Machines tab (relay/) shows the versions the same way.

Also check, once, that the intake stores an upload under the new key from a real machine (the row's last_received moves after the restart), and that node tools/logs.mjs lists no new rotation-check rows beyond the one from 3a.

5. The deletion, after section 4 reads 0 behind

In this order, each step checked before the next:

DLSITE="$(tr -d '[:space:]' < ~/.config/igneum/dlsite-dir)"
OLD="$(tr -d '[:space:]' < ~/.config/igneum/dl-token)"; NEW="$(tr -d '[:space:]' < ~/.config/igneum/dl-token.next)"
node tools/logs.mjs --rotation || { echo "machines still behind"; false; }

# 1. the old folder: gone from the downloads host (one deploy); the new one still serves
mv "$DLSITE/dl/$OLD" "$HOME/igneum-dl-old-$(date -u +%Y%m%d)"      # kept outside the site for a week, then rm -rf
(cd "$DLSITE" && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes)
curl -s -o /dev/null -w '%{http_code}\n' "https://dl.igneum.network/dl/$OLD/igneum-app-latest.json"     # 404
packaging/ota/publish-manifest.sh --verify-only --dest "$DLSITE/dl/$NEW" --base-url "https://dl.igneum.network/dl/$NEW"   # still live

# 2. the local files: the NEXT values become the plain ones (every script's default), the old ones kept dated
mv ~/.config/igneum/log-intake-key ~/.config/igneum/log-intake-key.old-$(date -u +%Y%m%d)
mv ~/.config/igneum/log-intake-key.next ~/.config/igneum/log-intake-key
mv ~/.config/igneum/dl-token ~/.config/igneum/dl-token.old-$(date -u +%Y%m%d)
mv ~/.config/igneum/dl-token.next ~/.config/igneum/dl-token
packaging/ota/publish-manifest.sh --verify-only                     # now reads the new token by default: live, verified

# 3. the intake: the old key dropped (LOG_INTAKE_KEY becomes the new value, LOG_INTAKE_KEY_NEXT removed), redeployed
cd site
npx --yes vercel@latest --global-config ~/.config/igneum/vercel env rm LOG_INTAKE_KEY production --scope igneum --yes
tr -d '[:space:]' < ~/.config/igneum/log-intake-key | npx --yes vercel@latest --global-config ~/.config/igneum/vercel env add LOG_INTAKE_KEY production --scope igneum
npx --yes vercel@latest --global-config ~/.config/igneum/vercel env rm LOG_INTAKE_KEY_NEXT production --scope igneum --yes
cd .. && git push origin master                                      # or a production deploy
# the old key is dead (401), the new one lives (200)
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "x-igneum-key: $(tr -d '[:space:]' < ~/.config/igneum/log-intake-key.old-$(date -u +%Y%m%d))" -H 'Content-Type: application/json' -d '{"label":"rotation-check","machine":"mac","run_id":"rotation-check","lines":"old key must be refused"}' https://igneum-six.vercel.app/api/log
curl -s -o /dev/null -w '%{http_code}\n' -X POST -H "x-igneum-key: $(tr -d '[:space:]' < ~/.config/igneum/log-intake-key)" -H 'Content-Type: application/json' -d '{"label":"rotation-check","machine":"mac","run_id":"rotation-check","lines":"new key accepted"}' https://igneum-six.vercel.app/api/log

# 4. the GitHub secrets: the plain names carry the new values, the _NEXT names go
tr -d '[:space:]' < ~/.config/igneum/dl-token       | gh secret set DL_TOKEN       --repo igneum-network/igneum
tr -d '[:space:]' < ~/.config/igneum/log-intake-key | gh secret set LOG_INTAKE_KEY --repo igneum-network/igneum
gh secret delete DL_TOKEN_NEXT --repo igneum-network/igneum; gh secret delete LOG_INTAKE_KEY_NEXT --repo igneum-network/igneum

# 5. the app machines keep reporting (a last look, an hour later)
node tools/logs.mjs --rotation

The relay is not involved: since round 4 (X23) it has its own key (~/.config/igneum/relay-key, RELAY_KEY), and the intake key only reports. The old launcher packages under proto-cuda/windows-app and windows-miner (0.2.0) carried the old key in upload-log.bat; those machines are the unknown rows of section 4 and upload nothing after step 3, which is the intent.

6. The fresh repository, after section 5

The old values are dead values once section 5 is done; only then is the rewrite worth running. The owner's two settings come first: rename the GitHub login igneum-labs to a neutral handle (the numeric noreply id 337424239 stays, so the rewritten author line is <new> <337424239+<new>@users.noreply.github.com>), and remove the second login from the organisation's owners. Then, from the main checkout with every agent frozen:

gh pr list --repo igneum-network/igneum            # must be empty
git worktree list > ~/igneum-worktrees-$(date -u +%Y%m%d).txt
IGNEUM_FILTER_REPO=<dir>/git_filter_repo.py tools/repo/fresh-repo.sh --new-login <new handle> --new-repo igneum-network/<name> \
    [--public-claude-md <scrubbed CLAUDE.md>] --work ~/igneum-rewrite

The script clones origin afresh (mirror, no hardlinks), reads the personal identities and the second login from the history and the four secret values from ~/.config/igneum, writes the rule files 0600 and removes them after the pass, runs the one git-filter-repo invocation of docs/plans/history-rewrite.md section 2 (drop the four internal files, replace the secrets and the names, mailmap both personal identities to the login, every offset to +0000, optionally the public CLAUDE.md in every commit), then demands zero for: secret lines in any blob, identity lines in any blob, identity lines in commit metadata, stamps not +0000, commits touching the dropped files, identities other than the login, the old login in any blob (with --new-login). It prints the push commands and runs none of them: gh repo create igneum-network/<name> --private, git remote add origin in the clone, git push --mirror origin, then the GitHub secrets (section 3a), the Vercel Git connection moved to the new repository, the old repository archived, the main checkout re-cloned and every worktree re-created from its rewritten branch.

Dry run of 5 October 2026 (throwaway mirror clone of the main checkout under the session scratchpad, nothing pushed)

Count Before After
commits (all refs) 410 353 (57 commits that only touched the four dropped files are gone)
refs 49 29 (filter-repo drops the remote-tracking refs of the mirror)
author and committer identities 3 1 (igneum-labs <337424239+[removed]>)
stamps not +0000 660 of 820 0 of 706
commits touching the four dropped files 53 0
secret lines in any blob (old key, new key, old token, new token) 21 0
identity lines in any blob (first name outside the login, surname, personal addresses, second login, the other businesses) 1839 0
identity lines in commit metadata 171 0
standing login lines in any blob 94 61 (0 with --new-login after the rename)
pass run time 67 s; 4 min with the clone and the greps

The report sits next to the clone (<work>/report.txt, with commit-map, 411 lines). The throwaway clone was removed after the run; nothing left the Mac.

7. The order for the afternoon

  1. Merge rotation-2 into master (the Windows build on that push packages with the _NEXT secrets only when they exist: set them first, section 3a, or expect the payload step to fail on the missing LOG_INTAKE_KEY).
  2. Section 3a: the site's LOG_INTAKE_KEY_NEXT, the four repository secrets, the curl check.
  3. Section 3b: --dry-run, then the cut with --dl-both.
  4. Section 4 through the afternoon: node tools/logs.mjs --rotation until 0 behind (the slot rule means an hour or two for a synced fleet; a machine that is off waits for its owner).
  5. Section 5: the deletion, in order.
  6. The owner's two GitHub settings (login rename, one owner); then section 6, the fresh repository, from a frozen tree.