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
12 changes: 11 additions & 1 deletion .github/workflows/upm-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
with:
dotnet-version: 8.0.x

- name: Stage UPM package (${{ matrix.mode }})
- name: Stage UPM packages (${{ matrix.mode }})
run: dotnet run --project NxGraph.Build -- stage-${{ matrix.mode }}

- name: Upload staged UPM package
Expand All @@ -36,3 +36,13 @@ jobs:
name: upm-${{ matrix.mode }}
path: upm/com.enzx.nxgraph/

# Only binary mode produces this package: a prebuilt NxGraph.Serialization.dll
# references the assembly named NxGraph and cannot bind to a source-compiled core,
# which Unity would surface as an unresolved reference.
- name: Upload staged serialization UPM package
if: matrix.mode == 'binary'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: upm-serialization
path: upm/com.enzx.nxgraph.serialization/

89 changes: 66 additions & 23 deletions .github/workflows/upm-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ jobs:
fi
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "mode=$MODE" >> "$GITHUB_OUTPUT"

# Only binary mode ships the serialization package: a prebuilt
# NxGraph.Serialization.dll references the assembly named NxGraph and cannot bind
# to a source-compiled core, so source-mode releases publish the core alone.
if [[ "$MODE" == "binary" ]]; then
{
echo "assets<<EOF"
echo "com.enzx.nxgraph-${VER}.tgz"
echo "com.enzx.nxgraph.serialization-${VER}.tgz"
echo "EOF"
} >> "$GITHUB_OUTPUT"
else
echo "assets=com.enzx.nxgraph-${VER}.tgz" >> "$GITHUB_OUTPUT"
fi

echo "Resolved: version=$VER, mode=$MODE"

- name: Build, test, stage, and package UPM
Expand All @@ -74,41 +89,59 @@ jobs:
upm-patch-version
upm-tarball

- name: Push to upm branch
- name: Push package branches
shell: bash
run: |
set -euo pipefail
VER="${{ steps.meta.outputs.version }}"

# Park the tarball outside the worktree: `git clean -fdx` below would delete it
# (it is untracked and not covered by the package-dir exclusion), and the release
# step needs it as an asset.
mv "com.enzx.nxgraph-${VER}.tgz" /tmp/
MODE="${{ steps.meta.outputs.mode }}"

# Park everything the release still needs outside the worktree. Publishing rewrites
# the tree to each package's layout in turn, and `git clean -fdx` deletes anything
# untracked — which covers both the tarballs and the staged binaries.
STASH=/tmp/upm-release
rm -rf "$STASH" && mkdir -p "$STASH"
mv "com.enzx.nxgraph-${VER}.tgz" "$STASH/"
cp -r upm/com.enzx.nxgraph "$STASH/core"
if [[ "$MODE" == "binary" ]]; then
mv "com.enzx.nxgraph.serialization-${VER}.tgz" "$STASH/"
cp -r upm/com.enzx.nxgraph.serialization "$STASH/serialization"
fi

git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"

