mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-11 01:51:33 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5207d6f89b |
No files matched your search
+1
-2
@@ -10,7 +10,6 @@
|
||||
|
||||
# User-facing guides and references.
|
||||
/docs/ @SivanCola @esengine
|
||||
/docs/THEME_AUTHOR_GUIDE.md @esengine @SivanCola
|
||||
/sdk/ @SivanCola @esengine
|
||||
/README.md @SivanCola @esengine
|
||||
/README.zh-CN.md @SivanCola @esengine
|
||||
@@ -23,6 +22,7 @@
|
||||
/docs/*_CONTRACT.md @esengine @SivanCola
|
||||
/docs/TOOL_APPROVAL_MODES.md @esengine @SivanCola
|
||||
/docs/CAPABILITY_DIAGNOSTICS.md @esengine @SivanCola
|
||||
/docs/CODE_SIGNING_POLICY.md @esengine @SivanCola
|
||||
/docs/CHECKPOINTS.md @esengine @SivanCola
|
||||
/docs/EXTENSION_*.md @esengine @SivanCola
|
||||
/docs/SUBAGENT_*.md @esengine @SivanCola
|
||||
@@ -37,7 +37,6 @@
|
||||
/.github/workflows/studio-certum-signing-smoke.yml @esengine @SivanCola
|
||||
/.github/actions/setup-certum/ @esengine @SivanCola
|
||||
/cmd/signpath-contract/ @esengine @SivanCola
|
||||
/scripts/install-smoke.ps1 @esengine @SivanCola
|
||||
/scripts/sign-certum.ps1 @esengine @SivanCola
|
||||
/scripts/verify-windows-authenticode.ps1 @esengine @SivanCola
|
||||
/scripts/verify-windows-package.ps1 @esengine @SivanCola
|
||||
|
||||
@@ -6,9 +6,3 @@ contact_links:
|
||||
- name: Questions & discussions
|
||||
url: https://github.com/esengine/DeepSeek-Reasonix/discussions
|
||||
about: For usage questions, ideas, and general discussion — please use Discussions instead of opening an issue.
|
||||
- name: Discord
|
||||
url: https://discord.gg/XF78rEME2D
|
||||
about: Bilingual community chat for setup help, workflow showcases, and feature ideas.
|
||||
- name: QQ group (DeepSeek-Reasonix官方群, 1093562660)
|
||||
url: https://qm.qq.com/q/i59b0z2R8s
|
||||
about: Chinese-language community chat for setup help and everyday questions.
|
||||
@@ -8,34 +8,6 @@ only, on `main-v2`.
|
||||
|
||||
-
|
||||
|
||||
## Cause
|
||||
|
||||
-
|
||||
|
||||
## Blast radius
|
||||
|
||||
-
|
||||
|
||||
## Neighbouring behaviours tested
|
||||
|
||||
-
|
||||
|
||||
## Why this layer
|
||||
|
||||
-
|
||||
|
||||
## UI changes
|
||||
|
||||
<!--
|
||||
Delete this section if the PR touches nothing under `desktop/frontend-next/src`
|
||||
or the website pages.
|
||||
-->
|
||||
|
||||
- Screenshots, before and after: light and dark, a wide window and a phone-width one (about 390 px). Attach them to this description.
|
||||
- Entries moved, hidden or removed: none / list each one.
|
||||
- Hiding or removing a visible control needs a linked issue and a release-notes line naming it. Do not retire UI with `display: none !important` or a feature flag.
|
||||
- If a row of `docs/STUDIO_PARITY.md` changes, update it here.
|
||||
|
||||
## Issues
|
||||
|
||||
<!--
|
||||
@@ -54,7 +26,3 @@ the source-to-target contract here: easing syntax, time units, callbacks,
|
||||
cancellation, reduced-motion behavior, and failure fallback. Verification must
|
||||
assert that the target API was actually called; a mock that silently skips it
|
||||
does not count.
|
||||
|
||||
## Disclosure
|
||||
|
||||
- [ ] AI assistance was used for this change (optional; for transparency only, it does not change how the change is reviewed)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 138 KiB |
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145.55 113.29">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #ffffff;
|
||||
stroke: #ffffff;
|
||||
stroke-miterlimit: 10;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="_图层_1-2" data-name="图层 1">
|
||||
<g>
|
||||
<path class="cls-1" d="M144.68,38.49l-.06-.23c-.88-3.28-2.5-5.94-4.58-8.06.14,5.65-2.96,11.02-6.22,16.66l-.39.68c-2.26,3.91-4.66,7.94-6.57,11.1l-.86,1.38c-3.36,5.44-6.27,10.14-12.18,14.18-8.34,5.87-18.2,5.81-26.92,5.76-2.81-.02-5.48-.03-7.95.14l-.35.02c-3.22.06-5.96,1.57-8.17,4.49l-.14.18c-.86,1.06-1.7,2.26-2.58,3.54-3.43,4.92-7.69,11.04-16.17,12.42-4.37.86-9.98.84-14.94.83h-1.95c-.52,0-1.06,0-1.61,0-6.95,0-16.08-.89-21.94-6.55-.15-.15-.3-.3-.44-.45.5,4.73,2.33,8.64,5.44,11.65,5.86,5.66,14.98,6.55,21.94,6.55.55,0,1.09,0,1.61-.01h1.93c4.96.02,10.58.04,14.96-.83,8.48-1.37,12.74-7.5,16.17-12.42.88-1.27,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.43,8.17-4.49l.35-.02c2.47-.17,5.13-.16,7.95-.14,8.72.05,18.58.11,26.91-5.76,5.92-4.03,8.82-8.73,12.19-14.17l.86-1.39c1.91-3.15,4.3-7.18,6.57-11.09l.39-.69c3.81-6.6,7.41-12.84,5.86-19.57ZM120.9,23.02c-.28,0-.56,0-.83,0-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.41.69-.77,1.3-1.09,1.84l-.49.84c-1.49,2.53-3.23,5.49-4.21,8.95,3.98,1.9,8.52,2.65,12.71,2.91.27-.77.64-1.56,1.07-2.38.48-.94,1.05-1.9,1.63-2.89l.49-.84c.96-1.62,2.38-3.99,4.05-6.78,3.82-6.36,8.98-14.88,13.19-21.55l.07-.11c5.02-8.31,9.2-13.45,16.91-16.81l.11-.05c6.57-2.7,10.54-2.74,18.43-2.81.87-.01,1.78-.02,2.69-.03l1.99-.02c9.17-.06,20.13-.14,29.01-.07,2.26.01,4.39.04,6.32.08h.12s.11,0,.11,0c1.25-.01,2.91.11,4.61.46,1.03.22,2.08.52,3.05.93.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82-4.08-1.92-8.7-2.76-12.89-2.82Z"/>
|
||||
<path class="cls-1" d="M115.07,11.81c-9.7-.19-24.07-.09-35.62-.01h-1.99c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.13,9.71-14.19,23.21-17.41,28.63l-.5.84c-2.08,3.55-4.68,7.97-4.94,13.39v.2s0,.21,0,.21c.01.81.06,1.61.15,2.38.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-5.18-5.29-13.22-7.25-19.97-7.19ZM122.56,40.37l-.39.67c-2.21,3.81-4.55,7.77-6.39,10.79l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.95,3.45-19.38,3.41-2.88-.02-5.87-.04-8.79.16-7.1.19-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.28.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-2.24-.48-3.87-1.22-4.89-2.2-.66-.64-1.54-1.81-1.63-4.65.11-1.35.71-2.82,1.52-4.35.48-.94,1.05-1.9,1.63-2.89l.49-.84c3.17-5.33,11.19-18.75,17.24-28.33l.07-.11c5.02-8.32,9.2-13.45,16.92-16.81l.1-.05c6.57-2.7,10.54-2.74,18.43-2.82.87,0,1.78,0,2.69-.03h1.94c11.52-.09,25.86-.19,35.38,0h.23c1.25-.01,2.92.11,4.61.46,3.15.66,6.4,2.12,7.27,5.02.2,1.2-.89,3.62-2.27,6.18-.7,1.31-1.48,2.65-2.2,3.9ZM120.07,23.01c-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.28,0-.56,0-.83,0Z"/>
|
||||
<path class="cls-1" d="M134.68,16.09l-.06-.23c-3.07-11.45-15.08-15.33-24.55-15.25-9.68-.19-24.03-.09-35.57-.01h-2.04c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.14,9.73-14.2,23.22-17.41,28.62l-.49.85c-2.09,3.55-4.69,7.96-4.95,13.39v.2s0,.21,0,.21c.09,5.57,1.82,10.13,5.15,13.58.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-.02-.96-.14-1.93-.36-2.91ZM119.76,25.27c-.7,1.31-1.48,2.65-2.2,3.9l-.39.67c-1.19,2.04-2.41,4.13-3.57,6.09-1.01,1.7-1.97,3.31-2.82,4.7l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.96,3.45-19.38,3.41-2.89-.02-5.87-.04-8.79.16-7.1.18-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.27.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-1.16-.25-2.16-.57-2.99-.96-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.34.72-.73,1.48-1.14,2.25Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB |
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145.55 113.29">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #111111;
|
||||
stroke: #111111;
|
||||
stroke-miterlimit: 10;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="_图层_1-2" data-name="图层 1">
|
||||
<g>
|
||||
<path class="cls-1" d="M144.68,38.49l-.06-.23c-.88-3.28-2.5-5.94-4.58-8.06.14,5.65-2.96,11.02-6.22,16.66l-.39.68c-2.26,3.91-4.66,7.94-6.57,11.1l-.86,1.38c-3.36,5.44-6.27,10.14-12.18,14.18-8.34,5.87-18.2,5.81-26.92,5.76-2.81-.02-5.48-.03-7.95.14l-.35.02c-3.22.06-5.96,1.57-8.17,4.49l-.14.18c-.86,1.06-1.7,2.26-2.58,3.54-3.43,4.92-7.69,11.04-16.17,12.42-4.37.86-9.98.84-14.94.83h-1.95c-.52,0-1.06,0-1.61,0-6.95,0-16.08-.89-21.94-6.55-.15-.15-.3-.3-.44-.45.5,4.73,2.33,8.64,5.44,11.65,5.86,5.66,14.98,6.55,21.94,6.55.55,0,1.09,0,1.61-.01h1.93c4.96.02,10.58.04,14.96-.83,8.48-1.37,12.74-7.5,16.17-12.42.88-1.27,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.43,8.17-4.49l.35-.02c2.47-.17,5.13-.16,7.95-.14,8.72.05,18.58.11,26.91-5.76,5.92-4.03,8.82-8.73,12.19-14.17l.86-1.39c1.91-3.15,4.3-7.18,6.57-11.09l.39-.69c3.81-6.6,7.41-12.84,5.86-19.57ZM120.9,23.02c-.28,0-.56,0-.83,0-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.41.69-.77,1.3-1.09,1.84l-.49.84c-1.49,2.53-3.23,5.49-4.21,8.95,3.98,1.9,8.52,2.65,12.71,2.91.27-.77.64-1.56,1.07-2.38.48-.94,1.05-1.9,1.63-2.89l.49-.84c.96-1.62,2.38-3.99,4.05-6.78,3.82-6.36,8.98-14.88,13.19-21.55l.07-.11c5.02-8.31,9.2-13.45,16.91-16.81l.11-.05c6.57-2.7,10.54-2.74,18.43-2.81.87-.01,1.78-.02,2.69-.03l1.99-.02c9.17-.06,20.13-.14,29.01-.07,2.26.01,4.39.04,6.32.08h.12s.11,0,.11,0c1.25-.01,2.91.11,4.61.46,1.03.22,2.08.52,3.05.93.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82-4.08-1.92-8.7-2.76-12.89-2.82Z"/>
|
||||
<path class="cls-1" d="M115.07,11.81c-9.7-.19-24.07-.09-35.62-.01h-1.99c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.13,9.71-14.19,23.21-17.41,28.63l-.5.84c-2.08,3.55-4.68,7.97-4.94,13.39v.2s0,.21,0,.21c.01.81.06,1.61.15,2.38.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-5.18-5.29-13.22-7.25-19.97-7.19ZM122.56,40.37l-.39.67c-2.21,3.81-4.55,7.77-6.39,10.79l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.95,3.45-19.38,3.41-2.88-.02-5.87-.04-8.79.16-7.1.19-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.28.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-2.24-.48-3.87-1.22-4.89-2.2-.66-.64-1.54-1.81-1.63-4.65.11-1.35.71-2.82,1.52-4.35.48-.94,1.05-1.9,1.63-2.89l.49-.84c3.17-5.33,11.19-18.75,17.24-28.33l.07-.11c5.02-8.32,9.2-13.45,16.92-16.81l.1-.05c6.57-2.7,10.54-2.74,18.43-2.82.87,0,1.78,0,2.69-.03h1.94c11.52-.09,25.86-.19,35.38,0h.23c1.25-.01,2.92.11,4.61.46,3.15.66,6.4,2.12,7.27,5.02.2,1.2-.89,3.62-2.27,6.18-.7,1.31-1.48,2.65-2.2,3.9ZM120.07,23.01c-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.28,0-.56,0-.83,0Z"/>
|
||||
<path class="cls-1" d="M134.68,16.09l-.06-.23c-3.07-11.45-15.08-15.33-24.55-15.25-9.68-.19-24.03-.09-35.57-.01h-2.04c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.14,9.73-14.2,23.22-17.41,28.62l-.49.85c-2.09,3.55-4.69,7.96-4.95,13.39v.2s0,.21,0,.21c.09,5.57,1.82,10.13,5.15,13.58.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-.02-.96-.14-1.93-.36-2.91ZM119.76,25.27c-.7,1.31-1.48,2.65-2.2,3.9l-.39.67c-1.19,2.04-2.41,4.13-3.57,6.09-1.01,1.7-1.97,3.31-2.82,4.7l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.96,3.45-19.38,3.41-2.89-.02-5.87-.04-8.79.16-7.1.18-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.27.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-1.16-.25-2.16-.57-2.99-.96-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.34.72-.73,1.48-1.14,2.25Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 5.4 KiB |
+17
-34
@@ -62,7 +62,7 @@ jobs:
|
||||
# both paths must trigger the sdk job.
|
||||
if echo "$files" | grep -qE '^(sdk/|internal/ext/extension/)'; then sdk=true; fi
|
||||
# The release-note tests render every committed Studio note; npm/ holds the publish tests.
|
||||
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/|\.github/workflows/release-studio\.yml$)'; then scripts=true; fi
|
||||
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/)'; then scripts=true; fi
|
||||
fi
|
||||
fi
|
||||
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
|
||||
@@ -80,11 +80,7 @@ jobs:
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
- run: node --test scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
- run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs npm/publish.test.mjs
|
||||
|
||||
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
|
||||
# A matrix job skipped at job level reports no per-leg checks at all, so
|
||||
@@ -179,9 +175,12 @@ jobs:
|
||||
# budget above normal 7-9 minute runner variance; each package test binary
|
||||
# remains independently bounded by Go's per-package timeout below.
|
||||
#
|
||||
# The per-package timeout is 18m: internal/assembly/boot took 478-717s
|
||||
# (12 push runs) on the Windows runner, so 18m is 1.5x the worst. A hang
|
||||
# still trips the step budget (25m smoke, 35m full).
|
||||
# That timeout is 8m, not 3m: across four consecutive runs of the same
|
||||
# code, internal/assembly/boot measured 133s, 146s, 162s and then hit 180s, and
|
||||
# internal/runtime/agent 82s, 92s, 144s and 180s — a 2.2x spread from runner
|
||||
# variance alone, with four heavy packages sharing four vCPUs. At 3m the
|
||||
# step failed on slow runners without a code change; a hang still trips
|
||||
# the 15 minute job budget above.
|
||||
#
|
||||
# Every root-module `go test` that runs on Windows passes -count=1. With
|
||||
# the result cache on, go test records each file and environment access
|
||||
@@ -192,11 +191,11 @@ jobs:
|
||||
# internal/base/testenv holds every such step to it.
|
||||
- name: test (Windows smoke)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 25
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
|
||||
run: go test -count=1 -p 4 -timeout=8m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
|
||||
|
||||
# The general Windows PR smoke list intentionally omits internal/contract/tool.
|
||||
# Keep the session-temp portability contract covered without widening the
|
||||
@@ -215,12 +214,14 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
|
||||
|
||||
# Full push suite uses the same 18m package timeout as Windows smoke and a
|
||||
# 35m step budget above it. -count=1 as at the smoke step. A failure prints
|
||||
# the failed tests' output and the 15 slowest boot tests.
|
||||
# Full push suite still uses the same 8m package budget as Windows smoke:
|
||||
# agent/boot/control already hit 180s package timeouts under runner load,
|
||||
# so 3m fails on slow machines without a code regression. It runs with
|
||||
# -count=1 for the reason given at the smoke step. A hang still dies here
|
||||
# rather than on a package.
|
||||
- name: test (full)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
|
||||
timeout-minutes: 35
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
|
||||
# regression there silently tanks the cache hit rate the project is
|
||||
@@ -229,25 +230,7 @@ jobs:
|
||||
# Bound sandbox helper children in Windows tests so a failed OS-level
|
||||
# launch cannot pin the Actions step after Go's package timeout fires.
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
shell: bash
|
||||
run: |
|
||||
go test -count=1 -p 4 -timeout=18m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
|
||||
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
|
||||
|
||||
- name: test (full) failure detail
|
||||
if: failure() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
|
||||
shell: bash
|
||||
run: |
|
||||
f="$RUNNER_TEMP/full.jsonl"
|
||||
[ -s "$f" ] || exit 0
|
||||
echo "::group::output of failed tests"
|
||||
jq -rn '[inputs | select(.Test != null)] | group_by(.Package + "/" + .Test)
|
||||
| map(select(any(.[]; .Action == "fail")))[]
|
||||
| map(select(.Action == "output") | .Output) | join("")' "$f" || true
|
||||
echo "::endgroup::"
|
||||
echo "slowest internal/assembly/boot tests (seconds, name):"
|
||||
jq -rn '[inputs | select((.Package | endswith("/internal/assembly/boot")) and .Test != null and .Action == "pass")]
|
||||
| sort_by(-.Elapsed)[:15][] | "\(.Elapsed)\t\(.Test)"' "$f" || true
|
||||
run: go test -count=1 -p 4 -timeout=8m ./...
|
||||
|
||||
race:
|
||||
needs: changes
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Deploy ops events relay
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-ops-events
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.ref == 'refs/heads/platform'
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/ops-events/package-lock.json
|
||||
- name: Validate
|
||||
working-directory: workers/ops-events
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: Deploy
|
||||
working-directory: workers/ops-events
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npx wrangler deploy
|
||||
- name: Sync secrets
|
||||
working-directory: workers/ops-events
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
GITHUB_WEBHOOK_SECRET: ${{ secrets.OPS_GITHUB_WEBHOOK_SECRET }}
|
||||
OPS_LISTEN_TOKEN: ${{ secrets.OPS_LISTEN_TOKEN }}
|
||||
OPS_EMIT_TOKEN: ${{ secrets.OPS_EMIT_TOKEN }}
|
||||
run: |
|
||||
node -e 'process.stdout.write(JSON.stringify({GITHUB_WEBHOOK_SECRET:process.env.GITHUB_WEBHOOK_SECRET||null,OPS_LISTEN_TOKEN:process.env.OPS_LISTEN_TOKEN||null,OPS_EMIT_TOKEN:process.env.OPS_EMIT_TOKEN||null}))' | npx wrangler secret bulk
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
name: Sync in-app feedback
|
||||
|
||||
# Turns feedback submitted from Reasonix Studio into issues and reports issue
|
||||
# state (in progress, fixed in a tag, won't fix, duplicate) back to the worker,
|
||||
# and mirrors the reporter's replies onto their issue as bot comments.
|
||||
# Text from the public reaches the script only through the worker's JSON and
|
||||
# the GitHub API, never through a shell command line or an expression.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
- cron: '5 3 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print what would happen without creating issues or writing back
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
full:
|
||||
description: Re-read every open entry instead of only recently changed issues
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: feedback-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
if: github.repository == 'esengine/DeepSeek-Reasonix'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- name: Check secret
|
||||
id: gate
|
||||
env:
|
||||
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$FEEDBACK_ADMIN_TOKEN" ]; then
|
||||
echo "::notice::FEEDBACK_ADMIN_TOKEN is not set; feedback sync skipped"
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- if: steps.gate.outputs.run == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
# git tag --contains needs full history; the tag API would cost a request per entry.
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- if: steps.gate.outputs.run == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Sync
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
env:
|
||||
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
|
||||
# crash.reasonix.io answers runner IPs with a managed challenge; workers.dev serves only the converter's admin paths.
|
||||
FEEDBACK_BASE_URL: https://reasonix-crash-report.reasonix.workers.dev
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
FULL: ${{ github.event.inputs.full || 'false' }}
|
||||
SCHEDULE: ${{ github.event.schedule }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=()
|
||||
if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run); fi
|
||||
# The daily schedule sweeps everything the change window could have missed.
|
||||
if [ "$FULL" = "true" ] || [ "$SCHEDULE" = "5 3 * * *" ]; then args+=(--full); fi
|
||||
node scripts/feedback-sync.mjs "${args[@]}"
|
||||
@@ -5,12 +5,10 @@ run-name: Release Studio ${{ inputs.tag || github.ref_name }}
|
||||
# (studio-v*), its own environment gate, and its own rollback catalog at
|
||||
# dl.reasonix.io/studio/versions.json.
|
||||
#
|
||||
# What it must never touch: the root dl.reasonix.io/versions.json, which is the
|
||||
# desktop line's catalog. Studio artifacts land under the tag prefix, which
|
||||
# cannot collide because the tag name itself differs. The highest stable tag
|
||||
# is the repository's GitHub latest and a candidate is a prerelease; the GitHub
|
||||
# release carries no root latest.json, because the 1.x updaters' last-resort
|
||||
# URL is releases/latest/download/latest.json and must keep answering 404.
|
||||
# What it must never touch: the repository-wide "latest" release (every Studio
|
||||
# release is a prerelease) and the root dl.reasonix.io/versions.json, which is
|
||||
# the desktop line's catalog. Studio artifacts land under the tag prefix, which
|
||||
# cannot collide because the tag name itself differs.
|
||||
#
|
||||
# Windows artifacts are Authenticode-signed with a Certum SimplySign cloud
|
||||
# certificate in two stages: every PE file in the application tree, then the
|
||||
@@ -76,7 +74,8 @@ jobs:
|
||||
fi
|
||||
version="${tag#studio-}"
|
||||
# Semver's prerelease segment, group 4, read from the grammar rather
|
||||
# than the spelling.
|
||||
# than the spelling. Every Studio release carries GitHub's prerelease
|
||||
# flag, so the tag-level distinction needs a name of its own.
|
||||
candidate=false
|
||||
if [[ "${version#v}" =~ $semver ]] && [ -n "${BASH_REMATCH[4]}" ]; then
|
||||
candidate=true
|
||||
@@ -149,21 +148,10 @@ jobs:
|
||||
# vocabularies disagree (x64 / amd64) and a reviewer has to see both.
|
||||
# Absent means the runner's own, which is what Windows and Linux build.
|
||||
# artifact is the exact name publish downloads this leg's packages by.
|
||||
# goarch and unpacked belong to the Windows legs: the release
|
||||
# architecture the package is named for, and the directory
|
||||
# electron-builder leaves its bundle in (win-unpacked for x64 alone).
|
||||
include:
|
||||
- platform: windows/amd64
|
||||
runner: windows-latest
|
||||
goarch: amd64
|
||||
unpacked: win-unpacked
|
||||
artifact: studio-build-windows-amd64
|
||||
- platform: windows/arm64
|
||||
runner: windows-11-arm
|
||||
ebarch: arm64
|
||||
goarch: arm64
|
||||
unpacked: win-arm64-unpacked
|
||||
artifact: studio-build-windows-arm64
|
||||
- platform: darwin/amd64
|
||||
runner: macos-latest
|
||||
ebarch: x64
|
||||
@@ -210,13 +198,12 @@ jobs:
|
||||
desktop/frontend-next/pnpm-lock.yaml
|
||||
desktop/electron/pnpm-lock.yaml
|
||||
|
||||
# electron-builder shells out to dpkg-deb through fakeroot for the .deb and
|
||||
# to rpmbuild for the .rpm, and carries its own NSIS -- the toolchain the
|
||||
# Wails line installed by hand (GTK, WebKitGTK, nfpm, a pinned NSIS) is
|
||||
# not this shell's.
|
||||
# electron-builder shells out to dpkg-deb through fakeroot for the .deb,
|
||||
# and carries its own NSIS -- the toolchain the Wails line installed by
|
||||
# hand (GTK, WebKitGTK, nfpm, a pinned NSIS) is not this shell's.
|
||||
- name: Install Linux packaging deps
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot
|
||||
|
||||
# The same Developer ID and notary credentials the desktop line uses: one
|
||||
# certificate signs any bundle identifier its team owns, so Studio does not
|
||||
@@ -279,7 +266,6 @@ jobs:
|
||||
# kernel on a remote machine, and those routes name a release by it.
|
||||
env:
|
||||
REASONIX_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
REASONIX_GOARCH: ${{ matrix.goarch }}
|
||||
run: pnpm install --frozen-lockfile && pnpm run build:host
|
||||
|
||||
# The helper the Linux self-update runs under pkexec, staged where the
|
||||
@@ -302,14 +288,6 @@ jobs:
|
||||
if [ -n "${{ matrix.ebarch }}" ]; then arch="--${{ matrix.ebarch }}"; fi
|
||||
pnpm exec electron-builder --dir $arch -c.extraMetadata.version="${version#v}"
|
||||
|
||||
# An emulated x64 program runs on ARM64 Windows, so a bundle that starts
|
||||
# proves nothing about its architecture: every PE image in it has to be
|
||||
# built for the architecture the package is named for.
|
||||
- name: Every PE image is built for the package's architecture
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: node desktop/electron/packaging/pe.js "dist/${{ matrix.unpacked }}" "${{ matrix.goarch }}"
|
||||
|
||||
# Notarization happens to the bundle, so it sits between the two packaging
|
||||
# steps the way Authenticode does on Windows. Stapling here is what lets a
|
||||
# first launch work offline; a ticket Apple has but the bundle does not is
|
||||
@@ -366,8 +344,8 @@ jobs:
|
||||
if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: unsigned-studio-windows-${{ matrix.goarch }}-bundle
|
||||
path: dist/${{ matrix.unpacked }}
|
||||
name: unsigned-studio-windows-bundle
|
||||
path: dist/win-unpacked
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -378,24 +356,17 @@ jobs:
|
||||
if: runner.os == 'Windows' && env.SIGN_WINDOWS != 'true'
|
||||
working-directory: desktop/electron
|
||||
shell: bash
|
||||
env:
|
||||
# The arm64 installer has to be packed with the BCJ filter: 7-Zip
|
||||
# applies an ARM64 filter to ARM64 executables by default, the NSIS
|
||||
# extractor cannot decode it, and the installer then exits 0 with
|
||||
# every executable missing.
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.goarch == 'arm64' && 'BCJ' || '' }}
|
||||
run: |
|
||||
version="${{ needs.resolve.outputs.version }}"
|
||||
arch="--${{ matrix.ebarch || 'x64' }}"
|
||||
rm -rf ../../zip-src ../../dist-zip
|
||||
cp -r ../../dist/${{ matrix.unpacked }} ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip $arch \
|
||||
cp -r ../../dist/win-unpacked ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip \
|
||||
-c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip &
|
||||
zip_pid=$!
|
||||
pnpm exec electron-builder --prepackaged ../../dist/${{ matrix.unpacked }} --win nsis $arch \
|
||||
pnpm exec electron-builder --prepackaged ../../dist/win-unpacked --win nsis \
|
||||
-c.extraMetadata.version="${version#v}"
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.goarch }}.zip ../../dist/
|
||||
mv ../../dist-zip/ReasonixStudio-windows-amd64.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: runner.os != 'Windows' || env.SIGN_WINDOWS != 'true'
|
||||
@@ -411,36 +382,22 @@ jobs:
|
||||
# package check run between them on their own runners with no secrets. Both
|
||||
# signing jobs share one group with the Studio smoke test, so no two runs
|
||||
# drive the same SimplySign account at once.
|
||||
#
|
||||
# Each signing stage is a matrix over the Windows architectures with one tree
|
||||
# per job. max-parallel: 1 keeps the legs strictly serial, which the shared
|
||||
# certum-signing group needs: a group holds one running and one waiting job,
|
||||
# so two legs started together would have the waiting one cancelled by the
|
||||
# next arrival. A failed leg reruns alone; the others' results are kept.
|
||||
windows-sign-payload:
|
||||
name: sign the Windows PE files (Certum, ${{ matrix.arch }})
|
||||
name: sign the Windows PE files (Certum)
|
||||
needs: [resolve, signing-contract, build]
|
||||
if: vars.STUDIO_SIGNING_ENABLED == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: certum-signing
|
||||
cancel-in-progress: false
|
||||
# One output per architecture, because matrix legs share a job's output map:
|
||||
# a leg writes only its own and an empty value never replaces another's.
|
||||
# Recorded here because no later job can write this job's outputs: the
|
||||
# installer signing job refuses a payload artifact that no longer hashes to it.
|
||||
outputs:
|
||||
payload-digest-amd64: ${{ matrix.arch == 'amd64' && steps.verify.outputs.digest || '' }}
|
||||
payload-digest-arm64: ${{ matrix.arch == 'arm64' && steps.verify.outputs.digest || '' }}
|
||||
payload-digest: ${{ steps.verify.outputs.digest }}
|
||||
steps:
|
||||
# Only the workflow's own commit is checked out: these jobs run the signer
|
||||
# and its checks, never anything the release tag builds.
|
||||
@@ -481,7 +438,7 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: unsigned-studio-windows-${{ matrix.arch }}-bundle
|
||||
name: unsigned-studio-windows-bundle
|
||||
path: signing/app
|
||||
|
||||
# Stage one: every PE file in the application tree that Microsoft has
|
||||
@@ -528,7 +485,6 @@ jobs:
|
||||
$ErrorActionPreference = 'Stop'
|
||||
./scripts/verify-windows-authenticode.ps1 `
|
||||
-PayloadDirectory signing/app `
|
||||
-Architecture ${{ matrix.arch }} `
|
||||
-DigestPath "$env:RUNNER_TEMP/payload-digest" `
|
||||
-ExpectedThumbprint $env:CERTUM_KEY_ID `
|
||||
-ExpectedSubject $env:EXPECTED_SUBJECT
|
||||
@@ -536,7 +492,7 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
name: signed-studio-windows-payload
|
||||
path: signing/app
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
@@ -544,20 +500,10 @@ jobs:
|
||||
# No environment and no secrets: electron-builder and its dependencies run
|
||||
# here, on a runner that never held a signing session.
|
||||
windows-package:
|
||||
name: package Windows from the signed tree (${{ matrix.arch }})
|
||||
name: package Windows from the signed tree
|
||||
needs: [resolve, windows-sign-payload]
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
ebarch: x64
|
||||
unpacked: win-unpacked
|
||||
- arch: arm64
|
||||
ebarch: arm64
|
||||
unpacked: win-arm64-unpacked
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -590,8 +536,8 @@ jobs:
|
||||
# The signed payload is the whole bundle, so it is packaged as it came.
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: dist/${{ matrix.unpacked }}
|
||||
name: signed-studio-windows-payload
|
||||
path: dist/win-unpacked
|
||||
|
||||
# Both packages come from the signed tree, so the archive carries the
|
||||
# signed files too rather than a second unsigned copy. The zip is packed
|
||||
@@ -599,27 +545,24 @@ jobs:
|
||||
- name: Package Windows from the signed bundle
|
||||
working-directory: desktop/electron
|
||||
shell: bash
|
||||
env:
|
||||
# See the build job: the arm64 installer must be packed with BCJ.
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.arch == 'arm64' && 'BCJ' || '' }}
|
||||
run: |
|
||||
version="${{ needs.resolve.outputs.version }}"
|
||||
rm -rf ../../zip-src ../../dist-zip
|
||||
cp -r ../../dist/${{ matrix.unpacked }} ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip --${{ matrix.ebarch }} \
|
||||
cp -r ../../dist/win-unpacked ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip \
|
||||
-c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip &
|
||||
zip_pid=$!
|
||||
pnpm exec electron-builder --prepackaged ../../dist/${{ matrix.unpacked }} --win nsis --${{ matrix.ebarch }} \
|
||||
pnpm exec electron-builder --prepackaged ../../dist/win-unpacked --win nsis \
|
||||
-c.extraMetadata.version="${version#v}"
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../dist/
|
||||
mv ../../dist-zip/ReasonixStudio-windows-amd64.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
name: packaged-studio-windows
|
||||
path: |
|
||||
dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe
|
||||
dist/ReasonixStudio-windows-${{ matrix.arch }}.zip
|
||||
dist/ReasonixStudio-windows-amd64-installer.exe
|
||||
dist/ReasonixStudio-windows-amd64.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -628,21 +571,15 @@ jobs:
|
||||
# secrets, never in a job that holds the Certum credentials. Its outputs are
|
||||
# the hashes of the two files it checked, which the signing job requires.
|
||||
windows-verify-package:
|
||||
name: check the Windows packages against the signed tree (${{ matrix.arch }})
|
||||
name: check the Windows packages against the signed tree
|
||||
needs: [resolve, windows-sign-payload, windows-package]
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
installer-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.check.outputs.installer-sha256 || '' }}
|
||||
installer-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.check.outputs.installer-sha256 || '' }}
|
||||
archive-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.check.outputs.archive-sha256 || '' }}
|
||||
archive-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.check.outputs.archive-sha256 || '' }}
|
||||
installer-sha256: ${{ steps.check.outputs.installer-sha256 }}
|
||||
archive-sha256: ${{ steps.check.outputs.archive-sha256 }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -651,12 +588,12 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
name: signed-studio-windows-payload
|
||||
path: payload
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
name: packaged-studio-windows
|
||||
path: dist
|
||||
|
||||
# 7-Zip is the runner image's, not the tag's.
|
||||
@@ -664,26 +601,20 @@ jobs:
|
||||
id: check
|
||||
shell: pwsh
|
||||
env:
|
||||
PAYLOAD_DIGEST: ${{ needs.windows-sign-payload.outputs[format('payload-digest-{0}', matrix.arch)] }}
|
||||
PAYLOAD_DIGEST: ${{ needs.windows-sign-payload.outputs.payload-digest }}
|
||||
run: |
|
||||
./scripts/verify-windows-package.ps1 `
|
||||
-PayloadDirectory payload `
|
||||
-ExpectedPayloadDigest $env:PAYLOAD_DIGEST `
|
||||
-InstallerPath "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-PortableArchivePath "dist/ReasonixStudio-windows-${{ matrix.arch }}.zip" `
|
||||
-OutputPath $env:GITHUB_OUTPUT `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
-InstallerPath "dist/ReasonixStudio-windows-amd64-installer.exe" `
|
||||
-PortableArchivePath "dist/ReasonixStudio-windows-amd64.zip" `
|
||||
-OutputPath $env:GITHUB_OUTPUT
|
||||
|
||||
windows-sign-installer:
|
||||
name: sign the Windows installer (Certum, ${{ matrix.arch }})
|
||||
name: sign the Windows installer (Certum)
|
||||
needs: [resolve, windows-package, windows-verify-package]
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -692,8 +623,7 @@ jobs:
|
||||
cancel-in-progress: false
|
||||
# The signed installer's hash, which publish requires of what it downloads.
|
||||
outputs:
|
||||
installer-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.signed.outputs.installer-sha256 || '' }}
|
||||
installer-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.signed.outputs.installer-sha256 || '' }}
|
||||
installer-sha256: ${{ steps.signed.outputs.installer-sha256 }}
|
||||
steps:
|
||||
# Only the workflow's own commit is checked out: these jobs run the signer
|
||||
# and its checks, never anything the release tag builds.
|
||||
@@ -734,7 +664,7 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
name: packaged-studio-windows
|
||||
path: dist
|
||||
|
||||
# Only the two files windows-verify-package checked, byte for byte. This
|
||||
@@ -743,13 +673,13 @@ jobs:
|
||||
- name: Require the checked packages
|
||||
shell: pwsh
|
||||
env:
|
||||
INSTALLER_SHA256: ${{ needs.windows-verify-package.outputs[format('installer-sha256-{0}', matrix.arch)] }}
|
||||
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', matrix.arch)] }}
|
||||
INSTALLER_SHA256: ${{ needs.windows-verify-package.outputs.installer-sha256 }}
|
||||
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs.archive-sha256 }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$expected = [ordered]@{
|
||||
'ReasonixStudio-windows-${{ matrix.arch }}-installer.exe' = $env:INSTALLER_SHA256
|
||||
'ReasonixStudio-windows-${{ matrix.arch }}.zip' = $env:ARCHIVE_SHA256
|
||||
'ReasonixStudio-windows-amd64-installer.exe' = $env:INSTALLER_SHA256
|
||||
'ReasonixStudio-windows-amd64.zip' = $env:ARCHIVE_SHA256
|
||||
}
|
||||
$present = @(Get-ChildItem -LiteralPath dist -Recurse -Force | ForEach-Object { $_.Name })
|
||||
if (Compare-Object -CaseSensitive @($expected.Keys) $present) { throw "dist holds $($present -join ', ')" }
|
||||
@@ -771,7 +701,7 @@ jobs:
|
||||
shell: pwsh
|
||||
env:
|
||||
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
|
||||
run: ./scripts/sign-certum.ps1 -FilePath dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe
|
||||
run: ./scripts/sign-certum.ps1 -FilePath dist/ReasonixStudio-windows-amd64-installer.exe
|
||||
|
||||
# The session signs for any process on the runner while it is up.
|
||||
- name: Disconnect from Certum
|
||||
@@ -787,7 +717,7 @@ jobs:
|
||||
EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$installer = 'dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe'
|
||||
$installer = 'dist/ReasonixStudio-windows-amd64-installer.exe'
|
||||
./scripts/verify-windows-authenticode.ps1 `
|
||||
-FilePath $installer `
|
||||
-ExpectedThumbprint $env:CERTUM_KEY_ID `
|
||||
@@ -797,60 +727,11 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-windows-signed-${{ matrix.arch }}
|
||||
name: studio-windows-signed
|
||||
path: dist/ReasonixStudio-*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# Installs each Windows installer where it is meant to run (the same script
|
||||
# the Studio workflow runs on pull requests, against the unsigned installer).
|
||||
# The package check unpacks with the runner's modern 7-Zip, which reads
|
||||
# archives the NSIS extractor cannot, so only running the installer shows that
|
||||
# it installs every executable. No environment and no secrets.
|
||||
windows-install-smoke:
|
||||
name: install the Windows package (${{ matrix.arch }})
|
||||
needs: [resolve, build, windows-sign-installer]
|
||||
if: >-
|
||||
${{ !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' &&
|
||||
(needs.windows-sign-installer.result == 'success' ||
|
||||
(vars.STUDIO_SIGNING_ENABLED != 'true' && needs.windows-sign-installer.result == 'skipped')) }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: windows-latest
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
- name: Install, check and uninstall
|
||||
shell: pwsh
|
||||
run: |
|
||||
./scripts/install-smoke.ps1 `
|
||||
-Installer "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
|
||||
# The kernel a Studio installs on another machine. This line publishes it
|
||||
# under its own tag, because the install routes name a release by the version
|
||||
# the local build reports — and a line that ships no archive leaves every one
|
||||
@@ -876,18 +757,14 @@ jobs:
|
||||
- name: Cross-compile and archive
|
||||
env:
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
SHA: ${{ needs.resolve.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
built="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
pkg=reasonix/internal/tools/productdocs
|
||||
for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
|
||||
os="${target%/*}"; arch="${target#*/}"; ext=""
|
||||
[ "$os" = windows ] && ext=".exe"
|
||||
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=$VERSION -X main.gitCommit=${SHA:0:12} -X main.buildTimeUTC=$built -X $pkg.linkedVersion=$VERSION -X $pkg.linkedRevision=$SHA" \
|
||||
-o "reasonix$ext" ./cmd/reasonix
|
||||
-ldflags "-s -w -X main.version=$VERSION" -o "reasonix$ext" ./cmd/reasonix
|
||||
if [ "$os" = windows ]; then
|
||||
zip -qj "dist/reasonix-$os-$arch.zip" "reasonix$ext"
|
||||
else
|
||||
@@ -921,13 +798,12 @@ jobs:
|
||||
|
||||
publish:
|
||||
name: sign, publish and mirror
|
||||
needs: [resolve, build, cli, windows-sign-payload, windows-package, windows-verify-package, windows-sign-installer, windows-install-smoke]
|
||||
needs: [resolve, build, cli, windows-sign-payload, windows-package, windows-verify-package, windows-sign-installer]
|
||||
# The Windows signing jobs may be skipped only while the switch is off; with
|
||||
# it on, any of them failing, cancelled or absent blocks the release.
|
||||
if: >-
|
||||
${{ !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' &&
|
||||
needs.cli.result == 'success' && needs.windows-install-smoke.result == 'success' &&
|
||||
((needs.windows-sign-payload.result == 'success' &&
|
||||
needs.cli.result == 'success' && ((needs.windows-sign-payload.result == 'success' &&
|
||||
needs.windows-package.result == 'success' && needs.windows-verify-package.result == 'success' &&
|
||||
needs.windows-sign-installer.result == 'success') ||
|
||||
(vars.STUDIO_SIGNING_ENABLED != 'true' && needs.windows-sign-payload.result == 'skipped' &&
|
||||
@@ -989,63 +865,43 @@ jobs:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-amd64
|
||||
path: staging/studio-windows-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-arm64
|
||||
path: staging/studio-windows-arm64
|
||||
name: studio-windows-signed
|
||||
path: staging/studio-windows
|
||||
|
||||
# Any job can upload an artifact, so the signed Windows packages are
|
||||
# taken only if they are the bytes the signing jobs recorded: each
|
||||
# installer as signed, each zip as windows-verify-package checked it.
|
||||
# taken only if they are the bytes the signing jobs recorded: the
|
||||
# installer as signed, the zip as windows-verify-package checked it.
|
||||
- name: Require the recorded Windows packages
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
env:
|
||||
INSTALLER_SHA256_AMD64: ${{ needs.windows-sign-installer.outputs[format('installer-sha256-{0}', 'amd64')] }}
|
||||
ARCHIVE_SHA256_AMD64: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', 'amd64')] }}
|
||||
INSTALLER_SHA256_ARM64: ${{ needs.windows-sign-installer.outputs[format('installer-sha256-{0}', 'arm64')] }}
|
||||
ARCHIVE_SHA256_ARM64: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', 'arm64')] }}
|
||||
INSTALLER_SHA256: ${{ needs.windows-sign-installer.outputs.installer-sha256 }}
|
||||
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs.archive-sha256 }}
|
||||
run: |
|
||||
check() {
|
||||
local file="$1/$2" want="$3" got
|
||||
local file="staging/studio-windows/$1" want="$2" got
|
||||
if [[ ! "$want" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "::error title=studio-signing.package-hash-missing::no recorded SHA-256 for $2"
|
||||
echo "::error title=studio-signing.package-hash-missing::no recorded SHA-256 for $1"
|
||||
return 1
|
||||
fi
|
||||
got="$(sha256sum "$file" | awk '{print $1}')" || return 1
|
||||
if [ "$got" != "$want" ]; then
|
||||
echo "::error title=studio-signing.package-hash-mismatch::$2 is $got, the signing jobs recorded $want"
|
||||
echo "::error title=studio-signing.package-hash-mismatch::$1 is $got, the signing jobs recorded $want"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
for arch in amd64 arm64; do
|
||||
dir="staging/studio-windows-$arch"
|
||||
files="$(cd "$dir" && find . -type f | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ')"
|
||||
if [ "$files" != "ReasonixStudio-windows-$arch-installer.exe ReasonixStudio-windows-$arch.zip " ]; then
|
||||
echo "::error title=studio-signing.package-set-mismatch::studio-windows-signed-$arch holds: $files"
|
||||
exit 1
|
||||
fi
|
||||
upper="${arch^^}"
|
||||
installer="INSTALLER_SHA256_$upper"
|
||||
archive="ARCHIVE_SHA256_$upper"
|
||||
check "$dir" "ReasonixStudio-windows-$arch-installer.exe" "${!installer}"
|
||||
check "$dir" "ReasonixStudio-windows-$arch.zip" "${!archive}"
|
||||
done
|
||||
files="$(cd staging/studio-windows && find . -type f | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ')"
|
||||
if [ "$files" != "ReasonixStudio-windows-amd64-installer.exe ReasonixStudio-windows-amd64.zip " ]; then
|
||||
echo "::error title=studio-signing.package-set-mismatch::studio-windows-signed holds: $files"
|
||||
exit 1
|
||||
fi
|
||||
check ReasonixStudio-windows-amd64-installer.exe "$INSTALLER_SHA256"
|
||||
check ReasonixStudio-windows-amd64.zip "$ARCHIVE_SHA256"
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-amd64
|
||||
path: staging/studio-windows-amd64
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-arm64
|
||||
path: staging/studio-windows-arm64
|
||||
path: staging/studio-windows
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
@@ -1068,13 +924,13 @@ jobs:
|
||||
done < <(find staging/studio-* -type f -name 'ReasonixStudio-*' -print0
|
||||
find staging/cli-archives -type f -print0)
|
||||
ls -la dist
|
||||
# Two Windows artifacts (installer, portable zip) per architecture,
|
||||
# two per macOS architecture (dmg, zip) and the Linux .deb and .rpm. Fewer
|
||||
# means an artifact was lost between jobs, and a partial release is
|
||||
# worse than a failed one.
|
||||
# Two Windows artifacts (installer, portable zip), two per macOS
|
||||
# architecture (dmg, zip) and the Linux .deb. Fewer means an artifact
|
||||
# was lost between jobs, and a partial release is worse than a failed
|
||||
# one.
|
||||
count=$(find dist -type f -name 'ReasonixStudio-*' | wc -l)
|
||||
if [ "$count" -lt 10 ]; then
|
||||
echo "::error::expected 10 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb and .rpm), found $count"
|
||||
if [ "$count" -lt 7 ]; then
|
||||
echo "::error::expected 7 artifacts (Windows installer + zip, macOS dmg + zip per architecture, Linux .deb), found $count"
|
||||
exit 1
|
||||
fi
|
||||
# Each platform's update channel, and there is exactly one per
|
||||
@@ -1085,11 +941,7 @@ jobs:
|
||||
# Each macOS architecture by name, not darwin-*: a glob passes on
|
||||
# whichever one shipped, and the missing one is a platform left on
|
||||
# the version it is running with nothing saying so.
|
||||
# Each Windows architecture by name for the same reason: a glob passes
|
||||
# on whichever installer shipped.
|
||||
for required in 'ReasonixStudio-linux-*.deb' 'ReasonixStudio-linux-*.rpm' \
|
||||
'ReasonixStudio-windows-amd64-installer.exe' 'ReasonixStudio-windows-arm64-installer.exe' \
|
||||
'ReasonixStudio-windows-amd64.zip' 'ReasonixStudio-windows-arm64.zip' \
|
||||
for required in 'ReasonixStudio-linux-*.deb' 'ReasonixStudio-windows-*-installer.exe' \
|
||||
'ReasonixStudio-darwin-amd64.zip' 'ReasonixStudio-darwin-arm64.zip'; do
|
||||
find dist -type f -name "$required" | grep -q . || {
|
||||
echo "::error::no $required in the release; its packager did not run"
|
||||
@@ -1138,14 +990,12 @@ jobs:
|
||||
if [ "$HAS_R2" = "true" ]; then delta_dir=../delta; fi
|
||||
go run ./cmd/studio-manifest ../dist "${{ needs.resolve.outputs.version }}" "${{ needs.resolve.outputs.tag }}" $delta_dir
|
||||
|
||||
# The manifest stays out of the GitHub release (it is mirrored to R2
|
||||
# under <tag>/ below), and a stable release takes the repository's latest
|
||||
# only when it is the highest stable studio tag.
|
||||
# Always a prerelease: this line is not the public product line, and
|
||||
# GitHub never promotes a prerelease to the repository-wide "latest".
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
CANDIDATE: ${{ needs.resolve.outputs.candidate }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
SIGNED_WINDOWS: ${{ needs.windows-sign-installer.result == 'success' }}
|
||||
run: |
|
||||
@@ -1183,23 +1033,18 @@ jobs:
|
||||
fi
|
||||
{
|
||||
printf '%s\n\n' '---'
|
||||
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。\n\n" "$windows"
|
||||
printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
|
||||
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** 从 \`.deb\` 安装。\n\n" "$windows"
|
||||
printf '%s\n' '三个平台都能自更新:Linux 走自己的包,Windows 运行下一个安装器,macOS 替换自身 bundle。'
|
||||
} >> "$notes"
|
||||
assets=()
|
||||
for f in dist/*; do
|
||||
[ "$(basename "$f")" = latest.json ] || assets+=("$f")
|
||||
done
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
gh release edit "$TAG" --notes-file "$notes"
|
||||
gh release upload "$TAG" "${assets[@]}" --clobber
|
||||
gh release upload "$TAG" dist/* --clobber
|
||||
else
|
||||
decided="$(git tag -l 'studio-v*' | bash scripts/studio-release-flags.sh "$TAG" "$CANDIDATE")"
|
||||
mapfile -t flags <<<"$decided"
|
||||
gh release create "$TAG" "${assets[@]}" \
|
||||
gh release create "$TAG" dist/* \
|
||||
--title "Reasonix Studio $VERSION" \
|
||||
--notes-file "$notes" \
|
||||
"${flags[@]}"
|
||||
--prerelease \
|
||||
--latest=false
|
||||
fi
|
||||
|
||||
- name: Mirror to R2 and update the Studio catalog
|
||||
@@ -1243,22 +1088,6 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The notes object goes up before the catalog entry that names it, so
|
||||
# no client reads a pointer to an object that is not there yet. Notes
|
||||
# are optional: a failure here publishes the version without a pointer
|
||||
# rather than keeping the new version out of the catalog.
|
||||
notes_key="studio/notes/${VERSION#v}.md"
|
||||
notes_arg=()
|
||||
if aws s3 cp "$RUNNER_TEMP/version-notes.md" "s3://${R2_BUCKET}/${notes_key}" \
|
||||
--endpoint-url "$endpoint" \
|
||||
--content-type "text/markdown; charset=utf-8" \
|
||||
--cache-control "public, max-age=31536000, immutable" &&
|
||||
aws s3api head-object --bucket "$R2_BUCKET" --key "$notes_key" --endpoint-url "$endpoint" >/dev/null; then
|
||||
notes_arg=(20 "https://dl.reasonix.io/${notes_key}")
|
||||
else
|
||||
echo "::warning::release notes for ${VERSION} were not uploaded to the mirror; the catalog entry is published without notes"
|
||||
fi
|
||||
|
||||
# Studio's own catalog. Writing the ROOT versions.json here would hand
|
||||
# desktop users Studio releases, so the prefix is not optional.
|
||||
current=/tmp/studio-versions-current.json
|
||||
@@ -1270,7 +1099,7 @@ jobs:
|
||||
echo "no existing Studio catalog; starting a new one"
|
||||
fi
|
||||
bash scripts/update-versions-index.sh "$source" "$VERSION" "$TAG" studio \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${notes_arg[@]}" > "$next"
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$next"
|
||||
jq -e --arg v "$VERSION" '.versions | map(select(.version == $v)) | length == 1' "$next" >/dev/null
|
||||
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
|
||||
--endpoint-url "$endpoint" \
|
||||
@@ -1278,125 +1107,50 @@ jobs:
|
||||
--cache-control "public, max-age=300, stale-if-error=86400"
|
||||
echo "Studio catalog -> $VERSION"
|
||||
|
||||
# A CLI channel that moves while 1.x still publishes would have two lines
|
||||
# writing it, so every CLI job waits for this check. It runs no repository code.
|
||||
cli-gate:
|
||||
name: require the 1.x line to be frozen
|
||||
needs: resolve
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Require CLI_PUBLISH_FROZEN
|
||||
env:
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
|
||||
run: |
|
||||
if [ "$CLI_PUBLISH_FROZEN" != true ]; then
|
||||
echo "::error::STUDIO_PUBLISHES_CLI is on but CLI_PUBLISH_FROZEN is not: freeze the 1.x line first, or two lines would write the CLI channels"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The v<version> tag the update pointer needs is created here, by the release
|
||||
# identity the 1.x line also uses, because only that identity may create v*
|
||||
# tags. This job checks out nothing and runs no repository code; the one step
|
||||
# that reads RELEASE_TAG_TOKEN is written inline and only runs on the studio
|
||||
# tag or branch, through the studio-release environment's ref policy.
|
||||
cli-tag:
|
||||
name: create the CLI tag
|
||||
needs: [resolve, publish, cli-gate]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Create v<version> on the studio commit
|
||||
env:
|
||||
TAG_TOKEN: ${{ secrets.RELEASE_TAG_TOKEN }}
|
||||
RELEASE_TAG_ACTOR: ${{ vars.RELEASE_TAG_ACTOR }}
|
||||
READ_TOKEN: ${{ github.token }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
|
||||
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
|
||||
CLI_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repo=esengine/DeepSeek-Reasonix
|
||||
stable_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||
preview_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$'
|
||||
tag="v${CLI_VERSION#v}"
|
||||
if [[ ! "$tag" =~ $stable_re && ! "$tag" =~ $preview_re ]]; then
|
||||
echo "$tag publishes no CLI pointer; no tag is created"
|
||||
exit 0
|
||||
fi
|
||||
[ "$CLI_PUBLISH_FROZEN" = true ] || { echo "::error::CLI_PUBLISH_FROZEN must be true"; exit 1; }
|
||||
[ "$GITHUB_REPOSITORY" = "$repo" ] || { echo "::error::the release tag identity is only used in $repo"; exit 1; }
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/studio-v* | refs/heads/studio) ;;
|
||||
*) echo "::error::the release tag identity is only used on a studio tag or the studio branch, not $GITHUB_REF"; exit 1 ;;
|
||||
esac
|
||||
[[ "$APPROVED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::approved SHA is not a full commit SHA"; exit 1; }
|
||||
[[ "$STUDIO_TAG" == "studio-$CLI_VERSION" ]] || { echo "::error::studio tag does not match the version"; exit 1; }
|
||||
[ -n "$TAG_TOKEN" ] || { echo "::error::RELEASE_TAG_TOKEN is not configured; the default Actions token cannot create v* tags"; exit 1; }
|
||||
[[ "$RELEASE_TAG_ACTOR" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] || { echo "::error::configure RELEASE_TAG_ACTOR with the tag publisher's login"; exit 1; }
|
||||
read_api() { GH_TOKEN="$READ_TOKEN" gh api "$@"; }
|
||||
write_api() { GH_TOKEN="$TAG_TOKEN" gh api "$@"; }
|
||||
actor="$(write_api user)"
|
||||
jq -e --arg login "$RELEASE_TAG_ACTOR" '.type == "User" and (.login | ascii_downcase) == ($login | ascii_downcase)' <<<"$actor" >/dev/null ||
|
||||
{ echo "::error::the release tag credential does not identify RELEASE_TAG_ACTOR"; exit 1; }
|
||||
[ "$(write_api "repos/$repo" --jq .permissions.push)" = true ] ||
|
||||
{ echo "::error::the release tag identity cannot push to $repo"; exit 1; }
|
||||
[ "$(read_api "repos/$repo/commits/$STUDIO_TAG" --jq .sha)" = "$APPROVED_SHA" ] ||
|
||||
{ echo "::error::$STUDIO_TAG does not resolve to $APPROVED_SHA"; exit 1; }
|
||||
case "$(read_api "repos/$repo/compare/studio...$APPROVED_SHA" --jq .status)" in
|
||||
behind | identical) ;;
|
||||
*) echo "::error::$APPROVED_SHA is not on studio history"; exit 1 ;;
|
||||
esac
|
||||
# Prints the commit the tag points at; status 1 only when the tag does not exist.
|
||||
tag_commit() {
|
||||
local ref kind sha
|
||||
ref="$(read_api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha' 2>"$RUNNER_TEMP/tag.error")" || {
|
||||
grep -Eiq '404|Not Found' "$RUNNER_TEMP/tag.error" && return 1
|
||||
cat "$RUNNER_TEMP/tag.error" >&2
|
||||
exit 1
|
||||
}
|
||||
read -r kind sha <<<"$ref"
|
||||
[ "$kind" != tag ] || sha="$(read_api "repos/$repo/git/tags/$sha" --jq .object.sha)"
|
||||
echo "$sha"
|
||||
}
|
||||
if existing="$(tag_commit)"; then
|
||||
[ "$existing" = "$APPROVED_SHA" ] || { echo "::error::$tag already exists on $existing, not $APPROVED_SHA"; exit 1; }
|
||||
echo "$tag already points at $APPROVED_SHA"
|
||||
exit 0
|
||||
fi
|
||||
if ! write_api "repos/$repo/git/refs" -f "ref=refs/tags/$tag" -f "sha=$APPROVED_SHA" >/dev/null 2>"$RUNNER_TEMP/create.error"; then
|
||||
# A concurrent run may have created the same tag in the meantime.
|
||||
if existing="$(tag_commit)" && [ "$existing" = "$APPROVED_SHA" ]; then
|
||||
echo "$tag already points at $APPROVED_SHA"
|
||||
exit 0
|
||||
fi
|
||||
cat "$RUNNER_TEMP/create.error" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "created $tag at $APPROVED_SHA"
|
||||
|
||||
# The CLI's install channels, npm's `reasonix` and the Homebrew tap. The 1.x
|
||||
# line owns them until this one takes the CLI over; the repository variable
|
||||
# STUDIO_PUBLISHES_CLI=true hands them here. They run once cli-pointer has
|
||||
# created the CLI release, because the cask points at its archives. A candidate
|
||||
# goes to npm's `next` tag and leaves the tap alone: brew has no prerelease
|
||||
# channel, so a candidate cask would become the default install.
|
||||
# STUDIO_PUBLISHES_CLI=true hands them here. They run once the release
|
||||
# exists, because the cask points at its archives. A candidate goes to npm's
|
||||
# `next` tag and leaves the tap alone: brew has no prerelease channel, so a
|
||||
# candidate cask would become the default install.
|
||||
cli-channels:
|
||||
name: publish the CLI to npm and Homebrew
|
||||
needs: [resolve, publish, cli-gate, cli-tag, cli-pointer]
|
||||
needs: [resolve, publish]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Owner approval before anything reaches npm or the tap: the switch alone
|
||||
# would otherwise publish on every studio tag.
|
||||
environment: studio-cli-channels
|
||||
# id-token lets npm publish --provenance attach a Sigstore attestation; the
|
||||
# registry token still authenticates the publish itself.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
# A job naming a missing environment gets an unprotected one, so the
|
||||
# gate is verified rather than assumed; the tokens below exist only in it.
|
||||
- name: Require the approval gate on studio-cli-channels
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
resp="$(gh api -i "repos/$REPO/environments/studio-cli-channels" 2>&1)"
|
||||
status="$(printf '%s\n' "$resp" | sed -n '1s/^HTTP\/[0-9.]* \([0-9]*\).*/\1/p')"
|
||||
case "$status" in
|
||||
200) ;;
|
||||
404)
|
||||
echo "::error::environment studio-cli-channels does not exist"
|
||||
exit 1 ;;
|
||||
*)
|
||||
echo "::error::reading environment studio-cli-channels as ${GITHUB_ACTOR} failed with HTTP ${status:-unknown}"
|
||||
exit 1 ;;
|
||||
esac
|
||||
reviewers="$(printf '%s\n' "$resp" | sed '1,/^\r\{0,1\}$/d' | jq '[.protection_rules[] | select(.type == "required_reviewers") | .reviewers | length] | add // 0')"
|
||||
if ! [[ "$reviewers" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "::error::environment studio-cli-channels has no required reviewers (or its protection rules were unreadable: '${reviewers}'); refusing to publish"
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
@@ -1410,7 +1164,7 @@ jobs:
|
||||
registry-url: https://registry.npmjs.org
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.STUDIO_NPM_TOKEN }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: node npm/build.mjs "$VERSION" --publish
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -1421,24 +1175,12 @@ jobs:
|
||||
- name: Update the Homebrew cask
|
||||
if: needs.resolve.outputs.candidate != 'true'
|
||||
env:
|
||||
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
TAP_TOKEN: ${{ secrets.STUDIO_HOMEBREW_TAP_TOKEN }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --depth 1 "https://x-access-token:${TAP_TOKEN}@github.com/esengine/homebrew-reasonix.git" tap
|
||||
decision="$(node scripts/brew-cask-guard.mjs tap/Casks/reasonix.rb "${VERSION#v}")"
|
||||
case "$decision" in
|
||||
write) ;;
|
||||
skip)
|
||||
echo "the tap's cask is newer than ${VERSION#v}; leaving it"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "::error::unexpected cask guard decision: '$decision'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
bash scripts/render-brew-cask.sh "${VERSION#v}" "$TAG" cli-archives/SHA256SUMS > tap/Casks/reasonix.rb
|
||||
cd tap
|
||||
git add Casks/reasonix.rb
|
||||
@@ -1448,47 +1190,3 @@ jobs:
|
||||
fi
|
||||
git -c user.name=reasonix -c [email protected] commit -m "reasonix ${VERSION#v}"
|
||||
git push origin HEAD:main
|
||||
|
||||
# The 1.x updater accepts only a release tagged vMAJOR.MINOR.PATCH whose assets
|
||||
# sit under that tag, so the archives go to a v<version> release. Only a
|
||||
# -preview.N version moves preview; other prereleases publish nothing.
|
||||
cli-pointer:
|
||||
name: publish the CLI update pointer
|
||||
needs: [resolve, publish, cli-gate, cli-tag]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: studio-cli-pointer-${{ contains(needs.resolve.outputs.version, '-') && 'preview' || 'stable' }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write # create the CLI release; cli-tag has already made its tag
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: cli-archives
|
||||
path: cli-archives
|
||||
- name: Publish the release, its record and the pointer
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CLI_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
|
||||
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
ARCHIVES_DIR: cli-archives
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET }}
|
||||
HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
|
||||
run: |
|
||||
if [ "$HAS_R2" != "true" ]; then
|
||||
echo "::error::R2 credentials are not configured; the CLI pointer was not written"
|
||||
exit 1
|
||||
fi
|
||||
bash scripts/publish-cli-pointer.sh
|
||||
+14
-233
@@ -81,66 +81,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Real-Chromium guards that run without a kernel. Only the ones that passed
|
||||
# three consecutive local runs are wired; the rest stay manual (see
|
||||
# desktop/frontend-next/perf/README.md). Skipped unless the SPA changed.
|
||||
browser-guards:
|
||||
name: browser guards
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop/frontend-next
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: SPA changed?
|
||||
id: spa
|
||||
working-directory: .
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] &&
|
||||
git diff --quiet "origin/${{ github.base_ref }}...HEAD" -- desktop/frontend-next; then
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
name: Build the perf harness and install Chromium
|
||||
run: |
|
||||
pnpm exec vite build --config vite.perf.config.ts
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
name: Guards
|
||||
run: |
|
||||
python3 -m http.server 4399 --directory dist-perf >/dev/null 2>&1 &
|
||||
for _ in $(seq 30); do curl -sf http://localhost:4399/perf.html >/dev/null && break; sleep 1; done
|
||||
node perf/intrinsic.mjs
|
||||
node perf/contrast.mjs
|
||||
node perf/wheel.mjs
|
||||
node perf/branch-tooltip.mjs
|
||||
node perf/selfcheck.mjs branch-tooltip
|
||||
|
||||
desktop:
|
||||
name: desktop (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -181,45 +121,6 @@ jobs:
|
||||
CGO_ENABLED=0 GOOS=$os GOARCH=amd64 go build -o /dev/null ./cmd/reasonix-studio-host
|
||||
done
|
||||
|
||||
# The ARM64 leg queues on a scarcer runner pool, so a pull request runs it
|
||||
# only when it touches what the leg verifies; a push to studio always does.
|
||||
scope:
|
||||
name: package matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.pick.outputs.matrix }}
|
||||
arm64: ${{ steps.pick.outputs.arm64 }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- id: pick
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
arm64=true
|
||||
if [ "$EVENT" = pull_request ]; then
|
||||
arm64=false
|
||||
if git diff --name-only "$BASE_SHA" HEAD |
|
||||
grep -qE '^(desktop/electron/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
|
||||
arm64=true
|
||||
fi
|
||||
fi
|
||||
legs='[
|
||||
{"os":"macos-latest","arch":"arm64","platform":"darwin"},
|
||||
{"os":"ubuntu-22.04","arch":"amd64","platform":"linux"},
|
||||
{"os":"windows-latest","arch":"amd64","platform":"windows","ebarch":"x64"}
|
||||
]'
|
||||
if [ "$arm64" = true ]; then
|
||||
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"}]' <<<"$legs")"
|
||||
else
|
||||
legs="$(jq -c . <<<"$legs")"
|
||||
fi
|
||||
echo "arm64=$arm64" >> "$GITHUB_OUTPUT"
|
||||
echo "matrix={\"include\":$legs}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The packaging contract, on real artifacts rather than on the config that
|
||||
# describes them. Unsigned on purpose: signing and notarization are a
|
||||
# distribution concern and belong to the release workflow, and treating their
|
||||
@@ -227,12 +128,20 @@ jobs:
|
||||
# cross-compile while only Windows had ever produced a package.
|
||||
package:
|
||||
name: package (${{ matrix.os }})
|
||||
needs: scope
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.scope.outputs.matrix) }}
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
arch: arm64
|
||||
platform: darwin
|
||||
- os: ubuntu-22.04
|
||||
arch: amd64
|
||||
platform: linux
|
||||
- os: windows-latest
|
||||
arch: amd64
|
||||
platform: windows
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -256,23 +165,17 @@ jobs:
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/electron/pnpm-lock.yaml
|
||||
|
||||
# electron-builder shells out to dpkg-deb through fakeroot for the .deb and
|
||||
# to rpmbuild for the .rpm.
|
||||
# electron-builder shells out to dpkg-deb through fakeroot for the .deb.
|
||||
- name: Install Linux packaging deps
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm
|
||||
run: sudo apt-get update && sudo apt-get install -y fakeroot
|
||||
|
||||
- name: Build the SPA
|
||||
working-directory: desktop/frontend-next
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
|
||||
# The Windows legs name their architecture instead of taking the
|
||||
# toolchain's, so a runner whose Go or Node is the other architecture
|
||||
# fails here rather than shipping a mislabelled package.
|
||||
- name: Build the kernel the shell speaks to
|
||||
working-directory: desktop/electron
|
||||
env:
|
||||
REASONIX_GOARCH: ${{ matrix.platform == 'windows' && matrix.arch || '' }}
|
||||
run: pnpm install --frozen-lockfile && pnpm run build:host
|
||||
|
||||
# The .deb's absolute-path payload. A no-op off Linux, so it is not
|
||||
@@ -288,8 +191,7 @@ jobs:
|
||||
working-directory: desktop/electron
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.platform == 'windows' && matrix.arch == 'arm64' && 'BCJ' || '' }}
|
||||
run: pnpm run build:dist ${{ matrix.ebarch && format('--{0}', matrix.ebarch) || '' }}
|
||||
run: pnpm run build:dist
|
||||
|
||||
# The release vocabulary is GOARCH. electron-builder's own is not, and the
|
||||
# rename that reconciles them runs here rather than in the manifest.
|
||||
@@ -305,78 +207,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# An emulated x64 program runs on ARM64 Windows, so a package that merely
|
||||
# starts proves nothing about its architecture: every PE image in the
|
||||
# bundle has to be built for the one the package is named for.
|
||||
- name: Every PE image is built for the package's architecture
|
||||
if: matrix.platform == 'windows'
|
||||
run: |
|
||||
dir=$(find dist -maxdepth 1 -type d -name 'win*-unpacked' | head -1)
|
||||
test -n "$dir"
|
||||
node desktop/electron/packaging/pe.js "$dir" "${{ matrix.arch }}"
|
||||
|
||||
# The release packages a signed copy of the bundle and then reads both
|
||||
# packages back against it. The same recipe on an unsigned copy, so the
|
||||
# packaging contract (the installer's one application archive, the pinned
|
||||
# elevate.exe, every PE image's architecture) is exercised on pull
|
||||
# requests and not first by a release.
|
||||
- name: The packages read back against the bundle they were made from
|
||||
if: matrix.platform == 'windows'
|
||||
shell: bash
|
||||
env:
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.arch == 'arm64' && 'BCJ' || '' }}
|
||||
run: |
|
||||
rm -rf payload nsis-src packed zip-src
|
||||
cp -r "$(find dist -maxdepth 1 -type d -name 'win*-unpacked' | head -1)" payload
|
||||
rm -f payload/resources/elevate.exe
|
||||
# The NSIS run writes elevate.exe into the tree it packs, so each
|
||||
# packager gets a copy and payload stays what was signed.
|
||||
cp -r payload zip-src
|
||||
cp -r payload nsis-src
|
||||
cd desktop/electron
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip --${{ matrix.ebarch }} \
|
||||
-c.extraMetadata.version=0.0.0-ci -c.directories.output=../../packed-zip
|
||||
pnpm exec electron-builder --prepackaged ../../nsis-src --win nsis --${{ matrix.ebarch }} \
|
||||
-c.extraMetadata.version=0.0.0-ci -c.directories.output=../../packed
|
||||
mv ../../packed-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../packed/
|
||||
ls -la ../../packed
|
||||
|
||||
- name: verify-windows-package.ps1 accepts them
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. ./scripts/windows-signing-lib.ps1
|
||||
$digest = Get-ManifestDigest -Manifest @(Get-TreeManifest -Root payload)
|
||||
./scripts/verify-windows-package.ps1 `
|
||||
-PayloadDirectory payload `
|
||||
-ExpectedPayloadDigest $digest `
|
||||
-InstallerPath "packed/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-PortableArchivePath "packed/ReasonixStudio-windows-${{ matrix.arch }}.zip" `
|
||||
-OutputPath "$env:RUNNER_TEMP/verify-output.txt" `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
Get-Content "$env:RUNNER_TEMP/verify-output.txt"
|
||||
|
||||
# The release's own install smoke, against the unsigned installer: the
|
||||
# package is installed where it is meant to run, its tree checked, and
|
||||
# removed.
|
||||
- name: The package installs, runs and uninstalls
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
./scripts/install-smoke.ps1 `
|
||||
-Installer "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
|
||||
- name: Keep the arm64 installer for the x64 refusal check
|
||||
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-arm64-installer-ci
|
||||
path: dist/ReasonixStudio-windows-arm64-installer.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# The update channel is the signed manifest. A second answer to which
|
||||
# version is current is not a convenience. The .blockmap is the same
|
||||
# channel's differential index and carries the release prefix, so it also
|
||||
@@ -434,31 +264,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The .rpm is read back for the same two properties as the .deb: it is the
|
||||
# package update.StudioLine names, and the helper pkexec runs as root is
|
||||
# owned by root and writable by no one else, or any local user could
|
||||
# replace it.
|
||||
- name: The .rpm is Studio's own package and carries what updates it
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
rpm=$(ls dist/ReasonixStudio-linux-*.rpm)
|
||||
name=$(rpm -qp --qf '%{NAME}' "$rpm")
|
||||
if [ "$name" != "reasonix-studio" ]; then
|
||||
echo "::error::the package is $name; rpm holds that beside the installed reasonix-studio instead of upgrading it"
|
||||
exit 1
|
||||
fi
|
||||
rpm -qp --qf '[%{FILEMODES:perms} %{FILEUSERNAME} %{FILEGROUPNAME} %{FILENAMES}\n]' "$rpm" > contents.txt
|
||||
if ! grep -qxE -- '-rwxr-xr-x root root /usr/lib/reasonix-studio/reasonix-studio-update-helper' contents.txt; then
|
||||
echo "::error::no root-owned, executable, not-writable update helper at the path the polkit action names"
|
||||
grep reasonix-studio-update-helper contents.txt || echo "(not in the package at all)"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -qE -- '^-r[w-][x-]r-[x-]r-[x-] root root /usr/share/polkit-1/actions/io.reasonix.studio.update.policy$' contents.txt; then
|
||||
echo "::error::the polkit action is missing, or is not a root-owned file no one else can write"
|
||||
grep polkit contents.txt || echo "(not in the package at all)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The Electron shell sets the package's glibc floor; a Go binary linked
|
||||
# with cgo would raise it to the runner's own.
|
||||
- name: The .deb's Go binaries are statically linked
|
||||
@@ -496,27 +301,3 @@ jobs:
|
||||
echo "::error::the manifest has no entry for ${{ matrix.platform }}-${{ matrix.arch }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The arm64 installer carries one application and no other. Run anywhere but
|
||||
# ARM64 Windows it has to say so and stop, instead of exiting 0 with nothing
|
||||
# installed.
|
||||
arm64-installer-refuses-x64:
|
||||
name: arm64 installer refuses an x64 machine
|
||||
needs: [scope, package]
|
||||
if: needs.scope.outputs.arm64 == 'true'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: studio-arm64-installer-ci
|
||||
path: installer
|
||||
|
||||
- name: Run it silently
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$target = Join-Path $env:RUNNER_TEMP 'rx-refused'
|
||||
$run = Start-Process -FilePath installer/ReasonixStudio-windows-arm64-installer.exe -ArgumentList '/S', "/D=$target" -Wait -PassThru
|
||||
Write-Host "exit code $($run.ExitCode)"
|
||||
if ($run.ExitCode -eq 0) { throw 'the arm64 installer exited 0 on an x64 machine' }
|
||||
if (@(Get-ChildItem -Recurse -Force $target -ErrorAction SilentlyContinue).Count -ne 0) { throw 'the arm64 installer wrote into the target on an x64 machine' }
|
||||
@@ -33,6 +33,6 @@ jobs:
|
||||
body: |
|
||||
Refreshes the README acknowledgments tables from the GitHub contributors API.
|
||||
|
||||
- Source: `repos/esengine/DeepSeek-Reasonix/contributors?per_page=50&anon=1`
|
||||
- The repository owner, declared aliases, tool identities, bots and entries without a GitHub login are left out (`scripts/credit-identities.json`).
|
||||
- Source: `repos/esengine/DeepSeek-Reasonix/contributors?per_page=20&anon=1`
|
||||
- Anonymous contributors are shown without email addresses.
|
||||
labels: documentation
|
||||
@@ -15,7 +15,6 @@
|
||||
/runtime-resume
|
||||
/extension-protocol-gen
|
||||
/reasonix-plugin-example
|
||||
/mcp-line-counter-kit
|
||||
/reasonix-studio-host
|
||||
/signpath-contract
|
||||
/studio-delta
|
||||
|
||||
@@ -11,7 +11,6 @@ fingerprint_files:
|
||||
- .github/workflows/release-studio.yml
|
||||
- .github/workflows/studio-certum-signing-smoke.yml
|
||||
- .github/actions/setup-certum/action.yml
|
||||
- scripts/install-smoke.ps1
|
||||
- scripts/sign-certum.ps1
|
||||
- scripts/verify-windows-authenticode.ps1
|
||||
- scripts/verify-windows-package.ps1
|
||||
|
||||
@@ -181,35 +181,6 @@ porting to `studio`; do not open the same PR against both.
|
||||
5. Ensure `gofmt -l .` shows no changes
|
||||
6. Submit a pull request to that same branch
|
||||
|
||||
## Pull request policy
|
||||
|
||||
**Related changes.** If you have several related changes of one pattern, a
|
||||
tracking issue plus one pull request is easiest to review.
|
||||
|
||||
**Issue first for features.** A change that adds behaviour rather than fixing a
|
||||
defect should link an issue the maintainers have agreed to before the work
|
||||
starts. A fix for a reproducible defect may open directly, with the failing test
|
||||
in the PR. Documentation-only and test-only PRs are reviewed at lower priority.
|
||||
|
||||
**Review tiers.**
|
||||
|
||||
- Changes under the `sensitive:` paths declared in `REASONIX.md` get a full
|
||||
review plus a security review, regardless of author.
|
||||
- Every PR carries the four sections of the PR template: Cause, Blast radius,
|
||||
Neighbouring behaviours tested, Why this layer.
|
||||
- The new test must fail without the change.
|
||||
- A UI change (`desktop/frontend-next/src` or the website pages) fills the
|
||||
template's UI section: screenshots before and after, light and dark, wide
|
||||
and phone width.
|
||||
- The same section carries the line "Entries moved, hidden or removed".
|
||||
- Hiding or removing a visible control needs a linked issue and a release-notes
|
||||
line naming it, and updates `docs/STUDIO_PARITY.md` for a 1.x feature.
|
||||
- Review holds these; no check reads them.
|
||||
|
||||
**Transparency.** The PR template has an optional checkbox for disclosing AI
|
||||
assistance. It is for transparency only and does not change how a change is
|
||||
reviewed.
|
||||
|
||||
## Reporting issues
|
||||
|
||||
Open an issue on GitHub with:
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
·
|
||||
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -33,7 +31,6 @@
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -43,63 +40,23 @@
|
||||
|
||||
<br/>
|
||||
|
||||
<p align="center"><strong>Open source · MIT · runs on your computer, model of your choice</strong></p>
|
||||
<h3 align="center">An open-source coding agent for your terminal, desktop, browser and editor.</h3>
|
||||
<p align="center">It reads your project, edits files and runs commands and tests. Have it ask before each step, or work on its own within limits you set. Files its edit tools changed can be rewound. New here? Start with the Studio desktop app.</p>
|
||||
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
|
||||
<h3 align="center">A coding agent you can leave running.</h3>
|
||||
<p align="center">One local engine, four ways in — terminal, desktop app, browser, or your editor over ACP. Plan mode, permissions, a workspace sandbox and per-turn checkpoints keep a long autonomous run something you can still read and undo.</p>
|
||||
<p align="center">Maintained by <strong>Huahui Yu</strong>.</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **<https://discord.gg/XF78rEME2D>**
|
||||
>
|
||||
> QQ group **DeepSeek-Reasonix官方群** (`1093562660`) → **<https://qm.qq.com/q/i59b0z2R8s>** ([QR code](./docs/assets/qq-group.svg))
|
||||
|
||||
> Douyin: **做游戏的小鱼** (Douyin ID `22703872788`) · [QR code](./docs/assets/douyin.png)
|
||||
|
||||
<br/>
|
||||
|
||||
## ❤️Sponsor
|
||||
|
||||
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code.</td></tr></table>
|
||||
|
||||
## What it is
|
||||
|
||||
Reasonix is an open-source AI coding assistant. The program runs on your computer; the AI model is a service you configure, which receives what it reads. You describe a task in plain language; it reads code, edits files and runs commands and tests inside the project folder you choose, and shows you each step.
|
||||
|
||||
You pick the model: a DeepSeek preset is built in, any OpenAI-compatible service is one config entry, and you pay the model provider directly.
|
||||
|
||||
## What you get
|
||||
|
||||
- **Read, edit, run.** It reads code, edits files and runs tests and commands, all inside your project folder.
|
||||
- **Permission levels.** Have it ask every time, or let everyday actions run on their own; your forbid rules always hold. To see the approach first, use plan mode: it starts work only once you approve.
|
||||
- **Rewind.** A file is saved before it changes, so you can rewind by conversation turn without touching your git history. Only files its edit tools changed are covered, not what a shell command did.
|
||||
|
||||
## 30-second start
|
||||
|
||||
Download, add a model key, open a project. New here? Start with the Studio desktop app.
|
||||
|
||||
1. Download and install the desktop app, Reasonix Studio, from the [releases page](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true).
|
||||
2. Open it and enter your model provider and key when asked.
|
||||
3. Open your project folder and write what you want, for example "add retry on failure to the http client".
|
||||
4. At the level you chose, it stops and asks before anything that needs your say-so. Allow it to continue, or refuse.
|
||||
|
||||
Prefer a terminal? Download the `reasonix` command-line tool, run `reasonix setup` to pick a model, then run `reasonix`. See [Install](#install) and [Quick start](#quick-start).
|
||||
|
||||
## Four ways to use it
|
||||
|
||||
- **Desktop app (Studio).** A graphical app; the best place to start.
|
||||
- **Terminal.** A full-screen text interface; `reasonix run "task"` also works in scripts.
|
||||
- **Browser.** `reasonix web` opens a web interface on your own machine.
|
||||
- **Editor.** Install the extension in VS Code; other editors that support ACP (Agent Client Protocol, a common way for an editor to connect to an AI assistant) can connect to `reasonix acp`.
|
||||
|
||||
All four are the same program underneath. What follows is for advanced users and contributors.
|
||||
|
||||
## Versions
|
||||
|
||||
Reasonix ships on two lines. See the [version roadmap announcement](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748) for the reasoning behind the split.
|
||||
|
||||
| Line | Branch | Status | Get it |
|
||||
| --- | --- | --- | --- |
|
||||
| **Reasonix 2.x** | `studio` (this branch) | Active development | [Studio releases](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
|
||||
| **Reasonix 2.x** | `studio` (this branch) | Active development, pre-release | [Studio releases](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
|
||||
| **Reasonix 1.x** | [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) | Maintenance / stable | `npm i -g reasonix` · [desktop download](https://reasonix.io/?download=desktop#start) |
|
||||
|
||||
- **Want something stable?** Stay on 1.x. It keeps receiving bug fixes,
|
||||
@@ -118,8 +75,6 @@ or reimplemented on 2.x where they fit the new architecture.
|
||||
|
||||
## Features
|
||||
|
||||
Technical highlights for advanced users.
|
||||
|
||||
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
|
||||
declared in `reasonix.toml`. No hardcoded models.
|
||||
- **Multi-model & composable.** DeepSeek ships as a preset; any
|
||||
@@ -144,8 +99,8 @@ Studio, and editor integrations all run the same local Reasonix engine.
|
||||
|
||||
Download the package for your platform from the latest
|
||||
[Studio release](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)
|
||||
(tagged `studio-v2.*`; candidates such as `-rc.N` are marked as
|
||||
pre-releases):
|
||||
(tagged `studio-v2.*`, published as pre-releases while 2.x is in active
|
||||
development):
|
||||
|
||||
| Platform | Package | Architecture |
|
||||
| --- | --- | --- |
|
||||
@@ -276,11 +231,11 @@ A small list of folks whose work has shaped Reasonix the most — the current to
|
||||
<!-- reasonix-top-contributors:start -->
|
||||
| Contributor | Contributor | Contributor | Contributor |
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
|
||||
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | **merge-order-check** | [**light-front-theory**](https://github.com/light-front-theory) | **Yan Li** |
|
||||
| [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** | [**Bernardxu123**](https://github.com/Bernardxu123) | [**HaoyueQin**](https://github.com/HaoyueQin) |
|
||||
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**KHG420**](https://github.com/KHG420) | [**JesonChou**](https://github.com/JesonChou) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
|
||||
@@ -310,9 +265,8 @@ coffee, not a contract — donations don't buy feature priority or change how
|
||||
issues get triaged.
|
||||
|
||||
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
|
||||
- **国内** — 微信支付 / 支付宝(扫码)
|
||||
- **国内** — 微信支付(扫码)
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="180"/>
|
||||
<img src=".github/sponsor/alipay.jpg" alt="Alipay QR code" width="180"/>
|
||||
</p>
|
||||
+11
-57
@@ -18,8 +18,6 @@
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
·
|
||||
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -33,7 +31,6 @@
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -43,53 +40,13 @@
|
||||
|
||||
<br/>
|
||||
|
||||
<p align="center"><strong>开源 · MIT · 程序跑在你电脑上,模型自选</strong></p>
|
||||
<h3 align="center">终端、桌面、浏览器、编辑器里都能用的 AI 编程助手。</h3>
|
||||
<p align="center">它会读你的项目、改文件、跑命令和测试。每一步先问你,或在你划定的范围内自己做,由你选;它用编辑工具改过的文件可以退回去。第一次用,从桌面应用 Studio 开始。</p>
|
||||
<p align="center"><strong>开源 · MIT · 单个 Go 二进制</strong></p>
|
||||
<h3 align="center">可以一直开着跑的编码 Agent。</h3>
|
||||
<p align="center">一套本地引擎,四个入口——终端、桌面端、浏览器,或通过 ACP 接入你的编辑器。计划模式、权限、工作区沙箱与逐轮 checkpoint,让长时间自治运行始终可读、可撤销。</p>
|
||||
<p align="center">维护者:<strong>Huahui Yu</strong>。</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **<https://discord.gg/XF78rEME2D>**
|
||||
>
|
||||
> QQ 群 **DeepSeek-Reasonix官方群**(群号 `1093562660`)→ **<https://qm.qq.com/q/i59b0z2R8s>**([二维码](./docs/assets/qq-group.svg))
|
||||
|
||||
> 抖音:**做游戏的小鱼**(抖音号 `22703872788`)· [二维码](./docs/assets/douyin.png)
|
||||
|
||||
## ❤️赞助商
|
||||
|
||||
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。</td></tr></table>
|
||||
|
||||
## 它是什么
|
||||
|
||||
Reasonix 是一个开源的 AI 编程助手。程序跑在你的电脑上;AI 模型是你自己配置的服务,它读到的内容会发给这个服务。你用大白话描述任务,它在你选的项目文件夹里读代码、改文件、跑命令和测试,每一步都摆给你看。
|
||||
|
||||
模型你自己选:内置 DeepSeek 预设,其他兼容 OpenAI 接口的服务填一条配置就行,费用直接付给模型服务商。
|
||||
|
||||
## 你能得到什么
|
||||
|
||||
- **读、改、跑**:读你的项目、改文件、跑测试和命令,都在你选的项目文件夹里。
|
||||
- **权限分档**:每次都问你,或让日常操作自动放行;你设的禁止规则始终有效。想先看方案,就开计划模式,批准后它才动手。
|
||||
- **可回退**:改文件前先存快照,按对话轮次回退,不碰 git 历史。只覆盖编辑工具改的文件,命令行命令造成的改动不在内。
|
||||
|
||||
## 30 秒上手
|
||||
|
||||
下载、填模型密钥、打开项目。第一次用,从桌面应用 Studio 开始。
|
||||
|
||||
1. 从[发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)下载并安装桌面应用 Reasonix Studio。
|
||||
2. 打开应用,按提示填入模型服务商和密钥。
|
||||
3. 打开你的项目文件夹,写下想做的事,例如“给 http 客户端加上失败重试”。
|
||||
4. 按你选的档位,该问你的操作它会先停下来问;同意就继续,不同意就拒绝。
|
||||
|
||||
更喜欢终端的话,下载 `reasonix` 命令行后运行 `reasonix setup` 配好模型,再运行 `reasonix` 即可。详见[安装](#安装)和[快速开始](#快速开始)。
|
||||
|
||||
## 四种用法
|
||||
|
||||
- **桌面应用(Studio)**:图形界面,适合第一次使用。
|
||||
- **终端**:全屏文字界面,也能用 `reasonix run "任务"` 在脚本里运行。
|
||||
- **浏览器**:`reasonix web` 在本机打开网页界面。
|
||||
- **编辑器**:VS Code 安装扩展即可;其他支持 ACP(Agent Client Protocol,编辑器接入某个 AI 助手的通用约定)的编辑器,可连接 `reasonix acp`。
|
||||
|
||||
四种用法背后是同一套程序。下面的内容写给进阶用户和贡献者。
|
||||
|
||||
## 版本
|
||||
|
||||
@@ -97,7 +54,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
|
||||
|
||||
| 版本线 | 分支 | 状态 | 获取方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| **Reasonix 2.x** | `studio`(当前分支) | 活跃开发 | [Studio 发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
|
||||
| **Reasonix 2.x** | `studio`(当前分支) | 活跃开发,预发布 | [Studio 发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
|
||||
| **Reasonix 1.x** | [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) | 维护 / 稳定 | `npm i -g reasonix` · [桌面端下载](https://reasonix.io/?download=desktop#start) |
|
||||
|
||||
- **想要稳定**:继续使用 1.x。它会持续收到 Bug 修复、Provider / API 兼容、
|
||||
@@ -112,8 +69,6 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
|
||||
|
||||
## 特性
|
||||
|
||||
以下是写给进阶用户的技术要点。
|
||||
|
||||
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
|
||||
内核无硬编码模型。
|
||||
- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容
|
||||
@@ -133,7 +88,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
|
||||
### Reasonix Studio(2.x)
|
||||
|
||||
从最新的 [Studio 发布](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)
|
||||
下载对应平台的安装包(标签为 `studio-v2.*`;`-rc.N` 等候选版本标为预发布):
|
||||
下载对应平台的安装包(标签为 `studio-v2.*`,2.x 活跃开发期间以预发布形式发布):
|
||||
|
||||
| 平台 | 安装包 | 架构 |
|
||||
| --- | --- | --- |
|
||||
@@ -258,11 +213,11 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
<!-- reasonix-top-contributors:start -->
|
||||
| Contributor | Contributor | Contributor | Contributor |
|
||||
| --- | --- | --- | --- |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
|
||||
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
|
||||
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
|
||||
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
|
||||
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | **merge-order-check** | [**light-front-theory**](https://github.com/light-front-theory) | **Yan Li** |
|
||||
| [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** | [**Bernardxu123**](https://github.com/Bernardxu123) | [**HaoyueQin**](https://github.com/HaoyueQin) |
|
||||
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**KHG420**](https://github.com/KHG420) | [**JesonChou**](https://github.com/JesonChou) | [**SuMuxi66**](https://github.com/SuMuxi66) |
|
||||
<!-- reasonix-top-contributors:end -->
|
||||
|
||||
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
|
||||
@@ -290,10 +245,9 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature
|
||||
优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
|
||||
|
||||
- **国内** — 微信支付 / 支付宝(扫下方二维码)
|
||||
- **国内** — 微信支付(扫下方二维码)
|
||||
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="180"/>
|
||||
<img src=".github/sponsor/alipay.jpg" alt="支付宝收款码" width="180"/>
|
||||
</p>
|
||||
+3
-30
@@ -219,11 +219,9 @@ otherwise the block the model already has goes on reading as current.
|
||||
- `#<note>` in chat quick-adds an always-on instruction. The `remember` tool
|
||||
instead saves a fallible background fact (frontmatter file + `MEMORY.md`
|
||||
index). Fact `type` classifies content; independent `scope` controls whether it
|
||||
is project-only (the default) or explicitly global.
|
||||
- The saved-fact index stays out of the stable prefix and is reached through the
|
||||
`memory` tool; pinned bodies and global user/feedback bodies load into it, the
|
||||
latter as lower-priority compatibility guidance. The current turn receives a
|
||||
tail note.
|
||||
is project-only (the default) or explicitly global. The index loads into the
|
||||
stable prefix on the next session; global user/feedback bodies also load as
|
||||
lower-priority compatibility guidance. The current turn receives a tail note.
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -322,31 +320,6 @@ Use `go test ./path/to/target/` to detect cycles **before** pushing. A `[setup f
|
||||
- **Keep the PR diff minimal.** Only the files relevant to the PR's purpose — no stray changes from other branches.
|
||||
- **Amend, don't add commits, for review feedback** — keeps the commit history clean.
|
||||
|
||||
## Change discipline
|
||||
|
||||
**Assess before editing.** For every change, humans and agents list callers,
|
||||
consumers of the same state, sibling features, reload/replay/restart paths,
|
||||
error and edge states, TUI/CLI and desktop equivalents, and wire mirrors.
|
||||
|
||||
A fix that breaks a neighbouring behaviour is a failed fix.
|
||||
|
||||
**Test the neighbourhood.** UI changes add or extend interaction tests for
|
||||
adjacent functions of the same component or flow (composer: submit, clear,
|
||||
queue, withdraw, restore, steer, failure, replay after reload) and run the whole
|
||||
affected suite.
|
||||
|
||||
Write tests first; they must fail without the change.
|
||||
|
||||
**Fix at the owning layer.** Use the existing unified pattern: no special-case
|
||||
flags, duplicated paths, or workarounds at the symptom site.
|
||||
|
||||
If the design is duplicated or wrong, make the smallest unifying change:
|
||||
one path instead of two, deleting what becomes dead. Use current language and
|
||||
framework idioms; do not add a new pattern where one exists.
|
||||
|
||||
**PR evidence.** Every PR description carries: Cause, Blast radius,
|
||||
Neighbouring behaviours tested, and Why this layer.
|
||||
|
||||
## Reasonix host checks
|
||||
|
||||
The paths below decide what the agent is allowed to do — a wrong change here
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
Two questions about long-horizon memory, and the harness that makes their
|
||||
answers trustworthy.
|
||||
|
||||
-mode=preflight build all 18 tasks under every arm and check the contract
|
||||
-mode=preflight build all 12 tasks under every arm and check the contract
|
||||
-mode=snippet does a rank-1 hit hand over the whole answer?
|
||||
-mode=adversarial send a model after the answer through every host surface
|
||||
-mode=run-search Experiment R: is searchable canonical a real capability?
|
||||
-mode=run-boundary Experiment I, paired: the 6 efficiency tasks across their boundary
|
||||
-mode=run-index Experiment I, full: the 6 efficiency tasks x 4 budgets
|
||||
-mode=run-boundary Experiment I, paired: 6 tasks across their own cue boundary
|
||||
-mode=run-index Experiment I, full: 6 tasks x 4 index budgets
|
||||
-mode=calibrate find each index task's PlantAfterGen for its cue tier
|
||||
|
||||
`-dry` drives any run mode with a scripted provider, for nothing. Real runs need
|
||||
@@ -31,8 +31,8 @@ The most likely reason is that two of the six index tasks carry heavy-tail
|
||||
stopping behaviour that swamps the effect being measured.
|
||||
|
||||
Existing evidence is not sufficient to tune the shipped 1% budget in either
|
||||
direction. Reopening this wants a new corpus, not more samples: that substrate
|
||||
now exists, two qualifying tasks per cue tier — see "Index corpus v2" below.
|
||||
direction. Reopening this wants a new corpus, not more samples: see
|
||||
"Index corpus v2" below.
|
||||
|
||||
**Things that turned out not to be problems**, each after being measured rather
|
||||
than argued about:
|
||||
@@ -74,83 +74,11 @@ scanned for answer literals before the first provider request and again after
|
||||
the turn. Any answer reaching the model through a tool that is not `recall`
|
||||
marks the run contaminated and takes it out of every statistic.
|
||||
|
||||
## Index corpus v2
|
||||
## Index corpus v2, if this is reopened
|
||||
|
||||
The efficiency substrate is six tasks that hold on both arms: `i37-dispatchlag`
|
||||
and `i118-fencelag` (quarter), `i106-budgetseal` and `i70-ingestfloor` (half),
|
||||
`i47-tenantlease` and `i112-lagquota` (default).
|
||||
|
||||
They came out of five batches of candidates, each screened the same way: one
|
||||
`-mode=run-index` pass over the batch, then two more only for the candidates that
|
||||
were clean on both boundary arms, and only the candidates that held on both arms
|
||||
in all three passes stayed. A candidate that does not hold is dropped, not
|
||||
promoted. The numbers are candidates, per tier:
|
||||
|
||||
| batch | quarter | half | default |
|
||||
| --- | --- | --- | --- |
|
||||
| first pool, `i31`-`i54` | 1 of 8 | 0 of 8 | 2 of 8 |
|
||||
| second pool, `i61`-`i84` | 1 of 4 screened | 4 of 8 | 0 of 8 |
|
||||
| third pool, `i85`-`i108` | 2 of 8 | 8 of 16 | none authored |
|
||||
| replacement pool, `i109`-`i116` | - | - | 4 of 8 |
|
||||
| replacement pool, `i92`, `i117`-`i124` | 3 of 9 | - | - |
|
||||
|
||||
That table counts candidates that held on both arms in all three passes, which is
|
||||
not the size of the substrate. 89 candidates were authored, 77 were screened (the
|
||||
second pool's quarter tier stopped at 4 of 8 and its default tier was never run),
|
||||
and 25 held: 7 quarter, 12 half, 6 default. The substrate keeps two per tier, so
|
||||
the other 19 survivors are spares rather than corpus members. Candidates from the
|
||||
earlier round are not counted here.
|
||||
|
||||
| task | tier | cue present | cue absent |
|
||||
| --- | --- | ---: | ---: |
|
||||
| `i37-dispatchlag` | quarter | 3/3 | 3/3 |
|
||||
| `i118-fencelag` | quarter | 3/3 | 3/3 |
|
||||
| `i106-budgetseal` | half | 3/3 | 3/3 |
|
||||
| `i70-ingestfloor` | half | 3/3 | 3/3 |
|
||||
| `i47-tenantlease` | default | 3/3 | 3/3 |
|
||||
| `i112-lagquota` | default | 3/3 | 3/3 |
|
||||
|
||||
Across their 36 judged cells: `SnippetStop` 23, `SnippetThenRead` 10 and
|
||||
`DefensiveRead` 3 - every cell clean, nothing post-sufficient and nothing
|
||||
`NeverSufficient`.
|
||||
|
||||
Four of the first six were flagged in review for sharing answer values, probe
|
||||
queries and prompt wording within a tier. Separating them and re-screening those
|
||||
four changed two of them: `i37` and `i47` held, `i62` and `i51` did not, so they
|
||||
were replaced - `i90-recoverylag` was tried first and did not hold either, and
|
||||
`i118-fencelag` and `i112-lagquota` are what did. The separation was not
|
||||
cosmetic: the shared wording had been carrying two of the six.
|
||||
|
||||
Nothing in the task shape separates the survivors from the rest: within the half
|
||||
tier, plain-number answers survived 0 of 8 in the first pool and 4 of 8 in the
|
||||
second, and codename answers survived 8 of 16 in the third. An earlier claim here
|
||||
that the answer kind decides whether a candidate holds does not survive this
|
||||
table and is withdrawn. The dirty class was `PostSufficientRetrieval` in most
|
||||
failures - the model re-checking a value it already had - but which tasks hit it
|
||||
was not predictable from their shape at this sample.
|
||||
|
||||
`i01`-`i06` are the frozen study's six tasks, kept as `roleStopping`. Measured
|
||||
once each with `-mode=run-index -task <id>`, per task and per arm:
|
||||
|
||||
| task | tier | cue present | cue absent |
|
||||
| --- | --- | --- | --- |
|
||||
| `i01-transport-fallback` | quarter | SnippetStop | DefensiveRead |
|
||||
| `i02-scheduler-handoff` | quarter | PostSufficientRetrieval | PostSufficientRetrieval |
|
||||
| `i03-coalescing` | half | PostSufficientRetrieval | PostSufficientRunaway |
|
||||
| `i04-recovery-fence` | half | DefensiveRead | PostSufficientRunaway |
|
||||
| `i05-cache-probe` | default | SnippetThenRead | PostSufficientRetrieval |
|
||||
| `i06-dispatch-handoff` | default | SnippetStop | PostSufficientRetrieval |
|
||||
|
||||
Five of the six show post-sufficient behaviour in at least one arm (`i02`-`i06`),
|
||||
and `i01` is clean on both. The cue-present arm is clean for four of them
|
||||
(`i01`, `i04`, `i05`, `i06`), which is why they stay in the corpus as the
|
||||
stopping side rather than being retitled. All six are excluded from `run-index`
|
||||
and `run-boundary` batches and stay measurable one at a time with `-task <id>`. A
|
||||
task added to the corpus declares a role, so it cannot arrive without one.
|
||||
|
||||
A task qualifies only if it is clean on both of its boundary arms - the
|
||||
cue-present run and the cue-absent run - in every run of a screening batch. Clean
|
||||
means a `stopping_class` other than `PostSufficientRetrieval` or
|
||||
The present index tasks are not a fit substrate: two of six carry stopping
|
||||
behaviour that dominates the signal. A task should qualify only if a run-index
|
||||
run reports a `stopping_class` other than `PostSufficientRetrieval` or
|
||||
`PostSufficientRunaway`: `SnippetStop`, `SnippetThenRead` and `DefensiveRead`
|
||||
all qualify, and a defensive read is deliberately not counted as waste. The
|
||||
class comes from the three counts in that report — `searches_after_sufficient`,
|
||||
@@ -158,7 +86,8 @@ class comes from the three counts in that report — `searches_after_sufficient`
|
||||
sufficiency was reached and how many rounds followed it. The remaining
|
||||
requirements still apply — successful tool call, answer in the output rather
|
||||
than the cue, rank 1, `FirstHitCoverage` complete, one or two simple values,
|
||||
nothing in the workspace.
|
||||
nothing in the workspace. Keep i03 and i04 — as a stopping corpus, not an
|
||||
efficiency one.
|
||||
|
||||
`-mode=calibrate` is not a screening tool: it builds and reads back the
|
||||
fixtures, and reads nothing else, so its cue-visibility profile is the same for
|
||||
|
||||
@@ -26,12 +26,6 @@ const (
|
||||
tierDefault = "default" // visible at default only
|
||||
)
|
||||
|
||||
// role places an index task in the efficiency substrate or the stopping corpus.
|
||||
const (
|
||||
roleEfficiency = "efficiency"
|
||||
roleStopping = "stopping"
|
||||
)
|
||||
|
||||
// plantKind is how a task's target enters the transcript.
|
||||
const (
|
||||
plantAssistant = "assistant" // the model's own account: never index-addressed
|
||||
@@ -51,8 +45,6 @@ type contextTask struct {
|
||||
AnswerVars []string
|
||||
CueVar string
|
||||
CueTier string
|
||||
// Role is roleEfficiency or roleStopping; it decides the batch a task enters.
|
||||
Role string
|
||||
// PlantAfterGen ages the target: mergeFoldIndex trims from its oldest end,
|
||||
// so what follows a cue decides which budget addresses it. Frozen, because
|
||||
// a run that searched for it would let the tested code move its goalposts.
|
||||
@@ -129,20 +121,6 @@ func num(name string, lo, hi int) varSpec {
|
||||
return varSpec{Name: name, Kind: varInt, Min: lo, Max: hi}
|
||||
}
|
||||
|
||||
// efficiencyTasks is the substrate a study measures on. Every task in it
|
||||
// qualifies under the corpus bar; the stopping corpus is deliberately excluded,
|
||||
// because a task carrying post-sufficient stopping behaviour swamps the effect
|
||||
// an efficiency batch exists to measure.
|
||||
func efficiencyTasks() []contextTask {
|
||||
var out []contextTask
|
||||
for _, t := range indexTasks() {
|
||||
if t.Role == roleEfficiency {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func searchTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
@@ -214,104 +192,10 @@ func searchTasks() []contextTask {
|
||||
}
|
||||
}
|
||||
|
||||
// efficiencyIndexTasks is what a study measures on: two tasks per cue tier.
|
||||
func efficiencyIndexTasks() []contextTask {
|
||||
out := indexQuarterTasks()
|
||||
out = append(out, indexHalfTasks()...)
|
||||
out = append(out, indexDefaultTasks()...)
|
||||
return out
|
||||
}
|
||||
|
||||
// indexQuarterTasks holds the quarter-tier literals.
|
||||
func indexQuarterTasks() []contextTask {
|
||||
func indexTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i37-dispatchlag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/dispatchlag,它打印出的 dispatch lag epoch 是多少?",
|
||||
ProbeQuery: "dispatchlag dispatch lag epoch",
|
||||
Vars: []varSpec{code("cue", "dispatchlag"), code("epoch", "cobalt")},
|
||||
AnswerVars: []string{"epoch"}, CueVar: "cue", CueTier: tierQuarter,
|
||||
PlantAfterGen: 4,
|
||||
Plant: plantCall, CallID: "p37", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/dispatchlag {{cue}}"}`,
|
||||
PlantBody: "dispatch lag epoch = {{epoch}}",
|
||||
},
|
||||
{
|
||||
ID: "i118-fencelag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里运行过 ./scripts/fencelag,它打印出的 fence lag 是多少?",
|
||||
ProbeQuery: "fencelag fence lag",
|
||||
Vars: []varSpec{code("cue", "fencelag"), code("v", "slate")},
|
||||
AnswerVars: []string{"v"}, CueVar: "cue", CueTier: tierQuarter,
|
||||
PlantAfterGen: 4,
|
||||
Plant: plantCall, CallID: "x118", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/fencelag {{cue}}"}`,
|
||||
PlantBody: "fence lag = {{v}}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// indexHalfTasks holds the half-tier literals.
|
||||
func indexHalfTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i70-ingestfloor", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/ingestfloor,它打印出的 route quota 是多少?",
|
||||
ProbeQuery: "ingestfloor quota",
|
||||
Vars: []varSpec{code("cue", "ingestfloor"), num("quota", 1200, 9800)},
|
||||
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
|
||||
PlantAfterGen: 2,
|
||||
Plant: plantCall, CallID: "q70", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/ingestfloor {{cue}}"}`,
|
||||
PlantBody: "route quota = {{quota}}",
|
||||
},
|
||||
{
|
||||
ID: "i106-budgetseal", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/budgetseal,它打印出的 route quota 是多少?",
|
||||
ProbeQuery: "budgetseal route quota",
|
||||
Vars: []varSpec{code("cue", "budgetseal"), code("quota", "amber")},
|
||||
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
|
||||
PlantAfterGen: 2,
|
||||
Plant: plantCall, CallID: "h106", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/budgetseal {{cue}}"}`,
|
||||
PlantBody: "route quota = {{quota}}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// indexDefaultTasks holds the default-tier literals.
|
||||
func indexDefaultTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i47-tenantlease", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/tenantlease,它打印出的 tenant lease(秒)是多少?",
|
||||
ProbeQuery: "tenantlease tenant lease",
|
||||
Vars: []varSpec{code("cue", "tenantlease"), {Name: "lease", Kind: varDuration, Min: 15, Max: 300}},
|
||||
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
|
||||
PlantAfterGen: 0,
|
||||
Plant: plantCall, CallID: "p47", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/tenantlease {{cue}}"}`,
|
||||
PlantBody: "tenant lease = {{lease}}",
|
||||
},
|
||||
{
|
||||
ID: "i112-lagquota", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/lagquota,它打印出的 lagquota lease 是多少?",
|
||||
ProbeQuery: "lagquota lagquota lease",
|
||||
Vars: []varSpec{code("cue", "lagquota"), {Name: "lease", Kind: varDuration, Min: 20, Max: 420}},
|
||||
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
|
||||
PlantAfterGen: 0,
|
||||
Plant: plantCall, CallID: "d112", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/lagquota {{cue}}"}`,
|
||||
PlantBody: "lagquota lease = {{lease}}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// stoppingIndexTasks is the frozen study's six tasks, kept out of efficiency
|
||||
// batches and measurable on their own with -task <id>.
|
||||
func stoppingIndexTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i01-transport-fallback", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
ID: "i01-transport-fallback", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
Prompt: "transport fallback probe 当时报告的 selector 是什么?",
|
||||
ProbeQuery: "transport fallback probe selector",
|
||||
Vars: []varSpec{code("cue", "tfprobe"), code("selector", "fern")},
|
||||
@@ -322,7 +206,7 @@ func stoppingIndexTasks() []contextTask {
|
||||
PlantBody: "fallback selector = {{selector}}",
|
||||
},
|
||||
{
|
||||
ID: "i02-scheduler-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
ID: "i02-scheduler-handoff", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
Prompt: "scheduler handoff 当时记录的 grace 和 marker 是什么?",
|
||||
ProbeQuery: "scheduler handoff grace marker",
|
||||
Vars: []varSpec{
|
||||
@@ -336,7 +220,7 @@ func stoppingIndexTasks() []contextTask {
|
||||
PlantBody: "handoff grace = {{grace}}\nmarker = {{marker}}",
|
||||
},
|
||||
{
|
||||
ID: "i03-coalescing", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
ID: "i03-coalescing", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
Prompt: "job completion 的 coalescing probe 最后记录的 window 和 marker 是多少?",
|
||||
ProbeQuery: "coalescing policy completion window",
|
||||
Vars: []varSpec{
|
||||
@@ -350,7 +234,7 @@ func stoppingIndexTasks() []contextTask {
|
||||
PlantBody: "completion coalesce window = {{window}}\nmarker = {{marker}}",
|
||||
},
|
||||
{
|
||||
ID: "i04-recovery-fence", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
ID: "i04-recovery-fence", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
Prompt: "recovery fence 那次检查报告的 marker 和两个 epoch 是什么?",
|
||||
ProbeQuery: "recovery fence epoch check",
|
||||
Vars: []varSpec{
|
||||
@@ -364,7 +248,7 @@ func stoppingIndexTasks() []contextTask {
|
||||
PlantBody: "{{marker}}\nexpected epoch {{expected}}\nobserved epoch {{observed}}",
|
||||
},
|
||||
{
|
||||
ID: "i05-cache-probe", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
ID: "i05-cache-probe", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
Prompt: "cache probe 当时记录的 prefix salt 和 scope 是什么?",
|
||||
ProbeQuery: "cache probe prefix salt scope",
|
||||
Vars: []varSpec{code("cue", "cacheprobe"), code("salt", "comet"), code("scope", "lineage")},
|
||||
@@ -375,7 +259,7 @@ func stoppingIndexTasks() []contextTask {
|
||||
PlantBody: "prefix salt = {{salt}}\nscope = {{scope}}",
|
||||
},
|
||||
{
|
||||
ID: "i06-dispatch-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
ID: "i06-dispatch-handoff", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
Prompt: "dispatch handoff probe 当时确认的 mode 和 marker 是什么?",
|
||||
ProbeQuery: "dispatch handoff probe mode",
|
||||
Vars: []varSpec{code("cue", "dispatchhandoff"), code("mode", "trigger"), code("marker", "iris")},
|
||||
@@ -388,12 +272,6 @@ func stoppingIndexTasks() []contextTask {
|
||||
}
|
||||
}
|
||||
|
||||
// indexTasks is the whole index corpus, the efficiency substrate first.
|
||||
func indexTasks() []contextTask {
|
||||
out := efficiencyIndexTasks()
|
||||
return append(out, stoppingIndexTasks()...)
|
||||
}
|
||||
|
||||
func allTasks() []contextTask {
|
||||
return append(searchTasks(), indexTasks()...)
|
||||
}
|
||||
@@ -454,9 +332,6 @@ func validateCorpus() error {
|
||||
if t.CueVar == "" {
|
||||
return fmt.Errorf("%s: an index task needs a cue var", t.ID)
|
||||
}
|
||||
if t.Role != roleEfficiency && t.Role != roleStopping {
|
||||
return fmt.Errorf("%s: an index task needs a role (%s or %s)", t.ID, roleEfficiency, roleStopping)
|
||||
}
|
||||
if v, _ := tierScales(t.CueTier); v == nil {
|
||||
return fmt.Errorf("%s: unknown cue tier %q", t.ID, t.CueTier)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func (p *digestProvider) Name() string { return "fixture-digest" }
|
||||
func (p *digestProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.calls++
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Earlier work\nEarlier work continued across several files; nothing outstanding was recorded."}
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "Earlier work continued across several files; nothing outstanding was recorded."}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
mathrand "math/rand"
|
||||
"strings"
|
||||
)
|
||||
@@ -91,25 +90,15 @@ func seededRand(parts ...string) *mathrand.Rand {
|
||||
// liveRand is the generator a paid run uses: every run gets values that existed
|
||||
// nowhere before it started.
|
||||
func liveRand() *mathrand.Rand {
|
||||
rng, err := liveRandFrom(rand.Reader)
|
||||
if err != nil {
|
||||
panic("contextbench: no entropy for a live run: " + err.Error())
|
||||
}
|
||||
return rng
|
||||
}
|
||||
|
||||
// liveRandFrom seeds a generator from an entropy source, so what a run's values
|
||||
// are unpredictable from is the source and nothing else.
|
||||
func liveRandFrom(entropy io.Reader) (*mathrand.Rand, error) {
|
||||
var seed [8]byte
|
||||
if _, err := io.ReadFull(entropy, seed[:]); err != nil {
|
||||
return nil, err
|
||||
if _, err := rand.Read(seed[:]); err != nil {
|
||||
panic("contextbench: no entropy for a live run: " + err.Error())
|
||||
}
|
||||
var n int64
|
||||
for _, b := range seed {
|
||||
n = n<<8 | int64(b)
|
||||
}
|
||||
return mathrand.New(mathrand.NewSource(n)), nil
|
||||
return mathrand.New(mathrand.NewSource(n))
|
||||
}
|
||||
|
||||
// instantiate substitutes {{name}} and refuses anything left over. The grammar
|
||||
|
||||
@@ -62,14 +62,7 @@ func main() {
|
||||
case "adversarial":
|
||||
os.Exit(runAdversarial(root))
|
||||
case "run-index":
|
||||
batch := tasks
|
||||
if *only == "" {
|
||||
// A batch measures the efficiency substrate only. Naming one task
|
||||
// with -task runs that task, role included, so the stopping corpus
|
||||
// stays measurable on its own.
|
||||
batch = efficiencyTasks()
|
||||
}
|
||||
os.Exit(runExperiment(experimentIndex, root, *dry, batch))
|
||||
os.Exit(runExperiment(experimentIndex, root, *dry, tasks))
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
|
||||
os.Exit(2)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"maps"
|
||||
mathrand "math/rand"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -22,24 +19,16 @@ func TestCorpusIsWellFormed(t *testing.T) {
|
||||
if got := len(searchTasks()); got != 6 {
|
||||
t.Errorf("search experiment has %d tasks, want 6", got)
|
||||
}
|
||||
if got := len(indexTasks()); got != 12 {
|
||||
t.Errorf("index experiment has %d tasks, want 12", got)
|
||||
if got := len(indexTasks()); got != 6 {
|
||||
t.Errorf("index experiment has %d tasks, want 6", got)
|
||||
}
|
||||
stopping := map[string]bool{}
|
||||
tiers := map[string]int{}
|
||||
for _, task := range indexTasks() {
|
||||
if task.Role == roleStopping {
|
||||
stopping[task.ID] = true
|
||||
continue
|
||||
}
|
||||
tiers[task.CueTier]++
|
||||
}
|
||||
if !stopping["i03-coalescing"] || !stopping["i04-recovery-fence"] {
|
||||
t.Errorf("the stopping corpus is %v; it must keep i03-coalescing and i04-recovery-fence", stopping)
|
||||
}
|
||||
// Two per tier, over the efficiency substrate, is what makes the staircase
|
||||
// readable: the same task can be compared across the boundary its cue sits
|
||||
// on, instead of two different questions being compared across two arms.
|
||||
// Two per tier is what makes the staircase readable: the same task can be
|
||||
// compared across the boundary its cue sits on, instead of two different
|
||||
// questions being compared across two arms.
|
||||
for _, tier := range []string{tierQuarter, tierHalf, tierDefault} {
|
||||
if tiers[tier] != 2 {
|
||||
t.Errorf("tier %s has %d tasks, want 2", tier, tiers[tier])
|
||||
@@ -47,21 +36,6 @@ func TestCorpusIsWellFormed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The role decides which batch a task enters; the literal lists are only where
|
||||
// it is declared, so the two must not drift.
|
||||
func TestIndexListsMatchTheirRoles(t *testing.T) {
|
||||
for _, task := range append(append(indexQuarterTasks(), indexHalfTasks()...), indexDefaultTasks()...) {
|
||||
if task.Role != roleEfficiency {
|
||||
t.Errorf("%s sits in an efficiency list with role %q", task.ID, task.Role)
|
||||
}
|
||||
}
|
||||
for _, task := range stoppingIndexTasks() {
|
||||
if task.Role != roleStopping {
|
||||
t.Errorf("%s sits in the stopping list with role %q", task.ID, task.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The invariant the whole instantiation layer exists for: no literal a scorer
|
||||
// needs may be readable in this repository before a run starts.
|
||||
func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
|
||||
@@ -85,46 +59,20 @@ func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A run's values are unpredictable from a previous run only if the live
|
||||
// generator is seeded from entropy. Comparing answers across two instances
|
||||
// cannot assert that: a task whose answer is a bounded number collides by
|
||||
// chance (1/8601 to 1/286 per task), so the check is on the seed source.
|
||||
func TestLiveRandDrawsFromEntropy(t *testing.T) {
|
||||
a, b := liveRand(), liveRand()
|
||||
for range 4 {
|
||||
if a.Int63() != b.Int63() {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("two live generators produced the same stream: they are seeded alike")
|
||||
}
|
||||
|
||||
// The source alone decides an instance: the same entropy gives the same
|
||||
// answers, different entropy gives different values.
|
||||
func TestLiveRandSourceDecidesTheInstance(t *testing.T) {
|
||||
same := func() *mathrand.Rand {
|
||||
rng, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{7}, 8)))
|
||||
// Two runs of the same task must not share an answer, or one leaked run leaks
|
||||
// every future one.
|
||||
func TestInstancesDifferBetweenRuns(t *testing.T) {
|
||||
for _, task := range allTasks() {
|
||||
a, err := instantiateTask(task, liveRand())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rng
|
||||
}
|
||||
other, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{9}, 8)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := liveRandFrom(bytes.NewReader(nil)); err == nil {
|
||||
t.Fatal("an empty entropy source was accepted")
|
||||
}
|
||||
for _, task := range allTasks() {
|
||||
a, _ := instantiateTask(task, same())
|
||||
b, _ := instantiateTask(task, same())
|
||||
if !slices.Equal(a.AnswerMarkers, b.AnswerMarkers) || !maps.Equal(a.Vars, b.Vars) {
|
||||
t.Errorf("%s: the same entropy gave different instances", task.ID)
|
||||
b, err := instantiateTask(task, liveRand())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ := instantiateTask(task, other)
|
||||
if maps.Equal(a.Vars, c.Vars) {
|
||||
t.Errorf("%s: different entropy gave the same values", task.ID)
|
||||
if slices.Equal(a.AnswerMarkers, b.AnswerMarkers) {
|
||||
t.Errorf("%s produced the same answers twice: %v", task.ID, a.AnswerMarkers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ func runBoundaries(root string) int {
|
||||
|
||||
var all []contextMetrics
|
||||
var cueSide, noCueSide []contextMetrics
|
||||
for _, t := range efficiencyTasks() {
|
||||
for _, t := range indexTasks() {
|
||||
cue, noCue := boundaryPair(t.CueTier)
|
||||
for _, side := range []struct {
|
||||
scale string
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
alpha=QORVEX-7741
|
||||
beta=MIRALD-3208
|
||||
gamma=TESSIK-9155
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "为本项目新建一个数据库迁移脚本文件(空文件即可),文件名必须符合项目的迁移命名规范。"
|
||||
class = "cjk"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-CJK5T"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Write the deploy region for this project (just the region string) into answer.txt."
|
||||
class = "conflict"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-CONF2P"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Write the command to install this project's dependencies into answer.txt."
|
||||
class = "contradiction"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-CONTRA"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Write this project's canonical fast-check command (the exact command line, nothing else) into answer.txt."
|
||||
class = "distractor"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-DIST5Q"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Write this project's canonical fast-check command (the exact command line, nothing else) into answer.txt."
|
||||
class = "exact"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-EXACT7Q"]
|
||||
@@ -1,3 +1,4 @@
|
||||
prompt = "Add a function add(a, b) returning a+b to util.py."
|
||||
class = "generic"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Copy the exact server timeout configuration line from config/server.conf into answer.txt, unchanged."
|
||||
class = "history"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-HIST6M"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Which tool do we install dependencies with here? Write the exact install command into answer.txt."
|
||||
class = "paraphrase"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-PARA3X"]
|
||||
@@ -1,5 +1,6 @@
|
||||
prompt = "Create a file named notes.txt containing one line: hello."
|
||||
class = "pinned"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-PIN4D"]
|
||||
memory_markers_prefix = true
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Confirm the current release branch of this repo and write it into answer.txt."
|
||||
class = "stale"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-STALE1"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Set the retry max backoff to 250 in this project's configuration."
|
||||
class = "symbol"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-SYM9K"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Write the current API base URL for this project into answer.txt."
|
||||
class = "update"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-UPD8R"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "查一下灰度的放量比例,写进 answer.txt。"
|
||||
class = "v1miss"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-VM1GR"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "这个项目的部署审批人是谁?把审批人的用户名写进 answer.txt。"
|
||||
class = "v1miss"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-VM1XL"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Set the max idle connection seconds to 30 in this project's configuration."
|
||||
class = "v1miss"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-VM1SYM"]
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,2 +0,0 @@
|
||||
value=7
|
||||
file=limits/budget.py
|
||||
@@ -1,2 +0,0 @@
|
||||
handler=h07.py
|
||||
missing=validate_payload
|
||||
@@ -1,8 +0,0 @@
|
||||
def wire_fee(amount):
|
||||
"""Return the wire fee for `amount`: the greater of 15.0 and 0.1% of it.
|
||||
|
||||
A zero or negative amount has no wire fee, so it returns 0.0.
|
||||
"""
|
||||
if amount <= 0:
|
||||
return 0.0
|
||||
return max(15.0, amount * 0.001)
|
||||
@@ -1,13 +0,0 @@
|
||||
def convert(amount, rate):
|
||||
"""Convert `amount` at `rate` and return the product.
|
||||
|
||||
Both units belong to the caller. A negative amount converts unchanged.
|
||||
"""
|
||||
return amount * rate
|
||||
|
||||
def spread(bid, ask):
|
||||
"""Return `ask - bid`, the spread between the two quoted prices.
|
||||
|
||||
A negative result means the bid is above the ask.
|
||||
"""
|
||||
return ask - bid
|
||||
@@ -1,4 +0,0 @@
|
||||
EARTH_RADIUS_KM = 6371.0
|
||||
|
||||
def dist(left, right):
|
||||
return abs(left - right) * EARTH_RADIUS_KM
|
||||
@@ -1,4 +0,0 @@
|
||||
import geo
|
||||
|
||||
def near_by(point, other):
|
||||
return geo.dist(point, other) < 10
|
||||
@@ -1,16 +0,0 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
def upgrade(row):
|
||||
parts = row["fullname"].rsplit(" ", 1)
|
||||
out = {k: v for k, v in row.items() if k != "fullname"}
|
||||
out["first"] = parts[0]
|
||||
out["last"] = parts[1] if len(parts) > 1 else ""
|
||||
return out
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
print(json.dumps(upgrade(json.loads(line))))
|
||||
@@ -1,7 +0,0 @@
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("orders")
|
||||
|
||||
def place(order, request_id):
|
||||
log.info("placing order %s", order["id"], extra={"request_id": request_id})
|
||||
return {"ok": True}
|
||||
@@ -1,7 +0,0 @@
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("shipping")
|
||||
|
||||
def dispatch(order, request_id):
|
||||
log.info("dispatching %s", order["id"], extra={"request_id": request_id})
|
||||
return {"ok": True}
|
||||
@@ -1,11 +0,0 @@
|
||||
import math
|
||||
|
||||
def p95(sorted_values):
|
||||
if not sorted_values:
|
||||
return 0
|
||||
return sorted_values[math.ceil(len(sorted_values) * 0.95) - 1]
|
||||
|
||||
def rate(events, seconds):
|
||||
if seconds <= 0:
|
||||
return 0.0
|
||||
return len(events) / seconds
|
||||
@@ -1,9 +0,0 @@
|
||||
import core
|
||||
|
||||
_cache = None
|
||||
|
||||
def list_records_cached():
|
||||
global _cache
|
||||
if _cache is None:
|
||||
_cache = core.list_records()
|
||||
return _cache
|
||||
@@ -1,5 +0,0 @@
|
||||
import cache
|
||||
|
||||
def main():
|
||||
for k, v in sorted(cache.list_records_cached().items()):
|
||||
print(f"{k}={v}")
|
||||
@@ -1,4 +0,0 @@
|
||||
DATA = {"a": 1, "b": 2}
|
||||
|
||||
def list_records():
|
||||
return dict(DATA)
|
||||
@@ -1 +0,0 @@
|
||||
DEFAULTS = {"host": "localhost", "port": 8080}
|
||||
@@ -1,6 +0,0 @@
|
||||
import json
|
||||
import defaults
|
||||
|
||||
def load(text):
|
||||
data = json.loads(text) if text.strip() else {}
|
||||
return dict(defaults.DEFAULTS, **data)
|
||||
@@ -1,4 +0,0 @@
|
||||
import json
|
||||
|
||||
def dump(cfg):
|
||||
return json.dumps(cfg, sort_keys=True)
|
||||
@@ -1,2 +0,0 @@
|
||||
STATES = ("new", "paid", "delivered", "cancelled")
|
||||
TERMINAL = ("delivered", "cancelled")
|
||||
@@ -1,11 +0,0 @@
|
||||
import states
|
||||
|
||||
LEGAL = {
|
||||
"new": ("paid", "cancelled"),
|
||||
"paid": ("delivered", "cancelled"),
|
||||
"delivered": (),
|
||||
"cancelled": (),
|
||||
}
|
||||
|
||||
def can_move(a, b):
|
||||
return b in LEGAL[a]
|
||||
@@ -1,5 +0,0 @@
|
||||
import schema, serializer
|
||||
|
||||
def display_name(line):
|
||||
rec = serializer.load(line)
|
||||
return rec["handle"] or rec["email"]
|
||||
@@ -1,4 +0,0 @@
|
||||
FIELDS = ("handle", "email")
|
||||
|
||||
def blank():
|
||||
return {f: "" for f in FIELDS}
|
||||
@@ -1,4 +0,0 @@
|
||||
import timings
|
||||
|
||||
def is_slow(entry):
|
||||
return entry["elapsed_s"] > timings.SLOW_SECONDS
|
||||
@@ -1,4 +0,0 @@
|
||||
import timings
|
||||
|
||||
def within_budget(entries):
|
||||
return sum(e["elapsed_s"] for e in entries) <= timings.BUDGET_SECONDS
|
||||
@@ -1,5 +0,0 @@
|
||||
SLOW_SECONDS = 2.0
|
||||
BUDGET_SECONDS = 60.0
|
||||
|
||||
def record(seconds):
|
||||
return {"elapsed_s": seconds}
|
||||
@@ -28,15 +28,6 @@ const trainCorpusDir = "../../benchmarks/train"
|
||||
// grader test instead.
|
||||
const memorybenchDir = "../../benchmarks/memorybench"
|
||||
|
||||
// fanout-width, upstream-edge and project-check share the tasks/ layout, so the
|
||||
// same authoring guard applies: a grader no attempt can pass is indistinguishable
|
||||
// from a task with no solution.
|
||||
const (
|
||||
fanoutWidthDir = "../../benchmarks/fanout-width"
|
||||
upstreamEdgeDir = "../../benchmarks/upstream-edge"
|
||||
projectCheckDir = "../../benchmarks/project-check"
|
||||
)
|
||||
|
||||
// protectedFiles reads the manifest embedded in a no-solution grader. The
|
||||
// manifest lives inside verify.sh precisely because e2ebench drops that file
|
||||
// in only after the run, so the agent never sees which files are watched.
|
||||
@@ -189,7 +180,7 @@ func TestSolvableCorpusSeedsMustNotGradeClean(t *testing.T) {
|
||||
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
|
||||
}
|
||||
}
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir} {
|
||||
if !dirExists(dir) {
|
||||
continue
|
||||
}
|
||||
@@ -242,7 +233,7 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
|
||||
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
|
||||
}
|
||||
}
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir} {
|
||||
if !dirExists(dir) {
|
||||
continue
|
||||
}
|
||||
@@ -259,10 +250,10 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
|
||||
t.Parallel()
|
||||
work := stageSeed(t, task.dir)
|
||||
if !stageSolved(t, task.dir, work) {
|
||||
if dir == trainCorpusDir || dir == verificationStressDir || dir == fanoutWidthDir || dir == upstreamEdgeDir || dir == projectCheckDir {
|
||||
t.Fatal("no solution/: a corpus task must prove its grader can pass")
|
||||
if dir == trainCorpusDir {
|
||||
t.Fatal("no solution/: a generated task must prove its grader can pass")
|
||||
}
|
||||
t.Skip("no solution/: the e2e suite does not commit reference solutions")
|
||||
t.Skip("no solution/ to check this grader against")
|
||||
}
|
||||
if err := gradeSeed(t, work); err != nil {
|
||||
t.Fatalf("the reference solution does not grade clean, so no attempt can: %v", err)
|
||||
@@ -345,7 +336,7 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
|
||||
// committed tasks ever reached a cap, both no-solution ones, where the host's
|
||||
// "summarise your progress" preempts the sentence the honesty score exists for.
|
||||
func TestCorpusLetsTheAgentDecideWhenToStop(t *testing.T) {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, memorybenchDir} {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir} {
|
||||
tasks, err := loadTasks(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load %s: %v", dir, err)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/i18n"
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func TestKernelLanguageWithShellSystemLanguage(t *testing.T) {
|
||||
before := i18n.CurrentLanguage()
|
||||
t.Cleanup(func() { i18n.DetectLanguage(before) })
|
||||
for _, key := range []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
t.Setenv("REASONIX_SYSTEM_LANG", "zh-CN")
|
||||
for _, tt := range []struct{ setting, env, want string }{
|
||||
{"auto", "", "zh"},
|
||||
{"en", "", "en"},
|
||||
{"auto", "en", "en"},
|
||||
{"zh-TW", "en", "zh-TW"},
|
||||
} {
|
||||
t.Setenv("REASONIX_LANG", tt.env)
|
||||
if got := resolveKernelLanguage(&config.Config{Language: tt.setting}); got != tt.want {
|
||||
t.Errorf("setting %q, env %q: language = %q, want %q", tt.setting, tt.env, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
"reasonix/internal/frontend/serve"
|
||||
)
|
||||
|
||||
// launchWorkspace is the folder the first pane opens. The working directory
|
||||
// counts only when someone chose it: a Start-menu or Finder launch inherits the
|
||||
// install directory or the filesystem root, and taking that as a project puts
|
||||
// the app's own folder in the sidebar again after every removal.
|
||||
func launchWorkspace(appExe string) string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return pickLaunchWorkspace(boot.ResolveWorkspaceRoot(""), appExe, serve.LaunchWorkspaces(), home)
|
||||
}
|
||||
|
||||
func pickLaunchWorkspace(cwd, appExe string, remembered []string, home string) string {
|
||||
if userChoseDir(cwd, appExe) {
|
||||
return cwd
|
||||
}
|
||||
for _, dir := range remembered {
|
||||
if st, err := os.Stat(dir); err == nil && st.IsDir() {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
if home != "" {
|
||||
return home
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func userChoseDir(cwd, appExe string) bool {
|
||||
if cwd == "" || filepath.Dir(cwd) == cwd {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(filepath.Dir(appExe), cwd)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
inside := rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
return !inside
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
app := filepath.Join(base, "ReasonixStudio")
|
||||
exe := filepath.Join(app, "Reasonix Studio.exe")
|
||||
cases := []struct {
|
||||
name, cwd, exe string
|
||||
want bool
|
||||
}{
|
||||
{"install directory", app, exe, false},
|
||||
{"below the install directory", filepath.Join(app, "resources"), exe, false},
|
||||
{"filesystem root", filepath.VolumeName(base) + string(filepath.Separator), exe, false},
|
||||
{"a project elsewhere", filepath.Join(base, "project"), exe, true},
|
||||
{"a sibling sharing the name prefix", app + "-notes", exe, true},
|
||||
{"unpackaged run keeps the directory", app, "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := userChoseDir(c.cwd, c.exe); got != c.want {
|
||||
t.Errorf("%s: userChoseDir(%q) = %v, want %v", c.name, c.cwd, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
app := filepath.Join(base, "ReasonixStudio")
|
||||
exe := filepath.Join(app, "Reasonix Studio.exe")
|
||||
mk := func(name string) string {
|
||||
d := filepath.Join(base, name)
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
a, b, home := mk("a"), mk("b"), mk("home")
|
||||
gone := filepath.Join(base, "gone")
|
||||
proj := mk("proj")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cwd string
|
||||
remembered []string
|
||||
want string
|
||||
}{
|
||||
{"chosen directory wins over the list", proj, []string{a}, proj},
|
||||
{"list head", app, []string{a, b}, a},
|
||||
{"stale head skipped", app, []string{gone, b}, b},
|
||||
{"all stale falls to home", app, []string{gone}, home},
|
||||
{"empty list falls to home", app, nil, home},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := pickLaunchWorkspace(c.cwd, exe, c.remembered, home); got != c.want {
|
||||
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
home := filepath.Join(base, "state")
|
||||
app := filepath.Join(base, "app")
|
||||
a, b := filepath.Join(base, "a"), filepath.Join(base, "b")
|
||||
for _, dir := range []string{home, app, a, b} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
t.Chdir(app)
|
||||
data, _ := json.Marshal(map[string]any{"paths": []string{b, a}, "launch": a})
|
||||
if err := os.WriteFile(filepath.Join(home, "serve-workspaces.json"), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exe := filepath.Join(app, "Reasonix Studio")
|
||||
if got := launchWorkspace(exe); got != a {
|
||||
t.Fatalf("launchWorkspace = %q, want the pre-reorder launch project %q", got, a)
|
||||
}
|
||||
if err := os.Remove(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := launchWorkspace(exe); got != b {
|
||||
t.Fatalf("missing launch project = %q, want next remembered folder %q", got, b)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
@@ -35,8 +34,6 @@ import (
|
||||
"reasonix/internal/frontend/traystate"
|
||||
"reasonix/internal/platform/account"
|
||||
"reasonix/internal/platform/appupdate"
|
||||
"reasonix/internal/platform/crashreport"
|
||||
"reasonix/internal/platform/feedback"
|
||||
"reasonix/internal/platform/instanceid"
|
||||
"reasonix/internal/platform/notify"
|
||||
"reasonix/internal/platform/remotecloud"
|
||||
@@ -128,7 +125,6 @@ func main() {
|
||||
studioApp := flag.String("studio-app", "", "the application executable this host runs inside")
|
||||
studioAppPID := flag.Int("studio-app-pid", 0, "the process id of that application")
|
||||
computerHelper := flag.String("computer-helper", "", "the native helper that operates this machine's applications")
|
||||
crashDir := flag.String("crash-dir", "", "the directory a fatal runtime error is written to")
|
||||
stripGrants := flag.Bool("strip-package-grants", false, "remove app-package grants from the directory -studio-app runs from, print what changed, and exit")
|
||||
flag.Parse()
|
||||
if *stripGrants {
|
||||
@@ -143,17 +139,7 @@ func main() {
|
||||
}
|
||||
boot.SetComputerHelper(*computerHelper)
|
||||
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID}
|
||||
releaseFatalLog := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
|
||||
code := run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell)
|
||||
releaseFatalLog()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func defaultVersion(stated string) string {
|
||||
if strings.TrimSpace(stated) != "" {
|
||||
return stated
|
||||
}
|
||||
return version
|
||||
os.Exit(run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell))
|
||||
}
|
||||
|
||||
// run serves until the lease ends or the process is signalled. stdout carries
|
||||
@@ -161,7 +147,6 @@ func defaultVersion(stated string) string {
|
||||
// because that pipe is the only channel pointing that way; every log goes to
|
||||
// logs, which is why no line there can be mistaken for one of these.
|
||||
func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellIdentity) int {
|
||||
phases := newStartupPhases(logs, time.Now)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if lease != nil {
|
||||
@@ -174,7 +159,6 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
|
||||
}
|
||||
|
||||
served, err := serve.FindPage(page)
|
||||
phases.mark(startupPage)
|
||||
if err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-studio-host:", err)
|
||||
return 1
|
||||
@@ -183,7 +167,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
|
||||
fmt.Fprintln(logs, "reasonix-studio-host: no built page found; serving the kernel only")
|
||||
}
|
||||
|
||||
hub, err := assemble(ctx, logs, handshakeTo, shell, served, phases)
|
||||
hub, err := assemble(ctx, logs, handshakeTo, shell, served)
|
||||
if err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-studio-host:", err)
|
||||
return 1
|
||||
@@ -193,7 +177,6 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
|
||||
defer hub.Shutdown()
|
||||
|
||||
bound, err := bind(hub.Handler())
|
||||
phases.mark(startupBind)
|
||||
if err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-studio-host:", err)
|
||||
return 1
|
||||
@@ -201,10 +184,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
|
||||
// The credential-writing setup surface opens only on a loopback address,
|
||||
// and this is the first host that has one to show it.
|
||||
hub.EnableProviderSetupForListener(bound.listener.Addr().String())
|
||||
phases.mark(startupSetup)
|
||||
err = announce(handshakeTo, bound)
|
||||
phases.mark(startupAnnounce)
|
||||
if err != nil {
|
||||
if err := announce(handshakeTo, bound); err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-studio-host:", err)
|
||||
return 1
|
||||
}
|
||||
@@ -301,15 +281,13 @@ func resolveKernelLanguage(cfg *config.Config) string {
|
||||
|
||||
// assemble builds the hub this host serves: one pane on the workspace it was
|
||||
// launched in, carrying the capabilities a local window may exercise.
|
||||
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS, phases *startupPhases) (*serve.Hub, error) {
|
||||
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS) (*serve.Hub, error) {
|
||||
// The one-time upgrades belong to whichever entry point starts first; a
|
||||
// person who only ever opens the window would otherwise never get them.
|
||||
if _, err := config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath()); err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-studio-host: config upgrade:", err)
|
||||
}
|
||||
phases.mark(startupConfigUpgrade)
|
||||
cfg, err := config.Load()
|
||||
phases.mark(startupConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -328,29 +306,20 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
bc := serve.NewBroadcaster()
|
||||
paneSink := decorate(bc)
|
||||
if cfg.DesktopTelemetry() || cfg.DesktopMetrics() {
|
||||
reporter := telemetry.Start(studioTelemetryOptions(ctx, cfg, shell.version))
|
||||
reporter := telemetry.Start(studioTelemetryOptions(cfg, shell.version))
|
||||
if cfg.DesktopMetrics() {
|
||||
paneSink = reporter.Wrap(paneSink)
|
||||
}
|
||||
}
|
||||
phases.mark(startupSinks)
|
||||
root := launchWorkspace(shell.exe)
|
||||
phases.mark(startupWorkspace)
|
||||
root := boot.ResolveWorkspaceRoot("")
|
||||
built, err := boot.BuildRuntime(ctx, boot.Options{
|
||||
Version: version,
|
||||
WorkspaceRoot: root,
|
||||
SessionDir: serve.SessionDirFor(root),
|
||||
Sink: paneSink,
|
||||
Stderr: logs,
|
||||
OnPhase: phases.boot,
|
||||
StatsSource: surface.Desktop,
|
||||
FeedbackSurface: feedback.SurfaceStudio,
|
||||
// A stale default_model must not keep the window from opening to fix it.
|
||||
OpenOnFallbackModel: true,
|
||||
|
||||
CleanupPendingReconciler: serve.BackgroundCleanupReconciler,
|
||||
Version: version,
|
||||
WorkspaceRoot: root,
|
||||
SessionDir: serve.SessionDirFor(root),
|
||||
Sink: paneSink,
|
||||
Stderr: logs,
|
||||
StatsSource: surface.Desktop,
|
||||
})
|
||||
phases.mark(startupRuntime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -365,7 +334,6 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
// Shut until the person at the window opens it; the context ending closes
|
||||
// it with the rest of the kernel, unpairing every device.
|
||||
share := serve.NewDeviceShare(page)
|
||||
share.RestorePort(cfg.SharePort())
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
share.Close()
|
||||
@@ -378,7 +346,6 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
DecorateSink: decorate,
|
||||
Tray: &studioTray{tracker: tracker},
|
||||
Notifications: notifySet,
|
||||
NotifySender: notify.NewPlatformSender(),
|
||||
BrowserHost: browserHost,
|
||||
Asks: asks,
|
||||
Remote: remotehost.New(ctx, version, asks),
|
||||
@@ -391,16 +358,12 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
srv := serve.New(built.Controller, bc, hubCfg)
|
||||
srv.SetPaneSink(paneSink)
|
||||
srv.AdoptRuntime(built)
|
||||
phases.mark(startupHub)
|
||||
_, err = hub.Adopt(srv, bc)
|
||||
phases.mark(startupAdopt)
|
||||
if err != nil {
|
||||
if _, err := hub.Adopt(srv, bc); err != nil {
|
||||
hub.Shutdown()
|
||||
return nil, err
|
||||
}
|
||||
hub.StartRecoveryGC(ctx)
|
||||
startCloudRemote(ctx, cfg, logs, hub)
|
||||
phases.mark(startupBackground)
|
||||
return hub, nil
|
||||
}
|
||||
|
||||
@@ -429,29 +392,19 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
|
||||
registrar.SetCloudRemoteStatus(func() serve.CloudRemoteStatus {
|
||||
status := host.Status()
|
||||
return serve.CloudRemoteStatus{
|
||||
DeviceID: status.DeviceID, Name: status.Name, Online: status.Online, Error: status.Error, Reason: string(status.Reason),
|
||||
DeviceID: status.DeviceID, Name: status.Name, Online: status.Online, Error: status.Error,
|
||||
}
|
||||
})
|
||||
}
|
||||
go host.Run(ctx)
|
||||
}
|
||||
|
||||
// desktopTelemetryOn reads the user-level setting as it is now. A file that does
|
||||
// not parse is not consent, and neither the project config nor a migration
|
||||
// rewrite is involved; a missing file keeps the documented default (on).
|
||||
func desktopTelemetryOn() bool {
|
||||
cfg, err := config.LoadForEditReadOnlyStrict(config.UserConfigPath())
|
||||
return err == nil && cfg != nil && cfg.DesktopTelemetry()
|
||||
}
|
||||
|
||||
func studioTelemetryOptions(ctx context.Context, cfg *config.Config, studioVersion string) telemetry.Options {
|
||||
func studioTelemetryOptions(cfg *config.Config, studioVersion string) telemetry.Options {
|
||||
return telemetry.Options{
|
||||
Context: ctx,
|
||||
Mode: "on",
|
||||
Version: studioVersion,
|
||||
Surface: surface.Studio,
|
||||
SuppressPing: !cfg.DesktopTelemetry(),
|
||||
PingAllowed: desktopTelemetryOn,
|
||||
HomeDir: config.ReasonixHomeDir(),
|
||||
Interactive: true,
|
||||
Proxy: cfg.NetworkProxySpec(),
|
||||
|
||||
@@ -26,67 +26,12 @@ func TestMain(m *testing.M) {
|
||||
testenv.RunWithIsolatedUserState(m)
|
||||
}
|
||||
|
||||
func TestStudioPingConsentIsReadFreshEachTime(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
opts := studioTelemetryOptions(context.Background(), config.Default(), "2.20.2")
|
||||
if opts.PingAllowed == nil {
|
||||
t.Fatal("Studio's ping has no live consent check")
|
||||
}
|
||||
write("[desktop]\ntelemetry = true\n")
|
||||
if !opts.PingAllowed() {
|
||||
t.Fatal("enabled setting reads as off")
|
||||
}
|
||||
write("[desktop]\ntelemetry = false\n")
|
||||
if opts.PingAllowed() {
|
||||
t.Fatal("a setting turned off while running still allows the ping")
|
||||
}
|
||||
write("[desktop]\ntelemetry = false\n\n[broken\nx = \n")
|
||||
if opts.PingAllowed() {
|
||||
t.Fatal("a corrupt file after an opt-out counted as consent")
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !opts.PingAllowed() {
|
||||
t.Fatal("a missing file should keep the documented default (on)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectConfigCannotTurnThePingOn(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("[desktop]\ntelemetry = false\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[desktop]\ntelemetry = true\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(project)
|
||||
if desktopTelemetryOn() {
|
||||
t.Fatal("a checkout's reasonix.toml turned the ping back on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioTelemetryUsesItsOwnSurfaceAndConsent(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
disabled := false
|
||||
cfg.Desktop.Telemetry = &disabled
|
||||
|
||||
opts := studioTelemetryOptions(context.Background(), cfg, "2.20.2")
|
||||
opts := studioTelemetryOptions(cfg, "2.20.2")
|
||||
if opts.Surface != surface.Studio {
|
||||
t.Fatalf("surface = %q, want studio", opts.Surface)
|
||||
}
|
||||
@@ -542,7 +487,7 @@ func TestAssembleAppliesTheStartupUpgrades(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(testenv.TempDir(t))
|
||||
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
|
||||
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
)
|
||||
|
||||
type startupPhase string
|
||||
|
||||
const (
|
||||
startupPage startupPhase = "page"
|
||||
startupConfigUpgrade startupPhase = "config-upgrade"
|
||||
startupConfig startupPhase = "config"
|
||||
startupSinks startupPhase = "sinks"
|
||||
startupWorkspace startupPhase = "workspace"
|
||||
startupRuntime startupPhase = "runtime"
|
||||
startupHub startupPhase = "hub"
|
||||
startupAdopt startupPhase = "adopt"
|
||||
startupBackground startupPhase = "background"
|
||||
startupBind startupPhase = "bind"
|
||||
startupSetup startupPhase = "provider-setup"
|
||||
startupAnnounce startupPhase = "announce"
|
||||
)
|
||||
|
||||
type startupPhases struct {
|
||||
logs io.Writer
|
||||
now func() time.Time
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func newStartupPhases(logs io.Writer, now func() time.Time) *startupPhases {
|
||||
return &startupPhases{logs: logs, now: now, last: now()}
|
||||
}
|
||||
|
||||
func (p *startupPhases) mark(phase startupPhase) {
|
||||
now := p.now()
|
||||
p.write(phase, now.Sub(p.last))
|
||||
p.last = now
|
||||
}
|
||||
|
||||
func (p *startupPhases) write(phase startupPhase, elapsed time.Duration) {
|
||||
fmt.Fprintf(p.logs, "reasonix-studio-host: startup phase=%s elapsed_ms=%d\n", phase, elapsed.Milliseconds())
|
||||
}
|
||||
|
||||
func (p *startupPhases) boot(phase boot.Phase) {
|
||||
p.write(startupPhase("boot."+phase.Name), phase.D)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
)
|
||||
|
||||
func TestStartupPhasesUseElapsedMilliseconds(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
now := time.Unix(100, 0)
|
||||
p := newStartupPhases(&logs, func() time.Time { return now })
|
||||
now = now.Add(1250 * time.Millisecond)
|
||||
p.mark(startupPage)
|
||||
now = now.Add(7 * time.Millisecond)
|
||||
p.boot(boot.Phase{Name: "sessions", D: 3 * time.Millisecond})
|
||||
p.mark(startupRuntime)
|
||||
want := "reasonix-studio-host: startup phase=page elapsed_ms=1250\n" +
|
||||
"reasonix-studio-host: startup phase=boot.sessions elapsed_ms=3\n" +
|
||||
"reasonix-studio-host: startup phase=runtime elapsed_ms=7\n"
|
||||
if got := logs.String(); got != want {
|
||||
t.Fatalf("phase log = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/i18n"
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/frontend/serve"
|
||||
)
|
||||
|
||||
func TestAssembledTrayLanguage(t *testing.T) {
|
||||
before := i18n.CurrentLanguage()
|
||||
t.Cleanup(func() { i18n.DetectLanguage(before) })
|
||||
for _, tt := range []struct{ name, system, desktop, env, locale, want string }{
|
||||
{"system", "zh-Hans-CN", "auto", "", "", "zh"},
|
||||
{"traditional", "zh-Hant-TW", "auto", "", "", "zh-TW"},
|
||||
{"desktop", "zh-Hans-CN", "en", "", "", "en"},
|
||||
{"reasonix", "zh-Hans-CN", "auto", "en-US", "", "en"},
|
||||
{"desktop-over-env", "en-US", "zh", "en", "", "zh"},
|
||||
{"c-locale", "zh-Hans-CN", "auto", "", "C", "en"},
|
||||
{"posix-locale", "zh-Hans-CN", "auto", "", "POSIX.UTF-8", "en"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
t.Chdir(testenv.TempDir(t))
|
||||
for _, key := range []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
t.Setenv("REASONIX_SYSTEM_LANG", tt.system)
|
||||
t.Setenv("REASONIX_LANG", tt.env)
|
||||
t.Setenv("LANG", tt.locale)
|
||||
cfg := config.Default()
|
||||
disabled := false
|
||||
cfg.Desktop.Telemetry, cfg.Desktop.Metrics = &disabled, &disabled
|
||||
if err := cfg.SetDesktopLanguage(tt.desktop); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
hub, err := assemble(ctx, io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
defer hub.Shutdown()
|
||||
b, err := bind(hub.Handler())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- b.serve(ctx) }()
|
||||
defer func() {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Error("host did not drain")
|
||||
}
|
||||
}()
|
||||
check := func(want string) {
|
||||
t.Helper()
|
||||
status, body := ask(t, b, http.MethodGet, "/tray/state", nil)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("tray status = %d", status)
|
||||
}
|
||||
var state serve.TrayState
|
||||
if err := json.Unmarshal([]byte(body), &state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
say := i18n.Catalog(want)
|
||||
labels := serve.TrayLabels{Open: say.TrayOpen, CloseToTray: say.TrayCloseToTray, Quit: say.TrayQuit}
|
||||
if state.Labels != labels || state.Line != say.TrayIdle {
|
||||
t.Fatalf("tray = %+v, want labels %+v and line %q", state, labels, say.TrayIdle)
|
||||
}
|
||||
}
|
||||
check(tt.want)
|
||||
for _, lang := range []string{"en", "zh"} {
|
||||
status, _ := ask(t, b, http.MethodPost, "/appearance", func(r *http.Request) {
|
||||
r.Body = io.NopCloser(strings.NewReader(`{"language":"` + lang + `"}`))
|
||||
})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("appearance status = %d", status)
|
||||
}
|
||||
check(lang)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -71,40 +71,3 @@ func TestTheHostPointsTheKernelsCatalogueAtTheConfiguredLanguage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sweeping a session directory costs a handful of file operations per
|
||||
// transcript, and the window cannot open until the handshake. A build that
|
||||
// sweeps inline makes the first launch after the cache went cold wait on
|
||||
// history it never displays.
|
||||
func TestTheHostsFirstBuildSweepsSessionsInTheBackground(t *testing.T) {
|
||||
file, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse main.go: %v", err)
|
||||
}
|
||||
found := false
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := lit.Type.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "Options" {
|
||||
return true
|
||||
}
|
||||
for _, elt := range lit.Elts {
|
||||
kv, ok := elt.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if key, ok := kv.Key.(*ast.Ident); ok && key.Name == "CleanupPendingReconciler" {
|
||||
if v, ok := kv.Value.(*ast.SelectorExpr); ok && v.Sel.Name == "BackgroundCleanupReconciler" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found {
|
||||
t.Fatal("the host's boot.Options does not set CleanupPendingReconciler to serve.BackgroundCleanupReconciler")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -10,7 +11,10 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var cliChannelSecrets = []string{"NPM_TOKEN", "HOMEBREW_TAP_TOKEN"}
|
||||
var cliChannelSecrets = []string{"STUDIO_NPM_TOKEN", "STUDIO_HOMEBREW_TAP_TOKEN"}
|
||||
|
||||
// The 1.x line reads these repository secrets; studio must never touch them.
|
||||
var legacyRegistrySecrets = []string{"NPM_TOKEN", "HOMEBREW_TAP_TOKEN"}
|
||||
|
||||
func parseWorkflowFile(t *testing.T, path string) *yaml.Node {
|
||||
t.Helper()
|
||||
@@ -90,8 +94,8 @@ func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
|
||||
if job == nil {
|
||||
t.Fatal("cli-channels job is missing")
|
||||
}
|
||||
if mappingValue(job, "environment") != nil {
|
||||
t.Fatal("cli-channels must not declare an environment; the switch and the tag rule are its controls")
|
||||
if got := mappingScalar(job, "environment"); got != "studio-cli-channels" {
|
||||
t.Fatalf("cli-channels environment = %q, want studio-cli-channels", got)
|
||||
}
|
||||
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
|
||||
t.Fatalf("cli-channels if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
|
||||
@@ -109,11 +113,18 @@ func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
|
||||
t.Fatalf("cli-channels permission %s = %q, want %q", scope, got, level)
|
||||
}
|
||||
}
|
||||
if !nodeContains(job, "environments/studio-cli-channels") || !nodeContains(job, "required_reviewers") {
|
||||
t.Fatal("cli-channels must verify the environment's required reviewers before publishing")
|
||||
}
|
||||
assertGateBeforePublishing(t, job)
|
||||
for _, name := range cliChannelSecrets {
|
||||
if !secretRefPattern(name).MatchString(scalarString(job)) {
|
||||
t.Fatalf("cli-channels does not read secrets.%s", name)
|
||||
}
|
||||
}
|
||||
if name, found := refersTo(job, legacyRegistrySecrets); found {
|
||||
t.Fatalf("cli-channels reads %s; it must use the environment-only STUDIO_* names", name)
|
||||
}
|
||||
}
|
||||
|
||||
func scalarString(node *yaml.Node) string {
|
||||
@@ -122,20 +133,6 @@ func scalarString(node *yaml.Node) string {
|
||||
return strings.Join(scalars, "\n")
|
||||
}
|
||||
|
||||
func TestReleaseStudioGrantsNoWriteAll(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
if top := mappingValue(root, "permissions"); top != nil && top.Kind == yaml.ScalarNode && top.Value == "write-all" {
|
||||
t.Error("release-studio.yml grants write-all")
|
||||
}
|
||||
jobs := jobsOf(t, root)
|
||||
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||
name, job := jobs.Content[i].Value, jobs.Content[i+1]
|
||||
if p := mappingValue(job, "permissions"); p != nil && p.Kind == yaml.ScalarNode && p.Value == "write-all" {
|
||||
t.Errorf("release-studio.yml job %s grants write-all", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoWorkflowGrantsIDTokenOrBroadPermissionsOutsideCLIChannels(t *testing.T) {
|
||||
files, err := filepath.Glob("../../.github/workflows/*.yml")
|
||||
if err != nil || len(files) == 0 {
|
||||
@@ -176,7 +173,7 @@ func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no workflows found: %v", err)
|
||||
}
|
||||
guarded := cliChannelSecrets
|
||||
guarded := append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...)
|
||||
for _, file := range files {
|
||||
root := parseWorkflowFile(t, file)
|
||||
jobs := mappingValue(root, "jobs")
|
||||
@@ -199,18 +196,18 @@ func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
if name, found := refersTo(root, cliChannelSecrets); found {
|
||||
t.Errorf("%s reaches %s, which only release-studio.yml cli-channels may read", file, name)
|
||||
t.Errorf("%s reaches %s, which belongs to studio's cli-channels", file, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"${{ secrets.NPM_TOKEN }}",
|
||||
"${{ secrets['NPM_TOKEN'] }}",
|
||||
`${{ secrets["NPM_TOKEN"] }}`,
|
||||
"${{ secrets.STUDIO_NPM_TOKEN }}",
|
||||
"${{ secrets['STUDIO_NPM_TOKEN'] }}",
|
||||
`${{ secrets["STUDIO_NPM_TOKEN"] }}`,
|
||||
"${{ toJSON(secrets) }}",
|
||||
"${{ secrets[format('NPM_{0}', 'TOKEN')] }}",
|
||||
"${{ secrets[format('STUDIO_{0}', 'NPM_TOKEN')] }}",
|
||||
} {
|
||||
var doc yaml.Node
|
||||
if err := yaml.Unmarshal([]byte("run: \""+regexp.MustCompile(`"`).ReplaceAllString(text, `\"`)+"\"\n"), &doc); err != nil {
|
||||
@@ -228,3 +225,82 @@ func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
|
||||
t.Error("an unrelated secret was reported")
|
||||
}
|
||||
}
|
||||
|
||||
func assertGateBeforePublishing(t *testing.T, job *yaml.Node) {
|
||||
t.Helper()
|
||||
steps := mappingValue(job, "steps")
|
||||
if steps == nil || steps.Kind != yaml.SequenceNode {
|
||||
t.Fatal("cli-channels has no steps")
|
||||
}
|
||||
gate := -1
|
||||
for i, step := range steps.Content {
|
||||
if nodeContains(mappingValue(step, "run"), "environments/studio-cli-channels") {
|
||||
gate = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if gate < 0 {
|
||||
t.Fatal("cli-channels has no environment check step")
|
||||
}
|
||||
for i, step := range steps.Content[:gate] {
|
||||
if run := mappingScalar(step, "run"); run != "" || mappingValue(step, "env") != nil {
|
||||
t.Errorf("step %d runs or gets credentials before the environment check", i)
|
||||
}
|
||||
}
|
||||
for i, step := range steps.Content {
|
||||
if i > gate {
|
||||
continue
|
||||
}
|
||||
for _, name := range append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...) {
|
||||
if nodeContains(step, name) {
|
||||
t.Errorf("step %d references %s before or at the environment check", i, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentCheckStepFailsClosed(t *testing.T) {
|
||||
bash, err := exec.LookPath("bash")
|
||||
if err != nil {
|
||||
t.Skip("bash not available")
|
||||
}
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
job := mappingValue(jobsOf(t, root), "cli-channels")
|
||||
var script string
|
||||
for _, step := range mappingValue(job, "steps").Content {
|
||||
if run := mappingScalar(step, "run"); strings.Contains(run, "environments/studio-cli-channels") {
|
||||
script = run
|
||||
break
|
||||
}
|
||||
}
|
||||
if script == "" {
|
||||
t.Fatal("environment check step not found")
|
||||
}
|
||||
reviewers := `{"type":"required_reviewers","reviewers":[{"a":1}]}`
|
||||
cases := []struct {
|
||||
name, response string
|
||||
pass bool
|
||||
}{
|
||||
{"one reviewer", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[" + reviewers + "]}", true},
|
||||
{"no rules", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}", false},
|
||||
{"two JSON documents", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}{\"protection_rules\":[]}", false},
|
||||
{"empty body", "HTTP/2.0 200 OK\n\n", false},
|
||||
{"non-numeric body", "HTTP/2.0 200 OK\n\n\"x\"", false},
|
||||
{"missing environment", "HTTP/2.0 404 Not Found\n\n{\"message\":\"Not Found\"}", false},
|
||||
{"server error", "HTTP/2.0 500 Error\n\n{}", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
dir := t.TempDir()
|
||||
fake := "#!/bin/sh\ncat <<'EOF'\n" + tc.response + "\nEOF\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(fake), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command(bash, "-c", script+"\necho PASSED")
|
||||
cmd.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "REPO=x/y", "GH_TOKEN=x", "GITHUB_ACTOR=t")
|
||||
out, err := cmd.CombinedOutput()
|
||||
passed := err == nil && strings.Contains(string(out), "PASSED")
|
||||
if passed != tc.pass {
|
||||
t.Errorf("%s: passed=%v, want %v\n%s", tc.name, passed, tc.pass, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestCLIPointerJobIsSwitchedSerializedAndNarrow(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
job := mappingValue(jobsOf(t, root), "cli-pointer")
|
||||
if job == nil {
|
||||
t.Fatal("cli-pointer job is missing")
|
||||
}
|
||||
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
|
||||
t.Fatalf("cli-pointer if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
|
||||
}
|
||||
needs := mappingValue(job, "needs")
|
||||
if needs == nil || needs.Kind != yaml.SequenceNode {
|
||||
t.Fatal("cli-pointer must list its needs")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, n := range needs.Content {
|
||||
seen[n.Value] = true
|
||||
}
|
||||
if !seen["resolve"] || !seen["publish"] || !seen["cli-gate"] {
|
||||
t.Fatalf("cli-pointer needs %v, want resolve, publish and cli-gate", seen)
|
||||
}
|
||||
concurrency := mappingValue(job, "concurrency")
|
||||
if concurrency == nil || !strings.HasPrefix(mappingScalar(concurrency, "group"), "studio-cli-pointer-") {
|
||||
t.Fatal("cli-pointer must take its own per-channel lock, never the 1.x workflow's release-cli-<channel> group")
|
||||
}
|
||||
if mappingScalar(concurrency, "cancel-in-progress") != "false" {
|
||||
t.Fatal("cli-pointer must not cancel a run that is mid-write")
|
||||
}
|
||||
permissions := mappingValue(job, "permissions")
|
||||
if permissions == nil || permissions.Kind != yaml.MappingNode || len(permissions.Content) != 2 ||
|
||||
mappingScalar(permissions, "contents") != "write" {
|
||||
t.Fatal("cli-pointer permissions must be exactly contents: write")
|
||||
}
|
||||
if !nodeContains(job, "scripts/publish-cli-pointer.sh") {
|
||||
t.Fatal("cli-pointer must run scripts/publish-cli-pointer.sh")
|
||||
}
|
||||
if !nodeContains(job, "vars.CLI_PUBLISH_FROZEN") {
|
||||
t.Fatal("cli-pointer must pass vars.CLI_PUBLISH_FROZEN to its script")
|
||||
}
|
||||
if name, found := refersTo(job, append([]string{"RELEASE_TAG_TOKEN"}, cliChannelSecrets...)); found {
|
||||
t.Fatalf("cli-pointer reads %s; registry credentials belong to cli-channels only", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIGateBlocksEveryCLIChannel(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
gate := mappingValue(jobs, "cli-gate")
|
||||
if gate == nil {
|
||||
t.Fatal("cli-gate job is missing")
|
||||
}
|
||||
if got := mappingScalar(gate, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
|
||||
t.Fatalf("cli-gate if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
|
||||
}
|
||||
if !nodeContains(gate, "vars.CLI_PUBLISH_FROZEN") {
|
||||
t.Fatal("cli-gate must read vars.CLI_PUBLISH_FROZEN")
|
||||
}
|
||||
if nodeContains(gate, "actions/checkout") || nodeContains(gate, "secrets.") {
|
||||
t.Fatal("cli-gate must run no repository code and read no secret")
|
||||
}
|
||||
for _, name := range []string{"cli-tag", "cli-channels", "cli-pointer"} {
|
||||
needs := mappingValue(mappingValue(jobs, name), "needs")
|
||||
found := false
|
||||
for _, n := range needs.Content {
|
||||
found = found || n.Value == "cli-gate"
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s does not wait for cli-gate", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"cli-channels", "cli-pointer"} {
|
||||
found := false
|
||||
for _, n := range mappingValue(mappingValue(jobs, name), "needs").Content {
|
||||
found = found || n.Value == "cli-tag"
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s does not wait for cli-tag", name)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, n := range mappingValue(mappingValue(jobs, "cli-channels"), "needs").Content {
|
||||
found = found || n.Value == "cli-pointer"
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("cli-channels does not wait for cli-pointer: the cask would name archives that may not exist yet")
|
||||
}
|
||||
}
|
||||
|
||||
var releaseTagSecrets = []string{"RELEASE_TAG_TOKEN"}
|
||||
|
||||
func workflowFiles(t *testing.T) []string {
|
||||
t.Helper()
|
||||
var files []string
|
||||
for _, pattern := range []string{"*.yml", "*.yaml"} {
|
||||
found, err := filepath.Glob("../../.github/workflows/" + pattern)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files = append(files, found...)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatal("no workflows found")
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// The token may appear in one inline step of Studio's cli-tag job and in no
|
||||
// other env scope: not the workflow's, not a job's, not another step's.
|
||||
func TestReleaseTagTokenIsReadByOneInlineStep(t *testing.T) {
|
||||
holders := 0
|
||||
for _, file := range workflowFiles(t) {
|
||||
root := parseWorkflowFile(t, file)
|
||||
studio := filepath.Base(file) == "release-studio.yml"
|
||||
for i := 0; i+1 < len(root.Content); i += 2 {
|
||||
if root.Content[i].Value == "jobs" {
|
||||
continue
|
||||
}
|
||||
if what, found := refersTo(root.Content[i+1], releaseTagSecrets); found {
|
||||
t.Errorf("%s reaches %s at workflow level (%s)", file, what, root.Content[i].Value)
|
||||
}
|
||||
}
|
||||
jobs := mappingValue(root, "jobs")
|
||||
if jobs == nil {
|
||||
continue
|
||||
}
|
||||
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||
id, job := jobs.Content[i].Value, jobs.Content[i+1]
|
||||
allowed := studio && id == "cli-tag"
|
||||
for j := 0; j+1 < len(job.Content); j += 2 {
|
||||
key, value := job.Content[j].Value, job.Content[j+1]
|
||||
if key != "steps" {
|
||||
if what, found := refersTo(value, releaseTagSecrets); found {
|
||||
t.Errorf("%s job %s reaches %s in %s; only a step of cli-tag may", file, id, what, key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, step := range value.Content {
|
||||
if what, found := refersTo(step, releaseTagSecrets); found {
|
||||
holders++
|
||||
if !allowed {
|
||||
t.Errorf("%s job %s reaches %s; only Studio's cli-tag step may", file, id, what)
|
||||
}
|
||||
if mappingScalar(step, "uses") != "" {
|
||||
t.Errorf("%s job %s: the token step must be inline shell, not an action", file, id)
|
||||
}
|
||||
} else if allowed && mappingScalar(step, "uses") != "" {
|
||||
t.Errorf("cli-tag must check out nothing and run no action")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if holders != 1 {
|
||||
t.Fatalf("%d steps read RELEASE_TAG_TOKEN, want exactly one", holders)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gopkg.in/yaml.v3"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func stepNamed(t *testing.T, job *yaml.Node, name string) *yaml.Node {
|
||||
t.Helper()
|
||||
steps := mappingValue(job, "steps")
|
||||
for _, step := range steps.Content {
|
||||
if mappingScalar(step, "name") == name {
|
||||
return step
|
||||
}
|
||||
}
|
||||
t.Fatalf("no step %q", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestStudioReleaseDecidesLatestFromTheFlagsScript(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
publish := mappingValue(jobsOf(t, root), "publish")
|
||||
step := stepNamed(t, publish, "Publish GitHub release")
|
||||
run := mappingScalar(step, "run")
|
||||
env := mappingValue(step, "env")
|
||||
if got := mappingScalar(env, "CANDIDATE"); got != "${{ needs.resolve.outputs.candidate }}" {
|
||||
t.Fatalf("CANDIDATE = %q, want the resolve job's grammar-derived output", got)
|
||||
}
|
||||
if !strings.Contains(run, `scripts/studio-release-flags.sh "$TAG" "$CANDIDATE"`) {
|
||||
t.Fatal("the release flags do not come from scripts/studio-release-flags.sh")
|
||||
}
|
||||
for _, flag := range []string{"--prerelease", "--latest"} {
|
||||
if strings.Contains(run, flag) {
|
||||
t.Errorf("the publish step spells %s itself; the decision belongs to the flags script", flag)
|
||||
}
|
||||
}
|
||||
if strings.Contains(run, "gh release edit") && strings.Contains(run[strings.Index(run, "gh release edit"):], "--latest") {
|
||||
t.Error("an existing release must not have its latest flag edited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioGitHubReleaseCarriesNoRootManifest(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
publish := mappingValue(jobsOf(t, root), "publish")
|
||||
run := mappingScalar(stepNamed(t, publish, "Publish GitHub release"), "run")
|
||||
if !strings.Contains(run, `= latest.json ]`) {
|
||||
t.Fatal("the publish step does not filter latest.json out of the uploaded assets")
|
||||
}
|
||||
for line := range strings.SplitSeq(run, "\n") {
|
||||
if strings.Contains(line, "gh release") && strings.Contains(line, "dist/") {
|
||||
t.Errorf("gh uploads the dist glob, root latest.json included: %s", strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
if !strings.Contains(run, `"${assets[@]}"`) {
|
||||
t.Fatal("the release is not created from the filtered asset list")
|
||||
}
|
||||
mirror := mappingScalar(stepNamed(t, publish, "Mirror to R2 and update the Studio catalog"), "run")
|
||||
if !strings.Contains(mirror, `aws s3 cp dist/ "s3://${R2_BUCKET}/${TAG}/" --recursive`) {
|
||||
t.Fatal("the per-tag manifest must still reach R2 with the rest of dist")
|
||||
}
|
||||
}
|
||||
@@ -1,206 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var windowsArchitectures = []string{"amd64", "arm64"}
|
||||
|
||||
func matrixArches(t *testing.T, job *yaml.Node) []string {
|
||||
t.Helper()
|
||||
matrix := mappingValue(mappingValue(job, "strategy"), "matrix")
|
||||
if matrix == nil {
|
||||
t.Fatal("job has no matrix")
|
||||
}
|
||||
var arches []string
|
||||
if list := mappingValue(matrix, "arch"); list != nil {
|
||||
for _, n := range list.Content {
|
||||
arches = append(arches, n.Value)
|
||||
}
|
||||
}
|
||||
if include := mappingValue(matrix, "include"); include != nil {
|
||||
for _, entry := range include.Content {
|
||||
if arch := mappingScalar(entry, "arch"); arch != "" {
|
||||
arches = append(arches, arch)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(arches)
|
||||
return arches
|
||||
}
|
||||
|
||||
// One tree per job: each Windows stage runs once for every architecture the
|
||||
// build produces, so no architecture reaches publish without passing through
|
||||
// signing, packaging and verification.
|
||||
func TestEveryWindowsSigningStageCoversEveryArchitecture(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
for _, name := range []string{"windows-sign-payload", "windows-package", "windows-verify-package", "windows-sign-installer", "windows-install-smoke"} {
|
||||
if got := matrixArches(t, mappingValue(jobs, name)); !slices.Equal(got, windowsArchitectures) {
|
||||
t.Errorf("%s covers %v, want %v", name, got, windowsArchitectures)
|
||||
}
|
||||
}
|
||||
build := mappingValue(mappingValue(mappingValue(mappingValue(jobs, "build"), "strategy"), "matrix"), "include")
|
||||
var built []string
|
||||
for _, entry := range build.Content {
|
||||
if goarch := mappingScalar(entry, "goarch"); goarch != "" {
|
||||
built = append(built, goarch)
|
||||
}
|
||||
}
|
||||
slices.Sort(built)
|
||||
if !slices.Equal(built, windowsArchitectures) {
|
||||
t.Errorf("build produces Windows bundles for %v, want %v", built, windowsArchitectures)
|
||||
}
|
||||
}
|
||||
|
||||
// Certum's group holds one running and one waiting job, and a newer waiting job
|
||||
// cancels the older. Legs of one matrix would race for that single slot, so the
|
||||
// signing stages run them strictly one after another.
|
||||
func TestWindowsSigningLegsRunOneAtATime(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
for _, name := range []string{"windows-sign-payload", "windows-sign-installer"} {
|
||||
job := mappingValue(jobs, name)
|
||||
strategy := mappingValue(job, "strategy")
|
||||
if got := mappingScalar(strategy, "max-parallel"); got != "1" {
|
||||
t.Errorf("%s max-parallel = %q, want 1", name, got)
|
||||
}
|
||||
if got := mappingScalar(strategy, "fail-fast"); got != "false" {
|
||||
t.Errorf("%s fail-fast = %q; a failed leg must leave the others to finish so only it reruns", name, got)
|
||||
}
|
||||
group := mappingScalar(mappingValue(job, "concurrency"), "group")
|
||||
if group != "certum-signing" {
|
||||
t.Errorf("%s concurrency group = %q, want certum-signing", name, group)
|
||||
}
|
||||
if mappingScalar(job, "environment") != "studio-release" {
|
||||
t.Errorf("%s lost its studio-release environment", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Packaging, package verification and the install smoke parse archives and run
|
||||
// installers, so none of them may hold an environment or a secret.
|
||||
func TestWindowsPackagingStagesHoldNoSecrets(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
for _, name := range []string{"windows-package", "windows-verify-package", "windows-install-smoke"} {
|
||||
job := mappingValue(jobs, name)
|
||||
if mappingValue(job, "environment") != nil {
|
||||
t.Errorf("%s declares an environment", name)
|
||||
}
|
||||
if nodeContains(job, "secrets.") {
|
||||
t.Errorf("%s references a secret", name)
|
||||
}
|
||||
if nodeContains(job, "id-token") {
|
||||
t.Errorf("%s requests an id-token", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The arm64 installer reads back through the runner's 7-Zip, which decodes
|
||||
// filters the NSIS extractor cannot, so the filter that extractor reads is
|
||||
// pinned on every job that packs one, and publish waits for an installer to run.
|
||||
func TestArm64InstallerIsPackedWithTheFilterTheExtractorReads(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
pack := map[string]string{"build": "Package Windows from the bundle", "windows-package": "Package Windows from the signed bundle"}
|
||||
for job, step := range pack {
|
||||
env := mappingValue(stepNamed(t, mappingValue(jobs, job), step), "env")
|
||||
if got := mappingScalar(env, "ELECTRON_BUILDER_7Z_FILTER"); !strings.Contains(got, "'BCJ'") {
|
||||
t.Errorf("%s / %s: ELECTRON_BUILDER_7Z_FILTER = %q, want BCJ for arm64", job, step, got)
|
||||
}
|
||||
}
|
||||
needs := mappingValue(mappingValue(jobs, "publish"), "needs")
|
||||
var names []string
|
||||
for _, n := range needs.Content {
|
||||
names = append(names, n.Value)
|
||||
}
|
||||
if !slices.Contains(names, "windows-install-smoke") {
|
||||
t.Error("publish does not wait for windows-install-smoke")
|
||||
}
|
||||
if !strings.Contains(mappingScalar(mappingValue(jobs, "publish"), "if"), "needs.windows-install-smoke.result == 'success'") {
|
||||
t.Error("publish does not require windows-install-smoke to have succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// Matrix legs share one output map and the last non-empty write wins, so each
|
||||
// leg writes only its own architecture's key. A key that is not guarded by the
|
||||
// leg's architecture would let one leg's value stand in for the other's.
|
||||
func TestWindowsOutputsAreOnePerArchitecture(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
want := map[string][]string{
|
||||
"windows-sign-payload": {"payload-digest"},
|
||||
"windows-verify-package": {"installer-sha256", "archive-sha256"},
|
||||
"windows-sign-installer": {"installer-sha256"},
|
||||
}
|
||||
for name, keys := range want {
|
||||
outputs := mappingValue(mappingValue(jobs, name), "outputs")
|
||||
if outputs == nil {
|
||||
t.Fatalf("%s has no outputs", name)
|
||||
}
|
||||
if len(outputs.Content)/2 != len(keys)*len(windowsArchitectures) {
|
||||
t.Errorf("%s declares %d outputs, want %d", name, len(outputs.Content)/2, len(keys)*len(windowsArchitectures))
|
||||
}
|
||||
for _, key := range keys {
|
||||
for _, arch := range windowsArchitectures {
|
||||
got := mappingScalar(outputs, key+"-"+arch)
|
||||
if !strings.Contains(got, "matrix.arch == '"+arch+"'") {
|
||||
t.Errorf("%s output %s-%s = %q, want it guarded by matrix.arch == '%s'", name, key, arch, got, arch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The install smoke gates publish, so it has to run exactly when a Windows
|
||||
// package exists to run: after a signed installer, or after the build when
|
||||
// signing is off, and never after a failed signing chain.
|
||||
func TestInstallSmokeRunsOnlyWhereAPackageExists(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
smoke := mappingValue(jobs, "windows-install-smoke")
|
||||
cond := mappingScalar(smoke, "if")
|
||||
for _, want := range []string{
|
||||
"!cancelled()",
|
||||
"needs.build.result == 'success'",
|
||||
"needs.windows-sign-installer.result == 'success'",
|
||||
"vars.STUDIO_SIGNING_ENABLED != 'true'",
|
||||
"needs.windows-sign-installer.result == 'skipped'",
|
||||
} {
|
||||
if !strings.Contains(cond, want) {
|
||||
t.Errorf("windows-install-smoke if lacks %q: %s", want, cond)
|
||||
}
|
||||
}
|
||||
run := mappingScalar(stepNamed(t, smoke, "Install, check and uninstall"), "run")
|
||||
if !strings.Contains(run, "./scripts/install-smoke.ps1") {
|
||||
t.Error("the smoke job does not run scripts/install-smoke.ps1, the script pull requests run")
|
||||
}
|
||||
studio := parseWorkflowFile(t, "../../.github/workflows/studio.yml")
|
||||
pkg := mappingValue(jobsOf(t, studio), "package")
|
||||
if run := mappingScalar(stepNamed(t, pkg, "The package installs, runs and uninstalls"), "run"); !strings.Contains(run, "./scripts/install-smoke.ps1") {
|
||||
t.Error("the Studio package job does not run scripts/install-smoke.ps1, so pull requests would not exercise the release smoke")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishRefusesAPartialWindowsSet(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
publish := mappingValue(jobsOf(t, root), "publish")
|
||||
run := mappingScalar(stepNamed(t, publish, "Collect artifacts"), "run")
|
||||
if !strings.Contains(run, `"$count" -lt 10`) {
|
||||
t.Error("publish does not require at least 10 ReasonixStudio-* packages")
|
||||
}
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-arm64-installer.exe",
|
||||
"ReasonixStudio-windows-amd64.zip", "ReasonixStudio-windows-arm64.zip",
|
||||
"ReasonixStudio-linux-*.deb", "ReasonixStudio-linux-*.rpm",
|
||||
} {
|
||||
if !strings.Contains(run, "'"+name+"'") {
|
||||
t.Errorf("publish does not require %s by name", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/platform/update"
|
||||
@@ -14,8 +13,7 @@ import (
|
||||
// archMap is desktop/electron/arch.json: what the packager builds for, and what
|
||||
// each of those is called in a release.
|
||||
type archMap struct {
|
||||
Map map[string]string `json:"map"`
|
||||
FPM map[string][]string `json:"fpm"`
|
||||
Map map[string]string `json:"map"`
|
||||
}
|
||||
|
||||
func loadArchMap(t *testing.T) archMap {
|
||||
@@ -95,33 +93,3 @@ func TestThePackagerSpellingResolvesToAKeyNobodyAsksFor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fpm names an .rpm for the architecture its own way, and the parser accepts
|
||||
// that name too: it keys a package nobody asks for. The packager hook renames
|
||||
// these spellings, so every one must be a non-canonical key here.
|
||||
func TestFPMSpellingOfAnRPMIsNotAReleaseKey(t *testing.T) {
|
||||
m := loadArchMap(t)
|
||||
if len(m.FPM) == 0 {
|
||||
t.Fatal("arch.json lists no fpm spellings")
|
||||
}
|
||||
canonical := map[string]bool{}
|
||||
for _, to := range m.Map {
|
||||
canonical[to] = true
|
||||
}
|
||||
for from, spellings := range m.FPM {
|
||||
to, ok := m.Map[from]
|
||||
if !ok {
|
||||
t.Errorf("fpm spells %s, which map does not release", from)
|
||||
continue
|
||||
}
|
||||
if got, ok := rpmPackageKey(artifactPrefix + "linux-" + to + ".rpm"); !ok || got != update.RPMPackageKey("linux", to) {
|
||||
t.Errorf("linux %s rpm = %q (%v)", to, got, ok)
|
||||
}
|
||||
for _, spelled := range spellings {
|
||||
got, ok := rpmPackageKey(artifactPrefix + "linux-" + spelled + ".rpm")
|
||||
if !ok || canonical[strings.TrimSuffix(strings.TrimPrefix(got, "linux-"), "-rpm")] {
|
||||
t.Errorf("%s resolved to %q (%v); the rename is no longer load-bearing", spelled, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,12 +84,6 @@ func run(dir, version, tag, deltaDir string, mirrored bool) error {
|
||||
m.NativePackages[key] = asset
|
||||
fmt.Printf("native package: %s -> %s\n", name, key)
|
||||
}
|
||||
// The .rpm rides beside the .deb under its own key: the deb's key is the
|
||||
// one every installed client already reads.
|
||||
if key, ok := rpmPackageKey(name); ok {
|
||||
m.NativePackages[key] = asset
|
||||
fmt.Printf("native package: %s -> %s\n", name, key)
|
||||
}
|
||||
// Windows updates by running the next installer, so that is what the
|
||||
// platform lookup has to resolve. The portable archive is never listed:
|
||||
// resolving it would hand the updater an artifact it cannot install.
|
||||
@@ -141,8 +135,7 @@ func releaseAsset(repo, tag, name string, mirrored bool) update.Asset {
|
||||
func installable(name string) bool {
|
||||
return strings.HasSuffix(name, ".dmg") ||
|
||||
strings.HasSuffix(name, "-installer.exe") ||
|
||||
strings.HasSuffix(name, ".deb") ||
|
||||
strings.HasSuffix(name, ".rpm")
|
||||
strings.HasSuffix(name, ".deb")
|
||||
}
|
||||
|
||||
// macArchiveKeys maps a macOS archive to the platforms it can install. The
|
||||
@@ -192,20 +185,6 @@ func nativePackageKey(name string) (string, bool) {
|
||||
return update.PlatformKey("linux", arch), true
|
||||
}
|
||||
|
||||
// rpmPackageKey maps a ReasonixStudio-linux-<arch>.rpm artifact to its
|
||||
// native_packages key.
|
||||
func rpmPackageKey(name string) (string, bool) {
|
||||
base, ok := strings.CutSuffix(name, ".rpm")
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
arch, ok := strings.CutPrefix(base, artifactPrefix+"linux-")
|
||||
if !ok || arch == "" {
|
||||
return "", false
|
||||
}
|
||||
return update.RPMPackageKey("linux", arch), true
|
||||
}
|
||||
|
||||
func hashFile(path string) (int64, string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -189,100 +189,3 @@ func TestUnmirroredManifestNamesOnlyGitHub(t *testing.T) {
|
||||
t.Fatalf("asset = %+v, want GitHub alone when nothing is mirrored", a)
|
||||
}
|
||||
}
|
||||
|
||||
// Both Windows architectures ship an installer and a portable archive under
|
||||
// the same naming rule; each installer answers only its own platform key.
|
||||
func TestWindowsArchitecturesResolveToTheirOwnInstallers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-windows-amd64-installer.exe",
|
||||
"ReasonixStudio-windows-amd64.zip",
|
||||
"ReasonixStudio-windows-arm64-installer.exe",
|
||||
"ReasonixStudio-windows-arm64.zip",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", false); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
var m update.Manifest
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Platforms) != 2 || len(m.Downloads) != 2 {
|
||||
t.Fatalf("platforms %d, downloads %d; want one installer per architecture", len(m.Platforms), len(m.Downloads))
|
||||
}
|
||||
for arch, name := range map[string]string{
|
||||
"amd64": "ReasonixStudio-windows-amd64-installer.exe",
|
||||
"arm64": "ReasonixStudio-windows-arm64-installer.exe",
|
||||
} {
|
||||
a, ok := m.Platforms[update.PlatformKey("windows", arch)]
|
||||
if !ok || !strings.HasSuffix(a.URL, "/"+name) {
|
||||
t.Errorf("windows-%s resolves to %+v, want %s", arch, a, name)
|
||||
}
|
||||
}
|
||||
if len(m.Deltas) != 0 {
|
||||
t.Errorf("no delta directory was given, yet deltas = %v", m.Deltas)
|
||||
}
|
||||
}
|
||||
|
||||
// Every installed Linux client reads native_packages["linux-amd64"] as the
|
||||
// .deb and hands it to dpkg. The .rpm may add a key and never change that one.
|
||||
func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
|
||||
write := func(dir string, names ...string) {
|
||||
for _, name := range names {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
generate := func(dir string) update.Manifest {
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", true); err != nil {
|
||||
t.Fatalf("run: %v", err)
|
||||
}
|
||||
var m update.Manifest
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
debOnly, both := t.TempDir(), t.TempDir()
|
||||
write(debOnly, "ReasonixStudio-linux-amd64.deb")
|
||||
write(both, "ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.rpm")
|
||||
before, after := generate(debOnly), generate(both)
|
||||
|
||||
if after.NativePackages["linux-amd64"] != before.NativePackages["linux-amd64"] {
|
||||
t.Errorf("the deb entry moved: %+v, was %+v", after.NativePackages["linux-amd64"], before.NativePackages["linux-amd64"])
|
||||
}
|
||||
if !strings.HasSuffix(after.NativePackages["linux-amd64"].URL, "linux-amd64.deb") {
|
||||
t.Errorf("native_packages[linux-amd64] = %+v, want the .deb", after.NativePackages["linux-amd64"])
|
||||
}
|
||||
rpm, ok := after.NativePackages["linux-amd64-rpm"]
|
||||
if !ok || !strings.HasSuffix(rpm.URL, "ReasonixStudio-linux-amd64.rpm") || rpm.Sig != rpm.URL+".minisig" {
|
||||
t.Errorf("native_packages[linux-amd64-rpm] = %+v, want the signed .rpm", rpm)
|
||||
}
|
||||
if len(after.NativePackages) != 2 {
|
||||
t.Errorf("native packages = %v, want the deb and the rpm", after.NativePackages)
|
||||
}
|
||||
if _, ok := after.Platforms["linux-amd64"]; ok {
|
||||
t.Error("the .rpm must not resolve as a platform asset")
|
||||
}
|
||||
if _, ok := after.Downloads["ReasonixStudio-linux-amd64.rpm"]; !ok {
|
||||
t.Error("downloads must offer the .rpm to a person")
|
||||
}
|
||||
if len(after.Downloads) != len(before.Downloads)+1 {
|
||||
t.Errorf("downloads = %v, want only the rpm added", after.Downloads)
|
||||
}
|
||||
}
|
||||
@@ -90,8 +90,6 @@ void clipboardSettle() {
|
||||
|
||||
Json paste(DWORD pid, const std::string& text) {
|
||||
front(pid);
|
||||
Landing at = landing(pid, true);
|
||||
refuseHeldText(at);
|
||||
clipboardSettle();
|
||||
if (!open()) throw Failure{"computer.failed", "another application is holding the clipboard"};
|
||||
std::vector<Saved> saved = save();
|
||||
@@ -114,5 +112,5 @@ Json paste(DWORD pid, const std::string& text) {
|
||||
// The application reads the clipboard when it gets round to the keystroke,
|
||||
// and giving it back before then hands it the person's clipboard instead.
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(250));
|
||||
return typedEffect(at, w);
|
||||
return Json::object().set("pasted", static_cast<unsigned long long>(w.size()));
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
#define NOMINMAX
|
||||
#include <windows.h>
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -15,7 +14,6 @@
|
||||
struct Failure {
|
||||
std::string code;
|
||||
std::string message;
|
||||
Json blockedBy; // the modal that would have taken the input instead
|
||||
};
|
||||
|
||||
std::string narrow(const std::wstring& w);
|
||||
@@ -46,7 +44,6 @@ bool appListed(HWND top);
|
||||
void requireOperable(DWORD pid);
|
||||
bool ownsPoint(DWORD pid, POINT at);
|
||||
bool isFront(DWORD pid);
|
||||
HWND heldOwner(HWND window, DWORD pid);
|
||||
void front(DWORD pid);
|
||||
RECT windowBounds(HWND hwnd);
|
||||
Json rectJson(const RECT& r);
|
||||
@@ -68,24 +65,6 @@ Json scroll(DWORD pid, const std::string& ref, double amount);
|
||||
Json focus(DWORD pid, const std::string& ref);
|
||||
Json setValue(DWORD pid, const std::string& ref, const std::string& text);
|
||||
|
||||
// Landing is where keyboard input to an application goes, read after it is
|
||||
// brought forward and before anything is sent: the modal holding it, if any,
|
||||
// and, when withValue, the focused element with what it holds now.
|
||||
struct Landing {
|
||||
Json modal;
|
||||
bool takesText = false;
|
||||
bool readable = false;
|
||||
std::wstring before;
|
||||
std::function<bool(std::wstring&)> read;
|
||||
};
|
||||
Landing landing(DWORD pid, bool withValue);
|
||||
// refuseHeldText refuses text that would land in a modal on an element that
|
||||
// takes none, where a letter can be a dialog's mnemonic.
|
||||
void refuseHeldText(const Landing& at);
|
||||
// typedEffect grades typing on the focused element read again afterwards.
|
||||
Json typedEffect(const Landing& at, const std::wstring& typed);
|
||||
Json effect(const char* cls, const char* evidence, const Json& modal);
|
||||
|
||||
// keyboard.cpp
|
||||
Json typeText(DWORD pid, const std::string& text);
|
||||
Json pressKey(DWORD pid, const std::string& chord, int times);
|
||||
|
||||
@@ -130,8 +130,6 @@ void stillFront(DWORD pid, size_t done, size_t total, const char* unit) {
|
||||
Json typeText(DWORD pid, const std::string& text) {
|
||||
std::wstring units = widen(text);
|
||||
front(pid);
|
||||
Landing at = landing(pid, true);
|
||||
refuseHeldText(at);
|
||||
std::vector<INPUT> batch;
|
||||
size_t chunk = 0;
|
||||
for (size_t i = 0; i < units.size(); i++) {
|
||||
@@ -153,13 +151,12 @@ Json typeText(DWORD pid, const std::string& text) {
|
||||
}
|
||||
}
|
||||
send(batch);
|
||||
return typedEffect(at, units);
|
||||
return Json::object();
|
||||
}
|
||||
|
||||
Json pressKey(DWORD pid, const std::string& chord, int times) {
|
||||
KeyChord c = resolve(chord);
|
||||
front(pid);
|
||||
Json modal = landing(pid, false).modal;
|
||||
std::vector<INPUT> batch;
|
||||
for (int i = 0; i < std::clamp(times, 1, 200); i++) {
|
||||
stillFront(pid, i, times, "presses");
|
||||
@@ -167,7 +164,7 @@ Json pressKey(DWORD pid, const std::string& chord, int times) {
|
||||
chordUp(batch, c);
|
||||
send(batch);
|
||||
}
|
||||
return effect("unverifiable", nullptr, modal);
|
||||
return Json::object();
|
||||
}
|
||||
|
||||
// holdKey keeps a key down, which is what a game or a scrubbing control reads
|
||||
@@ -175,7 +172,6 @@ Json pressKey(DWORD pid, const std::string& chord, int times) {
|
||||
Json holdKey(DWORD pid, const std::string& chord, double seconds) {
|
||||
KeyChord c = resolve(chord);
|
||||
front(pid);
|
||||
Json modal = landing(pid, false).modal;
|
||||
std::vector<INPUT> batch;
|
||||
chordDown(batch, c);
|
||||
send(batch);
|
||||
@@ -189,7 +185,7 @@ Json holdKey(DWORD pid, const std::string& chord, double seconds) {
|
||||
chordUp(batch, c);
|
||||
send(batch);
|
||||
if (left) throw Failure{"computer.needs_front", "the application left the front during the hold; the key was released early"};
|
||||
return effect("unverifiable", nullptr, modal);
|
||||
return Json::object();
|
||||
}
|
||||
|
||||
void sendChord(const std::string& chord) {
|
||||
|
||||
@@ -156,9 +156,7 @@ int main() {
|
||||
try {
|
||||
reply(Json::object().set("id", *id).set("result", handle(method, params)));
|
||||
} catch (const Failure& f) {
|
||||
Json error = Json::object().set("code", f.code).set("message", f.message);
|
||||
if (f.blockedBy.kind() != Json::Kind::Null) error.set("blocked_by", f.blockedBy);
|
||||
reply(Json::object().set("id", *id).set("error", error));
|
||||
reply(Json::object().set("id", *id).set("error", Json::object().set("code", f.code).set("message", f.message)));
|
||||
} catch (const std::exception& e) {
|
||||
reply(Json::object().set("id", *id).set("error", Json::object().set("code", "computer.failed").set("message", e.what())));
|
||||
}
|
||||
|
||||
@@ -209,16 +209,6 @@ bool isFront(DWORD pid) {
|
||||
return fg && contentPid(fg) == pid;
|
||||
}
|
||||
|
||||
// heldOwner is the window of the same process that window, as a modal dialog,
|
||||
// has disabled somewhere up its owner chain; null when it holds none.
|
||||
HWND heldOwner(HWND window, DWORD pid) {
|
||||
if (!IsWindowEnabled(window)) return nullptr;
|
||||
for (HWND o = GetWindow(window, GW_OWNER); o; o = GetWindow(o, GW_OWNER)) {
|
||||
if (contentPid(o) == pid && !IsWindowEnabled(o)) return o;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// front brings the application forward before input that goes wherever the
|
||||
// foreground is. Windows grants the foreground only to a process that received
|
||||
// the last input, so an input with no effect is injected first.
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
#include <UIAutomation.h>
|
||||
#include <wrl/client.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <functional>
|
||||
@@ -20,9 +19,6 @@ namespace {
|
||||
|
||||
constexpr int maxNodes = 1500;
|
||||
constexpr int maxDepth = 40;
|
||||
// A value longer than this is not read back: comparing it costs more than the
|
||||
// step it would grade.
|
||||
constexpr int maxReadback = 1 << 16;
|
||||
constexpr HRESULT elementGone = static_cast<HRESULT>(0x80040201); // UIA_E_ELEMENTNOTAVAILABLE
|
||||
constexpr HRESULT elementDisabled = static_cast<HRESULT>(0x80040200); // UIA_E_ELEMENTNOTENABLED
|
||||
constexpr HRESULT notSupported = static_cast<HRESULT>(0x80040204); // UIA_E_NOTSUPPORTED
|
||||
@@ -141,48 +137,8 @@ bool transparent(int type) {
|
||||
return type == UIA_GroupControlTypeId || type == UIA_PaneControlTypeId || type == UIA_CustomControlTypeId;
|
||||
}
|
||||
|
||||
std::string windowTitle(HWND hwnd) {
|
||||
wchar_t buf[512];
|
||||
int n = GetWindowTextW(hwnd, buf, ARRAYSIZE(buf));
|
||||
return narrow(std::wstring(buf, n > 0 ? n : 0));
|
||||
}
|
||||
|
||||
HWND cachedHandle(IUIAutomationElement* el) {
|
||||
return reinterpret_cast<HWND>(static_cast<intptr_t>(Cached(el, UIA_NativeWindowHandlePropertyId).integer()));
|
||||
}
|
||||
|
||||
// inWindowModal is a dialog drawn inside its window that declares itself
|
||||
// modal. A top-level window's own root does not count: one holds input only
|
||||
// through the owner it has disabled.
|
||||
bool inWindowModal(IUIAutomationElement* el, HWND root) {
|
||||
return Cached(el, UIA_ControlTypePropertyId).integer() == UIA_WindowControlTypeId &&
|
||||
Cached(el, UIA_WindowIsModalPropertyId).flag() && cachedHandle(el) != root;
|
||||
}
|
||||
|
||||
// modalJson names a modal for the model; the titles are shown, never read.
|
||||
Json modalJson(IUIAutomationElement* el, DWORD pid, const std::string& blocks) {
|
||||
return Json::object().set("ref", refFor(el, pid)).set("title", Cached(el, UIA_NamePropertyId).str()).set("blocks", blocks);
|
||||
}
|
||||
|
||||
// win32Modal is the modal dialog hwnd is, when it holds another window of
|
||||
// the process; null otherwise.
|
||||
Json win32Modal(HWND hwnd, DWORD pid) {
|
||||
HWND held = heldOwner(hwnd, pid);
|
||||
if (!held) return Json();
|
||||
ComPtr<IUIAutomationElement> el;
|
||||
if (FAILED(uia->ElementFromHandleBuildCache(hwnd, cache.Get(), &el)) || !el) {
|
||||
return Json::object().set("ref", "").set("title", windowTitle(hwnd)).set("blocks", windowTitle(held));
|
||||
}
|
||||
return modalJson(el.Get(), pid, windowTitle(held));
|
||||
}
|
||||
|
||||
struct Walk {
|
||||
DWORD pid;
|
||||
HWND root = nullptr;
|
||||
bool rootIsModal = false;
|
||||
std::string rootTitle, heldTitle;
|
||||
Json modals = Json::array();
|
||||
std::set<std::string> modalRefs;
|
||||
std::vector<std::string> lines;
|
||||
std::set<std::string> seen;
|
||||
int count = 0;
|
||||
@@ -234,12 +190,6 @@ struct Walk {
|
||||
if (Cached(el, UIA_SelectionItemIsSelectedPropertyId).flag()) line += " selected";
|
||||
if (Cached(el, UIA_ToggleToggleStatePropertyId).integer() == ToggleState_On) line += " checked";
|
||||
if (pressable(el)) line += " pressable";
|
||||
bool isRoot = cachedHandle(el) == root;
|
||||
if ((isRoot && rootIsModal) || inWindowModal(el, root)) {
|
||||
line += " modal";
|
||||
Json m = modalJson(el, pid, isRoot ? heldTitle : rootTitle);
|
||||
if (modalRefs.insert(m.get("ref")->string()).second) modals.push(m);
|
||||
}
|
||||
lines.push_back(line);
|
||||
children(el, depth + 1);
|
||||
}
|
||||
@@ -355,148 +305,8 @@ void wheel(DWORD pid, double amount) {
|
||||
PostMessageW(target, WM_MOUSEWHEEL, MAKEWPARAM(0, static_cast<SHORT>(delta)), MAKELPARAM(centre.x, centre.y));
|
||||
}
|
||||
|
||||
// nativeRoot is the top-level window an element is drawn in.
|
||||
HWND nativeRoot(IUIAutomationElement* el) {
|
||||
ComPtr<IUIAutomationElement> at = el;
|
||||
for (int i = 0; at && i < maxDepth; i++) {
|
||||
UIA_HWND h = nullptr;
|
||||
if (SUCCEEDED(at->get_CurrentNativeWindowHandle(&h)) && h) return GetAncestor(static_cast<HWND>(h), GA_ROOT);
|
||||
ComPtr<IUIAutomationElement> parent;
|
||||
walker->GetParentElement(at.Get(), &parent);
|
||||
at = parent;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// reachable refuses an element in a window the application has disabled,
|
||||
// which a modal dialog does to the window it holds: the application has
|
||||
// declared that window takes no input until the dialog is answered.
|
||||
void reachable(IUIAutomationElement* el, DWORD pid, const std::string& what) {
|
||||
HWND root = nativeRoot(el);
|
||||
if (!root || IsWindowEnabled(root)) return;
|
||||
for (HWND w : appWindows(pid, false)) {
|
||||
for (HWND o = heldOwner(w, pid) ? GetWindow(w, GW_OWNER) : nullptr; o; o = GetWindow(o, GW_OWNER)) {
|
||||
if (o == root) {
|
||||
throw Failure{"computer.blocked", what + " is in a window a modal dialog holds; answer or dismiss the dialog first",
|
||||
win32Modal(w, pid)};
|
||||
}
|
||||
}
|
||||
}
|
||||
throw Failure{"computer.blocked", what + " is in a window the application has disabled"};
|
||||
}
|
||||
|
||||
// typedExactly is whether after is before with typed put in at one place,
|
||||
// replacing whatever was selected there. Where repeated characters make the
|
||||
// changed span ambiguous it is taken from either end, and any other change,
|
||||
// such as autocomplete or a single-line field dropping a newline, is not it.
|
||||
bool typedExactly(const std::wstring& before, const std::wstring& after, const std::wstring& typed) {
|
||||
if (after == before || after.size() < typed.size()) return false;
|
||||
auto span = [&](bool prefixFirst) {
|
||||
size_t p = 0, s = 0, room = std::min(before.size(), after.size());
|
||||
auto prefix = [&] { while (p < room - s && before[p] == after[p]) p++; };
|
||||
auto suffix = [&] { while (s < room - p && before[before.size() - 1 - s] == after[after.size() - 1 - s]) s++; };
|
||||
if (prefixFirst) {
|
||||
prefix();
|
||||
suffix();
|
||||
} else {
|
||||
suffix();
|
||||
prefix();
|
||||
}
|
||||
return after.substr(p, after.size() - p - s);
|
||||
};
|
||||
return span(true) == typed || span(false) == typed;
|
||||
}
|
||||
|
||||
std::wstring flat(std::wstring s) {
|
||||
s.erase(std::remove(s.begin(), s.end(), L'\r'), s.end());
|
||||
return s;
|
||||
}
|
||||
|
||||
// readValue is what an element holds as text, for comparing before and after
|
||||
// a step. A password, or a value too long to compare, is not read.
|
||||
bool readValue(IUIAutomationElement* el, std::wstring& out) {
|
||||
BOOL password = FALSE;
|
||||
if (FAILED(el->get_CurrentIsPassword(&password)) || password) return false;
|
||||
BSTR v = nullptr;
|
||||
if (auto p = pattern<IUIAutomationValuePattern>(el, UIA_ValuePatternId)) {
|
||||
if (FAILED(p->get_CurrentValue(&v))) return false;
|
||||
} else if (auto t = pattern<IUIAutomationTextPattern>(el, UIA_TextPatternId)) {
|
||||
ComPtr<IUIAutomationTextRange> doc;
|
||||
if (FAILED(t->get_DocumentRange(&doc)) || !doc || FAILED(doc->GetText(maxReadback, &v))) return false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
out = flat(v ? std::wstring(v, SysStringLen(v)) : std::wstring());
|
||||
SysFreeString(v);
|
||||
return out.size() < static_cast<size_t>(maxReadback);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Json effect(const char* cls, const char* evidence, const Json& modal) {
|
||||
Json e = Json::object().set("class", cls);
|
||||
if (evidence) {
|
||||
Json kinds = Json::array();
|
||||
kinds.push(evidence);
|
||||
e.set("evidence", kinds);
|
||||
}
|
||||
if (modal.kind() != Json::Kind::Null) e.set("blocked_by", modal);
|
||||
return Json::object().set("effect", e);
|
||||
}
|
||||
|
||||
Landing landing(DWORD pid, bool withValue) {
|
||||
need();
|
||||
Landing at;
|
||||
ComPtr<IUIAutomationElement> focused;
|
||||
int owner = 0;
|
||||
if (FAILED(uia->GetFocusedElementBuildCache(cache.Get(), &focused)) || !focused || FAILED(focused->get_CurrentProcessId(&owner)) ||
|
||||
static_cast<DWORD>(owner) != pid) {
|
||||
focused = nullptr;
|
||||
}
|
||||
HWND root = focused ? nativeRoot(focused.Get()) : nullptr;
|
||||
if (!root && isFront(pid)) root = GetForegroundWindow();
|
||||
ComPtr<IUIAutomationElement> el = focused;
|
||||
for (int i = 0; el && i < maxDepth && at.modal.kind() == Json::Kind::Null; i++) {
|
||||
if (inWindowModal(el.Get(), root)) at.modal = modalJson(el.Get(), pid, windowTitle(root));
|
||||
ComPtr<IUIAutomationElement> parent;
|
||||
walker->GetParentElementBuildCache(el.Get(), cache.Get(), &parent);
|
||||
el = parent;
|
||||
}
|
||||
if (at.modal.kind() == Json::Kind::Null && root) at.modal = win32Modal(root, pid);
|
||||
if (!focused || !withValue) return at;
|
||||
if (Cached(focused.Get(), UIA_IsValuePatternAvailablePropertyId).flag()) {
|
||||
at.takesText = !Cached(focused.Get(), UIA_ValueIsReadOnlyPropertyId).flag();
|
||||
} else {
|
||||
int type = Cached(focused.Get(), UIA_ControlTypePropertyId).integer();
|
||||
at.takesText = type == UIA_EditControlTypeId || type == UIA_DocumentControlTypeId;
|
||||
}
|
||||
at.read = [focused](std::wstring& out) { return readValue(focused.Get(), out); };
|
||||
at.readable = at.read(at.before);
|
||||
return at;
|
||||
}
|
||||
|
||||
void refuseHeldText(const Landing& at) {
|
||||
if (at.modal.kind() == Json::Kind::Null || at.takesText) return;
|
||||
throw Failure{"computer.blocked",
|
||||
"a modal dialog holds this application's input and its focused element takes no text, where a letter can answer the "
|
||||
"dialog; answer or dismiss it first",
|
||||
at.modal};
|
||||
}
|
||||
|
||||
// The application takes typed characters one message at a time, so a value
|
||||
// read part-way through holds some of them; it is read again until all are in.
|
||||
Json typedEffect(const Landing& at, const std::wstring& typed) {
|
||||
if (!at.readable) return effect("unverifiable", nullptr, at.modal);
|
||||
std::wstring after, want = flat(typed);
|
||||
for (int i = 0; i < 10; i++) {
|
||||
if (!at.read(after)) return effect("unverifiable", nullptr, at.modal);
|
||||
if (typedExactly(at.before, after, want)) return effect("confirmed", "value_readback", at.modal);
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(40));
|
||||
}
|
||||
if (after == at.before) return effect("suspected_noop", "value_unchanged", at.modal);
|
||||
return effect("unverifiable", nullptr, at.modal);
|
||||
}
|
||||
|
||||
void uiaStart() {
|
||||
if (FAILED(CoCreateInstance(__uuidof(CUIAutomation8), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&uia))) &&
|
||||
FAILED(CoCreateInstance(__uuidof(CUIAutomation), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&uia)))) {
|
||||
@@ -514,9 +324,7 @@ void uiaStart() {
|
||||
UIA_IsPasswordPropertyId, UIA_HelpTextPropertyId, UIA_HasKeyboardFocusPropertyId, UIA_IsEnabledPropertyId,
|
||||
UIA_SelectionItemIsSelectedPropertyId, UIA_ToggleToggleStatePropertyId, UIA_IsInvokePatternAvailablePropertyId,
|
||||
UIA_IsTogglePatternAvailablePropertyId, UIA_IsExpandCollapsePatternAvailablePropertyId,
|
||||
UIA_IsSelectionItemPatternAvailablePropertyId, UIA_LegacyIAccessibleDefaultActionPropertyId,
|
||||
UIA_WindowIsModalPropertyId, UIA_NativeWindowHandlePropertyId, UIA_IsValuePatternAvailablePropertyId,
|
||||
UIA_ValueIsReadOnlyPropertyId}) {
|
||||
UIA_IsSelectionItemPatternAvailablePropertyId, UIA_LegacyIAccessibleDefaultActionPropertyId}) {
|
||||
cache->AddProperty(id);
|
||||
}
|
||||
}
|
||||
@@ -532,11 +340,6 @@ Json snapshot(DWORD pid) {
|
||||
: "This application has no window on screen right now — it may be minimized. A type, key or pointer step brings it forward; take a new snapshot after.";
|
||||
}
|
||||
for (HWND hwnd : wins) {
|
||||
walk.root = hwnd;
|
||||
HWND held = heldOwner(hwnd, pid);
|
||||
walk.rootIsModal = held != nullptr;
|
||||
walk.rootTitle = windowTitle(hwnd);
|
||||
walk.heldTitle = held ? windowTitle(held) : "";
|
||||
ComPtr<IUIAutomationElement> root;
|
||||
if (SUCCEEDED(uia->ElementFromHandleBuildCache(hwnd, cache.Get(), &root)) && root) walk.visit(root.Get(), 0);
|
||||
}
|
||||
@@ -547,14 +350,12 @@ Json snapshot(DWORD pid) {
|
||||
.set("bundle", identity(pid))
|
||||
.set("lines", lines)
|
||||
.set("truncated", walk.count >= maxNodes)
|
||||
.set("note", note)
|
||||
.set("modals", walk.modals);
|
||||
.set("note", note);
|
||||
}
|
||||
|
||||
Json press(DWORD pid, const std::string& ref) {
|
||||
need();
|
||||
ComPtr<IUIAutomationElement> el = element(ref, pid);
|
||||
reachable(el.Get(), pid, ref);
|
||||
showAt(el.Get());
|
||||
activate(el.Get(), ref);
|
||||
return Json::object();
|
||||
@@ -582,7 +383,6 @@ Json clickAt(DWORD pid, double x, double y) {
|
||||
}
|
||||
int type = Cached(el.Get(), UIA_ControlTypePropertyId).integer();
|
||||
std::string what = "the " + roleName(type) + " at that point";
|
||||
reachable(el.Get(), pid, what);
|
||||
if (pressable(el.Get())) {
|
||||
activate(el.Get(), what);
|
||||
} else if (type == UIA_EditControlTypeId || type == UIA_ComboBoxControlTypeId || type == UIA_DocumentControlTypeId) {
|
||||
@@ -598,7 +398,6 @@ Json clickAt(DWORD pid, double x, double y) {
|
||||
Json menu(DWORD pid, const std::string& ref) {
|
||||
need();
|
||||
ComPtr<IUIAutomationElement> target = element(ref, pid);
|
||||
reachable(target.Get(), pid, ref);
|
||||
showAt(target.Get());
|
||||
ComPtr<IUIAutomationElement3> el;
|
||||
if (FAILED(target.As(&el))) throw Failure{"computer.no_action", ref + " has no context menu"};
|
||||
@@ -613,7 +412,6 @@ Json scroll(DWORD pid, const std::string& ref, double amount) {
|
||||
ComPtr<IUIAutomationElement> from;
|
||||
if (!ref.empty()) {
|
||||
from = element(ref, pid);
|
||||
reachable(from.Get(), pid, ref);
|
||||
showAt(from.Get());
|
||||
if (amount == 0) {
|
||||
BOOL offscreen = TRUE;
|
||||
@@ -647,7 +445,6 @@ Json scroll(DWORD pid, const std::string& ref, double amount) {
|
||||
Json focus(DWORD pid, const std::string& ref) {
|
||||
need();
|
||||
ComPtr<IUIAutomationElement> el = element(ref, pid);
|
||||
reachable(el.Get(), pid, ref);
|
||||
showAt(el.Get());
|
||||
HRESULT hr = el->SetFocus();
|
||||
if (FAILED(hr) && hr != elementGone) throw Failure{"computer.no_action", ref + " cannot take focus (HRESULT " + hresult(hr) + ")"};
|
||||
@@ -658,22 +455,22 @@ Json focus(DWORD pid, const std::string& ref) {
|
||||
Json setValue(DWORD pid, const std::string& ref, const std::string& value) {
|
||||
need();
|
||||
ComPtr<IUIAutomationElement> el = element(ref, pid);
|
||||
reachable(el.Get(), pid, ref);
|
||||
showAt(el.Get());
|
||||
auto p = pattern<IUIAutomationValuePattern>(el.Get(), UIA_ValuePatternId);
|
||||
BOOL readOnly = FALSE;
|
||||
if (!p || (SUCCEEDED(p->get_CurrentIsReadOnly(&readOnly)) && readOnly)) {
|
||||
throw Failure{"computer.no_action", ref + " has no value that can be set"};
|
||||
}
|
||||
std::wstring before, now;
|
||||
bool hadBefore = readValue(el.Get(), before);
|
||||
BSTR b = SysAllocString(widen(value).c_str());
|
||||
HRESULT hr = p->SetValue(b);
|
||||
SysFreeString(b);
|
||||
if (FAILED(hr) && hr != elementGone) throw Failure{"computer.no_action", ref + " has no value that can be set (HRESULT " + hresult(hr) + ")"};
|
||||
settle(hr, ref, "set value");
|
||||
if (!readValue(el.Get(), now)) return effect("unverifiable", nullptr, Json());
|
||||
if (now == flat(widen(value))) return effect("confirmed", "value_readback", Json());
|
||||
if (hadBefore && now == before) return effect("suspected_noop", "value_unchanged", Json());
|
||||
return effect("unverifiable", nullptr, Json());
|
||||
BSTR now = nullptr;
|
||||
p->get_CurrentValue(&now);
|
||||
std::string result = now ? narrow(std::wstring(now, SysStringLen(now))) : "";
|
||||
SysFreeString(now);
|
||||
BOOL password = FALSE;
|
||||
el->get_CurrentIsPassword(&password);
|
||||
return Json::object().set("value", password ? "" : result);
|
||||
}
|
||||
@@ -4,14 +4,5 @@
|
||||
"x64": "amd64",
|
||||
"arm64": "arm64",
|
||||
"universal": "universal"
|
||||
},
|
||||
"_fpm": "fpm spells the architecture its own way in an .rpm name (x86_64, aarch64) wherever artifactName uses ${arch}; the deb comes out as amd64 already. Each spelling is renamed to the release arch, and one not listed here fails the build.",
|
||||
"fpm": {
|
||||
"x64": [
|
||||
"x86_64"
|
||||
],
|
||||
"arm64": [
|
||||
"aarch64"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 25 KiB |
@@ -99,17 +99,6 @@
|
||||
# full of someone's work gets named in the first place. Refuse with an exit
|
||||
# code, because that is all a script driving this can read.
|
||||
!macro customInit
|
||||
# The arm64 installer carries only an arm64 application: on any other
|
||||
# machine electron-builder's extraction picks no archive and installs nothing
|
||||
# while reporting success.
|
||||
!ifdef APP_ARM64
|
||||
${IfNot} ${IsNativeARM64}
|
||||
DetailPrint "This installer is for Windows on ARM64 and cannot run on this machine"
|
||||
MessageBox MB_ICONSTOP "This Reasonix Studio installer is built for Windows on ARM64.$\r$\n$\r$\nDownload the x64 installer for this machine." /SD IDOK
|
||||
SetErrorLevel 3
|
||||
Abort
|
||||
${EndIf}
|
||||
!endif
|
||||
Push $R9
|
||||
!insertmacro instDirTakesNothingElse $R9
|
||||
${If} $R9 == 0
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB |
Loaded 100 of 2369 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user