Installs into .claude/skills of the current project.
Are you the author of Deploy?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/microsoft-deploy-power-platform-skills)
---
name: deploy
description: Use to deploy, publish, or push an Expo/React Native Power Apps mobile app to a Power Platform tenant.
user-invocable: true
allowed-tools: Read, Glob, Bash, AskUserQuestion, Skill
model: sonnet
---
> **Plugin check**: Run `node "${PLUGIN_ROOT}/scripts/check-version.js"` - if it outputs a message, show it to the user before proceeding.
**π Shared instructions: [shared-instructions.md](${PLUGIN_ROOT}/shared/shared-instructions.md)** β read first.
# Deploy
Builds the mobile app in the current directory and pushes it to the Power Platform environment recorded in `power.config.json`.
This skill uses the standard 4-step deployment flow for this plugin: check memory bank, build, deploy, then update memory bank.
## Out of scope (deliberately)
- `expo run:ios` / `expo run:android` β local native compile is the user's choice; run your platform-specific native command directly when ready.
- OTA updates and store distribution β out of scope for v0.
- Starting Metro for local dev β created apps use `npm run dev`; template Metro config writes `.powernative` logs for `/debug-app`.
## Workflow
1. Check memory bank β 1.5 App ID preflight β 2. Build β 2.4 Native package β 2.5 Offline profile coverage gate β 3. Deploy β 4. Update memory bank
---
### Step 1 β Check memory bank
Read `memory-bank.md` from the project root if present. Capture:
- Project name
- Environment (id + display name)
- Current version
If absent, continue β the project may have been created without the plugin. Re-derive env from `power.config.json` if needed.
### Step 1.5 β App ID preflight (first-deploy gate)
Read `power.config.json` **before building anything**:
```bash
node -e "const c=require('./power.config.json');console.log(c.appId||'MISSING')"
```
- **Prints a GUID** β normal path. Continue to Step 2.
- **Prints `MISSING`** (null, absent, or empty) β this is a **first deploy**. Say so plainly *before* doing any work:
> "β οΈ First deploy detected β `power.config.json` has no `appId` yet. The app ID is minted by the first push, but it is compiled **into** the native bundle at build time. So two full build+push cycles are required. I'll run both; the second is not optional."
Then run Steps 2 β 2.4 β 2.5 β 3 **twice**. In cycle 2, `npm run build` *and* the Step 2.4 native packaging commands must all re-run β the Hermes bundles from cycle 1 have an empty app ID compiled in. Step 2.5 (offline profile gate) may be skipped on cycle 2 **only if** no schema or profile file changed between the two cycles; if in doubt, re-run it β it is a local, no-network check.
**Why two cycles are unavoidable.** `power-apps push` mints the app ID and writes it back to `power.config.json`, but it refuses to run at all without an existing build β it fails immediately if the configured `buildPath` (`./dist`) is absent. So the ID cannot be minted before the first build, and the first build cannot contain the ID.
**Why this is so easy to miss.** The runtime guard is:
```js
Platform.OS !== 'web' && !isDevPlayer && !hasConfiguredValue(powerConfig.appId)
```
Web is **exempt**, and so is Dev Player. The Code App, `npm run dev`, and the browser preview all look perfectly healthy. The failure appears only in the **wrapped native app**, as a full-screen red *"App ID is missing β Push the mobile app to the Power Platform environment, rebuild it, and try again."* That is after a base-package wrap, a signed build, and a device install β the most expensive possible place to discover a one-line config gap.
### Step 2 β Build
**Telemetry checkpoint: `build_power_apps_bundle`**
**Print before starting:**
> "β Building production web bundle via `npm run build` (= `expo export --platform web`). ~30β90 seconds."
First regenerate `connectorSchemas.ts` so `app/_layout.tsx`'s `schemaMap` import reflects every connector currently in `.power/schemas/`. The npm `prestart`/`preandroid`/`preios` hooks cover dev runs, but `npm run build` does **not** β if a connector was added since the last `npm run dev`, the bundled JS would ship a stale schema map. Always regenerate before build:
```bash
npm run generate-schemas
npm run build
```
If `package.json` has no `build` script, fall back to:
```bash
npx expo export --platform web
```
(The current template does not define a `build` script, so this fallback is the normal path for freshly scaffolded apps. Both forms produce the same `dist/` web output.)
**Known issue β `expo export --platform web` never exits.** The export finishes its work (writes `dist/`, prints `Exported: dist` and the asset count) and then **hangs indefinitely**. Reproduced deterministically across separate runs; observed still alive 2h34m after completing. `dist/` is complete and correct when this happens. Suspected cause: the Metro config returned by `createPowerAppsMetroConfig` (`metro.config.js`) installs a dev-server middleware internally, which appears to hold an open handle β a web *export* should not need a dev server. Note the template itself only calls `createPowerAppsMetroConfig`; the middleware is applied inside `@microsoft/power-apps-native-host`, not in app code. **Not yet root-caused.**
**Do not wait on the process.** Run it detached and poll for the artifact. Per shared-instructions, scratch files stay project-local in `.tmp/` β a fixed `/tmp/` path would collide across concurrent projects, and a stale log there could satisfy the grep below and falsely report success:
```bash
mkdir -p .tmp
rm -f .tmp/expo-web-export.log
npx expo export --platform web > .tmp/expo-web-export.log 2>&1 &
EXPORT_PID=$!
for _ in $(seq 1 90); do
grep -q "Exported: dist" .tmp/expo-web-export.log 2>/dev/null && break
sleep 2
done
if ! grep -q "Exported: dist" .tmp/expo-web-export.log 2>/dev/null; then
echo "web export did not complete in 180s"; tail -30 .tmp/expo-web-export.log; exit 1
fi
test -f dist/index.html || { echo "dist/index.html missing"; exit 1; }
kill "$EXPORT_PID" 2>/dev/null || true
echo "β web export complete (process terminated manually β known hang)"
```
Treat a completed `dist/` as success even though the process had to be killed. The Step 2.4 native packaging commands are **not** affected β both exit 0 cleanly and stage into `dist/` via a temp dir, so they do not clear the web build.
If the build fails:
- **`TS6133` (unused import)** β remove the import and retry once.
- **Other TypeScript errors** β report file + line and STOP. Don't deploy a broken build.
- **Metro bundler errors** β surface the full stack and STOP.
Verify `dist/` exists with `index.html` before continuing.
### Step 2.4 β Native package (Hermes bundle + customer assets)
**Print before starting:**
> "β Compiling the native Hermes bundle and hash-addressed asset package for iOS and Android. No JavaScript is compiled inside the wrap pipeline β it only consumes these prebuilt files. ~1β3 minutes."
**Node version gate (required).** The native export crashes on **Node < 20.19.4** β it hits `util.styleText(['yellow','inverse','bold'], β¦)`, which older Node rejects, failing the Metro bundle with a cryptic `ERR_INVALID_ARG_VALUE`. Check first:
```bash
node -e 'const [M,m,p]=process.versions.node.split(".").map(Number); const ok = M>20 || (M===20 && (m>19 || (m===19 && p>=4))); if (!ok) { console.error(`Node ${process.versions.node} is too old; need >= 20.19.4`); process.exit(1); } console.log(`β Node ${process.versions.node}`);'
```
If it exits non-zero, STOP and tell the user to switch (`nvm use 20.19.4`, or install Node β₯ 20.19.4) and rerun. Do **not** run the native packaging commands on older Node.
The web build above produces `dist/index.html` (the hosted Code App). Native **wrapped** apps additionally need a precompiled Hermes bundle **and** the customer's images/fonts as hash-addressed asset files, so the wrap pipeline never compiles or downloads JavaScript. Produce both platforms:
```bash
npm run bundle:android
npm run bundle:ios
```
Each command produces that platform's native Hermes bundle **and** its customer asset package, writing next to `dist/index.html`:
- **Android:** `dist/index.android.bundle.hbc` (Hermes bytecode) + `dist/powerapps-customer-assets-android/` (`manifest.json` + `assets/<fileHash>.<type>`)
- **iOS:** `dist/main.jsbundle.hbc` (Hermes bytecode) + `dist/powerapps-customer-assets-ios/` (`manifest.json` + `assets/<fileHash>.<type>`)
Both platforms are required β the verification below fails if either bundle or either manifest is missing.
These sit alongside `index.html` under the same container SAS, so the wrap pipeline fetches them as siblings β no RP or connector change is required.
**Verify before continuing** β STOP on any failure (never push a web-only build for a native-wrapped app):
```bash
# Hermes magic bytes on both bundles (expect c61fbc03)
for f in dist/index.android.bundle.hbc dist/main.jsbundle.hbc; do
test -f "$f" || { echo "MISSING $f"; exit 1; }
node -e 'const fs=require("fs"),b=Buffer.alloc(4),fd=fs.openSync(process.argv[1],"r");fs.readSync(fd,b,0,4,0);fs.closeSync(fd);process.exit(b.toString("hex")==="c61fbc03"?0:1)' "$f" || { echo "$f is not Hermes bytecode"; exit 1; }
done
# both asset manifests present
test -f dist/powerapps-customer-assets-android/manifest.json || { echo "MISSING android manifest"; exit 1; }
test -f dist/powerapps-customer-assets-ios/manifest.json || { echo "MISSING ios manifest"; exit 1; }
echo "β native package + asset manifests present"
```
If a native packaging step fails, surface the error and STOP. If the app renders bundled images/fonts, also confirm each `manifest.json` `assets` array is non-empty (an empty array means the app doesn't `require()` any static asset yet).
### Step 2.5 β Offline profile coverage gate
**Telemetry checkpoint: `validate_offline_profile_coverage`**
This is the final chance to catch schema that never made it into the Mobile Offline Profile before it ships β a table added to the data model but not the profile never syncs to devices, and a new column arrives blank offline. Validate that every schema change is covered **before** pushing.
Run the local, no-network delta check (`.datamodel-manifest.json` vs `offline-profile.json`):
```bash
node "${PLUGIN_ROOT}/scripts/offline-profile-delta.js"
```
Branch on the JSON `status` (full contract in [offline-profile-reconciliation.md](${PLUGIN_ROOT}/shared/references/offline-profile-reconciliation.md)):
| `status` | Action |
|---|---|
| `no-manifest` | Connectors-only app β no Dataverse schema. Continue to Step 3 silently. |
| `no-profile` | No offline profile in this project. Print one line: `β· No offline profile β skipping offline coverage check. Run /setup-offline-profile if you want offline support.` Continue to Step 3. |
| `in-sync` | Print `β Offline profile covers all schema changes.` Continue to Step 3. |
| `error` | `offline-profile.json` is unreadable β the script prints `status: error` and **exits non-zero**. Offline coverage can't be validated against a corrupt file, so **STOP before pushing**: surface the `error` string and have the user fix `offline-profile.json` and re-run, or type the `deploy without offline` override (below) to push anyway. |
| `delta` | **STOP before pushing.** See below. |
**On `delta`** β print the uncovered schema, then gate with `AskUserQuestion`:
```
β The offline profile is missing schema changes. If you deploy now, these won't be
available on disconnected devices:
Tables not in the profile : <missingTables[].logicalName>
Tables with new columns : <tablesWithNewColumns[].logicalName (newColumns)>
```
Options:
- **Update the offline profile now (recommended)** β read and execute `${PLUGIN_ROOT}/skills/add-table-to-offline-profile/SKILL.md` for each `missingTables[]` entry (or once with `--all-new`), then read and execute `${PLUGIN_ROOT}/skills/edit-offline-profile/SKILL.md` with `--table <t> --columns add:<newColumns>` for each `tablesWithNewColumns[]` entry. Follow the ordering in the reconciliation reference, then re-run the delta check; when it reports `in-sync`, continue to Step 3.
- **Deploy anyway** β requires an explicit override. Wait for the exact phrase `deploy without offline` (case-insensitive); a bare `y`/`yes` is not enough, mirroring the environment-mismatch gate in Step 3. Then continue to Step 3 and note the skipped reconciliation in the Step 4 build-history row.
Do not push until the gate is resolved (reconciled to `in-sync`, or explicitly overridden).
### Step 3 β Deploy
**Telemetry checkpoint: `push_app_to_power_platform`**
**Resolve and confirm the target environment FIRST.** `npx power-apps push` deploys to the environment configured in `power.config.json`. Resolve that ID to a Dataverse URL so the user catches drift before pushing.
Run:
```bash
ENV_ID=$(node -e "console.log(require('./power.config.json').environmentId)")
node "${PLUGIN_ROOT}/scripts/resolve-environment.js" "$ENV_ID"
```
From `resolve-environment.js` capture the **Environment URL** (e.g. `https://contoso.crm.dynamics.com/`), **Environment ID**, and **Tenant ID**. Cross-check against `memory-bank.md` / `power.config.json`:
- **Match** β proceed to the confirmation prompt below.
- **Mismatch** β STOP. Surface both values side-by-side and ask the user to either (a) update `power.config.json` by re-running init in the intended app root, or (b) explicitly type `override` to push to the environment already recorded in `power.config.json`. Do not proceed on a bare `y`.
- **Cannot resolve/authenticate** β STOP with `az login --tenant <env-tenant>` instructions, or ask the user to provide the environment URL directly.
**Print before starting:**
> "β Pushing bundle to Power Platform via `npx power-apps push`. ~30β60 seconds."
Confirm with the user using the **resolved env URL, not just the friendly name**:
> "Ready to deploy to **<env-name>** (`<env-url>`)? This will update the live app for every user in that environment. Type `yes deploy to <env-name>` to confirm."
Wait for the exact phrase `yes deploy to <env-name>` (case-insensitive, env-name matching). A bare `y` / `yes` is not enough β too easy to fire on autopilot when the wrong env is active. Then:
```bash
npx power-apps push --non-interactive
```
Capture the app URL from the output if printed.
**First-deploy loop-back.** If Step 1.5 reported `MISSING`, re-read the config now:
```bash
node -e "const c=require('./power.config.json');console.log(c.appId||'STILL MISSING')"
```
- **GUID** β the app was registered. **Go back to Step 2 and run Build β 2.4 β 2.5 β Deploy one more time.** The artifacts now sitting in `dist/` (and already uploaded to the blob) still have an empty app ID compiled in; without the second cycle the wrapped app fails on-device. Step 2.5 may be skipped on this second pass **only if** nothing under `.datamodel-manifest.json` / `offline-profile.json` changed since cycle 1.
- **`STILL MISSING`** β push did not register the app. STOP and report. Do not proceed to wrap.
On the second pass this check is a no-op, and Step 4 runs as normal.
If deploy fails, report the error and STOP β do not retry silently. Common fixes:
| Error | Fix |
|---|---|
| `npx power-apps push` auth error, wrong user, or multiple accounts | Follow shared-instructions command-failure handling. `az login` / `az account set` does not switch the standalone Power Apps CLI account. |
| Environment mismatch | Re-run `npx power-apps init -t MobileApp --display-name <name> --environment-id <id> --non-interactive` in a fresh/app root for the intended target|
| `npx power-apps push` not recognised | Run `npm install` in the project so `@microsoft/power-apps` provides the CLI, or install `@microsoft/power-apps-cli` only as a last-resort prerequisite after user confirmation. |
### Step 4 β Update memory bank
If `memory-bank.md` exists, increment the version (`v1.0.0` β `v1.1.0`) and update:
- Current version
- Last deployed timestamp
- App URL (if captured)
- Append a row to the **Build history** section: `| v1.1.0 | <timestamp> | deploy | success |`
Print the summary card:
```
β Deploy β <project-name>
βββββββββββββββββββββββββββββββββββββββββββββ
Version : <new-version>
Environment : <env-name>
App URL : <url or "see make.powerapps.com">
Bundle path : dist/
Local dev: npm run dev (writes .powernative logs for /debug-app)
Re-deploy: /deploy
List conns: /list-connections
βββββββββββββββββββββββββββββββββββββββββββββ
```
---
## Local dev (out of scope for this skill β for reference only)
When the user wants normal Expo iteration with portable monitoring, they can run:
```bash
npm run dev # Metro + QR + .powernative log
```
This launches Metro, prints a QR code, and writes sanitized output to `.powernative/metro-logs/` so `/debug-app` can reattach after a host/session restart. They can:
- Scan the QR with the installed native dev client
- Reload from the native dev-client menu
Do not use React Native Web, browser automation, direct Metro/localhost HTTP probes, or screen-by-screen runtime checks.
If they want to compile a native binary locally, they run the platform-specific native command directly. Local native compile and manual device testing are user-owned and are not deployment gates for this skill.
## Reference
- [`shared/version-check.md`](${PLUGIN_ROOT}/shared/version-check.md) β min versions (only Always-required tier matters here)
- [`shared/memory-bank.md`](${PLUGIN_ROOT}/shared/memory-bank.md) β Build history schema
- [`shared/references/offline-profile-reconciliation.md`](${PLUGIN_ROOT}/shared/references/offline-profile-reconciliation.md) β Step 2.5 offline coverage gate