mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5207d6f89b | ||
|
|
20354e388f | ||
|
|
4111108fc4 |
No files matched your search
@@ -22,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
|
||||
|
||||
@@ -61,8 +61,8 @@ jobs:
|
||||
# its DTOs are generated from internal/ext/extension/protocol, so
|
||||
# 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.
|
||||
if echo "$files" | grep -qE '^(scripts/|release-notes/studio/)'; then scripts=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/)'; then scripts=true; fi
|
||||
fi
|
||||
fi
|
||||
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
- run: node --test scripts/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.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
|
||||
|
||||
@@ -1118,9 +1118,39 @@ jobs:
|
||||
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 }}
|
||||
@@ -1134,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
|
||||
@@ -1145,7 +1175,7 @@ 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: |
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
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()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var document yaml.Node
|
||||
if err := yaml.Unmarshal(data, &document); err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
if len(document.Content) != 1 {
|
||||
t.Fatalf("%s: not a single YAML document", path)
|
||||
}
|
||||
return document.Content[0]
|
||||
}
|
||||
|
||||
func jobsOf(t *testing.T, root *yaml.Node) *yaml.Node {
|
||||
t.Helper()
|
||||
jobs := mappingValue(root, "jobs")
|
||||
if jobs == nil || jobs.Kind != yaml.MappingNode {
|
||||
t.Fatal("workflow has no jobs mapping")
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
func permissionsIssue(node *yaml.Node) string {
|
||||
if node == nil {
|
||||
return ""
|
||||
}
|
||||
if node.Kind != yaml.MappingNode {
|
||||
return "permissions is a " + node.Value + " shorthand, not a mapping"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// secretsRefs reports every way a workflow can reach a named secret: dotted
|
||||
// and subscripted access, and the whole-context forms that expose them all.
|
||||
var secretsWholeContext = regexp.MustCompile(`\bsecrets\b\s*(?:[^.\[\w\s]|$)|toJSON\(\s*secrets\s*\)|\bsecrets\s*\[\s*[^'"\s]`)
|
||||
|
||||
func secretRefPattern(name string) *regexp.Regexp {
|
||||
return regexp.MustCompile(`\bsecrets\s*(?:\.\s*` + name + `\b|\[\s*['"]` + name + `['"]\s*\])`)
|
||||
}
|
||||
|
||||
func scalarsOf(node *yaml.Node, out *[]string) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
if node.Kind == yaml.ScalarNode {
|
||||
*out = append(*out, node.Value)
|
||||
}
|
||||
for _, child := range node.Content {
|
||||
scalarsOf(child, out)
|
||||
}
|
||||
}
|
||||
|
||||
func refersTo(node *yaml.Node, names []string) (string, bool) {
|
||||
var scalars []string
|
||||
scalarsOf(node, &scalars)
|
||||
for _, text := range scalars {
|
||||
for _, name := range names {
|
||||
if secretRefPattern(name).MatchString(text) {
|
||||
return name, true
|
||||
}
|
||||
}
|
||||
if secretsWholeContext.MatchString(text) && regexp.MustCompile(`\$\{\{`).MatchString(text) {
|
||||
return "the whole secrets context", true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
job := mappingValue(jobs, "cli-channels")
|
||||
if job == nil {
|
||||
t.Fatal("cli-channels job is missing")
|
||||
}
|
||||
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)
|
||||
}
|
||||
permissions := mappingValue(job, "permissions")
|
||||
if permissions == nil || permissions.Kind != yaml.MappingNode {
|
||||
t.Fatal("cli-channels must declare its permissions as a mapping")
|
||||
}
|
||||
want := map[string]string{"contents": "read", "id-token": "write"}
|
||||
if len(permissions.Content) != 2*len(want) {
|
||||
t.Fatalf("cli-channels permissions has %d entries, want exactly %v", len(permissions.Content)/2, want)
|
||||
}
|
||||
for scope, level := range want {
|
||||
if got := mappingScalar(permissions, scope); got != level {
|
||||
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 {
|
||||
var scalars []string
|
||||
scalarsOf(node, &scalars)
|
||||
return strings.Join(scalars, "\n")
|
||||
}
|
||||
|
||||
func TestNoWorkflowGrantsIDTokenOrBroadPermissionsOutsideCLIChannels(t *testing.T) {
|
||||
files, err := filepath.Glob("../../.github/workflows/*.yml")
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no workflows found: %v", err)
|
||||
}
|
||||
for _, file := range files {
|
||||
root := parseWorkflowFile(t, file)
|
||||
if top := mappingValue(root, "permissions"); top != nil {
|
||||
if msg := permissionsIssue(top); msg != "" {
|
||||
t.Errorf("%s: workflow-level %s", file, msg)
|
||||
} else if mappingScalar(top, "id-token") != "" {
|
||||
t.Errorf("%s: id-token at workflow level", file)
|
||||
}
|
||||
}
|
||||
jobs := mappingValue(root, "jobs")
|
||||
if jobs == nil {
|
||||
continue
|
||||
}
|
||||
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||
name, job := jobs.Content[i].Value, jobs.Content[i+1]
|
||||
permissions := mappingValue(job, "permissions")
|
||||
if permissions == nil {
|
||||
continue
|
||||
}
|
||||
if msg := permissionsIssue(permissions); msg != "" {
|
||||
t.Errorf("%s job %s: %s", file, name, msg)
|
||||
continue
|
||||
}
|
||||
if mappingScalar(permissions, "id-token") != "" && !(filepath.Base(file) == "release-studio.yml" && name == "cli-channels") {
|
||||
t.Errorf("%s job %s requests id-token; only release-studio.yml cli-channels may", file, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
|
||||
files, err := filepath.Glob("../../.github/workflows/*.yml")
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no workflows found: %v", err)
|
||||
}
|
||||
guarded := append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...)
|
||||
for _, file := range files {
|
||||
root := parseWorkflowFile(t, file)
|
||||
jobs := mappingValue(root, "jobs")
|
||||
if jobs == nil {
|
||||
continue
|
||||
}
|
||||
if filepath.Base(file) == "release-studio.yml" {
|
||||
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||
name, job := jobs.Content[i].Value, jobs.Content[i+1]
|
||||
if name == "cli-channels" {
|
||||
continue
|
||||
}
|
||||
if what, found := refersTo(job, guarded); found {
|
||||
t.Errorf("release-studio.yml job %s reaches %s; only cli-channels may", name, what)
|
||||
}
|
||||
}
|
||||
if what, found := refersTo(mappingValue(root, "env"), guarded); found {
|
||||
t.Errorf("release-studio.yml workflow env reaches %s", what)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if name, found := refersTo(root, cliChannelSecrets); found {
|
||||
t.Errorf("%s reaches %s, which belongs to studio's cli-channels", file, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"${{ secrets.STUDIO_NPM_TOKEN }}",
|
||||
"${{ secrets['STUDIO_NPM_TOKEN'] }}",
|
||||
`${{ secrets["STUDIO_NPM_TOKEN"] }}`,
|
||||
"${{ toJSON(secrets) }}",
|
||||
"${{ secrets[format('STUDIO_{0}', 'NPM_TOKEN')] }}",
|
||||
} {
|
||||
var doc yaml.Node
|
||||
if err := yaml.Unmarshal([]byte("run: \""+regexp.MustCompile(`"`).ReplaceAllString(text, `\"`)+"\"\n"), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found := refersTo(doc.Content[0], cliChannelSecrets); !found {
|
||||
t.Errorf("reference form not detected: %s", text)
|
||||
}
|
||||
}
|
||||
var doc yaml.Node
|
||||
if err := yaml.Unmarshal([]byte("run: echo ${{ secrets.R2_BUCKET }}\n"), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, found := refersTo(doc.Content[0], cliChannelSecrets); found {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
owner: @esengine
|
||||
backup: @SivanCola
|
||||
status: active
|
||||
reviewed: 2026-09-30
|
||||
---
|
||||
|
||||
# Code signing policy
|
||||
|
||||
DRAFT. Not in force until the owner resolves every `TO CONFIRM` marker and the Windows signing provider named in section 1 is true. Do not link this page from the README before then.
|
||||
|
||||
Scope: Windows and macOS binaries of Reasonix Studio (`studio-v2.*` releases) published from `esengine/DeepSeek-Reasonix`.
|
||||
Requirements source: <https://signpath.org/terms> (SignPath Foundation OSS terms). Requirements that page does not state are marked `UNVERIFIED`.
|
||||
|
||||
## 1. Current signing status
|
||||
|
||||
| ID | Fact |
|
||||
| --- | --- |
|
||||
| S1 | Windows Authenticode signing of Studio releases uses a Certum certificate through Certum SimplySign cloud signing, when the repository variable `STUDIO_SIGNING_ENABLED` is `true`. |
|
||||
| S2 | Windows releases are NOT signed through SignPath. The SignPath Foundation certificate is not in use for any release. |
|
||||
| S3 | macOS packages are signed with an Apple Developer ID and notarized. Linux packages carry a detached minisign signature. |
|
||||
| S4 | Every package carries a `.minisig` signature and the release carries `SHA256SUMS`. |
|
||||
| S5 | This page becomes the SignPath Foundation code signing policy only after the Windows release workflow signs through SignPath and section 1 is rewritten to say so. |
|
||||
|
||||
## 2. What is signed
|
||||
|
||||
| ID | Rule |
|
||||
| --- | --- |
|
||||
| P1 | Only binaries built from this repository's own source by `.github/workflows/release-studio.yml` on the `studio` branch MAY be signed. |
|
||||
| P2 | The signed set is the Studio executables, the installer that packages them, and the bundled `reasonix` CLI archives built in the same run. |
|
||||
| P3 | Third-party binaries bundled unmodified keep their upstream signatures. They MUST NOT be re-signed with the project certificate. |
|
||||
| P4 | The signing contract `.signpath/contracts/release-signing.yml` declares the allowed branch, build definitions and the files whose fingerprint the release checks before any signing job runs. |
|
||||
| P5 | Software containing malware, or features designed to identify or exploit security vulnerabilities in other software, MUST NOT be signed. |
|
||||
|
||||
## 3. Build origin and integrity
|
||||
|
||||
| ID | Rule |
|
||||
| --- | --- |
|
||||
| B1 | Signing runs only inside the GitHub Actions release workflow, started by a `studio-v*` tag or a manual dispatch of that workflow. |
|
||||
| B2 | The workflow builds the unsigned Windows bundle first. The signing jobs refuse a bundle whose executable set differs from the declared list or whose digest changed after the build. |
|
||||
| B3 | The signing jobs run in the `studio-release` environment and share the concurrency group `certum-signing`. |
|
||||
| B4 | Reproducible builds: UNVERIFIED. No claim is made that two builds of one commit are byte-identical. |
|
||||
|
||||
## 4. Team roles
|
||||
|
||||
Only one role holder is known. Everyone else MUST be added by the owner; none is assumed.
|
||||
|
||||
| Role | Members | Responsibility |
|
||||
| --- | --- | --- |
|
||||
| Author (committer) | @esengine | Trusted to change source code without a second review. TO CONFIRM: whether any other account has write access and belongs here. |
|
||||
| Reviewer | @esengine | Reviews every change from a non-committer before it is merged. TO CONFIRM: whether @SivanCola reviews as backup. |
|
||||
| Approver | @esengine | Approves each signing request, by dispatching the release and approving the `studio-release` environment. TO CONFIRM: approvers must be listed by name; add a second person if one exists. |
|
||||
|
||||
## 5. Privacy
|
||||
|
||||
| ID | Statement |
|
||||
| --- | --- |
|
||||
| V1 | Reasonix runs locally. Prompts, files and session content are sent only to the model providers the user configures. |
|
||||
| V2 | Anonymous, content-free telemetry (random install ID, version, OS, architecture, surface, fixed-bucket counters) goes to `https://crash.reasonix.io`. Details and opt-out for the CLI are in `docs/GUIDE.md`, section "CLI telemetry". |
|
||||
| V3 | TO CONFIRM: what the Studio desktop app itself sends, whether it is disclosed during installation, and whether the installer offers a way to disable it. The SignPath terms require all three for software that sends user data to systems the user did not specify. Nothing here is verified for Studio. |
|
||||
|
||||
## 6. Attribution line
|
||||
|
||||
Use this line only after S2 stops being true:
|
||||
|
||||
> Free code signing on Windows provided by [SignPath.io](https://signpath.io), certificate by [SignPath Foundation](https://signpath.org)
|
||||
|
||||
## 7. Enforcement
|
||||
|
||||
| ID | Rule |
|
||||
| --- | --- |
|
||||
| E1 | `go run ./cmd/signpath-contract validate` fails a release workflow that reaches signing credentials without being declared in the contract. |
|
||||
| E2 | Changes to this page, `.signpath/` and the release workflow require review by the CODEOWNERS listed in `.github/CODEOWNERS`. |
|
||||
+28
-1
@@ -2,7 +2,7 @@
|
||||
owner: @esengine
|
||||
backup: @SivanCola
|
||||
status: active
|
||||
reviewed: 2026-09-27
|
||||
reviewed: 2026-09-30
|
||||
---
|
||||
|
||||
# Studio release runbook
|
||||
@@ -60,9 +60,34 @@ Jobs in the run:
|
||||
| `windows-sign-installer` | Requires both packages to hash to the checked values, signs the installer, verifies its signature, and outputs the signed installer's SHA-256. Opens no archive. | shared concurrency group `certum-signing`, environment `studio-release` |
|
||||
| `cli` | Builds `reasonix` archives for six OS/arch targets plus `SHA256SUMS`. | fails on a missing archive |
|
||||
| `publish` | Renders the notes with their authors, minisigns, writes `latest.json`, creates the GitHub prerelease, mirrors to R2. | environment `studio-release`; skipped unless all four Windows signing jobs succeeded or signing is off; an unresolved `#N` stops it before signing |
|
||||
| `cli-channels` | Only with `STUDIO_PUBLISHES_CLI=true` (unset today; 1.x owns the channels). Publishes `reasonix` and `@reasonix/cli-*` to npm with `--provenance`, then updates the Homebrew cask (not for a candidate). | environment `studio-cli-channels`, verified to have a required reviewer at run time; `id-token: write` on this job only; runs after `publish` |
|
||||
|
||||
The `studio-release` environment allows the `studio-v*` tag and the `studio` branch. It has no required reviewer.
|
||||
|
||||
The `studio-cli-channels` environment guards `cli-channels`. Create it and its secrets before setting `STUDIO_PUBLISHES_CLI`:
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| Required reviewers | @esengine and @SivanCola |
|
||||
| Deployment branches and tags | the `studio-v*` tag and the `studio` branch, as in `studio-release` |
|
||||
| Environment secrets | `STUDIO_NPM_TOKEN` and `STUDIO_HOMEBREW_TAP_TOKEN`, values copied from the existing `NPM_TOKEN` and `HOMEBREW_TAP_TOKEN` |
|
||||
|
||||
Leave the repository-level `NPM_TOKEN` and `HOMEBREW_TAP_TOKEN` alone: the 1.x release line still reads them. Delete them after 1.x stops publishing.
|
||||
|
||||
The two `STUDIO_*` names exist only in that environment, so a missing environment yields no token and the publish fails. The job's first step reads the environment through the API before any step that runs a command or holds a credential.
|
||||
|
||||
It fails when the environment does not exist (404), when it has no required reviewer or the rules cannot be read as a count, and on any other status.
|
||||
|
||||
The last failure is reported with the acting identity.
|
||||
|
||||
Turn off "Allow administrators to bypass configured protection rules" on the environment: with `can_admins_bypass` on, an administrator can skip the approval.
|
||||
|
||||
Each release then waits for one approval before npm or the tap is touched. The `studio` branch is admitted for the `workflow_dispatch` recovery run; there the provenance records the branch head commit while the build is the tag's commit. The 1.x line behaves the same.
|
||||
|
||||
No other job requests `id-token` or references these two secrets, and no workflow file reads them but `release-studio.yml`'s `cli-channels`; `cmd/signpath-contract` tests this over the parsed YAML of every workflow.
|
||||
|
||||
Verify a published package with `npm view reasonix dist.attestations`: the attestation is present and names this repository.
|
||||
|
||||
## 4. Verification
|
||||
|
||||
| ID | Expected | Command |
|
||||
@@ -216,6 +241,8 @@ Studio and 1.x signing jobs and smoke tests share the concurrency group `certum-
|
||||
| `APPLE_CERT_P12`, `APPLE_CERT_PASSWORD`, `APPLE_API_KEY_P8`, `APPLE_API_KEY_ID`, `APPLE_API_ISSUER_ID` | secret | macOS signing and notarization |
|
||||
| `CERTUM_USERNAME`, `CERTUM_OTP_URI`, `CERTUM_KEY_ID` | repository secret | Windows Authenticode signing shared with 1.x (section 7) |
|
||||
| `STUDIO_SIGNING_ENABLED` | variable | Windows signing switch |
|
||||
| `STUDIO_PUBLISHES_CLI` | variable | hands the CLI's npm and Homebrew channels to this workflow; set it only after the `studio-cli-channels` environment and its secrets exist and 1.x has stopped publishing them |
|
||||
| `STUDIO_NPM_TOKEN`, `STUDIO_HOMEBREW_TAP_TOKEN` | environment secret (`studio-cli-channels`) | `cli-channels` only |
|
||||
| `STUDIO_SIGNING_SUBJECT` | variable | the signer subject every signed executable must carry; required when signing is on |
|
||||
| `MINISIGN_PRIVATE_KEY`, `MINISIGN_PASSWORD` | secret | detached signatures verified by the updater |
|
||||
| `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_ACCOUNT_ID`, `R2_BUCKET` | secret | artifact mirror and catalog |
|
||||
|
||||
+1
-1
@@ -227,7 +227,7 @@ function ensurePackage(
|
||||
log(`publish ${entry.name}@${version} (${stagingTag})`);
|
||||
try {
|
||||
runner(
|
||||
["publish", "--access", "public", "--tag", stagingTag],
|
||||
["publish", "--access", "public", "--provenance", "--tag", stagingTag],
|
||||
{ cwd: entry.dir, inherit: true },
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -148,6 +148,7 @@ test("fills a partially published package set before advancing canary", (t) => {
|
||||
const publishes = fx.calls.filter(({ args }) => args[0] === "publish");
|
||||
assert.equal(publishes.length, 1);
|
||||
assert.equal(publishes[0].cwd, fx.packages[1].dir);
|
||||
assert.ok(publishes[0].args.includes("--provenance"), "every publish attaches a provenance attestation");
|
||||
for (const { name } of fx.packages) {
|
||||
assert.equal(fx.registry.get(name).tags.get("canary"), "1.5.0-canary.42");
|
||||
assert.equal(fx.registry.get(name).tags.has("canary-staging"), false);
|
||||
|
||||
Reference in new issue
Block a user