Build tagged macOS releases
This commit is contained in:
154
.gitea/scripts/release.py
Normal file
154
.gitea/scripts/release.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create and publish Gitea releases for tagged DS4Server builds."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from urllib.error import HTTPError
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
|
||||||
|
def issue_numbers(message: str) -> list[int]:
|
||||||
|
return sorted({int(number) for number in re.findall(r"#(\d+)", message)})
|
||||||
|
|
||||||
|
|
||||||
|
def markdown(text: str) -> str:
|
||||||
|
return text.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]")
|
||||||
|
|
||||||
|
|
||||||
|
def release_body(issues: list[dict[str, object]], server: str, repository: str, sha: str) -> str:
|
||||||
|
groups = {"Fixes": [], "Improvements": [], "Other changes": []}
|
||||||
|
for issue in issues:
|
||||||
|
labels = {label["name"] for label in issue.get("labels", [])} # type: ignore[index]
|
||||||
|
group = "Fixes" if "bug" in labels else "Improvements" if "enhancement" in labels else "Other changes"
|
||||||
|
title = " ".join(str(issue["title"]).split())
|
||||||
|
groups[group].append(f"- [#{issue['number']} {markdown(title)}]({issue['html_url']})")
|
||||||
|
|
||||||
|
sections = [f"## {name}\n" + "\n".join(items) for name, items in groups.items() if items]
|
||||||
|
if not sections:
|
||||||
|
sections.append("No closed issues were linked from commits in this release.")
|
||||||
|
sections.append(f"Built from [{sha[:12]}]({server}/{repository}/commit/{sha}).")
|
||||||
|
return "\n\n".join(sections)
|
||||||
|
|
||||||
|
|
||||||
|
class Gitea:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.api_url = os.environ["GITEA_API_URL"].rstrip("/")
|
||||||
|
self.repository = os.environ["GITEA_REPOSITORY"]
|
||||||
|
self.token = os.environ["GITEA_TOKEN"]
|
||||||
|
|
||||||
|
def request(self, method: str, path: str, payload: object | None = None) -> object:
|
||||||
|
data = None if payload is None else json.dumps(payload).encode()
|
||||||
|
request = Request(
|
||||||
|
f"{self.api_url}{path}",
|
||||||
|
data=data,
|
||||||
|
method=method,
|
||||||
|
headers={"Authorization": f"token {self.token}", "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
with urlopen(request) as response:
|
||||||
|
content = response.read()
|
||||||
|
return None if not content else json.loads(content)
|
||||||
|
|
||||||
|
def issue(self, number: int) -> dict[str, object]:
|
||||||
|
return self.request("GET", f"/repos/{self.repository}/issues/{number}") # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def commit_messages(sha: str) -> str:
|
||||||
|
try:
|
||||||
|
previous = subprocess.check_output(
|
||||||
|
["git", "describe", "--tags", "--abbrev=0", f"{sha}^"],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
).strip()
|
||||||
|
except subprocess.CalledProcessError:
|
||||||
|
previous = ""
|
||||||
|
revision = f"{previous}..{sha}" if previous else sha
|
||||||
|
return subprocess.check_output(["git", "log", "--format=%s%n%b", revision], text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare() -> None:
|
||||||
|
gitea = Gitea()
|
||||||
|
tag = os.environ["GITEA_REF_NAME"]
|
||||||
|
sha = os.environ["GITEA_SHA"]
|
||||||
|
server = os.environ["GITEA_SERVER_URL"].rstrip("/")
|
||||||
|
|
||||||
|
issues = [gitea.issue(number) for number in issue_numbers(commit_messages(sha))]
|
||||||
|
closed = [issue for issue in issues if issue["state"] == "closed"]
|
||||||
|
payload = {
|
||||||
|
"tag_name": tag,
|
||||||
|
"target_commitish": sha,
|
||||||
|
"name": tag,
|
||||||
|
"body": release_body(closed, server, gitea.repository, sha),
|
||||||
|
"draft": True,
|
||||||
|
"prerelease": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
release = gitea.request("GET", f"/repos/{gitea.repository}/releases/tags/{quote(tag, safe='')}")
|
||||||
|
except HTTPError as error:
|
||||||
|
if error.code != 404:
|
||||||
|
raise
|
||||||
|
release = gitea.request("POST", f"/repos/{gitea.repository}/releases", payload)
|
||||||
|
else:
|
||||||
|
if not release["draft"]: # type: ignore[index]
|
||||||
|
raise RuntimeError(f"release {tag} is already published")
|
||||||
|
release = gitea.request("PATCH", f"/repos/{gitea.repository}/releases/{release['id']}", payload) # type: ignore[index]
|
||||||
|
for asset in release["assets"] or []: # type: ignore[index]
|
||||||
|
gitea.request(
|
||||||
|
"DELETE",
|
||||||
|
f"/repos/{gitea.repository}/releases/{release['id']}/assets/{asset['id']}", # type: ignore[index]
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
|
||||||
|
output.write(f"release_id={release['id']}\n") # type: ignore[index]
|
||||||
|
|
||||||
|
|
||||||
|
def publish() -> None:
|
||||||
|
gitea = Gitea()
|
||||||
|
release_id = int(os.environ["RELEASE_ID"])
|
||||||
|
gitea.request("PATCH", f"/repos/{gitea.repository}/releases/{release_id}", {"draft": False})
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseTests(unittest.TestCase):
|
||||||
|
def test_notes_are_unique_sorted_grouped_and_linked(self) -> None:
|
||||||
|
self.assertEqual(issue_numbers("Fix #12, refs #3 and #12"), [3, 12])
|
||||||
|
body = release_body(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"number": 12,
|
||||||
|
"title": "Fix [unlock]",
|
||||||
|
"html_url": "https://example.test/issues/12",
|
||||||
|
"labels": [{"name": "bug"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"number": 3,
|
||||||
|
"title": "Add search",
|
||||||
|
"html_url": "https://example.test/issues/3",
|
||||||
|
"labels": [{"name": "enhancement"}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"https://example.test",
|
||||||
|
"owner/repo",
|
||||||
|
"1234567890abcdef",
|
||||||
|
)
|
||||||
|
self.assertIn("## Fixes\n- [#12 Fix \\[unlock\\]](https://example.test/issues/12)", body)
|
||||||
|
self.assertIn("## Improvements\n- [#3 Add search](https://example.test/issues/3)", body)
|
||||||
|
self.assertIn("[1234567890ab](https://example.test/owner/repo/commit/1234567890abcdef)", body)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
command = sys.argv[1] if len(sys.argv) == 2 else ""
|
||||||
|
if command == "prepare":
|
||||||
|
prepare()
|
||||||
|
elif command == "publish":
|
||||||
|
publish()
|
||||||
|
elif command == "test":
|
||||||
|
unittest.main(argv=[sys.argv[0]])
|
||||||
|
else:
|
||||||
|
raise SystemExit("usage: release.py prepare|publish|test")
|
||||||
98
.gitea/workflows/release.yml
Normal file
98
.gitea/workflows/release.yml
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
name: Tagged release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare-release:
|
||||||
|
runs-on: linux-arm64
|
||||||
|
outputs:
|
||||||
|
release_id: ${{ steps.release.outputs.release_id }}
|
||||||
|
steps:
|
||||||
|
- name: Check out the tag
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Test release-note generation
|
||||||
|
run: python3 .gitea/scripts/release.py test
|
||||||
|
|
||||||
|
- name: Create draft release
|
||||||
|
id: release
|
||||||
|
env:
|
||||||
|
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
GITEA_SHA: ${{ gitea.sha }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: python3 .gitea/scripts/release.py prepare
|
||||||
|
|
||||||
|
build-macos-arm64:
|
||||||
|
needs: prepare-release
|
||||||
|
runs-on: linux-arm64
|
||||||
|
container: ghcr.io/rust-cross/cargo-zigbuild@sha256:82af75c41958c2af2787e8bedd912da7678a9438937e223e9d83d006d747b38b
|
||||||
|
steps:
|
||||||
|
- name: Check out the tag
|
||||||
|
env:
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_SERVER_URL: ${{ gitea.server_url }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
git init
|
||||||
|
git -c http.extraHeader="Authorization: token $GITEA_TOKEN" fetch --depth=1 \
|
||||||
|
"$GITEA_SERVER_URL/$GITEA_REPOSITORY.git" "refs/tags/$GITEA_REF_NAME"
|
||||||
|
git checkout --detach FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Build and package the Apple Silicon application
|
||||||
|
env:
|
||||||
|
CARGO_TARGET_AARCH64_APPLE_DARWIN_RUSTFLAGS: -C link-arg=-Wl,-headerpad,0x1000
|
||||||
|
GITEA_REF_NAME: ${{ gitea.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
version=$(printf '%s' "$GITEA_REF_NAME" | sed 's/[^A-Za-z0-9._-]/-/g')
|
||||||
|
asset="dist/DS4Server-${version}-darwin-arm64.dmg"
|
||||||
|
cargo zigbuild --release --locked --target aarch64-apple-darwin --bin ds4-server
|
||||||
|
cargo run --release --locked --manifest-path tools/macos-packager/Cargo.toml -- \
|
||||||
|
"$GITEA_REF_NAME" target/aarch64-apple-darwin/release/ds4-server "$asset"
|
||||||
|
|
||||||
|
- name: Upload the DMG
|
||||||
|
env:
|
||||||
|
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
asset=$(find dist -maxdepth 1 -type f -name '*.dmg' -print -quit)
|
||||||
|
test -n "$asset"
|
||||||
|
name=$(basename "$asset")
|
||||||
|
curl --fail --silent --show-error --retry 3 \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/octet-stream" \
|
||||||
|
--data-binary "@$asset" \
|
||||||
|
"$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases/$RELEASE_ID/assets?name=$name"
|
||||||
|
|
||||||
|
publish-release:
|
||||||
|
needs:
|
||||||
|
- prepare-release
|
||||||
|
- build-macos-arm64
|
||||||
|
runs-on: linux-arm64
|
||||||
|
steps:
|
||||||
|
- name: Check out the tag
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Publish complete release
|
||||||
|
env:
|
||||||
|
GITEA_API_URL: ${{ gitea.server_url }}/api/v1
|
||||||
|
GITEA_REPOSITORY: ${{ gitea.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
RELEASE_ID: ${{ needs.prepare-release.outputs.release_id }}
|
||||||
|
run: python3 .gitea/scripts/release.py publish
|
||||||
1
tools/macos-packager/.gitignore
vendored
Normal file
1
tools/macos-packager/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
3996
tools/macos-packager/Cargo.lock
generated
Normal file
3996
tools/macos-packager/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
13
tools/macos-packager/Cargo.toml
Normal file
13
tools/macos-packager/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "ds4-macos-packager"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
rust-version = "1.97"
|
||||||
|
license = "MIT"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
apple-bundles = "0.21"
|
||||||
|
apple-codesign = { version = "0.29", default-features = false }
|
||||||
|
apple-dmg = "0.5"
|
||||||
|
simple-file-manifest = "0.11"
|
||||||
154
tools/macos-packager/src/main.rs
Normal file
154
tools/macos-packager/src/main.rs
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
use apple_bundles::MacOsApplicationBundleBuilder;
|
||||||
|
use apple_codesign::{SigningSettings, UnifiedSigner};
|
||||||
|
use simple_file_manifest::FileEntry;
|
||||||
|
use std::{
|
||||||
|
env,
|
||||||
|
error::Error,
|
||||||
|
ffi::OsString,
|
||||||
|
fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
const APP_NAME: &str = "DS4Server";
|
||||||
|
const APP_EXECUTABLE: &str = "ds4-server";
|
||||||
|
const BUNDLE_ID: &str = "de.rfc1437.ds4server";
|
||||||
|
const MIB: u64 = 1024 * 1024;
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
|
let args: Vec<OsString> = env::args_os().collect();
|
||||||
|
if args.len() != 4 {
|
||||||
|
return Err("usage: ds4-macos-packager <version> <executable> <output.dmg>".into());
|
||||||
|
}
|
||||||
|
package(
|
||||||
|
args[1].to_str().ok_or("version is not valid UTF-8")?,
|
||||||
|
Path::new(&args[2]),
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."),
|
||||||
|
Path::new(&args[3]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn package(
|
||||||
|
release_tag: &str,
|
||||||
|
executable: &Path,
|
||||||
|
repository: PathBuf,
|
||||||
|
output: &Path,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
let version = bundle_version(release_tag)?;
|
||||||
|
if !executable.is_file() {
|
||||||
|
return Err(format!("release executable is missing: {}", executable.display()).into());
|
||||||
|
}
|
||||||
|
let output_dir = output.parent().unwrap_or_else(|| Path::new("."));
|
||||||
|
fs::create_dir_all(output_dir)?;
|
||||||
|
if output.try_exists()? {
|
||||||
|
return Err(format!("output already exists: {}", output.display()).into());
|
||||||
|
}
|
||||||
|
let bundle_path = output_dir.join(format!("{APP_NAME}.app"));
|
||||||
|
if bundle_path.try_exists()? {
|
||||||
|
return Err(format!("staging bundle already exists: {}", bundle_path.display()).into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut bundle = MacOsApplicationBundleBuilder::new(APP_NAME)?;
|
||||||
|
bundle.set_info_plist_required_keys(APP_NAME, BUNDLE_ID, version, "DS4S", APP_EXECUTABLE)?;
|
||||||
|
bundle.set_info_plist_key("CFBundleShortVersionString", version)?;
|
||||||
|
bundle.set_info_plist_key("CFBundleIconFile", format!("{APP_NAME}.icns"))?;
|
||||||
|
bundle.set_info_plist_key(
|
||||||
|
"LSApplicationCategoryType",
|
||||||
|
"public.app-category.developer-tools",
|
||||||
|
)?;
|
||||||
|
bundle.set_info_plist_key("NSHighResolutionCapable", true)?;
|
||||||
|
bundle.add_icon(FileEntry::new_from_path(
|
||||||
|
repository.join("assets/DS4Server.icns"),
|
||||||
|
false,
|
||||||
|
))?;
|
||||||
|
bundle.add_file_macos(APP_EXECUTABLE, FileEntry::new_from_path(executable, true))?;
|
||||||
|
add_resources(&mut bundle, &repository.join("metal"), Path::new("metal"))?;
|
||||||
|
add_resources(
|
||||||
|
&mut bundle,
|
||||||
|
&repository.join("assets/dev-brain"),
|
||||||
|
Path::new("dev-brain"),
|
||||||
|
)?;
|
||||||
|
let materialized = bundle.materialize_bundle(output_dir)?;
|
||||||
|
|
||||||
|
let signer = UnifiedSigner::new(SigningSettings::default());
|
||||||
|
signer.sign_path_in_place(&materialized)?;
|
||||||
|
apple_dmg::create_dmg(
|
||||||
|
&materialized,
|
||||||
|
output,
|
||||||
|
APP_NAME,
|
||||||
|
dmg_sectors(directory_size(&materialized)?)?,
|
||||||
|
)?;
|
||||||
|
signer.sign_path_in_place(output)?;
|
||||||
|
fs::remove_dir_all(materialized)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn add_resources(
|
||||||
|
bundle: &mut MacOsApplicationBundleBuilder,
|
||||||
|
source: &Path,
|
||||||
|
destination: &Path,
|
||||||
|
) -> Result<(), Box<dyn Error>> {
|
||||||
|
for entry in fs::read_dir(source)? {
|
||||||
|
let entry = entry?;
|
||||||
|
let path = entry.path();
|
||||||
|
let destination = destination.join(entry.file_name());
|
||||||
|
if entry.file_type()?.is_dir() {
|
||||||
|
add_resources(bundle, &path, &destination)?;
|
||||||
|
} else {
|
||||||
|
bundle.add_file_resources(destination, FileEntry::new_from_path(path, false))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bundle_version(tag: &str) -> Result<&str, Box<dyn Error>> {
|
||||||
|
let version = tag.strip_prefix('v').unwrap_or(tag);
|
||||||
|
let version = version.split(['-', '+']).next().unwrap_or_default();
|
||||||
|
let parts: Vec<&str> = version.split('.').collect();
|
||||||
|
if !(1..=3).contains(&parts.len())
|
||||||
|
|| parts
|
||||||
|
.iter()
|
||||||
|
.any(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit()))
|
||||||
|
{
|
||||||
|
return Err(format!("release tag is not a numeric Apple bundle version: {tag}").into());
|
||||||
|
}
|
||||||
|
Ok(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn directory_size(path: &Path) -> std::io::Result<u64> {
|
||||||
|
fs::read_dir(path)?.try_fold(0_u64, |total, entry| {
|
||||||
|
let entry = entry?;
|
||||||
|
let metadata = entry.metadata()?;
|
||||||
|
let size = if metadata.is_dir() {
|
||||||
|
directory_size(&entry.path())?
|
||||||
|
} else {
|
||||||
|
metadata.len()
|
||||||
|
};
|
||||||
|
total
|
||||||
|
.checked_add(size)
|
||||||
|
.ok_or_else(|| std::io::Error::other("bundle size overflow"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dmg_sectors(bundle_bytes: u64) -> Result<u32, Box<dyn Error>> {
|
||||||
|
let capacity = bundle_bytes
|
||||||
|
.checked_add((bundle_bytes / 10).max(16 * MIB))
|
||||||
|
.ok_or("DMG capacity overflow")?
|
||||||
|
.max(64 * MIB);
|
||||||
|
Ok(u32::try_from(capacity.div_ceil(512))?)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn release_inputs_produce_valid_bundle_metadata_and_capacity() {
|
||||||
|
assert_eq!(bundle_version("v1.2.3").unwrap(), "1.2.3");
|
||||||
|
assert_eq!(bundle_version("2.0.0-rc.1").unwrap(), "2.0.0");
|
||||||
|
assert!(bundle_version("nightly").is_err());
|
||||||
|
assert!(bundle_version("1.2.3.4").is_err());
|
||||||
|
let sectors = dmg_sectors(100 * MIB).unwrap();
|
||||||
|
assert!(u64::from(sectors) * 512 >= 116 * MIB);
|
||||||
|
assert_eq!(dmg_sectors(1).unwrap(), 131_072);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user