com.elestrago.unity.package-tools 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CAHNGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## [2.4.0](https://gitlab.com/elestrago-pkg/package-tool/-/tags/2.4.0)
6
+
7
+ ### Added
8
+
9
+ - Added `unity-package-release` skill sample for cutting `V-{version}` release commits.
10
+
11
+ ---
12
+
5
13
  ## [2.3.0](https://gitlab.com/elestrago-pkg/package-tool/-/tags/2.3.0)
6
14
 
7
15
  ### Added
package/README.md CHANGED
@@ -29,7 +29,7 @@ Add the package to `Packages/manifest.json`:
29
29
  }
30
30
  ],
31
31
  "dependencies": {
32
- "com.elestrago.unity.package-tools": "2.3.0"
32
+ "com.elestrago.unity.package-tools": "2.4.0"
33
33
  }
34
34
  }
35
35
  ```
@@ -0,0 +1,373 @@
1
+ ---
2
+ name: unity-package-release
3
+ description: Cut a release commit for any Unity package that uses the `com.elestrago.unity.package-tools` editor tool (the one that drives a `PackageManifestConfig` ScriptableObject and exports via the **Export Package Source** Inspector button). Discovers the config asset, package name, changelog filename, README install snippet, and publish flow at runtime via `scripts/discover_package.py`, so the same skill works in every package-tool consumer regardless of layout. Bumps the SO's `packageVersion`, updates the consumer-install version line in the project-root README (if one exists), prepends or appends an entry to the changelog (whatever filename the config's `changelogPath` resolves to), prompts the maintainer to run **Export Package Source** in Unity so the exported `Assets/Package/<X>/` artifacts and `Release/package.json` regenerate, stages exactly the touched files, and proposes a `V-{version} {summary}` commit. Detects the publish flow (GitLab elestrago CI, GitLab generic, GitHub Actions, or manual) and prints the matching handoff block — the skill never tags or pushes itself. Use whenever a package-tool maintainer wants to wrap pending changes into a release — phrasings like "bump the version", "cut a release", "update changelog and commit", "ship this as 2.x.y", "make a V- commit", or after a `/unity-package-tool-developer` run finishes its code edits. Refuses to run if no `PackageManifestConfig.asset` is found under `Assets/`.
4
+ ---
5
+
6
+ # unity-package-release
7
+
8
+ Maintainer skill for any Unity-package repo that uses `com.elestrago.unity.package-tools` (the editor tool whose `PackageManifestConfig` ScriptableObject + **Export Package Source** Inspector button drive the package build). Walks the version-bump → changelog → Export → commit pipeline for whatever changes are already on disk.
9
+
10
+ **Repo-agnostic by design.** Other than relying on the `PackageManifestConfig` shape, the skill makes no assumptions about layout: the config asset can live anywhere under `Assets/`; the changelog filename is whatever `changelogPath` resolves to (commonly `CHANGELOG.md`, occasionally `CAHNGELOG.md` due to a long-standing typo in the upstream tool's example); the README may or may not contain a consumer-install snippet; the publish step may be a GitLab pipeline, a GitHub workflow, or a manual `npm publish`. Everything repo-specific is discovered by `scripts/discover_package.py` at the start of each run.
11
+
12
+ **Out of scope.** The skill never writes code (`.cs` files belong to the developer skill if there is one; otherwise to the maintainer). It never invokes Unity. It never tags. It never pushes. Tagging and publishing happen in whatever the project's chosen flow is — the skill prints a handoff block matched to that flow and stops.
13
+
14
+ Two common entry points:
15
+
16
+ - **After a developer-skill run** — e.g. `unity-package-tool-developer` ends by asking whether to hand off here. The change is fresh on disk, the user accepts, and this skill picks up at Step 1.
17
+ - **Stand-alone** — the maintainer edited files by hand and now wants a clean release commit. Run `/unity-package-release` directly.
18
+
19
+ ## Output contract (strict)
20
+
21
+ This skill writes only inside this allow-list, all discovered at runtime by `scripts/discover_package.py` (path field shown for each):
22
+
23
+ - **The `PackageManifestConfig.asset`** (`config_asset.path`) — overwrites only the line whose number is `config_asset.package_version_line`. Never touches `packageVersion` lines elsewhere in the file (they belong to nested `dependencies:` entries, identified by a four-space indent; the skill targets only the SO's own two-space-indented field).
24
+ - **The project-root changelog** (`changelog.resolved_path`) — prepends one entry, or appends a bullet to the in-flight entry. This is the only changelog the skill writes; never write to any copy under the destination path (those are export artifacts regenerated by `FileTools.CopyOrReplaceFileToDirectory` on every export).
25
+ - **The project-root README** (`readme.resolved_path`) — only the version string inside the consumer-install snippet at `readme.install_snippet.line`. Skipped entirely when `readme.install_snippet` is `null` (the README documents installation a different way, or there is no README). Bumped in lockstep with `packageVersion`.
26
+
27
+ This skill **never** writes outside that allow-list. Specifically, it never touches:
28
+
29
+ - Any `.cs` file — code changes belong elsewhere.
30
+ - The export destination directory (`package.destination_path`, commonly `Release/` but configurable) — those files are regenerated by Unity when the maintainer clicks **Export Package Source**.
31
+ - Any pipeline file (`.gitlab-ci.yml`, `.github/workflows/*.yml`) — the skill reads them to detect the publish flow but does not modify them.
32
+ - The documentation directory (`package.documentation_path`, commonly `Documentation~/`) — owned by the project's docs skill or by the maintainer.
33
+ - The exported package source folder under the destination path (its `CAHNGELOG.md` / `CHANGELOG.md` / `README.md` / `LICENSE` / docs) — these are export artifacts. Edit the project-root files instead. If a diff touches them, treat it as a misroute and re-apply to the project-root files.
34
+
35
+ The skill **does** stage the regenerated export-artifact files after the user confirms the Export ran (Step 5 option (a)). Staging is not the same as writing — Unity wrote them; the skill just adds them to the commit.
36
+
37
+ For git, the skill **stages and commits** the allow-list files (plus any `.cs` files the user explicitly lists as part of the release) after explicit user confirmation in Step 7; it never pushes, tags, or amends.
38
+
39
+ ## Skill args
40
+
41
+ Parse from the user's prompt (defaults shown):
42
+
43
+ - `dry-run=false` — when `true`, print intended edits and the commit plan to the conversation but do not write or stage.
44
+ - `version=<semver>` — when present, set `packageVersion` to this exact value instead of asking. Skips the in-flight/new-version prompt.
45
+ - `mode=<in-flight|new-version|ask>` — default `ask`. `in-flight` skips the version-bump prompt and appends a bullet to the existing entry. `new-version` skips the prompt and increments per `segment`.
46
+ - `segment=<patch|minor|major>` — only meaningful with `mode=new-version` (or when the user picks "new version" in the prompt and `version=` is not set). Default `patch`.
47
+ - `category=<Added|Changed|Fixed|Removed|Security|Deprecated>` — when the kind of change is unambiguous from the prompt, set the changelog category directly. Otherwise asked once.
48
+ - `summary=<text>` — when present, use as the changelog bullet text and the commit summary. Otherwise asked once.
49
+ - `config-asset=<path>` — explicit path to the `PackageManifestConfig.asset`. Use only in repos that ship more than one config; otherwise the script picks the closest-to-root candidate automatically.
50
+ - `publish-flow=<gitlab-elestrago-ci|gitlab-generic|github-actions|manual>` — override the auto-detected publish flow when Step 8 picks the wrong handoff block. The detection is best-effort; this argument lets the maintainer pin it.
51
+ - `skip-docs=false` — when `true`, skip the docs regeneration prompt (Step 4).
52
+ - `skip-export=false` — when `true`, skip the Export Package Source prompt (Step 5) and stage only project-root files. Same staleness warning applies.
53
+
54
+ ## Preflight (refuse to run on failure)
55
+
56
+ 1. Run `scripts/discover_package.py` from the skill's own directory (resolve `${SKILL_DIR}` at runtime — do not hardcode):
57
+
58
+ ```bash
59
+ python3 "${SKILL_DIR}/scripts/discover_package.py" --repo .
60
+ ```
61
+
62
+ If the script exits non-zero or `ok` is `false`, the repo does not look like a package-tool consumer (no `PackageManifestConfig.asset` under `Assets/`). Abort with the script's error message.
63
+
64
+ 2. The `git.status_porcelain` field of the JSON output is the same as `git status --porcelain`. Inspect for pre-staged paths from a prior session that would otherwise ride along silently — call them out before proceeding so the maintainer can unstage anything that doesn't belong in this release commit. Do not fail; just make the existing diff visible.
65
+
66
+ ## Pipeline (execute in order)
67
+
68
+ ### Step 1 — Read current state
69
+
70
+ Use the JSON from the preflight `discover_package.py` run. The relevant fields are:
71
+
72
+ - `config_asset.path` and `config_asset.package_version_line` — for the targeted version-bump rewrite.
73
+ - `package.name`, `package.display_name`, `package.version` — the current SO state.
74
+ - `package.destination_path` — the export target (e.g. `Release`, `Build`, `Packages/<package>`, etc.).
75
+ - `changelog.resolved_path`, `changelog.exists`, `changelog.latest_heading` — including the heading's `version`, `url`, and `tag_url_format` (with `{version}` placeholder) so Step 3 can mirror the existing URL pattern.
76
+ - `readme.resolved_path`, `readme.install_snippet` (which may be `null`) — for the targeted README rewrite.
77
+ - `release_package_json` — the exported `package.json` inside the destination, if any, and its `version`. Used in Step 5 to warn when the user is about to skip the Export and silently break the publish trigger.
78
+ - `publish_flow.detected_flow` — for Step 8.
79
+ - `git.branch`, `git.remote_url`, `git.recent_commits`, `git.diff_stat_head` — for context.
80
+
81
+ Show the user the relevant subset (current `packageVersion`, README install-snippet version, latest changelog heading, recent commits, diff stat) and explicitly surface any drift between the three version sources. Drift is common during in-flight work but worth flagging so the user can confirm intent.
82
+
83
+ ### Step 2 — Decide the version (asks user via `AskUserQuestion` unless args say otherwise)
84
+
85
+ If `version=` was passed, use that exact value (no prompt). Apply Step 2c.
86
+
87
+ If `mode=in-flight` was passed, fold into the in-flight version (no bump). Skip to Step 3.
88
+
89
+ If `mode=new-version` was passed, increment per `segment` (default patch). Apply Step 2c.
90
+
91
+ Otherwise ask via `AskUserQuestion`:
92
+
93
+ - (a) **Fold into in-flight version (no bump)** — append a bullet to the existing `## [{current-version}]` entry. Use when several bullets are accumulating into one release across multiple commits.
94
+ - (b) **Start a new version (patch)** — most common for routine changes.
95
+ - (c) **Start a new version (minor)** — for new user-facing features.
96
+ - (d) **Start a new version (major)** — for breaking changes.
97
+
98
+ The in-flight option leads the list because the most common drift signal (latest changelog heading equal to current `packageVersion` with no matching `V-{version}` commit) means a release is being assembled across multiple commits and auto-incrementing is wrong.
99
+
100
+ **Step 2c** (only when a new version was chosen or `version=` was set):
101
+
102
+ Write the new version to both files, preserving the rest of each file byte-for-byte:
103
+
104
+ - The `PackageManifestConfig.asset` at `config_asset.path`, line `config_asset.package_version_line` — replace only the version on that line. Never touch other `packageVersion:` lines (they're nested under `dependencies:` and indented one level deeper).
105
+ - The project-root README at `readme.resolved_path`, line `readme.install_snippet.line` — replace only the version inside the snippet's `"<package-name>": "X.Y.Z"`. Skip this when `readme.install_snippet` is `null`.
106
+
107
+ ### Step 3 — Write the changelog entry
108
+
109
+ Edit the project-root changelog at `changelog.resolved_path`. Do **not** edit any copy that lives inside the export destination — those are regenerated by `FileTools.CopyDocumentationToDirectory` on every export, so manual edits there are overwritten on the next `CreateOrUpdatePackageSource`.
110
+
111
+ Detect the existing format from `changelog.latest_heading`. The canonical format used by package-tool consumers is:
112
+
113
+ ```markdown
114
+ ## [{version}]({tag-url})
115
+
116
+ ### {Category}
117
+
118
+ - One-bullet description.
119
+
120
+ ---
121
+ ```
122
+
123
+ Where `{tag-url}` is `changelog.latest_heading.url` with the version substituted — use `changelog.latest_heading.tag_url_format` directly (it already has `{version}` in place of the previous version's number). If `tag_url_format` is `null` (the existing changelog uses plain `## [version]` headings without a link, or there is no previous entry to copy from), write a plain `## [{version}]` heading and ask the user once whether to add a link target — different repos point at GitLab tags, GitHub releases, openupm pages, or no link at all.
124
+
125
+ If Step 2 folded into the in-flight version: append the new bullet under the matching category in the existing entry (creating the category heading if missing).
126
+
127
+ If Step 2 started a new version: prepend a new entry above the most recent.
128
+
129
+ **Category.** If `category=` was passed, use it. Otherwise ask via `AskUserQuestion`. Categories are the Keep-a-Changelog set: `Added`, `Changed`, `Fixed`, `Removed`, `Security`, `Deprecated`.
130
+
131
+ **Summary.** If `summary=` was passed, use it. Otherwise ask the user once, showing a draft based on `git.diff_stat_head`.
132
+
133
+ **Format rules — strict.** The changelog is a bullet list, not a release-notes paragraph. Each change is **one bullet**, ideally one sentence (≈15 words), absolute hard cap two short sentences. Third-person past-style imperative (`Added X`, `Fixed Y`, `Moved Z`). Name the user-facing symbol (`copyEntries`, `CIUtils.Generate`, `Tools/PackageTools/<menu>`) so the reader can grep. **No semantics, edge cases, pipeline order, fallback behavior, or design rationale in the bullet** — that prose belongs in the docs directory. If you find yourself reaching for em-dashes, "such that", or a second clause to explain *how* it works, stop and move that detail to docs. Anti-example: a five-sentence bullet listing fallback paths, ordering inside the pipeline, and overwrite semantics. Correct example: `Added copyEntries on PackageManifestConfig for staging external content before export. See Documentation~/manual.md.`
134
+
135
+ ### Step 4 — Documentation prompt
136
+
137
+ If the change has *any* user-facing semantics beyond a self-evident button or menu — new config fields, new pipeline behavior, fallback or merge rules, new CLI flags, new template tokens, or anything a user would need to read prose to use correctly — the long explanation belongs in the docs directory (`package.documentation_path`), not the changelog.
138
+
139
+ Skip this step when `skip-docs=true`. Otherwise ask the user via `AskUserQuestion`:
140
+
141
+ - (a) **Run the project's docs skill now (recommended)** — if the repo has a docs-generation skill installed (e.g. `unity-package-docs`), invoke it; it will read the updated source and rewrite the docs directory. Recommended whenever any prose in the changelog cross-references a docs section.
142
+ - (b) **Defer** — note the deferred work in the summary so the user can batch it with other changes before the next release commit. The cross-reference in the changelog still names the *intended* target the user will fill in on the next docs run.
143
+ - (c) **Skip** — only when the change is purely internal (refactor, helper rename, dependency-free constant) with no user-facing effect.
144
+
145
+ Do not silently invoke a docs skill — wait for the answer, since regeneration touches a wide file set and the user may want to stage other changes first. The changelog bullet (Step 3) must cross-reference the doc section regardless of which option is chosen.
146
+
147
+ ### Step 5 — Export Package Source confirmation
148
+
149
+ The export artifacts inside `package.destination_path` (the destination's `README.md`, the changelog copy, the docs directory, the `package.json`) are regenerated by `FileTools.CreateOrUpdatePackageSource` when the maintainer clicks **Export Package Source** on the `PackageManifestConfig.asset` in the Unity inspector. The skill cannot trigger this — Unity must be open.
150
+
151
+ Without this step the commit ships project-root README/changelog files that disagree with what consumers actually pull (the destination copy is what gets packed), and `release_package_json.path` does not get a fresh `version`, which means CI flows that watch that file for a version delta will not publish even though the changelog and config asset advertise a new version.
152
+
153
+ Surface the current `release_package_json.version` vs. the new `packageVersion` in the prompt so the maintainer can see whether the destination is stale.
154
+
155
+ Skip this step when `skip-export=true` (treat as option (b) below).
156
+
157
+ Otherwise prompt the user once via `AskUserQuestion`:
158
+
159
+ - (a) **Done — I ran Export Package Source (recommended)** — proceed to Step 6 and stage both the project-root files and the regenerated destination artifacts in the same commit.
160
+ - (b) **Skip Export — commit project-root files only** — proceed to Step 6 staging only the project-root files. Print a warning that the next consumer pull will see stale artifacts, and (if applicable) that CI publish triggers may not fire until the next export runs. Use only for in-progress work that the maintainer will rebuild before publishing.
161
+ - (c) **Cancel commit** — exit. Step 2 and Step 3 edits stay on disk; the maintainer will stage and commit by hand.
162
+
163
+ Do not proceed past this prompt without an answer.
164
+
165
+ ### Step 6 — Stage the right files
166
+
167
+ Build the staging list from what this run actually changed (not a blanket `git add -A`, which would sweep in the maintainer's unrelated work-in-progress):
168
+
169
+ - The project-root changelog at `changelog.resolved_path` (always, if Step 3 wrote).
170
+ - The project-root README at `readme.resolved_path` (only if Step 2 bumped and `readme.install_snippet` was non-null).
171
+ - The `PackageManifestConfig.asset` at `config_asset.path` (only if Step 2 bumped).
172
+ - Inside `package.destination_path`: the regenerated changelog/README/docs copies and `package.json` — only when Step 5's answer was (a). Filter by mtime newer than the start of this skill run so untouched files don't ride along.
173
+ - Any `.cs` files (or asmdef edits) the user explicitly lists as part of this release — typically the output of a recent developer-skill run. Ask the user if it's not obvious from the unstaged diff which files belong in this commit.
174
+
175
+ Show the user the exact `git add` invocation listing each file. Ask for confirmation if anything looks off (e.g. an unexpected `.meta` file slipped in — meta files are normally regenerated and may or may not belong in the release commit). Run it.
176
+
177
+ After staging, re-check `git status --porcelain` and surface anything in the index that this skill did not stage — see [[feedback_staging_isolation]]: prior-session staged changes ride along silently otherwise. If extras are present, ask the user before committing.
178
+
179
+ ### Step 7 — Propose the commit message and commit
180
+
181
+ Commit message format (see [[feedback_commit_message]]):
182
+
183
+ ```
184
+ V-{version} {imperative summary}
185
+ ```
186
+
187
+ Where:
188
+
189
+ - `{version}` is the value now in the SO after Step 2. For an in-flight change (option (a) in Step 2), this is the unchanged version — the commit still reads `V-{version}` with the existing one, signalling that more bullets are accumulating under that heading.
190
+ - `{imperative summary}` is short, third-person past-style imperative, mirroring the changelog bullet just written (≤ ~72 chars on the subject line — keep it greppable, not a paragraph). When several bullets are landing across multiple skill runs into the same in-flight version, each commit has its own summary describing what *that* commit added; do not try to summarize the whole release.
191
+
192
+ Optional body lines are fine when the summary alone leaves the diff ambiguous, but keep it short — the changelog and docs directory carry the long-form prose.
193
+
194
+ Show the user the proposed `git commit -m "..."` command and ask via `AskUserQuestion`:
195
+
196
+ - (a) **Commit now (recommended)** — skill runs `git commit -m "..."` and reports the new SHA.
197
+ - (b) **Print only** — print the command, do not execute. Use when the maintainer wants to tweak the wording first.
198
+ - (c) **Cancel** — leave the staging area as-is and exit. The maintainer will inspect and commit by hand.
199
+
200
+ Never pass `--amend`, `--no-verify`, or `-c commit.gpgsign=false`. If a pre-commit hook fails, surface the hook output verbatim, fix the underlying issue, and create a fresh commit rather than amending.
201
+
202
+ ### Step 8 — Publish handoff (do not tag, do not push tags)
203
+
204
+ The skill never tags and never pushes. The publish step varies per repo; pick the matching block by `publish_flow.detected_flow` (or by the `publish-flow=` argument override), and print it for the maintainer. Substitute `{version}` with the value committed in Step 7 and `<current-branch>` with `git.branch`.
205
+
206
+ For **`gitlab-elestrago-ci`** (`.gitlab-ci.yml` includes the shared `elestrago/ci-pipelines` `unity-package-npm-publish.yml`):
207
+
208
+ ```
209
+ Commit landed. Next steps are manual + CI:
210
+ 1. Push the working branch: git push -u origin <current-branch>
211
+ 2. Open a merge request to main.
212
+ 3. On merge, .gitlab-ci.yml -> elestrago/ci-pipelines unity-package-npm-publish.yml
213
+ detects the version change in {release-package-json-path}, creates tag {version},
214
+ and publishes to the npm registry.
215
+ ```
216
+
217
+ For an in-flight commit (no version bump this run), replace step 3 with: "Tag and publish wait until the release-closing commit lands on main with a fresh `{release-package-json-path}` version. This commit does not change the version, so CI does nothing."
218
+
219
+ For **`gitlab-generic`** (a `.gitlab-ci.yml` exists but does not include the shared elestrago pipeline):
220
+
221
+ ```
222
+ Commit landed. Next steps are manual + CI:
223
+ 1. Push the working branch: git push -u origin <current-branch>
224
+ 2. GitLab CI is configured (.gitlab-ci.yml) but the publish flow is project-specific.
225
+ Check the pipeline definition for the release/publish trigger conditions
226
+ and follow the project's documented process.
227
+ 3. If publishing is gated on tags, the tag for this release is {version}.
228
+ This skill does not create tags — create it manually after merging.
229
+ ```
230
+
231
+ For **`github-actions`** (`.github/workflows/` contains at least one workflow):
232
+
233
+ ```
234
+ Commit landed. Next steps are manual + CI:
235
+ 1. Push the working branch: git push -u origin <current-branch>
236
+ 2. GitHub Actions workflows are configured ({github-workflow-files}). Check the
237
+ workflows for the release/publish trigger conditions (push to main, tag
238
+ creation, manual dispatch, etc.) and follow the project's documented process.
239
+ 3. If publishing is gated on tags, the tag for this release is {version}.
240
+ This skill does not create tags — create it manually after merging.
241
+ ```
242
+
243
+ For **`manual`** (no CI configuration detected):
244
+
245
+ ```
246
+ Commit landed. Next steps are manual:
247
+ 1. Push the working branch: git push -u origin <current-branch>
248
+ 2. Open a merge/pull request to the project's main branch and merge.
249
+ 3. Tag the release: git tag {version} && git push origin {version}
250
+ 4. Publish via the project's chosen mechanism (npm publish, openupm submit,
251
+ UPM Git URL, manual asset upload, etc.) — this skill does not know which
252
+ applies here. Ask the maintainer if uncertain.
253
+ ```
254
+
255
+ In every case, do not execute the printed commands — push and publish are the maintainer's call (they may want to amend other in-flight commits first, rebase, or hold for review).
256
+
257
+ ## Idempotency and safety
258
+
259
+ - The skill never overwrites files outside the Output contract allow-list (plus the `.cs` files the user explicitly lists as part of the release in Step 6).
260
+ - Re-running on a clean repo: surfaces no edits, no prompts, and exits cleanly.
261
+ - The changelog entry is prepended/appended once — re-runs with the same `summary=` do not duplicate; matching-bullet detection lives on the exact bullet text.
262
+ - Step 6 skips itself when the staging list is empty — re-running after a successful commit does not produce a second empty commit.
263
+ - `dry-run=true` prints the intended file edits, staging list, and commit message without writing anything, staging anything, or running git commands that mutate state.
264
+
265
+ ## Examples
266
+
267
+ ### Example 1 — straight handoff in a GitLab-elestrago-CI repo
268
+
269
+ The user just finished a `/unity-package-tool-developer` run that added a `Tools/PackageTools/Refresh All Configs` menu item to the `com.elestrago.unity.package-tools` repo. The developer skill's handoff asked whether to invoke `/unity-package-release`; the user picked yes.
270
+
271
+ `scripts/discover_package.py` reports:
272
+
273
+ - `config_asset.path: Assets/PackageManifest/PackageManifestConfig.asset` (line 27)
274
+ - `package.name: com.elestrago.unity.package-tools`, `package.version: 2.0.11`, `package.destination_path: Release`
275
+ - `changelog.resolved_path: CAHNGELOG.md` (the typo'd filename from this repo's config — used verbatim because the script read `changelogPath` from the asset)
276
+ - `changelog.latest_heading.tag_url_format: https://gitlab.com/elestrago-pkg/package-tool/-/tags/{version}`
277
+ - `readme.install_snippet.line: 32`, `readme.install_snippet.version: 2.0.11`
278
+ - `release_package_json.version: 2.0.11`
279
+ - `publish_flow.detected_flow: gitlab-elestrago-ci`
280
+
281
+ Step 2: user picks (b) **Start a new version (patch)**. New version `2.0.12`. Skill rewrites line 27 of the asset and line 32 of `README.md`.
282
+
283
+ Step 3: category `Added`, summary drafted from the diff. Skill prepends:
284
+
285
+ ```markdown
286
+ ## [2.0.12](https://gitlab.com/elestrago-pkg/package-tool/-/tags/2.0.12)
287
+
288
+ ### Added
289
+
290
+ - `Tools/PackageTools/Refresh All Configs` menu item that logs all discovered `PackageManifestConfig` assets.
291
+
292
+ ---
293
+ ```
294
+
295
+ (The URL was built by substituting `2.0.12` into `tag_url_format`.)
296
+
297
+ Step 4: trivial menu item, no semantics → user picks (c) **Skip**.
298
+
299
+ Step 5: user clicks **Export Package Source** in Unity, returns, picks (a) **Done**.
300
+
301
+ Step 6 stages: `git add Assets/Package/PackageTool/Editor/MenuItems.cs Assets/PackageManifest/PackageManifestConfig.asset CAHNGELOG.md README.md Assets/Package/PackageTool/CAHNGELOG.md Assets/Package/PackageTool/README.md Release/package.json`.
302
+
303
+ Step 7: `git commit -m "V-2.0.12 Add Refresh All Configs menu item; update version to 2.0.12."`
304
+
305
+ Step 8 prints the **gitlab-elestrago-ci** block with `git push -u origin develop` and the `Release/package.json` watch.
306
+
307
+ ### Example 2 — different consumer repo, `CHANGELOG.md` filename, GitHub Actions
308
+
309
+ Same maintainer, different repo (`com.example.unity.tween-helpers`). Discovery reports:
310
+
311
+ - `config_asset.path: Assets/Editor/Config/TweenPackageManifest.asset` (the config asset is named differently and lives elsewhere — the script finds it by signature)
312
+ - `package.name: com.example.unity.tween-helpers`, `package.version: 0.4.2`, `package.destination_path: Build/UPM`
313
+ - `changelog.resolved_path: CHANGELOG.md` (no typo here — the consumer uses the default `changelogPath`)
314
+ - `changelog.latest_heading.tag_url_format: https://github.com/example/unity-tween-helpers/releases/tag/v{version}` (extracted from the existing entry; uses GitHub releases, not GitLab tags)
315
+ - `readme.install_snippet.line: 18`
316
+ - `release_package_json.path: Build/UPM/package.json`
317
+ - `publish_flow.detected_flow: github-actions`, `publish_flow.github_workflow_files: [.github/workflows/upm-publish.yml]`
318
+
319
+ The maintainer ran `/unity-package-release fix tween easing overshoot for negative durations`. The skill detects `fix` in the prompt, suggests `category=Fixed`, and the user accepts.
320
+
321
+ Step 2: user picks (b) **Start a new version (patch)** → `0.4.3`. Skill writes the new version into the SO at `Assets/Editor/Config/TweenPackageManifest.asset` (the line number reported by the script) and into the consumer-install snippet at `README.md:18`.
322
+
323
+ Step 3: prepends the changelog entry, building the URL from the GitHub releases `tag_url_format`:
324
+
325
+ ```markdown
326
+ ## [0.4.3](https://github.com/example/unity-tween-helpers/releases/tag/v0.4.3)
327
+
328
+ ### Fixed
329
+
330
+ - Tween easing overshoot on negative-duration tweens.
331
+
332
+ ---
333
+ ```
334
+
335
+ Step 5: Export done.
336
+
337
+ Step 6 stages: `git add Assets/Editor/Config/TweenPackageManifest.asset CHANGELOG.md README.md Build/UPM/CHANGELOG.md Build/UPM/README.md Build/UPM/package.json Assets/Runtime/Tween/Easing.cs`.
338
+
339
+ Step 7: `git commit -m "V-0.4.3 Fix tween easing overshoot on negative-duration tweens."`
340
+
341
+ Step 8 prints the **github-actions** block:
342
+
343
+ ```
344
+ Commit landed. Next steps are manual + CI:
345
+ 1. Push the working branch: git push -u origin fix/tween-easing
346
+ 2. GitHub Actions workflows are configured (.github/workflows/upm-publish.yml). Check the
347
+ workflows for the release/publish trigger conditions (push to main, tag
348
+ creation, manual dispatch, etc.) and follow the project's documented process.
349
+ 3. If publishing is gated on tags, the tag for this release is 0.4.3.
350
+ This skill does not create tags — create it manually after merging.
351
+ ```
352
+
353
+ ### Example 3 — third repo, no CI, in-flight version
354
+
355
+ `com.example.unity.gizmo-tools` — small internal package, no `.gitlab-ci.yml`, no `.github/workflows/`. Discovery reports `publish_flow.detected_flow: manual`. Latest changelog heading is `## [1.2.0]` (no link) with two bullets, no matching `V-1.2.0` commit yet — release is mid-assembly.
356
+
357
+ User runs `/unity-package-release add gizmo color cycling helper`.
358
+
359
+ Step 1 surfaces the in-flight state and the missing tag-URL format (`changelog.latest_heading.tag_url_format: null`).
360
+
361
+ Step 2: user picks (a) **Fold into in-flight version (no bump)**.
362
+
363
+ Step 3 detects the null `tag_url_format` and asks once: "The existing changelog uses plain `## [version]` headings without a link target. Continue with no link, or paste a URL pattern?" User picks "no link". Skill appends under `### Added` in the existing entry.
364
+
365
+ Step 4: behavior change → user picks (b) **Defer**.
366
+
367
+ Step 5: Export done.
368
+
369
+ Step 6 stages: `git add Assets/Tools/GizmoConfig.asset CHANGELOG.md Packages/com.example.unity.gizmo-tools/CHANGELOG.md Packages/com.example.unity.gizmo-tools/package.json Assets/Runtime/Gizmos/ColorCycle.cs`.
370
+
371
+ Step 7: `git commit -m "V-1.2.0 Add gizmo color cycling helper."`
372
+
373
+ Step 8 prints the **manual** block — no CI, the maintainer tags and publishes by hand whenever they choose.
@@ -0,0 +1,366 @@
1
+ """Discover the PackageManifestConfig.asset in a Unity-package repo and report
2
+ the fields and files the release skill needs to drive its pipeline.
3
+
4
+ The skill must not hardcode paths or line numbers because it is shipped to
5
+ multiple repos that all use the same `com.elestrago.unity.package-tools` editor
6
+ tool but differ in their layout, package name, changelog filename, README
7
+ location, and publish flow. Everything repo-specific is read at runtime.
8
+
9
+ Run from the repo root (default) or pass `--repo <path>`:
10
+
11
+ python3 .claude/skills/unity-package-release/scripts/discover_package.py
12
+
13
+ Output is a single JSON object on stdout. Errors go to stderr; non-zero exit
14
+ codes mean the caller cannot proceed safely.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import re
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+
29
+ # A PackageManifestConfig asset is a Unity ScriptableObject with a recognisable
30
+ # set of fields. We detect by signature rather than by path, since the canonical
31
+ # location varies between repos (the package-tool repo keeps it under
32
+ # Assets/PackageManifest/, but consumers are free to put it anywhere under
33
+ # Assets/).
34
+ CONFIG_SIGNATURE_FIELDS = (
35
+ "packageName:",
36
+ "packageVersion:",
37
+ "packageDestinationPath:",
38
+ "changelogPath:",
39
+ )
40
+
41
+ # Top-level SO fields in Unity YAML are indented two spaces under `MonoBehaviour:`.
42
+ # Dependency entries are inside a `dependencies:` list and indent four spaces, so
43
+ # the rule "first line matching exactly `^ <field>: `" reliably picks the SO's
44
+ # own value and skips nested `packageVersion:` entries inside dependencies.
45
+ TOP_LEVEL_FIELD_RE = re.compile(r"^ (?P<field>[A-Za-z_][A-Za-z0-9_]*): ?(?P<value>.*)$")
46
+
47
+ # Inline-list `[a, b, c]` and `[]` show up for string-array fields. We don't
48
+ # parse them here — none of the fields the release skill cares about are arrays
49
+ # of primitives — but the regex above tolerates them by capturing the whole
50
+ # right-hand side as a string.
51
+
52
+
53
+ def find_config_asset(repo: Path) -> Path | None:
54
+ """Walk `<repo>/Assets/` for the first `.asset` file whose contents contain
55
+ all of the PackageManifestConfig signature fields. Returns None when no
56
+ candidate is found — the caller should refuse to proceed in that case."""
57
+ assets_dir = repo / "Assets"
58
+ if not assets_dir.is_dir():
59
+ return None
60
+ candidates: list[Path] = []
61
+ for path in assets_dir.rglob("*.asset"):
62
+ try:
63
+ head = path.read_text(encoding="utf-8", errors="replace")[:4096]
64
+ except OSError:
65
+ continue
66
+ if all(sig in head for sig in CONFIG_SIGNATURE_FIELDS):
67
+ candidates.append(path)
68
+ if not candidates:
69
+ return None
70
+ # If multiple configs exist (some repos package more than one), prefer the
71
+ # one nearest the repo root by path-segment count, then by name for
72
+ # determinism. Multi-package repos are out of scope for this skill — the
73
+ # user should pass --config-asset to disambiguate.
74
+ candidates.sort(key=lambda p: (len(p.relative_to(repo).parts), str(p)))
75
+ return candidates[0]
76
+
77
+
78
+ def parse_config_fields(asset_path: Path) -> dict[str, Any]:
79
+ """Extract the top-level scalar fields of a PackageManifestConfig asset.
80
+ Returns a dict mapping field-name → {"value": str, "line": int} for every
81
+ top-level field encountered, and a flat dict of values under "_values" for
82
+ convenience. Skips list-element fields under `dependencies:`, `samples:`,
83
+ `copyEntries:`, etc., because they sit at four-space indent."""
84
+ lines = asset_path.read_text(encoding="utf-8").splitlines()
85
+ fields: dict[str, dict[str, Any]] = {}
86
+ values: dict[str, str] = {}
87
+ for idx, raw in enumerate(lines, start=1):
88
+ m = TOP_LEVEL_FIELD_RE.match(raw)
89
+ if not m:
90
+ continue
91
+ name = m.group("field")
92
+ val = m.group("value").strip()
93
+ # Re-occurrences at top level shadow earlier ones; the SO YAML doesn't
94
+ # normally do that for scalars, but be defensive.
95
+ fields[name] = {"value": val, "line": idx}
96
+ values[name] = val
97
+ return {"_fields": fields, "_values": values}
98
+
99
+
100
+ def resolve_repo_relative(repo: Path, value: str) -> Path:
101
+ """Mirror the runtime path resolution that
102
+ `FileTools.CopyOrReplaceFileToDirectory` uses: `Path.GetFullPath(value)` is
103
+ resolved against the current working directory, which Unity sets to the
104
+ project root. So a relative path in the asset means "relative to repo
105
+ root"."""
106
+ p = Path(value)
107
+ if p.is_absolute():
108
+ return p
109
+ return (repo / p).resolve()
110
+
111
+
112
+ def read_last_changelog_heading(changelog_path: Path) -> dict[str, Any] | None:
113
+ """Read the first `## ` heading from the changelog and try to extract the
114
+ version and the URL it links to, so the skill can mirror the same format
115
+ when prepending a new entry. The skill should not assume any particular URL
116
+ host — it's free-form text per repo."""
117
+ if not changelog_path.is_file():
118
+ return None
119
+ heading_re = re.compile(r"^##\s+(.*)$")
120
+ version_in_heading_re = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
121
+ plain_version_re = re.compile(r"\[?([0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.+-]+)?)\]?")
122
+ try:
123
+ text = changelog_path.read_text(encoding="utf-8")
124
+ except OSError:
125
+ return None
126
+ for line in text.splitlines():
127
+ m = heading_re.match(line)
128
+ if not m:
129
+ continue
130
+ body = m.group(1).strip()
131
+ linked = version_in_heading_re.search(body)
132
+ if linked:
133
+ version = linked.group(1).strip()
134
+ url = linked.group(2).strip()
135
+ tag_url_format = url.replace(version, "{version}", 1) if version and version in url else None
136
+ return {
137
+ "heading": line.rstrip(),
138
+ "version": version,
139
+ "url": url,
140
+ "tag_url_format": tag_url_format,
141
+ }
142
+ # Heading without a markdown link — still useful for showing the user.
143
+ plain = plain_version_re.search(body)
144
+ return {
145
+ "heading": line.rstrip(),
146
+ "version": plain.group(1) if plain else None,
147
+ "url": None,
148
+ "tag_url_format": None,
149
+ }
150
+ return None
151
+
152
+
153
+ def find_readme_install_snippet(readme_path: Path, package_name: str) -> dict[str, Any] | None:
154
+ """Find the consumer-install snippet line in the project-root README — the
155
+ one that looks like `"com.example.foo": "X.Y.Z"`. Returns the line number,
156
+ the version string, and the raw line so the skill can do a targeted rewrite
157
+ without touching anything else in the README. Returns None when the README
158
+ has no such snippet (some repos document installation differently)."""
159
+ if not readme_path.is_file():
160
+ return None
161
+ pattern = re.compile(r'"' + re.escape(package_name) + r'"\s*:\s*"([^"]+)"')
162
+ try:
163
+ text = readme_path.read_text(encoding="utf-8")
164
+ except OSError:
165
+ return None
166
+ for idx, line in enumerate(text.splitlines(), start=1):
167
+ m = pattern.search(line)
168
+ if m:
169
+ return {"line": idx, "version": m.group(1), "raw": line}
170
+ return None
171
+
172
+
173
+ def detect_publish_flow(repo: Path) -> dict[str, Any]:
174
+ """Sniff out what publish flow this repo uses. The release skill prints a
175
+ different handoff block per flow, and the maintainer can override via the
176
+ `publish-flow=` skill arg. Detection is best-effort — when ambiguous, the
177
+ skill should ask the user rather than guessing."""
178
+ gitlab_ci = repo / ".gitlab-ci.yml"
179
+ github_workflows_dir = repo / ".github" / "workflows"
180
+ flow: dict[str, Any] = {
181
+ "gitlab_ci_present": gitlab_ci.is_file(),
182
+ "gitlab_ci_includes_elestrago_unity_npm": False,
183
+ "github_workflows_present": github_workflows_dir.is_dir() and any(
184
+ p.is_file() and p.suffix in {".yml", ".yaml"}
185
+ for p in github_workflows_dir.iterdir()
186
+ ),
187
+ "github_workflow_files": [],
188
+ "detected_flow": "manual",
189
+ }
190
+ if gitlab_ci.is_file():
191
+ try:
192
+ ci_text = gitlab_ci.read_text(encoding="utf-8")
193
+ except OSError:
194
+ ci_text = ""
195
+ # The shared pipeline include is the signal the skill's original CI
196
+ # handoff block was written for. Any other GitLab CI setup we treat as
197
+ # "gitlab-generic" so the skill can degrade gracefully.
198
+ if "unity-package-npm-publish" in ci_text or "elestrago/ci-pipelines" in ci_text:
199
+ flow["gitlab_ci_includes_elestrago_unity_npm"] = True
200
+ if flow["github_workflows_present"]:
201
+ flow["github_workflow_files"] = sorted(
202
+ str(p.relative_to(repo)) for p in github_workflows_dir.iterdir()
203
+ if p.is_file() and p.suffix in {".yml", ".yaml"}
204
+ )
205
+ # Prioritise: the elestrago-CI signal is the most specific, then any other
206
+ # GitLab CI, then GitHub Actions, then manual.
207
+ if flow["gitlab_ci_includes_elestrago_unity_npm"]:
208
+ flow["detected_flow"] = "gitlab-elestrago-ci"
209
+ elif flow["gitlab_ci_present"]:
210
+ flow["detected_flow"] = "gitlab-generic"
211
+ elif flow["github_workflows_present"]:
212
+ flow["detected_flow"] = "github-actions"
213
+ else:
214
+ flow["detected_flow"] = "manual"
215
+ return flow
216
+
217
+
218
+ def read_release_package_json(repo: Path, destination_path: str) -> dict[str, Any] | None:
219
+ """Read the `package.json` inside the exported destination if it exists.
220
+ Some CI flows (notably gitlab-elestrago-ci) trigger publishing on a version
221
+ delta of this file, so the skill needs to know whether it exists and what
222
+ version it currently advertises — so it can warn when the user is about to
223
+ skip the Export Package Source step and silently break the publish."""
224
+ if not destination_path:
225
+ return None
226
+ dest = resolve_repo_relative(repo, destination_path)
227
+ package_json = dest / "package.json"
228
+ if not package_json.is_file():
229
+ return {
230
+ "path": str(package_json.relative_to(repo)) if dest.is_relative_to(repo) else str(package_json),
231
+ "present": False,
232
+ "version": None,
233
+ }
234
+ try:
235
+ import json as _json
236
+ data = _json.loads(package_json.read_text(encoding="utf-8"))
237
+ version = data.get("version") if isinstance(data, dict) else None
238
+ except (OSError, ValueError):
239
+ version = None
240
+ return {
241
+ "path": str(package_json.relative_to(repo)) if dest.is_relative_to(repo) else str(package_json),
242
+ "present": True,
243
+ "version": version,
244
+ }
245
+
246
+
247
+ def read_git_state(repo: Path) -> dict[str, Any]:
248
+ """Best-effort git state. Failures (missing git, not a repo) reduce to
249
+ empty/null fields rather than aborting — the skill can still do its job
250
+ without git, just without the convenience signals."""
251
+ def _run(args: list[str]) -> str | None:
252
+ try:
253
+ out = subprocess.check_output(
254
+ args, cwd=str(repo), stderr=subprocess.DEVNULL, text=True
255
+ )
256
+ return out.strip()
257
+ except (subprocess.CalledProcessError, FileNotFoundError):
258
+ return None
259
+ return {
260
+ "branch": _run(["git", "rev-parse", "--abbrev-ref", "HEAD"]),
261
+ "remote_url": _run(["git", "remote", "get-url", "origin"]),
262
+ "recent_commits": _run(["git", "log", "--oneline", "-5"]),
263
+ "status_porcelain": _run(["git", "status", "--porcelain"]),
264
+ "diff_stat_head": _run(["git", "diff", "--stat", "HEAD"]),
265
+ }
266
+
267
+
268
+ def discover(repo: Path, override_config: Path | None = None) -> dict[str, Any]:
269
+ asset = override_config if override_config else find_config_asset(repo)
270
+ if asset is None or not asset.is_file():
271
+ return {
272
+ "ok": False,
273
+ "error": (
274
+ "No PackageManifestConfig.asset found under Assets/. This skill "
275
+ "requires the com.elestrago.unity.package-tools editor tool to be "
276
+ "installed in the project (it provides the ScriptableObject this "
277
+ "skill drives)."
278
+ ),
279
+ }
280
+ parsed = parse_config_fields(asset)
281
+ values = parsed["_values"]
282
+ fields = parsed["_fields"]
283
+
284
+ package_name = values.get("packageName", "")
285
+ package_version = values.get("packageVersion")
286
+ changelog_path_raw = values.get("changelogPath", "")
287
+ readme_path_raw = values.get("readmePath", "")
288
+ license_path_raw = values.get("licensePath", "")
289
+ documentation_path_raw = values.get("documentationPath", "")
290
+ destination_path_raw = values.get("packageDestinationPath", "")
291
+
292
+ changelog_resolved = resolve_repo_relative(repo, changelog_path_raw) if changelog_path_raw else None
293
+ readme_resolved = resolve_repo_relative(repo, readme_path_raw) if readme_path_raw else None
294
+
295
+ changelog_info = read_last_changelog_heading(changelog_resolved) if changelog_resolved else None
296
+ readme_snippet = (
297
+ find_readme_install_snippet(readme_resolved, package_name)
298
+ if readme_resolved and package_name else None
299
+ )
300
+
301
+ release_json_info = read_release_package_json(repo, destination_path_raw)
302
+ publish_flow = detect_publish_flow(repo)
303
+ git_state = read_git_state(repo)
304
+
305
+ return {
306
+ "ok": True,
307
+ "repo": str(repo),
308
+ "config_asset": {
309
+ "path": str(asset.relative_to(repo)) if asset.is_relative_to(repo) else str(asset),
310
+ "package_version_line": fields.get("packageVersion", {}).get("line"),
311
+ },
312
+ "package": {
313
+ "name": package_name,
314
+ "display_name": values.get("displayName"),
315
+ "version": package_version,
316
+ "unity_version": values.get("unityVersion"),
317
+ "documentation_path": documentation_path_raw or None,
318
+ "destination_path": destination_path_raw or None,
319
+ },
320
+ "changelog": {
321
+ "path_in_config": changelog_path_raw or None,
322
+ "resolved_path": str(changelog_resolved.relative_to(repo)) if changelog_resolved and changelog_resolved.is_relative_to(repo) else (str(changelog_resolved) if changelog_resolved else None),
323
+ "exists": bool(changelog_resolved and changelog_resolved.is_file()),
324
+ "latest_heading": changelog_info,
325
+ },
326
+ "readme": {
327
+ "path_in_config": readme_path_raw or None,
328
+ "resolved_path": str(readme_resolved.relative_to(repo)) if readme_resolved and readme_resolved.is_relative_to(repo) else (str(readme_resolved) if readme_resolved else None),
329
+ "exists": bool(readme_resolved and readme_resolved.is_file()),
330
+ "install_snippet": readme_snippet,
331
+ },
332
+ "license": {
333
+ "path_in_config": license_path_raw or None,
334
+ },
335
+ "release_package_json": release_json_info,
336
+ "publish_flow": publish_flow,
337
+ "git": git_state,
338
+ }
339
+
340
+
341
+ def main() -> int:
342
+ parser = argparse.ArgumentParser(description=__doc__)
343
+ parser.add_argument(
344
+ "--repo",
345
+ default=os.getcwd(),
346
+ help="Repository root (defaults to current working directory).",
347
+ )
348
+ parser.add_argument(
349
+ "--config-asset",
350
+ default=None,
351
+ help=(
352
+ "Explicit path to PackageManifestConfig.asset. Use to disambiguate "
353
+ "in repos that ship multiple configs."
354
+ ),
355
+ )
356
+ args = parser.parse_args()
357
+ repo = Path(args.repo).resolve()
358
+ override = Path(args.config_asset).resolve() if args.config_asset else None
359
+ result = discover(repo, override)
360
+ json.dump(result, sys.stdout, indent=2)
361
+ sys.stdout.write("\n")
362
+ return 0 if result.get("ok") else 2
363
+
364
+
365
+ if __name__ == "__main__":
366
+ sys.exit(main())
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "com.elestrago.unity.package-tools",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "displayName": "Package Tool",
5
5
  "description": "Tool for create unity packages",
6
6
  "category": "unity",
7
7
  "unity": "2021.3",
8
8
  "homepage": "https://gitlab.com/elestrago-pkg/package-tool",
9
- "documentationUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.3.0/README.md",
10
- "changelogUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.3.0/CHANGELOG.md",
11
- "licensesUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.3.0/LICENSE",
9
+ "documentationUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.4.0/README.md",
10
+ "changelogUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.4.0/CHANGELOG.md",
11
+ "licensesUrl": "https://gitlab.com/elestrago-pkg/package-tool/-/blob/2.4.0/LICENSE",
12
12
  "license": "MIT",
13
13
  "keywords": [
14
14
  "unity",