Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions .github/scripts/collect_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,14 @@
import hashlib
from pathlib import Path

from package_portable import PLATFORMS
from release_version import parse


def expected_assets(version):
parse(version)
return sorted(
[f"plotium-{version}-{p}.zip" for p in PLATFORMS.values()]
+ [f"plotium-{version}-{p}.dmg" for p in PLATFORMS.values() if p.startswith("macos-")]
[f"plotium-{version}-{p}.zip" for p in ("windows-x86_64", "linux-x86_64")]
+ [f"plotium-{version}-macos-{architecture}.dmg" for architecture in ("aarch64", "x86_64")]
)


Expand Down
6 changes: 4 additions & 2 deletions .github/scripts/package_macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
import tempfile

from dmg_layout import validate_layout, write_layout
from package_portable import PLATFORMS, package, run
import package_mcp
from package_portable import PLATFORMS, stage, run
from release_version import ROOT, apple_versions

TARGETS = {"aarch64-apple-darwin": "arm64", "x86_64-apple-darwin": "x86_64"}
Expand Down Expand Up @@ -160,8 +161,9 @@ def create_dmg(staging, dmg):
def create(target, output, skip_build=False, skip_connector_smoke=False):
if sys.platform != "darwin":
raise RuntimeError("macOS packaging requires macOS and Apple command-line tools")
directory, version = package(target, output, skip_build, skip_connector_smoke)
directory, version = stage(target, output, skip_build)
platform = PLATFORMS[target]
package_mcp.package(directory, platform, version, runtime_smoke=not skip_connector_smoke)
staging = output / f"dmg-{platform}"
if staging.exists():
shutil.rmtree(staging)
Expand Down
4 changes: 4 additions & 0 deletions .github/scripts/package_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,14 @@
"plotium_properties_list", "plotium_properties_inspect", "plotium_plan", "plotium_execute",
"plotium_document_inspect", "plotium_figure_diagnose", "plotium_render_preview", "plotium_preview_plan",
"plotium_table_columns", "plotium_table_read_rows", "plotium_save_status", "plotium_export_status",
"plotium_capabilities_get", "plotium_apply", "plotium_analysis_results_inspect",
"plotium_diffusion_inspect", "plotium_processing_inspect",
"plotium_tasks_get", "plotium_tasks_list", "plotium_tasks_cancel",
}
UNBOUND_TOOLS = {
"plotium_get_state", "plotium_properties_list", "plotium_execute",
"plotium_save_status", "plotium_export_status",
"plotium_capabilities_get", "plotium_tasks_get", "plotium_tasks_list", "plotium_tasks_cancel",
}


