diff --git a/CHANGELOG.md b/CHANGELOG.md index 41efbd9..e87006d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- **`apple submit --yes` submits a draft prepared with `--no-submit`.** The second step of the two-step flow + ended with "nothing to submit" when the draft already held the version and every item; it now sends the submit + (same "not ready yet" retry). `iap-submit` the same for a draft that holds the asked-for items. Pitfall 90. + ## 0.8.22 · 2026-10-20 - **`apple submit` / `resubmit` switch Game Center on first.** A build with the Game Center entitlement and an app diff --git a/cli/apple/iap-submit.ts b/cli/apple/iap-submit.ts index a282826..9e4e9ac 100644 --- a/cli/apple/iap-submit.ts +++ b/cli/apple/iap-submit.ts @@ -121,7 +121,7 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise bindIds(r, { app: o.appId })); - const submitting = willSubmit(state, plan); + const submitting = willSubmit(state, plan, o.appVersionId); const busy = state.choice?.action === 'busy'; if (a.has('json') && !o.quiet) { printJson({ dryRun: dry, items: plan.items, skipped: plan.skipped, firstOfType: plan.firstOfType, hasApprovedVersion: plan.hasApprovedVersion, @@ -149,7 +149,7 @@ export async function submitPlanned(ctx: Ctx, c: Asc, o: SubmitOpts): Promise x.groupId === s.subs.find(y => y.productId === id)?.groupId && APPROVED_PRODUCT.has(x.state)) : !approvedTypes.has(type); - if (plan.skipped.length > before && firstOfType && NEVER_SUBMITTED.has(productState) && plan.skipped.at(-1)?.why !== 'already in the draft submission') leftOut.push(`${id} (${plan.skipped.at(-1)?.why})`); + if (plan.skipped.length > before && firstOfType && NEVER_SUBMITTED.has(productState) && plan.skipped.at(-1)?.why !== ALREADY_IN_DRAFT) leftOut.push(`${id} (${plan.skipped.at(-1)?.why})`); }; const leftOut: string[] = []; const pickOne = (id: string, productState: string, versions: PVersion[], kind: ItemKind, type: string): void => { @@ -172,7 +175,7 @@ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersi if (!v) { plan.skipped.push({ id, why: 'no version on App Store Connect yet (create the product first: storebound apple iap / apple subscriptions)' }); return; } if (IN_REVIEW.has(v.state)) { plan.skipped.push({ id, why: `already ${v.state}` }); return; } if (!SUBMITTABLE.has(v.state)) { plan.skipped.push({ id, why: `latest version is ${v.state}: ${NEW_VERSION_FROM.has(v.state) ? 'add --new-version to send a new version' : 'change it in App Store Connect to send a new version'}` }); return; } - if (already.has(v.id)) { plan.skipped.push({ id, why: 'already in the draft submission' }); return; } + if (already.has(v.id)) { plan.skipped.push({ id, why: ALREADY_IN_DRAFT }); return; } plan.items.push({ kind, versionId: v.id, label: `${id} (${typeLabel(type)}, version ${v.version ?? '?'})`, ...(v.state === 'DEVELOPER_REJECTED' ? { devRejected: true } : {}) }); if (!approvedTypes.has(type)) firstTypes.add(type); }; @@ -222,8 +225,15 @@ export function planIapSubmission(s: SubmitState, want: string[], o: { withVersi /** The plan's word for an item it adds. Pure. */ export const addAction = (it: PlanItem): string => (it.devRejected ? `add (${DEV_REJECTED_NOTE})` : 'add'); -/** Whether the run ends with a submit: new items, or a submission back from App Review that only needs resubmitting. Pure. */ -export const willSubmit = (s: SubmitState, plan: SubmitPlan): boolean => plan.items.length > 0 || s.draft?.state === 'UNRESOLVED_ISSUES'; +/** Whether the run ends with a submit: new items, a submission back from App Review that only needs resubmitting, or a + * draft prepared earlier (`--no-submit`) that already holds what was asked for: the app version when one is named, + * else at least one of the asked-for items. Pure. */ +export function willSubmit(s: SubmitState, plan: SubmitPlan, appVersionId?: string): boolean { + if (plan.items.length > 0 || s.draft?.state === 'UNRESOLVED_ISSUES') return true; + const held = s.draft?.itemVersionIds ?? []; + if (!held.length) return false; + return appVersionId ? held.includes(appVersionId) : plan.skipped.some(sk => sk.why === ALREADY_IN_DRAFT); +} /** Fills the placeholders of a planned request (app id, the id of the draft created a moment ago). Pure. */ export function bindIds(r: Req, ids: { app?: string; submission?: string }): Req { diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 42ad0d1..b1248cc 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -9,6 +9,7 @@ fails without the fix. | # | date | lesson | guard | |---|---|---|---| +| 90 | 2026-10 | `apple submit --build N --version X --yes` after `apple submit --no-submit --yes` (the documented two-step flow) printed the plan (every item "in it already", then "submit PATCH submitted: true"), logged "not added ...: already in the draft submission" and ended with "nothing to submit" without the PATCH: the submit only ran for new items or an UNRESOLVED_ISSUES submission. | `willSubmit` (cli/lib/review-submit.ts) also submits a READY_FOR_REVIEW draft that already holds the named app version (iap-submit: one of the asked-for items), with the same "not ready yet" retry. `tests/resubmit.test.ts` ("the second step submits the prepared draft"). | | 85 | 2026-10 | `shots record --source web` filmed at the page's CSS size: Chromium's screencast ignores the deviceScaleFactor (maxWidth only shrinks), so a 1080x1920 preview of a 430x932 @2x page was a 2.5x upscale. | `Page.captureScreenshot` with `clip.scale` = dpr (long side capped at 1920), 3 requests in flight, timestamped frames joined by ffmpeg; a warning when the first frame is smaller than asked or the rate is under 20 frames/s. `recordSize` / `captureShortfall` in `cli/shots/devices.ts`, `tests/video.test.ts`. | | 80 | 2026-10 | A game's hero logo and titles sat against the screenshot edges, a game with a macOS version had no Mac screenshots, and the contact sheet timed out at a flat 30 s with 9 sizes. | Templates keep 7 % / 5 % safe margins and `shots check` fails text or logo boxes under 6 % / 4 % (`marginViolations`, report.json `boxes`); `apple.mac` adds the `mac` size (APP_DESKTOP, macOS version); `sheetTimeoutMs` scales with images, sizes and load. `tests/mac-landscape.test.ts`, `tests/browser/landscape.browser.ts`. | | 78 | 2026-10 | `storebound api --yes` with stdout piped or redirected printed a dry run and exited 0 (twice); the owner took it as sent. | `decide` (`cli/api/gate.ts`) refuses with `NOT_A_TTY`: exit 1, "refused: writes through `storebound api` need an interactive terminal ... Nothing was sent." on stderr, the dedicated command named; without `--yes` still a dry run. `tests/api.test.ts`. | diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index db3e96b..b9b143d 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1100,3 +1100,16 @@ that fails without the fix (see CONTRIBUTING.md). - **Guard:** `readGcSwitch` / `gcSwitchPlan` / `ensureGcSwitch` / `gameCenterInUse` in `cli/apple/game-center.ts`, `gameServicesFindings` (codes game-center.detail, game-center.version), the fake store's `gameCenterBuild` 409 and `tests/game-services.test.ts`. + +## 90. The second step of `apple submit --no-submit` said "nothing to submit" +- **Symptom:** `apple submit --build N --version X --yes` after `apple submit --no-submit --yes` prepared the draft + printed the plan (every item "in it already", last row "submit PATCH submitted: true · owner step (--yes)"), + logged "not added : already in the draft submission" for each item and ended with "nothing to submit"; the + draft stayed READY_FOR_REVIEW and was never sent. +- **Cause:** the submit ran only when the run added items or the submission was back from App Review + (UNRESOLVED_ISSUES). A draft that already held everything counted as nothing to do. +- **Do:** update storebound and run the same `--yes` again: the prepared draft is submitted as it is (with the + "not ready yet" retry). A plan that ends with a submit row and a run that ends with "nothing to submit" is a bug: + check `apple status` for the draft's state before re-running anything else. +- **Guard:** `willSubmit` in `cli/lib/review-submit.ts` (a non-empty READY_FOR_REVIEW draft that holds the named app + version, or for `iap-submit` an asked-for item, is submitted); `tests/resubmit.test.ts`. diff --git a/tests/resubmit.test.ts b/tests/resubmit.test.ts index f86d62a..ac9259f 100644 --- a/tests/resubmit.test.ts +++ b/tests/resubmit.test.ts @@ -225,6 +225,24 @@ describe('apple submit --build (a first submission, the desktop owner action expect(shown(b)).toEqual([]); }); + test('--no-submit --yes, then --yes: the second step submits the prepared draft that holds everything already', async () => { + fresh(); + const p = project(ONE); + await capture(() => appleSubmit(p.ctx(['--build', '8', '--no-submit', '--yes']))); + const sid = s.asc.all('reviewSubmissions')[0]!.id; + expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['READY_FOR_REVIEW']); + expect(s.asc.children(sid, 'reviewSubmissionItems')).toHaveLength(2); + s.faults.push({ ...NOT_READY, match: new RegExp(`^/v1/reviewSubmissions/${sid}$`), times: 1 }); + const a = s.calls.length; + const r = await capture(() => appleSubmit(p.ctx(['--build', '8', '--yes']))); + expect([r.exit ?? 0, r.err]).toEqual([0, '']); + expect(r.out).toContain('in it already'); + expect(r.out).not.toContain('nothing to submit'); + expect(shown(a)).toEqual([`PATCH /v1/reviewSubmissions/${sid}`, `PATCH /v1/reviewSubmissions/${sid}`]); + expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['WAITING_FOR_REVIEW']); + expect(r.out).toContain(`review submission ${sid} submitted`); + }); + test('a live version is not submitted; no editable version is a clear stop', async () => { fresh(); s.asc.res.get('ver-1')!.attributes.appVersionState = 'READY_FOR_SALE';