返回 DeepSeek-Reasonix
main.go
根目录 / desktop / cmd / sign / main.go
1 // Command sign is the CI-side signing and manifest tool for desktop releases. It
2 // is never shipped in any artifact — the release workflow invokes it via
3 // `go run ./cmd/sign`. It shares desktop/internal/update with the running updater
4 // so the sign path and the verify path use one definition of the manifest and one
5 // minisign implementation.
6 //
7 // Subcommands:
8 //
9 // sign <file>... Write <file>.minisig for each file, signing with the
10 // encrypted minisign private key in $MINISIGN_PRIVATE_KEY
11 // (decrypted with $MINISIGN_PASSWORD).
12 //
13 // manifest <dir> <ver> <tag> [notes-ver]
14 // Scan <dir> for the per-platform artifacts, compute
15 // size + sha256, and write <dir>/latest.json with GitHub
16 // release download URLs. The R2 mirror step rewrites those
17 // URLs to the CDN afterwards (url + sig fields together).
18 //
19 // windows-payload <dir> <ver> Write a deterministic manifest of the installer
20 // executables and every file under <dir>/app.
21 package main
22
23 import (
24 "crypto/rand"
25 "crypto/sha256"
26 "encoding/hex"
27 "encoding/json"
28 "fmt"
29 "io"
30 "io/fs"
31 "os"
32 "path/filepath"
33 "strings"
34
35 "aead.dev/minisign"
36
37 "reasonix/desktop/internal/update"
38 "reasonix/internal/installlayout"
39 )
40
41 var websiteDownloads = map[string]struct{}{
42 "Reasonix-darwin-arm64.dmg": {},
43 "Reasonix-darwin-amd64.dmg": {},
44 "Reasonix-darwin-universal.dmg": {},
45 "Reasonix-windows-amd64.zip": {},
46 }
47
48 var updaterArtifacts = map[string]struct {
49 key string
50 kind string
51 }{
52 "Reasonix-darwin-arm64.zip": {key: "darwin-arm64", kind: artifactPortable},
53 "Reasonix-darwin-amd64.zip": {key: "darwin-amd64", kind: artifactPortable},
54 "Reasonix-windows-amd64-installer.exe": {key: "windows-amd64", kind: artifactPortable},
55 "Reasonix-windows-arm64-installer.exe": {key: "windows-arm64", kind: artifactPortable},
56 "Reasonix-linux-amd64.tar.gz": {key: "linux-amd64", kind: artifactPortable},
57 "Reasonix-linux-amd64.deb": {key: "linux-amd64", kind: artifactNative},
58 }
59
60 func main() {
61 if len(os.Args) < 2 {
62 usage()
63 }
64 var err error
65 switch os.Args[1] {
66 case "sign":
67 err = signFiles(os.Args[2:])
68 case "manifest":
69 if len(os.Args) != 5 && len(os.Args) != 6 {
70 usage()
71 }
72 notesVersion := os.Args[3]
73 if len(os.Args) == 6 {
74 notesVersion = os.Args[5]
75 }
76 err = genManifest(os.Args[2], os.Args[3], os.Args[4], notesVersion)
77 case "windows-payload":
78 if len(os.Args) != 4 {
79 usage()
80 }
81 err = genWindowsPayloadManifest(os.Args[2], os.Args[3])
82 case "genkey":
83 if len(os.Args) != 3 {
84 usage()
85 }
86 err = genKey(os.Args[2])
87 case "verify":
88 if len(os.Args) != 3 {
89 usage()
90 }
91 err = verifyFile(os.Args[2])
92 default:
93 usage()
94 }
95 if err != nil {
96 fmt.Fprintln(os.Stderr, "sign:", err)
97 os.Exit(1)
98 }
99 }
100
101 func usage() {
102 fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n windows-payload <dir> <version>\n genkey <dir>\n verify <file>")
103 os.Exit(2)
104 }
105
106 func genWindowsPayloadManifest(dir, version string) error {
107 hashes := make(map[string]string)
108 for _, name := range update.WindowsPayloadFileNames() {
109 sum, err := hashRegularFile(filepath.Join(dir, name))
110 if err != nil {
111 return fmt.Errorf("Windows payload %s: %w", name, err)
112 }
113 hashes[name] = sum
114 }
115 if err := hashWindowsPayloadTree(dir, hashes); err != nil {
116 return err
117 }
118 b, err := update.EncodeWindowsPayloadManifest(version, hashes)
119 if err != nil {
120 return err
121 }
122 return os.WriteFile(filepath.Join(dir, update.WindowsPayloadManifestName), b, 0o644)
123 }
124
125 // hashWindowsPayloadTree records every regular file under dir/app with its
126 // forward-slash name; a symlink anywhere in the tree fails the build.
127 func hashWindowsPayloadTree(dir string, hashes map[string]string) error {
128 root := filepath.Join(dir, installlayout.AppShellDirName)
129 info, err := os.Lstat(root)
130 if os.IsNotExist(err) {
131 return nil
132 }
133 if err != nil {
134 return fmt.Errorf("Windows payload %s: %w", installlayout.AppShellDirName, err)
135 }
136 if !info.IsDir() {
137 return fmt.Errorf("Windows payload %s is not a directory", installlayout.AppShellDirName)
138 }
139 return filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
140 if err != nil {
141 return err
142 }
143 rel, relErr := filepath.Rel(dir, path)
144 if relErr != nil {
145 return relErr
146 }
147 name := filepath.ToSlash(rel)
148 if d.Type()&fs.ModeSymlink != 0 {
149 return fmt.Errorf("Windows payload %s: symlinks are not allowed", name)
150 }
151 if d.IsDir() {
152 return nil
153 }
154 sum, err := hashRegularFile(path)
155 if err != nil {
156 return fmt.Errorf("Windows payload %s: %w", name, err)
157 }
158 hashes[name] = sum
159 return nil
160 })
161 }
162
163 func hashRegularFile(path string) (string, error) {
164 info, err := os.Lstat(path)
165 if err != nil {
166 return "", err
167 }
168 if !info.Mode().IsRegular() {
169 return "", fmt.Errorf("not a regular file")
170 }
171 _, sum, err := hashFile(path)
172 return sum, err
173 }
174
175 // verifyFile checks <file> against <file>.minisig using the embedded public key —
176 // the same check the updater runs before applying. A self-test that the signing
177 // key matches what's compiled in. Returns an error (nonzero exit) on mismatch.
178 func verifyFile(path string) error {
179 data, err := os.ReadFile(path)
180 if err != nil {
181 return err
182 }
183 sig, err := os.ReadFile(path + ".minisig")
184 if err != nil {
185 return err
186 }
187 if err := update.Verify(data, sig); err != nil {
188 return err
189 }
190 fmt.Printf("OK: %s verifies against the embedded public key\n", path)
191 return nil
192 }
193
194 // genKey generates a fresh minisign key pair, writing the encrypted private key
195 // (reasonix.key) and the public key (reasonix.pub) into dir. The password comes
196 // from $MINISIGN_PASSWORD. The public key is printed — it's safe to publish; embed
197 // it in internal/update/verify.go. The private key never leaves dir.
198 func genKey(dir string) error {
199 pw := os.Getenv("MINISIGN_PASSWORD")
200 if strings.TrimSpace(pw) == "" {
201 return fmt.Errorf("genkey: MINISIGN_PASSWORD is empty")
202 }
203 pub, priv, err := minisign.GenerateKey(rand.Reader)
204 if err != nil {
205 return err
206 }
207 enc, err := minisign.EncryptKey(pw, priv)
208 if err != nil {
209 return err
210 }
211 if err := os.MkdirAll(dir, 0o700); err != nil {
212 return err
213 }
214 keyPath := filepath.Join(dir, "reasonix.key")
215 pubPath := filepath.Join(dir, "reasonix.pub")
216 if err := os.WriteFile(keyPath, enc, 0o600); err != nil {
217 return err
218 }
219 pubText, err := pub.MarshalText()
220 if err != nil {
221 return err
222 }
223 if err := os.WriteFile(pubPath, pubText, 0o644); err != nil {
224 return err
225 }
226 fmt.Printf("private key -> %s (keep secret; this is the MINISIGN_PRIVATE_KEY value)\n", keyPath)
227 fmt.Printf("public key -> %s\n\n", pubPath)
228 fmt.Printf("public key (embed in internal/update/verify.go, key ID %016X):\n%s\n", pub.ID(), pubText)
229 return nil
230 }
231
232 // signFiles writes a detached .minisig next to each input file. The private key is
233 // read only from the environment — it never touches disk or argv.
234 func signFiles(files []string) error {
235 if len(files) == 0 {
236 return fmt.Errorf("sign: no files given")
237 }
238 keyText := os.Getenv("MINISIGN_PRIVATE_KEY")
239 if strings.TrimSpace(keyText) == "" {
240 return fmt.Errorf("sign: MINISIGN_PRIVATE_KEY is empty")
241 }
242 priv, err := minisign.DecryptKey(os.Getenv("MINISIGN_PASSWORD"), []byte(keyText))
243 if err != nil {
244 return fmt.Errorf("sign: decrypt private key: %w", err)
245 }
246 for _, f := range files {
247 data, err := os.ReadFile(f)
248 if err != nil {
249 return err
250 }
251 sig := minisign.SignWithComments(priv, data,
252 "file:"+filepath.Base(f), "Reasonix desktop release")
253 out := f + ".minisig"
254 if err := os.WriteFile(out, sig, 0o644); err != nil {
255 return err
256 }
257 fmt.Printf("signed %s -> %s\n", f, out)
258 }
259 return nil
260 }
261
262 // genManifest scans dir for the per-platform artifacts and writes dir/latest.json.
263 // version is the semver compared by the updater (e.g. "v1.1.0"); tag is the GitHub
264 // release tag used in download URLs (e.g. "desktop-v1.1.0").
265 //
266 // Portable updater channels land in platforms (tarballs/installers). Debian/Ubuntu
267 // .deb packages land only in native_packages so older clients keep resolving the
268 // tarball under platforms["linux-amd64"].
269 func genManifest(dir, version, tag string, notesVersions ...string) error {
270 repo := os.Getenv("GITHUB_REPOSITORY")
271 if repo == "" || repo == "esengine/reasonix" {
272 repo = "esengine/DeepSeek-Reasonix"
273 }
274 notesVersion := version
275 if len(notesVersions) > 1 {
276 return fmt.Errorf("manifest: expected at most one release-notes version")
277 }
278 if len(notesVersions) == 1 {
279 notesVersion = notesVersions[0]
280 }
281 m := update.Manifest{
282 Version: version,
283 DownloadPage: "https://reasonix.io/?download=desktop#start",
284 ReleaseNotesURL: "https://reasonix.io/changelog/" + notesVersion + "/",
285 Platforms: map[string]update.Asset{},
286 NativePackages: map[string]update.Asset{},
287 Downloads: map[string]update.Asset{},
288 }
289 entries, err := os.ReadDir(dir)
290 if err != nil {
291 return err
292 }
293 for _, e := range entries {
294 name := e.Name()
295 if e.IsDir() || strings.HasSuffix(name, ".minisig") || name == "latest.json" {
296 continue
297 }
298 key, kind := matchArtifact(name)
299 _, websiteDownload := websiteDownloads[name]
300 if key == "" && !websiteDownload {
301 continue
302 }
303 size, sum, err := hashFile(filepath.Join(dir, name))
304 if err != nil {
305 return err
306 }
307 url := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", repo, tag, name)
308 asset := update.Asset{URL: url, Sig: url + ".minisig", Size: size, SHA256: sum}
309 asset.InstallLayout = update.ElectronInstallLayout
310 if websiteDownload {
311 m.Downloads[name] = asset
312 fmt.Printf("manifest download: %s (%d bytes)\n", name, size)
313 }
314 if key != "" {
315 switch kind {
316 case artifactNative:
317 m.NativePackages[key] = asset
318 fmt.Printf("manifest native: %s -> %s (%d bytes)\n", key, name, size)
319 default:
320 m.Platforms[key] = asset
321 fmt.Printf("manifest: %s -> %s (%d bytes)\n", key, name, size)
322 }
323 }
324 }
325 if len(m.Platforms) == 0 {
326 return fmt.Errorf("manifest: no platform artifacts found in %s", dir)
327 }
328 if len(m.NativePackages) == 0 {
329 m.NativePackages = nil // omit empty map so older tooling sees a clean document
330 }
331 if len(m.Downloads) == 0 {
332 m.Downloads = nil
333 }
334 b, err := json.MarshalIndent(m, "", " ")
335 if err != nil {
336 return err
337 }
338 return os.WriteFile(filepath.Join(dir, "latest.json"), append(b, '\n'), 0o644)
339 }
340
341 const (
342 artifactPortable = "portable"
343 artifactNative = "native"
344 )
345
346 // matchArtifact returns the platform key and channel kind embedded in a file name,
347 // or ("", "") if the file is not a publishable updater/download artifact.
348 func matchArtifact(name string) (key, kind string) {
349 artifact, ok := updaterArtifacts[name]
350 if !ok {
351 return "", ""
352 }
353 return artifact.key, artifact.kind
354 }
355
356 // hashFile returns the size and lowercase-hex SHA-256 of a file, streaming it so
357 // large artifacts don't have to fit in memory.
358 func hashFile(path string) (int64, string, error) {
359 f, err := os.Open(path)
360 if err != nil {
361 return 0, "", err
362 }
363 defer f.Close()
364 h := sha256.New()
365 n, err := io.Copy(h, f)
366 if err != nil {
367 return 0, "", err
368 }
369 return n, hex.EncodeToString(h.Sum(nil)), nil
370 }
371
371 lines GO