alpha-gate 0.1.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.
- package/CHANGELOG.md +20 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/bin/alpha-gate.mjs +75 -0
- package/deploy/backup.sh +45 -0
- package/deploy/deploy.sh +21 -0
- package/deploy/dev.sh +56 -0
- package/deploy/lib/statedir.sh +11 -0
- package/deploy/teardown.sh +17 -0
- package/docs/ONBOARDING.md +16 -0
- package/docs/PRINCIPLES.md +195 -0
- package/docs/README.md +54 -0
- package/docs/UPLOADING.md +14 -0
- package/docs/integrate/activation.md +56 -0
- package/docs/integrate/sparkle-go.md +106 -0
- package/docs/integrate/sparkle-swift.md +63 -0
- package/docs/maintain/backup.md +52 -0
- package/docs/maintain/migrate-account.md +95 -0
- package/docs/maintain/teardown.md +46 -0
- package/docs/maintain/troubleshooting.md +102 -0
- package/docs/maintain/updating.md +92 -0
- package/docs/operate/add-users.md +55 -0
- package/docs/operate/channels.md +54 -0
- package/docs/operate/email.md +51 -0
- package/docs/operate/monitoring.md +56 -0
- package/docs/operate/publish.md +90 -0
- package/docs/operate/remove-users.md +47 -0
- package/docs/setup/cloudflare-account.md +41 -0
- package/docs/setup/deploy.md +84 -0
- package/docs/setup/install.md +65 -0
- package/migrations/0001_clients.sql +12 -0
- package/migrations/0002_builds_streams.sql +30 -0
- package/migrations/0003_access_log.sql +14 -0
- package/migrations/0004_meta.sql +5 -0
- package/migrations/0005_admin_audit.sql +14 -0
- package/migrations/0006_build_dmg.sql +6 -0
- package/migrations/0007_access_requests.sql +12 -0
- package/migrations/0008_build_rollback_target.sql +7 -0
- package/migrations/0009_hidden.sql +6 -0
- package/migrations/0010_purged_at.sql +5 -0
- package/package.json +65 -0
- package/publish.sh +302 -0
- package/release.json +7 -0
- package/src/auth/access-jwt.ts +210 -0
- package/src/auth/token-gate.ts +27 -0
- package/src/core/appcast.ts +107 -0
- package/src/core/audit-chain.ts +145 -0
- package/src/core/invite-template.ts +127 -0
- package/src/core/no-build.ts +160 -0
- package/src/core/resolver.ts +60 -0
- package/src/core/tokens.ts +51 -0
- package/src/core/types.ts +76 -0
- package/src/core/validation.ts +60 -0
- package/src/core/verdict.ts +195 -0
- package/src/core/version.ts +113 -0
- package/src/cron.ts +37 -0
- package/src/db/access-log.ts +168 -0
- package/src/db/access-requests.ts +90 -0
- package/src/db/admin-audit.ts +101 -0
- package/src/db/builds.ts +169 -0
- package/src/db/client.ts +80 -0
- package/src/db/clients.ts +103 -0
- package/src/db/meta.ts +30 -0
- package/src/db/streams.ts +85 -0
- package/src/deploy/cli.ts +181 -0
- package/src/deploy/commands/deploy.ts +392 -0
- package/src/deploy/commands/dev.ts +190 -0
- package/src/deploy/commands/teardown.ts +159 -0
- package/src/deploy/core/args.ts +205 -0
- package/src/deploy/core/colors.ts +49 -0
- package/src/deploy/core/config.ts +65 -0
- package/src/deploy/core/parse.ts +51 -0
- package/src/deploy/core/paths.ts +27 -0
- package/src/deploy/core/plan.ts +138 -0
- package/src/deploy/core/result.ts +13 -0
- package/src/deploy/core/state.ts +64 -0
- package/src/deploy/core/table.ts +49 -0
- package/src/deploy/core/types.ts +39 -0
- package/src/deploy/core/ui.ts +107 -0
- package/src/deploy/seams/clock.ts +10 -0
- package/src/deploy/seams/files.ts +52 -0
- package/src/deploy/seams/io.ts +56 -0
- package/src/deploy/seams/wrangler.ts +100 -0
- package/src/deploy/ui-preview.ts +112 -0
- package/src/deps.ts +41 -0
- package/src/dev/admin-entry.ts +104 -0
- package/src/env.ts +47 -0
- package/src/lib/clock.ts +17 -0
- package/src/lib/hosts.ts +27 -0
- package/src/lib/text.ts +6 -0
- package/src/r2/builds-bucket.ts +50 -0
- package/src/r2/keys.ts +27 -0
- package/src/routes/admin/admin-context.ts +9 -0
- package/src/routes/admin/audit-fields.ts +22 -0
- package/src/routes/admin/branding.tsx +161 -0
- package/src/routes/admin/builds.tsx +388 -0
- package/src/routes/admin/clients.tsx +396 -0
- package/src/routes/admin/confirm.tsx +80 -0
- package/src/routes/admin/flash.ts +72 -0
- package/src/routes/admin/form.ts +43 -0
- package/src/routes/admin/index.ts +146 -0
- package/src/routes/admin/invite.ts +57 -0
- package/src/routes/admin/middleware.ts +52 -0
- package/src/routes/admin/negotiate.ts +13 -0
- package/src/routes/admin/pending.tsx +73 -0
- package/src/routes/admin/read-model.ts +518 -0
- package/src/routes/admin/streams.tsx +182 -0
- package/src/routes/admin/theme.ts +34 -0
- package/src/routes/admin/upload.tsx +320 -0
- package/src/routes/admin/views.tsx +293 -0
- package/src/routes/app/access.tsx +38 -0
- package/src/routes/app/app-context.ts +8 -0
- package/src/routes/app/appcast.ts +68 -0
- package/src/routes/app/assets.ts +25 -0
- package/src/routes/app/download.ts +45 -0
- package/src/routes/app/get.tsx +35 -0
- package/src/routes/app/index.ts +31 -0
- package/src/routes/app/resolve.ts +16 -0
- package/src/services/anchor.ts +54 -0
- package/src/services/audit.ts +19 -0
- package/src/services/branding.ts +51 -0
- package/src/services/email-cloudflare.ts +80 -0
- package/src/services/email.ts +68 -0
- package/src/services/self-update.ts +62 -0
- package/src/views/access-page.tsx +39 -0
- package/src/views/admin/ci-page.tsx +76 -0
- package/src/views/admin/combobox.tsx +191 -0
- package/src/views/admin/forms.tsx +37 -0
- package/src/views/admin/layout.tsx +496 -0
- package/src/views/admin/manage-pages.tsx +223 -0
- package/src/views/admin/manage.tsx +1120 -0
- package/src/views/admin/plist-extract.ts +233 -0
- package/src/views/admin/read-pages.tsx +1098 -0
- package/src/views/admin/setup-page.tsx +108 -0
- package/src/views/admin/table-enhance.ts +176 -0
- package/src/views/admin/ui.tsx +159 -0
- package/src/views/get-page.tsx +39 -0
- package/src/views/layout.tsx +57 -0
- package/src/worker.ts +23 -0
- package/tsconfig.json +35 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Install
|
|
2
|
+
|
|
3
|
+
How to install the Alpha Gate CLI, from npm or from a git clone, and run both Workers on your machine without a Cloudflare account.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- **Node ≥ 20** and npm.
|
|
8
|
+
- A Cloudflare account — only when you deploy (the free tier is enough). Local development and tests need no account.
|
|
9
|
+
- macOS — only when you [publish builds](../operate/publish.md); Apple signing and notarization run on your Mac. Deploying works from any OS.
|
|
10
|
+
|
|
11
|
+
## Install from npm
|
|
12
|
+
|
|
13
|
+
No clone; you run a pinned, versioned release:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx alpha-gate deploy --instance <slug>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`npx alpha-gate <command>` runs the latest release; `npx alpha-gate@0.1.0 <command>` pins one. (Until the first release lands on npm, install from a clone, below.) You can also install globally with `npm i -g alpha-gate` and run `alpha-gate <command>`. The commands are `deploy`, `dev`, `publish`, `backup`, and `teardown`; run `alpha-gate <command> --help` for a command's options.
|
|
20
|
+
|
|
21
|
+
State lives in `~/.alpha-gate`: per-instance records (`<slug>.state.json`) and the rendered wrangler configs. Set `$ALPHA_GATE_HOME` to move it. The package files themselves sit in npm's versioned cache, so nothing durable is written there.
|
|
22
|
+
|
|
23
|
+
## Install from a clone
|
|
24
|
+
|
|
25
|
+
For contributors, or to run unreleased `main`:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
git clone <your-fork> alpha-gate
|
|
29
|
+
cd alpha-gate
|
|
30
|
+
npm install
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The `deploy/*.sh` wrappers are the same CLI: `./deploy/deploy.sh --instance <slug>` takes the same flags as `npx alpha-gate deploy --instance <slug>`. A clone keeps its state in the repo's `.deploy/` directory instead of `~/.alpha-gate`, so a checkout keeps finding the instances it deployed. `npm install` needs no Cloudflare account, and the test suite (`npm test`) runs offline.
|
|
34
|
+
|
|
35
|
+
## Run it locally
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
./deploy/dev.sh
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This starts both Workers on Miniflare with no Cloudflare account: the app on `http://localhost:8787` and the admin on `http://localhost:8788/admin`. It renders a local wrangler config, applies migrations to a local database, seeds a demo user and a demo build so `/get`, `/appcast`, and `/download` return real data, and prints the seeded `/get?token=` link. The first run downloads the `workerd` runtime, so it needs the network once. Ctrl-C stops both Workers.
|
|
42
|
+
|
|
43
|
+
Flags:
|
|
44
|
+
|
|
45
|
+
| Flag | Effect |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `--port <n>` | App port (default 8787); when both Workers run, the admin takes the next port up. With `--role admin`, `--port` is the admin's port. |
|
|
48
|
+
| `--no-seed` | Skip the demo user and build. |
|
|
49
|
+
| `--reset` | Wipe the local D1/R2 state before starting. |
|
|
50
|
+
| `--role app` or `--role admin` | Start one Worker instead of both. |
|
|
51
|
+
|
|
52
|
+
The local admin runs behind a dev-only auth shim: there is no login, every request acts as the admin `dev@local`, and mutations are audited under that name. **The shim is localhost-only and cannot ship** — nothing in the deployed Worker references it, and it refuses to serve unless `DEV_ADMIN=1`, which only the local `dev` command sets when it starts the admin. Production admin auth is unchanged.
|
|
53
|
+
|
|
54
|
+
From an npm install, `alpha-gate dev` starts one Worker at a time: the app by default, or the admin with `--role admin`.
|
|
55
|
+
|
|
56
|
+
## Where state lives
|
|
57
|
+
|
|
58
|
+
| Install | State directory |
|
|
59
|
+
|---|---|
|
|
60
|
+
| npm (`npx alpha-gate`) | `~/.alpha-gate` |
|
|
61
|
+
| git clone | `<repo>/.deploy` |
|
|
62
|
+
|
|
63
|
+
`$ALPHA_GATE_HOME` overrides both. The directory holds per-instance deploy records and the rendered wrangler configs; the data itself (users, tokens, builds, channels) lives in your instance's D1 database (see [Backup](../maintain/backup.md)).
|
|
64
|
+
|
|
65
|
+
Next: [Prepare your Cloudflare account](cloudflare-account.md)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- 0001_clients.sql
|
|
2
|
+
CREATE TABLE clients (
|
|
3
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4
|
+
email TEXT NOT NULL UNIQUE,
|
|
5
|
+
token TEXT NOT NULL UNIQUE,
|
|
6
|
+
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'revoked'
|
|
7
|
+
pinned_build_id INTEGER, -- nullable; overrides stream resolution
|
|
8
|
+
label TEXT,
|
|
9
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
10
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
11
|
+
);
|
|
12
|
+
CREATE INDEX idx_clients_token ON clients(token);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
-- 0002_builds_streams.sql
|
|
2
|
+
CREATE TABLE builds (
|
|
3
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4
|
+
short_version TEXT NOT NULL, -- human, e.g. '1.4.0'
|
|
5
|
+
build_number INTEGER NOT NULL UNIQUE, -- machine CFBundleVersion, monotonic
|
|
6
|
+
object_key TEXT NOT NULL, -- R2 key of the archive
|
|
7
|
+
ed_signature TEXT NOT NULL, -- Sparkle EdDSA (from generate_appcast)
|
|
8
|
+
length INTEGER NOT NULL,
|
|
9
|
+
min_os TEXT,
|
|
10
|
+
critical INTEGER NOT NULL DEFAULT 0, -- mandatory/critical update flag
|
|
11
|
+
status TEXT NOT NULL DEFAULT 'available', -- 'available' | 'withdrawn'
|
|
12
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
CREATE TABLE streams (
|
|
16
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
17
|
+
name TEXT NOT NULL UNIQUE -- e.g. 'stable', 'beta', 'canary'
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
CREATE TABLE build_streams (
|
|
21
|
+
build_id INTEGER NOT NULL REFERENCES builds(id),
|
|
22
|
+
stream_id INTEGER NOT NULL REFERENCES streams(id),
|
|
23
|
+
PRIMARY KEY (build_id, stream_id)
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
CREATE TABLE user_streams (
|
|
27
|
+
client_id INTEGER NOT NULL REFERENCES clients(id),
|
|
28
|
+
stream_id INTEGER NOT NULL REFERENCES streams(id),
|
|
29
|
+
PRIMARY KEY (client_id, stream_id)
|
|
30
|
+
);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- 0003_access_log.sql
|
|
2
|
+
CREATE TABLE access_log (
|
|
3
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4
|
+
client_id INTEGER,
|
|
5
|
+
email TEXT, -- denormalized; survives client deletion
|
|
6
|
+
event TEXT NOT NULL, -- 'check' | 'download' | 'update'
|
|
7
|
+
short_version TEXT,
|
|
8
|
+
build_number INTEGER,
|
|
9
|
+
ip TEXT,
|
|
10
|
+
user_agent TEXT,
|
|
11
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
12
|
+
);
|
|
13
|
+
CREATE INDEX idx_access_log_client ON access_log(client_id);
|
|
14
|
+
CREATE INDEX idx_access_log_created ON access_log(created_at);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
-- 0005_admin_audit.sql
|
|
2
|
+
CREATE TABLE admin_audit (
|
|
3
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4
|
+
actor_email TEXT NOT NULL, -- from the Access JWT
|
|
5
|
+
action TEXT NOT NULL, -- e.g. 'client.revoke', 'build.withdraw', 'stream.assign'
|
|
6
|
+
target TEXT, -- entity affected (email, build_number, stream)
|
|
7
|
+
detail TEXT, -- JSON: params / before-after
|
|
8
|
+
ip TEXT,
|
|
9
|
+
ray_id TEXT, -- Cloudflare Ray ID, to cross-reference platform logs
|
|
10
|
+
prev_hash TEXT, -- hash of the previous row (chain)
|
|
11
|
+
hash TEXT NOT NULL, -- SHA-256(prev_hash ‖ canonical(entry)) — tamper-evidence
|
|
12
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
13
|
+
);
|
|
14
|
+
CREATE INDEX idx_admin_audit_created ON admin_audit(created_at);
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- 0006_build_dmg.sql
|
|
2
|
+
-- Second build artifact (decision 0003): the first-install DMG, alongside the EdDSA-signed .app zip.
|
|
3
|
+
-- The DMG carries no EdDSA — notarization/Gatekeeper seal it and Sparkle never touches it. Both
|
|
4
|
+
-- nullable: a build may ship the zip only (the Sparkle enclosure is always object_key/ed_signature/length).
|
|
5
|
+
ALTER TABLE builds ADD COLUMN dmg_object_key TEXT;
|
|
6
|
+
ALTER TABLE builds ADD COLUMN dmg_length INTEGER;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- 0007_access_requests.sql
|
|
2
|
+
-- §13 — submissions from the public "request access" page (§13 IA #10). The admin reviews these and
|
|
3
|
+
-- invites or dismisses each. No FK to clients: a request is from someone who may not have a row yet.
|
|
4
|
+
CREATE TABLE access_requests (
|
|
5
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
6
|
+
email TEXT NOT NULL,
|
|
7
|
+
ip TEXT,
|
|
8
|
+
user_agent TEXT,
|
|
9
|
+
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'handled' | 'dismissed'
|
|
10
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
11
|
+
);
|
|
12
|
+
CREATE INDEX idx_access_requests_status ON access_requests(status);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- 0008_build_rollback_target.sql
|
|
2
|
+
-- §9/§13 #7 — an operator annotation marking a known-good build as the designated rollback target.
|
|
3
|
+
-- This is a LABEL ONLY, not a downgrade switch: Sparkle cannot downgrade, so a real rollback is a
|
|
4
|
+
-- roll-forward (rebuild the good code with a higher build_number, withdraw the bad one — see §9). The
|
|
5
|
+
-- flag records which build to roll forward FROM and surfaces it on the builds list / manage page; the
|
|
6
|
+
-- resolver (§8) is unaffected and keeps serving the highest available build.
|
|
7
|
+
ALTER TABLE builds ADD COLUMN rollback_target INTEGER NOT NULL DEFAULT 0;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
-- Admin-list visibility: a soft "hide" that declutters the Users/Builds lists. UI-only — it does NOT
|
|
2
|
+
-- affect resolution or serving (a hidden available build still serves; a hidden user still gets updates).
|
|
3
|
+
-- Withdraw/revoke remain the functional controls. The list views exclude hidden rows by default and a
|
|
4
|
+
-- "show hidden" toggle reveals them.
|
|
5
|
+
ALTER TABLE clients ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;
|
|
6
|
+
ALTER TABLE builds ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
-- Archive purge (storage lifecycle): when the R2 bytes of a WITHDRAWN build are deleted to reclaim
|
|
2
|
+
-- free-tier space, the D1 row is kept (build_number uniqueness, download/update counts, and the
|
|
3
|
+
-- audit chain stay intact) and stamped here. A purged build can be restored to "available" only
|
|
4
|
+
-- after re-uploading its archive — purged_at gates that. NULL = archive still present.
|
|
5
|
+
ALTER TABLE builds ADD COLUMN purged_at TEXT;
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "alpha-gate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Self-hosted Cloudflare distribution gate for a Sparkle-updated macOS app",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"sparkle",
|
|
7
|
+
"macos",
|
|
8
|
+
"auto-update",
|
|
9
|
+
"cloudflare-workers",
|
|
10
|
+
"self-hosted",
|
|
11
|
+
"distribution"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/alex-ivanov/alpha-gate/blob/main/CHANGELOG.md",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/alex-ivanov/alpha-gate.git"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"bin": {
|
|
21
|
+
"alpha-gate": "bin/alpha-gate.mjs"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"bin/",
|
|
28
|
+
"src/",
|
|
29
|
+
"migrations/",
|
|
30
|
+
"deploy/",
|
|
31
|
+
"publish.sh",
|
|
32
|
+
"tsconfig.json",
|
|
33
|
+
"release.json",
|
|
34
|
+
"CHANGELOG.md",
|
|
35
|
+
"docs/"
|
|
36
|
+
],
|
|
37
|
+
"alphaGate": {
|
|
38
|
+
"minSupported": "0.1.0",
|
|
39
|
+
"breaking": false
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"test": "vitest run --config test/vitest.config.ts && vitest run --config test/vitest.deploy.config.ts",
|
|
43
|
+
"test:app": "vitest run --config test/vitest.config.ts",
|
|
44
|
+
"test:deploy": "vitest run --config test/vitest.deploy.config.ts",
|
|
45
|
+
"test:watch": "vitest --config test/vitest.config.ts",
|
|
46
|
+
"ui:preview": "tsx src/deploy/ui-preview.ts",
|
|
47
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.deploy.json",
|
|
48
|
+
"lint": "biome check .",
|
|
49
|
+
"format": "biome format --write .",
|
|
50
|
+
"check": "biome check . && npm run typecheck && npm run test"
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"hono": "^4.12.25",
|
|
54
|
+
"tsx": "^4.22.4",
|
|
55
|
+
"wrangler": "^4.100.0"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@biomejs/biome": "^2.5.0",
|
|
59
|
+
"@cloudflare/vitest-pool-workers": "^0.16.15",
|
|
60
|
+
"@cloudflare/workers-types": "^4.20260613.1",
|
|
61
|
+
"@types/node": "^25.9.3",
|
|
62
|
+
"typescript": "^6.0.3",
|
|
63
|
+
"vitest": "^4.1.8"
|
|
64
|
+
}
|
|
65
|
+
}
|
package/publish.sh
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ONE command to publish a signed, notarized macOS app to Alpha Gate. Give it the artifact:
|
|
3
|
+
#
|
|
4
|
+
# ./publish.sh MyApp.dmg # into the only deployed instance, no channel
|
|
5
|
+
# ./publish.sh MyApp.zip --channel beta # a signed .app .zip, linked to a channel by NAME
|
|
6
|
+
# ./publish.sh MyApp.dmg --instance myalpha --critical
|
|
7
|
+
#
|
|
8
|
+
# It reads the version straight from the app's Info.plist (never retype it), makes the Sparkle EdDSA
|
|
9
|
+
# signature with sign_update, and uploads. The Worker never signs and never holds the key. It handles
|
|
10
|
+
# BOTH artifact formats and the >90 MB register path itself, and pre-checks the build number against
|
|
11
|
+
# the running instance so you never fail after a multi-minute upload.
|
|
12
|
+
#
|
|
13
|
+
# What it needs on macOS: hdiutil (built in), Sparkle's sign_update (auto-found in Xcode's
|
|
14
|
+
# DerivedData, or pass --sign-update / $SIGN_UPDATE, or bypass with ED_SIGNATURE=<sig>). For a real
|
|
15
|
+
# instance it needs a Cloudflare Access service token — entered once, then stored in your Keychain.
|
|
16
|
+
#
|
|
17
|
+
# CI / headless: set CF_ACCESS_CLIENT_ID + CF_ACCESS_CLIENT_SECRET and pass --admin-url; on a runner
|
|
18
|
+
# with no app to read (already-built zip), pass --build-number/--short-version and ED_SIGNATURE.
|
|
19
|
+
set -euo pipefail
|
|
20
|
+
|
|
21
|
+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
22
|
+
# shellcheck source=deploy/lib/statedir.sh
|
|
23
|
+
. "${ROOT}/deploy/lib/statedir.sh"
|
|
24
|
+
STATE_DIR="$(alpha_gate_state_dir "${ROOT}")"
|
|
25
|
+
|
|
26
|
+
ARTIFACT=""; INSTANCE=""; ADMIN_URL=""; CHANNEL=""; STREAM_ID=""; CRITICAL_FLAG=""
|
|
27
|
+
DRY_RUN=0; RESET_TOKEN=0; BUILD_OVERRIDE=""; SHORT_OVERRIDE=""; MIN_OS_OVERRIDE=""
|
|
28
|
+
SIGN_UPDATE="${SIGN_UPDATE:-}"
|
|
29
|
+
|
|
30
|
+
need() { [ "$#" -ge 2 ] || { echo "missing value for $1" >&2; exit 1; }; }
|
|
31
|
+
while [ "$#" -gt 0 ]; do
|
|
32
|
+
case "$1" in
|
|
33
|
+
--instance) need "$@"; INSTANCE="$2"; shift 2 ;;
|
|
34
|
+
--admin-url) need "$@"; ADMIN_URL="$2"; shift 2 ;;
|
|
35
|
+
--channel) need "$@"; CHANNEL="$2"; shift 2 ;;
|
|
36
|
+
--stream-id) need "$@"; STREAM_ID="$2"; shift 2 ;; # legacy; --channel is preferred
|
|
37
|
+
--sign-update) need "$@"; SIGN_UPDATE="$2"; shift 2 ;;
|
|
38
|
+
--build-number) need "$@"; BUILD_OVERRIDE="$2"; shift 2 ;;
|
|
39
|
+
--short-version) need "$@"; SHORT_OVERRIDE="$2"; shift 2 ;;
|
|
40
|
+
--min-os) need "$@"; MIN_OS_OVERRIDE="$2"; shift 2 ;;
|
|
41
|
+
--critical) CRITICAL_FLAG="true"; shift ;;
|
|
42
|
+
--reset-token) RESET_TOKEN=1; shift ;;
|
|
43
|
+
--dry-run) DRY_RUN=1; shift ;;
|
|
44
|
+
-h|--help)
|
|
45
|
+
sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
|
46
|
+
exit 0 ;;
|
|
47
|
+
-*) echo "unknown flag: $1" >&2; exit 1 ;;
|
|
48
|
+
*) ARTIFACT="$1"; shift ;;
|
|
49
|
+
esac
|
|
50
|
+
done
|
|
51
|
+
|
|
52
|
+
[ -n "${ARTIFACT}" ] || { echo "usage: ./publish.sh <MyApp.dmg|MyApp.zip> [--channel <name>] [--instance <slug>]" >&2; exit 1; }
|
|
53
|
+
[ -f "${ARTIFACT}" ] || { echo "artifact not found: ${ARTIFACT}" >&2; exit 1; }
|
|
54
|
+
|
|
55
|
+
# ─── Resolve the instance + admin URL ──────────────────────────────────────────────────────────────
|
|
56
|
+
# Default --instance when exactly one instance is deployed (its .deploy/<slug>.state.json). This makes
|
|
57
|
+
# the steady-state command './publish.sh MyApp.dmg' with no flags at all.
|
|
58
|
+
if [ -z "${ADMIN_URL}" ] && [ -z "${INSTANCE}" ]; then
|
|
59
|
+
shopt -s nullglob
|
|
60
|
+
STATES=("${STATE_DIR}"/*.state.json)
|
|
61
|
+
shopt -u nullglob
|
|
62
|
+
if [ "${#STATES[@]}" -eq 1 ]; then
|
|
63
|
+
INSTANCE="$(basename "${STATES[0]}" .state.json)"
|
|
64
|
+
echo "using the only deployed instance: ${INSTANCE}" >&2
|
|
65
|
+
elif [ "${#STATES[@]}" -gt 1 ]; then
|
|
66
|
+
echo "multiple instances deployed — pass --instance <slug>. Found:" >&2
|
|
67
|
+
for s in "${STATES[@]}"; do echo " $(basename "$s" .state.json)" >&2; done
|
|
68
|
+
exit 1
|
|
69
|
+
fi
|
|
70
|
+
fi
|
|
71
|
+
if [ -z "${ADMIN_URL}" ] && [ -n "${INSTANCE}" ]; then
|
|
72
|
+
STATE="${STATE_DIR}/${INSTANCE}.state.json"
|
|
73
|
+
[ -f "${STATE}" ] || { echo "no deploy state for instance '${INSTANCE}'; pass --admin-url" >&2; exit 1; }
|
|
74
|
+
ADMIN_URL="$(node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync(process.argv[1],"utf8")).admin_url||""))' "${STATE}" 2>/dev/null)" \
|
|
75
|
+
|| { echo "could not read admin_url from ${STATE}; pass --admin-url" >&2; exit 1; }
|
|
76
|
+
fi
|
|
77
|
+
[ -n "${ADMIN_URL}" ] || { echo "--admin-url or --instance is required" >&2; exit 1; }
|
|
78
|
+
|
|
79
|
+
case "${ADMIN_URL}" in
|
|
80
|
+
http://localhost*|http://127.0.0.1*|http://0.0.0.0*|"http://[::1]"*) LOCAL=1 ;;
|
|
81
|
+
*) LOCAL=0 ;;
|
|
82
|
+
esac
|
|
83
|
+
|
|
84
|
+
# ─── Cloudflare Access service token (Keychain, entered once) ───────────────────────────────────────
|
|
85
|
+
# The admin Worker is behind Access; reaching it needs a service token. Stored in the login Keychain
|
|
86
|
+
# keyed by instance/host. Skipped for a localhost dev admin (auto-authenticated) and for --dry-run.
|
|
87
|
+
if [ "${LOCAL}" -eq 0 ] && [ "${DRY_RUN}" -eq 0 ]; then
|
|
88
|
+
KEY="${INSTANCE:-$(printf '%s' "${ADMIN_URL}" | sed -E 's#^https?://([^/]+).*#\1#')}"
|
|
89
|
+
KC_SERVICE="alpha-gate-access"
|
|
90
|
+
kc_get() { security find-generic-password -s "${KC_SERVICE}" -a "$1" -w 2>/dev/null || true; }
|
|
91
|
+
if [ "${RESET_TOKEN}" -eq 1 ]; then
|
|
92
|
+
security delete-generic-password -s "${KC_SERVICE}" -a "${KEY}-client-id" >/dev/null 2>&1 || true
|
|
93
|
+
security delete-generic-password -s "${KC_SERVICE}" -a "${KEY}-client-secret" >/dev/null 2>&1 || true
|
|
94
|
+
CF_ACCESS_CLIENT_ID=""; CF_ACCESS_CLIENT_SECRET=""
|
|
95
|
+
echo "Forgot the stored token for '${KEY}'." >&2
|
|
96
|
+
fi
|
|
97
|
+
[ -z "${CF_ACCESS_CLIENT_ID:-}" ] && CF_ACCESS_CLIENT_ID="$(kc_get "${KEY}-client-id")"
|
|
98
|
+
[ -z "${CF_ACCESS_CLIENT_SECRET:-}" ] && CF_ACCESS_CLIENT_SECRET="$(kc_get "${KEY}-client-secret")"
|
|
99
|
+
if [ -n "${CF_ACCESS_CLIENT_ID}" ] && [ -n "${CF_ACCESS_CLIENT_SECRET}" ]; then
|
|
100
|
+
:
|
|
101
|
+
elif [ -t 0 ]; then
|
|
102
|
+
echo "" >&2
|
|
103
|
+
echo "No Access service token stored for '${KEY}'. Create one (see the admin /admin/ci page):" >&2
|
|
104
|
+
echo " 1. Cloudflare Zero Trust → Access → Service Auth → 'Create service token'." >&2
|
|
105
|
+
echo " 2. On the admin Access application, add a policy (Action: Service Auth) allowing it." >&2
|
|
106
|
+
printf 'Service Token Client ID: ' >&2; IFS= read -r CF_ACCESS_CLIENT_ID || true
|
|
107
|
+
printf 'Service Token Client Secret: ' >&2; IFS= read -rs CF_ACCESS_CLIENT_SECRET || true; echo "" >&2
|
|
108
|
+
if [ -z "${CF_ACCESS_CLIENT_ID}" ] || [ -z "${CF_ACCESS_CLIENT_SECRET}" ]; then
|
|
109
|
+
echo "both are required" >&2; exit 1
|
|
110
|
+
fi
|
|
111
|
+
security add-generic-password -U -s "${KC_SERVICE}" -a "${KEY}-client-id" -w "${CF_ACCESS_CLIENT_ID}" >/dev/null
|
|
112
|
+
security add-generic-password -U -s "${KC_SERVICE}" -a "${KEY}-client-secret" -w "${CF_ACCESS_CLIENT_SECRET}" >/dev/null
|
|
113
|
+
echo "Stored in your login Keychain; future runs read it automatically." >&2
|
|
114
|
+
else
|
|
115
|
+
echo "No service token for '${KEY}' and no TTY. Set CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET." >&2
|
|
116
|
+
exit 1
|
|
117
|
+
fi
|
|
118
|
+
export CF_ACCESS_CLIENT_ID CF_ACCESS_CLIENT_SECRET
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
# curl with the Access headers (or dev-local defaults). We check the status ourselves because a
|
|
122
|
+
# rejected service token comes back as a 3xx redirect to the Access login, not a 4xx.
|
|
123
|
+
api() { # METHOD PATH [curl args…] → body on stdout, returns nonzero on failure
|
|
124
|
+
local method="$1" path="$2"; shift 2
|
|
125
|
+
local id="${CF_ACCESS_CLIENT_ID:-dev-local}" secret="${CF_ACCESS_CLIENT_SECRET:-dev-local}"
|
|
126
|
+
local body status
|
|
127
|
+
body="$(mktemp)"
|
|
128
|
+
status="$(curl -sS -o "${body}" -w '%{http_code}' -X "${method}" \
|
|
129
|
+
-H "CF-Access-Client-Id: ${id}" -H "CF-Access-Client-Secret: ${secret}" \
|
|
130
|
+
"$@" "${ADMIN_URL}${path}")" || { echo "could not reach ${ADMIN_URL} (network error)" >&2; rm -f "${body}"; return 1; }
|
|
131
|
+
if [ "${status}" -ge 200 ] && [ "${status}" -lt 300 ]; then cat "${body}"; rm -f "${body}"; return 0; fi
|
|
132
|
+
echo "request failed: HTTP ${status}" >&2
|
|
133
|
+
if [ "${status}" -ge 300 ] && [ "${status}" -lt 400 ]; then
|
|
134
|
+
echo " Cloudflare Access rejected the service token. Confirm the token + a Service-Auth policy" >&2
|
|
135
|
+
echo " on the admin Access app (see /admin/ci), or re-enter: ./publish.sh … --reset-token." >&2
|
|
136
|
+
else
|
|
137
|
+
sed 's/^/ /' "${body}" >&2
|
|
138
|
+
fi
|
|
139
|
+
rm -f "${body}"; return 1
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
# ─── Read version + min-OS from the app inside the artifact ─────────────────────────────────────────
|
|
143
|
+
# One helper reads an Info.plist with PlistBuddy (not `defaults`, which caches). DMGs are mounted;
|
|
144
|
+
# zips are read with unzip -p through plutil. Overrides (flags/env) beat what we read.
|
|
145
|
+
SHORT_VERSION=""; BUILD_NUMBER=""; MIN_OS=""
|
|
146
|
+
pb() { /usr/libexec/PlistBuddy -c "Print :$1" "$2" 2>/dev/null || true; }
|
|
147
|
+
|
|
148
|
+
read_from_plist() { # PLIST_PATH
|
|
149
|
+
SHORT_VERSION="${SHORT_OVERRIDE:-${SHORT_VERSION:-$(pb CFBundleShortVersionString "$1")}}"
|
|
150
|
+
BUILD_NUMBER="${BUILD_OVERRIDE:-${BUILD_NUMBER:-$(pb CFBundleVersion "$1")}}"
|
|
151
|
+
MIN_OS="${MIN_OS_OVERRIDE:-${MIN_OS:-$(pb LSMinimumSystemVersion "$1")}}"
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case "${ARTIFACT##*.}" in
|
|
155
|
+
dmg)
|
|
156
|
+
ATTACH="$(hdiutil attach "${ARTIFACT}" -nobrowse -readonly -noautoopen -mountrandom /tmp 2>&1)" \
|
|
157
|
+
|| { echo "could not mount ${ARTIFACT}:" >&2; printf '%s\n' "${ATTACH}" | sed 's/^/ /' >&2; exit 1; }
|
|
158
|
+
DEV="$(printf '%s\n' "${ATTACH}" | grep -Eo '^/dev/disk[0-9]+' | head -1 || true)"
|
|
159
|
+
MOUNTS="$(printf '%s\n' "${ATTACH}" | grep -Eo '/tmp/[^[:space:]]+' || true)"
|
|
160
|
+
cleanup() {
|
|
161
|
+
if [ -n "${DEV:-}" ]; then hdiutil detach "${DEV}" -quiet >/dev/null 2>&1 || true; fi
|
|
162
|
+
for m in ${MOUNTS:-}; do hdiutil detach "${m}" -quiet >/dev/null 2>&1 || true; done
|
|
163
|
+
}
|
|
164
|
+
trap cleanup EXIT INT TERM
|
|
165
|
+
APP=""
|
|
166
|
+
for M in ${MOUNTS:-}; do
|
|
167
|
+
A="$(/bin/ls -d "${M}"/*.app 2>/dev/null | head -1 || true)"
|
|
168
|
+
[ -n "${A}" ] && { APP="${A}"; break; }
|
|
169
|
+
done
|
|
170
|
+
[ -n "${APP}" ] || { echo "no .app found in ${ARTIFACT}" >&2; exit 1; }
|
|
171
|
+
if [ -L "${APP}" ]; then
|
|
172
|
+
echo "error: '${APP##*/}' in the DMG is a symlink → $(readlink "${APP}"); its version would come" >&2
|
|
173
|
+
echo " from the link target, not the DMG. Rebuild it, or pass --build-number/--short-version." >&2
|
|
174
|
+
exit 1
|
|
175
|
+
fi
|
|
176
|
+
PLIST="${APP}/Contents/Info.plist"
|
|
177
|
+
[ -f "${PLIST}" ] || { echo "no Info.plist in ${APP##*/}" >&2; exit 1; }
|
|
178
|
+
read_from_plist "${PLIST}"
|
|
179
|
+
cleanup; trap - EXIT INT TERM # release before signing/uploading — neither needs it mounted
|
|
180
|
+
;;
|
|
181
|
+
zip)
|
|
182
|
+
# A signed .app .zip: find the Info.plist inside and read it via a temp copy (PlistBuddy needs a
|
|
183
|
+
# file). If the zip has no readable .app plist, the operator must pass the version flags.
|
|
184
|
+
PLIST_ENTRY="$(unzip -Z1 "${ARTIFACT}" 2>/dev/null | grep -E '\.app/Contents/Info\.plist$' | head -1 || true)"
|
|
185
|
+
if [ -n "${PLIST_ENTRY}" ]; then
|
|
186
|
+
TMP_PLIST="$(mktemp)"
|
|
187
|
+
if unzip -p "${ARTIFACT}" "${PLIST_ENTRY}" > "${TMP_PLIST}" 2>/dev/null && [ -s "${TMP_PLIST}" ]; then
|
|
188
|
+
plutil -convert xml1 "${TMP_PLIST}" >/dev/null 2>&1 || true
|
|
189
|
+
read_from_plist "${TMP_PLIST}"
|
|
190
|
+
fi
|
|
191
|
+
rm -f "${TMP_PLIST}"
|
|
192
|
+
fi
|
|
193
|
+
;;
|
|
194
|
+
*)
|
|
195
|
+
# Any other extension (e.g. .tar): can't read a plist — require the version flags.
|
|
196
|
+
: ;;
|
|
197
|
+
esac
|
|
198
|
+
|
|
199
|
+
# Fall back to overrides for formats we couldn't read.
|
|
200
|
+
SHORT_VERSION="${SHORT_OVERRIDE:-${SHORT_VERSION}}"
|
|
201
|
+
BUILD_NUMBER="${BUILD_OVERRIDE:-${BUILD_NUMBER}}"
|
|
202
|
+
MIN_OS="${MIN_OS_OVERRIDE:-${MIN_OS}}"
|
|
203
|
+
[ -n "${SHORT_VERSION}" ] || { echo "could not read the short version; pass --short-version" >&2; exit 1; }
|
|
204
|
+
[ -n "${BUILD_NUMBER}" ] || { echo "could not read the build number; pass --build-number" >&2; exit 1; }
|
|
205
|
+
echo "read ${SHORT_VERSION} (build ${BUILD_NUMBER}${MIN_OS:+, min macOS ${MIN_OS}}) from ${ARTIFACT##*/}" >&2
|
|
206
|
+
|
|
207
|
+
# Sparkle compares CFBundleVersion (= build_number) numerically; the server rejects non-integers.
|
|
208
|
+
case "${BUILD_NUMBER}" in
|
|
209
|
+
''|*[!0-9]*)
|
|
210
|
+
echo "error: build number '${BUILD_NUMBER}' (CFBundleVersion) is not a positive integer." >&2
|
|
211
|
+
echo " Set CFBundleVersion to a monotonic integer, or pass --build-number <n>." >&2
|
|
212
|
+
exit 1 ;;
|
|
213
|
+
esac
|
|
214
|
+
|
|
215
|
+
# ─── Pre-flight against the running instance (publish-info): catch a duplicate/typo BEFORE signing ──
|
|
216
|
+
if [ "${DRY_RUN}" -eq 0 ]; then
|
|
217
|
+
INFO="$(api GET /admin/publish-info || true)"
|
|
218
|
+
if [ -n "${INFO}" ]; then
|
|
219
|
+
TOP="$(printf '%s' "${INFO}" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(String(JSON.parse(s).topBuild??""))}catch{}})' 2>/dev/null || true)"
|
|
220
|
+
if [ -n "${TOP}" ] && [ "${BUILD_NUMBER}" -le "${TOP}" ] 2>/dev/null; then
|
|
221
|
+
echo "error: build number ${BUILD_NUMBER} is not above the current highest (${TOP})." >&2
|
|
222
|
+
echo " Each build must increase — use $((TOP + 1)) or higher." >&2
|
|
223
|
+
exit 1
|
|
224
|
+
fi
|
|
225
|
+
if [ -n "${CHANNEL}" ]; then
|
|
226
|
+
NAMES="$(printf '%s' "${INFO}" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{const c=(JSON.parse(s).channels||[]).map(x=>x.name);process.stdout.write(c.join(", "))}catch{}})' 2>/dev/null || true)"
|
|
227
|
+
case ", ${NAMES}," in
|
|
228
|
+
*", ${CHANNEL},"*) : ;;
|
|
229
|
+
*)
|
|
230
|
+
echo "error: channel '${CHANNEL}' does not exist on this instance." >&2
|
|
231
|
+
echo " Existing channels: ${NAMES:-(none)}" >&2
|
|
232
|
+
exit 1 ;;
|
|
233
|
+
esac
|
|
234
|
+
fi
|
|
235
|
+
fi
|
|
236
|
+
fi
|
|
237
|
+
|
|
238
|
+
# ─── Sparkle EdDSA signature ────────────────────────────────────────────────────────────────────────
|
|
239
|
+
find_sign_update() {
|
|
240
|
+
[ -n "${SIGN_UPDATE}" ] && { printf '%s' "${SIGN_UPDATE}"; return; }
|
|
241
|
+
command -v sign_update >/dev/null 2>&1 && { printf 'sign_update'; return; }
|
|
242
|
+
# Sparkle SPM builds land its tools under Xcode DerivedData; find the newest.
|
|
243
|
+
local dd="${HOME}/Library/Developer/Xcode/DerivedData"
|
|
244
|
+
[ -d "${dd}" ] && find "${dd}" -type f -name sign_update -path '*/artifacts/*' \
|
|
245
|
+
-exec stat -f '%m %N' {} + 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if [ -z "${ED_SIGNATURE:-}" ]; then
|
|
249
|
+
SIGN_BIN="$(find_sign_update)"
|
|
250
|
+
if [ -n "${SIGN_BIN}" ] && { [ "${SIGN_BIN}" = "sign_update" ] || [ -x "${SIGN_BIN}" ]; }; then
|
|
251
|
+
echo "signing with ${SIGN_BIN}" >&2
|
|
252
|
+
SU_OUT="$("${SIGN_BIN}" "${ARTIFACT}")"
|
|
253
|
+
ED_SIGNATURE="$(printf '%s' "${SU_OUT}" | sed -n 's/.*edSignature="\([^"]*\)".*/\1/p')"
|
|
254
|
+
[ -n "${ED_SIGNATURE}" ] || ED_SIGNATURE="$(printf '%s' "${SU_OUT}" | tr -d '[:space:]')"
|
|
255
|
+
elif [ "${DRY_RUN}" -eq 1 ]; then
|
|
256
|
+
ED_SIGNATURE="DRY-RUN-SIGNATURE"
|
|
257
|
+
else
|
|
258
|
+
echo "sign_update not found. Pass --sign-update <path>, set \$SIGN_UPDATE, or ED_SIGNATURE=<sig>." >&2
|
|
259
|
+
echo " (Sparkle ships it inside the package; SPM builds put it under Xcode DerivedData.)" >&2
|
|
260
|
+
exit 1
|
|
261
|
+
fi
|
|
262
|
+
fi
|
|
263
|
+
|
|
264
|
+
# ─── Upload (or register for >90 MB) ────────────────────────────────────────────────────────────────
|
|
265
|
+
SIZE="$(wc -c < "${ARTIFACT}" | tr -d ' ')"
|
|
266
|
+
CEILING=$((90 * 1024 * 1024))
|
|
267
|
+
FILENAME="${ARTIFACT##*/}"
|
|
268
|
+
|
|
269
|
+
common_fields=(
|
|
270
|
+
--data-urlencode "short_version=${SHORT_VERSION}"
|
|
271
|
+
--data-urlencode "build_number=${BUILD_NUMBER}"
|
|
272
|
+
--data-urlencode "ed_signature=${ED_SIGNATURE}"
|
|
273
|
+
)
|
|
274
|
+
[ -n "${MIN_OS}" ] && common_fields+=(--data-urlencode "min_os=${MIN_OS}")
|
|
275
|
+
[ -n "${CRITICAL_FLAG}" ] && common_fields+=(--data-urlencode "critical=true")
|
|
276
|
+
[ -n "${CHANNEL}" ] && common_fields+=(--data-urlencode "channel=${CHANNEL}")
|
|
277
|
+
[ -n "${STREAM_ID}" ] && common_fields+=(--data-urlencode "stream_id=${STREAM_ID}")
|
|
278
|
+
|
|
279
|
+
if [ "${DRY_RUN}" -eq 1 ]; then
|
|
280
|
+
echo "[dry-run] would publish build ${BUILD_NUMBER} (${SHORT_VERSION})${CHANNEL:+ → channel ${CHANNEL}} to ${ADMIN_URL}" >&2
|
|
281
|
+
exit 0
|
|
282
|
+
fi
|
|
283
|
+
|
|
284
|
+
if [ "${SIZE}" -le "${CEILING}" ]; then
|
|
285
|
+
# Full upload: multipart. -F for the file, the metadata fields converted to -F.
|
|
286
|
+
up=(-F "archive=@${ARTIFACT}")
|
|
287
|
+
for ((i=0; i<${#common_fields[@]}; i+=2)); do up+=(-F "${common_fields[$((i+1))]}"); done
|
|
288
|
+
api POST /admin/builds/upload "${up[@]}" >/dev/null
|
|
289
|
+
else
|
|
290
|
+
# Over the Worker body cap: PUT to R2 with the operator's OWN wrangler auth (no extra token), then
|
|
291
|
+
# register metadata-only. This removes the old manual two-step for large DMGs.
|
|
292
|
+
echo "artifact is ${SIZE} bytes (> 90 MB) — uploading to R2 with wrangler, then registering." >&2
|
|
293
|
+
OBJECT_KEY="build/${BUILD_NUMBER}/${FILENAME}"
|
|
294
|
+
BUCKET="$(node -e 'const s=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(s.bucket||("alpha-gate-"+(process.argv[2]||"")))' "${STATE_DIR}/${INSTANCE}.state.json" "${INSTANCE}" 2>/dev/null || echo "alpha-gate-${INSTANCE}")"
|
|
295
|
+
( cd "${ROOT}" && npx wrangler r2 object put "${BUCKET}/${OBJECT_KEY}" --file "${ARTIFACT}" --remote ) \
|
|
296
|
+
|| { echo "wrangler R2 upload failed" >&2; exit 1; }
|
|
297
|
+
api POST /admin/builds/register \
|
|
298
|
+
--data-urlencode "object_key=${OBJECT_KEY}" --data-urlencode "size=${SIZE}" \
|
|
299
|
+
"${common_fields[@]}" >/dev/null
|
|
300
|
+
fi
|
|
301
|
+
|
|
302
|
+
echo "✓ published build ${BUILD_NUMBER} (${SHORT_VERSION})${CHANNEL:+ → ${CHANNEL}} to ${ADMIN_URL}"
|
package/release.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "\u00a722 self-update manifest FALLBACK. By default the deployed tool's daily cron polls registry.npmjs.org/<name>/latest; this file exists for forks that point $UPDATE_MANIFEST_URL at a static manifest instead. Keep `latest` in sync with package.json's `version` on every release; bump `min_supported` and set `breaking` when a deploy requires manual steps. Extra keys (like this one) are ignored by the parser.",
|
|
3
|
+
"latest": "0.1.0",
|
|
4
|
+
"min_supported": "0.1.0",
|
|
5
|
+
"notes_url": "https://github.com/alex-ivanov/alpha-gate/blob/main/CHANGELOG.md",
|
|
6
|
+
"breaking": false
|
|
7
|
+
}
|