Skip to content

Commit bb0689b

Browse files
committed
Keep only the current Particle release and remove older builds
1 parent ab5cb7e commit bb0689b

6 files changed

Lines changed: 102 additions & 2 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
The main Swift package is this directory, `/Users/lakshyaprajapati/Repositories/Particle`. The package display name is Particle. Existing Swift target/module and product identifiers retain the upstream names until the model port; do not mass-rename implementation symbols as workspace housekeeping.
44

5+
Release retention is a standing user requirement (27 October 2026): whenever creating a new Particle version, delete ANY older application version. Verify/sign the new release and install it successfully first; then remove older Particle/ParticleOne application bundles, old builds of the same version, release ZIPs, Homebrew download copies, and cached extracted apps. Keep only the current release/build. Never remove a running app: close it normally and finish cleanup. Model weights, conversations, memories, settings, source history and validation evidence are separate data and must be preserved. `publish_update.py` prunes old distribution archives; `install_particle_release.py` prunes old application/cache copies after verified installation. Run `python3 Scripts/particleone/cleanup_releases.py` as the final release check, including after a Sparkle update. Do not declare a new version finished with older app builds left behind.
6+
57
Use `Scripts/particle` for doctor, resolve, build, manifest, test, and xcode commands. The default toolchain is `/Volumes/Particle/Xcode.app/Contents/Developer`. Build outputs and compiler/dependency caches belong on the SD card. The user explicitly approved a separate, bounded internal SSD expert-data cache: `~/Library/Caches/Particle/Experts/`, 4096 MiB of payload by default. The user subsequently approved up to 18432 MiB for the new bounded SSD tier and a verified .particle model conversion. Keep the original GGUF intact. Legacy victim caching remains available; read-through and complete-mirror profiles are explicit choices. A complete mirror must replace the partial cache within one budget, never add a redundant full copy. Use `--no-ssd-cache` for SD-only expert-read controls. `PARTICLE_TOOLCHAIN=clt` explicitly selects installed Command Line Tools as a fallback.
68

79
The Sarvam model is `/Volumes/Particle/Models/sarvam-30b/sarvam-30b-q4_k_m.gguf`, exposed as `PARTICLE_MODEL_PATH`. It is a Q4_K_M GGUF containing Q4_K, Q5_K, Q6_K, and FP32 tensors. The legacy TurboFieldfare engine is Gemma-specific. ParticleCLI uses the separate ParticleGGUF/ParticleCore Sarvam path; never pass the Sarvam GGUF to the legacy loader.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env python3
2+
"""Keep only the current Particle release; never touch models or workspace data."""
3+
from pathlib import Path
4+
import argparse,json,plistlib,re,shutil,subprocess
5+
6+
BUNDLE_ID='app.particleone.desktop'
7+
ARCHIVE=re.compile(r'^(?:[0-9a-f]{64}--)?Particle(?:One)?-([0-9]+(?:\.[0-9]+)*)(?:-([0-9]+))?-arm64\.zip$')
8+
9+
def number(value):
10+
if not isinstance(value,str) or not re.fullmatch(r'[0-9]+(?:\.[0-9]+)*',value):raise ValueError('Invalid release number')
11+
parts=list(map(int,value.split('.')))
12+
while len(parts)>1 and parts[-1]==0:parts.pop()
13+
return tuple(parts)
14+
15+
def older(version,build,current_version,current_build):
16+
a,b=number(version),number(current_version)
17+
return a<b or (a==b and build is not None and number(build)<number(current_build))
18+
19+
def prune_archives(folders,version,build):
20+
number(version);number(build);removed=[]
21+
for folder in folders:
22+
if not folder.is_dir() or folder.is_symlink():continue
23+
for path in folder.iterdir():
24+
match=ARCHIVE.fullmatch(path.name)
25+
if path.is_symlink() or not path.is_file() or not match:continue
26+
if older(match[1],match[2],version,build):path.unlink();removed.append(str(path))
27+
return removed
28+
29+
def archive_folders(root,home):
30+
return [root/'dist',root/'dist/update-staging',home/'Library/Application Support/ParticleOneUpdates/Releases',home/'Library/Caches/Homebrew/downloads']
31+
32+
def cleanup(root,version,build,home=None):
33+
home=home or Path.home();app=home/'Applications/Particle.app'
34+
current=plistlib.loads((app/'Contents/Info.plist').read_bytes())
35+
if current.get('CFBundleIdentifier')!=BUNDLE_ID or current.get('CFBundleShortVersionString')!=version or current.get('CFBundleVersion')!=build:raise RuntimeError('Install the current release before pruning application copies.')
36+
subprocess.run(['/usr/bin/codesign','--verify','--deep','--strict',str(app)],check=True)
37+
processes=subprocess.check_output(['/bin/ps','-axww','-o','comm='],text=True).splitlines()
38+
removed=prune_archives(archive_folders(root,home),version,build);active=[]
39+
folders=[root/'dist',root/'dist/update-staging',home/'Applications',Path('/Applications'),home/'.Trash']
40+
cache=home/'Library/Caches/Sparkle_generate_appcast'
41+
candidates=[]
42+
for folder in folders:
43+
if folder.is_symlink():continue
44+
try:candidates.extend(folder.glob('*Particle*.app'))
45+
except OSError:continue
46+
if cache.is_dir() and not cache.is_symlink():candidates.extend(cache.glob('*/*Particle*.app'))
47+
for path in candidates:
48+
if path.is_symlink() or not path.is_dir() or path==app:continue
49+
try:info=plistlib.loads((path/'Contents/Info.plist').read_bytes())
50+
except (OSError,ValueError):continue
51+
if info.get('CFBundleIdentifier')!=BUNDLE_ID:continue
52+
try:is_old=older(info.get('CFBundleShortVersionString'),info.get('CFBundleVersion'),version,build)
53+
except ValueError:continue
54+
if not is_old:continue
55+
if any(str(path)+'/Contents/' in line for line in processes):active.append(str(path));continue
56+
shutil.rmtree(path);removed.append(str(path))
57+
return {'current_version':version,'current_build':build,'removed':removed,'active_older_copies':active}
58+
59+
def main():
60+
parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--root',type=Path,default=Path(__file__).resolve().parents[2]);args=parser.parse_args()
61+
receipt=json.loads((args.root/'dist/particleone-release.json').read_text())
62+
report=cleanup(args.root,receipt['version'],receipt['build']);print(json.dumps(report,indent=2))
63+
output=args.root/('docs/particleone/validation-'+receipt['version']);output.mkdir(parents=True,exist_ok=True)
64+
(output/'older-release-cleanup.json').write_text(json.dumps(report,indent=2)+'\n')
65+
if report['active_older_copies']:raise SystemExit('Close the listed older Particle copies normally, then rerun cleanup.')
66+
if __name__=='__main__':main()

