Skip to content

epic-129 S1: release path to ADR-9 (--ci, --preview, release on merge, tests gate the publish, ARM CI) - #3

Merged
rafalmaciag merged 6 commits into
masterfrom
feature/epic-129-s1-release-path
Oct 24, 2026
Merged

rafalmaciag merged 6 commits into
masterfrom
feature/epic-129-s1-release-path

Conversation

@rafalmaciag

@rafalmaciag rafalmaciag commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Brings Drawing's release path to ADR-9. Spec: docs/epics/epic-129-matrix/alignment-review.md §3 (the eight-row table).

Nothing is published by this PR. It carries the label release:none, so the new release.yml skips on merge: no tag, no package.

# S1 fix Status
1 release.sh --ci: no prompts; refuses a dirty tree (exit 1) done
2 release.sh --preview: tags ModelingEvolution.Drawing/{next}-preview.{N}, from any branch; the existing tag trigger of publish-nuget.yml publishes it done
3 get_current_version excludes -preview tags done
4 release.yml runs on push: master (keeps workflow_dispatch, with its explicit bump). The bump is the highest release:major|minor|patch|none label over every PR merged in first-parent lastReleaseTag..HEAD (an unlabelled PR counts as minor; all none = skip; no merged PR for any of those commits = the job fails). It runs the suite, then ./release.sh --<bump> --ci done
5 publish-nuget.yml gets on: workflow_call (input version); release.yml calls it in the same run with secrets: inherit. Version order: call input → dispatch input → tag. Tag trigger kept. A manual dispatch only re-publishes an existing release tag and builds that tag's commit. The GitHub Release is created after the publish done
6 publish-nuget.yml: `
7 --major keeps build+1: from 1.13.0.71, --major gives 2.0.0.72 done
8 ci.yml: ubuntu-24.04-arm job (10.0.x); paths include release.sh, release.test.sh and all three workflow files done

Also:

  • release.sh is parameterised (PACKAGE, INITIAL_VERSION at the top) so the matrix repo copies it unchanged. Flags: --patch (default) | --minor | --major | --version X.Y.Z.B, plus --preview, --ci, --dry-run. A non-preview release runs on master only. Inside Actions it writes version, tag, previous to $GITHUB_OUTPUT.
  • release.test.sh (plain bash) runs as the release-script job in ci.yml: 32 assertions, including a real tag+push into a throw-away bare origin.
  • Previews: AssemblyVersion/FileVersion get the numeric part (2.0.0.72), Version/PackageVersion the full 2.0.0.72-preview.1.
  • The workflow_dispatch path of release.yml keeps its bump choice (patch/minor/major/custom) and goes through release.sh as well; the duplicate version arithmetic in the workflow is gone.
  • release.ps1 is deleted: it was a second release path with the old defects (it counted -preview tags, had no branch check, and pushed HEAD).
  • release.sh fetches tags from origin before computing the version, counts untracked files as dirty, rejects an empty --version, refuses to tag (previews included) when the newest release tag is not an ancestor of HEAD, and removes the local tag when its push fails.
  • release.yml runs one at a time (concurrency: release-master, no cancel-in-progress). GitHub may replace a pending run with a newer one, but the newer run's bump covers the whole range since the last release, so nothing is lost. release.sh refuses to tag a commit whose history lacks the newest release, so an older merge can never get a higher version. Recovery: re-run a failed run only while its commit is still master's tip; otherwise the next merge carries the bump.
  • BezierFitTests, CorrectionTests and DensifyTests now write their SVG galleries to <temp>/ModelingEvolution.Drawing.Tests/<gallery> instead of the tracked bezier-fit-svg/, correction-svg/ and densify-svg/ folders (the committed galleries stay as a one-time snapshot). A test run used to rewrite 35 tracked files, so release.yml's tag job, which runs the suite before release.sh --ci, would have found a dirty tree on every release.

Verification (local, mcr.microsoft.com/dotnet/sdk:10.0):

  • Sources test suite, master before this change: 1633 passed, 0 failed, 0 skipped.
  • Sources test suite, this branch, with the publish workflow's version injection for 2.0.0.72-preview.1: 1633 passed, and again in a fresh clone as release.yml's tag job runs it: 1633 passed, 0 failed, 0 skipped; dotnet pack produced ModelingEvolution.Drawing.2.0.0.72-preview.1.nupkg.
  • Clean-tree proof, fresh clone of 2283cf7 after restore, build and test (Release): git status --porcelain printed nothing (0 lines).
  • ./release.test.sh: 32 passed, 0 failed. bash -n and shellcheck clean; actionlint clean on release.yml.

🤖 Generated with Claude Code

- release.sh: --ci (no prompts, refuses a dirty tree), --preview
  ({next}-preview.{N}, any branch), --dry-run; current version ignores
  -preview tags; --major keeps build+1 (1.13.0.71 -> 2.0.0.72); a release
  runs on master only; writes version/tag/previous to $GITHUB_OUTPUT;
  parameterised by PACKAGE and INITIAL_VERSION so matrix copies it unchanged.
- release.test.sh: plain-bash tests in a throw-away repo.
- release.yml: on push master, bump from the merged PR's release:* label
  (default minor, release:none skips), runs release.sh --<bump> --ci, then
  calls publish-nuget.yml in the same run.
- publish-nuget.yml: workflow_call trigger with a version input; tests gate
  the push (no more || true); preview suffix stripped for Assembly/FileVersion.
- ci.yml: ubuntu-24.04-arm job, release-script job, paths cover the release
  files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rafalmaciag rafalmaciag added the release:none release.yml bump on merge: none label Oct 24, 2026

@rafalmaciag rafalmaciag left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of aff4e77 (concrete defects only; release.test.sh passes locally 22/22, CI green on x64 + arm64).

1. release.yml:67-83: when the PR lookup finds nothing, the job publishes a minor.
If repos/$REPO/commits/$SHA/pulls returns [] (the endpoint can lag right after a merge, or the push to master was a local git merge and push with no PR), LABELS is empty, COUNT=0, and BUMP=minor. A merge labelled release:none, this PR included, then tags and pushes 1.14.0.72 to nuget.org. That cannot be undone (nuget.org can only unlist). The fallback should apply only when a PR was found and has no label. "No PR found" should retry, then fail or skip.

2. release.yml:30-32: the concurrency group can cancel a merge's release without anyone noticing.
With cancel-in-progress: false, GitHub still keeps only one pending run per group. A third run queued while one is running cancels the pending one. Example: merge A is publishing, merge B (release:major, the ADR-11 merge) is pending, merge C (no label) arrives. B is cancelled, and C releases --minor as 1.14.0.72, so the breaking change ships under 1.x.

3. release.yml:94-126: the tag and the GitHub Release are created before any test runs.
release.sh pushes the tag and ncipollo/release-action publishes the GitHub Release in the tag job. Tests run later, in publish. With a red suite, NuGet gets nothing (correct), but the public GitHub Release "v1.14.0.72" and its tag exist. The next merge then bumps past it, so a released version has no package. The GitHub Release should be created after publish succeeds. release.yml also does not wait for ci.yml, so a failure only on arm64 or 9.0.x does not stop the publish.

4. release.yml:101-102 + release.sh:110-112,155: custom with an empty custom_version makes a silent patch release.
./release.sh --version "" --ci sets custom_version="". The -n check skips it, so the default patch bump runs. Verified: --version '' produces …/1.0.1.1 with exit 0.

5. release.ps1 (unchanged, still in the tree) is a second release path that skips fixes 3 and master-only.
It takes the highest tag including -preview tags, so the first preview breaks its arithmetic. It has no branch check, edits the csproj, and runs git push origin HEAD followed by the tag push, which triggers publish-nuget.yml. It should be deleted, so that release.sh is the only implementation.

6. release.sh:42-81: tags are never fetched before the version is computed.
On a laptop with stale local tags, --preview computes {next} from an old release. Example: local tags stop at 1.12.0.70 while origin has 1.13.0.71, so the preview is 1.13.0.71-preview.1, a preview of an already-released version. Two leads can also both compute -preview.N: the second push is rejected and leaves a local tag behind, so every retry fails with "Tag already exists". A git fetch --tags --force origin before get_current_version fixes this. CI is unaffected (fetch-depth: 0).

7. publish-nuget.yml:14-19: workflow_dispatch publishes any version from any branch to nuget.org.
This path existed before, but the PR keeps it beside the new workflow_call. Feature-branch code can ship as a release version, for example 1.14.0.72 with no tag, which is what ADR-9's "master pins releases only" forbids. Restrict it to master or remove it.

Minor:

  • release.sh:138-139: git diff-index ignores untracked files, so --ci accepts a tree with untracked files.
  • release.sh:181-191: an interactive (non---ci, non-preview) release from a laptop on master is still allowed. Its tag push triggers publish-nuget.yml on the tag, which bypasses the label flow and races release.yml for the same version number.

Rafal Maciag and others added 2 commits October 24, 2026 12:30
- release.yml: no merged PR for the pushed commit -> retry 3 times (10 s),
  then fail; minor is the default only for a found PR without a label.
- release.yml: concurrency group removed (it can cancel a pending release);
  the reasoning is in the header comment.
- release.yml: the suite (restore, build, test Release) runs before
  release.sh, in a separate worktree because the tests rewrite tracked SVG
  galleries; the GitHub Release moves to a github-release job after publish.
- release.yml: custom bump with an empty custom_version fails.
- publish-nuget.yml: workflow_dispatch only re-publishes an existing release
  tag and builds that tag's commit.
- release.sh: empty or malformed --version exits 1; tags are fetched from
  origin first (a failed fetch is a warning under --dry-run alone, exit 1
  otherwise); untracked files make the tree dirty; a failed tag push removes
  the local tag.
- release.test.sh: cases for the above; every invocation reads /dev/null.
- release.ps1 deleted: a second release path with the old defects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BezierFitTests, CorrectionTests and DensifyTests wrote their galleries into
the tracked bezier-fit-svg/, correction-svg/ and densify-svg/ folders, so a
test run left a dirty tree (CRLF -> LF on Linux, and platform-dependent
numbers). They now write to <temp>/ModelingEvolution.Drawing.Tests/<gallery>;
the committed galleries stay as a one-time snapshot.

release.yml: the tag job runs the suite in its own checkout again (the
worktree workaround is removed); release.sh --ci finds a clean tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rafalmaciag

Copy link
Copy Markdown
Contributor Author

Re-check of 33f9756 + 2283cf7. release.test.sh: 29/29 pass in a scratch copy, and nothing is left behind.

# Finding Status
1 No PR found → minor Closed. 4 tries, 10 s apart, then the job fails. The loop is safe under bash -e (&& break and the if do not trip -e).
2 Concurrency cancels a pending release Not closed, the problem changed shape (see below).
3 Tag and GitHub Release before tests Closed for ordering: test → tag → publish → GitHub Release, and the release job only runs if publish succeeded. Still open: arm64 and 9.0.x CI do not gate the release (the tag job tests x64/10.0 only). Minor.
4 Empty --version → patch Closed in both places (release.yml dispatch check, release.sh version_given).
5 release.ps1 Closed (deleted, no references left).
6 Stale tags Closed. Tags are fetched first and a failed push removes the local tag. Minor: git fetch --tags without --force fails with "Could not fetch tags from origin" when a local tag differs from origin's tag of the same name. It fails safe, but the message is misleading.
7 Dispatch publishes any version Closed. A manual run only re-publishes an existing tag and builds the tag's commit. Under a dispatched release.yml, github.event_name is workflow_dispatch, so the tag checkout runs on the commit it was just tagged on, which is fine.
8 Untracked files Closed (git status --porcelain, with a test).
9 Interactive laptop release on master Open (owner question, unchanged).
10 Tests read stdin Closed (< /dev/null everywhere).

New: without serialisation, an older commit can be released after a newer one.
Two merges, A then B (B contains A), start two runs whose tag jobs now build and test for a minute or more before they tag. If B's tests finish first, B tags V1. A's release.sh then fetches V1 and tags V2 on commit A, which lacks B's changes, and publishes it as the highest version.

  • Worse case: A is release:major (the ADR-11 merge) and B has no label. B ships A's breaking change as 1.14.0.72 under 1.x, the same outcome finding 2 was about.
  • The recovery documented in release.yml ("the lead re-runs the loser") produces the same inversion when the loser is A.

A fix that covers both 2 and this:

  • Restore the concurrency group.
  • Make the bump the highest release:* label over every PR merged in lastReleaseTag..HEAD, so a cancelled pending run loses nothing.
  • In release.sh, refuse to tag when the newest release tag is not an ancestor of HEAD.

Minor: the SVG fix moved three galleries to the temp folder. The other writers are fine: skeleton-svg/ and skeleton-performance-results.md are in .gitignore, and TorchRotationTests writes to a relative file name on Linux, which lands in bin/. Nothing in ci.yml asserts a clean tree after dotnet test, so a future test that writes into the repo would only show up as a failed release on merge.

Rafal Maciag and others added 2 commits October 24, 2026 12:39
…mmit

- release.yml: concurrency group release-master restored. The bump is the
  highest release:* label (major > minor > patch > none) over every PR merged
  in first-parent lastReleaseTag..HEAD, so a cancelled pending run loses
  nothing. An unlabelled PR counts as minor, all release:none skips, no PR for
  any commit fails. HEAD's lookup is retried (API lag after a merge).
  Recovery text: re-run only while the commit is master's tip.
- release.sh: refuses to tag (previews included) when the newest release tag
  is not an ancestor of HEAD; skipped when there is no release tag yet.
  Fetch-failure message names both causes.
- release.test.sh: ancestor cases (refused off-history, accepted after merge).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both build-and-test jobs (x64 matrix and arm64) check git status --porcelain
after the Test step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rafalmaciag

Copy link
Copy Markdown
Contributor Author

Final pass on 3ea6523 + e7a3d40. release.test.sh: 32/32 pass in a scratch copy. CI is green on e7a3d40. I ran the bump step from release.yml under bash -eo pipefail against throw-away repos, with a stub gh and sleep.

Item Status
Race: merge A then B, B ⊃ A Closed. The concurrency group serialises the runs. If B somehow tags first, A's release.sh refuses, because the newest release is not an ancestor of HEAD (covered by tests, previews included).
Cancelled pending run Closed. Simulated: tag on A, B release:major, C unlabelled → the bump is major.
Label aggregation under set -e Closed. Every path exits as intended: all none → skip; no PR anywhere → fails after 4 tries; a re-run on an already-tagged commit fails with "No commits since the last release tag", so nothing is released twice.
First-parent range, last tag not on the first-parent line Holds. The newest Drawing tag (1.13.0.71) sits on master's first-parent line, and from now on release.sh only tags master HEAD. A tag on a side branch is also handled: everything reachable from the tag is excluded, so unreleased master commits are still counted.
Matrix first release (no release tag, a preview tag exists) Works. Simulated: the range is the whole history, commits pushed directly are skipped as "no merged PR", the PR release:patch → patch. release.sh skips the ancestor check (previews are ignored) and gives 0.0.1.1. Note: release.yml hard-codes ModelingEvolution.Drawing/* in the range step, so Matrix's copy must change that line too.
Tests writing into the repo (CI clean-tree step) Closed. The step runs in both build jobs.
arm64 / 9.0.x do not gate the release Open, minor (unchanged).
Interactive laptop release on master Open, owner question (unchanged).

New, should-fix: when HEAD's PR is not found, its label is ignored.
The code fails only when no commit in the range has a PR. If HEAD's PR association still lags after the 4 tries, HEAD is skipped and older PRs in the range decide the bump. Simulated: an unlabelled PR whose earlier release failed is still in the range, and HEAD is a release:major PR → bump minor, so a breaking change ships under 1.x. With an older release:none PR in the range, the run is skipped and the major waits for the next merge. Fix: fail when HEAD has no PR after the retries, whatever else is in the range.

…he range never decides HEAD's bump

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@rafalmaciag
rafalmaciag merged commit 2a8f900 into master Oct 24, 2026
4 checks passed
@rafalmaciag
rafalmaciag deleted the feature/epic-129-s1-release-path branch October 24, 2026 16:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:none release.yml bump on merge: none

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant