pgserve 2.4.0 → 2.6.0

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.
Files changed (36) hide show
  1. package/README.md +5 -8
  2. package/bin/pgserve-wrapper.cjs +23 -0
  3. package/bin/postgres-server.js +28 -0
  4. package/package.json +2 -1
  5. package/scripts/aggregate-manifest.sh +184 -0
  6. package/scripts/assemble-tarball.sh +191 -0
  7. package/scripts/audit-redaction-lint.js +349 -0
  8. package/scripts/build-binary.sh +213 -0
  9. package/scripts/fetch-postgres-bins.sh +234 -0
  10. package/scripts/postinstall.cjs +102 -18
  11. package/scripts/verify-published-artifacts.sh +211 -0
  12. package/src/audit/audit.js +134 -0
  13. package/src/cli-install.cjs +258 -26
  14. package/src/commands/doctor.js +465 -0
  15. package/src/commands/gc.js +276 -0
  16. package/src/commands/provision.js +396 -0
  17. package/src/commands/trust.js +187 -0
  18. package/src/commands/verify.js +360 -0
  19. package/src/cosign/cache-token.js +328 -0
  20. package/src/cosign/schema.js +97 -0
  21. package/src/cosign/trust-list.js +81 -0
  22. package/src/cosign/trust-store.js +250 -0
  23. package/src/cosign/verify-binary.js +277 -0
  24. package/src/gc/audit-log.js +150 -0
  25. package/src/gc/orphan-detection.js +190 -0
  26. package/src/gc/queries.js +193 -0
  27. package/src/lib/pg-query.js +145 -0
  28. package/src/lib/runtime-json.js +181 -0
  29. package/src/provision/advisory-lock.js +91 -0
  30. package/src/provision/db-naming.js +130 -0
  31. package/src/provision/fingerprint.js +144 -0
  32. package/src/schema/pgserve-meta.js +120 -0
  33. package/src/security/blocked-versions.js +103 -0
  34. package/src/upgrade/index.js +5 -0
  35. package/src/upgrade/steps/binary-cache-flush.js +2 -2
  36. package/src/upgrade/steps/cosign-meta-migration.js +123 -0
package/README.md CHANGED
@@ -101,17 +101,14 @@ psql postgresql://localhost:8432/myapp
101
101
  ## Installation
102
102
 
103
103
  ```bash
104
- # Zero install (recommended)
105
- npx pgserve
106
-
107
- # Global install
108
- npm install -g pgserve
104
+ # Canonical install — signed binary from GitHub Releases
105
+ curl -fsSL https://raw.githubusercontent.com/namastexlabs/pgserve/main/install.sh | bash
109
106
 
110
- # Project dependency
111
- npm install pgserve
107
+ # Pinned version
108
+ PGSERVE_VERSION=v2.6.0 curl -fsSL .../install.sh | bash
112
109
  ```
113
110
 