‎Scripts/particleone/install_particle_release.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pathlib import Path
44
import datetime,hashlib,json,os,plistlib,shutil,sqlite3,subprocess,tempfile
55
ROOT=Path(__file__).resolve().parents[2]
6+
from cleanup_releases import cleanup
67
IDENTITY='app.particleone.desktop'
78

89
def info(app):return plistlib.loads((app/'Contents/Info.plist').read_bytes())
@@ -62,6 +63,8 @@ def main():
6263
assert (model/'manifest.json').read_bytes()==model_manifest
6364
if legacy.exists():shutil.rmtree(legacy)
6465
if backup.exists():shutil.rmtree(backup)
65-
report={'app':str(target),'version':value['CFBundleShortVersionString'],'build':value['CFBundleVersion'],'model':str(model),'updated_workspaces':changed,'same_bundle_identifier':True,'same_update_key':True,'weights_copied':0,'user_transcripts_and_memories_preserved':True}
66+
retention=cleanup(ROOT,release['version'],release['build'])
67+
if retention['active_older_copies']:raise RuntimeError('Close older Particle copies and run cleanup_releases.py; no active app was removed.')
68+
report={'older_release_cleanup':retention,'app':str(target),'version':value['CFBundleShortVersionString'],'build':value['CFBundleVersion'],'model':str(model),'updated_workspaces':changed,'same_bundle_identifier':True,'same_update_key':True,'weights_copied':0,'user_transcripts_and_memories_preserved':True}
6669
(ROOT/('docs/particleone/validation-'+release['version'])/'installation.json').write_text(json.dumps(report,indent=2)+'\n');print(json.dumps(report,indent=2))
6770
if __name__=='__main__':main()

‎Scripts/particleone/publish_update.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import argparse,hashlib,json,os,plistlib,re,shutil,subprocess,sys,tempfile,time,urllib.request,xml.etree.ElementTree as ET
44
from pathlib import Path
55
from urllib.parse import urlparse
6+
from cleanup_releases import prune_archives,archive_folders
67
ROOT=Path(__file__).resolve().parents[2]
78
NS='http://www.andymatuschak.org/xml-namespaces/sparkle'
89
ET.register_namespace('sparkle',NS)
@@ -76,6 +77,7 @@ def main():
7677
time.sleep(0.2)
7778
else:raise SystemExit('Signed release saved, but local update host is unavailable or serving another feed. Check the host log; no unrelated process was stopped.')
7879
refresh_local_metadata(release,target)
80+
prune_archives(archive_folders(ROOT,Path.home()),release['version'],release['build'])
7981
print('Published Particle '+release['version']+' ('+release['build']+') to '+release['update_feed'])
8082
print('Existing updater-enabled installations will find it at their next check.')
8183
if __name__=='__main__':main()

‎Scripts/particleone/test_updates.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
sys.path.insert(0,str(Path(__file__).parent))
88
from publish_update import validate_appcast,NS
99
from serve_updates import UpdateHandler
10+
from cleanup_releases import prune_archives,older
1011

