Compare commits

...
Author SHA1 Message Date
esengine 5207d6f89b docs: draft code signing policy page (not in force) 2026-09-30 01:46:32 -07:00
YHH 20354e388f Merge pull request #11395 from esengine/ci/studio-cli-channels-hardening
ci(release): attest and gate the CLI channels job
2026-09-30 01:07:04 -07:00
esengine 4111108fc4 ci(release): attest and gate the CLI channels job
cli-channels runs in the studio-cli-channels environment and is the only job
with id-token: write. Its registry tokens are environment-only secrets
(STUDIO_NPM_TOKEN, STUDIO_HOMEBREW_TAP_TOKEN), and its first step fails unless
the environment has a required reviewer. npm publish passes --provenance,
matching the 1.x release line. A contract test parses every workflow and pins
the permissions, the secret references and the id-token grant. npm/ changes now
trigger the CI job that runs npm/publish.test.mjs.
2026-09-30 00:49:28 -07:00
8 changed files with 445 additions and 7 deletions

No files matched your search

+1
View File
@@ -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
+3 -3
View File
@@ -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
+32 -2
View File
@@ -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: |
+306
View File
@@ -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)
}
}
}
+73
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
+1
View File
@@ -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);