EAS Workflows 实战指南:从 0 到 1 搭建 Expo 应用商店发布 CI/CD 流水线
【免费下载链接】skillsA collection of AI agent skills for working with Expo projects and Expo Application Services项目地址: https://gitcode.com/GitHub_Trending/skills9/skills
如果每次发版你还靠在本地手敲eas build和eas submit,这套流程慢、难复现、也很难交接给同事。本文以 EAS Workflows 为主线,从 0 到 1 带出一套可版本化、可复现的 Expo 应用商店发布 CI/CD 流水线:先跑通推送即构建,再叠加 PR 预览与双平台生产发布,最后用服务端校验和凭据托管收尾,读完后你可以独立写出并验证一条面向 App Store / Google Play 的发布流水线。
EAS Workflows 能自动化什么、不能自动化什么:先定边界
写 YAML 之前先把三条边界钉死,比背字段省心得多。主技能文件把它的定位说得很干脆:EAS Workflows 是 "the build → submit → update pipeline" 的自动化层,见 eas-app-stores/SKILL.md。
它是编排层,不是新的构建系统。type: build作业仍然调用 EAS Build,type: submit仍然调用 EAS Submit,profile字段指向eas.json里已存在的构建/提交配置。工作流只负责三件事:顺序、触发条件、并行关系。
它不包办所有场景。workflows.md 开头就划好了分工:需要写、改或校验超出示例范围的 workflow YAML 时改用eas-workflows技能;网站与 API 路由的部署工作流(type: deploy)归eas-hosting技能管。
"成功"只到 EAS 服务为止。技能原文有一句值得逐字记住:
A successful EAS build or a queued submission does not establish Apple acceptance, tester access, or an App Store release. (EAS 构建成功或提交已排队,都不代表 Apple 已接受、测试者可获得访问、或 App Store 发布生效。)
这句话写 YAML 时同样成立:submit作业变绿,只说明上传被排队了。这一点后文"别把作业成功当已上架"会展开。
计费与账号前提(触发前先看一眼)
EAS Workflows 跑在 Expo Application Services 上,属于计费产品:
- 每个作业消耗所属计划的 build/compute minutes;
- 执行构建或提交的作业,还需要付费的 Apple Developer 与 Google Play 开发者账号;
- 触发前建议先确认所请求服务的免费额度与付费限制。
维护这个仓库的同学还会注意到,scripts/check-skill-limits.ts 会在 CI 中强制校验所有eas-*技能在 H1 之后紧接成本声明——所以每个eas-开头的技能正文都以成本提示开场。这是仓库约束,不是写作风格。
另外,工作流跑在 EAS 服务端,读不到你本地的隐式状态。按 expo-overview 的共享约定,两个"连接状态"必须提前就位:
- 登录态:用
eas whoami检查、eas login建立; - 项目已链接:应用配置中存在
extra.eas.projectId;缺失时用eas init创建。
动笔前固定的格式底线
以下是纯机械性约束,违反了连语法校验都过不去,快速过一遍即可。
文件位置与体积:工作流放在.eas/workflows/*.yml(或.yaml),每个文件不得超过 16 KiB。仓库示例都遵循这一约定,例如 ios-app-store.md 与 app-store-metadata.md 中的片段均标注为.eas/workflows/release.yml。
顶层键一共五个:
| 顶层键 | 作用 | 是否必需 |
|---|---|---|
name | 工作流展示名称 | 示例均提供 |
on | 触发条件 | 至少一个 |
jobs | 作业定义 | 必需 |
defaults | 所有作业共享的默认值 | 可选 |
concurrency | 控制并行运行 | 可选 |
动态值统一用${{ }}语法,schema 定义了github.*、inputs.*、needs.*、jobs.*、steps.*、workflow.*六族上下文。本文四个发布场景实际只用到其中三种:${{ github.event.pull_request.number }}、needs.<job>、needs.<job>.outputs.*。
eas-workflows技能反复提醒:每个分区的完整规范应以 workflow schema 为准,别凭记忆填字段。
第一步:先跑通"推送即构建"
别用复杂场景开局。最简单的起点是"推送到指定分支就构建":
# .eas/workflows/build.yml name: Build on: push: branches: - main - release/* jobs: build: type: build params: platform: all profile: production出处见 workflows.md。这里有两个新值:
platform: all—— 一个作业覆盖双平台,代价是失去按平台单独重试、单独诊断的粒度(生产发布再讲拆分写法)。release/*通配—— 所有发布分支共享同一条流水线,不必逐个枚举。
这个示例刻意不带submit:典型用途是"每次合并主干都产出一个可安装验证的产物",把提交动作留给人工决策或后面的 tag 流水线。
跑通这一步后先验证两件事:eas.json里的productionprofile 能被作业解析、产物确实能下载。这两点通过后,再往复杂场景走。
第二步:加一条 PR 预览分支——为什么是 type: update
同样是"PR 预览",Web 与原生是两条完全不同的路,别混用:
- 网站与 API 路由走
type: deploy搭配prod: false,目标是 EAS Hosting(Cloudflare Workers),见 eas-hosting/SKILL.md; - 原生 App 走
type: update,OTA 发布 JS bundle 与资源,不产生新的原生二进制。
PR 预览示例:
# .eas/workflows/pr-preview.yml name: PR Preview on: pull_request: types: [opened, synchronize] jobs: publish: type: update params: branch: "pr-${{ github.event.pull_request.number }}" message: "PR #${{ github.event.pull_request.number }}"为什么 update 就够用:PR 预览改的通常是 JS 与样式,正好落在 OTA 能力范围内。eas-update 技能给出的模型是——build是装到设备上的原生 App,update是面向某平台与 runtime version 的 JS/资源包,branch是有序的更新流,channel是构建时嵌入的部署目标。一旦改动涉及原生代码或原生配置,OTA 就兜不住了,必须重新构建。这也解释了为什么下节的生产发布用build+submit而不是update。
一行值得抄的写法:branch: "pr-${{ github.event.pull_request.number }}"让每个 PR 拥有独立更新分支、互不覆盖;message里带上 PR 号,之后在 EAS 面板排查"这个预览包来自哪个 PR"时能直接对上。
这里有个容易踩的坑:预览构建的 channel 必须指向这条分支,更新才能下发。eas-update 的判断条件是"平台匹配、runtime version 匹配、且已安装构建的 channel 指向包含该更新的 branch"三者同时成立。channel 与 branch 虽然常常同名,但在服务端是两个独立对象——eas channel:edit改的是 channel 的服务端分支映射,不会改变已安装构建里嵌入的 channel。所以 PR 预览方案要单独规划预览构建的 channel/分支映射策略,别指望pr-123这个分支名自动生效。
第三步:生产发布——双平台并行构建与提交
结构最完整的场景,"Complete release workflow for both platforms":
# .eas/workflows/release.yml name: Release on: push: tags: ['v*'] jobs: build-ios: type: build params: platform: ios profile: production build-android: type: build params: platform: android profile: production submit-ios: type: submit needs: [build-ios] params: platform: ios profile: production submit-android: type: submit needs: [build-android] params: platform: android profile: production依赖图怎么读
四个作业构成两条互不相交的链:build-ios → submit-ios、build-android → submit-android。链之间没有needs关系,所以 iOS 与 Android 并行推进,每个submit只等自己的build。这比"先构建两个平台、再统一提交"的串行写法快,也避免单平台失败干扰另一条链的诊断。
profile 是引用,不是定义
profile: production必须在项目eas.json中存在对应条目,否则作业启动时才失败。注意一个语义上的"惊喜":build.production与submit.production是两个不同分区下的同名 profile。示例不区分分区地写profile,是因为type已经决定了查哪张表——type: build读build,type: submit读submit。主技能文件里的完整示例:
{ "build": { "production": { "autoIncrement": true, "ios": { "resourceClass": "m-medium" } } }, "submit": { "production": { "ios": { "appleId": "your@email.com", "ascAppId": "1234567890" }, "android": { "serviceAccountKeyPath": "./google-service-account.json", "track": "internal" } } } }关键改进:显式传 build_id
上面示例的 submit 作业只传了platform和profile,默认会去选"最新构建"来提交。在每天都触发构建的项目里,"最新构建"很可能不是你刚构建出来的那一个。ios-app-store.md 给了更严谨的写法:
submit: type: submit needs: [build] params: build_id: ${{ needs.build.outputs.build_id }} profile: productiontestflight.md 的理由很直接:"For separate jobs, pass the explicit build ID rather than selecting whichever unrelated build is newest."(拆分的作业之间,要显式传构建 ID,而不是选最新的那个无关构建。)用needs.build.outputs.build_id把两者绑定,可以防止把错误二进制推到商店;发布流程有可审计性要求的话,建议直接把build_id写进 submit 作业。
触发方式:用 tag 驱动版本化发布
on.push.tags: ['v*']让流水线只在打标签时运行,发布点与代码版本一一对应,出问题时能直接 checkout 对应 commit 复查。参考文档 Tips 建议两种触发搭配:tag 做版本化发布,workflow_dispatch处理手动生产发布。
第四步:按需触发——条件作业
前两条流水线都是"无条件"触发,README 改一个字也消耗一次构建额度。加一层探测来过滤:
# .eas/workflows/conditional-release.yml name: Conditional Release on: push: branches: [main] jobs: check-changes: type: run params: command: | if git diff --name-only HEAD~1 | grep -q "^src/"; then echo "has_changes=true" >> $GITHUB_OUTPUT fi build: type: build needs: [check-changes] if: needs.check-changes.outputs.has_changes == 'true' params: platform: all profile: production三段结构一目了然:type: run作业执行 shell 命令做探测;needs: [check-changes]声明依赖;if决定是否真正开工。这是"只在源码目录变化时才发布"的标准写法——文档、README、CI 配置的改动不会白白消耗构建额度。
两个细节:
if的比较用字符串'true'而非布尔true,与其上游$GITHUB_OUTPUT的文本性质一致;- eas-workflows 的生成清单要求"确保
if条件符合 schema 的长度约束",说明该字段并非无限制的自由文本。
⚠️ 这里有一处需要自行验证的细节:check-changes用 GitHub Actions 约定$GITHUB_OUTPUT写出结果,下游读的是 EAS 表达式上下文needs.check-changes.outputs.has_changes。这两者是否在同一次运行中直接打通,取决于run作业类型对输出的实际处理方式——仓库文档没有进一步说明,而eas-workflows反复强调"不要依赖记忆中的字段值,先取 schema 再写"。建议照搬进生产前按这个顺序做:
- 取 schema,确认
run作业的输出声明方式与needs.*.outputs的可用性; - 用下一节的
workflow:validate做服务端校验; - 在一条非生产分支上跑一次,确认
has_changes取值符合预期,再接入main。
写完必做的一步:服务端 workflow:validate 校验
四条 YAML 写完,必须落到一个能给出确定性结论的动作上。eas-workflows技能给的命令:
npx -y eas-cli@latest workflow:validate .eas/workflows/<workflow.yml> --non-interactive围绕这条命令有几条约束值得展开:
- 逐个文件运行:每个被修改的工作流都单独执行一次校验;
- 需要登录态与已链接项目:它不是纯离线工具;
- 会检查 profile 引用:与纯 schema 校验不同,它还会把
profile与项目的eas.json对照——正好能拦下"profile: production写错或不存在"这类错误; - 会做服务端校验:在这里修复成本远低于上线后发现;
- 成功标志是固定文案:命令打印
Workflow configuration YAML is valid.才算通过;反复修复并重跑,直到出现该输出; - 明确禁止替代:文档直接写明 "Do not replace this command with a local YAML or JSON Schema validator."
为什么强调"取实时 schema"
同样的谨慎也体现在资料获取上。写之前,eas-workflows技能要求先拉取三份资源:
- JSON Schema—— 文档用大写强调 "It is NECESSARY to fetch this schema"。它是作业类型、参数、触发类型、runner 类型的权威来源(最终校验仍以 EAS CLI 为准);
- 语法文档—— YAML 概览、示例、表达式与上下文;
- 预打包作业文档—— 各 job type 的参数与输出。
为避免 Agent 反复抓取被限流,技能自带一个缓存抓取脚本 scripts/fetch.js:按 URL 哈希落缓存、带 ETag 条件请求支持 304 复用、无缓存头时兜底 15 分钟 TTL。正因为 schema 的"新鲜度"由服务端缓存头控制,"每次写 YAML 前都取 schema"在实践中才是可行的。
五项自查清单
同一份技能还给出写作阶段的自查,可直接当 review checklist:
- 先取 schema,拿到当前 job type、参数与允许值;
- 核对每种 job type 的必填字段是否齐全;
- 确认
needs与after中引用的作业确实存在于本文件——删掉一个 job 却忘了清理别处的needs,是这类 YAML 最常见的翻车点; - 检查表达式引用的上下文与输出是否有效;
- 确认
if条件符合 schema 的长度约束。
别把"作业成功"当成"已上架"
工作流只编排作业,真正的商店行为由type: submit背后的 EAS Submit 决定。要让流水线可靠,得理解下游两个特性。
提交只是"排队"
主技能文件列出提交相关的只读命令,并标注了版本差异:
# 列表与查看提交作业 eas submit:list -p ios --json eas submit:view SUBMISSION_ID --json这些命令在 EAS CLI 23.2.0 中可用,但在测试过的 18.6.0 中不存在;不想改全局安装时,可以用npx eas-cli@23.2.0这类固定版本调用。testflight.md 进一步区分了两个命令的语义:submit:view报告的是EAS 作业,submit:status才读取 App Store Connect,反映 App Store 版本与 TestFlight 处理/内测/外测状态,并且需要对应 profile 或环境中的 App Store Connect API key。
同文件的"状态推进表"把"验证到了哪一步"的边界说得很清楚:
| 已验证状态 | 它证明了什么 | 下一步检查 |
|---|---|---|
| EAS 构建完成 | 产出了产物 | 确认 build ID、来源提交与 profile |
| 提交已排队 | EAS 安排了上传 | 跟踪 submission URL 与 worker 日志 |
| Apple 上传/处理成功 | Apple 接受了二进制 | TestFlight 可用性与合规事项 |
| 分配给测试者并可用 | 目标测试者可安装 | 在真机安装验证 |
| App Review 批准并发布 | 公开 App Store 分发 | 仅属于已授权的生产发布 |
实际含义是:不要在submit作业成功后立即宣布"已上架"。文档还特别提醒,不能仅凭--no-wait返回成功就认定上传完成。
元数据推送:顺序里有业务含义
参考示例以单作业两步的形式给出:
# .eas/workflows/release.yml jobs: submit-and-metadata: steps: - name: Submit to App Store run: eas submit -p ios --latest - name: Push Metadata run: eas metadata:push见 app-store-metadata.md。注意这种写法与前面"作业类型 +params"的风格不同——它依赖作业内的steps列表,而steps.*正是可用表达式上下文之一,所以这种写法在 schema 中有对应位置。该片段本身未声明作业类型,实际接入时以 schema 与workflow:validate的结果为准。
两个 step 的先后不能随意调换:metadata:push之前必须先有二进制。元数据文档的常见问题里就有一条 "Binary not found",解法是 "Push a binary witheas submitbefore pushing metadata"。
Android 侧:同一份 YAML 服务不同发布策略
type: submit在 Android 上落到 Play Console 的某个 track(internal→alpha→beta→production),由eas.json中submitprofile 的track与releaseStatus控制:暂停进行中的发布用releaseStatus: "halted";做灰度用inProgress搭配rollout: 0.1(先放 10%)。这意味着:同一份工作流 YAML 可以为不同的发布策略服务,只需在eas.json里换 track 与 rollout 参数,不必改 YAML。
还有一条容易忽略的边界:submit:status目前只读取 Apple 的状态,EAS 作业成功并不能证明 Play Console 上的发布已生效(见 play-store.md)。排查 Android 发布时,去 Play Console 看 track 与发布状态。
凭据别进仓库
参考文档 Tips 的最后一条:"Keep sensitive values in EAS Secrets, not workflow files." 工作流文件会进 Git 仓库,任何写死的凭据都会随之扩散。
Android 服务账号是最典型的需要处理的凭据。它可以以文件形式存进 EAS Secrets,再被eas.json引用:
eas secret:create --name GOOGLE_SERVICE_ACCOUNT \ --value "$(cat google-service-account.json)" --type file{ "submit": { "production": { "android": { "serviceAccountKeyPath": "@secret:GOOGLE_SERVICE_ACCOUNT" } } } }见 play-store.md。注意serviceAccountKeyPath里放的是@secret:引用而非真实路径——这样工作流 YAML 与eas.json都可以安全提交,密钥只在 EAS 服务端解析。CI 环境下的另一种做法是EXPO_ANDROID_SERVICE_ACCOUNT_KEY_BASE64环境变量(服务账号 JSON 的 base64)。
排错速查表
把参考文档 Tips 与各参考文件中的故障条目合并,按"先查什么"组织:
| 现象 | 先查什么 | 出处 |
|---|---|---|
| 工作流 YAML 校验不通过 | 用workflow:validate逐个文件重跑,确认profile存在于eas.json | eas-workflows/SKILL.md |
| 作业引用的前置作业不存在 | 检查needs/after是否指向本文件中真实存在的 job | eas-workflows/SKILL.md |
| PR 预览更新下发不到设备 | 核对构建的 channel 是否指向pr-<n>分支、平台与 runtime version 是否匹配 | eas-update/SKILL.md |
| 提交推到了错误的构建 | 改用build_id: ${{ needs.build.outputs.build_id }}显式绑定 | testflight.md |
submit成功但无可安装包 | 查 TestFlight 处理/合规状态与测试组分配 | testflight.md |
| 元数据推送报 "Binary not found" | 先跑一次eas submit,再eas metadata:push | app-store-metadata.md |
| Android 版本号冲突 | autoIncrement: true,配合appVersionSource: "remote" | play-store.md |
| iOS 上传被拒(大图标 alpha 通道) | 移除默认 AppIcon PNG 的 alpha 通道后重新构建 | testflight.md |
| Android 提交报权限不足 | 在 Play Console → API access 给服务账号 "Release to production" | play-store.md |
主技能文件还写了一条通用汇报纪律:报告时给出确切的 build ID / 版本号,以及已验证到的最远状态,不要把"作业成功"等同于"商店可下载"。
落地六步清单
第一次接入工作流的项目,建议按这个顺序推进,每步都有明确的验证动作:
- 对齐账号状态:
eas whoami确认登录,检查应用配置中的extra.eas.projectId,缺失则eas init; - 先写非生产流水线:从"推送到 main 即构建"开始,只跑
type: build,确认profile能被解析、产物能下载; - 加上校验环节:把
npx -y eas-cli@latest workflow:validate .eas/workflows/<file>.yml --non-interactive作为提交前的固定动作; - 接入 PR 预览:先确认预览构建的 channel/分支映射策略,再打开
pull_request触发,避免一开就产生大量无效分支; - 最后接生产发布:tag 触发 + 显式
build_id的 submit 作业,并在流水线之外保留 TestFlight / Play Console 的人工核验; - 把凭据移出仓库:所有服务账号、API key 走 EAS Secrets,YAML 与
eas.json只保留引用。
收尾:记住的是四个技能的边界
eas-app-stores负责"构建什么、提交到哪",eas-workflows负责"YAML 怎么写、怎么校验",eas-update负责"OTA 的通道与兼容性",eas-hosting负责"网页与 API 路由的部署"。把这四个技能的边界分清,比记住任何一个字段名都更有长期价值。
【免费下载链接】skillsA collection of AI agent skills for working with Expo projects and Expo Application Services项目地址: https://gitcode.com/GitHub_Trending/skills9/skills
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考