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