# Create an orphan branch with just the UPM package contents
git checkout --orphan upm-staging
git rm -rf . > /dev/null 2>&1 || true
git clean -fdx -e "${UPM_PACKAGE_DIR}" > /dev/null 2>&1 || true
# Publish one package layout at the root of its own orphan branch. Each package gets
# its own branch so both stay installable by plain git URL, and so the existing
# '#upm' URL keeps meaning exactly what it meant before the split.
publish() {
local src="$1" branch="$2"

# Move UPM package contents to root
cp -r ${UPM_PACKAGE_DIR}/* .
cp -r ${UPM_PACKAGE_DIR}/.* . 2>/dev/null || true
git checkout --orphan upm-staging
git rm -rf . > /dev/null 2>&1 || true
git clean -fdx > /dev/null 2>&1 || true

# Drop the nested copy so the published branch is only the package layout.
rm -rf upm
# `/.` copies dotfiles (.gitkeep) along with everything else.
cp -r "${src}/." .

git add -A
git commit -m "Release UPM v${VER}"
git add -A
git commit -m "Release UPM v${VER}"
git push origin "upm-staging:${branch}" --force

git push origin upm-staging:upm --force
# Return to the released commit so the next publish starts from a known tree.
git checkout --force --detach "${GITHUB_SHA}" > /dev/null 2>&1
git branch -D upm-staging > /dev/null 2>&1 || true

# Restore the tarball for the release step.
mv "/tmp/com.enzx.nxgraph-${VER}.tgz" .
echo "Pushed ${branch} @ v${VER}."
}

echo "Pushed UPM package v${VER} to 'upm' branch."
publish "$STASH/core" upm
if [[ "$MODE" == "binary" ]]; then
publish "$STASH/serialization" upm-serialization
fi

# Restore the tarballs for the release step.
mv "$STASH"/*.tgz .

- name: Create GitHub Release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
Expand All @@ -134,8 +167,18 @@ jobs:
"com.enzx.nxgraph": "https://github.com/${{ github.repository }}.git#upm/v${{ steps.meta.outputs.version }}"
```

### Optional: serialization

JSON and MessagePack payloads live in a separate package, released from the same
version and published to its own branch. It bundles its own dependencies — see its
README for the duplicate-assembly caveat. Binary-mode releases only.

```json
"com.enzx.nxgraph.serialization": "https://github.com/${{ github.repository }}.git#upm-serialization"
```

### Install from tarball
Download the `.tgz` file below and install via Unity Package Manager → "Add package from tarball".
files: com.enzx.nxgraph-${{ steps.meta.outputs.version }}.tgz
Download a `.tgz` file below and install via Unity Package Manager → "Add package from tarball".
files: ${{ steps.meta.outputs.assets }}
draft: false
prerelease: ${{ contains(steps.meta.outputs.version, '-') }}
29 changes: 26 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,31 @@ TestResults/
coverage.info
coverage.json
/BenchmarkDotNet.Artifacts/
upm/com.enzx.nxgraph/Runtime/**/*.dll
upm/com.enzx.nxgraph/Runtime/**/*.pdb
upm/com.enzx.nxgraph/Runtime/**/*.xml
# Staged UPM binaries: produced on demand by `NxGraph.Build -- stage-binary`, never
# committed. The `.meta` sidecars beside them ARE committed — they carry the plugin GUIDs
# and define which assemblies staging is allowed to write.
upm/*/Runtime/**/*.dll
upm/*/Runtime/**/*.pdb
upm/*/Runtime/**/*.xml
# Staged UPM sources: `stage-source` copies the core library here. The folder is a staging
# artifact and only exists in source mode, so Unity's generated .meta for it goes too.
upm/com.enzx.nxgraph/Runtime/NxGraph/
upm/com.enzx.nxgraph/Runtime/NxGraph.meta
*.log
*.tgz

# Unity development project. Everything here is regenerated from the packages and the
# assets that are tracked; Unity rewrites the C# projects on every import, so they must
# not sit next to NxGraph.sln in source control.
unity/*/Library/
unity/*/Temp/
unity/*/Obj/
unity/*/Build/
unity/*/Builds/
unity/*/Logs/
unity/*/UserSettings/
unity/*/MemoryCaptures/
unity/*/Recordings/
unity/*/*.csproj
unity/*/*.sln
unity/*/*.user
20 changes: 14 additions & 6 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,20 @@
these. Per-project files keep only what genuinely differs: TargetFramework(s), package
Title/Description/Tags/readme, and IsPackable/IsTestProject flags.

TFM decision (recorded): TargetFrameworks stay per-project. The core library
multi-targets net8.0 + netstandard2.1 because the Unity (UPM) package stages the
netstandard2.1 build of NxGraph.dll; the serialization packages stay net8.0-only —
no netstandard2.1 consumer story exists for them (the UPM package ships only the
core), so widening them would cost polyfills for nothing. Revisit only if a
netstandard consumer of the serialization surface appears. -->
TFM decision (recorded, superseding the earlier net8.0-only serialization rule):
TargetFrameworks stay per-project, and all three shipped libraries — NxGraph,
NxGraph.Serialization.Abstraction, NxGraph.Serialization — multi-target
net8.0 + netstandard2.1. The netstandard2.1 leg exists for Unity: the core package
(com.enzx.nxgraph) stages NxGraph.dll and the abstraction, and the serialization
package (com.enzx.nxgraph.serialization) stages NxGraph.Serialization.dll together
with its dependencies. The netstandard2.1 consumer the previous note was waiting for
is the Unity graph editor, which loads and saves graph payloads in-editor.

Cost of the widening, so it is not re-litigated: three small shim files per
serialization assembly (init-only marker, caller-argument attribute, argument-null
guard), an explicit System.Text.Json reference on the netstandard2.1 leg only, and
the Compat.cs helpers for the text-IO overloads netstandard2.1 lacks. No public
surface differs between the two TFMs. -->
<Project>

<PropertyGroup>
Expand Down
113 changes: 100 additions & 13 deletions NxGraph.Build/BuildHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,27 +68,59 @@ public static string FindRepoRoot()
public static string SourceRoot(string repoRoot) =>
Path.Combine(repoRoot, "NxGraph");

/// <summary>Directory name of the core UPM package.</summary>
public const string CorePackageDir = "com.enzx.nxgraph";

/// <summary>Directory name of the optional serialization UPM package.</summary>
public const string SerializationPackageDir = "com.enzx.nxgraph.serialization";

public static string PackageRoot(string repoRoot) =>
Path.Combine(repoRoot, "upm", "com.enzx.nxgraph");
Path.Combine(repoRoot, "upm", CorePackageDir);

public static string SerializationPackageRoot(string repoRoot) =>
Path.Combine(repoRoot, "upm", SerializationPackageDir);

public static string StagedSourceRoot(string repoRoot) =>
Path.Combine(PackageRoot(repoRoot), "Runtime", "NxGraph");

public static string PluginsRoot(string repoRoot) =>
Path.Combine(PackageRoot(repoRoot), "Runtime", "Plugins");

public static string SerializationPluginsRoot(string repoRoot) =>
Path.Combine(SerializationPackageRoot(repoRoot), "Runtime", "Plugins");

public static string BuildOutput(string repoRoot) =>
Path.Combine(repoRoot, "NxGraph", "bin", "Release", "netstandard2.1");

/// <summary>
/// The serialization project's netstandard2.1 output. Because that project sets
/// <c>CopyLocalLockFileAssemblies</c> on this TFM, the directory holds the whole
/// dependency closure — which is exactly what the Unity package bundles.
/// </summary>
public static string SerializationBuildOutput(string repoRoot) =>
Path.Combine(repoRoot, "NxGraph.Serialization", "bin", "Release", "netstandard2.1");

public static string ArtifactsDir(string repoRoot) =>
Path.Combine(repoRoot, OptionalEnv("ARTIFACTS_DIR") ?? "artifacts");

/// <summary>
/// The core package's package.json. <c>UPM_PACKAGE_DIR</c> still overrides it, which is how
/// the release workflow points at a relocated layout.
/// </summary>
public static string PackageJsonPath(string repoRoot)
{
var upmDir = OptionalEnv("UPM_PACKAGE_DIR") ?? Path.Combine("upm", "com.enzx.nxgraph");
var upmDir = OptionalEnv("UPM_PACKAGE_DIR") ?? Path.Combine("upm", CorePackageDir);
return Path.Combine(repoRoot, upmDir, "package.json");
}

/// <summary>
/// The serialization package's package.json. Deliberately not overridable by
/// <c>UPM_PACKAGE_DIR</c> — that variable names one directory, and the two packages are
/// versioned and released together.
/// </summary>
public static string SerializationPackageJsonPath(string repoRoot) =>
Path.Combine(SerializationPackageRoot(repoRoot), "package.json");

// ── Pack helper (replaces the 3× duplicated dotnet pack blocks) ───

public static IReadOnlyList<string> PackArgs(
Expand Down Expand Up @@ -137,7 +169,13 @@ public static IReadOnlyList<string> PackArgs(

// ── package.json version patching ──────────────────────────────────

public static void PatchPackageJsonVersion(string packageJsonPath, string version)
/// <param name="pinDependency">
/// When set, the named entry under <c>dependencies</c> is pinned to the same version. The
/// two UPM packages ship as a unit, so the serialization package always depends on the
/// exact core version released alongside it.
/// </param>
public static void PatchPackageJsonVersion(string packageJsonPath, string version,
string? pinDependency = null)
{
if (!File.Exists(packageJsonPath))
throw new FileNotFoundException($"package.json not found at {packageJsonPath}");
Expand All @@ -148,25 +186,44 @@ public static void PatchPackageJsonVersion(string packageJsonPath, string versio

node["version"] = version;

if (pinDependency is not null)
{
if (node["dependencies"] is not JsonObject dependencies)
throw new InvalidOperationException(
$"{packageJsonPath} has no 'dependencies' object to pin '{pinDependency}' in.");

if (!dependencies.ContainsKey(pinDependency))
throw new InvalidOperationException(
$"{packageJsonPath} declares no dependency on '{pinDependency}'.");

dependencies[pinDependency] = version;
Console.WriteLine($"Pinned dependency {pinDependency} to {version}");
}

var options = new JsonSerializerOptions { WriteIndented = true };
File.WriteAllText(packageJsonPath, node.ToJsonString(options) + Environment.NewLine);

Console.WriteLine($"Updated package.json version to {version}");
Console.WriteLine($"Updated {Path.GetFileName(Path.GetDirectoryName(packageJsonPath))} version to {version}");
}

// ── Tarball creation ───────────────────────────────────────────────

public static string CreateTarball(string repoRoot, string version)
{
var upmRelDir = OptionalEnv("UPM_PACKAGE_DIR") ?? Path.Combine("upm", "com.enzx.nxgraph");
var upmAbsDir = Path.Combine(repoRoot, upmRelDir);
var tarballName = $"com.enzx.nxgraph-{version}.tgz";
var upmRelDir = OptionalEnv("UPM_PACKAGE_DIR") ?? Path.Combine("upm", CorePackageDir);
return CreateTarball(repoRoot, version, Path.Combine(repoRoot, upmRelDir), CorePackageDir);
}

/// <summary>Tarballs one package directory as <c>{packageName}-{version}.tgz</c>.</summary>
public static string CreateTarball(string repoRoot, string version, string upmAbsDir, string packageName)
{
var tarballName = $"{packageName}-{version}.tgz";
var tarballPath = Path.Combine(repoRoot, tarballName);

if (!Directory.Exists(upmAbsDir))
throw new DirectoryNotFoundException($"UPM package directory not found: {upmAbsDir}");

// We need to create a .tar.gz with entries rooted at "com.enzx.nxgraph/"
// We need to create a .tar.gz with entries rooted at "{packageName}/"
using var fileStream = File.Create(tarballPath);
using var gzipStream = new GZipStream(fileStream, CompressionLevel.Optimal);
TarFile.CreateFromDirectory(
Expand Down Expand Up @@ -213,18 +270,48 @@ public static void ClearStagedSource(string repoRoot)
}
}

public static void ClearStagedPlugins(string repoRoot)
public static void ClearStagedPlugins(string repoRoot) => ClearPlugins(PluginsRoot(repoRoot));

/// <summary>
/// Removes staged binaries from a Plugins folder while preserving the tracked sidecars.
/// <para>
/// The <c>.meta</c> files must survive: they carry the plugin GUIDs Unity uses as reference
/// identity, they are committed (the binaries themselves are gitignored and staged on
/// demand), and regenerating them would hand every consumer project a new GUID for the same
/// assembly. They double as the reviewed allowlist of what may be staged — see
/// <see cref="StagedPluginNames"/>.
/// </para>
/// </summary>
public static void ClearPlugins(string pluginsDir)
{
var dir = PluginsRoot(repoRoot);
Directory.CreateDirectory(dir);
Directory.CreateDirectory(pluginsDir);

foreach (var file in Directory.GetFiles(dir))
foreach (var file in Directory.GetFiles(pluginsDir))
{
if (Path.GetFileName(file) == ".gitkeep") continue;
var name = Path.GetFileName(file);
if (name == ".gitkeep" || name.EndsWith(".meta", StringComparison.OrdinalIgnoreCase))
continue;

File.Delete(file);
}
}

/// <summary>
/// The file names a Plugins folder is allowed to contain, derived from the committed
/// <c>.meta</c> sidecars. Staging compares against this so that a new transitive dependency
/// can neither be silently bundled (unreviewed binary in a shipped package) nor silently
/// dropped (a TypeLoadException at the consumer) — it fails the build until someone adds
/// the matching <c>.meta</c>.
/// </summary>
public static HashSet<string> StagedPluginNames(string pluginsDir)
{
Directory.CreateDirectory(pluginsDir);

return Directory.GetFiles(pluginsDir, "*.meta")
.Select(f => Path.GetFileNameWithoutExtension(f)!)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
}

// ── Target resolution (tag-based) ──────────────────────────────────

public static (string target, string version) ResolvePublishTarget()
Expand Down
Loading
Loading