@sdods/cli 0.2.2 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -1
- package/dist/commands/analyze.d.ts +2 -0
- package/dist/commands/analyze.js +12 -32
- package/dist/commands/run.d.ts +1 -0
- package/dist/commands/run.js +29 -2
- package/package.json +8 -8
- package/templates/.claude/skills/sdods-release-channels/SKILL.md +185 -0
- package/templates/.claude/skills/code-signing/SKILL.md +0 -238
- package/templates/.claude/skills/sdods-desktop-release/SKILL.md +0 -170
package/dist/commands/analyze.js
CHANGED
|
@@ -8,7 +8,11 @@ import { projectTemplateFiles } from '../templates/project.js';
|
|
|
8
8
|
/** Programmatic entry used by `sdods init --from <app>`: analyze → propose → apply. */
|
|
9
9
|
export async function initFromApp(opts) {
|
|
10
10
|
const { analyzeProject, proposeProject, applyProposal } = await import('@sdods/core/analyze');
|
|
11
|
-
const report = analyzeProject(opts.appPath, {
|
|
11
|
+
const report = analyzeProject(opts.appPath, {
|
|
12
|
+
openapi: opts.openapi,
|
|
13
|
+
maxFiles: opts.maxFiles,
|
|
14
|
+
maxDepth: opts.maxDepth,
|
|
15
|
+
});
|
|
12
16
|
const proposal = proposeProject(report, {
|
|
13
17
|
slug: opts.slug,
|
|
14
18
|
name: opts.name,
|
|
@@ -52,12 +56,18 @@ export function register(program) {
|
|
|
52
56
|
.option('--force', 'overwrite an existing project when applying')
|
|
53
57
|
.option('--no-import-specs', 'do not copy existing Playwright specs into recorded/imported')
|
|
54
58
|
.option('--report-only', 'print the analysis, skip the proposal')
|
|
59
|
+
.option('--max-files <n>', 'file budget for the scan (default 25000); raise it on a large monorepo', (v) => Number.parseInt(v, 10))
|
|
60
|
+
.option('--max-depth <n>', 'directory depth budget for the scan (default 12)', (v) => Number.parseInt(v, 10))
|
|
55
61
|
.action(async (path, opts, cmd) => {
|
|
56
62
|
const ctx = createContext(cmd);
|
|
57
63
|
// the app path is relative to where the command was typed, not to --cwd (the SDODS repo)
|
|
58
64
|
const appPath = resolvePath(process.cwd(), path ?? '.');
|
|
59
65
|
const { analyzeProject, proposeProject, applyProposal } = await import('@sdods/core/analyze');
|
|
60
|
-
const report = analyzeProject(appPath, {
|
|
66
|
+
const report = analyzeProject(appPath, {
|
|
67
|
+
openapi: opts.openapi,
|
|
68
|
+
maxFiles: opts.maxFiles,
|
|
69
|
+
maxDepth: opts.maxDepth,
|
|
70
|
+
});
|
|
61
71
|
const slug = opts.project ?? opts.slug;
|
|
62
72
|
const proposal = opts.reportOnly
|
|
63
73
|
? undefined
|
|
@@ -169,41 +179,11 @@ function printReport(r) {
|
|
|
169
179
|
out(`${icon} ${pc.bold(c.title)} — ${c.detail}${c.fix ? pc.dim(`\n → ${c.fix}`) : ''}`);
|
|
170
180
|
}
|
|
171
181
|
}
|
|
172
|
-
/**
|
|
173
|
-
* Why each module exists. Modules are inferred by fusing several signals - the file that defines
|
|
174
|
-
* a route, the monorepo workspace, a domain directory, an OpenAPI tag - so the winning signal and
|
|
175
|
-
* its confidence are the part a reviewer actually needs to judge the proposal.
|
|
176
|
-
*/
|
|
177
|
-
function printModules(p) {
|
|
178
|
-
const mods = p.detectedModules ?? [];
|
|
179
|
-
if (!mods.length)
|
|
180
|
-
return;
|
|
181
|
-
out(pc.dim('--- modules ---'));
|
|
182
|
-
const w = Math.max(6, ...mods.map((m) => m.name.length));
|
|
183
|
-
for (const m of mods) {
|
|
184
|
-
const targets = [
|
|
185
|
-
m.routes ? `${m.routes} route${m.routes === 1 ? '' : 's'}` : '',
|
|
186
|
-
m.endpoints ? `${m.endpoints} endpoint${m.endpoints === 1 ? '' : 's'}` : '',
|
|
187
|
-
]
|
|
188
|
-
.filter(Boolean)
|
|
189
|
-
.join(', ');
|
|
190
|
-
const from = m.evidence[0]?.file;
|
|
191
|
-
out(` ${m.name.padEnd(w)} ${pc.dim(m.confidence.toFixed(2))} ${targets.padEnd(22)}` +
|
|
192
|
-
`${pc.dim(m.signals.join('+'))}${from ? pc.dim(` ← ${from}`) : ''}`);
|
|
193
|
-
}
|
|
194
|
-
const ignored = p.ignoredTargets ?? [];
|
|
195
|
-
if (ignored.length) {
|
|
196
|
-
out(pc.dim(`--- skipped (${ignored.length}) ---`));
|
|
197
|
-
for (const i of ignored)
|
|
198
|
-
out(pc.dim(` ${i.target.padEnd(20)} ${i.reason}`));
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
182
|
function printProposal(p) {
|
|
202
183
|
heading(`Proposed project: ${p.slug}`);
|
|
203
184
|
out(pc.dim(`envs: ${Object.keys(p.envYamls).join(', ')} · starter features: ${Object.keys(p.starterFeatures).length} · coverage targets: ${p.coverageMap.length}`));
|
|
204
185
|
for (const n of p.notes)
|
|
205
186
|
warn(n);
|
|
206
|
-
printModules(p);
|
|
207
187
|
out(pc.dim('--- sdods.project.yaml ---'));
|
|
208
188
|
const lines = p.projectYaml.split('\n');
|
|
209
189
|
out(lines.slice(0, 60).join('\n'));
|
package/dist/commands/run.d.ts
CHANGED
package/dist/commands/run.js
CHANGED
|
@@ -4,6 +4,7 @@ import { execa } from 'execa';
|
|
|
4
4
|
import pc from 'picocolors';
|
|
5
5
|
import { newRunId, runFiles, } from '@sdods/contracts';
|
|
6
6
|
import { SdodsError, CLI_OVERRIDES_ENV, VERSION, formatFindings, lintProject, listGeneratedProjects, moduleByName, moduleDir, normalizeTagExpr, serializeCliOverrides, } from '@sdods/core';
|
|
7
|
+
import { analyzeChangeImpact } from '@sdods/mcp';
|
|
7
8
|
import { createContext } from '../context.js';
|
|
8
9
|
import { collect, json, out, parseIntFlag, warn } from '../ui.js';
|
|
9
10
|
function addRunOptions(cmd) {
|
|
@@ -23,6 +24,7 @@ function addRunOptions(cmd) {
|
|
|
23
24
|
.option('--retries <n>', 'retries per test', parseIntFlag('retries'))
|
|
24
25
|
.option('--grep <pattern>', 'filter tests by title (regular expression)')
|
|
25
26
|
.option('--feature <path>', 'only this feature file (relative to features/)')
|
|
27
|
+
.option('--since <range>', 'only features impacted by a git range, e.g. main..HEAD (test-impact analysis)')
|
|
26
28
|
.option('--scenario <name>', 'only scenarios whose title contains this text')
|
|
27
29
|
.option('--run-id <id>', 'run id (default: uuid v7)')
|
|
28
30
|
.option('--artifacts-dir <dir>', 'artifacts root (default: .sdods/runs)')
|
|
@@ -249,9 +251,34 @@ export async function runCommand(flags, cmd) {
|
|
|
249
251
|
const rel = relative(join(cfg.project.root, 'features'), moduleDir(cfg.project.root, moduleByName(projectCfg, m))).replace(/\\/g, '/');
|
|
250
252
|
filters.push(`\\.sdods/generated/${escapeRe(runId)}/${escapeRe(entry.slug)}/[^/]+/${escapeRe(rel)}/`);
|
|
251
253
|
}
|
|
254
|
+
const featureFilter = (rel) => escapeRe(rel.replace(/^features\//, '').replace(/\.feature$/, '')) + '\\.feature\\.spec';
|
|
252
255
|
if (flags.feature)
|
|
253
|
-
filters.push(
|
|
254
|
-
|
|
256
|
+
filters.push(featureFilter(flags.feature));
|
|
257
|
+
// --since <range>: run only what the change could have broken.
|
|
258
|
+
//
|
|
259
|
+
// `analyzeChangeImpact` has existed for a while and was reachable only
|
|
260
|
+
// through MCP, so it could advise a human and could not select a run. This is
|
|
261
|
+
// the seam that was missing; the mapping itself is unchanged.
|
|
262
|
+
if (flags.since) {
|
|
263
|
+
const impact = analyzeChangeImpact(cfg.project.root, ctx.rootDir, flags.since);
|
|
264
|
+
if (impact.impacted.length === 0) {
|
|
265
|
+
// Deliberately NOT "run everything" and deliberately not a silent empty
|
|
266
|
+
// run. A run that registered zero scenarios exits 0 and looks identical
|
|
267
|
+
// to a green run, which is the single most dangerous outcome a test
|
|
268
|
+
// runner has. Say so, in words, and stop.
|
|
269
|
+
out(pc.bold(`No features impacted by ${flags.since}.`));
|
|
270
|
+
out(` ${impact.changedFiles.length} changed file(s), none reaching a feature.`);
|
|
271
|
+
out(pc.dim(' Nothing was run. This is not a pass — re-run without --since to verify.'));
|
|
272
|
+
// Exit 0: selecting nothing is a correct outcome for this flag, not a
|
|
273
|
+
// failure. The wording above is what stops it being read as a pass.
|
|
274
|
+
return 0;
|
|
275
|
+
}
|
|
276
|
+
out(pc.bold(`--since ${flags.since}: ${impact.impacted.length} impacted feature(s)`));
|
|
277
|
+
for (const row of impact.impacted)
|
|
278
|
+
out(` ${row.feature} ${pc.dim(row.reasons.join('; '))}`);
|
|
279
|
+
for (const row of impact.impacted)
|
|
280
|
+
filters.push(featureFilter(row.feature));
|
|
281
|
+
}
|
|
255
282
|
// positional filters must precede `--project` (variadic in the runner CLI)
|
|
256
283
|
args.splice(4, 0, ...filters);
|
|
257
284
|
if (flags.list) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sdods/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "SDODS command line: run, record, analyze, agents, MCP server, scheduler and the web server.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "SDODS <admin@sdods.com>",
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
"node": ">=22"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@sdods/contracts": "0.
|
|
27
|
-
"@sdods/core": "0.
|
|
26
|
+
"@sdods/contracts": "0.3.1",
|
|
27
|
+
"@sdods/core": "0.3.1",
|
|
28
28
|
"commander": "^15.0.0",
|
|
29
29
|
"execa": "^10.0.1",
|
|
30
30
|
"picocolors": "^1.1.1",
|
|
@@ -33,12 +33,12 @@
|
|
|
33
33
|
"zod": "^4.5.4",
|
|
34
34
|
"@playwright/test": "^1.62.1",
|
|
35
35
|
"playwright-core": "^1.62.1",
|
|
36
|
-
"@sdods/integrations": "0.
|
|
37
|
-
"@sdods/mcp": "0.
|
|
38
|
-
"@sdods/agents": "0.
|
|
39
|
-
"@sdods/db": "0.
|
|
36
|
+
"@sdods/integrations": "0.3.1",
|
|
37
|
+
"@sdods/mcp": "0.3.1",
|
|
38
|
+
"@sdods/agents": "0.3.1",
|
|
39
|
+
"@sdods/db": "0.3.1",
|
|
40
40
|
"csv-parse": "^7.0.2",
|
|
41
|
-
"@sdods/server": "0.
|
|
41
|
+
"@sdods/server": "0.3.1"
|
|
42
42
|
},
|
|
43
43
|
"homepage": "https://sdods.com",
|
|
44
44
|
"bugs": {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: sdods-release-channels
|
|
3
|
+
description: Publish SDODS through the package managers — Homebrew, Scoop, winget, npm, Docker/GHCR and the apt repository — and keep the install page honest about which of them actually work. Use when asked to add or fix a distribution channel, publish to a package manager, push the tap or bucket, submit the winget manifest, build the apt repo, or when a channel is missing from sdods.com/install.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Distribution channels
|
|
7
|
+
|
|
8
|
+
SDODS ships through six package managers on top of the two install scripts. They are **not one
|
|
9
|
+
release**: they publish on three schedules, from two repositories, and two of them are published by
|
|
10
|
+
someone who is not you.
|
|
11
|
+
|
|
12
|
+
| Channel | Publishes | Where the manifest lives | Who publishes it |
|
|
13
|
+
| --------- | ----------------- | ----------------------------- | ------------------------- |
|
|
14
|
+
| npm | the CLI | — (`release.yml`) | changesets, on `main` |
|
|
15
|
+
| Docker | the server image | — (`release.yml`) | `release.yml`, after npm |
|
|
16
|
+
| Homebrew | the CLI | `siri1410/homebrew-sdods` | you, `git push` |
|
|
17
|
+
| Scoop | the desktop app | `siri1410/scoop-sdods` | you, `git push` |
|
|
18
|
+
| winget | the desktop app | `microsoft/winget-pkgs` | **Microsoft's reviewers** |
|
|
19
|
+
| apt | the desktop app | Pages on `sdods-releases` | `apt.yml`, run by hand |
|
|
20
|
+
|
|
21
|
+
## The one rule
|
|
22
|
+
|
|
23
|
+
**A channel goes live when its registry answers, not when its manifest is committed here.**
|
|
24
|
+
|
|
25
|
+
`bun run channels:sync` probes each one — npm's registry, GHCR's anonymous pull token, the tap and
|
|
26
|
+
bucket contents API, winget-pkgs, and the apt `InRelease` file — and writes the result into
|
|
27
|
+
`apps/www/lib/install-channels.ts`. The install page renders live channels only, so a tab whose
|
|
28
|
+
command would 404 cannot ship. Never flip a `live:` flag by hand; the flag is an observation.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
bun run channels:sync
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
bun run channels:sync -- --check # non-zero if the committed state has drifted
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Every SHA-256 in `packaging/` is computed from bytes the script downloaded. Do not transcribe one
|
|
39
|
+
from a release page — it is the single field where being wrong means the package manager refuses
|
|
40
|
+
to install at all, after every check here has passed.
|
|
41
|
+
|
|
42
|
+
## The order things must happen in
|
|
43
|
+
|
|
44
|
+
Two channels install the CLI from npm, and three install the desktop app from a release. Neither
|
|
45
|
+
can be published before the thing it points at.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
changesets publishes @sdods/cli → release.yml builds the GHCR image → channels:sync
|
|
49
|
+
→ Homebrew formula (npm tarball) → push the tap
|
|
50
|
+
desktop-v* tag → workflow drafts → a PERSON publishes the release → channels:sync
|
|
51
|
+
→ Scoop, winget, apt manifests → push / submit / run apt.yml
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Per channel
|
|
55
|
+
|
|
56
|
+
### npm — automatic
|
|
57
|
+
|
|
58
|
+
`release.yml` runs changesets on every push to `main`. `scripts/publish-npm.sh` prints a
|
|
59
|
+
`New tag: <pkg>@<version>` line per package, which is the **only** signal changesets/action reads
|
|
60
|
+
to set its `published` output. Do not remove it: without it a successful publish reads as "nothing
|
|
61
|
+
published" and the image job below never runs. That was the actual bug.
|
|
62
|
+
|
|
63
|
+
### Docker — automatic, after npm
|
|
64
|
+
|
|
65
|
+
Gated on `needs.changesets.outputs.published`, not on a `v*` tag. It used to gate on the tag and
|
|
66
|
+
nothing in this repository creates one, so the job had never run and the image did not exist.
|
|
67
|
+
Do not "fix" it by pushing a tag from CI: a tag pushed with `GITHUB_TOKEN` does not trigger
|
|
68
|
+
workflows, so the job still would not run.
|
|
69
|
+
|
|
70
|
+
**The image is `linux/amd64` only, and Docker does not emulate a missing architecture** — it
|
|
71
|
+
refuses the pull with `no matching manifest for linux/arm64/v8`. Apple silicon needs
|
|
72
|
+
`--platform linux/amd64`. Building arm64 in CI was tried and abandoned: under QEMU on an amd64
|
|
73
|
+
runner the arm64 stage did not finish in 90 minutes, because `bun install`, the native module
|
|
74
|
+
builds and the web bundle all run emulated. Fixing it properly means either a native arm64 runner
|
|
75
|
+
(a paid tier for a private repository) or splitting the Dockerfile so the arch-independent web
|
|
76
|
+
bundle builds on `$BUILDPLATFORM`. `channels:sync` reads the architectures out of the manifest and
|
|
77
|
+
writes the note from them, so the page cannot claim an arch the image does not carry.
|
|
78
|
+
|
|
79
|
+
**GHCR packages default to private, and visibility is a UI-only setting.** After the first push:
|
|
80
|
+
Profile → Packages → `sdods-server` → Package settings → Change visibility → Public. Until then
|
|
81
|
+
`channels:sync` reports `no anonymous pull token (403)` and the Docker tab stays hidden.
|
|
82
|
+
|
|
83
|
+
### Homebrew — you push the tap
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
gh repo create siri1410/homebrew-sdods --public -d 'Homebrew tap for SDODS'
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Copy the rendered formula into the tap as `Formula/sdods.rb`, then verify it locally before anyone
|
|
90
|
+
installs from it. `--new` implies `--strict` and `--online` and is the check that catches a bad
|
|
91
|
+
`url`, a redundant `version` or a foreign-architecture binary (it is `--new`, not `--new-formula`,
|
|
92
|
+
which current Homebrew rejects):
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
brew audit --strict --new siri1410/sdods/sdods
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
brew install --build-from-source siri1410/sdods/sdods && brew test sdods
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
You can do all of that without publishing anything: `brew tap-new siri1410/sdods --no-git` makes a
|
|
103
|
+
local tap, and `brew untap siri1410/sdods` removes it.
|
|
104
|
+
|
|
105
|
+
The formula builds from the **npm tarball**, not from a clone, which is why the private source
|
|
106
|
+
repository is not a problem here.
|
|
107
|
+
|
|
108
|
+
Three things about it are load-bearing, and each was found by running it rather than reading it:
|
|
109
|
+
|
|
110
|
+
- **You cannot publish the formula the same day you publish the packages.** Homebrew's
|
|
111
|
+
`std_npm_args` passes `--min-release-age=`, so npm refuses any dependency published inside that
|
|
112
|
+
window: `No matching version found for @sdods/agents@X with a date before <date>`. The formula is
|
|
113
|
+
correct; it is too new. Wait for the packages to age out, then install.
|
|
114
|
+
- **Foreign prebuilds must be deleted.** `better-sqlite3` ships prebuilt binaries for every
|
|
115
|
+
platform and npm installs all of them; audit then fails with *"Binaries built for a non-native
|
|
116
|
+
architecture were installed into sdods's prefix"*. The `install` block removes every
|
|
117
|
+
`prebuilds/*` directory but this machine's.
|
|
118
|
+
- **`brew test` runs in an empty directory, so `sdods doctor` exits 1** — there are no projects to
|
|
119
|
+
find, which is the right answer for a fresh install. The test asserts exit `1` deliberately;
|
|
120
|
+
asserting `0` fails on a perfectly good install. It runs against Homebrew's `node`, which is
|
|
121
|
+
well ahead of 22 — that path is tested, and the native modules resolve on it.
|
|
122
|
+
|
|
123
|
+
### Scoop — you push the bucket
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
gh repo create siri1410/scoop-sdods --public -d 'Scoop bucket for SDODS'
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The manifest goes in as `bucket/sdods.json`. It installs the NSIS desktop installer silently with
|
|
130
|
+
`/S /D=$dir`; `/D` must be last and unquoted, which is an NSIS rule — quote it and the install
|
|
131
|
+
silently lands in the default directory instead of Scoop's.
|
|
132
|
+
|
|
133
|
+
### winget — you can open the PR, not merge it
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
wingetcreate submit --token <PAT> packaging/winget
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Then it sits in Microsoft's review queue. Expect friction: the installers are **unsigned**, and
|
|
140
|
+
that is a thing their validation flags. See the `code-signing` skill — winget is the channel that
|
|
141
|
+
most wants the certificate.
|
|
142
|
+
|
|
143
|
+
### apt — a person runs the workflow
|
|
144
|
+
|
|
145
|
+
`.github/workflows/apt.yml`, triggered manually. It needs `SDODS_APT_GPG_KEY` (the armoured
|
|
146
|
+
private key of the **project** signing key, as a repository secret) and `DESKTOP_RELEASE_TOKEN`.
|
|
147
|
+
It refuses to build unsigned: an unsigned repo can only be added with `[trusted=yes]`, which
|
|
148
|
+
disables signature checking for everything else that machine installs.
|
|
149
|
+
|
|
150
|
+
The workflow proves the repo works before publishing it — it adds the built repo as a local apt
|
|
151
|
+
source and asserts `apt-cache policy sdods` reports an installable candidate. A repo that merely
|
|
152
|
+
looks right has been verified zero times. The `apt · flat repo layout` job in `ci.yml` runs the
|
|
153
|
+
same proof on every PR against a synthetic package, so the layout is exercised without a key.
|
|
154
|
+
|
|
155
|
+
**It is a standard `dists/` repository, not a flat one, and that is load-bearing.** A flat repo is
|
|
156
|
+
addressed with a `./` distribution, which puts a `./` segment into every path apt builds:
|
|
157
|
+
|
|
158
|
+
```
|
|
159
|
+
https://sdods.com/apt/./InRelease
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Firebase Hosting answers that with a 302 to an internal origin host that 404s. The result is a
|
|
163
|
+
repository where every file is served correctly to `curl` on the normalised path, and every real
|
|
164
|
+
`apt update` fails with *"does not have a Release file"*. It shipped that way, and only an actual
|
|
165
|
+
`apt install` in a container found it — checking that the files were reachable was not the same
|
|
166
|
+
question.
|
|
167
|
+
|
|
168
|
+
`build-repo.sh` also scans the pool directory by name rather than `.`, because `apt-ftparchive
|
|
169
|
+
packages .` writes `Filename: ./pool/...` and that is the same broken segment one level down. And
|
|
170
|
+
it splits the index per architecture with awk rather than `apt-ftparchive --arch`, which matches
|
|
171
|
+
nothing here and silently writes an empty `Packages`.
|
|
172
|
+
|
|
173
|
+
**GitHub Pages has to be enabled on the releases repository** — Settings → Pages → source
|
|
174
|
+
`gh-pages` — or the workflow pushes the branch and the URL keeps 404ing.
|
|
175
|
+
|
|
176
|
+
## Adding a seventh channel
|
|
177
|
+
|
|
178
|
+
1. Add it to `INSTALL_CHANNELS` in `apps/www/lib/install-channels.ts` with `live: false`. Give it
|
|
179
|
+
`under:` if it belongs inside another tab's panel rather than owning a tab — Scoop and winget
|
|
180
|
+
sit under Windows because a visitor choosing how to install is choosing once, not three times.
|
|
181
|
+
2. Add a probe in `scripts/sync-channels.ts` and wire it into the `live` map. A channel with no
|
|
182
|
+
probe can never go live, which is the correct failure.
|
|
183
|
+
3. Put its manifest template in `packaging/<manager>/` with `{{PLACEHOLDER}}` fields.
|
|
184
|
+
4. Add the command to the docs table in `getting-started/installation.mdx`.
|
|
185
|
+
5. `bunx vitest run tests/install-channels.test.ts`.
|
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: code-signing
|
|
3
|
-
description: Obtain and wire code-signing certificates for the SDODS desktop app so macOS Gatekeeper and Windows SmartScreen stop blocking it. Use when asked about signing, notarization, Gatekeeper, SmartScreen, "app is damaged", "unidentified developer", Developer ID certificates, Azure Artifact Signing, or why the installers show security warnings.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Signing the SDODS desktop installers
|
|
7
|
-
|
|
8
|
-
Unsigned installers work, but every user is told not to run them. macOS refuses a double-click
|
|
9
|
-
("SDODS is damaged" or "unidentified developer") and Windows SmartScreen interrupts the install.
|
|
10
|
-
That undercuts the one-click promise more than any technical problem in the app.
|
|
11
|
-
|
|
12
|
-
**Everything in the build is already wired.** Supply the credentials as CI secrets and signing turns
|
|
13
|
-
on with no code change. What cannot be automated is acquiring the certificates: both require a
|
|
14
|
-
person's legal identity and a payment.
|
|
15
|
-
|
|
16
|
-
Facts below were verified against Microsoft and Apple documentation in September 2026. Two pieces
|
|
17
|
-
of widely repeated advice are now **wrong** — see the corrections at the end.
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
## macOS — Gatekeeper
|
|
22
|
-
|
|
23
|
-
### What to buy
|
|
24
|
-
|
|
25
|
-
**Apple Developer Program, $99/year.** There is no free path that satisfies Gatekeeper. Enrol at
|
|
26
|
-
<https://developer.apple.com/programs/>. Individual enrolment needs a legal name and payment;
|
|
27
|
-
organisation enrolment additionally needs a D-U-N-S number and takes longer.
|
|
28
|
-
|
|
29
|
-
### What to create
|
|
30
|
-
|
|
31
|
-
A **Developer ID Application** certificate — *not* "Apple Distribution", which is for the App
|
|
32
|
-
Store and will not satisfy Gatekeeper for direct download.
|
|
33
|
-
|
|
34
|
-
1. Xcode → Settings → Accounts → Manage Certificates → **+** → Developer ID Application.
|
|
35
|
-
(Or Certificates, Identifiers & Profiles on the developer portal with a CSR.)
|
|
36
|
-
2. Export it from Keychain Access as a `.p12` with a strong password.
|
|
37
|
-
3. Base64 it for CI: `base64 -i cert.p12 | pbcopy`.
|
|
38
|
-
|
|
39
|
-
### Notarization credentials
|
|
40
|
-
|
|
41
|
-
Notarization is a separate step: Apple scans the signed app and issues a ticket. It needs an
|
|
42
|
-
**app-specific password**, not your Apple ID password — create one at <https://appleid.apple.com>
|
|
43
|
-
under Sign-In and Security. You also need your **Team ID** (developer portal → Membership).
|
|
44
|
-
|
|
45
|
-
Locally, store credentials in the keychain once so the secret never reaches an env var or a log:
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
xcrun notarytool store-credentials sdods-notary \
|
|
49
|
-
--apple-id you@example.com --team-id ABCDE12345 --password <app-specific-password>
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
### CI secrets
|
|
53
|
-
|
|
54
|
-
| Secret | Value |
|
|
55
|
-
|---|---|
|
|
56
|
-
| `MAC_CSC_LINK` | base64 of the `.p12` |
|
|
57
|
-
| `MAC_CSC_KEY_PASSWORD` | the `.p12` password |
|
|
58
|
-
| `APPLE_ID` | the Apple ID email |
|
|
59
|
-
| `APPLE_APP_SPECIFIC_PASSWORD` | app-specific password |
|
|
60
|
-
| `APPLE_TEAM_ID` | 10-character Team ID |
|
|
61
|
-
|
|
62
|
-
`.github/workflows/desktop.yml` already passes all five. electron-builder signs and notarizes when
|
|
63
|
-
they are present and silently skips when they are not, so unsigned builds keep working.
|
|
64
|
-
|
|
65
|
-
### What is already correct in this repo
|
|
66
|
-
|
|
67
|
-
- `hardenedRuntime: true` — required for notarization.
|
|
68
|
-
- `entitlements` **and** `entitlementsInherit` both point at `build/entitlements.mac.plist`.
|
|
69
|
-
- That plist sets `com.apple.security.cs.disable-library-validation`. **Do not remove it.** The app
|
|
70
|
-
runs `.node` native modules that npm downloads at runtime; to the hardened runtime those are
|
|
71
|
-
unsigned code, so without this the app notarizes successfully and then dies at first database
|
|
72
|
-
open with an opaque dyld error.
|
|
73
|
-
- `mac.binaries` lists `Contents/Resources/node/bin/node`, so the bundled Node runtime is signed
|
|
74
|
-
too. A second executable inside the bundle is not signed automatically.
|
|
75
|
-
|
|
76
|
-
### Timing
|
|
77
|
-
|
|
78
|
-
`notarytool` typically returns in 2–10 minutes for a 100–200 MB bundle, ~15 at the 95th percentile.
|
|
79
|
-
Around major macOS releases it can take 30–60. Budget for it in the release process; it is not a
|
|
80
|
-
sign that something is wrong.
|
|
81
|
-
|
|
82
|
-
### Verifying
|
|
83
|
-
|
|
84
|
-
```bash
|
|
85
|
-
codesign --verify --deep --strict --verbose=2 /Applications/SDODS.app
|
|
86
|
-
spctl -a -vvv -t install /Applications/SDODS.app # expect "accepted / Notarized Developer ID"
|
|
87
|
-
xcrun stapler validate /Applications/SDODS.app
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
### Until certificates exist
|
|
91
|
-
|
|
92
|
-
Users can bypass Gatekeeper themselves — right-click SDODS in Applications → **Open** → confirm.
|
|
93
|
-
macOS remembers the choice. The download page prints this automatically while
|
|
94
|
-
`DESKTOP_RELEASE.signed` is false. `xattr -dr com.apple.quarantine /Applications/SDODS.app` also
|
|
95
|
-
works but is worse advice to give strangers.
|
|
96
|
-
|
|
97
|
-
---
|
|
98
|
-
|
|
99
|
-
## Windows — SmartScreen
|
|
100
|
-
|
|
101
|
-
### The gating question: where are you?
|
|
102
|
-
|
|
103
|
-
**Azure Artifact Signing** (formerly Azure Trusted Signing) is the best option, but it is
|
|
104
|
-
geographically restricted:
|
|
105
|
-
|
|
106
|
-
- **Individual developers: USA and Canada only.**
|
|
107
|
-
- Organisations: USA, Canada, EU, UK.
|
|
108
|
-
|
|
109
|
-
If you are an individual outside the US/Canada, this route is closed and an OV certificate is the
|
|
110
|
-
answer. Settle this before spending time on Azure — it determines the whole path.
|
|
111
|
-
|
|
112
|
-
### Option A — Azure Artifact Signing (preferred where available)
|
|
113
|
-
|
|
114
|
-
Generally available since April 2026. ~**$9.99/month** for 5,000 signatures and one certificate
|
|
115
|
-
profile ($99.99/month for 100,000 and ten profiles) — cheaper than any traditional certificate,
|
|
116
|
-
and **no hardware token**, which is what makes it work in CI at all.
|
|
117
|
-
|
|
118
|
-
Individuals may now apply as self-employed; the 3-years-of-history requirement from the preview
|
|
119
|
-
was dropped at GA. Identity validation runs through a third party (au10tix) and takes a few
|
|
120
|
-
business days.
|
|
121
|
-
|
|
122
|
-
Setup:
|
|
123
|
-
|
|
124
|
-
1. Azure subscription → create an **Artifact Signing** account (pick the region nearest your CI).
|
|
125
|
-
2. Complete identity validation. Assign yourself the **Identity Verifier** role — validation
|
|
126
|
-
cannot be completed without it, which is the usual place people get stuck.
|
|
127
|
-
3. Create a **certificate profile** (type: Public Trust).
|
|
128
|
-
4. Create a service principal for CI and grant it **Code Signing Certificate Profile Signer** on
|
|
129
|
-
the account.
|
|
130
|
-
|
|
131
|
-
Then add to `apps/desktop/electron-builder.yml` under `win:`:
|
|
132
|
-
|
|
133
|
-
```yaml
|
|
134
|
-
win:
|
|
135
|
-
azureSignOptions:
|
|
136
|
-
publisherName: '<exact name on the certificate>'
|
|
137
|
-
endpoint: 'https://<region>.codesigning.azure.net/'
|
|
138
|
-
codeSigningAccountName: '<account>'
|
|
139
|
-
certificateProfileName: '<profile>'
|
|
140
|
-
```
|
|
141
|
-
|
|
142
|
-
and set `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` as CI secrets.
|
|
143
|
-
|
|
144
|
-
**Version note:** the block above is electron-builder v26 syntax, which is what this repo pins
|
|
145
|
-
(26.15.3, the current release). v27 collapses Windows signing into a single `win.sign`
|
|
146
|
-
discriminated union (`type: 'signtool' | 'hsm' | 'pkcs11' | 'azure'`) and removes
|
|
147
|
-
`win.azureSignOptions` / `win.signtoolOptions`; `electron-builder migrate-schema` rewrites it.
|
|
148
|
-
Check which major version is installed before copying config from a blog post.
|
|
149
|
-
|
|
150
|
-
### Option B — OV certificate
|
|
151
|
-
|
|
152
|
-
From DigiCert, Sectigo, GlobalSign and similar. **$150–300/year.**
|
|
153
|
-
|
|
154
|
-
Since June 2023 the CA/Browser Forum requires the private key to live on an HSM or hardware token.
|
|
155
|
-
That is the real cost: a USB token cannot be plugged into a GitHub-hosted runner, so you either use
|
|
156
|
-
the CA's cloud HSM option or sign on a self-hosted runner. Choose a cloud-HSM product if you want
|
|
157
|
-
CI signing at all.
|
|
158
|
-
|
|
159
|
-
Wire it through the existing `WIN_CSC_LINK` / `WIN_CSC_KEY_PASSWORD` secrets, or the CA's own
|
|
160
|
-
signing tool via a custom `sign` hook.
|
|
161
|
-
|
|
162
|
-
### Option C — self-signed
|
|
163
|
-
|
|
164
|
-
Testing and enterprise-managed fleets only. Windows does not trust it, so public users get a
|
|
165
|
-
**stronger** block than with no signature at all. Never ship this publicly.
|
|
166
|
-
|
|
167
|
-
### Reputation
|
|
168
|
-
|
|
169
|
-
Signing does not remove SmartScreen warnings on day one. Reputation accrues to the publisher
|
|
170
|
-
identity as releases are downloaded and run without incident. What matters is signing **every**
|
|
171
|
-
release with the **same** identity — switching certificates resets the accumulated trust.
|
|
172
|
-
|
|
173
|
-
---
|
|
174
|
-
|
|
175
|
-
## Linux
|
|
176
|
-
|
|
177
|
-
No signing is required: AppImage and `.deb` install without any equivalent of Gatekeeper. Optional
|
|
178
|
-
hardening if it becomes useful:
|
|
179
|
-
|
|
180
|
-
- GPG-sign the `.deb` and publish the public key.
|
|
181
|
-
- Ship a `.zsync` file beside the AppImage for delta updates.
|
|
182
|
-
|
|
183
|
-
`SHA256SUMS.txt` already ships with every release, which is the verification most Linux users
|
|
184
|
-
expect.
|
|
185
|
-
|
|
186
|
-
---
|
|
187
|
-
|
|
188
|
-
## Two corrections to common advice
|
|
189
|
-
|
|
190
|
-
**EV certificates no longer bypass SmartScreen.** They did — instantly, on first download — which
|
|
191
|
-
is why almost every older guide recommends paying the EV premium for a new app. **Microsoft removed
|
|
192
|
-
that behaviour in 2024.** EV-signed files now build reputation exactly like OV-signed ones. An
|
|
193
|
-
existing EV certificate is still perfectly valid; buying one *specifically* to skip SmartScreen is
|
|
194
|
-
no longer justified. (I gave this outdated advice earlier in this project.)
|
|
195
|
-
|
|
196
|
-
**"Azure Trusted Signing" is now "Azure Artifact Signing."** Same service, renamed. Search results
|
|
197
|
-
and documentation are split across both names, and the individual-developer eligibility rules
|
|
198
|
-
changed at GA — preview-era pages saying individuals cannot sign up are out of date.
|
|
199
|
-
|
|
200
|
-
---
|
|
201
|
-
|
|
202
|
-
## Is there a free option?
|
|
203
|
-
|
|
204
|
-
**Windows: yes, but only for open source.** [SignPath Foundation](https://signpath.org/terms)
|
|
205
|
-
gives qualifying projects free OV-level signing through a managed pipeline. Their conditions:
|
|
206
|
-
|
|
207
|
-
- an **OSI-approved licence with no commercial dual-licensing** — Apache-2.0 qualifies;
|
|
208
|
-
- **no proprietary or non-open-source components**, including code from the maintainer;
|
|
209
|
-
- actively maintained, already released in the form to be signed, and functionality described on
|
|
210
|
-
the download page.
|
|
211
|
-
|
|
212
|
-
SDODS is Apache-2.0, so the licence is fine — but the repository is **private**, and the programme
|
|
213
|
-
is for open-source projects. Today it does not qualify. Making the source public would unlock it,
|
|
214
|
-
and would also remove the need for the separate public releases repo and let the
|
|
215
|
-
`NEXT_PUBLIC_REPO_PUBLIC` flags across both sites switch on. Applications take days to weeks.
|
|
216
|
-
|
|
217
|
-
**macOS: no.** There is no free path to a Developer ID certificate or to notarization. A free Apple
|
|
218
|
-
ID signs for local development only; the result still fails Gatekeeper on anyone else's Mac. The
|
|
219
|
-
$99/year membership is unavoidable for direct distribution.
|
|
220
|
-
|
|
221
|
-
**Linux: already free** — nothing to sign.
|
|
222
|
-
|
|
223
|
-
So the realistic floors are **$99/year** (Apple, plus SignPath for Windows if the source goes
|
|
224
|
-
public) or **~$219/year** (Apple plus Azure Artifact Signing at $9.99/month) with the source
|
|
225
|
-
staying private.
|
|
226
|
-
|
|
227
|
-
---
|
|
228
|
-
|
|
229
|
-
## After signing works
|
|
230
|
-
|
|
231
|
-
1. Verify a real download on a machine that has never seen the app, not the build machine.
|
|
232
|
-
2. Sync the download page with `--signed`, which removes the Gatekeeper/SmartScreen instructions:
|
|
233
|
-
```bash
|
|
234
|
-
bun run desktop:sync-release desktop-v0.1.0 --signed
|
|
235
|
-
```
|
|
236
|
-
3. Enable `electron-updater` for the app shell. It was left off deliberately: Squirrel.Mac
|
|
237
|
-
**refuses to install an unsigned update**, so auto-update only becomes real once macOS signing
|
|
238
|
-
is in place. See the `sdods-desktop-release` skill.
|