99 tag :
1010 description : " Release tag to upload installers to"
1111 required : true
12- default : " v1.0.3 "
12+ default : " v1.0.8 "
1313
1414permissions :
1515 contents : write
@@ -35,18 +35,20 @@ jobs:
3535
3636 mac :
3737 name : macOS ${{ matrix.arch }} installers
38- runs-on : macos-latest
38+ runs-on : ${{ matrix.runner }}
3939 needs : release
4040 strategy :
4141 fail-fast : false
4242 matrix :
4343 include :
4444 - arch : arm64
45+ runner : macos-14
4546 ffprobe : ffprobe-arm64
46- file_match : " Mach-O 64-bit executable arm64"
47+ ffprobe_match : " Mach-O 64-bit executable arm64"
4748 - arch : x64
49+ runner : macos-15-intel
4850 ffprobe : ffprobe-x64
49- file_match : " Mach-O 64-bit executable x86_64"
51+ ffprobe_match : " Mach-O 64-bit executable x86_64"
5052 env :
5153 RELEASE_TAG : ${{ github.event.inputs.tag || github.ref_name }}
5254 DL_STUDIO_MAC_ARCH : ${{ matrix.arch }}
@@ -66,12 +68,17 @@ jobs:
6668 - run : npx electron-builder --config electron-builder.config.cjs --mac dmg pkg --${{ matrix.arch }} --config.directories.output=release-upload
6769 - name : Verify macOS bundled binaries
6870 run : |
69- file "release-upload/mac-${{ matrix.arch }}/DL Studio.app/Contents/MacOS/DL Studio" | tee /tmp/app-file.txt
70- file "release-upload/mac-${{ matrix.arch }}/DL Studio.app/Contents/Resources/bin/ffmpeg" | tee /tmp/ffmpeg-file.txt
71- file "release-upload/mac-${{ matrix.arch }}/DL Studio.app/Contents/Resources/bin/${{ matrix.ffprobe }}" | tee /tmp/ffprobe-file.txt
72- grep -q "${{ matrix.file_match }}" /tmp/app-file.txt
73- grep -q "${{ matrix.file_match }}" /tmp/ffmpeg-file.txt
74- grep -q "${{ matrix.file_match }}" /tmp/ffprobe-file.txt
71+ app_dir="$(find release-upload -type d -name "DL Studio.app" -print -quit)"
72+ if [ -z "$app_dir" ]; then
73+ find release-upload -maxdepth 3 -print
74+ echo "DL Studio.app was not found in release-upload"
75+ exit 1
76+ fi
77+ bin_dir="$app_dir/Contents/Resources/bin"
78+ file "$bin_dir/ffmpeg" | tee /tmp/ffmpeg-file.txt
79+ grep -q "${{ matrix.ffprobe_match }}" /tmp/ffmpeg-file.txt
80+ file "$bin_dir/${{ matrix.ffprobe }}" | tee /tmp/ffprobe-file.txt
81+ grep -q "${{ matrix.ffprobe_match }}" /tmp/ffprobe-file.txt
7582 - name : Upload macOS assets
7683 env :
7784 GH_TOKEN : ${{ github.token }}
8390 "release-upload/DL-Studio-Mac-${version}-${{ matrix.arch }}.dmg.blockmap" \
8491 --clobber \
8592 --repo "${{ github.repository }}"
93+ - name : Upload macOS installer artifact
94+ uses : actions/upload-artifact@v4
95+ with :
96+ name : mac-${{ matrix.arch }}-installers
97+ path : |
98+ Mac/release-upload/DL-Studio-Mac-*-${{ matrix.arch }}.dmg
99+ Mac/release-upload/DL-Studio-Mac-*-${{ matrix.arch }}.pkg
100+ if-no-files-found : error
86101
87102 windows :
88103 name : Windows x64 installer
@@ -112,16 +127,230 @@ jobs:
112127 "release/DL-Studio-Windows-Setup-$version.exe.blockmap" `
113128 --clobber `
114129 --repo "${{ github.repository }}"
130+ - name : Upload Windows installer artifact
131+ uses : actions/upload-artifact@v4
132+ with :
133+ name : windows-installers
134+ path : Windows/release/DL-Studio-Windows-Setup-*.exe
135+ if-no-files-found : error
115136
116137 publish :
117138 name : Publish release
118139 runs-on : ubuntu-latest
140+ environment : production
119141 needs :
120142 - mac
121143 - windows
122144 env :
123145 RELEASE_TAG : ${{ github.event.inputs.tag || github.ref_name }}
124146 steps :
147+ - uses : actions/download-artifact@v4
148+ with :
149+ path : release-assets
150+ - uses : actions/setup-python@v5
151+ with :
152+ python-version : " 3.11"
153+ - name : Upload installers and manifests to OSS
154+ env :
155+ OSS_ACCESS_KEY_ID : ${{ secrets.OSS_ACCESS_KEY_ID }}
156+ OSS_ACCESS_KEY_SECRET : ${{ secrets.OSS_ACCESS_KEY_SECRET }}
157+ ALIYUN_OSS_ACCESS_KEY_ID : ${{ secrets.ALIYUN_OSS_ACCESS_KEY_ID }}
158+ ALIYUN_OSS_ACCESS_KEY_SECRET : ${{ secrets.ALIYUN_OSS_ACCESS_KEY_SECRET }}
159+ OSS_ENDPOINT : ${{ vars.CLIENT_RELEASE_OSS_ENDPOINT || 'https://infmemo.oss-cn-beijing.aliyuncs.com' }}
160+ OSS_BUCKET : ${{ vars.CLIENT_RELEASE_OSS_BUCKET || 'infmemo' }}
161+ OSS_IS_CNAME : ${{ vars.CLIENT_RELEASE_OSS_IS_CNAME || 'true' }}
162+ OSS_PREFIX : ${{ vars.CLIENT_RELEASE_OSS_PREFIX || 'infmemo' }}
163+ CLIENT_UPDATE_OSS_PREFIX : ${{ vars.CLIENT_UPDATE_OSS_PREFIX || 'client-releases' }}
164+ run : |
165+ python -m pip install --upgrade pip
166+ python -m pip install oss2
167+ python - <<'PY'
168+ import hashlib
169+ import json
170+ import mimetypes
171+ import os
172+ from datetime import datetime, timezone
173+ from pathlib import Path
174+
175+ import oss2
176+
177+ release_tag = os.environ["RELEASE_TAG"].strip()
178+ if not release_tag.lower().startswith("v"):
179+ release_tag = f"v{release_tag}"
180+ version = release_tag[1:]
181+ oss_prefix = os.environ.get("OSS_PREFIX", "infmemo").strip().strip("/")
182+ release_prefix = os.environ.get("CLIENT_UPDATE_OSS_PREFIX", "client-releases").strip().strip("/")
183+ bucket_name = os.environ["OSS_BUCKET"].strip()
184+ endpoint = os.environ["OSS_ENDPOINT"].strip()
185+ is_cname = os.environ.get("OSS_IS_CNAME", "true").strip().lower() == "true"
186+ access_key_id = (os.environ.get("OSS_ACCESS_KEY_ID") or os.environ.get("ALIYUN_OSS_ACCESS_KEY_ID") or "").strip()
187+ access_key_secret = (os.environ.get("OSS_ACCESS_KEY_SECRET") or os.environ.get("ALIYUN_OSS_ACCESS_KEY_SECRET") or "").strip()
188+ if not access_key_id or not access_key_secret:
189+ raise RuntimeError("OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET or ALIYUN_OSS_ACCESS_KEY_ID/ALIYUN_OSS_ACCESS_KEY_SECRET are required")
190+
191+ root = Path("release-assets")
192+ files = sorted(path for path in root.rglob("*") if path.is_file())
193+ allowed_suffixes = {".exe", ".dmg", ".pkg"}
194+ release_root = "/".join(part for part in (oss_prefix, release_prefix, release_tag) if part)
195+ auth = oss2.Auth(access_key_id, access_key_secret)
196+ bucket = oss2.Bucket(auth, endpoint, bucket_name, is_cname=is_cname)
197+
198+ def normalize_version(value):
199+ text = str(value or "").strip()
200+ if text.lower().startswith("v"):
201+ text = text[1:]
202+ return text.split("-", 1)[0]
203+
204+ def version_parts(value):
205+ parts = []
206+ for raw in normalize_version(value).split("."):
207+ digits = ""
208+ for char in raw:
209+ if not char.isdigit():
210+ break
211+ digits += char
212+ parts.append(int(digits) if digits else 0)
213+ return parts
214+
215+ def compare_versions(left, right):
216+ left_parts = version_parts(left)
217+ right_parts = version_parts(right)
218+ length = max(len(left_parts), len(right_parts), 3)
219+ for index in range(length):
220+ left_value = left_parts[index] if index < len(left_parts) else 0
221+ right_value = right_parts[index] if index < len(right_parts) else 0
222+ if left_value > right_value:
223+ return 1
224+ if left_value < right_value:
225+ return -1
226+ return 0
227+
228+ def hash_file(path):
229+ digest = hashlib.sha256()
230+ size = 0
231+ with path.open("rb") as source:
232+ while True:
233+ chunk = source.read(1024 * 1024)
234+ if not chunk:
235+ break
236+ digest.update(chunk)
237+ size += len(chunk)
238+ return digest.hexdigest(), size
239+
240+ def content_type_for(name):
241+ lower = name.lower()
242+ if lower.endswith(".exe"):
243+ return "application/vnd.microsoft.portable-executable"
244+ if lower.endswith(".dmg"):
245+ return "application/x-apple-diskimage"
246+ if lower.endswith(".pkg"):
247+ return "application/octet-stream"
248+ return mimetypes.guess_type(name)[0] or "application/octet-stream"
249+
250+ def platform_for(name):
251+ lower = name.lower()
252+ if lower.endswith(".exe") or "windows" in lower:
253+ return "win32", "x64", "windows"
254+ if lower.endswith((".dmg", ".pkg")) or "mac" in lower:
255+ arch = "x64" if "x64" in lower or "x86_64" in lower else "arm64"
256+ return "darwin", arch, "mac"
257+ return "", "", "unknown"
258+
259+ def make_progress_callback(name, size_bytes):
260+ last_reported = {"percent": -1}
261+
262+ def progress(consumed_bytes, total_bytes):
263+ total = total_bytes or size_bytes
264+ if total <= 0:
265+ print(f"Uploading {name}: {consumed_bytes} bytes", flush=True)
266+ return
267+ percent = min(100, int(consumed_bytes * 100 / total))
268+ if percent == 100 or percent >= last_reported["percent"] + 10:
269+ last_reported["percent"] = percent
270+ print(f"Uploading {name}: {consumed_bytes}/{total} bytes ({percent}%)", flush=True)
271+
272+ return progress
273+
274+ assets = []
275+ for path in files:
276+ name = path.name
277+ suffix = path.suffix.lower()
278+ lower = name.lower()
279+ if suffix not in allowed_suffixes or lower.endswith((".blockmap", ".yml", ".yaml")):
280+ continue
281+ platform, arch, platform_dir = platform_for(name)
282+ if not platform:
283+ raise RuntimeError(f"Unsupported release asset name: {name}")
284+ sha256, size_bytes = hash_file(path)
285+ object_key = f"{release_root}/{platform_dir}/{name}"
286+ content_type = content_type_for(name)
287+ print(f"Starting OSS upload {name} -> oss://{bucket_name}/{object_key} ({size_bytes} bytes)", flush=True)
288+ bucket.put_object_from_file(
289+ object_key,
290+ str(path),
291+ headers={"Content-Type": content_type},
292+ progress_callback=make_progress_callback(name, size_bytes),
293+ )
294+ assets.append(
295+ {
296+ "platform": platform,
297+ "arch": arch,
298+ "assetName": name,
299+ "objectKey": object_key,
300+ "sha256": sha256,
301+ "sizeBytes": size_bytes,
302+ "contentType": content_type,
303+ }
304+ )
305+ print(f"Uploaded {name} -> oss://{bucket_name}/{object_key} ({platform}/{arch}, {size_bytes} bytes)", flush=True)
306+
307+ if not assets:
308+ raise RuntimeError("No installer assets were found for OSS publishing")
309+ assets.sort(key=lambda item: (item["platform"], item["arch"], item["assetName"]))
310+
311+ published_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
312+ manifest = {
313+ "version": release_tag,
314+ "tagName": release_tag,
315+ "releaseName": f"DL Studio {version}",
316+ "releaseUrl": f"https://github.com/{os.environ['GITHUB_REPOSITORY']}/releases/tag/{release_tag}",
317+ "publishedAt": published_at,
318+ "assets": assets,
319+ }
320+ manifest_bytes = json.dumps(manifest, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
321+ version_manifest_key = f"{release_root}/manifest.json"
322+ latest_manifest_key = "/".join(part for part in (oss_prefix, release_prefix, "latest.json") if part)
323+ bucket.put_object(version_manifest_key, manifest_bytes, headers={"Content-Type": "application/json; charset=utf-8"})
324+
325+ update_latest = True
326+ try:
327+ current_latest = json.loads(bucket.get_object(latest_manifest_key).read().decode("utf-8"))
328+ current_tag = current_latest.get("tagName") or current_latest.get("version")
329+ update_latest = compare_versions(release_tag, current_tag) >= 0
330+ except Exception as exc:
331+ if getattr(exc, "status", None) not in (404, None):
332+ print(f"Unable to read existing latest manifest, will overwrite: {exc}")
333+
334+ if update_latest:
335+ bucket.put_object(latest_manifest_key, manifest_bytes, headers={"Content-Type": "application/json; charset=utf-8"})
336+ print(f"Updated latest manifest: oss://{bucket_name}/{latest_manifest_key}")
337+ else:
338+ print(f"Kept existing latest manifest because {release_tag} is older")
339+
340+ Path("client-release-manifest.json").write_bytes(manifest_bytes)
341+ with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
342+ summary.write(f"### Client release OSS publish\n\n")
343+ summary.write(f"- Tag: `{release_tag}`\n")
344+ summary.write(f"- Version manifest: `oss://{bucket_name}/{version_manifest_key}`\n")
345+ summary.write(f"- Latest manifest updated: `{str(update_latest).lower()}`\n")
346+ for asset in assets:
347+ summary.write(f"- `{asset['platform']}/{asset['arch']}` `{asset['assetName']}` `{asset['sizeBytes']}` bytes\n")
348+ PY
349+ - name : Upload generated manifest artifact
350+ uses : actions/upload-artifact@v4
351+ with :
352+ name : client-release-manifest
353+ path : client-release-manifest.json
125354 - name : Publish GitHub release
126355 env :
127356 GH_TOKEN : ${{ github.token }}
0 commit comments