@tutti-os/app-release-tools 0.0.79 → 0.0.80

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/README.md CHANGED
@@ -13,11 +13,14 @@ verify-tutti-app-release-artifacts --release-file ./apps/vibe-design/latest.json
13
13
  verify-tutti-app-release-artifacts --catalog-file ./catalog.json --versions-file ./apps/vibe-design/versions.json
14
14
  ```
15
15
 
16
- The release command validates a complete Tutti app package, creates a zip,
17
- writes immutable `release.json`, and writes mutable `latest.json`. When the
18
- manifest declares `localizationInfo`, the release metadata includes the
19
- referenced manifest locale files so App Center can localize uninstalled remote
20
- apps without downloading the package.
16
+ The release command validates a complete Tutti app package, creates a
17
+ reproducible zip with stable entry ordering and timestamps, writes immutable
18
+ `release.json`, and writes mutable `latest.json`. Rebuilding the same package
19
+ version produces the same artifact SHA-256, so an interrupted release can
20
+ safely verify and reuse its immutable upload. When the manifest declares
21
+ `localizationInfo`, the release metadata includes the referenced manifest
22
+ locale files so App Center can localize uninstalled remote apps without
23
+ downloading the package.
21
24
 
22
25
  The versions command maintains one mutable `tutti.app.versions.v1` index per
23
26
  app. Every catalog-eligible release has an explicit minimum Tutti version and
@@ -2,7 +2,18 @@
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
4
  import { pathToFileURL } from "node:url";
5
- import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
5
+ import {
6
+ cp,
7
+ mkdir,
8
+ mkdtemp,
9
+ readFile,
10
+ readdir,
11
+ rm,
12
+ stat,
13
+ utimes,
14
+ writeFile
15
+ } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
6
17
  import path from "node:path";
7
18
 
8
19
  import { requireSemver } from "./tutti-app-versioning.mjs";
@@ -75,7 +86,7 @@ export async function buildTuttiAppRelease(options) {
75
86
 
76
87
  const artifactName = `${appId}-${version}.zip`;
77
88
  const artifactPath = path.join(releaseDir, artifactName);
78
- createZip(packageDir, artifactPath);
89
+ await createZip(packageDir, artifactPath);
79
90
  const artifact = await fileDigestAndSize(artifactPath);
80
91
 
81
92
  const iconName = path.basename(sourceIconPath);
@@ -623,17 +634,71 @@ function validateReleaseLocalizations(localizations, sourceLabel) {
623
634
  }
624
635
  }
625
636
 
626
- function createZip(packageDir, artifactPath) {
627
- const result = spawnSync("zip", ["-qr", artifactPath, "."], {
628
- cwd: packageDir,
629
- encoding: "utf8"
630
- });
631
- if (result.error) {
632
- throw result.error;
637
+ async function createZip(packageDir, artifactPath) {
638
+ const stagingDir = await mkdtemp(
639
+ path.join(tmpdir(), "tutti-app-release-archive-")
640
+ );
641
+ const archiveRoot = path.join(stagingDir, "package");
642
+ try {
643
+ await cp(packageDir, archiveRoot, {
644
+ recursive: true,
645
+ dereference: true
646
+ });
647
+ const entries = await normalizeArchiveEntries(archiveRoot);
648
+ await rm(artifactPath, { force: true });
649
+ const result = spawnSync("zip", ["-X", "-q", artifactPath, "-@"], {
650
+ cwd: archiveRoot,
651
+ encoding: "utf8",
652
+ env: {
653
+ ...process.env,
654
+ LC_ALL: "C",
655
+ TZ: "UTC"
656
+ },
657
+ input: `${entries.join("\n")}\n`
658
+ });
659
+ if (result.error) {
660
+ throw result.error;
661
+ }
662
+ if (result.status !== 0) {
663
+ throw new Error(
664
+ result.stderr || `zip exited with status ${result.status}`
665
+ );
666
+ }
667
+ } finally {
668
+ await rm(stagingDir, { recursive: true, force: true });
633
669
  }
634
- if (result.status !== 0) {
635
- throw new Error(result.stderr || `zip exited with status ${result.status}`);
670
+ }
671
+
672
+ async function normalizeArchiveEntries(rootDir, relativeDir = "") {
673
+ const fixedTimestamp = new Date("1980-01-01T00:00:00.000Z");
674
+ const directoryEntries = await readdir(path.join(rootDir, relativeDir), {
675
+ withFileTypes: true
676
+ });
677
+ directoryEntries.sort((left, right) =>
678
+ left.name < right.name ? -1 : left.name > right.name ? 1 : 0
679
+ );
680
+
681
+ const result = [];
682
+ for (const entry of directoryEntries) {
683
+ if (entry.name.includes("\n") || entry.name.includes("\r")) {
684
+ throw new Error(
685
+ `package path contains an unsupported newline: ${path.join(relativeDir, entry.name)}`
686
+ );
687
+ }
688
+ const relativePath = path.join(relativeDir, entry.name);
689
+ const absolutePath = path.join(rootDir, relativePath);
690
+ await utimes(absolutePath, fixedTimestamp, fixedTimestamp);
691
+ if (entry.isDirectory()) {
692
+ result.push(`${relativePath}/`);
693
+ result.push(...(await normalizeArchiveEntries(rootDir, relativePath)));
694
+ continue;
695
+ }
696
+ if (!entry.isFile()) {
697
+ throw new Error(`unsupported package entry type: ${relativePath}`);
698
+ }
699
+ result.push(relativePath);
636
700
  }
701
+ return result;
637
702
  }
638
703
 
639
704
  async function fileDigestAndSize(filePath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tutti-os/app-release-tools",
3
- "version": "0.0.79",
3
+ "version": "0.0.80",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {