* Drawing sheets: sizes, the ISO 5457 border and its marks (#3875) (#5108) * Drawing sheets: sizes and the ISO 5457 border (#3875) A sheet is a paper size and an orientation: ISO A0 to A5, Letter, Legal, ANSI A to F, architectural A to E1, or a custom size (finite, above 0 and at most 5,000 mm a side), landscape by default and either way for every size. A border profile is the rule for its border, and layout() works it out for a sheet: the frame inside the margins, and the reference fields with their labels. The ISO profile has a 20 mm filing margin on the left and 10 mm elsewhere, a 0.7 mm frame, fields of 50 mm out from the centre lines with the corner fields taking what's left, rows lettered from the top without I and O (AA, AB and on past Z), columns numbered from the left, and an A4 sheet labelled on its top and right only. A profile can divide the frame equally instead, or have no grid. The records sit in geom, next to the section records, so saving, rendering and export can all use them. Nothing is saved or drawn yet: there is no command and no format change. Margins that leave no drawing space, values that aren't finite, fields outside 1 to 1000 mm, more than 200 equal fields and unknown JSON fields are refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drawing sheets: ISO 5457's centring and trimming marks (#3875) A drawing border profile now also holds the grid reference border and the centring and trimming marks, either of which can be left out. The ISO profile follows ISO 5457:1999 (read in its identical adoption, IS 10711:2001): - the grid reference border 5 mm outside the drawing border on every side, with the field labels between the two; - four centring marks on the sheet's centre lines, from the grid reference border to 10 mm inside the drawing border; - at each corner a trimming mark of two overlapping 10 x 5 mm rectangles, in the margin outside the grid reference border. The labelled sides are now a rule worked out for each sheet: ISO 5457's (the top and right of A4, all four sides of the others) or fixed sides. So one profile labels every size correctly. validate() checks what doesn't depend on the sheet, for when a profile is read; layout() calls it, then checks the rest against the sheet. Refused: - a grid reference border that isn't finite, is negative or is wider than a margin; - a centring mark that isn't above 0, would reach past the sheet's centre lines, or is on a drawing border those lines miss; - a trimming mark that isn't above 0, is wider than it is long, is wider than the margin left outside the grid reference border, or is longer than half the sheet's shorter side. The grid rule's JSON is now externally tagged ("none", {"from_centre": {"field_mm": 50}}), as the sheet size is, so an unknown field is refused in every form; the internally tagged "none" ignored one. Nothing is saved yet, so the shape change costs nothing. The names follow the glossary, and ISO 5457's "drawing sheets": the module is drawing_sheet (was sheet, which means sheet metal elsewhere here), DrawingBorderProfile and SheetLayout (were BorderProfile and BorderLayout), sheet_border and drawing_border (were paper and frame), and Sheet::size_mm (was paper_mm). The first part of S1 is still a draft, so the renames are made here rather than in a commit of their own. The extra centring marks ISO 5457 asks for on sheets larger than A0 aren't here yet. Still nothing is saved or drawn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drawing sheets: a lighter built-in border, the filing margin a choice (#3875) Cameron's decisions after seeing the sheet in the app, 2026-10-23: - No trimming marks in the built-in profile: the filled corner blocks take a lot of ink. trimming_mark_mm stays, so a profile can still ask for them. - Centring marks stop at the drawing border: from the grid reference border to the drawing border, never into the drawing area (on A4 they crossed the tables). centring_mark_mm keeps its meaning, how far a mark reaches inside the drawing border, and now takes 0, the built-in value, as grid_border_mm already takes 0 for "on the drawing border". A mark of no length (a reach of 0 with no grid reference border) is refused. - Margins of 10 mm all round. ISO 5457's 20 mm filing margin on the left, for drawings that get bound, is a named choice: filing_margin, off when not given. On, it stands in for margins.left_mm, which is kept, and still checked, for when it's off again. The 20 mm is FILING_MARGIN_MM, and validate() and layout() both take the margins from one helper, laid_out_margins(). A3 landscape is now 400 x 277 at (10, 10), with eight 50 mm columns. With the filing margin it is 390 x 277 at (20, 10) again, the same as a 20 mm left margin set by hand. A profile with all three choices on (ISO 5457's full border) lays out exactly as the built-in profile did before, and the tests keep the old hand values for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Drawing W23a workshop plate oracle fixtures (#4950) (#4962) * test(oracle): reviewer-aligned parameterized workshop plate fixtures (#4950) Independent analytic W23 expected values and seven verified models, including four R10 corners and the 2 mm tangent top chamfer. Document accepted Drawing states and commit regenerated docs/oracle.md from successful workflow run #29 (90/90 pass). * fix(oracle): correct W23 chamfer centroid and clarify drawing session ownership * Paper scene: plain records of what is drawn on a sheet (#4890 A1) (#5513) geom::paper holds what a sheet shows as plain data: groups of strokes and fills, each group tagged with the object that drew it (an opaque id) and its kind, with one validation pass over a bounded record count. Seg2's checks and the coordinate rule move into profile.rs so section and paper share them, and drawing_sheet takes its side and line limits from paper, so each limit is written once. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Drawing sheets: centring marks across the whole margin (#3875) (#5554) The built-in sheet's centring marks now run from the trimmed sheet's edge to the drawing border. From the grid reference border, as before, a mark was the same length as a grid field mark, and on A3 it sat on one (the sheet's centre falls on a field edge), so it no longer read as a mark once the grid reference border's outline is left out (Cameron, after the prototype on #3875). - DrawingBorderProfile.centring_mark_start: sheet_edge (the built-in's, and when not given) or grid_border (ISO 5457's figure 5); ISO's full border in the tests asks for grid_border. - A mark still needs a length: one that stops on the drawing border needs room outside it on the side it starts from, every margin for sheet_edge, the grid reference border for grid_border. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Drawing sheets: no centring or trimming mark longer than the largest sheet's side (#3875) (#6410) A border profile capped every length at 5000 mm (the largest sheet's side) but two: a centring mark's reach inside the drawing border and a trimming mark's length. A file could give either a huge whole number, such as 18446744073709551615. The read's number check passes whole numbers, so the profile took it as 1.8e19 mm, and saving wrote it as a decimal the next read refused: a drawing that saved but didn't open again. K2's fuzz test of drawing files (#3890) found it. Both now take the cap the margins have, `MAX_SIDE_MM`, in `validate`, so the file is refused when it's read. Layout's own checks against the sheet are unchanged, so a 5000 mm mark still validates and is refused only on a sheet too small for it. Tests: the hostile-profile test refuses 5000.000001 mm and 1.8e19 mm for each mark, and takes 5000 mm on its own; 4 planted faults (each cap removed or made one short) are all caught. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Bill of materials types: phantom, reference, purchased and inseparable (#3874) (#5107) * Bill of materials types: phantom, reference, purchased and inseparable (#3874) A component's bill of materials type is a part property (`bom_type` in its metadata, set with component.metadata.set and read by component.metadata, as part_number is), so older releases keep it and no format change is needed. It isn't a design property: the Design Properties dialog doesn't list it and document.metadata.set refuses it. A word this build doesn't know reads as normal and is kept. assembly.bom counts by type: a phantom has no row and what it holds counts where it is placed, in the same rows as the same parts there; a reference counts nowhere, and nothing inside it does; purchased and inseparable components are one row, and in parts only an inseparable component's purchased parts are listed too. Rows of one part number but different types are different rows. Rows and children carry bom_type. With no types set the rows are as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Glossary: bill of materials and bill of materials type (#3874) The bill of materials types are a new idea, so the glossary gets their term, with the five types, and "normal" gets its fourth meaning in the table of words with more than one meaning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bill of materials types: a plain line for each (#3874) The guide's table and the glossary entry say what each type is for in plain words: something you make, something just to group, something to use as a reference, something you buy, something made as one piece. The names stay the five standard ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Bill of materials: kits fold together only when their contents' types match (#3874) With group_children, two kits with one part number share a row when what they hold reads the same. That reading (kit_key) compared each child by part number and count, but not by its bill of materials type, though rows themselves are told apart by type. So a kit holding a made bolt and a kit holding a bought one, both KIT, folded into one KIT x2 row listing the first kit's bolt twice: the other type was lost, and which one depended on creation order. 0xlillith found it in review of #5107. kit_key now includes each child's type. Equal part number, type and contents still fold. Test: two KIT kits each holding a BOLT fold into one row of two; once one bolt is purchased they are two rows, each listing its own bolt. Planted fault (the type left out of the key): caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Drawing files: a drawing saved as a container or as plain JSON, never read as a design (#3890) (#7098) * File format families: drawings share the design format's versioning (#3890) The format code works for a family of files: its own tag, its own numbers and its own folder of change files, with one reading of a file's format, one refusal of a file a newer build saved and one upgrade for every family. Designs (solidcraft/N) are one family and keep everything they had: FORMAT and FORMATS are as they were, and pending_names, format_string, version_of and read mean what they did. Drawings (solidcraft-drawing/N) are the other, with format 1 as their first record and each later change one file in crates/doc/src/drawing/format/pending/, as design changes are. Nothing saved changes. A design tag never reads as a drawing's or the other way round, and a file that names no format is a design from before format strings, never a drawing. Every refusal names its family; a design's read word for word as before. A design listing a pending change this build doesn't have is now refused as a newer build's before its content is checked, as a newer number already was, so a newer build's fields never read as damage. The list reader is take_names(v, key, noun), the shape #4811 uses for its own list. For the maintainer, the release step changes: scripts/fold-format-pending.sh now folds every family that crates/doc/build.rs lists (FAMILIES), each into its own next number: designs into crates/doc/src/format/v<N>/ as before, drawings into crates/doc/src/drawing/format/v<N>/ from 2. With nothing pending in a family it says so and moves nothing. Its test folds a drawing change into v2 beside the design changes. docs/releasing.md, CONTRIBUTING.md and AGENTS.md say so. build.rs lists each family's changes from its own folder, each folder's versions contiguous from its own base. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drawing record: format 1 of the drawing family (#3890) A drawing is now a record of its own (crates/doc/src/drawing.rs): its name, the designs it draws (sources: an id and the design file's path, relative to the drawing's folder as a linked design's is), its properties under the design metadata's rules, its units (each design's own, or one length unit of the expression unit table), its content language (a BCP 47 tag) and its sheets. A sheet holds its paper, a copy of its drawing border's profile, the resolution its pictures are rendered at (300 dpi unless it says, 25 to 2400), the component it documents and whether it is left out of the sheet count or of printing. This is format 1 of the drawing family; each later kind of record adds one file in crates/doc/src/drawing/format/pending/. The rules: one id counter for every object, raised above the largest id when a file is read and never rewound; every reference is an id that must resolve, or the file is damaged and refused, never patched; 1 to 1,000 sheets, at most 64 sources, names of 1 to 256 bytes, paths of 1 to 4,096 bytes without NUL. The same drawing always saves as the same bytes. Drawing::from_json reads at most 16 MiB of text, then runs the family's format check, the number check, the upgrade, the typed record (unknown fields refused everywhere) and Drawing::validate. The number check is now one function every family calls, Family::check_numbers, told which field holds other tools' data; a design's refusals read as before. Nothing saves or opens a drawing yet: the container is the next step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drawing ids: random and collision-free from format 1 (#3890) A drawing's objects now take random ids, the form jyviko chose on #3890 from #1017: no counter, so objects added on two branches never share an id and never edit one line. The allocator is one module for drawings now and designs later (#626 Phase 3), crates/doc/src/ids.rs: an IdSource gives nonzero u64s by splitmix64 from a seed, each checked against the ids in use and drawn again when it is one, or when it is 0. A run never draws a number twice, so at most one draw per id in use comes before a free one, and a seeded run over a file the same seed made still gets a new id. IdSource::seeded gives the same ids for tests and replays; IdSource::from_entropy seeds a session from the standard library's hash keys, the time and a count, with no new dependency. In a file an id is 13 characters of Crockford base32, lowercase (0000000000001 to fzzzzzzzzzzzz): ids::encode writes it and ids::decode reads only that spelling, case-sensitive, so an id has one form to search for. Anything else, 0 included, makes the drawing damaged. The drawing loses next_id (a file that holds one is refused), and Drawing::new and allocate_id take the IdSource. Messages name ids as the file writes them. The component a sheet documents stays its design's own number until designs move over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drawing record: a drawing too large to reopen isn't saved (#3890) from_json reads at most MAX_DRAWING_BYTES (16 MiB), but to_json had no limit. Other tools' properties are kept as they came, and indenting them can grow a drawing read in at about 4 MB past 16 MiB (two million zeros in one array), so the next read refused what the save had just written. 0xlillith found it in review of #3891. to_json now returns a Result and refuses JSON longer than a read takes ("drawing too large to save (at most 16 MiB)"), so every drawing saved opens again. Nothing writes drawing files yet, so the error reaches the future file writer before it publishes anything. Test: that case is refused, and a smaller compact drawing saves and reopens unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drawing files: a drawing saved as a container or as plain JSON, never read as a design (#3890) K3 of the drawing storage plan (W11.2): the file a drawing is saved in. Nothing writes one from a command yet; opening and saving come with K5. - A drawing container is a design container holding `drawing.json` (Deflate, as `document.json`) in place of `document.json`, and `container.json` says so: {"content": "drawing", "format": "solidcraft-container/1"}. A design's `container.json` is as it was, with no `content`. The plain JSON form is the drawing's own text. Both give the same bytes every save, so a drawing can live in version control as text. - `io::file_kind` tells a design from a drawing by `container.json` or by the JSON's format. The design reader refuses a drawing ("this file is a SolidCraft drawing, not a design: open it as a drawing") and the drawing reader a design; plain JSON only asks when it isn't a design, so reading a design costs nothing more. A container's content this build doesn't know, or a drawing container that also holds a design's `document.json`, is refused. - `read_drawing` (bytes, either form), `read_drawing_file` (one seekable handle, the design reader's checks) and `write_drawing_container`; `DRAWING_EXT` (`scdwg`) and `is_drawing_path` beside the design's. - The container writer's set-up and finish, the file opening and the container sniffing move into one function each, which designs and drawings both use. The container's own checks (budgets, entries, the ZIP directory) are the design container's, word for word. Tests: a drawing round-trips through both forms byte for byte, and the container holds exactly `container.json` and `drawing.json`; `file_kind` on both forms of both kinds; each reader refuses the other's file; unknown content, a stray `document.json`, a missing drawing, an unknown container version and a damaged drawing refused; both forms from disk, and a directory refused; mutation fuzz of both forms (an error or a drawing, never a panic). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Paper scene: SVG of a sheet, its lines, arcs, cubics and fills (#4890 A3a) (#7032) The first export of a paper scene, as SVG first (jyviko and 0xlillith on #4890), from the research in the plan's §16. A3b adds ellipses, hyperbolas and Béziers above degree 3, which this refuses as "not supported yet". - `geom::paper::Prepared`: writers read a scene through `Scene::prepare`, which checks it first, so none reads one unchecked. Clipping goes there too, once (A8). - `geom::paper::arc_pieces`: an arc's ends in pieces of at most a quarter turn, the last its own end. A half turn drawn from its ends is ill-conditioned: rounded to the nanometre (six decimals of a millimetre) its centre moves up to 1e-2 mm, where a quarter turn's stays under 1e-6 mm. - `io::paper::paper_svg`: one user unit a millimetre, the size in mm on the root; y flipped by writing the height less y, so the file's numbers are page coordinates and an anticlockwise arc has sweep flag 0; lines `L`, arcs `A` (never large-arc), cubics `C`, parabolas `Q`; fills black and even-odd; one path per item; groups as `data-object` and `data-kind` (object ids can't be XML ids). No background, so cutters see only the marks. - Numbers are whole nanometres (six decimals of a millimetre): no exponent, no "-0", the same bytes every time. They are the writer's own state too, so a joint is written once and a closed path ends exactly on its first point. Numbers are written straight onto the document, with no text made per number. A width under half a nanometre is written as one, and a piece too short for the grid is merged into the next, both counted in the report; a path's last piece has no next, so it is kept however short (unless it has no length). Output over 64 MiB is refused. Tests: the A3 drawing border's whole document; quarter, 270°, clockwise and whole-turn arcs, and a circle of two halves giving the same four quarters; the quarter-turn boundary both sides; cubics and parabolas, and the conics refused; the number format; a closed path whose start and end round apart; a sliver merged; an open path's short last line and arc kept, and one of no length dropped; read back through our own SVG reader (lines exactly, the arc as an arc); the scene's JSON mutated into the writer (never a panic). Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Drawing W17a: view definitions as data (#4816) (#4817) * claim: #4816 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drawing views as data: named views, scales and the map onto paper (#4816, W17a P1) What a drawing view is, before anything draws it (geom::drawing_view): - NamedView: the six principal views and the isometric views from the four upper corners, each an exact basis (a Plane whose normal points back at the viewer), never taken from a camera, whose pitch clamp would tilt a top view, with no -0 to write out; `iso` reads as the front-right one. - basis_looking: any other view from its direction, an up and an origin (paper x = direction x up, paper y = x x direction), refusing a zero direction, an up along it, or an origin that isn't finite or within 1e12 mm. W07's {direction, up, origin} view reads through it. - Scale: paper : model as two positive numbers, read and written as "1:2", from 1:100000 to 1000:1; Scale::fit picks the largest ISO 5455 preset (50:1 to 1:10000) at which a view fits a space (W07's "fits the page" default), compared as size x paper <= room x model so an exact fit at a reduction isn't lost to rounding. - ViewFrame: a view's basis, scale and place on paper, the point of its plane at `centre` landing at `position_mm`. Its paper map moves the view's curves and their accuracy onto paper (paper error = scale x model error); `boundary` maps a box of its plane onto paper from its four corners. In geom, where Seg2 lives: - Similarity2 and Seg2::similar (new): scale, turn and shift a segment exactly, an arc staying an arc. Similarity2::new refuses a scale that isn't above 0 and works the turn out once, not per point; - Seg2::map_controls: a segment's control points mapped by any function. It was doc's sheet::map_controls; its four callers in doc use the method now, unchanged. CurveAccuracy::scaled gives a curve's accuracy at another scale. Tests: each named view's basis and viewer side, and the same bases from a direction and up; a 50 mm cube in every principal view at 1:1 and 1:2, and in isometric (edges 50 x sqrt(2/3), outline 50 x sqrt(2)); the view's centre on its position (W07's 100 x 20 front view on A3 at 2:1); scales read, written and refused; the fitted scale, an exact fit at 1:5 and ISO's smallest; no -0 in any named view's axes; an origin refused by its own message; hostile bases, centres and positions; every segment kind moved by a similarity, and the similarities refused; scaled accuracy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drawing views: the bottom view pinned, square near parallel, bounded scales and boxes (#4816) From DevonHope's review of #4817: - The tests pin all six principal views exactly. The direction-and-up test uses literal ups, so a bottom view turned half round now fails it. - basis_looking squares x to the direction before normalising. Near the 1e-9 threshold the normal was up to 8.8e-9 rad off; now it is about 1e-16. - Scale::fit fits nothing for a size or room that is negative or not finite. A size of 0 still sets no limit that way. - Each side of a scale is 1e-6 to 1e6, so a scale writes at most 49 characters. - ViewFrame::boundary returns a Result. It refuses a box that is inside out or not finite on paper, instead of dropping a corner. - paper_map, boundary and seg_to_paper say which coordinates they take. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Drawing W18 S2: title blocks as data (#3875) (#3877) * Title blocks: what a cell shows and where its text goes (#3875) geom::cell holds a title-block cell's content: parts of text and fields (component, drawing, sheet, revision or prompt, by key, with an optional unit and decimals kept as written for the resolver to check), at most 64 parts and 4096 bytes of text, with no control character but a line break. Beside it are a resolved value (live, overridden and drifted, or missing, with "-" for each part that couldn't be read and why), an override with the value it replaced, and the projection a drawing's views use (first or third angle), which the title block's symbol will read. A fit warns and never cuts: a count of characters, not bytes, with its own count for text in Chinese, Japanese, Korean or Yi characters. The cell text rule places a label and a value: the label's baseline 1 mm plus its cap height below the cell's top, the value box under it inset by 1 mm, one line centred in the box, and wrapped or several lines down from its top at the font's pitch, with a warning when the last falls below the box. Wrapping by width, width warnings and glyphs a font lacks need the text measured, so they wait for the paper scene's text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Title blocks: definitions and their place on a sheet (#3875) geom::title_block holds a title block's definition: its size (1 to 1000 mm a side), its outline and cell line widths, and up to 64 cells. Each cell has a snake-case name of its own, a rectangle measured from the block's lower-right corner, an optional one-line label with its cap height, and a value: text from cell content, with its cap height, alignment and fit, or the projection symbol, which reads the drawing's projection and is never fixed in the block. validate() checks a definition on its own, as when one is read: cells inside the block and not overlapping (touching is fine), label and value cap heights that fit, and the content's own limits. layout() puts the block on the drawing border's lower-right corner of a sheet, refusing one that doesn't fit, and gives each cell's rectangle, its label's place and its value box by the cell text rule. On A3 landscape a 180 x 54 mm block runs from (230, 10) to (410, 64); it fits A5 landscape and every larger sheet, but not A5 upright. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cell text: a value box lies within the paper's coordinates (#3875) place_lines took any finite, ordered value box, but centring one far out (min x 1e308, max x 1.1e308) overflows: the anchor came back infinite, with no warning. 0xlillith found it in review of #3877. The paper scene already bounds every coordinate it holds by MAX_COORDINATE_MM (twenty times the longest sheet side). That limit is now public, and place_lines keeps its box inside it, so its anchor is always finite: one rule for what a paper coordinate may be. Tests: the hostile-box test refuses both overflow cases from the review and a box just past the limit on each side, and places lines in a box the size of the whole paper limit. The fuzz test's numbers now include values past the limit (1.1e5, ±1e308). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Title blocks: padding from the lines' inner edges, one label rule, nothing unread (#3875) A cell's text is padded from the inner edge of its lines: each side of a cell moves in by half the line drawn on it (the outline's on the block's edges, a cell line's inside), so a wide outline never covers the text. Before, the padding ran from the line's centre. The label's one-line rule lives with the cell text rules (`cell::check_label`), where parts lists will find it. The cell types nothing read yet (`Override`, `Resolved`, `State`, `Problem`, `Reason` and `MISSING`) wait for the resolver that will read them. The tests check the exact thresholds inside the lines (a label that leaves no room, a value that just fits), pick each of the lines, alignment and wrapping on their own so every combination comes up, and lay fuzzed blocks out on every standard sheet, either way, with and without the filing margin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Title blocks: the drawing border's line, descenders, room where the block sits (#3875) From DevonHope's review of #3877: - Each cell's lines are clamped to the inner edge of the drawing border's line. That line runs along the block's right and bottom edges, and along any other edge it reaches, as when the block fills the border. Aabb2::intersection, the 2D twin of Aabb3's, does the clamping. - layout checks each cell's room again where the block sits (place_cells, which validate runs at the origin), so rounding at the real corner can't turn a cell inside out. - The cell text rule counts descenders, at 3/10 of the cap height (ISO 3098's lettering B). They may reach into the padding under a value box, never past it to a line. place_lines warns Height when they would, and validate asks it whether one line fits. - The module doc no longer links to a private function. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> * Drawing W06a: exact orthographic hidden lines for the analytic subset (#2455) (#2457) * Drawing W06a: exact orthographic hidden lines for the analytic subset (#2455) crates/geom/src/hlr (L0): prepare once per body, then hidden_lines for a scene of placed bodies. Edges and face silhouettes are projected from their exact curves, split where projections cross, and each piece is classified by rays toward the viewer against the trimmed faces: visible, hidden or explicitly uncertain, never guessed. Planes, cylinders, cones, spheres and tori are supported whether stored as analytic records, revolutions or spline patches; freeform faces are reported and make what they could hide uncertain. Screen geometry is Seg2 with the W01a accuracy vocabulary: exact lines and conic arcs where the projection is exact, bounded chords otherwise. docs/drawing-hlr.md holds the contract and its limits. * geom/hlr: handle the brep records main gained in #3093 Main's exact model storage (#3093) added Curve::SurfaceIntersection, Surface::PlaneThrough and Surface::PlacedRevolution, and the HLR matches on the records no longer compiled. A plane through three points is a plane: it is trimmed and intersected as one, and a box stored that way draws the same bits as one with axis-stored planes. The procedural intersection curve and the placed revolution (whose affine placement may scale it unevenly) are refused by prepare as not supported yet, until the kernel adapter handles them. The records' positions move with the body's centre like the others. * geom/hlr: pad plane faces' boxes, so a plane record off by rounding still hides Plane faces took the box of their boundary chords unpadded, flat along the normal. When the plane record and its edges disagree by rounding (a unit cube's top plane one ulp above its edges, a 100 mm cube's 1e-11), the ray toward the viewer reached the plane just outside that box, the slab test culled the hit, and the back edges came out Visible with no doubt. Every face's box is now padded by 1e-6 of its size, as curved faces' boxes already were. Reported by DevonHope on #2457. * geom/hlr: limits from review: trim samples, scene extent, crossing pairs, surface groups From DevonHope's review of #2457: - Options::trim_samples must be 1 to MAX_TRIM_SAMPLES (4096). - Placed bodies must stay within MAX_COORDINATE (1e12 mm, as section and brep bound stored numbers). Placements 1e200 apart made the scene's size infinite and every tolerance taken from it with it, so everything came out Visible. - The crossing search counts its candidate pairs before it starts and meets each pair only in the first grid cell the two segments share, instead of keeping a set of the pairs met, which could outgrow wasm's 4 GB before the 500 million pair budget tripped. The same pairs in the same order. - prepare groups faces on one quadric or torus by comparing each face with the faces of its kind whose size agrees to the tolerance (sorted), not with every face; at most 50 million comparisons. prepare_cancellable polls a cancel callback between edges, faces and comparisons. * geom/hlr: boundary chords on a grid, and no fine pass over coincident curved chords The cost items of DevonHope's review of #2457: - Every ray hit asked how far it lies from its face's boundary by scanning all of the face's chords (4096 per circle; a plate's face with 256 holes has about a million). The chords are now bucketed on a uniform grid over the boundary's box, cells at least as long as the longest chord, and the test looks only at the cells around the point. It finds exactly the chords the scan finds. - Chords of two curved curves that lie on each other end to end (aligned holes seen along their axes) were cut into 16 by 16 pieces and intersected, to find crossings that are only noise within the curves' tolerance. They are now skipped; visibility changes where a coincident run starts or ends, and chords that only partly overlap still find those. - Fixtures: a plate with holes, coaxial cylinders seen along their axis, and 300 placements. * geom/hlr: pad boxes by the body's coordinates too; coincident chords only to rounding Review of the previous commits found two holes: - The padding grew with the face's size, but rounding grows with where the body was modelled: a 0.001 mm cube 1e7 mm out, its top plane one step (1.9e-9 mm) above its edges, still had its hit culled. The pad now adds 1e-12 of the body's offset. - Two chords were taken for coincident when their ends lay within each other's sag, which in a coarse scene can hide real crossings: an arc can bulge across a line 1 mm off its chord twice. Only chords that coincide to rounding are skipped now; identical projections, the costly case, still are. * geom/hlr: coincident curves meeting at shared sample points do not cross Timing the review's scenes showed that skipping coincident chords was not enough: chords of two coincident curves also touch end to end at every sample point they share, and each touch was a crossing to refine and split at. 64 coaxial cylinders seen along their axis gave 8.3 million crossings (19 s), 64 cylinders one behind another along the line of sight 4.2 million (214 s; before these commits the crossing budget refused it). A touch at a shared sample point is now dropped when the two curves also coincide, to rounding, on both sides of it. Where a coincident run starts or ends one side differs, and the touch is kept. * geom/hlr: the own-face margin allows for rounding where a body was modelled The far 0.001 mm cube of the previous commit showed a second effect of records rounded at large coordinates: its top plane, one ulp (1.9e-9 mm) above its edges, hid the top edges themselves, because a ray leaving an edge met its own face 3.2e-9 mm along, past the own-face margin of 1e-6 of the face (1.4e-9). The margin now also covers 1e-14 of the body's offset (about 45 ulps of its coordinates), which only matters for parts far smaller than their distance from the origin. * geom/hlr: no parity test for a hit on the boundary, where it is uncertain anyway * docs/drawing-hlr: the fixtures of the review's second round --------- Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com> --------- Co-authored-by: CamWight <91156642+CamWight@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Benny1496 <163034806+Benny1496@users.noreply.github.com> Co-authored-by: Mateusz Sych <151755914+SychPL@users.noreply.github.com> Co-authored-by: MateuszSychXpl <151755914+MateuszSychXpl@users.noreply.github.com>
24 KiB
Releasing SolidCraft
Releases are deliberate operations that an agent can complete end to end. Development lands
on main; pushing the selected commit to release builds all packages and creates a draft
GitHub Release. Publishing that verified draft is a separate operation. Pushing to main or
pushing a tag does not start the release workflow. There is no automatic commit-message-based
version bump and no patch release for every merge.
Names and deployment
Every release asset, binary, installer product name and package uses the SolidCraft name
(solidcraft-<version>-linux-<arch>.AppImage, SolidCraft-<version>-windows-x64.exe,
solidcraft-cli archives, …). The old name survives only in the shims listed in
compatibility.md (the end-of-life Flatpak refs of the old Linux app ID, the legacy .zsync and .flatpakrepo
copies, the deprecated old-name CLI alias); keep them until that document says they can go. Do not
rewrite published assets, checksums, tags or historical release notes of earlier versions.
The main installers (.msi, portable .zip, .dmg, .AppImage, .deb, .rpm, .flatpak, .snap) are
also attached without the version in their names, for example solidcraft-windows-x64.msi and
solidcraft-linux-x86_64.AppImage, and listed in SHA256SUMS.txt. The README's Download section
links to https://github.com/bherbruck/solidcraft/releases/latest/download/<name>, which follows
the release marked latest; nightlies never are. Keep these names stable: renaming one breaks the
README links and the in-app update check, which picks them by name (crates/ui-egui/src/update.rs;
updates and privacy).
Project Pages publishes the documentation, browser app and published-release Flatpak
repository under /solidcraft/. Do not add a separate owner-site publisher or permanent
old-name Pages/Flatpak aliases. GitHub repository redirects are separate from Pages hosting;
verify the new project URLs after deployment.
The browser app keeps itself for offline use (apps/solidcraft-web/sw.js). Browsers keep that
service worker after the code that registered it is gone, so to switch it off deploy
apps/solidcraft-web/sw.js unbuilt (as trunk serve builds do): it deletes what it kept and
unregisters. A site without build.json is also safe: launches then go to the network.
Nightly prereleases
release.yml also runs at midnight in America/New_York (Eastern time, following daylight
saving time). GitHub may start scheduled runs later when its runners are busy.
Before building, it compares the exact scheduled main commit with the tag of the most
recently published release, including prereleases. If the commits match, all packaging and
publication jobs are skipped: no new version, tag, or release is created. Drafts do not count
as published releases, so a failed build or publication can be retried the following night.
Changed commits use the existing package build and validation jobs. Only after all jobs
succeed, the workflow stages the assets in a draft and publishes a prerelease named
v<workspace-version>-nightly.<YYYYMMDD>.<run-number>.<run-attempt> with checksums and generated
notes. Any existing workspace prerelease suffix is replaced by the nightly suffix. Dates
use Eastern time; package versions are overridden without changing Cargo.toml or Cargo.lock,
so the binaries' embedded Cargo version remains the workspace version. Nightlies do not
replace the latest stable release or advance the release branch. The normal deliberate
release process below continues to create drafts for separate verification and publication.
Nightly publication uses GITHUB_TOKEN, so it does not trigger the downstream Flatpak Pages
deployment. The stable Flatpak repository is not automatically updated with nightly builds.
Choose and prepare the version
- Fetch
originand its tags. Inspect the previous published release and the changes since it. Choose one version for the complete batch, based on user-visible behavior:- Patch: compatible fixes, reliability improvements, and packaging corrections.
- Minor: new compatible capabilities. While on
0.x, also use a minor bump for breaking changes and explain the break and any migration in the notes. - Major: incompatible public behavior or file-format/API changes after
1.0. Moving to1.0is an explicit product decision, not something inferred from a commit prefix. - Use
-rc.1,-rc.2, etc. when a release candidate is requested. Documentation-only changes normally wait for the next release. State the reason for the chosen bump in the release notes. Follow any version or release scope specified by the owner.
- Update
[workspace.package] versionin rootCargo.toml. Then runcargo update --workspace --offlineto refresh the workspace packages' versions inCargo.lock(it changes only our own crates, never dependency versions, and needs no network). Inspect the diff: onlyversion =lines of workspace crates should change. SolidCraft does not currently implementcargo xtask version set. - Fix the file format numbers: run
scripts/fold-format-pending.sh. It moves the pending format changes of designs (crates/doc/src/format/pending/*.rs) and of drawings (crates/doc/src/drawing/format/pending/*.rs) into each one's nextv<N>/folder, so the release writes and reads those numbers for good, and does nothing for a family with nothing pending. Commit the move with the version files. (CONTRIBUTING.md, "Changing the design file format".) - Run
cargo xtask ciand the packaging checks in.github/workflows/packaging-lint.yml. Commit the version files through the normal review flow and land them onmain. Do not use[skip ci]. Record the exact commit SHA to release. If it changes, revalidate it.
Build the draft
Use an authenticated maintainer account or GitHub App with the repository's normal release permissions. Follow branch rules; never force-push or bypass required checks.
git fetch origin --tags
git switch release
git merge --ff-only origin/release
git merge --ff-only origin/main
git push origin release
For the first release, after this workflow has landed on main, create release from the
validated origin/main commit (git switch -c release origin/main) and push it. If the branch
already exists, use the commands above. Do not create the branch from unrelated work. If a
fast-forward fails, inspect and resolve the divergence through the normal review flow.
Find the release.yml Actions run for the recorded SHA and release branch, and monitor that
specific run. The workflow builds Linux (both architectures, including Flatpak), macOS,
Windows, FreeBSD, and web packages. Once every required job succeeds it creates a draft
SolidCraft v<version> with the artifacts, SHA256SUMS.txt, install notes, and generated changes.
A second push with the same untagged version can rebuild and replace that draft's complete asset set. Existing release notes are preserved on rebuild; refresh their changes section if new commits were added. An existing tag or published release is never overwritten. Use a new version after publication. Do not push the version tag yourself: publishing the draft creates it at the draft's target commit.
macOS disk image
packaging/macos/package.sh builds the .dmg with dmgbuild
(MIT; hash-pinned in packaging/macos/dmg-requirements.txt, installed into a throwaway venv under
target/). It writes the Finder layout, the background and the icon positions straight into the
image, so the build needs no Finder session or AppleScript, which are unreliable on CI runners.
The look lives in assets/platforms/macos/dmg/: layout.json (window size, icon size and
positions, shared by both files below), settings.py (the dmgbuild settings) and background.tiff
(a blueprint sheet, 540×360 pt, with a 1x and a 2x representation so Retina screens get the sharp one). Change the art with
make_background.py (needs Pillow; its docstring has the commands), view the result by mounting the
image, and commit the TIFF with the script. Rules the design follows:
- The title bar adds to the window rectangle (
title_barinlayout.json, 32 pt on macOS 27); the content area must equal the background's size. Older macOS versions have a thinner title bar and were not checked. - Finder draws the icon names itself, black in Light and white in Dark appearance, and art cannot change that. Each name therefore sits on a mid-tone plate that both colours read on. Keep the plates under the icons if you move them.
README.txtstays on the disk but is hidden (SetFile -a V), so the window stays calm.
package.sh mounts the finished image and checks the layout files, the volume icon, the bundle and
its signature. hdiutil prints deprecation warnings on macOS 27 (dmgbuild still uses it); if a
later macOS removes it, dmgbuild needs a release that uses diskutil image.
Notes for the next release
Items to carry into the next release's notes (step 3 below), then remove from here:
- None yet.
Verify and publish
When the task authorizes shipping a release, agents should complete these steps without requiring the owner to click through the GitHub UI. A request only to prepare a release or change release infrastructure does not itself request publication of the application.
-
Confirm that the workflow run succeeded for the intended SHA and that the matching release is still a draft targeting that SHA. Avoid publishing while another release build is active.
-
Download all assets to a fresh directory. Run
sha256sum --check SHA256SUMS.txtand check that every expected platform/architecture is present. Smoke-test the built CLI's--versionand an example model on an available platform. Inspect the package-check job output for other platforms; report any platform you could not exercise yourself. -
Review the generated notes. Add a concise description of features, fixes, breaking changes, and known limitations. Keep the installation instructions. The macOS app is Developer ID signed and notarized only when the build summary says so, and the Windows files are signed only when it says which SignPath policy signed them (
test-signingis a self-signed test certificate that Windows does not trust); other packages are unsigned. Do not claim otherwise. Resolve failed checks or missing artifacts before publication. -
Publish the verified draft using
ghor the equivalent GitHub release API. For example:# Substitute the version that was actually built and verified. gh release edit v0.2.0 --draft=false --latest # For a release candidate instead: gh release edit v0.2.0-rc.1 --draft=false --prerelease --latest=false -
Confirm that the release is public and its tag resolves to the recorded SHA. Report the release URL, version, checks performed, and any remaining limitations.
Dry runs and retries
gh workflow run release.yml --ref <branch> -f draft=false builds packages and uploads the
release-assets Actions artifact without creating or publishing a release. For an explicit
retry that creates/updates a draft, use --ref release -f draft=true. Draft creation from
another branch is rejected. A rerun must still use an unpublished, untagged version.
Flatpak repository coordination
The Flatpak repository proposed in PR #39 consumes published releases, not these drafts.
Keep that deployment separate from building the application. A publication made with an
Actions GITHUB_TOKEN does not trigger downstream release: published workflows; in that
case explicitly dispatch the Flatpak workflow with the published tag, or use an appropriately
authorized maintainer/App token for publication. Do not assume the repository updated: check
the deployment run. The Flatpak workflow requests the shared Pages workflow with that tag;
Pages assembles the documentation, browser app, and Flatpak repository together. Ordinary
docs updates include the latest stable Flatpak release too. Verify the resulting Pages run,
not only the dispatch job, so a release cannot erase the docs or app and docs updates cannot
erase the package repository.
Snap package and Snap Store
Every release and nightly attaches solidcraft-<version>-linux-<arch>.snap for x86_64 and
aarch64 (packaging/linux/snap-pack.sh, manifest packaging/linux/snap/snapcraft.yaml). Like the
Flatpak, it repackages the Linux job's tarball. The snap job installs it with --dangerous,
starts the confined app on headless X11 and Wayland (packaging/linux/snap-smoke.py), models a
box over the control channel and keeps the captured frames as the snap-smoke-<arch> artifact.
The Snap Store steps run only when the SNAPCRAFT_STORE_CREDENTIALS repository secret exists;
without it they are skipped and the .snap files are still attached to the release:
- nightlies are uploaded to
latest/edge; - release drafts (pushes to
release) are uploaded tolatest/candidate, after every package built and the draft was staged; - publishing a non-prerelease GitHub release runs
snap-store.yml, which promoteslatest/candidatetolatest/stable. As with the Flatpak repository, a publication made withGITHUB_TOKENdoesn't trigger it: then rungh workflow run snap-store.yml.
To enable the Snap Store once (owner):
-
Sign in at https://snapcraft.io with an Ubuntu One account and register the name at https://snapcraft.io/register-snap (or
snapcraft loginthensnapcraft register solidcraft). -
On a machine with snapcraft (
sudo snap install snapcraft --classic), export upload-only credentials for this snap:snapcraft export-login --snaps=solidcraft \ --channels=edge,candidate,stable \ --acls=package_access,package_push,package_update,package_release \ --expires=2027-10-01 solidcraft-store-credentials.txt -
Store them as the repository secret and delete the file:
gh secret set SNAPCRAFT_STORE_CREDENTIALS -R bherbruck/solidcraft < solidcraft-store-credentials.txt rm solidcraft-store-credentials.txt -
Optional: fill in the store listing at https://snapcraft.io/solidcraft/listing (the icon, summary and description come from the snap; add screenshots and the category "Art and design").
-
The next nightly fills
edgeand the next release fillscandidate, thenstableon publication. To fillstablefrom an already published release right away, download its.snapfiles and runsnapcraft upload --release=stable <file>for each.
Renew the credentials before --expires by repeating steps 2 and 3. The snap uses strict
confinement and only plugs that connect automatically, apart from removable-media, which users
connect with sudo snap connect solidcraft:removable-media (getting-started.md).
WinGet (Windows Package Manager)
SolidCraft is not in winget-pkgs yet: its first
version, as bherbruck.SolidCraft, has to be submitted and merged there once (by hand, with
Komac or wingetcreate) before winget install bherbruck.SolidCraft finds it. A first
submission, for 0.21.0, is already open as
microsoft/winget-pkgs#452486 (flagged
Validation-Executable-Error): take that one up rather than opening another. Until then the
README and downloads don't offer the command (#5734); add it back when the
package is listed. After that first version, publishing a non-prerelease
GitHub release runs winget.yml, which uses Komac
(a pinned, checksum-verified binary) to open a pull request at microsoft/winget-pkgs from the
owner's fork bherbruck/winget-pkgs. It submits the three versioned MSIs
(solidcraft-<version>-windows-{x64,x86,arm64}.msi, never the fixed-name copies), reads their
product codes, links the release notes and keeps the other fields from the previous version's
manifest. The winget-pkgs bots validate it and a moderator merges it, usually within a day or two.
The job does nothing, successfully, while the WINGET_TOKEN repository secret is missing or
before the package's first version is merged into winget-pkgs. Like snap-store.yml, a
publication made with GITHUB_TOKEN doesn't trigger it: then run
gh workflow run winget.yml -R bherbruck/solidcraft -f tag=v<version>.
To enable it once (owner):
-
Create a classic personal access token at https://github.com/settings/tokens/new (Settings > Developer settings > Personal access tokens > Tokens (classic)): note "solidcraft winget", an expiry such as one year, and only the
public_reposcope. Fine-grained tokens can't open pull requests on microsoft/winget-pkgs. -
Store it as the repository secret (paste the token when asked):
gh secret set WINGET_TOKEN -R bherbruck/solidcraft -
Keep the fork
bherbruck/winget-pkgs(Komac pushes its branches there). Its default branch doesn't need syncing.
When the token expires, create a new one the same way and run step 2 again. If a release was
published while the secret was missing or expired, submit it afterwards with
gh workflow run winget.yml -R bherbruck/solidcraft -f tag=v<version>.
macOS signing and notarization
packaging/macos/package.sh signs with the hardened runtime and notarizes when the release
workflow has these repository secrets; without them (forks, dry runs) it keeps the ad-hoc
signature and the job summary says so:
| Secret | Content |
|---|---|
APPLE_CERTIFICATE_P12 |
base64 of the Developer ID Application certificate and key, exported as .p12 |
APPLE_CERTIFICATE_PASSWORD |
password of that .p12 |
APPLE_API_KEY_P8 |
text of the App Store Connect API key (AuthKey_<id>.p8) |
APPLE_API_KEY_ID |
the key's ID |
APPLE_API_ISSUER |
the issuer ID (App Store Connect > Users and Access > Integrations) |
packaging/macos/signing.sh --setup imports the certificate into a temporary keychain after the
build, and --cleanup removes it at the end of the job.
The app is signed with packaging/macos/entitlements.plist. Its
com.apple.security.cs.disable-library-validation entitlement lets the hardened runtime load
3Dconnexion's 3DconnexionClient.framework (crates/threedx-client/src/macos.rs); without it
the 3D mouse silently stops working. Test a notarized build with a real 3D mouse before
publishing. The script notarizes the app (a zip of it) and staples it before the .dmg is made,
so a copy dragged to /Applications opens offline. It then notarizes and staples the .dmg, and
notarizes the CLI .zip (a zip can't be stapled). stapler staple is retried, because it can
fail with error 65 for a while after Apple accepts a submission. The checks are stapler validate
and spctl on the .dmg, and spctl --assess on the mounted app. To try it
locally, pass --sign and set MACOS_SIGN_IDENTITY and the APPLE_API_KEY_PATH, APPLE_API_KEY_ID,
APPLE_API_ISSUER variables and run the script.
The certificate expires in November 2027. To renew it, create a new Developer ID Application
certificate in the Apple Developer account, export it with its key as a .p12, and replace
APPLE_CERTIFICATE_P12 and APPLE_CERTIFICATE_PASSWORD. Releases built before the expiry stay
valid, because the signature carries a secure timestamp.
Signing currently uses a maintainer's personal Developer ID, so downloads show that person's
name, not an organisation's. Moving to an organisation account means replacing the certificate
and the API key secrets (APPLE_API_KEY_P8, APPLE_API_KEY_ID, APPLE_API_ISSUER).
Windows signing (SignPath Foundation)
When the SIGNPATH_API_TOKEN secret (the API token of the SignPath CI user) exists, the release
workflow's Windows jobs Authenticode-sign through SignPath Foundation
(organization fa603ff4-c702-404b-8588-a524371c7a4e, project solidcraft). Each architecture
sends two signing requests: solidcraft.exe, solidcraft-cli.exe and solvecraft-cli.exe first,
so the MSI, the portable zip and the plain .exe are made from signed binaries, then the MSI. Each
request uploads the files as a workflow artifact, SignPath fetches and signs it, and the job checks
that every file came back signed. Without the secret (forks) the jobs build unsigned as before.
- Policy:
test-signing(a self-signed test certificate) unless the dispatch inputsignpath_signing_policyor the repository variableSIGNPATH_SIGNING_POLICYsays otherwise. Switch to the real certificate withgh variable set SIGNPATH_SIGNING_POLICY -b release-signing -R tangency-labs/solidcraftonce SignPath has issued it. Requests underrelease-signingwait for approval in SignPath; the workflow waits up to an hour for each. - Artifact configuration:
packaging/windows/signpath-artifact-configuration.xml. When it changes, paste the new version into the SignPath project's artifact configuration when it lands. - Test-sign without publishing:
gh workflow run release.yml -R tangency-labs/solidcraft --ref <branch> -f draft=false, then check the Windows jobs' summaries and therelease-assetsartifact. - SignPath only signs runs whose jobs all ran on GitHub-hosted runners, so keep the
version,iconsandwindowsjobs off self-hosted runners.
Homebrew tap
macOS users install with brew install --cask tangency-labs/tap/solidcraft from the tap repository
tangency-labs/homebrew-tap (Casks/solidcraft.rb).
Homebrew's own cask repository only takes signed and notarized apps, so the tap stays until the
macOS signing work lands.
After publishing a stable release, bump the cask: set version and the sha256 of
solidcraft-<version>-macos-universal.dmg (the line for that file in the release's
SHA256SUMS.txt), run brew style Casks/solidcraft.rb, and push to the tap's main. The cask's
livecheck follows the latest GitHub release, so brew livecheck --cask tangency-labs/tap/solidcraft
shows when the tap is behind. Skip prereleases and nightlies.
Update signing
The app's Update now (docs/book/src/guide/updates.md) only installs a package whose SHA-256
matches the release's SHA256SUMS.txt, fetched over HTTPS. That protects against corrupted and
tampered downloads in transit, but not against someone who can upload release assets. Signing
the checksum list closes that gap: once PUBLIC_KEY in crates/ui-egui/src/update/install.rs
is set, the app also requires SHA256SUMS.txt.minisig, a minisign
signature by that key whose trusted comment names the release (SolidCraft v<version>), and
refuses to update in place otherwise. The release workflow signs the list when the
UPDATE_SIGNING_KEY secret exists, and fails when the app expects signatures but the secret is
missing.
To turn it on once (owner):
-
On a trusted machine with minisign (
sudo apt install minisign,brew install minisignorwinget install minisign), make a key pair without a password (the workflow can't type one):minisign -G -W -p solidcraft-update.pub -s solidcraft-update.key -
Store the secret key as the repository secret and keep an offline backup of it (a lost key means a new key and an app release that carries it; a leaked key means the same, quickly):
gh secret set UPDATE_SIGNING_KEY -R bherbruck/solidcraft < solidcraft-update.key -
Put the public key (the second line of
solidcraft-update.pub, starting withRW) intocrates/ui-egui/src/update/install.rs:pub const PUBLIC_KEY: Option<&str> = Some("RW…");and land that through a PR. Builds from then on refuse unsigned updates; releases built from then on carry
SHA256SUMS.txt.minisig. -
Delete
solidcraft-update.keyfrom the machine once the backup is safe.
Older builds without the key keep updating with the SHA-256 check alone.
Repository setup
The release branch is advanced only for intentional release batches. Restrict its writers to
maintainers/release agents through repository rules if required by the owner's policy. The
workflow needs contents: write only in its draft-creation job. It does not push commits,
calculate versions, or require a semantic-release bot to bypass main protections.
This workflow replaces the every-push-to-main semantic-release proposal in PR #38; do not merge that automation alongside this release model without redesigning its triggers.