114
- > PostgreSQL binaries are automatically downloaded on first run (~100MB).
111
+ > `install.sh` fetches the signed tarball from GitHub Releases and verifies it via `gh attestation verify` (Sigstore Rekor public-good). Requires the [`gh` CLI](https://cli.github.com/). pgserve no longer depends on npm — the install + upgrade path is binary tarballs all the way down.
115
112
 
116
113
  ### Windows
117
114
 
@@ -43,6 +43,29 @@ const __installSubcommands = new Set([
43
43
  'upgrade',
44
44
  'restart',
45
45
  'ui',
46
+ // pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
47
+ // `verify` shells out to cosign + writes an HMAC cache token. Pure node
48
+ // (no bun) so it must skip the bun probe like the install surface above.
49
+ 'verify',
50
+ // pgserve singleton (v2.4) — wish Group 3, read-only V1. `doctor` runs
51
+ // entirely in node (admin.json + supervisor probes + socket reachability)
52
+ // and must skip the bun probe so it works on any installed binary.
53
+ 'doctor',
54
+ // pgserve singleton (v2.4) — wish Group 3. `trust` manages the
55
+ // user-extensible cosign trust store at ~/.pgserve/trust/identities.json.
56
+ // Pure node, must skip bun probe.
57
+ 'trust',
58
+ // pgserve singleton (v2.4) — wish Group 3, verb 3. `gc` shells out to
59
+ // psql to scan pgserve_meta + pg_database, classify orphans, and
60
+ // (under --apply) DROP DATABASE. Pure node + child_process, must skip
61
+ // the bun probe.
62
+ 'gc',
63
+ // pgserve singleton (v2.4) — wish Group 3, verb 4. `provision` shells
64
+ // out to psql to CREATE ROLE / CREATE DATABASE / GRANT / INSERT INTO
65
+ // pgserve_meta. Idempotency-driven (no advisory lock — see
66
+ // src/commands/provision.js header for why). Pure node + child_process,
67
+ // must skip bun probe.
68
+ 'provision',
46
69
  ]);
47
70
  if (__subcommand && __installSubcommands.has(__subcommand)) {
48
71
  const cli = require(path.join(__dirname, '..', 'src', 'cli-install.cjs'));
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { PostgresManager } from '../src/postgres.js';
19
19
  import { resolveSocketDir, ensureSocketDir } from '../src/lib/socket-dir.js';
20
+ import { writeRuntimeJson, clearRuntimeJson } from '../src/lib/runtime-json.js';
20
21
  import { createLogger } from '../src/logger.js';
21
22
 
22
23
  // Global error handlers — surface unhandled rejections + uncaught errors
@@ -95,6 +96,27 @@ async function runPostmasterSubcommand(postmasterArgs) {
95
96
  process.exit(1);
96
97
  }
97
98
 
99
+ // cutover G19: drop a runtime discovery file at <socketDir>/runtime.json
100
+ // so consumers' UDS-first probes find the live socket without globbing
101
+ // ephemeral pid-stamped dirs. The file is intentionally separate from
102
+ // ~/.autopg/admin.json (which records supervisor metadata, not live
103
+ // socket info) — that split lets the postmaster restart under a new
104
+ // pid without rewriting the supervisor record. NO `supervisor` key
105
+ // here; the writer rejects it.
106
+ try {
107
+ writeRuntimeJson({
108
+ socketDir,
109
+ port: opts.port,
110
+ pid: manager.process?.pid ?? process.pid,
111
+ autopgPid: process.pid,
112
+ });
113
+ } catch (err) {
114
+ logger.warn(
115
+ { err: err.message },
116
+ 'pgserve postmaster: runtime.json write failed; consumers will fall back to admin.json',
117
+ );
118
+ }
119
+
98
120
  logger.info(
99
121
  { port: opts.port, socketDir, dataDir: opts.dataDir },
100
122
  'pgserve postmaster: ready (Unix socket + TCP)',
@@ -102,6 +124,12 @@ async function runPostmasterSubcommand(postmasterArgs) {
102
124
 
103
125
  const shutdown = async (signal) => {
104
126
  logger.info({ signal }, 'pgserve postmaster: stopping');
127
+ // Clear runtime.json BEFORE stopping the postmaster so the moment
128
+ // a graceful-shutdown signal lands, fresh consumers see "no live
129
+ // socket" instead of racing against a stale-pid record. On crash
130
+ // (uncaughtException, backend died) the file is left behind; the
131
+ // operator-facing detector is `process.kill(record.autopgPid, 0)`.
132
+ clearRuntimeJson(socketDir);
105
133
  try {
106
134
  await manager.stop();
107
135
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "2.4.0",
3
+ "version": "2.6.0",
4
4
  "description": "Embedded PostgreSQL server with true concurrent connections - zero config, auto-provision databases",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -30,6 +30,7 @@
30
30
  "console:dev": "bun build console/src/main.jsx --target browser --define 'process.env.NODE_ENV=\"development\"' --watch --outfile console/dist/app.js",
31
31
  "lint": "eslint src/ bin/",
32
32
  "lint:fix": "eslint src/ bin/ --fix",
33
+ "lint:audit": "bun scripts/audit-redaction-lint.js",
33
34
  "deadcode": "knip",
34
35
  "test:npx": "scripts/test-npx.sh",
35
36
  "test:bun-self-heal": "scripts/test-bun-self-heal.sh",
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # aggregate-manifest.sh — Group 8 of autopg-distribution-cutover.
4
+ #
5
+ # Walks dist/autopg-<version>-<platform>.tar.gz, gathers each tarball's
6
+ # SHA256 + signature URL + provenance URL + platform tuple, and emits
7
+ # dist/manifest.json that consumers (install.sh, autopg update) read to
8
+ # resolve a download for a given (channel, version, platform).
9
+ #
10
+ # This runs after Group 8 signing + provenance generation and before
11
+ # Group 9 (CDN publish).
12
+ #
13
+ # Usage:
14
+ # bash scripts/aggregate-manifest.sh --version 2.260503.1
15
+ # bash scripts/aggregate-manifest.sh --version 2.260503.1 --base-url https://cdn.automagik.dev/autopg/stable/2.260503.1
16
+ #
17
+ # Output:
18
+ # dist/manifest.json
19
+ #
20
+ # Exit codes:
21
+ # 0 ok
22
+ # 1 IO failure
23
+ # 2 invalid args / missing inputs
24
+
25
+ set -euo pipefail
26
+
27
+ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
28
+ DIST_DIR="${AUTOPG_DIST_DIR:-${REPO_ROOT}/dist}"
29
+
30
+ usage() {
31
+ cat <<EOF
32
+ Usage: $0 --version <v> [--base-url <url>] [--channel <c>] [--cosign-pub-url <url>]
33
+
34
+ --version autopg version, e.g. 2.260503.1 (or read from package.json)
35
+ --base-url absolute base URL prefix for tarball URLs
36
+ (default: relative — consumers resolve against the
37
+ directory the manifest sits in).
38
+ --channel channel hint embedded in the manifest (stable|beta|canary).
39
+ default: stable
40
+ --cosign-pub-url absolute URL to the published cosign public key.
41
+ default: <base-url>/../../keys/cosign.pub for production
42
+ (cdn.automagik.dev layout) or "keys/cosign.pub" relative.
43
+
44
+ Reads:
45
+ dist/autopg-<version>-<platform>.tar.gz
46
+ dist/autopg-<version>-<platform>.tar.gz.sha256
47
+ dist/autopg-<version>-<platform>.tar.gz.sig (optional)
48
+ dist/autopg-<version>-<platform>.tar.gz.intoto.jsonl (optional)
49
+
50
+ Writes:
51
+ dist/manifest.json
52
+ EOF
53
+ }
54
+
55
+ parse_args() {
56
+ VERSION="${AUTOPG_VERSION:-}"
57
+ BASE_URL=""
58
+ CHANNEL="stable"
59
+ COSIGN_PUB_URL=""
60
+ while [[ $# -gt 0 ]]; do
61
+ case "$1" in
62
+ --version) VERSION="$2"; shift 2 ;;
63
+ --base-url) BASE_URL="$2"; shift 2 ;;
64
+ --channel) CHANNEL="$2"; shift 2 ;;
65
+ --cosign-pub-url) COSIGN_PUB_URL="$2"; shift 2 ;;
66
+ -h|--help) usage; exit 0 ;;
67
+ *) echo "unknown arg: $1" >&2; usage; exit 2 ;;
68
+ esac
69
+ done
70
+ if [[ -z "$VERSION" ]]; then
71
+ VERSION=$(node -p "require('${REPO_ROOT}/package.json').version" 2>/dev/null || echo "")
72
+ fi
73
+ if [[ -z "$VERSION" ]]; then
74
+ echo "error: --version required (or set in package.json)" >&2; exit 2
75
+ fi
76
+ }
77
+
78
+ # Portable SHA256 — sha256sum on linux, shasum -a 256 on macOS.
79
+ sha256_of() {
80
+ if command -v sha256sum >/dev/null 2>&1; then
81
+ sha256sum "$1" | awk '{print $1}'
82
+ else
83
+ shasum -a 256 "$1" | awk '{print $1}'
84
+ fi
85
+ }
86
+
87
+ prefix_url() {
88
+ local rel="$1"
89
+ if [[ -n "$BASE_URL" ]]; then
90
+ printf '%s/%s' "${BASE_URL%/}" "$rel"
91
+ else
92
+ printf '%s' "$rel"
93
+ fi
94
+ }
95
+
96
+ emit_entry() {
97
+ local tarball="$1"
98
+ local first="$2"
99
+ local base platform sha sz
100
+ base=$(basename "$tarball")
101
+ # Strip "autopg-<version>-" prefix and ".tar.gz" suffix to get platform.
102
+ platform="${base#autopg-${VERSION}-}"
103
+ platform="${platform%.tar.gz}"
104
+
105
+ if [[ ! -f "${tarball}.sha256" ]]; then
106
+ echo "error: ${tarball}.sha256 missing — run assemble-tarball.sh first" >&2
107
+ return 1
108
+ fi
109
+ sha=$(awk '{print $1}' "${tarball}.sha256")
110
+ sz=$(stat -c %s "$tarball" 2>/dev/null || stat -f %z "$tarball")
111
+
112
+ local sig_url="" prov_url=""
113
+ if [[ -f "${tarball}.sig" ]]; then
114
+ sig_url=$(prefix_url "${base}.sig")
115
+ fi
116
+ if [[ -f "${tarball}.intoto.jsonl" ]]; then
117
+ prov_url=$(prefix_url "${base}.intoto.jsonl")
118
+ fi
119
+
120
+ if [[ "$first" -eq 0 ]]; then printf ',\n'; fi
121
+ printf ' {\n'
122
+ printf ' "platform": "%s",\n' "$platform"
123
+ printf ' "file": "%s",\n' "$base"
124
+ printf ' "url": "%s",\n' "$(prefix_url "$base")"
125
+ printf ' "sha256": "%s",\n' "$sha"
126
+ printf ' "size": %d,\n' "$sz"
127
+ printf ' "signature_url": "%s",\n' "$sig_url"
128
+ printf ' "provenance_url": "%s"\n' "$prov_url"
129
+ printf ' }'
130
+ }
131
+
132
+ main() {
133
+ parse_args "$@"
134
+ [[ -d "$DIST_DIR" ]] || { echo "error: $DIST_DIR not a directory" >&2; exit 2; }
135
+
136
+ local tarballs=()
137
+ while IFS= read -r line; do
138
+ tarballs+=("$line")
139
+ done < <(find "$DIST_DIR" -maxdepth 1 -name "autopg-${VERSION}-*.tar.gz" -type f | LC_ALL=C sort)
140
+
141
+ if [[ ${#tarballs[@]} -eq 0 ]]; then
142
+ echo "error: no autopg-${VERSION}-*.tar.gz files in ${DIST_DIR}" >&2
143
+ exit 2
144
+ fi
145
+
146
+ local generated_at
147
+ generated_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
148
+
149
+ local out="${DIST_DIR}/manifest.json"
150
+ {
151
+ printf '{\n'
152
+ printf ' "name": "autopg",\n'
153
+ printf ' "version": "%s",\n' "$VERSION"
154
+ printf ' "channel": "%s",\n' "$CHANNEL"
155
+ printf ' "schemaVersion": 1,\n'
156
+ printf ' "generated_at": "%s",\n' "$generated_at"
157
+ local cpub
158
+ if [[ -n "$COSIGN_PUB_URL" ]]; then
159
+ cpub="$COSIGN_PUB_URL"
160
+ elif [[ -n "$BASE_URL" ]]; then
161
+ # CDN layout: <base>/autopg/<channel>/<version>/manifest.json
162
+ # Public key lives at: <base>/autopg/keys/cosign.pub
163
+ # If caller passes the full <base>/autopg/<channel>/<version> as
164
+ # base-url, walk two levels up to reach the keys/ sibling.
165
+ cpub="${BASE_URL%/*}"
166
+ cpub="${cpub%/*}/keys/cosign.pub"
167
+ else
168
+ cpub="keys/cosign.pub"
169
+ fi
170
+ printf ' "cosign_pub_url": "%s",\n' "$cpub"
171
+ printf ' "platforms": [\n'
172
+ local first=1
173
+ for t in "${tarballs[@]}"; do
174
+ emit_entry "$t" "$first"
175
+ first=0
176
+ done
177
+ printf '\n ]\n'
178
+ printf '}\n'
179
+ } > "$out"
180
+
181
+ echo "==> manifest: $out ($(wc -l < "$out") lines, ${#tarballs[@]} platforms)"
182
+ }
183
+
184
+ main "$@"
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # assemble-tarball.sh — Group 7 of autopg-distribution-cutover.
4
+ #
5
+ # Assembles a single platform tarball with the locked shape:
6
+ #
7
+ # autopg/
8
+ # autopg # static binary (from build-binary.sh)
9
+ # postgres/
10
+ # bin/* # postgres + initdb + libpq + ...
11
+ # share/* # timezone data, locale, etc.
12
+ # manifest.json # per-file SHA256 + size
13
+ #
14
+ # The tarball lives at:
15
+ # dist/autopg-<version>-<platform>.tar.gz
16
+ # and a sibling .sha256 file holds the outer hash for Group 8 (cosign sign)
17
+ # and Group 9 (CDN publish) to consume.
18
+ #
19
+ # Inputs come from dist/<platform>/autopg/{autopg, postgres/}, populated by
20
+ # build-binary.sh + fetch-postgres-bins.sh.
21
+ #
22
+ # Usage:
23
+ # scripts/assemble-tarball.sh --platform linux-x64-glibc
24
+ # scripts/assemble-tarball.sh --all --version 2.260503.1
25
+
26
+ set -euo pipefail
27
+
28
+ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
29
+ DIST_DIR="${AUTOPG_DIST_DIR:-${REPO_ROOT}/dist}"
30
+
31
+ PLATFORMS=(linux-x64-glibc linux-x64-musl linux-arm64 darwin-x64 darwin-arm64)
32
+
33
+ usage() {
34
+ cat <<EOF
35
+ Usage: $0 (--platform <p> | --all) [--version <v>]
36
+
37
+ Platforms: ${PLATFORMS[*]}
38
+
39
+ Inputs (must already exist):
40
+ dist/<platform>/autopg/autopg (build-binary.sh)
41
+ dist/<platform>/autopg/postgres/bin/* (fetch-postgres-bins.sh)
42
+ dist/<platform>/autopg/postgres/share/* (fetch-postgres-bins.sh)
43
+
44
+ Outputs:
45
+ dist/autopg-<version>-<platform>.tar.gz
46
+ dist/autopg-<version>-<platform>.tar.gz.sha256
47
+ EOF
48
+ }
49
+
50
+ parse_args() {
51
+ TARGET_PLATFORM=""
52
+ ASSEMBLE_ALL=0
53
+ VERSION="${AUTOPG_VERSION:-}"
54
+
55
+ while [[ $# -gt 0 ]]; do
56
+ case "$1" in
57
+ --platform) TARGET_PLATFORM="$2"; shift 2 ;;
58
+ --all) ASSEMBLE_ALL=1; shift ;;
59
+ --version) VERSION="$2"; shift 2 ;;
60
+ -h|--help) usage; exit 0 ;;
61
+ *) echo "unknown arg: $1" >&2; usage; exit 2 ;;
62
+ esac
63
+ done
64
+
65
+ if [[ "$ASSEMBLE_ALL" -eq 0 && -z "$TARGET_PLATFORM" ]]; then
66
+ echo "error: pass --platform <p> or --all" >&2; usage; exit 2
67
+ fi
68
+
69
+ if [[ -z "$VERSION" ]]; then
70
+ VERSION=$(node -p "require('${REPO_ROOT}/package.json').version" 2>/dev/null || echo "0.0.0")
71
+ fi
72
+ }
73
+
74
+ # Portable SHA256 — use sha256sum on linux, shasum -a 256 on macOS.
75
+ sha256_of() {
76
+ if command -v sha256sum >/dev/null 2>&1; then
77
+ sha256sum "$1" | awk '{print $1}'
78
+ else
79
+ shasum -a 256 "$1" | awk '{print $1}'
80
+ fi
81
+ }
82
+
83
+ # Emit manifest.json for the given platform's staged tree.
84
+ # Walks autopg/ relative to <root>/, skipping manifest.json itself.
85
+ emit_manifest() {
86
+ local root="$1" platform="$2" out="$3"
87
+
88
+ pushd "$root" >/dev/null
89
+ {
90
+ printf '{\n'
91
+ printf ' "name": "autopg",\n'
92
+ printf ' "version": "%s",\n' "$VERSION"
93
+ printf ' "platform": "%s",\n' "$platform"
94
+ printf ' "schemaVersion": 1,\n'
95
+ printf ' "files": [\n'
96
+
97
+ local first=1
98
+ while IFS= read -r f; do
99
+ [[ "$f" == "autopg/manifest.json" ]] && continue
100
+ local h sz
101
+ h=$(sha256_of "$f")
102
+ sz=$(stat -c %s "$f" 2>/dev/null || stat -f %z "$f")
103
+ if [[ $first -eq 1 ]]; then
104
+ first=0
105
+ else
106
+ printf ',\n'
107
+ fi
108
+ printf ' { "path": "%s", "sha256": "%s", "size": %d }' "$f" "$h" "$sz"
109
+ done < <(find autopg -type f | LC_ALL=C sort)
110
+
111
+ printf '\n ]\n'
112
+ printf '}\n'
113
+ } > "$out"
114
+ popd >/dev/null
115
+ }
116
+
117
+ # Verify staged inputs are present + executable.
118
+ verify_inputs() {
119
+ local stage="$1" platform="$2"
120
+ local missing=0
121
+ for required in autopg/autopg autopg/postgres/bin/postgres; do
122
+ if [[ ! -f "${stage}/${required}" ]]; then
123
+ echo "error: ${platform}: missing ${required}" >&2
124
+ missing=1
125
+ fi
126
+ done
127
+ return $missing
128
+ }
129
+
130
+ assemble_one() {
131
+ local platform="$1"
132
+ local stage="${DIST_DIR}/${platform}"
133
+ local tarball="${DIST_DIR}/autopg-${VERSION}-${platform}.tar.gz"
134
+ local outer_sha="${tarball}.sha256"
135
+
136
+ if [[ ! -d "${stage}/autopg" ]]; then
137
+ echo "error: ${stage}/autopg/ does not exist (run build-binary.sh + fetch-postgres-bins.sh first)" >&2
138
+ return 1
139
+ fi
140
+
141
+ echo "==> [${platform}] assemble tarball"
142
+ # `assemble_one` is invoked from main as `assemble_one ... || rc=$?` which
143
+ # disables `set -e` for the duration of this function. Each potentially-
144
+ # failing command needs explicit `|| return 1` to halt early instead of
145
+ # silently producing a corrupt tarball (gemini bot review HIGH on PR #84).
146
+ verify_inputs "$stage" "$platform" || return 1
147
+
148
+ # 1) emit per-file manifest BEFORE the tarball is rolled — manifest is
149
+ # bundled inside.
150
+ emit_manifest "$stage" "$platform" "${stage}/autopg/manifest.json" || return 1
151
+
152
+ # 2) ensure binaries are executable inside the tar.
153
+ chmod +x "${stage}/autopg/autopg" || true
154
+ find "${stage}/autopg/postgres/bin" -type f -exec chmod +x {} +
155
+
156
+ # 3) build deterministic tarball: sorted entries, locked mtime.
157
+ local tar_flags=()
158
+ if tar --help 2>&1 | grep -q -- '--sort=name'; then
159
+ tar_flags+=(--sort=name)
160
+ fi
161
+ if tar --help 2>&1 | grep -q -- '--mtime='; then
162
+ tar_flags+=(--mtime=2026-01-01)
163
+ fi
164
+ if tar --help 2>&1 | grep -q -- '--owner='; then
165
+ tar_flags+=(--owner=0 --group=0 --numeric-owner)
166
+ fi
167
+
168
+ tar -C "$stage" -czf "$tarball" "${tar_flags[@]}" autopg/ || return 1
169
+ echo " ✓ tarball: $tarball ($(du -h "$tarball" | cut -f1))"
170
+
171
+ # 4) outer SHA256 — Group 8 cosign-signs this; Group 9 publishes both.
172
+ sha256_of "$tarball" > "$outer_sha" || return 1
173
+ echo " ✓ sha256: $(cat "$outer_sha") $(basename "$tarball")"
174
+ }
175
+
176
+ main() {
177
+ parse_args "$@"
178
+ mkdir -p "$DIST_DIR"
179
+
180
+ local rc=0
181
+ if [[ "$ASSEMBLE_ALL" -eq 1 ]]; then
182
+ for p in "${PLATFORMS[@]}"; do
183
+ assemble_one "$p" || rc=$?
184
+ done
185
+ else
186
+ assemble_one "$TARGET_PLATFORM" || rc=$?
187
+ fi
188
+ exit $rc
189
+ }
190
+
191
+ main "$@"