diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md index 0b8e0aa57..5712452fc 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 @@ -269,7 +325,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 @@ -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 @@ -501,7 +571,10 @@ 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. 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 @@ -617,9 +690,98 @@ 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` and `update-brew-tap.yml`. Poll both. npm +versions are immutable, which is why this waits for `--draft=false` instead of +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. 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" + gh run list --repo arul28/ADE --workflow "$workflow" --event release \ + --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) + 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 ! run_id_ready "${NPM_RUN_ID:-}"; then + echo "Publish ADE runtime packages did not start for v" + exit 1 +fi +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 --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 --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 +not configured, that is a release blocker, not a skip. `workflow_dispatch` on +the same workflow (tag + confirm `publish`) is recovery only. + +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: @@ -707,6 +869,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. @@ -765,6 +932,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 @@ -1272,8 +1443,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..c34faa974 100644 --- a/apps/ade-cli/scripts/build-runtime-npm-packages.mjs +++ b/apps/ade-cli/scripts/build-runtime-npm-packages.mjs @@ -332,6 +332,20 @@ 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; + if (relativePath === "native/tuiClient/cli.mjs") 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 +457,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 +490,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( @@ -493,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 a17cd68c1..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", @@ -166,6 +174,22 @@ 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", + ); + const collapsed = workflow.replace(/\s+/g, " "); + assert.match(workflow, /\n release:\n types: \[published\]/); + 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/); +}); + test("names the Windows binary ade.exe", () => { withTempDirs(({ artifacts, out }) => { writeFakeArtifacts(artifacts, "win32-x64"); @@ -263,6 +287,69 @@ 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", + 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. @@ -350,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", ]), @@ -362,8 +450,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({ @@ -386,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/, @@ -415,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, ]), }); }); @@ -422,7 +513,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 }) => { @@ -453,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/, @@ -507,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"]) }), @@ -640,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. 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