From 0e8af4dbe533d0f65ac14f961453f95a0a83c949 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:44:39 -0400 Subject: [PATCH 1/3] fix(sdk): publish runtime npm packages when /release undrafts The first-publish sequencing constraint was "wait until the GitHub release is public," not "a human clicks Actions." Trigger the existing workflow on release.published like the brew tap, fail closed without OIDC, and stop treating packlist ignore files as missing natives. Co-authored-by: Cursor --- .agents/skills/release/SKILL.md | 45 ++++++++++++++-- .../workflows/publish-runtime-packages.yml | 42 +++++++++------ CHANGELOG.md | 1 + .../scripts/build-runtime-npm-packages.mjs | 27 +++++++--- .../build-runtime-npm-packages.test.mjs | 51 +++++++++++++++++-- docs/features/sdk/README.md | 4 +- 6 files changed, 138 insertions(+), 32 deletions(-) diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 0b8e0aa57..39ce80fb5 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -269,7 +269,7 @@ Desktop release is needed if any changed file matches: - `apps/desktop/**` - `apps/ade-cli/**` - desktop/runtime release scripts under `apps/desktop/scripts/**` -- `.github/workflows/release*.yml`, `.github/workflows/update-brew-tap.yml` +- `.github/workflows/release*.yml`, `.github/workflows/update-brew-tap.yml`, `.github/workflows/publish-runtime-packages.yml` - shared package files that desktop imports - root package/build files that affect desktop packaging @@ -501,7 +501,9 @@ Expected shape: - `publish-release` (in `release-publish.yml`, called by `release.yml` after `run-release` succeeds) merges the per-arch updater manifests and creates the draft -- `update-brew-tap` runs after publication +- `update-brew-tap` and `Publish ADE runtime packages` both run after the + GitHub release is made public (`release.published`), not when the draft is + created. The conductor waits for the runtime npm job after undraft. If `platforms=mac,win` and `build-win-release` did not run, stop. The gate and the run disagree, and publishing would ship a macOS-only release under a @@ -617,6 +619,39 @@ gh api repos/arul28/ADE/releases/latest \ --jq '{tag_name,draft,prerelease,html_url,asset_count:(.assets|length)}' ``` +Undrafting publishes the GitHub release. That event starts +`publish-runtime-packages.yml`, which downloads this tag's runtime assets, +checksums them, and publishes `@ade-dev/runtime*` at the same version. npm +versions are immutable, which is why this waits for `--draft=false` instead of +the tag push. Do not treat desktop release as done until that job succeeds. + +```bash +# The release event can take a few seconds to enqueue the run. +for _ in 1 2 3 4 5 6; do + NPM_RUN_ID=$(gh run list --repo arul28/ADE \ + --workflow publish-runtime-packages.yml --event release \ + --json databaseId,headBranch,status,conclusion,url,createdAt \ + --jq "[.[] | select(.headBranch==\"v\")][0].databaseId") + [ -n "$NPM_RUN_ID" ] && [ "$NPM_RUN_ID" != "null" ] && break + sleep 10 +done +if [ -z "$NPM_RUN_ID" ] || [ "$NPM_RUN_ID" = "null" ]; then + echo "Publish ADE runtime packages did not start for v" + exit 1 +fi +gh run watch "$NPM_RUN_ID" --repo arul28/ADE --interval 30 +gh run view "$NPM_RUN_ID" --repo arul28/ADE --json conclusion,url +npm view @ade-dev/runtime version +npm view @ade-dev/runtime-darwin-arm64 version +``` + +If the run fails because Trusted Publisher / `RUNTIME_TRUSTED_PUBLISHING` is +not configured, that is a release blocker, not a skip. `workflow_dispatch` on +the same workflow (tag + confirm `publish`) is recovery only. + +The brew tap bump is the same `release.published` event. Watch it if it is +still in progress; do not block the npm wait on it. + ## Phase 5: iOS TestFlight Build and Distribution Do this only if iOS scope is `yes`. @@ -1272,8 +1307,10 @@ Desktop: - If a GitHub notarization job stalls, do not restart everything. Cancel the stuck run only when it has exceeded the cutoff, then use `gh run rerun --failed`. -- If the publish job fails after mac artifacts succeeded, inspect the draft - release/assets and workflow logs before rerunning anything. +- If the runtime npm publish job fails after the GitHub release is public, + rerun `publish-runtime-packages.yml` with the same tag and confirm `publish`. + Skip-if-exists means packages that already landed are left alone. Do not + unpublish. Do not invent a new version. - If `latest-mac.yml` references a missing asset, keep the release draft/private until fixed. - If `latest-mac.yml` references a universal ZIP, keep the release draft/private diff --git a/.github/workflows/publish-runtime-packages.yml b/.github/workflows/publish-runtime-packages.yml index 42ea88a39..0aaa66be8 100644 --- a/.github/workflows/publish-runtime-packages.yml +++ b/.github/workflows/publish-runtime-packages.yml @@ -13,29 +13,36 @@ # @ade-dev/runtime-linux-x64 @ade-dev/runtime-linux-arm64 # @ade-dev/runtime-win32-x64 @ade-dev/runtime (the meta package) # -# MANUAL ONLY, and deliberately so. This publishes the bytes of an EXISTING -# GitHub release, so it must run after that release is public and its assets -# are final. It never builds a runtime; it downloads one, verifies it against -# the release's own SHA256SUMS, and repackages it. The version published is the -# release tag with the leading `v` removed, which keeps the npm version and the -# ADE release version identical by construction. +# This publishes the bytes of an EXISTING GitHub release: it never builds a +# runtime. It downloads one, verifies it against the release's own SHA256SUMS, +# and repackages it. npm versions are immutable, so this must run only after +# the release is public and its assets are final — the same moment +# update-brew-tap.yml fires. `/release` undrafts; this workflow then publishes +# `@ade-dev/runtime*`. The npm version is the release tag with the leading `v` +# removed, so it stays identical to the ADE release by construction. +# +# Triggers: +# - `release.published` (not draft creation, not the v* tag push). Prereleases +# are skipped. This is the `/release` path. +# - `workflow_dispatch` with confirm=publish remains as recovery for one tag. # # Credentials — the same two modes as publish-sdk-packages.yml: # # 1. Trusted publishing (OIDC, no secret). Configure per package on npmjs.com → # Settings → Trusted Publisher: repository `arul28/ADE`, workflow -# `publish-runtime-packages.yml`. npm requires the package to already exist -# to configure it, so the FIRST version of each of the six ships by mode 2 -# (or from a maintainer machine). Then set the repo variable +# `publish-runtime-packages.yml`. Then set the repo variable # `RUNTIME_TRUSTED_PUBLISHING=true`. # 2. `NPM_TOKEN` secret — a granular access token scoped to the `@ade-dev` # packages, with an expiry. # -# With neither configured the run ends early with a notice instead of failing. +# With neither configured the run fails. A silent skip would look like a +# successful `/release` that never updated npm. name: Publish ADE runtime packages on: + release: + types: [published] workflow_dispatch: inputs: tag: @@ -57,7 +64,11 @@ concurrency: jobs: publish: - if: ${{ inputs.confirm == 'publish' }} + if: >- + ${{ + (github.event_name == 'release' && !github.event.release.prerelease) + || (github.event_name == 'workflow_dispatch' && inputs.confirm == 'publish') + }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -75,7 +86,7 @@ jobs: - name: Resolve the release version id: version env: - RELEASE_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag }} run: | set -euo pipefail case "$RELEASE_TAG" in @@ -105,16 +116,15 @@ jobs: echo "present=true" >> "$GITHUB_OUTPUT" echo "mode=token" >> "$GITHUB_OUTPUT" else - echo "Neither RUNTIME_TRUSTED_PUBLISHING nor NPM_TOKEN is configured; skipping publish." >> "$GITHUB_STEP_SUMMARY" - echo "present=false" >> "$GITHUB_OUTPUT" - echo "mode=none" >> "$GITHUB_OUTPUT" + echo "::error::Neither RUNTIME_TRUSTED_PUBLISHING nor NPM_TOKEN is configured; refusing to skip a runtime npm publish." + exit 1 fi - name: Download the release artifacts if: steps.token.outputs.present == 'true' env: GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ inputs.tag }} + RELEASE_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || inputs.tag }} run: | set -euo pipefail mkdir -p release-artifacts diff --git a/CHANGELOG.md b/CHANGELOG.md index 596522483..9f9a20ad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Licensing is documented in one place. A new page states what the SDK package, the chat-ui package, the runtime binary, and ADE itself are licensed under, and CI now fails a release whose SPDX field, `LICENSE` file, tarball, and README disagree (#1211, #1212). - `@ade-dev/sdk` and `@ade-dev/chat-ui` are MIT, and a proprietary app may ship the runtime binary. ADE itself, the desktop app, the CLI, and the runtime binary stay AGPL-3.0-only, but the new ADE Runtime Embedding Exception permits distributing an unmodified runtime binary inside a larger work that consumes it through the documented SDK interface. Re-signing the binary with your own identity does not count as modifying it. Modifying the runtime, or linking ADE source, keeps the AGPL in force. Every `@ade-dev/runtime-*` package now carries both documents (#1211). - `@ade-dev/sdk` and `@ade-dev/chat-ui` are at 0.2.0. Every 0.1.x option keeps its exact behavior; everything above is additive (#1212). +- `@ade-dev/runtime*` publishes when a GitHub release is made public. `/release` undrafts; the same `release.published` event as the brew tap then packs and publishes the six platform packages at that tag's version. A separate Actions click is recovery, not the release path. ## [1.2.72] - 2026-09-02 diff --git a/apps/ade-cli/scripts/build-runtime-npm-packages.mjs b/apps/ade-cli/scripts/build-runtime-npm-packages.mjs index 4c755102c..6bb8283f1 100644 --- a/apps/ade-cli/scripts/build-runtime-npm-packages.mjs +++ b/apps/ade-cli/scripts/build-runtime-npm-packages.mjs @@ -332,6 +332,19 @@ function listFilesRelative(dir, base = dir) { return out; } +/** + * Files that must survive `npm pack`. npm-packlist routinely omits ignore + * files and gyp metadata (`isexe/.npmignore`, `node-pty/build/config.gypi`). + * Those are not dropped native modules. A `.gitignore` that hides `*.node` + * still fails: the `.node` path is critical. + */ +function isRuntimeCriticalPackedFile(relativePath) { + if (relativePath.startsWith("bin/")) return true; + if (relativePath.startsWith("native/vendor/")) return true; + if (relativePath === "native/manifest.json") return true; + return relativePath.endsWith(".node"); +} + /** 50 MiB: a real native tree's `npm pack --dry-run --json` listing exceeds Node's 1 MiB default. */ export const NPM_PACK_MAX_BUFFER_BYTES = 50 * 1024 * 1024; @@ -443,7 +456,7 @@ export function verifyPackedRuntimeFiles({ packageDir, runPack = defaultPackRunn ); } const packed = new Set(packedPathsFromNpmPackJson(parsed)); - const missing = onDisk.filter((file) => !packed.has(file)); + const missing = onDisk.filter((file) => isRuntimeCriticalPackedFile(file) && !packed.has(file)); if (missing.length > 0) { throw new Error( `${packageDir}: npm would not pack ${missing.length} file(s) that exist on disk, starting ` + @@ -476,13 +489,13 @@ export function verifyPackedRuntimeFiles({ packageDir, runPack = defaultPackRunn // cr-sqlite is the one file whose absence is a crash rather than a degrade, // and it does NOT live under native/node_modules — `package-native-deps.mjs` // writes it to native/vendor/crsqlite//. An archive carrying - // node_modules but no vendor/ satisfied every other check here: the parity - // check is `onDisk ⊆ packed`, and a file on neither side is invisible to it. + // node_modules but no vendor/ satisfied every other check here: the critical- + // file check only looks at paths that already exist on disk, and a file on + // neither side is invisible to it. // The path is exact, not a shape: a `runtime-win32-x64` package carrying a - // Linux `native/vendor/crsqlite/win32-x64/crsqlite.so` satisfies every other - // check here — parity is `onDisk ⊆ packed`, node_modules is non-empty, and - // the launcher comes from the separate binary asset — and then dies at - // dlopen on the user's machine. + // Linux `native/vendor/crsqlite/win32-x64/crsqlite.so` satisfies the disk + // listing, node_modules is non-empty, and the launcher comes from the + // separate binary asset — and then dies at dlopen on the user's machine. const crsqlite = crsqliteExtensionPath(target); if (!packedFiles.includes(crsqlite)) { throw new Error( diff --git a/apps/ade-cli/scripts/build-runtime-npm-packages.test.mjs b/apps/ade-cli/scripts/build-runtime-npm-packages.test.mjs index a17cd68c1..84dd5ce79 100644 --- a/apps/ade-cli/scripts/build-runtime-npm-packages.test.mjs +++ b/apps/ade-cli/scripts/build-runtime-npm-packages.test.mjs @@ -166,6 +166,18 @@ test("publish workflow checksum step uses runtimeAssetNames", () => { assert.doesNotMatch(workflow, /ade-win32-x64\\.exe\(\\\.native\\.tar\\.gz\)\?/); }); +test("publish workflow runs when a GitHub release is published, not only on dispatch", () => { + const workflow = fs.readFileSync( + new URL("../../../.github/workflows/publish-runtime-packages.yml", import.meta.url), + "utf8", + ); + assert.match(workflow, /\n release:\n types: \[published\]/); + assert.match(workflow, /github\.event_name == 'release'/); + assert.match(workflow, /github\.event\.release\.tag_name/); + assert.match(workflow, /refusing to skip a runtime npm publish/); + assert.doesNotMatch(workflow, /MANUAL ONLY/); +}); + test("names the Windows binary ade.exe", () => { withTempDirs(({ artifacts, out }) => { writeFakeArtifacts(artifacts, "win32-x64"); @@ -263,6 +275,38 @@ test("fails the build when a dependency .gitignore drops a native module from th }); }); +test("does not fail the build when npm-packlist omits an ignore file", () => { + // isexe ships `.npmignore`. npm-packlist never puts ignore files in the + // tarball, so a check of `onDisk ⊆ packed` reports thousands of missing + // files while every .node and vendor binary is present. + withTempDirs(({ artifacts, out }) => { + writeFakeArtifacts(artifacts, "linux-x64"); + const packageDir = buildRuntimePackage({ + target: "linux-x64", + artifactsDir: artifacts, + outDir: out, + version: "1.2.3", + license: "L", + exception: "EXCEPTION TEXT", + }); + const ignorePath = path.join(packageDir, "native", "node_modules", "isexe", ".npmignore"); + fs.mkdirSync(path.dirname(ignorePath), { recursive: true }); + fs.writeFileSync(ignorePath, "*\n"); + verifyPackedRuntimeFiles({ + packageDir, + runPack: packedListing([ + "package.json", + "LICENSE", + "RUNTIME-EMBEDDING-EXCEPTION.md", + "README.md", + "bin/ade", + "native/node_modules/better-sqlite3/index.js", + "native/vendor/crsqlite/linux-x64/crsqlite.so", + ]), + }); + }); +}); + /** * The archive the empty/truncated-release case produces: a `node_modules` * directory and nothing inside it. @@ -362,8 +406,9 @@ test("fails the build when the packed tarball carries no launcher", () => { test("fails the build when the packed tarball carries no cr-sqlite extension", () => { // cr-sqlite does not live under native/node_modules — package-native-deps // writes it to native/vendor/crsqlite//. An archive carrying - // node_modules but no vendor/ satisfied every other check: the parity check - // is `onDisk ⊆ packed`, and a file on neither side is invisible to it. + // node_modules but no vendor/ satisfied every other check: the critical-file + // check only looks at paths that already exist on disk, and a file on neither + // side is invisible to it. withTempDirs(({ artifacts, out }) => { writeFakeArtifacts(artifacts, "linux-x64"); const packageDir = buildRuntimePackage({ @@ -422,7 +467,7 @@ test("accepts the cr-sqlite extension at the exact path its target implies", () test("refuses another platform's cr-sqlite extension in a target's own directory", () => { // The release assembly mislabels one native archive and the Windows package - // ships a Linux `.so`. Parity is `onDisk ⊆ packed`, node_modules is + // ships a Linux `.so`. Disk and the packed listing agree, node_modules is // non-empty, and `bin/ade.exe` comes from the separate binary asset, so this // assertion is the only one left that can catch it. withTempDirs(({ artifacts, out }) => { diff --git a/docs/features/sdk/README.md b/docs/features/sdk/README.md index 411b2fc2b..b08587ffe 100644 --- a/docs/features/sdk/README.md +++ b/docs/features/sdk/README.md @@ -28,7 +28,7 @@ surfaces stay first-party. The SDK is how a *different* app embeds ADE chat. | `packages/sdk/examples/electron/` | Runnable reference app: `sandbox: true`, `contextIsolation: true`, strict CSP. Not installed by CI. | | `apps/ade-cli/scripts/build-runtime-npm-packages.mjs` | Builds the six `@ade-dev/runtime*` packages from a release's artifacts. | | `apps/ade-cli/scripts/verify-runtime-package-contents.mjs` | Re-runs the packed-file assertion between build and publish, against the directories about to be published rather than the ones the build believed it wrote. | -| `.github/workflows/publish-runtime-packages.yml` | `workflow_dispatch`-only publish of those packages: `tag` + `confirm: publish`, checksum-verified, skip-if-version-exists. | +| `.github/workflows/publish-runtime-packages.yml` | Publishes those packages when a GitHub release is published (`release.published`), the same event `/release` uses when it undrafts. `workflow_dispatch` (`tag` + confirm `publish`) is recovery. Checksum-verified, skip-if-version-exists. | | `scripts/check-package-licenses.mjs` | Asserts the SPDX field, `LICENSE` file, tarball file list, and README `## License` section agree for every published package. | | `packages/sdk/src/runtimePidfile.ts` | `/runtime.pid` reclaim with pid-recycling and start-time guards. | | `packages/sdk/src/socketPath.ts` | Per-home Unix socket or hashed Windows named pipe. | @@ -111,7 +111,7 @@ An embedder shipping a signed, notarized app cannot download an executable at fi - `createAdeChat` accepts `binaryPath`, `runtimeNodeModules`, `runtimeRoot` and `allowDownload`. Both directories are validated at create, not at spawn. - The pinned and bundled routes skip `SHA256SUMS` by design: bytes signed into the embedder's bundle are verified by the OS. - `doctor().runtime` reports `source` (`explicit` | `bundled-package` | `cached-download` | `path` | `downloaded` | `attached`), the two runtime paths, `signature`, `downloadedThisSession` and `checksumVerified`. `doctor().binary` keeps its 0.1.x four-value `source`. -- Built by `apps/ade-cli/scripts/build-runtime-npm-packages.mjs` from a release's artifacts; published by `.github/workflows/publish-runtime-packages.yml` (`workflow_dispatch` only, `tag` + `confirm: publish`, checksum-verified, skip-if-version-exists). +- Built by `apps/ade-cli/scripts/build-runtime-npm-packages.mjs` from a release's artifacts; published by `.github/workflows/publish-runtime-packages.yml` when that GitHub release is published. `/release` undrafts; the workflow then publishes. `workflow_dispatch` is recovery. Checksum-verified, skip-if-version-exists. - Public guide: `sdk/bundling.mdx` — signing, entitlements, and an electron-builder fragment. ## Wire contract From b07d527eda5ff859e51ae074ce217005ab099c74 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:50:02 -0400 Subject: [PATCH 2/3] docs(release): run desktop, iOS, Cloudflare, npm, and brew in parallel The numbered phases were being treated as a queue, so /release sat in gh run watch while TestFlight and Workers could already have been moving. Keep only the real gates serial: ci-pass before tag, draft verify before undraft, and iOS validate-before-upload. Co-authored-by: Cursor --- .agents/skills/release/SKILL.md | 134 +++++++++++++++++++++++++++----- 1 file changed, 116 insertions(+), 18 deletions(-) diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 39ce80fb5..a1a99de1b 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -73,6 +73,62 @@ without burning a notarization, TestFlight upload, or build number. - **Do not wait forever.** If GitHub notarization or TestFlight processing exceeds its normal window by a lot, preserve state, retry only the failed phase when possible, or stop with a clear recovery command. +- **Do not serialize independent tiers.** Desktop GitHub Actions, iOS + TestFlight, and Cloudflare are independent once the shared docs/`ci-pass` + gate is behind you. Numbered phases are a catalog, not a queue. Follow + **Parallel schedule** below whenever more than one is in scope. + +## Parallel schedule + +This overrides the Phase 4 → 5 → 5.5 order when more than one tier is in +scope. Exclusive `gh run watch` is forbidden while another leg can make +progress — poll the run and keep working. + +Stay serial only for: + +1. Docs merge → green `ci-pass` on that SHA → desktop `v*` tag. Tagging + before `ci-pass` fails `verify`. +2. Draft asset verification → `--draft=false`. npm versions are immutable. +3. iOS IPA inspect / `altool --validate-app` → upload → `asc builds wait` → + group attach. +4. A Windows release host cannot archive iOS. Keep desktop and Cloudflare + in flight and say iOS needs a macOS host. Do not call the release done. + +### During the `ci-pass` wait (docs PR just landed) + +- Cloudflare: `deploy-web.yml` is already running for that SHA. Start + Phase 5.5 verification. Do not wait for a desktop tag. +- iOS: `asc doctor` and the App Clip preflight. Do not archive yet — a red + `ci-pass` would burn a build number. + +### After the desktop tag (or immediately if desktop is out of scope) + +Start every in-scope leg: + +- Desktop: poll `release.yml` until the draft exists. If iOS or Cloudflare + is also in scope, do **not** sit in `gh run watch`. +- iOS (macOS host): archive, export, inspect, validate, upload against + `RELEASE_SHA`. TestFlight does not need the GitHub draft. +- Cloudflare: finish any remaining reconcile or manual deploy. + +### Join before undraft + +If desktop is in scope, the draft must be verified before it goes public. +iOS may still be in `asc builds wait`. Cloudflare may still be verifying. +That is expected — do not stall undraft on them. + +### After undraft + +Poll `publish-runtime-packages.yml` and `update-brew-tap.yml` together and +join both. Keep polling iOS processing and Cloudflare if those legs are not +done. Desktop is not done until `npm view @ade-dev/runtime version` equals +the tag. + +### Join before `phase=done` + +Every in-scope leg is green. A green GitHub release with a missing npm +package, a missing TestFlight build, or a drifted Worker is not a finished +release. ## Machine Notes @@ -421,7 +477,10 @@ changelog. An SDK-docs-only commit does not get a `v*` tag. ## Phase 4: Desktop GitHub Workflow Release -Do this only if desktop scope is `yes`. +Do this only if desktop scope is `yes`. If iOS or Cloudflare is also in +scope, start those legs as soon as the tag exists — see **Parallel +schedule**. This phase is the desktop leg, not a barrier in front of the +others. The desktop happy path is GitHub Actions. Do not run local desktop release commands such as `release:mac:local`, `dist:mac:universal:signed`, @@ -473,7 +532,7 @@ gh run rerun "$RUN_ID" --repo arul28/ADE --failed The pushed tag triggers `.github/workflows/release.yml`, which calls `.github/workflows/release-core.yml` and creates a draft GitHub Release. -### Find and watch the workflow run +### Find and poll the workflow run Find the run for the pushed tag/SHA: @@ -484,13 +543,24 @@ gh run list --repo arul28/ADE --workflow release.yml --event push \ ``` Choose the run whose `headBranch` is `v` or whose `headSha` matches -`RELEASE_SHA`, then watch it: +`RELEASE_SHA`. + +If this is a desktop-only release, `gh run watch` is fine: ```bash gh run view "$RUN_ID" --repo arul28/ADE --json status,conclusion,url,jobs gh run watch "$RUN_ID" --repo arul28/ADE --interval 60 ``` +If iOS or Cloudflare is also in scope, poll instead and keep those legs +moving. `gh run watch` blocks the conductor for the whole notarization +window. + +```bash +gh run view "$RUN_ID" --repo arul28/ADE --json status,conclusion,url,jobs +# Poll on a timer between iOS/Cloudflare steps; do not exclusive-watch. +``` + Expected shape: - runtime/resource jobs run first @@ -503,7 +573,8 @@ Expected shape: draft - `update-brew-tap` and `Publish ADE runtime packages` both run after the GitHub release is made public (`release.published`), not when the draft is - created. The conductor waits for the runtime npm job after undraft. + created. After undraft, poll both runs together and join both. Do not + finish the npm wait before starting the brew wait, or the reverse. If `platforms=mac,win` and `build-win-release` did not run, stop. The gate and the run disagree, and publishing would ship a macOS-only release under a @@ -620,41 +691,59 @@ gh api repos/arul28/ADE/releases/latest \ ``` Undrafting publishes the GitHub release. That event starts -`publish-runtime-packages.yml`, which downloads this tag's runtime assets, -checksums them, and publishes `@ade-dev/runtime*` at the same version. npm +`publish-runtime-packages.yml` and `update-brew-tap.yml`. Poll both. npm versions are immutable, which is why this waits for `--draft=false` instead of -the tag push. Do not treat desktop release as done until that job succeeds. +the tag push. Desktop is not done until the npm job succeeds and +`npm view @ade-dev/runtime version` equals this tag. + +Do not exclusive-watch either run if iOS processing or Cloudflare is still +in flight — poll both GitHub jobs while those legs continue. ```bash -# The release event can take a few seconds to enqueue the run. +# The release event can take a few seconds to enqueue the runs. +find_run() { + local workflow="$1" + gh run list --repo arul28/ADE --workflow "$workflow" --event release \ + --json databaseId,headBranch,status,conclusion,url \ + --jq "[.[] | select(.headBranch==\"v\")][0].databaseId" +} for _ in 1 2 3 4 5 6; do - NPM_RUN_ID=$(gh run list --repo arul28/ADE \ - --workflow publish-runtime-packages.yml --event release \ - --json databaseId,headBranch,status,conclusion,url,createdAt \ - --jq "[.[] | select(.headBranch==\"v\")][0].databaseId") - [ -n "$NPM_RUN_ID" ] && [ "$NPM_RUN_ID" != "null" ] && break + NPM_RUN_ID=$(find_run publish-runtime-packages.yml) + BREW_RUN_ID=$(find_run update-brew-tap.yml) + [ -n "$NPM_RUN_ID" ] && [ "$NPM_RUN_ID" != "null" ] && \ + [ -n "$BREW_RUN_ID" ] && [ "$BREW_RUN_ID" != "null" ] && break sleep 10 done if [ -z "$NPM_RUN_ID" ] || [ "$NPM_RUN_ID" = "null" ]; then echo "Publish ADE runtime packages did not start for v" exit 1 fi -gh run watch "$NPM_RUN_ID" --repo arul28/ADE --interval 30 +# Poll both to completion. If this is desktop-only, watch is fine. +gh run watch "$NPM_RUN_ID" --repo arul28/ADE --interval 30 & +NPM_WATCH=$! +if [ -n "$BREW_RUN_ID" ] && [ "$BREW_RUN_ID" != "null" ]; then + gh run watch "$BREW_RUN_ID" --repo arul28/ADE --interval 30 & + BREW_WATCH=$! +fi +wait "$NPM_WATCH" +[ -n "${BREW_WATCH:-}" ] && wait "$BREW_WATCH" gh run view "$NPM_RUN_ID" --repo arul28/ADE --json conclusion,url npm view @ade-dev/runtime version npm view @ade-dev/runtime-darwin-arm64 version ``` -If the run fails because Trusted Publisher / `RUNTIME_TRUSTED_PUBLISHING` is +If the npm run fails because Trusted Publisher / `RUNTIME_TRUSTED_PUBLISHING` is not configured, that is a release blocker, not a skip. `workflow_dispatch` on the same workflow (tag + confirm `publish`) is recovery only. -The brew tap bump is the same `release.published` event. Watch it if it is -still in progress; do not block the npm wait on it. +A missing brew run is not a skip when `HOMEBREW_TAP_DEPLOY_KEY` is configured; +treat a failed tap bump as a desktop-leg failure. ## Phase 5: iOS TestFlight Build and Distribution -Do this only if iOS scope is `yes`. +Do this only if iOS scope is `yes`. Start it after the shared `ci-pass` + +tag gate (or immediately on an iOS-only release). Do **not** wait for the +desktop draft, undraft, npm publish, or brew tap — see **Parallel schedule**. Preflight: @@ -742,6 +831,11 @@ asc builds wait \ --timeout 40m ``` +When desktop is also in scope, do not park the whole conductor on this wait +if the IPA is already uploaded. Poll `asc builds list` until +`processingState=VALID` while undraft, npm, and brew proceed. `asc builds wait` +is the iOS-only happy path. + If automatic export fails due signing, use the repo's signing gotchas in `AGENTS.md` and the `asc-*` skills. Fix signing/profiles; do not silently remove targets. @@ -800,6 +894,10 @@ Every release reconciles it, or production silently drifts (this bit v1.2.28 and v1.2.29: a rewritten web client and two changed Workers sat undeployed while the desktop shipped). +Start this phase as soon as the docs PR (or any in-scope `main` push) lands. +`deploy-web.yml` runs on that push. Do not wait for the desktop tag, the +draft, npm, or TestFlight — see **Parallel schedule**. + **A Cloudflare surface has two halves, and only one of them is in git.** The code half is `wrangler.jsonc` plus `src/`. The account half — whether the R2 bucket exists, whether it has a lifecycle rule, whether a D1 migration was actually From 2058962a93fd779f2489c33460e684dd29933147 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:12:44 -0400 Subject: [PATCH 3/3] fix(sdk): fail closed on runtime pack and release watches Keep ADE Code in the packed runtime, and stop /release from treating a missing brew run or a failed npm version as success. Co-authored-by: Cursor --- .agents/skills/release/SKILL.md | 60 +++++++++++++++---- .../scripts/build-runtime-npm-packages.mjs | 10 ++++ .../build-runtime-npm-packages.test.mjs | 51 +++++++++++++++- .../verify-runtime-package-contents.test.mjs | 5 ++ 4 files changed, 114 insertions(+), 12 deletions(-) diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index a1a99de1b..5712452fc 100644 --- a/.agents/skills/release/SKILL.md +++ b/.agents/skills/release/SKILL.md @@ -697,9 +697,12 @@ the tag push. Desktop is not done until the npm job succeeds and `npm view @ade-dev/runtime version` equals this tag. Do not exclusive-watch either run if iOS processing or Cloudflare is still -in flight — poll both GitHub jobs while those legs continue. +in flight. Keep the run IDs and poll them between those legs. `gh run watch` +belongs only to a desktop-only release, or to the join after the other legs +have finished. ```bash +set -euo pipefail # The release event can take a few seconds to enqueue the runs. find_run() { local workflow="$1" @@ -707,29 +710,64 @@ find_run() { --json databaseId,headBranch,status,conclusion,url \ --jq "[.[] | select(.headBranch==\"v\")][0].databaseId" } +run_id_ready() { + [ -n "${1:-}" ] && [ "$1" != "null" ] +} +BREW_EXPECTED=0 +if [ "$(gh secret list --repo arul28/ADE --json name --jq 'any(.[]; .name == "HOMEBREW_TAP_DEPLOY_KEY")')" = "true" ]; then + BREW_EXPECTED=1 +fi for _ in 1 2 3 4 5 6; do NPM_RUN_ID=$(find_run publish-runtime-packages.yml) BREW_RUN_ID=$(find_run update-brew-tap.yml) - [ -n "$NPM_RUN_ID" ] && [ "$NPM_RUN_ID" != "null" ] && \ - [ -n "$BREW_RUN_ID" ] && [ "$BREW_RUN_ID" != "null" ] && break + if run_id_ready "$NPM_RUN_ID"; then + if [ "$BREW_EXPECTED" != "1" ] || run_id_ready "$BREW_RUN_ID"; then + break + fi + fi sleep 10 done -if [ -z "$NPM_RUN_ID" ] || [ "$NPM_RUN_ID" = "null" ]; then +if ! run_id_ready "${NPM_RUN_ID:-}"; then echo "Publish ADE runtime packages did not start for v" exit 1 fi -# Poll both to completion. If this is desktop-only, watch is fine. -gh run watch "$NPM_RUN_ID" --repo arul28/ADE --interval 30 & +if [ "$BREW_EXPECTED" = "1" ] && ! run_id_ready "${BREW_RUN_ID:-}"; then + echo "update-brew-tap did not start for v" + exit 1 +fi +``` + +While iOS or Cloudflare is still in flight, poll — do not `wait` yet: + +```bash +gh run view "$NPM_RUN_ID" --repo arul28/ADE --json status,conclusion,url +if [ -n "${BREW_RUN_ID:-}" ] && [ "$BREW_RUN_ID" != "null" ]; then + gh run view "$BREW_RUN_ID" --repo arul28/ADE --json status,conclusion,url +fi +``` + +Desktop-only, or joining after the other legs finished. Watch both in +parallel so neither wait starts only after the other has already completed: + +```bash +gh run watch "$NPM_RUN_ID" --repo arul28/ADE --interval 30 --exit-status & NPM_WATCH=$! -if [ -n "$BREW_RUN_ID" ] && [ "$BREW_RUN_ID" != "null" ]; then - gh run watch "$BREW_RUN_ID" --repo arul28/ADE --interval 30 & +if [ -n "${BREW_RUN_ID:-}" ] && [ "$BREW_RUN_ID" != "null" ]; then + gh run watch "$BREW_RUN_ID" --repo arul28/ADE --interval 30 --exit-status & BREW_WATCH=$! fi wait "$NPM_WATCH" [ -n "${BREW_WATCH:-}" ] && wait "$BREW_WATCH" -gh run view "$NPM_RUN_ID" --repo arul28/ADE --json conclusion,url -npm view @ade-dev/runtime version -npm view @ade-dev/runtime-darwin-arm64 version +gh run view "$NPM_RUN_ID" --repo arul28/ADE --json conclusion,url --exit-status +if [ -n "${BREW_RUN_ID:-}" ] && [ "$BREW_RUN_ID" != "null" ]; then + gh run view "$BREW_RUN_ID" --repo arul28/ADE --json conclusion,url --exit-status +fi +RUNTIME_VERSION=$(npm view @ade-dev/runtime version) +DARWIN_VERSION=$(npm view @ade-dev/runtime-darwin-arm64 version) +if [ "$RUNTIME_VERSION" != "" ] || [ "$DARWIN_VERSION" != "" ]; then + echo "expected @ade-dev/runtime@ and @ade-dev/runtime-darwin-arm64@, got $RUNTIME_VERSION / $DARWIN_VERSION" + exit 1 +fi ``` If the npm run fails because Trusted Publisher / `RUNTIME_TRUSTED_PUBLISHING` is diff --git a/apps/ade-cli/scripts/build-runtime-npm-packages.mjs b/apps/ade-cli/scripts/build-runtime-npm-packages.mjs index 6bb8283f1..c34faa974 100644 --- a/apps/ade-cli/scripts/build-runtime-npm-packages.mjs +++ b/apps/ade-cli/scripts/build-runtime-npm-packages.mjs @@ -342,6 +342,7 @@ function isRuntimeCriticalPackedFile(relativePath) { if (relativePath.startsWith("bin/")) return true; if (relativePath.startsWith("native/vendor/")) return true; if (relativePath === "native/manifest.json") return true; + if (relativePath === "native/tuiClient/cli.mjs") return true; return relativePath.endsWith(".node"); } @@ -506,6 +507,15 @@ export function verifyPackedRuntimeFiles({ packageDir, runPack = defaultPackRunn `${packedFiles.filter((file) => file.startsWith("native/vendor/crsqlite/")).join(", ") || "nothing under native/vendor/crsqlite/"}.`, ); } + // ADE Code loads this module from ADE_RUNTIME_ROOT. The launcher and + // native-module checks do not mention it, so a packlist omit still published. + if (!packedFiles.includes("native/tuiClient/cli.mjs")) { + throw new Error( + `${packageDir}: the packed tarball carries no native/tuiClient/cli.mjs. runAdeCode() ` + + `imports that module from ADE_RUNTIME_ROOT, so the package would install and then fail ` + + `the first \`ade code\` invocation.`, + ); + } for (const required of ["LICENSE", EXCEPTION_FILE_NAME, "README.md", "package.json"]) { if (!packed.has(required)) { throw new Error( diff --git a/apps/ade-cli/scripts/build-runtime-npm-packages.test.mjs b/apps/ade-cli/scripts/build-runtime-npm-packages.test.mjs index 84dd5ce79..c1fdbebd5 100644 --- a/apps/ade-cli/scripts/build-runtime-npm-packages.test.mjs +++ b/apps/ade-cli/scripts/build-runtime-npm-packages.test.mjs @@ -23,6 +23,8 @@ import { verifyPackedRuntimeFiles, } from "./build-runtime-npm-packages.mjs"; +const ADE_CODE_MODULE = "native/tuiClient/cli.mjs"; + /** * The cr-sqlite extension that target's loader can `dlopen`. * @@ -55,6 +57,11 @@ function writeFakeArtifacts(dir, target) { ); fs.mkdirSync(path.join(staging, "vendor", "crsqlite", target), { recursive: true }); fs.writeFileSync(path.join(staging, "vendor", "crsqlite", target, crsqliteName(target)), "binary"); + fs.mkdirSync(path.join(staging, "tuiClient"), { recursive: true }); + fs.writeFileSync( + path.join(staging, "tuiClient", "cli.mjs"), + "export async function runAdeCodeCli() { return 0; }\n", + ); execFileSync("tar", ["-czf", path.join(dir, archiveAsset), "-C", staging, "."], { windowsHide: true, }); @@ -92,6 +99,7 @@ test("builds the documented platform-package layout", () => { "bin/ade", "native/node_modules/better-sqlite3/index.js", "native/vendor/crsqlite/linux-x64/crsqlite.so", + ADE_CODE_MODULE, "LICENSE", "RUNTIME-EMBEDDING-EXCEPTION.md", "README.md", @@ -171,8 +179,12 @@ test("publish workflow runs when a GitHub release is published, not only on disp new URL("../../../.github/workflows/publish-runtime-packages.yml", import.meta.url), "utf8", ); + const collapsed = workflow.replace(/\s+/g, " "); assert.match(workflow, /\n release:\n types: \[published\]/); - assert.match(workflow, /github\.event_name == 'release'/); + assert.match( + collapsed, + /\(github\.event_name == 'release' && !github\.event\.release\.prerelease\) \|\| \(github\.event_name == 'workflow_dispatch' && inputs\.confirm == 'publish'\)/, + ); assert.match(workflow, /github\.event\.release\.tag_name/); assert.match(workflow, /refusing to skip a runtime npm publish/); assert.doesNotMatch(workflow, /MANUAL ONLY/); @@ -302,11 +314,42 @@ test("does not fail the build when npm-packlist omits an ignore file", () => { "bin/ade", "native/node_modules/better-sqlite3/index.js", "native/vendor/crsqlite/linux-x64/crsqlite.so", + ADE_CODE_MODULE, ]), }); }); }); +test("fails the build when the packed tarball drops ADE Code", () => { + withTempDirs(({ artifacts, out }) => { + writeFakeArtifacts(artifacts, "linux-x64"); + const packageDir = buildRuntimePackage({ + target: "linux-x64", + artifactsDir: artifacts, + outDir: out, + version: "1.2.3", + license: "L", + exception: "EXCEPTION TEXT", + }); + assert.throws( + () => + verifyPackedRuntimeFiles({ + packageDir, + runPack: packedListing([ + "package.json", + "LICENSE", + "RUNTIME-EMBEDDING-EXCEPTION.md", + "README.md", + "bin/ade", + "native/node_modules/better-sqlite3/index.js", + "native/vendor/crsqlite/linux-x64/crsqlite.so", + ]), + }), + /native\/tuiClient\/cli\.mjs/, + ); + }); +}); + /** * The archive the empty/truncated-release case produces: a `node_modules` * directory and nothing inside it. @@ -394,6 +437,7 @@ test("fails the build when the packed tarball carries no launcher", () => { "LICENSE", "RUNTIME-EMBEDDING-EXCEPTION.md", "README.md", + ADE_CODE_MODULE, "native/node_modules/better-sqlite3/index.js", "native/vendor/crsqlite/linux-x64/crsqlite.so", ]), @@ -431,6 +475,7 @@ test("fails the build when the packed tarball carries no cr-sqlite extension", ( "README.md", "bin/ade", "native/node_modules/better-sqlite3/index.js", + ADE_CODE_MODULE, ]), }), /carries no native\/vendor\/crsqlite\/linux-x64\/crsqlite\.so/, @@ -460,6 +505,7 @@ test("accepts the cr-sqlite extension at the exact path its target implies", () "bin/ade", "native/node_modules/better-sqlite3/index.js", "native/vendor/crsqlite/linux-x64/crsqlite.so", + ADE_CODE_MODULE, ]), }); }); @@ -498,6 +544,7 @@ test("refuses another platform's cr-sqlite extension in a target's own directory "bin/ade.exe", "native/node_modules/better-sqlite3/index.js", "native/vendor/crsqlite/win32-x64/crsqlite.so", + ADE_CODE_MODULE, ]), }), /carries no native\/vendor\/crsqlite\/win32-x64\/crsqlite\.dll/, @@ -552,6 +599,7 @@ test("requires the launcher name the target implies, not either one", () => { "README.md", "native/node_modules/better-sqlite3/index.js", "native/vendor/crsqlite/win32-x64/crsqlite.dll", + ADE_CODE_MODULE, ]; assert.throws( () => verifyPackedRuntimeFiles({ packageDir, runPack: packedListing([...listing, "bin/ade"]) }), @@ -685,6 +733,7 @@ test("fails the build when the packed tarball drops LICENSE, the exception, or R "bin/ade", "native/node_modules/better-sqlite3/index.js", "native/vendor/crsqlite/linux-x64/crsqlite.so", + ADE_CODE_MODULE, ]; assert.throws( () => diff --git a/apps/ade-cli/scripts/verify-runtime-package-contents.test.mjs b/apps/ade-cli/scripts/verify-runtime-package-contents.test.mjs index 6c8e2d355..c2d9ef513 100644 --- a/apps/ade-cli/scripts/verify-runtime-package-contents.test.mjs +++ b/apps/ade-cli/scripts/verify-runtime-package-contents.test.mjs @@ -41,6 +41,11 @@ function writeFakeArtifacts(dir, target) { fs.writeFileSync(path.join(staging, "node_modules", "better-sqlite3", "index.js"), "module.exports = {};\n"); fs.mkdirSync(path.join(staging, "vendor", "crsqlite", target), { recursive: true }); fs.writeFileSync(path.join(staging, "vendor", "crsqlite", target, crsqliteName(target)), "binary"); + fs.mkdirSync(path.join(staging, "tuiClient"), { recursive: true }); + fs.writeFileSync( + path.join(staging, "tuiClient", "cli.mjs"), + "export async function runAdeCodeCli() { return 0; }\n", + ); // `execFileSync`, not `spawnSync`: a missing or failing `tar` must throw // here. Discarding the result left an absent archive and failed the test // later with an unrelated message.