1112
class UpdateTests(unittest.TestCase):
1213
def test_feed_must_match_verified_archive_and_build(self):
@@ -15,6 +16,21 @@ def test_feed_must_match_verified_archive_and_build(self):
1516
self.assertEqual(validate_appcast(data,release,'https://example.com/app.zip',123),'signed-data')
1617
for candidate,url,size in [({'version':'0.2.3','build':'../../escape'},'https://example.com/app.zip',123),({'version':'0.2.3','build':'2'},'https://example.com/app.zip',123),(release,'https://other.example/app.zip',123),(release,'https://example.com/app.zip',124)]:
1718
with self.assertRaises(ValueError):validate_appcast(data,candidate,url,size)
19+
def test_retention_deletes_only_older_particle_archives(self):
20+
with tempfile.TemporaryDirectory() as folder:
21+
root=Path(folder)
22+
old=['Particle-0.3.0-arm64.zip','ParticleOne-0.2.3-2026102601-arm64.zip','Particle-0.3.1-2026102701-arm64.zip','a'*64+'--Particle-0.3.0-arm64.zip']
23+
keep=['Particle-0.3.1-arm64.zip','Particle-0.3.1-2026102702-arm64.zip','Particle-0.3.2-arm64.zip','other.zip','churo-chat.particle']
24+
for name in old+keep:(root/name).write_bytes(b'fixture')
25+
link=root/('b'*64+'--Particle-0.3.0-arm64.zip');link.symlink_to(root/'other.zip')
26+
removed=prune_archives([root],'0.3.1','2026102702')
27+
self.assertEqual(set(Path(p).name for p in removed),set(old))
28+
self.assertTrue(all((root/name).exists() for name in keep));self.assertTrue(link.is_symlink())
29+
def test_numeric_versions_and_same_version_builds(self):
30+
self.assertTrue(older('0.3.2',None,'0.3.10','100'))
31+
self.assertFalse(older('0.3.10',None,'0.3.2','100'))
32+
self.assertTrue(older('0.3.1','99','0.3.1.0','100'))
33+
self.assertFalse(older('0.3.1',None,'0.3.1','100'))
1834
def test_host_exposes_only_feed_and_non_symlink_archives(self):
1935
with tempfile.TemporaryDirectory() as name:
2036
root=Path(name);(root/'appcast.xml').write_text('signed-feed');(root/'ParticleOne-1.zip').write_bytes(b'archive')

‎docs/particleone/UPDATES.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ cd /Users/lakshyaprajapati/Repositories/Particle
1515
Scripts/release_particleone.sh
1616
```
1717

18-
This compiles and packages into a reusable internal staging directory, assigns an increasing UTC timestamp build number, verifies the archive and code signature, signs both archive and appcast, and publishes the feed. No version-named app folder needs to be opened. Use `--version 0.3.1` to change the display version; build numbers still increase automatically. Keep the previous release archives available for existing downloads.
18+
This compiles and packages into a reusable internal staging directory, assigns an increasing UTC timestamp build number, verifies the archive and code signature, signs both archive and appcast, and publishes the feed. No version-named app folder needs to be opened. Use `--version 0.3.1` to change the display version; build numbers still increase automatically. The user requires current-version-only retention. After successful publication, older distribution archives are deleted. After verified installation, older app builds and cached copies are deleted too. An older download in progress may need to restart against the current feed. Models and user data remain separate.
1919

2020
The development feed is `http://127.0.0.1:49271/appcast.xml`. A per-user LaunchAgent named `app.particleone.update-host` serves only the signed feed and app archives, bound exclusively to loopback. Directory listings, symlinks and other files are rejected. Host and release files live under `~/Library/Application Support/ParticleOneUpdates`, separate from workspace data. The local app polls once per minute; a build reaches an open updater-enabled copy on its next check. Settings also permits an immediate check. Failed offline checks retain the installed app.
2121

@@ -30,3 +30,14 @@ No public app archive/feed has been published. Supply an HTTPS `--update-feed` a
3030
References: [Sparkle setup](https://sparkle-project.org/documentation/), [publishing](https://sparkle-project.org/documentation/publishing/), [customization](https://sparkle-project.org/documentation/customization/).
3131

3232
From 0.3.0 the product/bundle is Particle.app and archives use the Particle- prefix. Existing bundle identity, update key, localhost feed and compatibility cask token remain unchanged. The server accepts both old and new archive names.
33+
34+
35+
## Mandatory release cleanup
36+
37+
Every new release keeps only its current version/build. The publisher removes older Particle/ParticleOne ZIPs from staging, the local feed and Homebrew downloads. The local installer verifies the new app before removing older app/cache copies. For a Sparkle-installed update, finish the release with:
38+
39+
```sh
40+
python3 Scripts/particleone/cleanup_releases.py
41+
```
42+
43+
The cleanup verifies the current installed bundle and matches only Particle app identities and archive names in known distribution/cache folders. A running older copy is reported for normal closure rather than deleted while active. Preserve source history, test evidence, models and private workspace data.

0 commit comments

Comments
 (0)