Expand Down
20 changes: 14 additions & 6 deletions .github/scripts/package_portable.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Build the existing flat ZIP contract; macOS also reuses its staged files."""
"""Stage release files and build Windows/Linux ZIPs; macOS uses architecture-specific DMGs."""
import argparse
import os
from pathlib import Path
Expand All @@ -23,7 +23,7 @@ def run(*args, **kwargs):
return subprocess.run(list(map(str, args)), cwd=ROOT, check=True, **kwargs)


def package(target, output, skip_build=False, skip_connector_smoke=False):
def stage(target, output, skip_build=False):
version = cargo_version()
platform = PLATFORMS[target]
environment = dict(os.environ, PLOTIUM_RELEASE_CHANNEL=parse(version).group(4) or "stable")
Expand Down Expand Up @@ -54,7 +54,16 @@ def package(target, output, skip_build=False, skip_connector_smoke=False):
shutil.copy2(target_dir / target / "release" / (name + suffix), directory)
for name in ("LICENSE", "COPYRIGHT.md", "README.md"):
shutil.copy2(ROOT / name, directory)
package_mcp.package(directory, platform, version, runtime_smoke=not skip_connector_smoke)
return directory, version


def package(target, output, skip_build=False):
platform = PLATFORMS[target]
if platform.startswith("macos-"):
raise ValueError("macOS releases use package_macos.py and architecture-specific DMGs")
directory, version = stage(target, output, skip_build)
suffix = ".exe" if platform.startswith("windows-") else ""
package_mcp.package(directory, platform, version)
archive = output / f"plotium-{version}-{platform}.zip"
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zipped:
for path in sorted(directory.iterdir()):
Expand All @@ -65,14 +74,13 @@ def package(target, output, skip_build=False, skip_connector_smoke=False):
zipped.extractall(smoke)
connector = Path(smoke) / ("plotium-mcp" + suffix)
connector.chmod(0o755)
if not skip_connector_smoke:
package_mcp.smoke(connector)
package_mcp.smoke(connector)
return directory, version


if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", choices=PLATFORMS, required=True)
parser.add_argument("--target", choices=[t for t, p in PLATFORMS.items() if not p.startswith("macos-")], required=True)
parser.add_argument("--output", type=Path, default=ROOT / "dist")
parser.add_argument("--skip-build", action="store_true")
args = parser.parse_args()
Expand Down
25 changes: 19 additions & 6 deletions .github/scripts/test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,19 @@ def test_binary_validation_checks_architecture_system_libraries_and_minos(self):
with self.subTest(outputs=outputs), patch("subprocess.check_output", side_effect=outputs), self.assertRaises(ValueError):
inspect_binary(binary, "arm64")

def test_collection_requires_the_exact_four_zip_and_two_dmg_names(self):
def test_collection_requires_two_zips_and_two_architecture_dmgs(self):
self.assertEqual(expected_assets("0.2.0"), [
"plotium-0.2.0-linux-x86_64.zip", "plotium-0.2.0-macos-aarch64.dmg",
"plotium-0.2.0-macos-x86_64.dmg",
"plotium-0.2.0-windows-x86_64.zip",
])
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
for name in expected_assets("0.1.0"):
(directory / name).write_bytes(b"release asset")
collect(directory, "0.1.0")
sums = (directory / "SHA256SUMS").read_text().splitlines()
self.assertEqual(len(sums), 6)
self.assertEqual(len(sums), 4)
self.assertEqual([line.split(" ")[1] for line in sums], expected_assets("0.1.0"))
(directory / "plotium-0.1.0-macos-aarch64.dmg").unlink()
with self.assertRaises(ValueError):
Expand All @@ -131,13 +136,21 @@ def test_collection_requires_the_exact_four_zip_and_two_dmg_names(self):
with self.assertRaises(ValueError):
collect(directory, "0.1.0")

def test_collection_rejects_extra_macos_zip_downloads(self):
with tempfile.TemporaryDirectory() as temporary:
directory = Path(temporary)
for name in expected_assets("0.2.0") + ["plotium-0.2.0-macos-aarch64.zip"]:
(directory / name).write_bytes(b"package")
with self.assertRaises(ValueError):
collect(directory, "0.2.0")

def test_flat_zip_retains_gui_connector_extension_and_licenses(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
target = "aarch64-apple-darwin"
target = "x86_64-pc-windows-msvc"
binaries = root / "target" / target / "release"
binaries.mkdir(parents=True)
for name in ("plotium", "plotium-mcp"):
for name in ("plotium.exe", "plotium-mcp.exe"):
(binaries / name).write_bytes(b"binary")
for name in ("LICENSE", "COPYRIGHT.md", "README.md"):
(root / name).write_text(name)
Expand All @@ -156,9 +169,9 @@ def mcp(directory, platform, version, runtime_smoke=True):
patch.dict("os.environ", {"CARGO_TARGET_DIR": str(root / "target")}):
package_portable.package(target, root / "dist", skip_build=True)
smoke.assert_called_once()
with zipfile.ZipFile(root / "dist/plotium-0.1.0-macos-aarch64.zip") as archive:
with zipfile.ZipFile(root / "dist/plotium-0.1.0-windows-x86_64.zip") as archive:
self.assertEqual(set(archive.namelist()), {
"plotium", "plotium-mcp", "plotium.mcpb", "LICENSE", "COPYRIGHT.md",
"plotium.exe", "plotium-mcp.exe", "plotium.mcpb", "LICENSE", "COPYRIGHT.md",
"README.md", "THIRD-PARTY-LICENSES.html", "MCP-THIRD-PARTY-LICENSES.html",
})

Expand Down
9 changes: 7 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,17 @@ jobs:
run: cargo install cargo-about --version 0.9.1 --locked --features cli
- name: Build release binaries
run: cargo build --locked --release --target "$TARGET" -p plotium -p plotium-agent
- name: Package macOS app, DMG, and unchanged portable ZIP
- name: Package macOS app and DMG
if: runner.os == 'macOS'
run: |
python3 -m venv "$RUNNER_TEMP/plotium-packaging-python"
"$RUNNER_TEMP/plotium-packaging-python/bin/python" -m pip install -r .github/scripts/requirements-packaging.txt
"$RUNNER_TEMP/plotium-packaging-python/bin/python" .github/scripts/package_macos.py --target "$TARGET" --skip-build
- name: Test whole-app update using the packaged DMG and shipping helper
if: runner.os == 'macOS'
env:
PLOTIUM_TEST_UPDATE_DMG: ${{ github.workspace }}/dist/plotium-${{ env.VERSION }}-${{ env.PLATFORM }}.dmg
run: cargo test --locked -p plotium-core update::bundle::tests -- --nocapture
- name: Package existing portable ZIP
if: runner.os != 'macOS'
run: python3 .github/scripts/package_portable.py --target "$TARGET" --skip-build
Expand Down Expand Up @@ -126,7 +131,7 @@ jobs:
pattern: release-*
path: dist
merge-multiple: true
- name: Require four ZIPs and both DMGs; generate and verify checksums
- name: Require Windows/Linux ZIPs and both macOS DMGs; generate and verify checksums
env:
VERSION: ${{ needs.version.outputs.version }}
run: |
Expand Down
36 changes: 22 additions & 14 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,8 @@ job logs, with a status summary on the workflow run.

## Package a macOS application locally

Build a DMG that can be dragged into Applications, together with the unchanged
portable ZIP. On macOS with Python 3.10+, Apple command-line tools, and the matching
Rust target:
Build a DMG that can be dragged into Applications. On macOS with Python 3.10+,
Apple command-line tools, and the matching Rust target:

```sh
cargo install cargo-about --version 0.9.1 --locked --features cli
Expand Down Expand Up @@ -165,19 +164,27 @@ The pinned Python tools only write and inspect Finder layout metadata during
packaging/tests; they are not application runtime dependencies. The optional
xtask feature reuses the workspace SVG renderer.

Trusted public distribution additionally requires authorized Developer ID
signing with hardened runtime, notarization, stapling, and validation of the
final Internet-downloaded DMG and installed app. Configure credentials and call
Apple's service only with separate authorization; ad-hoc checks cannot replace
that release gate.
The current no-paid-account distribution uses ad-hoc signatures and explicitly
advertises the lack of notarization. Test browser downloads on real Macs and
record any first-launch approval needed under Privacy & Security; do not disable
Gatekeeper or remove quarantine attributes. This is not equivalent to Developer ID
publisher authentication or notarization. If a paid developer membership becomes
available, add Developer ID signing, hardened runtime, notarization, and stapling;
configure credentials and call Apple's service only with separate authorization.

From 0.2.0, application bundles update from the matching processor's DMG by
replacing the complete app after exit, with rollback on replacement failure.
The Release workflow tests its actual DMG and shipping helper on both macOS
architectures. This does not replace browser-download and GUI launch acceptance.

## Publish a release

The [Release workflow](.github/workflows/release.yml) packages the desktop app
and native MCP connector for Windows x86-64, Linux x86-64, and macOS
Intel / Apple Silicon. macOS and Windows archives also contain the native
Intel / Apple Silicon. macOS applications and Windows ZIPs also contain the native
Claude Desktop extension. The workflow exercises the connector's actual stdio
initialization and tool discovery before and after archive extraction.
initialization and tool discovery in packaged installations. The macOS jobs
also exercise their actual DMGs through the shipping update helper.
It rechecks dependency policy before packaging, without rerunning the rest of
CI or requiring extra secrets.

Expand All @@ -204,14 +211,15 @@ and tag rather than moving an existing tag.
Use `v0.1.0` for a regular pre-1.0 release. Only `-alpha.N` and `-beta.N`
suffixes are supported for prereleases; they also select the updater channel.

The draft includes the existing four portable ZIPs, two macOS DMGs, license
notices, and `SHA256SUMS` covering all six packages. macOS DMGs are ad-hoc signed
and unnotarized; the portable ZIP contract is unchanged.
The draft contains exactly four install packages: Windows and Linux ZIPs, and
Intel and Apple Silicon macOS DMGs. License notices are inside the packages;
`SHA256SUMS` covers all four. Do not publish macOS ZIPs or a universal DMG.
macOS apps are ad-hoc signed and unnotarized.
Platform requirements are in the
[installation guide](https://plotium.nmrtist.space/getting-started/installation/).

For a rehearsal, use **Actions → Release → Run workflow** on the chosen ref;
this builds and validates the same six-package asset set and uploads it as
this builds and validates the same four-package asset set and uploads it as
`validated-release`, without creating a release. Per-architecture macOS validation
reports are separate Actions artifacts. Failed runs can be
retried to finish a draft; published releases require a new version.
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/app/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -640,6 +640,7 @@ fn main() -> eframe::Result<()> {
&& let Err(error) = plan.launch(RELAUNCH_REQUESTED.load(Ordering::Relaxed))
{
log::error!("failed to launch update helper: {error}");
plotium_core::update::record_failure(&error);
}
log::logger().flush();
Ok(())
Expand Down
15 changes: 5 additions & 10 deletions crates/app/src/ui/settings_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,16 +172,11 @@ fn render_category(
match cat {
SettingsCategory::General => {
properties::panel::preferences_section(app, SettingsCategory::General.section_id(), ui);
let automatic = !app.session.updates.requires_manual_install();
ui.add_enabled_ui(automatic, |ui| {
properties::panel::preferences_section(
app,
properties::panel::PREFERENCES_UPDATES_SECTION,
ui,
);
})
.response
.on_disabled_hover_text("Install updates from a new DMG for this application bundle.");
properties::panel::preferences_section(
app,
properties::panel::PREFERENCES_UPDATES_SECTION,
ui,
);
update_status_row(ui, &mut app.session.updates);
}
SettingsCategory::Appearance => {
Expand Down
9 changes: 0 additions & 9 deletions crates/app/src/ui/settings_dialog/controls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,6 @@ fn empty_state(ui: &mut Ui, text: &str) {
}

pub(super) fn update_status_row(ui: &mut Ui, updates: &mut UpdateService) {
if updates.requires_manual_install() {
ui.label(format!("Installed version {}.", env!("CARGO_PKG_VERSION")));
ui.label(updates.status().label());
ui.hyperlink_to(
"Download Plotium updates",
"https://github.com/nmrtist/plotium/releases",
);
return;
}
setting_row(
ui,
"Check for updates",
Expand Down
11 changes: 4 additions & 7 deletions crates/core/src/installation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ pub fn macos_bundle_contents(executable: &Path) -> Option<&Path> {
.then_some(contents)
}

/// Only macOS application bundles need whole-app replacement by DMG.
pub fn requires_manual_update(executable: &Path) -> bool {
/// Bundle installations select whole-app DMG updates instead of portable ZIPs.
pub fn uses_bundle_updates(executable: &Path) -> bool {
cfg!(target_os = "macos") && macos_bundle_contents(executable).is_some()
}

Expand All @@ -35,11 +35,8 @@ mod tests {
"/tmp/Plotium.app/Contents/plotium",
] {
assert_eq!(macos_bundle_contents(Path::new(path)), None);
assert!(!requires_manual_update(Path::new(path)));
assert!(!uses_bundle_updates(Path::new(path)));
}
assert_eq!(
requires_manual_update(executable),
cfg!(target_os = "macos")
);
assert_eq!(uses_bundle_updates(executable), cfg!(target_os = "macos"));
}
}
Loading
Loading