From a512ed5ed4297cc70b43e7db2e11239273e62626 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 3 Nov 2026 19:24:20 +0100 Subject: [PATCH 01/18] ci: add non-required iOS smoke shadow workflow --- .github/scripts/smoke-shadow.cjs | 111 ++++++++++++++ .github/scripts/smoke-shadow.test.cjs | 152 +++++++++++++++++++ .github/workflows/e2e-smoke-shadow.yml | 201 +++++++++++++++++++++++++ 3 files changed, 464 insertions(+) create mode 100644 .github/scripts/smoke-shadow.cjs create mode 100644 .github/scripts/smoke-shadow.test.cjs create mode 100644 .github/workflows/e2e-smoke-shadow.yml diff --git a/.github/scripts/smoke-shadow.cjs b/.github/scripts/smoke-shadow.cjs new file mode 100644 index 000000000..616667f73 --- /dev/null +++ b/.github/scripts/smoke-shadow.cjs @@ -0,0 +1,111 @@ +"use strict"; + +const { execFileSync } = require("node:child_process"); + +const CODE = + /^(Bitkit\/|BitkitNotification\/|BitkitWidget\/|Bitkit\.xcodeproj\/|Package\.swift$|\.github\/scripts\/|\.github\/actions\/swift-packages\/|\.github\/workflows\/e2e-smoke-shadow\.yml$|scripts\/shallow-source-packages\.sh$)/; + +function eligible(eventName, event) { + if (eventName === "workflow_dispatch") return true; + const pr = event.pull_request; + return ( + eventName === "pull_request" && + pr && + !pr.draft && + pr.base.ref === "master" && + (event.action !== "edited" || Boolean(event.changes?.base)) + ); +} + +function needsSmoke(eventName, event, files) { + return Boolean( + eligible(eventName, event) && + (eventName === "workflow_dispatch" || + files.some((file) => CODE.test(file))), + ); +} + +function verifyMerge({ tested, parents, head, base }) { + if ( + ![tested, head, base].every((sha) => /^[0-9a-f]{40}$/.test(sha)) || + parents.length !== 2 || + parents[0] !== base || + parents[1] !== head + ) { + throw new Error("Shadow checkout is not the current PR merge candidate"); + } +} + +async function inspect( + { github, context }, + git = (args) => execFileSync("git", args, { encoding: "utf8" }).trim(), +) { + if (!eligible(context.eventName, context.payload)) + return { code: false, reason: "draft or unrelated event" }; + const tested = git(["rev-parse", "HEAD"]); + if (context.eventName === "workflow_dispatch") { + if (tested !== context.sha) + throw new Error( + "Manual shadow checkout does not match its dispatched revision", + ); + return { + code: true, + tested, + head: tested, + base: tested, + reason: "manual dispatch", + }; + } + const number = context.payload.pull_request.number; + const [{ data: pr }, { data: baseRef }] = await Promise.all([ + github.rest.pulls.get({ ...context.repo, pull_number: number }), + github.rest.git.getRef({ ...context.repo, ref: "heads/master" }), + ]); + if (pr.draft || pr.base.ref !== "master") + throw new Error("PR eligibility changed after the shadow event"); + const head = pr.head.sha, + base = baseRef.object.sha; + if (head !== context.payload.pull_request.head.sha) + throw new Error("PR head changed after the shadow event"); + verifyMerge({ + tested, + head, + base, + parents: git(["show", "-s", "--format=%P", tested]).split(" "), + }); + const files = git(["diff", "--name-only", "-z", base, tested]) + .split("\0") + .filter(Boolean); + return { + code: needsSmoke(context.eventName, context.payload, files), + tested, + head, + base, + reason: "verified PR merge", + }; +} + +function verdict(detection, code, smoke) { + if (detection !== "success") + return { pass: false, message: "Shadow change detection did not succeed." }; + if (code === "false") + return { + pass: true, + message: + "Shadow smoke not required: successful detection found no applicable code changes.", + }; + if (code !== "true") + return { + pass: false, + message: "Shadow change selection is missing or invalid.", + }; + return { + pass: smoke === "success", + message: + smoke === "success" + ? "Shadow smoke passed all expected cases." + : `Shadow smoke incomplete: build/setup/test job ${smoke}.`, + }; +} + +module.exports = { eligible, needsSmoke, verifyMerge, inspect, verdict }; diff --git a/.github/scripts/smoke-shadow.test.cjs b/.github/scripts/smoke-shadow.test.cjs new file mode 100644 index 000000000..660bbf19c --- /dev/null +++ b/.github/scripts/smoke-shadow.test.cjs @@ -0,0 +1,152 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { + eligible, + needsSmoke, + verifyMerge, + inspect, + verdict, +} = require("./smoke-shadow.cjs"); + +const head = "a".repeat(40), + base = "b".repeat(40), + merge = "c".repeat(40); +function event(action = "synchronize") { + return { + action, + pull_request: { + number: 264, + draft: false, + head: { sha: head }, + base: { ref: "master" }, + }, + }; +} + +test("normal PR, ready-for-review, real retarget and dispatch are eligible", () => { + for (const action of [ + "opened", + "synchronize", + "reopened", + "ready_for_review", + ]) + assert.equal(eligible("pull_request", event(action)), true); + assert.equal( + eligible("pull_request", { + ...event("edited"), + changes: { base: { ref: { from: "stack" } } }, + }), + true, + ); + assert.equal(eligible("workflow_dispatch", {}), true); +}); +test("draft, stacked PR and unrelated edited event do not schedule smoke", () => { + const draft = event(); + draft.pull_request.draft = true; + const stacked = event(); + stacked.pull_request.base.ref = "feature"; + for (const payload of [draft, stacked, event("edited")]) + assert.equal(eligible("pull_request", payload), false); +}); +test("path filtering preserves successful explicit no-code selection", () => { + assert.equal( + needsSmoke("pull_request", event(), ["README.md", "journeys/a.xml"]), + false, + ); + for (const file of [ + "Bitkit/Wallet.swift", + "Bitkit.xcodeproj/project.pbxproj", + ".github/workflows/e2e-smoke-shadow.yml", + ".github/scripts/smoke-shadow.cjs", + ]) + assert.equal(needsSmoke("pull_request", event(), [file]), true); + assert.equal(needsSmoke("workflow_dispatch", {}, []), true); +}); +test("head/base changes and malformed merge identity fail closed", () => { + verifyMerge({ tested: merge, parents: [base, head], head, base }); + for (const parents of [ + [base, "d".repeat(40)], + ["d".repeat(40), head], + [head], + ]) + assert.throws( + () => verifyMerge({ tested: merge, parents, head, base }), + /current PR merge/, + ); +}); +test("detection uses actual merge parents and propagates API/selection failures", async () => { + const context = { + eventName: "pull_request", + payload: event(), + repo: { owner: "synonymdev", repo: "bitkit-ios" }, + }; + const github = { + rest: { + pulls: { get: async () => ({ data: event().pull_request }) }, + git: { getRef: async () => ({ data: { object: { sha: base } } }) }, + }, + }; + const git = (args) => + args[0] === "rev-parse" + ? merge + : args[0] === "show" + ? `${base} ${head}` + : "Bitkit/Wallet.swift\0"; + assert.deepEqual(await inspect({ github, context }, git), { + code: true, + tested: merge, + head, + base, + reason: "verified PR merge", + }); + const changed = { + ...context, + payload: { + ...event(), + pull_request: { ...event().pull_request, head: { sha: "d".repeat(40) } }, + }, + }; + await assert.rejects( + inspect({ github, context: changed }, git), + /head changed/, + ); + github.rest.git.getRef = async () => { + throw Error("API unavailable"); + }; + await assert.rejects(inspect({ github, context }, git), /API unavailable/); +}); +test("manual dispatch pins the actual workflow revision", async () => { + const context = { eventName: "workflow_dispatch", payload: {}, sha: merge }; + assert.equal((await inspect({ context }, () => merge)).tested, merge); + await assert.rejects( + inspect({ context }, () => head), + /dispatched revision/, + ); +}); +test("aggregation distinguishes path miss from failed, skipped or cancelled work", () => { + assert.equal(verdict("success", "false", "skipped").pass, true); + assert.equal(verdict("success", "true", "success").pass, true); + for (const status of ["failure", "cancelled", "skipped"]) { + assert.equal(verdict(status, "false", "skipped").pass, false); + assert.equal(verdict("success", "true", status).pass, false); + } + assert.equal(verdict("success", "", "success").pass, false); +}); +test("shadow has distinct names, one attempt, read permissions, no scheduled trigger or required-gate edits", () => { + const workflow = fs.readFileSync( + path.join(__dirname, "../workflows/e2e-smoke-shadow.yml"), + "utf8", + ); + assert.match(workflow, /name: e2e-smoke-shadow-status/); + assert.match(workflow, /E2E_MAX_ATTEMPTS: ['" ]?1['"]?/); + assert.match(workflow, /contents: read/); + assert.doesNotMatch( + workflow, + /pull_request_target|checks: write|schedule:|continue-on-error: true/, + ); + assert.match(workflow, /if: always\(\)/); + assert.match(workflow, /--spec.*smoke\.e2e\.ts/); +}); diff --git a/.github/workflows/e2e-smoke-shadow.yml b/.github/workflows/e2e-smoke-shadow.yml new file mode 100644 index 000000000..b54ba566b --- /dev/null +++ b/.github/workflows/e2e-smoke-shadow.yml @@ -0,0 +1,201 @@ +name: iOS E2E smoke (shadow) + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, edited] + branches: [master] + workflow_dispatch: + inputs: + e2e_branch: + description: "E2E branch containing the inventory and smoke producer" + default: default-feature-branch + +permissions: + contents: read + packages: read + pull-requests: read + +concurrency: + group: ios-smoke-shadow-${{ github.event_name == 'pull_request' && github.event.action == 'edited' && github.event.changes.base == null && format('ignored-{0}', github.run_id) || github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + detect: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.draft == false && + (github.event.action != 'edited' || github.event.changes.base != null)) + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + code: ${{ steps.plan.outputs.code }} + tested: ${{ steps.plan.outputs.tested }} + head: ${{ steps.plan.outputs.head }} + base: ${{ steps.plan.outputs.base }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + fetch-depth: 2 + persist-credentials: false + - run: node --test .github/scripts/smoke-shadow.test.cjs + - id: plan + uses: actions/github-script@v9 + with: + script: | + const {inspect} = require('./.github/scripts/smoke-shadow.cjs'); + const result = await inspect({github,context}); + for (const [key,value] of Object.entries(result)) core.setOutput(key,String(value)); + + e2e-branch: + needs: detect + if: needs.detect.outputs.code == 'true' + uses: synonymdev/bitkit-e2e-tests/.github/workflows/determine-e2e-branch.yml@main + with: + app_branch: ${{ github.head_ref || github.ref_name }} + e2e_branch_input: ${{ inputs.e2e_branch || 'default-feature-branch' }} + + smoke: + name: iOS smoke shadow + needs: [detect, e2e-branch] + if: needs.detect.outputs.code == 'true' + runs-on: macos-latest + cache-mode: read + timeout-minutes: 60 + env: + BACKEND: regtest + SIMULATOR_NAME: Bitkit smoke shadow + SIMULATOR_OS_VERSION: "26.2" + AUT_FILENAME: Bitkit.app + E2E_MAX_ATTEMPTS: "1" + ATTEMPT: "1" + E2E_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}-ios-smoke + E2E_APP_SHA: ${{ needs.detect.outputs.tested }} + E2E_BACKEND_REVISION: regtest:stag0 + E2E_PLAN: ${{ github.workspace }}/bitkit-e2e-tests/artifacts/reporting/plan.json + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.detect.outputs.tested }} + fetch-depth: 2 + persist-credentials: false + - name: Verify candidate before building + uses: actions/github-script@v9 + with: + script: | + const {inspect} = require('./.github/scripts/smoke-shadow.cjs'); + await inspect({github,context}); + - name: Resolve companion E2E ref + id: e2e-ref + uses: synonymdev/bitkit-e2e-tests/.github/actions/resolve-e2e-ref@main + with: + selected: ${{ needs.e2e-branch.outputs.branch }} + e2e_branch_input: ${{ inputs.e2e_branch || 'default-feature-branch' }} + - uses: actions/checkout@v7 + with: + repository: synonymdev/bitkit-e2e-tests + ref: ${{ steps.e2e-ref.outputs.ref }} + path: bitkit-e2e-tests + persist-credentials: false + - uses: actions/setup-node@v7 + with: + node-version: "22" + - name: Install and validate the inventory producer + working-directory: bitkit-e2e-tests + run: | + test -f test/inventory.json || { echo '::error::Selected E2E branch lacks the #264 inventory producer'; exit 1; } + npm ci + npm run inventory:check + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "26.2" + - run: brew install xcbeautify ffmpeg + - uses: ./.github/actions/swift-packages + - name: Create isolated simulator + run: | + udid=$(xcrun simctl create "$SIMULATOR_NAME" com.apple.CoreSimulator.SimDeviceType.iPhone-17 com.apple.CoreSimulator.SimRuntime.iOS-26-2) + echo "SIMULATOR_UDID=$udid" >> "$GITHUB_ENV" + xcrun simctl boot "$udid" + xcrun simctl bootstatus "$udid" -b + - name: Build matching regtest app + timeout-minutes: 30 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + E2E_BACKEND: network + E2E_NETWORK: regtest + run: | + set -o pipefail + xcodebuild -workspace Bitkit.xcodeproj/project.xcworkspace \ + -scheme Bitkit -configuration Debug \ + -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \ + -derivedDataPath DerivedData \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES" \ + -disablePackageRepositoryCache -disableAutomaticPackageResolution -skipPackageUpdates \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD' \ + -allowProvisioningUpdates build | xcbeautify + mkdir -p bitkit-e2e-tests/aut + cp -R DerivedData/Build/Products/Debug-iphonesimulator/Bitkit.app bitkit-e2e-tests/aut/Bitkit.app + ditto -c -k --keepParent bitkit-e2e-tests/aut/Bitkit.app "$RUNNER_TEMP/smoke-app.zip" + echo "E2E_ARTIFACT_DIGEST=$(shasum -a 256 "$RUNNER_TEMP/smoke-app.zip" | cut -d' ' -f1)" >> "$GITHUB_ENV" + - name: Prepare exact smoke plan + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/e2e-inventory.ts plan --platform ios --backend regtest --pack smoke --plan "$E2E_PLAN" + - name: Run one smoke attempt + timeout-minutes: 20 + working-directory: bitkit-e2e-tests + env: + RECORD_VIDEO: "true" + run: ./ci_run_ios.sh --spec ./test/specs/smoke.e2e.ts --mochaOpts.grep "$(cat artifacts/reporting/grep.txt)" + - name: Reconcile coverage even after failure + if: always() + run: | + if test -f "$E2E_PLAN"; then + cd bitkit-e2e-tests + node --experimental-strip-types scripts/e2e-inventory.ts report --plan "$E2E_PLAN" + else + echo '## iOS smoke shadow: incomplete — build/setup/selection did not produce a plan' >> "$GITHUB_STEP_SUMMARY" + exit 1 + fi + - name: Upload smoke evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: ios-smoke-shadow-${{ github.run_id }}-${{ github.run_attempt }} + path: bitkit-e2e-tests/artifacts/ + retention-days: 7 + - name: Delete owned simulator + if: always() + run: | + if [[ "${SIMULATOR_UDID:-}" =~ ^[0-9A-Fa-f-]{36}$ ]]; then + xcrun simctl shutdown "$SIMULATOR_UDID" || true + xcrun simctl delete "$SIMULATOR_UDID" + fi + + shadow-status: + name: e2e-smoke-shadow-status + if: >- + always() && + (github.event_name == 'workflow_dispatch' || + (github.event.pull_request.draft == false && + (github.event.action != 'edited' || github.event.changes.base != null))) + needs: [detect, e2e-branch, smoke] + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Summarize shadow result + env: + DETECTION: ${{ needs.detect.result }} + CODE: ${{ needs.detect.outputs.code }} + SMOKE: ${{ needs.smoke.result }} + run: | + node - <<'NODE' + const fs = require('node:fs'); + const {verdict} = require('./.github/scripts/smoke-shadow.cjs'); + const result = verdict(process.env.DETECTION,process.env.CODE,process.env.SMOKE); + fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY,`## iOS smoke shadow\n${result.message}\n\nThis check is observational; existing merge gates remain required.\n`); + if (!result.pass) process.exitCode = 1; + NODE From fee805dbc241c0ff1b97085f8dd8922a50f04e9d Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 3 Nov 2026 19:24:29 +0100 Subject: [PATCH 02/18] docs: describe shadow rollout and validation boundaries --- Docs/e2e-smoke-shadow.md | 43 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 Docs/e2e-smoke-shadow.md diff --git a/Docs/e2e-smoke-shadow.md b/Docs/e2e-smoke-shadow.md new file mode 100644 index 000000000..5cc793aeb --- /dev/null +++ b/Docs/e2e-smoke-shadow.md @@ -0,0 +1,43 @@ +# iOS smoke shadow (#264) + +This additive workflow observes three fresh-wallet regtest cases. It does not +replace `e2e-status`, alter rulesets, or change existing gates and schedules. +The inventory/reporting and smoke producer in `synonymdev/bitkit-e2e-tests` must +land first, or use matching companion branches in both repositories. + +Non-draft PRs into `master` run on open, synchronization, reopen, readiness and +base retargeting when app/build/consumer paths change. Documentation-only changes +produce an explicit no-code result. Manual dispatch runs the selected revision. +The workflow verifies the tested merge's parents against the current PR head and +`master`; stale or unverifiable candidates fail visibly. Unrelated PR edits do not +cancel an active smoke run. No scheduled trigger is added at this stage. + +Each selected run uses one standard hosted Mac, one simulator and one test attempt. +The job is capped at 60 minutes, the build at 30 and tests at 20. New commits cancel +older shadow runs. Cache access is read-only; evidence retention is seven days. +There are no automatic retries or shards. Existing expensive gates still run during +validation, so shadow temporarily adds compute. Do not assume parallelism reduces +billed minutes or that hosted-runner availability guarantees no queue. + +Inspect `e2e-smoke-shadow-status`, the Actions summary and `ios-smoke-shadow-*` +artifact. The report records app/E2E revisions, app archive digest, backend identity, +expected IDs, each outcome, first failure and duration. Build/setup failures report +incomplete coverage. Test durations do not include billed build/setup minutes. + +Before changing any required check, validate hosted execution, concurrent-run +isolation, representative failures, first-pass reliability and measured end-to-end +latency/cost over an agreed sample. The 10–15 minute smoke target is a measurement +goal, not a claim that this workflow has achieved it. Risk packs, full nightly +orchestration and the two-shard experiment remain later stages. + +Local workflow checks: + +```sh +node --test .github/scripts/smoke-shadow.test.cjs +actionlint -ignore 'unexpected key "cache-mode" for "job" section' .github/workflows/e2e-smoke-shadow.yml +``` + +The exception is limited to `cache-mode`, supported by +[GitHub's workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idcache-mode) +but not recognized by actionlint 1.7.12. Do not remove the read-only restriction to +satisfy the older schema. All other lint diagnostics remain errors. From 5c79e56aa6e31c2fd197fd90bf926818ccf56153 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 3 Nov 2026 19:30:40 +0100 Subject: [PATCH 03/18] ci: preserve early shadow diagnostics and cover build scripts --- .github/scripts/smoke-shadow.cjs | 2 +- .github/scripts/smoke-shadow.test.cjs | 71 ++++++++++++++++++++++++++ .github/workflows/e2e-smoke-shadow.yml | 17 +++++- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/.github/scripts/smoke-shadow.cjs b/.github/scripts/smoke-shadow.cjs index 616667f73..21ba794f9 100644 --- a/.github/scripts/smoke-shadow.cjs +++ b/.github/scripts/smoke-shadow.cjs @@ -3,7 +3,7 @@ const { execFileSync } = require("node:child_process"); const CODE = - /^(Bitkit\/|BitkitNotification\/|BitkitWidget\/|Bitkit\.xcodeproj\/|Package\.swift$|\.github\/scripts\/|\.github\/actions\/swift-packages\/|\.github\/workflows\/e2e-smoke-shadow\.yml$|scripts\/shallow-source-packages\.sh$)/; + /^(Bitkit\/|BitkitNotification\/|BitkitWidget\/|Bitkit\.xcodeproj\/|Package\.swift$|\.github\/scripts\/|\.github\/actions\/swift-packages\/|\.github\/workflows\/e2e-smoke-shadow\.yml$|scripts\/)/; function eligible(eventName, event) { if (eventName === "workflow_dispatch") return true; diff --git a/.github/scripts/smoke-shadow.test.cjs b/.github/scripts/smoke-shadow.test.cjs index 660bbf19c..e975d9944 100644 --- a/.github/scripts/smoke-shadow.test.cjs +++ b/.github/scripts/smoke-shadow.test.cjs @@ -2,6 +2,8 @@ const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); +const os = require("node:os"); +const { execFileSync } = require("node:child_process"); const path = require("node:path"); const { eligible, @@ -150,3 +152,72 @@ test("shadow has distinct names, one attempt, read permissions, no scheduled tri assert.match(workflow, /if: always\(\)/); assert.match(workflow, /--spec.*smoke\.e2e\.ts/); }); + +test("build-generator-only changes schedule smoke", () => { + assert.equal( + needsSmoke("pull_request", event(), [ + "scripts/generate-plural-localizations.swift", + ]), + true, + ); +}); +test("always diagnostic collector creates artifacts after checkout/setup failure and preserves failed build output", (t) => { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), "shadow-diagnostics-"), + ); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + const workflow = fs.readFileSync( + path.join(__dirname, "../workflows/e2e-smoke-shadow.yml"), + "utf8", + ); + const section = workflow + .split(" - name: Collect setup and build diagnostics\n")[1] + .split(" - name: Upload smoke evidence")[0]; + assert.match(section, /if: always\(\)/); + const script = section + .split(" run: |\n")[1] + .split("\n") + .map((line) => line.replace(/^ /, "")) + .join("\n"); + const metadata = { + job: "failure", + install: "failure", + build: "skipped", + appSha: merge, + }; + const invoke = () => + execFileSync("bash", ["-e", "-o", "pipefail", "-c", script], { + cwd: directory, + env: { + ...process.env, + RUNNER_TEMP: directory, + DIAGNOSTICS: JSON.stringify(metadata), + }, + }); + invoke(); // No checkout or build log exists yet. + const report = path.join( + directory, + "bitkit-e2e-tests/artifacts/reporting/setup.json", + ); + assert.deepEqual(JSON.parse(fs.readFileSync(report, "utf8")), metadata); + fs.writeFileSync( + path.join(directory, "ios-smoke-build.log"), + "error: fixture build failed\n", + ); + metadata.install = "success"; + metadata.build = "failure"; + invoke(); + assert.equal( + fs.readFileSync( + path.join(directory, "bitkit-e2e-tests/artifacts/ios-smoke-build.log"), + "utf8", + ), + "error: fixture build failed\n", + ); + assert.equal(JSON.parse(fs.readFileSync(report, "utf8")).build, "failure"); + assert.match(workflow, /set -o pipefail/); + assert.match( + workflow, + /build 2>&1 \| tee "\$RUNNER_TEMP\/ios-smoke-build.log" \| xcbeautify/, + ); +}); diff --git a/.github/workflows/e2e-smoke-shadow.yml b/.github/workflows/e2e-smoke-shadow.yml index b54ba566b..c2d315c05 100644 --- a/.github/workflows/e2e-smoke-shadow.yml +++ b/.github/workflows/e2e-smoke-shadow.yml @@ -63,6 +63,7 @@ jobs: cache-mode: read timeout-minutes: 60 env: + PLATFORM: ios BACKEND: regtest SIMULATOR_NAME: Bitkit smoke shadow SIMULATOR_OS_VERSION: "26.2" @@ -101,6 +102,7 @@ jobs: with: node-version: "22" - name: Install and validate the inventory producer + id: install working-directory: bitkit-e2e-tests run: | test -f test/inventory.json || { echo '::error::Selected E2E branch lacks the #264 inventory producer'; exit 1; } @@ -118,9 +120,9 @@ jobs: xcrun simctl boot "$udid" xcrun simctl bootstatus "$udid" -b - name: Build matching regtest app + id: build timeout-minutes: 30 env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} E2E_BACKEND: network E2E_NETWORK: regtest run: | @@ -132,7 +134,7 @@ jobs: -clonedSourcePackagesDirPath "$SOURCE_PACKAGES" \ -disablePackageRepositoryCache -disableAutomaticPackageResolution -skipPackageUpdates \ SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD' \ - -allowProvisioningUpdates build | xcbeautify + -allowProvisioningUpdates build 2>&1 | tee "$RUNNER_TEMP/ios-smoke-build.log" | xcbeautify mkdir -p bitkit-e2e-tests/aut cp -R DerivedData/Build/Products/Debug-iphonesimulator/Bitkit.app bitkit-e2e-tests/aut/Bitkit.app ditto -c -k --keepParent bitkit-e2e-tests/aut/Bitkit.app "$RUNNER_TEMP/smoke-app.zip" @@ -156,6 +158,17 @@ jobs: echo '## iOS smoke shadow: incomplete — build/setup/selection did not produce a plan' >> "$GITHUB_STEP_SUMMARY" exit 1 fi + - name: Collect setup and build diagnostics + if: always() + env: + DIAGNOSTICS: | + {"job":"${{ job.status }}","install":"${{ steps.install.outcome }}","build":"${{ steps.build.outcome }}","appSha":"${{ needs.detect.outputs.tested }}","runId":"${{ github.run_id }}","runAttempt":"${{ github.run_attempt }}"} + run: | + mkdir -p bitkit-e2e-tests/artifacts/reporting + printf '%s\n' "$DIAGNOSTICS" > bitkit-e2e-tests/artifacts/reporting/setup.json + if test -f "$RUNNER_TEMP/ios-smoke-build.log"; then + cp "$RUNNER_TEMP/ios-smoke-build.log" bitkit-e2e-tests/artifacts/ + fi - name: Upload smoke evidence if: always() uses: actions/upload-artifact@v7 From e07caa752c7c1cbcc9ce392eeac927e808a09fc9 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 3 Nov 2026 19:59:42 +0100 Subject: [PATCH 04/18] ci: keep iOS smoke validation manual until gate cutover --- .github/scripts/smoke-shadow.test.cjs | 24 ++++++++++++++++++++++-- .github/workflows/e2e-smoke-shadow.yml | 21 +++++---------------- Docs/e2e-smoke-shadow.md | 25 ++++++++++++++----------- 3 files changed, 41 insertions(+), 29 deletions(-) diff --git a/.github/scripts/smoke-shadow.test.cjs b/.github/scripts/smoke-shadow.test.cjs index e975d9944..dea527398 100644 --- a/.github/scripts/smoke-shadow.test.cjs +++ b/.github/scripts/smoke-shadow.test.cjs @@ -28,7 +28,7 @@ function event(action = "synchronize") { }; } -test("normal PR, ready-for-review, real retarget and dispatch are eligible", () => { +test("retained PR selector recognizes PR events (automatic trigger disabled)", () => { for (const action of [ "opened", "synchronize", @@ -153,7 +153,7 @@ test("shadow has distinct names, one attempt, read permissions, no scheduled tri assert.match(workflow, /--spec.*smoke\.e2e\.ts/); }); -test("build-generator-only changes schedule smoke", () => { +test("retained PR selector recognizes build scripts (automatic trigger disabled)", () => { assert.equal( needsSmoke("pull_request", event(), [ "scripts/generate-plural-localizations.swift", @@ -221,3 +221,23 @@ test("always diagnostic collector creates artifacts after checkout/setup failure /build 2>&1 \| tee "\$RUNNER_TEMP\/ios-smoke-build.log" \| xcbeautify/, ); }); + +test("manual validation cannot add an automatic Mac run for PRs, pushes, schedules or build-script changes", () => { + const workflow = fs.readFileSync( + path.join(__dirname, "../workflows/e2e-smoke-shadow.yml"), + "utf8", + ); + const triggers = workflow.split("on:\n")[1].split("\npermissions:")[0]; + assert.deepEqual( + [...triggers.matchAll(/^ ([a-z_]+):/gm)].map((match) => match[1]), + ["workflow_dispatch"], + ); + const detect = workflow.split(" detect:\n")[1].split(" e2e-branch:")[0]; + assert.match(detect, /if: github.event_name == 'workflow_dispatch'/); + const status = workflow.split(" shadow-status:\n")[1]; + assert.match( + status, + /if: always\(\) && github.event_name == 'workflow_dispatch'/, + ); + assert.match(workflow, /group: ios-smoke-shadow-\$\{\{ github.ref \}\}/); +}); diff --git a/.github/workflows/e2e-smoke-shadow.yml b/.github/workflows/e2e-smoke-shadow.yml index c2d315c05..71dc5ff62 100644 --- a/.github/workflows/e2e-smoke-shadow.yml +++ b/.github/workflows/e2e-smoke-shadow.yml @@ -1,9 +1,6 @@ -name: iOS E2E smoke (shadow) +name: iOS E2E smoke (manual validation) on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review, edited] - branches: [master] workflow_dispatch: inputs: e2e_branch: @@ -13,18 +10,14 @@ on: permissions: contents: read packages: read - pull-requests: read concurrency: - group: ios-smoke-shadow-${{ github.event_name == 'pull_request' && github.event.action == 'edited' && github.event.changes.base == null && format('ignored-{0}', github.run_id) || github.event.pull_request.number || github.ref }} + group: ios-smoke-shadow-${{ github.ref }} cancel-in-progress: true jobs: detect: - if: >- - github.event_name == 'workflow_dispatch' || - (github.event.pull_request.draft == false && - (github.event.action != 'edited' || github.event.changes.base != null)) + if: github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 5 outputs: @@ -52,7 +45,7 @@ jobs: if: needs.detect.outputs.code == 'true' uses: synonymdev/bitkit-e2e-tests/.github/workflows/determine-e2e-branch.yml@main with: - app_branch: ${{ github.head_ref || github.ref_name }} + app_branch: ${{ github.ref_name }} e2e_branch_input: ${{ inputs.e2e_branch || 'default-feature-branch' }} smoke: @@ -186,11 +179,7 @@ jobs: shadow-status: name: e2e-smoke-shadow-status - if: >- - always() && - (github.event_name == 'workflow_dispatch' || - (github.event.pull_request.draft == false && - (github.event.action != 'edited' || github.event.changes.base != null))) + if: always() && github.event_name == 'workflow_dispatch' needs: [detect, e2e-branch, smoke] runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/Docs/e2e-smoke-shadow.md b/Docs/e2e-smoke-shadow.md index 5cc793aeb..ad23ce90e 100644 --- a/Docs/e2e-smoke-shadow.md +++ b/Docs/e2e-smoke-shadow.md @@ -1,22 +1,25 @@ # iOS smoke shadow (#264) -This additive workflow observes three fresh-wallet regtest cases. It does not +This manual validation workflow observes three fresh-wallet regtest cases. It does not replace `e2e-status`, alter rulesets, or change existing gates and schedules. The inventory/reporting and smoke producer in `synonymdev/bitkit-e2e-tests` must land first, or use matching companion branches in both repositories. -Non-draft PRs into `master` run on open, synchronization, reopen, readiness and -base retargeting when app/build/consumer paths change. Documentation-only changes -produce an explicit no-code result. Manual dispatch runs the selected revision. -The workflow verifies the tested merge's parents against the current PR head and -`master`; stale or unverifiable candidates fail visibly. Unrelated PR edits do not -cancel an active smoke run. No scheduled trigger is added at this stage. +This workflow is **manual-only** (`workflow_dispatch`). Opening, updating or +retargeting a PR, pushing a commit, or changing a build script does not start it. +The existing gates remain the only automatic app gates during validation. Dispatch +against an existing selected revision; there is no need to push a new commit to +validate it. The checkout must match that dispatch's SHA. + +After representative manual runs validate the smoke pack, switch the automatic PR +gate in one deliberate change. Do not add an automatic smoke job alongside the old +gate as a rollout phase. Existing nightly coverage continues unchanged. Each selected run uses one standard hosted Mac, one simulator and one test attempt. -The job is capped at 60 minutes, the build at 30 and tests at 20. New commits cancel -older shadow runs. Cache access is read-only; evidence retention is seven days. -There are no automatic retries or shards. Existing expensive gates still run during -validation, so shadow temporarily adds compute. Do not assume parallelism reduces +The job is capped at 60 minutes, the build at 30 and tests at 20. A newer manual dispatch on the same ref cancels +an older manual smoke run. Cache access is read-only; evidence retention is seven days. +There are no automatic retries or shards. Manual validation consumes compute only when explicitly dispatched; it does not +add an automatic Mac run to each PR. Do not assume parallelism reduces billed minutes or that hosted-runner availability guarantees no queue. Inspect `e2e-smoke-shadow-status`, the Actions summary and `ios-smoke-shadow-*` From c337673ff97ad17b4b431b06b140fc2768bfcfd7 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 3 Nov 2026 20:55:05 +0100 Subject: [PATCH 05/18] ci: select iOS risk packs in the existing E2E gate --- .github/scripts/smoke-shadow.cjs | 111 ------- .github/scripts/smoke-shadow.test.cjs | 243 -------------- .github/workflows/e2e-smoke-shadow.yml | 203 ------------ .github/workflows/e2e-staging.yml | 201 ++++++------ .github/workflows/e2e-tests.yml | 435 ++++++++++++++++--------- .github/workflows/e2e_migration.yml | 263 +++++++++------ Docs/e2e-ci.md | 20 ++ Docs/e2e-smoke-shadow.md | 46 --- 8 files changed, 548 insertions(+), 974 deletions(-) delete mode 100644 .github/scripts/smoke-shadow.cjs delete mode 100644 .github/scripts/smoke-shadow.test.cjs delete mode 100644 .github/workflows/e2e-smoke-shadow.yml create mode 100644 Docs/e2e-ci.md delete mode 100644 Docs/e2e-smoke-shadow.md diff --git a/.github/scripts/smoke-shadow.cjs b/.github/scripts/smoke-shadow.cjs deleted file mode 100644 index 21ba794f9..000000000 --- a/.github/scripts/smoke-shadow.cjs +++ /dev/null @@ -1,111 +0,0 @@ -"use strict"; - -const { execFileSync } = require("node:child_process"); - -const CODE = - /^(Bitkit\/|BitkitNotification\/|BitkitWidget\/|Bitkit\.xcodeproj\/|Package\.swift$|\.github\/scripts\/|\.github\/actions\/swift-packages\/|\.github\/workflows\/e2e-smoke-shadow\.yml$|scripts\/)/; - -function eligible(eventName, event) { - if (eventName === "workflow_dispatch") return true; - const pr = event.pull_request; - return ( - eventName === "pull_request" && - pr && - !pr.draft && - pr.base.ref === "master" && - (event.action !== "edited" || Boolean(event.changes?.base)) - ); -} - -function needsSmoke(eventName, event, files) { - return Boolean( - eligible(eventName, event) && - (eventName === "workflow_dispatch" || - files.some((file) => CODE.test(file))), - ); -} - -function verifyMerge({ tested, parents, head, base }) { - if ( - ![tested, head, base].every((sha) => /^[0-9a-f]{40}$/.test(sha)) || - parents.length !== 2 || - parents[0] !== base || - parents[1] !== head - ) { - throw new Error("Shadow checkout is not the current PR merge candidate"); - } -} - -async function inspect( - { github, context }, - git = (args) => execFileSync("git", args, { encoding: "utf8" }).trim(), -) { - if (!eligible(context.eventName, context.payload)) - return { code: false, reason: "draft or unrelated event" }; - const tested = git(["rev-parse", "HEAD"]); - if (context.eventName === "workflow_dispatch") { - if (tested !== context.sha) - throw new Error( - "Manual shadow checkout does not match its dispatched revision", - ); - return { - code: true, - tested, - head: tested, - base: tested, - reason: "manual dispatch", - }; - } - const number = context.payload.pull_request.number; - const [{ data: pr }, { data: baseRef }] = await Promise.all([ - github.rest.pulls.get({ ...context.repo, pull_number: number }), - github.rest.git.getRef({ ...context.repo, ref: "heads/master" }), - ]); - if (pr.draft || pr.base.ref !== "master") - throw new Error("PR eligibility changed after the shadow event"); - const head = pr.head.sha, - base = baseRef.object.sha; - if (head !== context.payload.pull_request.head.sha) - throw new Error("PR head changed after the shadow event"); - verifyMerge({ - tested, - head, - base, - parents: git(["show", "-s", "--format=%P", tested]).split(" "), - }); - const files = git(["diff", "--name-only", "-z", base, tested]) - .split("\0") - .filter(Boolean); - return { - code: needsSmoke(context.eventName, context.payload, files), - tested, - head, - base, - reason: "verified PR merge", - }; -} - -function verdict(detection, code, smoke) { - if (detection !== "success") - return { pass: false, message: "Shadow change detection did not succeed." }; - if (code === "false") - return { - pass: true, - message: - "Shadow smoke not required: successful detection found no applicable code changes.", - }; - if (code !== "true") - return { - pass: false, - message: "Shadow change selection is missing or invalid.", - }; - return { - pass: smoke === "success", - message: - smoke === "success" - ? "Shadow smoke passed all expected cases." - : `Shadow smoke incomplete: build/setup/test job ${smoke}.`, - }; -} - -module.exports = { eligible, needsSmoke, verifyMerge, inspect, verdict }; diff --git a/.github/scripts/smoke-shadow.test.cjs b/.github/scripts/smoke-shadow.test.cjs deleted file mode 100644 index dea527398..000000000 --- a/.github/scripts/smoke-shadow.test.cjs +++ /dev/null @@ -1,243 +0,0 @@ -"use strict"; -const test = require("node:test"); -const assert = require("node:assert/strict"); -const fs = require("node:fs"); -const os = require("node:os"); -const { execFileSync } = require("node:child_process"); -const path = require("node:path"); -const { - eligible, - needsSmoke, - verifyMerge, - inspect, - verdict, -} = require("./smoke-shadow.cjs"); - -const head = "a".repeat(40), - base = "b".repeat(40), - merge = "c".repeat(40); -function event(action = "synchronize") { - return { - action, - pull_request: { - number: 264, - draft: false, - head: { sha: head }, - base: { ref: "master" }, - }, - }; -} - -test("retained PR selector recognizes PR events (automatic trigger disabled)", () => { - for (const action of [ - "opened", - "synchronize", - "reopened", - "ready_for_review", - ]) - assert.equal(eligible("pull_request", event(action)), true); - assert.equal( - eligible("pull_request", { - ...event("edited"), - changes: { base: { ref: { from: "stack" } } }, - }), - true, - ); - assert.equal(eligible("workflow_dispatch", {}), true); -}); -test("draft, stacked PR and unrelated edited event do not schedule smoke", () => { - const draft = event(); - draft.pull_request.draft = true; - const stacked = event(); - stacked.pull_request.base.ref = "feature"; - for (const payload of [draft, stacked, event("edited")]) - assert.equal(eligible("pull_request", payload), false); -}); -test("path filtering preserves successful explicit no-code selection", () => { - assert.equal( - needsSmoke("pull_request", event(), ["README.md", "journeys/a.xml"]), - false, - ); - for (const file of [ - "Bitkit/Wallet.swift", - "Bitkit.xcodeproj/project.pbxproj", - ".github/workflows/e2e-smoke-shadow.yml", - ".github/scripts/smoke-shadow.cjs", - ]) - assert.equal(needsSmoke("pull_request", event(), [file]), true); - assert.equal(needsSmoke("workflow_dispatch", {}, []), true); -}); -test("head/base changes and malformed merge identity fail closed", () => { - verifyMerge({ tested: merge, parents: [base, head], head, base }); - for (const parents of [ - [base, "d".repeat(40)], - ["d".repeat(40), head], - [head], - ]) - assert.throws( - () => verifyMerge({ tested: merge, parents, head, base }), - /current PR merge/, - ); -}); -test("detection uses actual merge parents and propagates API/selection failures", async () => { - const context = { - eventName: "pull_request", - payload: event(), - repo: { owner: "synonymdev", repo: "bitkit-ios" }, - }; - const github = { - rest: { - pulls: { get: async () => ({ data: event().pull_request }) }, - git: { getRef: async () => ({ data: { object: { sha: base } } }) }, - }, - }; - const git = (args) => - args[0] === "rev-parse" - ? merge - : args[0] === "show" - ? `${base} ${head}` - : "Bitkit/Wallet.swift\0"; - assert.deepEqual(await inspect({ github, context }, git), { - code: true, - tested: merge, - head, - base, - reason: "verified PR merge", - }); - const changed = { - ...context, - payload: { - ...event(), - pull_request: { ...event().pull_request, head: { sha: "d".repeat(40) } }, - }, - }; - await assert.rejects( - inspect({ github, context: changed }, git), - /head changed/, - ); - github.rest.git.getRef = async () => { - throw Error("API unavailable"); - }; - await assert.rejects(inspect({ github, context }, git), /API unavailable/); -}); -test("manual dispatch pins the actual workflow revision", async () => { - const context = { eventName: "workflow_dispatch", payload: {}, sha: merge }; - assert.equal((await inspect({ context }, () => merge)).tested, merge); - await assert.rejects( - inspect({ context }, () => head), - /dispatched revision/, - ); -}); -test("aggregation distinguishes path miss from failed, skipped or cancelled work", () => { - assert.equal(verdict("success", "false", "skipped").pass, true); - assert.equal(verdict("success", "true", "success").pass, true); - for (const status of ["failure", "cancelled", "skipped"]) { - assert.equal(verdict(status, "false", "skipped").pass, false); - assert.equal(verdict("success", "true", status).pass, false); - } - assert.equal(verdict("success", "", "success").pass, false); -}); -test("shadow has distinct names, one attempt, read permissions, no scheduled trigger or required-gate edits", () => { - const workflow = fs.readFileSync( - path.join(__dirname, "../workflows/e2e-smoke-shadow.yml"), - "utf8", - ); - assert.match(workflow, /name: e2e-smoke-shadow-status/); - assert.match(workflow, /E2E_MAX_ATTEMPTS: ['" ]?1['"]?/); - assert.match(workflow, /contents: read/); - assert.doesNotMatch( - workflow, - /pull_request_target|checks: write|schedule:|continue-on-error: true/, - ); - assert.match(workflow, /if: always\(\)/); - assert.match(workflow, /--spec.*smoke\.e2e\.ts/); -}); - -test("retained PR selector recognizes build scripts (automatic trigger disabled)", () => { - assert.equal( - needsSmoke("pull_request", event(), [ - "scripts/generate-plural-localizations.swift", - ]), - true, - ); -}); -test("always diagnostic collector creates artifacts after checkout/setup failure and preserves failed build output", (t) => { - const directory = fs.mkdtempSync( - path.join(os.tmpdir(), "shadow-diagnostics-"), - ); - t.after(() => fs.rmSync(directory, { recursive: true, force: true })); - const workflow = fs.readFileSync( - path.join(__dirname, "../workflows/e2e-smoke-shadow.yml"), - "utf8", - ); - const section = workflow - .split(" - name: Collect setup and build diagnostics\n")[1] - .split(" - name: Upload smoke evidence")[0]; - assert.match(section, /if: always\(\)/); - const script = section - .split(" run: |\n")[1] - .split("\n") - .map((line) => line.replace(/^ /, "")) - .join("\n"); - const metadata = { - job: "failure", - install: "failure", - build: "skipped", - appSha: merge, - }; - const invoke = () => - execFileSync("bash", ["-e", "-o", "pipefail", "-c", script], { - cwd: directory, - env: { - ...process.env, - RUNNER_TEMP: directory, - DIAGNOSTICS: JSON.stringify(metadata), - }, - }); - invoke(); // No checkout or build log exists yet. - const report = path.join( - directory, - "bitkit-e2e-tests/artifacts/reporting/setup.json", - ); - assert.deepEqual(JSON.parse(fs.readFileSync(report, "utf8")), metadata); - fs.writeFileSync( - path.join(directory, "ios-smoke-build.log"), - "error: fixture build failed\n", - ); - metadata.install = "success"; - metadata.build = "failure"; - invoke(); - assert.equal( - fs.readFileSync( - path.join(directory, "bitkit-e2e-tests/artifacts/ios-smoke-build.log"), - "utf8", - ), - "error: fixture build failed\n", - ); - assert.equal(JSON.parse(fs.readFileSync(report, "utf8")).build, "failure"); - assert.match(workflow, /set -o pipefail/); - assert.match( - workflow, - /build 2>&1 \| tee "\$RUNNER_TEMP\/ios-smoke-build.log" \| xcbeautify/, - ); -}); - -test("manual validation cannot add an automatic Mac run for PRs, pushes, schedules or build-script changes", () => { - const workflow = fs.readFileSync( - path.join(__dirname, "../workflows/e2e-smoke-shadow.yml"), - "utf8", - ); - const triggers = workflow.split("on:\n")[1].split("\npermissions:")[0]; - assert.deepEqual( - [...triggers.matchAll(/^ ([a-z_]+):/gm)].map((match) => match[1]), - ["workflow_dispatch"], - ); - const detect = workflow.split(" detect:\n")[1].split(" e2e-branch:")[0]; - assert.match(detect, /if: github.event_name == 'workflow_dispatch'/); - const status = workflow.split(" shadow-status:\n")[1]; - assert.match( - status, - /if: always\(\) && github.event_name == 'workflow_dispatch'/, - ); - assert.match(workflow, /group: ios-smoke-shadow-\$\{\{ github.ref \}\}/); -}); diff --git a/.github/workflows/e2e-smoke-shadow.yml b/.github/workflows/e2e-smoke-shadow.yml deleted file mode 100644 index 71dc5ff62..000000000 --- a/.github/workflows/e2e-smoke-shadow.yml +++ /dev/null @@ -1,203 +0,0 @@ -name: iOS E2E smoke (manual validation) - -on: - workflow_dispatch: - inputs: - e2e_branch: - description: "E2E branch containing the inventory and smoke producer" - default: default-feature-branch - -permissions: - contents: read - packages: read - -concurrency: - group: ios-smoke-shadow-${{ github.ref }} - cancel-in-progress: true - -jobs: - detect: - if: github.event_name == 'workflow_dispatch' - runs-on: ubuntu-latest - timeout-minutes: 5 - outputs: - code: ${{ steps.plan.outputs.code }} - tested: ${{ steps.plan.outputs.tested }} - head: ${{ steps.plan.outputs.head }} - base: ${{ steps.plan.outputs.base }} - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.sha }} - fetch-depth: 2 - persist-credentials: false - - run: node --test .github/scripts/smoke-shadow.test.cjs - - id: plan - uses: actions/github-script@v9 - with: - script: | - const {inspect} = require('./.github/scripts/smoke-shadow.cjs'); - const result = await inspect({github,context}); - for (const [key,value] of Object.entries(result)) core.setOutput(key,String(value)); - - e2e-branch: - needs: detect - if: needs.detect.outputs.code == 'true' - uses: synonymdev/bitkit-e2e-tests/.github/workflows/determine-e2e-branch.yml@main - with: - app_branch: ${{ github.ref_name }} - e2e_branch_input: ${{ inputs.e2e_branch || 'default-feature-branch' }} - - smoke: - name: iOS smoke shadow - needs: [detect, e2e-branch] - if: needs.detect.outputs.code == 'true' - runs-on: macos-latest - cache-mode: read - timeout-minutes: 60 - env: - PLATFORM: ios - BACKEND: regtest - SIMULATOR_NAME: Bitkit smoke shadow - SIMULATOR_OS_VERSION: "26.2" - AUT_FILENAME: Bitkit.app - E2E_MAX_ATTEMPTS: "1" - ATTEMPT: "1" - E2E_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }}-ios-smoke - E2E_APP_SHA: ${{ needs.detect.outputs.tested }} - E2E_BACKEND_REVISION: regtest:stag0 - E2E_PLAN: ${{ github.workspace }}/bitkit-e2e-tests/artifacts/reporting/plan.json - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ needs.detect.outputs.tested }} - fetch-depth: 2 - persist-credentials: false - - name: Verify candidate before building - uses: actions/github-script@v9 - with: - script: | - const {inspect} = require('./.github/scripts/smoke-shadow.cjs'); - await inspect({github,context}); - - name: Resolve companion E2E ref - id: e2e-ref - uses: synonymdev/bitkit-e2e-tests/.github/actions/resolve-e2e-ref@main - with: - selected: ${{ needs.e2e-branch.outputs.branch }} - e2e_branch_input: ${{ inputs.e2e_branch || 'default-feature-branch' }} - - uses: actions/checkout@v7 - with: - repository: synonymdev/bitkit-e2e-tests - ref: ${{ steps.e2e-ref.outputs.ref }} - path: bitkit-e2e-tests - persist-credentials: false - - uses: actions/setup-node@v7 - with: - node-version: "22" - - name: Install and validate the inventory producer - id: install - working-directory: bitkit-e2e-tests - run: | - test -f test/inventory.json || { echo '::error::Selected E2E branch lacks the #264 inventory producer'; exit 1; } - npm ci - npm run inventory:check - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: "26.2" - - run: brew install xcbeautify ffmpeg - - uses: ./.github/actions/swift-packages - - name: Create isolated simulator - run: | - udid=$(xcrun simctl create "$SIMULATOR_NAME" com.apple.CoreSimulator.SimDeviceType.iPhone-17 com.apple.CoreSimulator.SimRuntime.iOS-26-2) - echo "SIMULATOR_UDID=$udid" >> "$GITHUB_ENV" - xcrun simctl boot "$udid" - xcrun simctl bootstatus "$udid" -b - - name: Build matching regtest app - id: build - timeout-minutes: 30 - env: - E2E_BACKEND: network - E2E_NETWORK: regtest - run: | - set -o pipefail - xcodebuild -workspace Bitkit.xcodeproj/project.xcworkspace \ - -scheme Bitkit -configuration Debug \ - -destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \ - -derivedDataPath DerivedData \ - -clonedSourcePackagesDirPath "$SOURCE_PACKAGES" \ - -disablePackageRepositoryCache -disableAutomaticPackageResolution -skipPackageUpdates \ - SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD' \ - -allowProvisioningUpdates build 2>&1 | tee "$RUNNER_TEMP/ios-smoke-build.log" | xcbeautify - mkdir -p bitkit-e2e-tests/aut - cp -R DerivedData/Build/Products/Debug-iphonesimulator/Bitkit.app bitkit-e2e-tests/aut/Bitkit.app - ditto -c -k --keepParent bitkit-e2e-tests/aut/Bitkit.app "$RUNNER_TEMP/smoke-app.zip" - echo "E2E_ARTIFACT_DIGEST=$(shasum -a 256 "$RUNNER_TEMP/smoke-app.zip" | cut -d' ' -f1)" >> "$GITHUB_ENV" - - name: Prepare exact smoke plan - working-directory: bitkit-e2e-tests - run: node --experimental-strip-types scripts/e2e-inventory.ts plan --platform ios --backend regtest --pack smoke --plan "$E2E_PLAN" - - name: Run one smoke attempt - timeout-minutes: 20 - working-directory: bitkit-e2e-tests - env: - RECORD_VIDEO: "true" - run: ./ci_run_ios.sh --spec ./test/specs/smoke.e2e.ts --mochaOpts.grep "$(cat artifacts/reporting/grep.txt)" - - name: Reconcile coverage even after failure - if: always() - run: | - if test -f "$E2E_PLAN"; then - cd bitkit-e2e-tests - node --experimental-strip-types scripts/e2e-inventory.ts report --plan "$E2E_PLAN" - else - echo '## iOS smoke shadow: incomplete — build/setup/selection did not produce a plan' >> "$GITHUB_STEP_SUMMARY" - exit 1 - fi - - name: Collect setup and build diagnostics - if: always() - env: - DIAGNOSTICS: | - {"job":"${{ job.status }}","install":"${{ steps.install.outcome }}","build":"${{ steps.build.outcome }}","appSha":"${{ needs.detect.outputs.tested }}","runId":"${{ github.run_id }}","runAttempt":"${{ github.run_attempt }}"} - run: | - mkdir -p bitkit-e2e-tests/artifacts/reporting - printf '%s\n' "$DIAGNOSTICS" > bitkit-e2e-tests/artifacts/reporting/setup.json - if test -f "$RUNNER_TEMP/ios-smoke-build.log"; then - cp "$RUNNER_TEMP/ios-smoke-build.log" bitkit-e2e-tests/artifacts/ - fi - - name: Upload smoke evidence - if: always() - uses: actions/upload-artifact@v7 - with: - name: ios-smoke-shadow-${{ github.run_id }}-${{ github.run_attempt }} - path: bitkit-e2e-tests/artifacts/ - retention-days: 7 - - name: Delete owned simulator - if: always() - run: | - if [[ "${SIMULATOR_UDID:-}" =~ ^[0-9A-Fa-f-]{36}$ ]]; then - xcrun simctl shutdown "$SIMULATOR_UDID" || true - xcrun simctl delete "$SIMULATOR_UDID" - fi - - shadow-status: - name: e2e-smoke-shadow-status - if: always() && github.event_name == 'workflow_dispatch' - needs: [detect, e2e-branch, smoke] - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - - uses: actions/checkout@v7 - with: - ref: ${{ github.sha }} - persist-credentials: false - - name: Summarize shadow result - env: - DETECTION: ${{ needs.detect.result }} - CODE: ${{ needs.detect.outputs.code }} - SMOKE: ${{ needs.smoke.result }} - run: | - node - <<'NODE' - const fs = require('node:fs'); - const {verdict} = require('./.github/scripts/smoke-shadow.cjs'); - const result = verdict(process.env.DETECTION,process.env.CODE,process.env.SMOKE); - fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY,`## iOS smoke shadow\n${result.message}\n\nThis check is observational; existing merge gates remain required.\n`); - if (!result.pass) process.exitCode = 1; - NODE diff --git a/.github/workflows/e2e-staging.yml b/.github/workflows/e2e-staging.yml index 2c3fba80b..53126d912 100644 --- a/.github/workflows/e2e-staging.yml +++ b/.github/workflows/e2e-staging.yml @@ -1,44 +1,57 @@ name: E2E Staging (iOS) - on: - schedule: - - cron: "0 4 * * *" workflow_dispatch: inputs: e2e_branch: - description: "Branch of synonymdev/bitkit-e2e-tests to use (main | default-feature-branch | custom branch name)" + description: Branch of synonymdev/bitkit-e2e-tests to use (main | default-feature-branch | custom branch name) required: false - default: "default-feature-branch" + default: default-feature-branch post_to_slack: - description: "Post E2E staging summary to Slack (#bitkit-staging-nightly)" + description: Post E2E staging summary to Slack (#bitkit-staging-nightly) type: boolean default: false required: false - + workflow_call: + inputs: + app_ref: + type: string + required: true + e2e_ref: + type: string + required: true + app_artifact: + type: string + default: "" + post_to_slack: + type: boolean + default: false env: TERM: xterm-256color FORCE_COLOR: 1 GIT_LFS_SKIP_SMUDGE: "1" - SIMULATOR_NAME: "iPhone 17" + SIMULATOR_NAME: iPhone 17 IOS_VERSION: "26.2" - concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + group: e2e-staging-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true - jobs: build-staging: - runs-on: macos-latest - + runs-on: ${{ inputs.app_artifact != '' && 'ubuntu-latest' || 'macos-latest' }} steps: - name: Checkout uses: actions/checkout@v7 - + with: + ref: ${{ inputs.app_ref || github.sha }} + if: inputs.app_artifact == '' + - name: Record selected revision + id: app + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + if: inputs.app_artifact == '' - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: "26.2" - + if: inputs.app_artifact == '' - name: System Information run: | echo "=== System Information ===" @@ -47,20 +60,20 @@ jobs: echo "" echo "Xcode Version:" xcodebuild -version - + if: inputs.app_artifact == '' - name: Install xcbeautify run: | brew install xcbeautify - + if: inputs.app_artifact == '' - name: Swift packages uses: ./.github/actions/swift-packages - + if: inputs.app_artifact == '' - name: Pre-start simulator run: | echo "⏱️ Starting simulator at $(date)" xcrun simctl boot "${{ env.SIMULATOR_NAME }}" || true echo "✅ Simulator started at $(date)" - + if: inputs.app_artifact == '' - name: Build iOS app (regtest) env: GITHUB_ACTOR: ${{ github.actor }} @@ -90,72 +103,100 @@ jobs: SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD' \ -allowProvisioningUpdates \ build - + if: inputs.app_artifact == '' - name: Prepare app for E2E tests (regtest) run: | mkdir -p e2e-app cp -r DerivedData/Build/Products/Debug-iphonesimulator/Bitkit.app e2e-app/bitkit.app - + if: inputs.app_artifact == '' + - name: Record app build identity + env: + BUILD_PLATFORM: ios + BUILD_BACKEND: regtest + run: |- + python3 - <<'PY' + import json, os, subprocess + from pathlib import Path + Path('e2e-app/e2e-build.json').write_text(json.dumps({'appSha': subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip(), 'platform': os.environ['BUILD_PLATFORM'], 'backend': os.environ['BUILD_BACKEND']})) + PY + if: inputs.app_artifact == '' - name: Upload iOS app (regtest) uses: actions/upload-artifact@v7 with: name: bitkit-e2e-ios-staging_${{ github.run_number }} path: e2e-app/ - + retention-days: 7 + if: inputs.app_artifact == '' + - name: Reuse the network app build + if: inputs.app_artifact != '' + env: + APP_ARTIFACT: ${{ inputs.app_artifact }} + run: echo "Using app artifact $APP_ARTIFACT" + timeout-minutes: 40 + outputs: + appSha: ${{ inputs.app_ref || steps.app.outputs.sha }} e2e-branch: uses: synonymdev/bitkit-e2e-tests/.github/workflows/determine-e2e-branch.yml@main with: app_branch: ${{ github.head_ref || github.ref_name }} - e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - + e2e_branch_input: ${{ inputs.e2e_branch || 'default-feature-branch' }} e2e-tests-staging: runs-on: macos-latest - timeout-minutes: 120 - needs: [build-staging, e2e-branch] - + timeout-minutes: 135 + needs: + - build-staging + - e2e-branch strategy: fail-fast: false matrix: shard: - - { name: multi_address, grep: "@multi_address_staging" } - - { name: pubky, grep: "@pubky_staging" } - - { name: transfer, grep: "@transfer_staging" } - - { name: ios_nightly_ui, grep: "@ios_nightly" } - + - name: pubky-paykit + ids: + - paykit_1 + - pubky_profile_1 + - pubky_profile_2 + - pubky_profile_3 + - pubky_profile_4 + - pubky_profile_5 name: e2e-tests-staging - ${{ matrix.shard.name }} - env: BACKEND: regtest - + PLATFORM: ios + AUT_FILENAME: bitkit.app + E2E_APP_SHA: ${{ needs.build-staging.outputs.appSha }} + E2E_RUN_ID: ${{ format('{0}-{1}-staging-{2}', github.run_id, github.run_attempt, matrix.shard.name) }} + E2E_PLAN: artifacts/reporting/plan.json + E2E_BACKEND_REVISION: regtest-stag0 + E2E_VARIANT: network-regtest + E2E_CASE_IDS: ${{ toJSON(matrix.shard.ids) }} + RECORD_VIDEO: "true" + SIMULATOR_NAME: iPhone 17 + SIMULATOR_OS_VERSION: "26.2" steps: - - name: Resolve E2E tests ref + - name: Resolve standalone E2E ref id: e2e-ref + if: inputs.e2e_ref == '' uses: synonymdev/bitkit-e2e-tests/.github/actions/resolve-e2e-ref@main with: selected: ${{ needs.e2e-branch.outputs.branch }} - e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - + e2e_branch_input: ${{ inputs.e2e_branch || 'default-feature-branch' }} - name: Clone E2E tests uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests path: bitkit-e2e-tests - ref: ${{ steps.e2e-ref.outputs.ref }} - + ref: ${{ inputs.e2e_ref || steps.e2e-ref.outputs.ref }} - name: Download iOS app (regtest) uses: actions/download-artifact@v8 with: - name: bitkit-e2e-ios-staging_${{ github.run_number }} + name: ${{ inputs.app_artifact || format('bitkit-e2e-ios-staging_{0}', github.run_number) }} path: bitkit-e2e-tests/aut - - name: List iOS app directory contents run: ls -l bitkit-e2e-tests/aut - - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 22 - - name: Cache npm cache uses: actions/cache@v6 with: @@ -163,17 +204,14 @@ jobs: key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - - name: Install dependencies working-directory: bitkit-e2e-tests run: npm ci - - name: Install ffmpeg run: | echo "Installing ffmpeg with Homebrew..." brew install ffmpeg echo "ffmpeg version: $(ffmpeg -version | head -1)" - - name: Boot Simulator run: | echo "Erasing simulator..." @@ -193,70 +231,33 @@ jobs: echo "Verifying simulator state..." xcrun simctl list devices booted - - - name: Run E2E Tests 1 (${{ matrix.shard.name }}) - continue-on-error: true - id: test1 + - name: Prepare exact run plan working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep '${{ matrix.shard.grep }}' - env: - SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} - SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} - RECORD_VIDEO: true - ATTEMPT: 1 - - - name: Run E2E Tests 2 (${{ matrix.shard.name }}) - continue-on-error: true - if: steps.test1.outcome != 'success' - id: test2 + run: node --experimental-strip-types scripts/ci-run.ts prepare + - name: Run selected tests (up to three attempts) + id: tests working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.shard.grep }}" + run: node --experimental-strip-types scripts/ci-run.ts run env: - SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} - SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} - RECORD_VIDEO: true - ATTEMPT: 2 - - - name: Reset iOS Simulator before attempt 3 - if: steps.test1.outcome != 'success' && steps.test2.outcome != 'success' - run: | - echo "🔁 Resetting iOS simulator before attempt 3..." - xcrun simctl shutdown all || true - xcrun simctl erase "${{ env.SIMULATOR_NAME }}" || true - xcrun simctl boot "${{ env.SIMULATOR_NAME }}" || true - xcrun simctl bootstatus "${{ env.SIMULATOR_NAME }}" -b - open -a Simulator - sleep 15 - xcrun simctl list devices booted - - - name: Run E2E Tests 3 (${{ matrix.shard.name }}) - if: steps.test1.outcome != 'success' && steps.test2.outcome != 'success' - id: test3 + RECORD_VIDEO: "true" + - name: Report all attempts + if: always() working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.shard.grep }}" - env: - SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} - SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} - RECORD_VIDEO: true - ATTEMPT: 3 - + run: node --experimental-strip-types scripts/ci-run.ts report - name: Upload E2E Artifacts (${{ matrix.shard.name }}) - # Preserve failed-attempt evidence even when a retry passes. if: always() uses: actions/upload-artifact@v7 with: name: e2e-artifacts-staging_${{ matrix.shard.name }}_${{ github.run_number }} path: bitkit-e2e-tests/artifacts/ retention-days: 7 - slack-report: - if: >- - always() && - (github.event_name == 'schedule' || - (github.event_name == 'workflow_dispatch' && inputs.post_to_slack == true)) + if: always() && inputs.post_to_slack == true runs-on: ubuntu-latest - needs: [build-staging, e2e-branch, e2e-tests-staging] - + needs: + - build-staging + - e2e-branch + - e2e-tests-staging steps: - name: Resolve E2E tests ref id: e2e-ref @@ -265,13 +266,12 @@ jobs: with: selected: ${{ needs.e2e-branch.outputs.branch }} e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - + if: inputs.e2e_ref == '' - name: Checkout E2E tests uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests - ref: ${{ steps.e2e-ref.outputs.ref || 'main' }} - + ref: ${{ inputs.e2e_ref || steps.e2e-ref.outputs.ref || 'main' }} - name: Post Slack summary env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_STAGING }} @@ -284,3 +284,4 @@ jobs: RUN_ATTEMPT: ${{ github.run_attempt }} E2E_TESTS_REF: ${{ steps.e2e-ref.outputs.ref }} run: python3 scripts/slack_summary.py e2e-staging + timeout-minutes: 135 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 6877cd5fb..277194957 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1,89 +1,127 @@ name: e2e-tests -run-name: >- - ${{ inputs.retarget_run && format('Retarget E2E for PR #{0}', inputs.pr) || github.event.pull_request.title || github.event.head_commit.message || github.workflow }} - +run-name: "${{ inputs.retarget_run && format('Retarget E2E for PR #{0}', inputs.pr) || github.event.pull_request.title || github.event.head_commit.message || github.workflow }}" on: workflow_dispatch: inputs: e2e_branch: - description: "Branch of synonymdev/bitkit-e2e-tests to use (main | default-feature-branch | custom branch name)" + description: Branch of synonymdev/bitkit-e2e-tests to use (main | default-feature-branch | custom branch name) required: false - default: "default-feature-branch" + default: default-feature-branch pr: - description: "PR number to test as merged into master" + description: PR number to test as merged into master required: false default: "" merge_commit: - description: "Pinned PR merge commit for retarget validation" + description: Pinned PR merge commit for retarget validation default: "" head_sha: - description: "PR head verified by the retarget dispatcher" + description: PR head verified by the retarget dispatcher default: "" app_branch: - description: "PR branch for selecting matching E2E coverage" + description: PR branch for selecting matching E2E coverage default: "" check_run_id: - description: "PR check owned by this retarget validation" + description: PR check owned by this retarget validation default: "" retarget_run: - description: "Retarget status owner (set by e2e-retarget.yml)" + description: Retarget status owner (set by e2e-retarget.yml) default: "" workflow_ref: - description: "Workflow source recorded for this validation generation" + description: Workflow source recorded for this validation generation default: master helper_ref: - description: "Recovery helper branch or tag" + description: Recovery helper branch or tag default: master - # PRs stacked on another branch run no E2E until they target master; e2e-retarget.yml starts it on retarget + selection: + description: "Test selection: auto for a PR merge, base, or full" + type: choice + options: + - full + - auto + - base + default: full + shards: + description: Manual local-shard cost experiment; automatic runs use one + type: choice + options: + - "1" + - "2" + default: "1" pull_request: - types: [opened, synchronize, reopened, ready_for_review] - branches: [master] - + types: + - opened + - synchronize + - reopened + - ready_for_review + branches: + - master + schedule: + - cron: 0 3 * * * env: TERM: xterm-256color FORCE_COLOR: 1 GIT_LFS_SKIP_SMUDGE: "1" - concurrency: group: e2e-tests-${{ github.event.pull_request.number || inputs.pr || github.ref }} cancel-in-progress: true - jobs: detect-changes: - if: github.event.pull_request.draft == false + needs: + - e2e-branch + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + pull-requests: read outputs: - code: ${{ (github.event_name == 'workflow_dispatch' && !inputs.pr) || steps.filter.outputs.code == 'true' }} + code: ${{ steps.selection.outputs.code }} + matrix: ${{ steps.selection.outputs.matrix }} + staging: ${{ steps.selection.outputs.staging }} + migration: ${{ steps.selection.outputs.migration }} + appSha: ${{ steps.selection.outputs.appSha }} + e2eSha: ${{ steps.selection.outputs.e2eSha }} steps: - - uses: actions/checkout@v7 + - name: Checkout tested app + uses: actions/checkout@v7 with: - ref: ${{ inputs.merge_commit || (inputs.pr && format('refs/pull/{0}/merge', inputs.pr)) || '' }} - fetch-depth: 2 - - name: Resolve tested PR merge commits - if: inputs.pr != '' - id: merge-base - run: | - echo "base=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT" - echo "head=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - - uses: dorny/paths-filter@v4 - # Retarget filters the exact tested merge against its first parent. - if: github.event_name == 'pull_request' || inputs.pr - id: filter + ref: ${{ inputs.merge_commit || (inputs.pr && format('refs/pull/{0}/merge', inputs.pr)) || github.sha }} + path: app-ci + fetch-depth: 0 + - name: Resolve E2E tests ref + id: e2e-ref + uses: synonymdev/bitkit-e2e-tests/.github/actions/resolve-e2e-ref@main with: - base: ${{ steps.merge-base.outputs.base || 'master' }} - ref: ${{ steps.merge-base.outputs.head || '' }} - filters: | - code: - - 'Bitkit/**' - - 'BitkitNotification/**' - - 'Bitkit.xcodeproj/**' - - 'Package.swift' - - '.github/workflows/e2e-tests.yml' - - '.github/scripts/**' - + selected: ${{ needs.e2e-branch.outputs.branch }} + e2e_branch_input: ${{ inputs.e2e_branch || 'default-feature-branch' }} + - name: Checkout test selection + uses: actions/checkout@v7 + with: + repository: synonymdev/bitkit-e2e-tests + ref: ${{ steps.e2e-ref.outputs.ref }} + path: e2e-ci + - uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Select shared gate and affected packs + id: selection + env: + PLATFORM: ios + E2E_APP_ROOT: ${{ format('{0}/app-ci', github.workspace) }} + E2E_SELECTION: ${{ inputs.retarget_run != '' && 'auto' || inputs.selection || 'full' }} + E2E_SHARDS: ${{ inputs.shards || '1' }} + E2E_MANUAL_PR: ${{ inputs.pr }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node e2e-ci/scripts/ci-selection.cjs + - name: Upload selection + if: always() + uses: actions/upload-artifact@v7 + with: + name: e2e-selection + path: selection.json + retention-days: 7 build-local: needs: detect-changes - # PR code must not publish dependency caches into the dispatch ref's scope. cache-mode: read permissions: contents: read @@ -91,15 +129,13 @@ jobs: pull-requests: read checks: write actions: write - if: github.event.pull_request.draft == false && needs.detect-changes.outputs.code == 'true' + if: needs.detect-changes.outputs.code == 'true' runs-on: macos-latest - steps: - name: Checkout uses: actions/checkout@v7 with: - ref: ${{ inputs.merge_commit || (inputs.pr && format('refs/pull/{0}/merge', inputs.pr)) || '' }} - + ref: ${{ needs.detect-changes.outputs.appSha }} - name: Checkout retarget reporter if: inputs.retarget_run != '' uses: actions/checkout@v7 @@ -121,12 +157,10 @@ jobs: script: | const {reportRetargetResult} = require('./retarget-ci/.github/scripts/retarget-tests.cjs'); await reportRetargetResult({github, context, core, name: 'e2e-status', phase: 'start'}); - - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: "26.2" - - name: System Information run: | echo "=== System Information ===" @@ -135,43 +169,22 @@ jobs: echo "" echo "Xcode Version:" xcodebuild -version - - - name: Verify current PR merge checkout - if: github.event_name == 'pull_request' || inputs.pr - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr }} - run: | - [[ "$PR_NUMBER" =~ ^[0-9]+$ ]] || exit 1 - tested=$(git rev-parse HEAD) - head=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" --jq 'select(.base.ref == "master") | .head.sha') - base=$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/master" --jq '.object.sha') - expected="$base $head" - actual=$(gh api "repos/$GITHUB_REPOSITORY/commits/$tested" --jq '[.parents[].sha] | join(" ")') - if [[ ! "$expected" =~ ^[0-9a-f]{40}\ [0-9a-f]{40}$ || "$actual" != "$expected" ]]; then - echo "::error::Checked-out merge does not match the PR's current base and head" - exit 1 - fi - - name: Install xcbeautify run: | brew install xcbeautify - - name: Swift packages uses: ./.github/actions/swift-packages - - name: Pre-start simulator run: | echo "⏱️ Starting simulator at $(date)" xcrun simctl boot "iPhone 17" || true echo "✅ Simulator started at $(date)" - - name: Build iOS app (local) env: GITHUB_ACTOR: ${{ github.actor }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CHATWOOT_API: ${{ secrets.CHATWOOT_API }} - SIMULATOR_NAME: "iPhone 17" + SIMULATOR_NAME: iPhone 17 OS_VERSION: "26.2" run: | echo "=== Building iOS app (local) ===" @@ -198,27 +211,34 @@ jobs: TREZOR_BRIDGE_URL=http://127.0.0.1:21325 \ -allowProvisioningUpdates \ build - - name: Prepare app for E2E tests (local) run: | # Copy the .app bundle to the expected location and name mkdir -p e2e-app cp -r DerivedData/Build/Products/Debug-iphonesimulator/Bitkit.app e2e-app/bitkit.app - + - name: Record app build identity + env: + BUILD_PLATFORM: ios + BUILD_BACKEND: local + run: |- + python3 - <<'PY' + import json, os, subprocess + from pathlib import Path + Path('e2e-app/e2e-build.json').write_text(json.dumps({'appSha': subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip(), 'platform': os.environ['BUILD_PLATFORM'], 'backend': os.environ['BUILD_BACKEND']})) + PY - name: Upload iOS app (local) uses: actions/upload-artifact@v7 with: name: bitkit-e2e-ios_${{ github.run_number }} path: e2e-app/ - + retention-days: 7 + timeout-minutes: 40 e2e-branch: - needs: detect-changes - if: github.event.pull_request.draft == false && needs.detect-changes.outputs.code == 'true' + if: github.event_name != 'pull_request' || github.event.pull_request.draft == false uses: synonymdev/bitkit-e2e-tests/.github/workflows/determine-e2e-branch.yml@main with: app_branch: ${{ inputs.app_branch || github.head_ref || github.ref_name }} e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - e2e-tests-local: cache-mode: read permissions: @@ -227,32 +247,37 @@ jobs: pull-requests: read checks: write actions: write - if: github.event.pull_request.draft == false && needs.detect-changes.outputs.code == 'true' + if: needs.detect-changes.outputs.code == 'true' runs-on: namespace-profile-mac - needs: [detect-changes, build-local, e2e-branch] + needs: + - detect-changes + - build-local + - e2e-branch env: - SIMULATOR_NAME: "iPhone 17" + SIMULATOR_NAME: iPhone 17 SIMULATOR_OS_VERSION: "26.2" - + PLATFORM: ios + BACKEND: local + AUT_FILENAME: bitkit.app + E2E_APP_SHA: ${{ needs.detect-changes.outputs.appSha }} + E2E_RUN_ID: ${{ format('{0}-{1}-local-{2}', github.run_id, github.run_attempt, matrix.shard.name) }} + E2E_PLAN: artifacts/reporting/plan.json + E2E_BACKEND_REVISION: local-docker + E2E_VARIANT: local-trezor + E2E_CASE_IDS: ${{ toJSON(matrix.shard.ids) }} + RECORD_VIDEO: "true" strategy: fail-fast: false matrix: - shard: - # - { name: onboarding_backup_numberpad, grep: "@onboarding|@backup|@numberpad" } - # - { name: onchain_boost_receive_widgets, grep: "@onchain|@boost|@receive|@widgets" } - # - { name: settings, grep: "@settings" } - # - { name: security, grep: "@security" } - - { name: e2e, grep: '@ios_gate' } - + shard: ${{ fromJSON(needs.detect-changes.outputs.matrix) }} + max-parallel: 2 name: e2e-tests-local - ${{ matrix.shard.name }} - steps: - name: Checkout stack scripts uses: actions/checkout@v7 with: - ref: ${{ inputs.merge_commit || '' }} + ref: ${{ needs.detect-changes.outputs.appSha }} sparse-checkout: .github/scripts - - name: Checkout retarget reporter if: inputs.retarget_run != '' uses: actions/checkout@v7 @@ -274,35 +299,23 @@ jobs: script: | const {reportRetargetResult} = require('./retarget-ci/.github/scripts/retarget-tests.cjs'); await reportRetargetResult({github, context, core, name: 'e2e-status', phase: 'start'}); - - - name: Resolve E2E tests ref - id: e2e-ref - uses: synonymdev/bitkit-e2e-tests/.github/actions/resolve-e2e-ref@main - with: - selected: ${{ needs.e2e-branch.outputs.branch }} - e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - - name: Clone E2E tests uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests path: bitkit-e2e-tests - ref: ${{ steps.e2e-ref.outputs.ref }} - + ref: ${{ needs.detect-changes.outputs.e2eSha }} - name: Download iOS app uses: actions/download-artifact@v8 with: name: bitkit-e2e-ios_${{ github.run_number }} path: bitkit-e2e-tests/aut - - name: List iOS app directory contents run: ls -l bitkit-e2e-tests/aut - - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 22 - - name: Cache npm cache uses: actions/cache@v6 with: @@ -310,17 +323,14 @@ jobs: key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - - name: Install dependencies working-directory: bitkit-e2e-tests run: npm ci - - name: Run regtest setup run: | .github/scripts/namespace-stack.sh up bitkit-e2e-tests/docker (cd bitkit-e2e-tests/docker && docker compose up --force-recreate -d) .github/scripts/namespace-stack.sh forward 43782 39388 60001 8080 9735 10009 3003 9001 9002 21325 21328 - - name: Wait for electrum server run: | for _ in $(seq 1 60); do @@ -328,7 +338,6 @@ jobs: sleep 5 done exit 1 - - name: Copy LND credentials working-directory: bitkit-e2e-tests/docker run: | @@ -339,13 +348,11 @@ jobs: done docker cp lnd:/home/lnd/.lnd/tls.cert lnd/tls.cert test -s lnd/data/chain/bitcoin/regtest/admin.macaroon - - name: Clear previous E2E artifacts working-directory: bitkit-e2e-tests run: | rm -rf artifacts/ rm -rf /tmp/lock/ - - name: Clear iOS Simulator environment run: | udid=$(xcrun simctl list devices available -j | python3 -c ' @@ -359,77 +366,67 @@ jobs: xcrun simctl boot "$udid" xcrun simctl bootstatus "$udid" -b defaults write com.apple.iphonesimulator DisableAllNotifications -bool true - - - name: Run E2E Tests 1 (${{ matrix.shard.name }}) - continue-on-error: true - id: test1 + - name: Prepare exact run plan working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep '${{ matrix.shard.grep }}' - env: - RECORD_VIDEO: true - ATTEMPT: 1 - - - name: Run E2E Tests 2 (${{ matrix.shard.name }}) - continue-on-error: true - if: steps.test1.outcome != 'success' - id: test2 + run: node --experimental-strip-types scripts/ci-run.ts prepare + - name: Run selected tests (up to three attempts) + id: tests working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.shard.grep }}" + run: node --experimental-strip-types scripts/ci-run.ts run env: - RECORD_VIDEO: true - ATTEMPT: 2 - - - name: Run E2E Tests 3 (${{ matrix.shard.name }}) - if: steps.test1.outcome != 'success' && steps.test2.outcome != 'success' - id: test3 + RECORD_VIDEO: "true" + - name: Report all attempts + if: always() working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.shard.grep }}" - env: - RECORD_VIDEO: true - ATTEMPT: 3 - + run: node --experimental-strip-types scripts/ci-run.ts report - name: Upload E2E Artifacts (${{ matrix.shard.name }}) - # Preserve failed-attempt evidence even when a retry passes. if: always() uses: actions/upload-artifact@v7 with: name: e2e-artifacts_${{ matrix.shard.name }}_${{ github.run_number }} path: bitkit-e2e-tests/artifacts/ retention-days: 7 - - name: Tear down regtest stack if: always() run: .github/scripts/namespace-stack.sh down - + timeout-minutes: 135 e2e-status: - if: always() && github.event.pull_request.draft == false + if: always() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) name: e2e-status runs-on: ubuntu-latest - needs: [detect-changes, e2e-tests-local] + needs: + - detect-changes + - build-local + - e2e-tests-local + - staging + - migration + - build-network steps: - - name: Verify change detection succeeded - if: needs.detect-changes.result != 'success' - run: | - echo "Change detection failed or did not complete." - exit 1 - - - name: E2E skipped - no code changes - if: needs.detect-changes.outputs.code != 'true' - run: echo "✅ E2E skipped - no code changes" - - - name: Verify all E2E shards succeeded - if: needs.detect-changes.outputs.code == 'true' - run: | - if [ "${{ needs.e2e-tests-local.result }}" != "success" ]; then - echo "❌ Some E2E shards failed." - exit 1 - fi - echo "✅ All E2E shards passed!" - + - name: Require all selected jobs + env: + RESULTS: ${{ toJSON(needs) }} + run: |- + python3 - <<'PY' + import json, os + j=json.loads(os.environ['RESULTS']) + assert j['detect-changes']['result']=='success', 'Selection did not succeed' + s=j['detect-changes']['outputs'] + required=['build-local','e2e-tests-local'] if s['code']=='true' else [] + if s['staging']=='true' or s['migration']=='true': required.append('build-network') + required += [k for k in ['staging','migration'] if s[k]=='true'] + failed={k:j[k]['result'] for k in required if j[k]['result']!='success'} + assert not failed, f'Selected jobs did not pass: {failed}' + print('All selected E2E jobs passed' if required else 'No app code changes') + PY report-retarget-result: name: Report retarget E2E result if: always() && inputs.retarget_run != '' - needs: [detect-changes, build-local, e2e-branch, e2e-tests-local, e2e-status] + needs: + - detect-changes + - build-local + - e2e-branch + - e2e-tests-local + - e2e-status runs-on: ubuntu-latest permissions: contents: read @@ -455,3 +452,115 @@ jobs: const {reportRetargetResult, e2eRetargetResult} = require('./retarget-ci/.github/scripts/retarget-tests.cjs'); const result = e2eRetargetResult(JSON.parse(process.env.TEST_RESULTS)); await reportRetargetResult({github, context, core, name: 'e2e-status', phase: 'complete', result}); + staging: + needs: + - detect-changes + - build-network + if: needs.detect-changes.outputs.staging == 'true' + uses: ./.github/workflows/e2e-staging.yml + with: + app_ref: ${{ needs.detect-changes.outputs.appSha }} + e2e_ref: ${{ needs.detect-changes.outputs.e2eSha }} + app_artifact: bitkit-e2e-ios-network_${{ github.run_number }} + post_to_slack: ${{ github.event_name == 'schedule' }} + secrets: inherit + migration: + needs: + - detect-changes + - build-network + if: needs.detect-changes.outputs.migration == 'true' + uses: ./.github/workflows/e2e_migration.yml + with: + app_ref: ${{ needs.detect-changes.outputs.appSha }} + e2e_ref: ${{ needs.detect-changes.outputs.e2eSha }} + extended_rn: true + app_artifact: bitkit-e2e-ios-network_${{ github.run_number }} + post_to_slack: ${{ github.event_name == 'schedule' }} + secrets: inherit + build-network: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + ref: ${{ needs.detect-changes.outputs.appSha }} + - name: Record selected revision + id: app + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Set up Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: "26.2" + - name: System Information + run: | + echo "=== System Information ===" + echo "macOS Version:" + sw_vers + echo "" + echo "Xcode Version:" + xcodebuild -version + - name: Install xcbeautify + run: | + brew install xcbeautify + - name: Swift packages + uses: ./.github/actions/swift-packages + - name: Pre-start simulator + run: | + echo "⏱️ Starting simulator at $(date)" + xcrun simctl boot "${{ env.SIMULATOR_NAME }}" || true + echo "✅ Simulator started at $(date)" + - name: Build iOS app (regtest) + env: + GITHUB_ACTOR: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + E2E_BACKEND: network + E2E_NETWORK: regtest + run: | + echo "=== Building iOS app (regtest) ===" + echo "Using simulator: ${{ env.SIMULATOR_NAME }} (iOS ${{ env.IOS_VERSION }})" + + if xcodebuild -showsdks | grep -q "iOS Simulator"; then + echo "✅ iOS Simulator platform already installed" + else + echo "⚙️ iOS Simulator platform not found — downloading..." + xcodebuild -downloadPlatform iOS + fi + + xcodebuild -workspace Bitkit.xcodeproj/project.xcworkspace \ + -scheme Bitkit \ + -configuration Debug \ + -destination "platform=iOS Simulator,name=${{ env.SIMULATOR_NAME }},OS=${{ env.IOS_VERSION }}" \ + -derivedDataPath DerivedData \ + -clonedSourcePackagesDirPath "$SOURCE_PACKAGES" \ + -disablePackageRepositoryCache \ + -disableAutomaticPackageResolution \ + -skipPackageUpdates \ + SWIFT_ACTIVE_COMPILATION_CONDITIONS='DEBUG E2E_BUILD' \ + -allowProvisioningUpdates \ + build + - name: Prepare app for E2E tests (regtest) + run: | + mkdir -p e2e-app + cp -r DerivedData/Build/Products/Debug-iphonesimulator/Bitkit.app e2e-app/bitkit.app + - name: Record app build identity + env: + BUILD_PLATFORM: ios + BUILD_BACKEND: regtest + run: |- + python3 - <<'PY' + import json, os, subprocess + from pathlib import Path + Path('e2e-app/e2e-build.json').write_text(json.dumps({'appSha': subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip(), 'platform': os.environ['BUILD_PLATFORM'], 'backend': os.environ['BUILD_BACKEND']})) + PY + - name: Upload iOS app (regtest) + uses: actions/upload-artifact@v7 + with: + name: bitkit-e2e-ios-network_${{ github.run_number }} + path: e2e-app/ + retention-days: 7 + timeout-minutes: 40 + outputs: + appSha: ${{ steps.app.outputs.sha }} + needs: + - detect-changes + if: needs.detect-changes.outputs.staging == 'true' || needs.detect-changes.outputs.migration == 'true' diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index 9153cbcc5..6ae3e7589 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -1,55 +1,68 @@ name: E2E Migration (iOS) - on: workflow_dispatch: inputs: e2e_branch: - description: "Branch of synonymdev/bitkit-e2e-tests to use (main | default-feature-branch | custom branch name)" + description: Branch of synonymdev/bitkit-e2e-tests to use (main | default-feature-branch | custom branch name) required: false - default: "default-feature-branch" + default: default-feature-branch previous_native_version: - description: "Archived native release tag (empty uses the pinned baseline)" + description: Archived native release tag (empty uses the pinned baseline) required: false default: "" extended_rn: - description: "Also run RN 1.1.6 passphrase and legacy-address cases" + description: Also run RN 1.1.6 passphrase and legacy-address cases type: boolean default: false post_to_slack: - description: "Post E2E migration summary to Slack (#bitkit-staging-nightly)" + description: Post E2E migration summary to Slack (#bitkit-staging-nightly) type: boolean default: false required: false - schedule: - - cron: "0 3 * * *" - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - + workflow_call: + inputs: + app_ref: + type: string + required: true + e2e_ref: + type: string + required: true + extended_rn: + type: boolean + default: true + app_artifact: + type: string + default: "" + post_to_slack: + type: boolean + default: false env: TERM: xterm-256color FORCE_COLOR: 1 GIT_LFS_SKIP_SMUDGE: "1" - SIMULATOR_NAME: "iPhone 17" + SIMULATOR_NAME: iPhone 17 IOS_VERSION: "26.2" - concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} + group: e2e-migration-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true - jobs: build: - if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) - runs-on: macos-latest - + runs-on: ${{ inputs.app_artifact != '' && 'ubuntu-latest' || 'macos-latest' }} steps: - name: Checkout uses: actions/checkout@v7 - + with: + ref: ${{ inputs.app_ref || github.sha }} + if: inputs.app_artifact == '' + - name: Record selected revision + id: app + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + if: inputs.app_artifact == '' - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: "26.2" - + if: inputs.app_artifact == '' - name: System Information run: | echo "=== System Information ===" @@ -58,20 +71,20 @@ jobs: echo "" echo "Xcode Version:" xcodebuild -version - + if: inputs.app_artifact == '' - name: Install xcbeautify run: | brew install xcbeautify - + if: inputs.app_artifact == '' - name: Swift packages uses: ./.github/actions/swift-packages - + if: inputs.app_artifact == '' - name: Pre-start simulator run: | echo "⏱️ Starting simulator at $(date)" xcrun simctl boot "${{ env.SIMULATOR_NAME }}" || true echo "✅ Simulator started at $(date)" - + if: inputs.app_artifact == '' - name: Build iOS app (regtest) env: GITHUB_ACTOR: ${{ github.actor }} @@ -102,32 +115,47 @@ jobs: SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) E2E_BUILD' \ -allowProvisioningUpdates \ build - + if: inputs.app_artifact == '' - name: Prepare app for E2E tests (regtest) run: | # Copy the .app bundle to the expected location and name mkdir -p e2e-app cp -r DerivedData/Build/Products/Debug-iphonesimulator/Bitkit.app e2e-app/bitkit.app - + if: inputs.app_artifact == '' + - name: Record app build identity + run: |- + python3 - <<'PY' + import json, subprocess + from pathlib import Path + Path('e2e-app/e2e-build.json').write_text(json.dumps({'appSha': subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip(), 'platform':'ios', 'backend':'regtest'})) + PY + if: inputs.app_artifact == '' - name: Upload iOS app (regtest) uses: actions/upload-artifact@v7 with: - name: bitkit-e2e-ios_${{ github.run_number }} + name: bitkit-e2e-migration-ios_${{ github.run_number }} path: e2e-app/ - + retention-days: 7 + if: inputs.app_artifact == '' + - name: Reuse the network app build + if: inputs.app_artifact != '' + env: + APP_ARTIFACT: ${{ inputs.app_artifact }} + run: echo "Using app artifact $APP_ARTIFACT" + timeout-minutes: 40 + outputs: + appSha: ${{ inputs.app_ref || steps.app.outputs.sha }} e2e-branch: - if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) uses: synonymdev/bitkit-e2e-tests/.github/workflows/determine-e2e-branch.yml@main with: app_branch: ${{ github.head_ref || github.ref_name }} - e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - + e2e_branch_input: ${{ inputs.e2e_branch || 'default-feature-branch' }} resolve-e2e-ref: - if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) - needs: [e2e-branch] + needs: + - e2e-branch runs-on: ubuntu-latest outputs: - ref: ${{ steps.e2e-ref.outputs.ref }} + ref: ${{ inputs.e2e_ref || steps.e2e-ref.outputs.ref }} steps: - name: Resolve E2E tests ref id: e2e-ref @@ -135,10 +163,11 @@ jobs: with: selected: ${{ needs.e2e-branch.outputs.branch }} e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - + if: inputs.e2e_ref == '' + timeout-minutes: 135 migration-plan: - if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) - needs: [e2e-branch] + needs: + - e2e-branch runs-on: ubuntu-latest outputs: version: ${{ steps.plan.outputs.version }} @@ -152,20 +181,24 @@ jobs: with: selected: ${{ needs.e2e-branch.outputs.branch }} e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} + if: inputs.e2e_ref == '' - name: Checkout E2E tests uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests - ref: ${{ steps.e2e-ref.outputs.ref }} + ref: ${{ inputs.e2e_ref || steps.e2e-ref.outputs.ref || 'main' }} - name: Resolve migration cases and baseline id: plan run: python3 scripts/migration-matrix.py ios env: PREVIOUS_NATIVE_VERSION: ${{ inputs.previous_native_version }} EXTENDED_RN: ${{ inputs.extended_rn || false }} - + timeout-minutes: 135 prepare-wallets: - needs: [e2e-branch, resolve-e2e-ref, migration-plan] + needs: + - e2e-branch + - resolve-e2e-ref + - migration-plan uses: synonymdev/bitkit-e2e-tests/.github/workflows/migration-wallet-setup.yml@main strategy: fail-fast: false @@ -177,22 +210,31 @@ jobs: rn_version: v1.1.6 setup_type: ${{ matrix.scenario.setup_type }} scenario_name: ${{ matrix.scenario.name }} - rn-tests: - if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) runs-on: macos-latest - needs: [build, e2e-branch, migration-plan, prepare-wallets] - + needs: + - build + - e2e-branch + - migration-plan + - prepare-wallets strategy: fail-fast: false matrix: scenario: ${{ fromJSON(needs.migration-plan.outputs.rn) }} - name: ios RN - ${{ matrix.scenario.version }} - ${{ matrix.scenario.name }} env: BACKEND: regtest MIGRATION_EXTENDED: ${{ inputs.extended_rn || false }} - + PLATFORM: ios + AUT_FILENAME: bitkit.app + SIMULATOR_NAME: iPhone 17 + SIMULATOR_OS_VERSION: "26.2" + E2E_APP_SHA: ${{ needs.build.outputs.appSha }} + E2E_RUN_ID: ${{ format('{0}-{1}-migration-{2}', github.run_id, github.run_attempt, matrix.scenario.name) }} + E2E_PLAN: artifacts/reporting/plan.json + E2E_BACKEND_REVISION: regtest-stag0 + E2E_VARIANT: network-regtest-migration + E2E_CASE_TAG: ${{ matrix.scenario.grep }} steps: - name: Resolve E2E tests ref id: e2e-ref @@ -200,38 +242,31 @@ jobs: with: selected: ${{ needs.e2e-branch.outputs.branch }} e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - + if: inputs.e2e_ref == '' - name: Clone E2E tests uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests path: bitkit-e2e-tests - ref: ${{ steps.e2e-ref.outputs.ref }} - + ref: ${{ inputs.e2e_ref || steps.e2e-ref.outputs.ref || 'main' }} - name: Download iOS app uses: actions/download-artifact@v8 with: - name: bitkit-e2e-ios_${{ github.run_number }} + name: ${{ inputs.app_artifact || format('bitkit-e2e-migration-ios_{0}', github.run_number) }} path: bitkit-e2e-tests/aut - - name: Download migration env uses: actions/download-artifact@v8 with: name: migration-env_${{ matrix.scenario.version }}_${{ matrix.scenario.name }} path: bitkit-e2e-tests/artifacts - - # Restore uses the Android-prepared seed directly in the native target. - # Its source provenance is in the matching migration-setup-logs artifact. - name: Download migration source if: matrix.scenario.name != 'rn_restore' working-directory: bitkit-e2e-tests run: python3 scripts/download-migration-app.py ios '${{ matrix.scenario.source }}' '${{ matrix.scenario.version }}' - - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 22 - - name: Cache npm cache uses: actions/cache@v6 with: @@ -239,17 +274,14 @@ jobs: key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - - name: Install dependencies working-directory: bitkit-e2e-tests run: npm ci - - name: Install ffmpeg run: | echo "Installing ffmpeg..." brew install ffmpeg || brew upgrade ffmpeg || true echo "ffmpeg version: $(ffmpeg -version | head -1)" - - name: Boot Simulator run: | echo "Erasing simulator..." @@ -269,7 +301,6 @@ jobs: echo "Verifying simulator state..." xcrun simctl list devices booted - - name: Start Appium Server working-directory: bitkit-e2e-tests run: | @@ -289,62 +320,61 @@ jobs: done curl --fail --silent --show-error http://127.0.0.1:4723/status > /dev/null - - name: Load independent wallet for attempt 1 working-directory: bitkit-e2e-tests run: cat 'artifacts/wallet-attempt-1/migration_setup_${{ matrix.scenario.setup_type }}.env' >> "$GITHUB_ENV" - + - name: Prepare exact migration plan + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts prepare - name: Run E2E Tests 1 (${{ matrix.scenario.name }}) continue-on-error: true id: test1 working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep '${{ matrix.scenario.grep }}' + run: node --experimental-strip-types scripts/ci-run.ts attempt env: SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} RECORD_VIDEO: true ATTEMPT: 1 - - name: Load independent wallet for attempt 2 if: steps.test1.outcome != 'success' working-directory: bitkit-e2e-tests run: cat 'artifacts/wallet-attempt-2/migration_setup_${{ matrix.scenario.setup_type }}.env' >> "$GITHUB_ENV" - - name: Run E2E Tests 2 (${{ matrix.scenario.name }}) continue-on-error: true if: steps.test1.outcome != 'success' id: test2 working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.scenario.grep }}" + run: node --experimental-strip-types scripts/ci-run.ts attempt env: SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} RECORD_VIDEO: true ATTEMPT: 2 - - name: Load independent wallet for attempt 3 if: steps.test1.outcome != 'success' && steps.test2.outcome != 'success' working-directory: bitkit-e2e-tests run: cat 'artifacts/wallet-attempt-3/migration_setup_${{ matrix.scenario.setup_type }}.env' >> "$GITHUB_ENV" - - name: Run E2E Tests 3 (${{ matrix.scenario.name }}) if: steps.test1.outcome != 'success' && steps.test2.outcome != 'success' id: test3 working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.scenario.grep }}" + run: node --experimental-strip-types scripts/ci-run.ts attempt env: SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} RECORD_VIDEO: true ATTEMPT: 3 - - name: Copy Appium logs to artifacts if: always() working-directory: bitkit-e2e-tests run: | mkdir -p artifacts cp appium.log artifacts/ || true - + - name: Report all migration attempts + if: always() + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts report - name: Upload E2E Artifacts (${{ matrix.scenario.name }}) if: always() uses: actions/upload-artifact@v7 @@ -353,22 +383,32 @@ jobs: path: | bitkit-e2e-tests/artifacts/ !bitkit-e2e-tests/artifacts/**/*.env - + retention-days: 7 + timeout-minutes: 135 native-tests: - if: github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-'))) runs-on: macos-latest - needs: [build, e2e-branch, migration-plan] - + needs: + - build + - e2e-branch + - migration-plan strategy: fail-fast: false matrix: scenario: ${{ fromJSON(needs.migration-plan.outputs.native) }} - name: ios native - ${{ matrix.scenario.version }} - ${{ matrix.scenario.name }} env: BACKEND: regtest MIGRATION_EXTENDED: ${{ inputs.extended_rn || false }} - + PLATFORM: ios + AUT_FILENAME: bitkit.app + SIMULATOR_NAME: iPhone 17 + SIMULATOR_OS_VERSION: "26.2" + E2E_APP_SHA: ${{ needs.build.outputs.appSha }} + E2E_RUN_ID: ${{ format('{0}-{1}-migration-{2}', github.run_id, github.run_attempt, matrix.scenario.name) }} + E2E_PLAN: artifacts/reporting/plan.json + E2E_BACKEND_REVISION: regtest-stag0 + E2E_VARIANT: network-regtest-migration + E2E_CASE_TAG: ${{ matrix.scenario.grep }} steps: - name: Resolve E2E tests ref id: e2e-ref @@ -376,29 +416,25 @@ jobs: with: selected: ${{ needs.e2e-branch.outputs.branch }} e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - + if: inputs.e2e_ref == '' - name: Clone E2E tests uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests path: bitkit-e2e-tests - ref: ${{ steps.e2e-ref.outputs.ref }} - + ref: ${{ inputs.e2e_ref || steps.e2e-ref.outputs.ref || 'main' }} - name: Download iOS app uses: actions/download-artifact@v8 with: - name: bitkit-e2e-ios_${{ github.run_number }} + name: ${{ inputs.app_artifact || format('bitkit-e2e-migration-ios_{0}', github.run_number) }} path: bitkit-e2e-tests/aut - - name: Download migration source working-directory: bitkit-e2e-tests run: python3 scripts/download-migration-app.py ios '${{ matrix.scenario.source }}' '${{ matrix.scenario.version }}' - - name: Setup Node.js uses: actions/setup-node@v7 with: node-version: 22 - - name: Cache npm cache uses: actions/cache@v6 with: @@ -406,17 +442,14 @@ jobs: key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - - name: Install dependencies working-directory: bitkit-e2e-tests run: npm ci - - name: Install ffmpeg run: | echo "Installing ffmpeg..." brew install ffmpeg || brew upgrade ffmpeg || true echo "ffmpeg version: $(ffmpeg -version | head -1)" - - name: Boot Simulator run: | echo "Erasing simulator..." @@ -436,7 +469,6 @@ jobs: echo "Verifying simulator state..." xcrun simctl list devices booted - - name: Start Appium Server working-directory: bitkit-e2e-tests run: | @@ -456,48 +488,50 @@ jobs: done curl --fail --silent --show-error http://127.0.0.1:4723/status > /dev/null - + - name: Prepare exact migration plan + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts prepare - name: Run E2E Tests 1 (${{ matrix.scenario.name }}) continue-on-error: true id: test1 working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep '${{ matrix.scenario.grep }}' + run: node --experimental-strip-types scripts/ci-run.ts attempt env: SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} RECORD_VIDEO: true ATTEMPT: 1 - - name: Run E2E Tests 2 (${{ matrix.scenario.name }}) continue-on-error: true if: steps.test1.outcome != 'success' id: test2 working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.scenario.grep }}" + run: node --experimental-strip-types scripts/ci-run.ts attempt env: SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} RECORD_VIDEO: true ATTEMPT: 2 - - name: Run E2E Tests 3 (${{ matrix.scenario.name }}) if: steps.test1.outcome != 'success' && steps.test2.outcome != 'success' id: test3 working-directory: bitkit-e2e-tests - run: ./ci_run_ios.sh --mochaOpts.grep "${{ matrix.scenario.grep }}" + run: node --experimental-strip-types scripts/ci-run.ts attempt env: SIMULATOR_NAME: ${{ env.SIMULATOR_NAME }} SIMULATOR_OS_VERSION: ${{ env.IOS_VERSION }} RECORD_VIDEO: true ATTEMPT: 3 - - name: Copy Appium logs to artifacts if: always() working-directory: bitkit-e2e-tests run: | mkdir -p artifacts cp appium.log artifacts/ || true - + - name: Report all migration attempts + if: always() + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts report - name: Upload E2E Artifacts (${{ matrix.scenario.name }}) if: always() uses: actions/upload-artifact@v7 @@ -506,10 +540,18 @@ jobs: path: | bitkit-e2e-tests/artifacts/ !bitkit-e2e-tests/artifacts/**/*.env - + retention-days: 7 + timeout-minutes: 135 migration-result: - if: always() && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (startsWith(github.head_ref, 'release-') || startsWith(github.base_ref, 'release-')))) - needs: [build, e2e-branch, resolve-e2e-ref, migration-plan, prepare-wallets, rn-tests, native-tests] + if: always() + needs: + - build + - e2e-branch + - resolve-e2e-ref + - migration-plan + - prepare-wallets + - rn-tests + - native-tests runs-on: ubuntu-latest steps: - name: Require all migration jobs to succeed @@ -523,14 +565,19 @@ jobs: print(f'Migration failed or required jobs did not run: {failed}') sys.exit(1) PYTHON - + timeout-minutes: 135 slack-report: - if: >- - always() && - (github.event_name == 'schedule' || - (github.event_name == 'workflow_dispatch' && inputs.post_to_slack == true)) + if: always() && inputs.post_to_slack == true runs-on: ubuntu-latest - needs: [build, e2e-branch, resolve-e2e-ref, migration-plan, prepare-wallets, rn-tests, native-tests, migration-result] + needs: + - build + - e2e-branch + - resolve-e2e-ref + - migration-plan + - prepare-wallets + - rn-tests + - native-tests + - migration-result steps: - name: Resolve E2E tests ref id: e2e-ref @@ -539,13 +586,12 @@ jobs: with: selected: ${{ needs.e2e-branch.outputs.branch }} e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - + if: inputs.e2e_ref == '' - name: Checkout E2E tests uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests - ref: ${{ steps.e2e-ref.outputs.ref || 'main' }} - + ref: ${{ inputs.e2e_ref || steps.e2e-ref.outputs.ref || 'main' }} - name: Post Slack summary env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_STAGING }} @@ -559,3 +605,4 @@ jobs: RUN_ATTEMPT: ${{ github.run_attempt }} E2E_TESTS_REF: ${{ steps.e2e-ref.outputs.ref }} run: python3 scripts/slack_summary.py e2e-migration + timeout-minutes: 135 diff --git a/Docs/e2e-ci.md b/Docs/e2e-ci.md new file mode 100644 index 000000000..65ef1aa06 --- /dev/null +++ b/Docs/e2e-ci.md @@ -0,0 +1,20 @@ +# iOS E2E CI + +`e2e-tests.yml` is the entry point for PR, manual and nightly E2E selection. The shared policy and runner live in `synonymdev/bitkit-e2e-tests`; see [selection and reporting](https://github.com/synonymdev/bitkit-e2e-tests/blob/main/docs/inventory-reporting.md). + +The base gate is exactly `send_1`, `send_2`, `lightning_1`, `transfer_2`, `boost_2`. Changed files automatically add risk coverage. Broad or unclassified changes expand to all applicable lanes. The required `e2e-status` aggregates every selected job. Existing unit and native integration workflows remain separate and unchanged. Android is unchanged. + +Every night at 03:00 UTC the entry point runs all applicable local, staging and extended migration cases, even for an unchanged revision. The existing staging/migration workflows provide reusable execution and retain standalone manual dispatch. They no longer schedule duplicate runs. Known skipped cases and unsupported mainnet fixtures are reported as gaps, not successful coverage. + +Manual validation uses the branch containing the changed workflow: + +```sh +gh workflow run e2e-tests.yml --ref codex/264-inventory-shadow \ + -f e2e_branch=codex/264-inventory-shadow -f selection=base -f shards=1 +``` + +To test a PR merge, including a stacked PR, also pass `-f pr=NUMBER -f selection=auto`. The merge must match the PR's actual base and head. `selection=full` exercises all lanes. The optional `shards=2` experiment applies only to manual local execution; automatic runs use one shard. Each shard has isolated infrastructure and shares the same built local app. Staging/migration share one network app build. + +Attempts are bounded to three, stop on success, and retain first-failure evidence. Missing selected cases or failed selected jobs fail the aggregate status. Builds are capped at 40 minutes, execution jobs at 135 minutes and runner attempts at 40 minutes. Diagnostic artifacts expire after seven days. Compare end-to-end duration and billed Mac/backend usage before making two shards automatic. + +Land the E2E producer first. Validate these branches before merging this consumer. There is no shadow workflow and no additional smoke-test replacement. diff --git a/Docs/e2e-smoke-shadow.md b/Docs/e2e-smoke-shadow.md deleted file mode 100644 index ad23ce90e..000000000 --- a/Docs/e2e-smoke-shadow.md +++ /dev/null @@ -1,46 +0,0 @@ -# iOS smoke shadow (#264) - -This manual validation workflow observes three fresh-wallet regtest cases. It does not -replace `e2e-status`, alter rulesets, or change existing gates and schedules. -The inventory/reporting and smoke producer in `synonymdev/bitkit-e2e-tests` must -land first, or use matching companion branches in both repositories. - -This workflow is **manual-only** (`workflow_dispatch`). Opening, updating or -retargeting a PR, pushing a commit, or changing a build script does not start it. -The existing gates remain the only automatic app gates during validation. Dispatch -against an existing selected revision; there is no need to push a new commit to -validate it. The checkout must match that dispatch's SHA. - -After representative manual runs validate the smoke pack, switch the automatic PR -gate in one deliberate change. Do not add an automatic smoke job alongside the old -gate as a rollout phase. Existing nightly coverage continues unchanged. - -Each selected run uses one standard hosted Mac, one simulator and one test attempt. -The job is capped at 60 minutes, the build at 30 and tests at 20. A newer manual dispatch on the same ref cancels -an older manual smoke run. Cache access is read-only; evidence retention is seven days. -There are no automatic retries or shards. Manual validation consumes compute only when explicitly dispatched; it does not -add an automatic Mac run to each PR. Do not assume parallelism reduces -billed minutes or that hosted-runner availability guarantees no queue. - -Inspect `e2e-smoke-shadow-status`, the Actions summary and `ios-smoke-shadow-*` -artifact. The report records app/E2E revisions, app archive digest, backend identity, -expected IDs, each outcome, first failure and duration. Build/setup failures report -incomplete coverage. Test durations do not include billed build/setup minutes. - -Before changing any required check, validate hosted execution, concurrent-run -isolation, representative failures, first-pass reliability and measured end-to-end -latency/cost over an agreed sample. The 10–15 minute smoke target is a measurement -goal, not a claim that this workflow has achieved it. Risk packs, full nightly -orchestration and the two-shard experiment remain later stages. - -Local workflow checks: - -```sh -node --test .github/scripts/smoke-shadow.test.cjs -actionlint -ignore 'unexpected key "cache-mode" for "job" section' .github/workflows/e2e-smoke-shadow.yml -``` - -The exception is limited to `cache-mode`, supported by -[GitHub's workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idcache-mode) -but not recognized by actionlint 1.7.12. Do not remove the read-only restriction to -satisfy the older schema. All other lint diagnostics remain errors. From edf61ffc4b23daf37ff778e9028a481133083bfc Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 3 Nov 2026 21:00:42 +0100 Subject: [PATCH 06/18] Fix shared network build and immutable migration fixture handoff --- .github/workflows/e2e-tests.yml | 3 +++ .github/workflows/e2e_migration.yml | 11 ++++++++--- Docs/e2e-ci.md | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 277194957..9a184b491 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -478,6 +478,9 @@ jobs: post_to_slack: ${{ github.event_name == 'schedule' }} secrets: inherit build-network: + env: + SIMULATOR_NAME: iPhone 17 + IOS_VERSION: "26.2" runs-on: macos-latest steps: - name: Checkout diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index 6ae3e7589..bf2f5f8c5 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -174,6 +174,7 @@ jobs: all: ${{ steps.plan.outputs.all }} rn: ${{ steps.plan.outputs.rn }} native: ${{ steps.plan.outputs.native }} + e2eSha: ${{ steps.tests-sha.outputs.sha }} steps: - name: Resolve E2E tests ref id: e2e-ref @@ -193,13 +194,16 @@ jobs: env: PREVIOUS_NATIVE_VERSION: ${{ inputs.previous_native_version }} EXTENDED_RN: ${{ inputs.extended_rn || false }} + - name: Pin resolved E2E source + id: tests-sha + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" timeout-minutes: 135 prepare-wallets: needs: - e2e-branch - resolve-e2e-ref - migration-plan - uses: synonymdev/bitkit-e2e-tests/.github/workflows/migration-wallet-setup.yml@main + uses: synonymdev/bitkit-e2e-tests/.github/workflows/migration-wallet-setup.yml@a4996b56f8a73e4d9bbc2fb300a05000595bd6cd strategy: fail-fast: false matrix: @@ -210,6 +214,7 @@ jobs: rn_version: v1.1.6 setup_type: ${{ matrix.scenario.setup_type }} scenario_name: ${{ matrix.scenario.name }} + e2e_sha: ${{ needs.migration-plan.outputs.e2eSha }} rn-tests: runs-on: macos-latest needs: @@ -248,7 +253,7 @@ jobs: with: repository: synonymdev/bitkit-e2e-tests path: bitkit-e2e-tests - ref: ${{ inputs.e2e_ref || steps.e2e-ref.outputs.ref || 'main' }} + ref: ${{ needs.migration-plan.outputs.e2eSha }} - name: Download iOS app uses: actions/download-artifact@v8 with: @@ -422,7 +427,7 @@ jobs: with: repository: synonymdev/bitkit-e2e-tests path: bitkit-e2e-tests - ref: ${{ inputs.e2e_ref || steps.e2e-ref.outputs.ref || 'main' }} + ref: ${{ needs.migration-plan.outputs.e2eSha }} - name: Download iOS app uses: actions/download-artifact@v8 with: diff --git a/Docs/e2e-ci.md b/Docs/e2e-ci.md index 65ef1aa06..26c4b0ce1 100644 --- a/Docs/e2e-ci.md +++ b/Docs/e2e-ci.md @@ -17,4 +17,4 @@ To test a PR merge, including a stacked PR, also pass `-f pr=NUMBER -f selection Attempts are bounded to three, stop on success, and retain first-failure evidence. Missing selected cases or failed selected jobs fail the aggregate status. Builds are capped at 40 minutes, execution jobs at 135 minutes and runner attempts at 40 minutes. Diagnostic artifacts expire after seven days. Compare end-to-end duration and billed Mac/backend usage before making two shards automatic. -Land the E2E producer first. Validate these branches before merging this consumer. There is no shadow workflow and no additional smoke-test replacement. +Land the E2E producer first. Validate these branches before merging this consumer. The migration fixture workflow is pinned to the producer commit that supports immutable source selection, allowing full branch validation before either PR merges. There is no shadow workflow and no additional smoke-test replacement. From f0a4aa3d708e2d3dcfc12f0773234ea9f75f595d Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 3 Nov 2026 21:02:01 +0100 Subject: [PATCH 07/18] Pin migration fixture workflow with supported cache --- .github/workflows/e2e_migration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index bf2f5f8c5..f05e4c7de 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -203,7 +203,7 @@ jobs: - e2e-branch - resolve-e2e-ref - migration-plan - uses: synonymdev/bitkit-e2e-tests/.github/workflows/migration-wallet-setup.yml@a4996b56f8a73e4d9bbc2fb300a05000595bd6cd + uses: synonymdev/bitkit-e2e-tests/.github/workflows/migration-wallet-setup.yml@39f8964ee28c801874bae1cc7667505c9bb03065 strategy: fail-fast: false matrix: From ee5e4ac0c6b8de56dfb30975243f852b6583a8f0 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 3 Nov 2026 22:39:37 +0100 Subject: [PATCH 08/18] Own iOS E2E policy and scope selection to actual feature paths --- .github/e2e-policy.json | 1015 ++++++++++++++++++++++++ .github/scripts/e2e-selection.cjs | 331 ++++++++ .github/scripts/e2e-selection.test.cjs | 293 +++++++ .github/workflows/e2e-selection.yml | 63 ++ .github/workflows/e2e-tests.yml | 7 +- Docs/e2e-ci.md | 34 +- 6 files changed, 1737 insertions(+), 6 deletions(-) create mode 100644 .github/e2e-policy.json create mode 100644 .github/scripts/e2e-selection.cjs create mode 100644 .github/scripts/e2e-selection.test.cjs create mode 100644 .github/workflows/e2e-selection.yml diff --git a/.github/e2e-policy.json b/.github/e2e-policy.json new file mode 100644 index 000000000..b49fd73f3 --- /dev/null +++ b/.github/e2e-policy.json @@ -0,0 +1,1015 @@ +{ + "schemaVersion": 1, + "base": ["send_1", "send_2", "lightning_1", "transfer_2", "boost_2"], + "groups": { + "send": [ + "send_3", + "onchain_1", + "onchain_2", + "onchain_3", + "numberpad_1", + "numberpad_2", + "numberpad_3", + "numberpad_4", + "boost_1", + "lnurl_1" + ], + "fees": ["boost_1", "onchain_3", "settings_03"], + "addresses": [ + "multi_address_1", + "multi_address_3", + "multi_address_4", + "onchain_1", + "onchain_2", + "onchain_3", + "boost_1" + ], + "receive": [ + "receive_1", + "onchain_1", + "onchain_2", + "onchain_3", + "numberpad_1", + "numberpad_2", + "numberpad_3", + "numberpad_4", + "lnurl_1" + ], + "lightning": [ + "lnurl_1", + "send_3", + "multi_address_3", + "multi_address_4", + "settings_09" + ], + "hardware": ["hardware_wallet_1", "hardware_wallet_2", "hardware_wallet_4"], + "recovery": [ + "backup_1", + "onboarding_1", + "onboarding_2", + "security_1", + "settings_07", + "migration_native_restore", + "migration_native_upgrade", + "migration_rn_restore", + "migration_rn_upgrade", + "migration_3", + "migration_4" + ], + "security": ["security_1", "settings_06", "settings_07"], + "widgets": ["widgets_1", "widgets_2", "widgets_3", "settings_12"], + "currency": [ + "numberpad_1", + "numberpad_2", + "numberpad_3", + "numberpad_4", + "settings_01", + "settings_02" + ], + "pubky-paykit": [ + "pubky_profile_1", + "pubky_profile_2", + "pubky_profile_3", + "pubky_profile_4", + "pubky_profile_5", + "paykit_1" + ], + "settings": [ + "settings_01", + "settings_02", + "settings_03", + "settings_04", + "settings_05", + "settings_06", + "settings_07", + "settings_09", + "settings_10", + "settings_11", + "settings_12", + "settings_13", + "settings_14" + ], + "electrum": ["settings_10"], + "gossip": ["settings_11"], + "tags": ["settings_04", "receive_1", "onchain_1"], + "status": ["settings_14"], + "support": ["settings_05"], + "devtools": ["settings_13"], + "lnurl": ["lnurl_1"], + "activity": ["onchain_1", "onchain_2", "onchain_3"] + }, + "rules": [ + { + "name": "app-lifecycle-and-wallet-core", + "groups": [], + "files": [ + "Bitkit/AppScene.swift", + "Bitkit/BitkitApp.swift", + "Bitkit/ContentView.swift", + "Bitkit/MainNavView.swift", + "Bitkit/SceneDelegate.swift", + "Bitkit/Services/CoreService.swift", + "Bitkit/Services/LightningService.swift", + "Bitkit/Services/ServiceQueue.swift", + "Bitkit/Managers/SessionManager.swift", + "Bitkit/Managers/BalanceManager.swift", + "Bitkit/ViewModels/AppViewModel.swift", + "Bitkit/ViewModels/WalletViewModel.swift", + "Bitkit/ViewModels/NavigationViewModel.swift", + "Bitkit/ViewModels/SheetViewModel.swift", + "Bitkit/Utilities/StartupHandler.swift", + "Bitkit/Utilities/AppReset.swift", + "Bitkit/Utilities/MainNavRouting.swift", + "Bitkit/Utilities/StateLocker.swift", + "Bitkit/Utilities/BlockingQueue.swift", + "Bitkit/Utilities/Crypto.swift", + "Bitkit/Models/BalanceState.swift", + "Bitkit/Models/NodeLifecycleState.swift", + "Bitkit/Models/WalletType.swift", + "Bitkit/Constants/Env.swift" + ], + "prefixes": [], + "full": true, + "reason": "Shared wallet/node lifecycle, account state and persistence used across all lanes." + }, + { + "name": "build-and-e2e-infrastructure", + "groups": [], + "files": [ + "Bitkit/Info.plist", + "Bitkit/Bitkit.entitlements", + ".github/workflows/e2e-tests.yml", + ".github/workflows/e2e-staging.yml", + ".github/workflows/e2e_migration.yml", + ".github/scripts/namespace-stack.sh", + ".github/actions/swift-packages/action.yml", + "scripts/shallow-source-packages.sh" + ], + "prefixes": ["Bitkit.xcodeproj/"], + "full": true, + "reason": "App dependency/build identity or orchestration shared by all lanes." + }, + { + "name": "hardware-directories", + "groups": ["hardware"], + "files": [], + "prefixes": [ + "Bitkit/Services/Trezor/", + "Bitkit/Services/Jade/", + "Bitkit/ViewModels/Trezor/", + "Bitkit/Components/Trezor/", + "Bitkit/Views/Trezor/", + "Bitkit/Views/Transfer/Hardware/" + ], + "reason": "Feature-owned source directory." + }, + { + "name": "widgets-directories", + "groups": ["widgets"], + "files": [], + "prefixes": [ + "Bitkit/Services/Widgets/", + "Bitkit/ViewModels/Widgets/", + "Bitkit/Components/Widgets/", + "Bitkit/Views/Widgets/", + "BitkitWidget/" + ], + "reason": "Feature-owned source directory." + }, + { + "name": "send-directories", + "groups": ["send"], + "files": [], + "prefixes": [ + "Bitkit/Views/Wallets/Send/", + "Bitkit/Views/Settings/Quickpay/" + ], + "reason": "Feature-owned source directory." + }, + { + "name": "receive-directories", + "groups": ["receive"], + "files": [], + "prefixes": ["Bitkit/Views/Wallets/Receive/"], + "reason": "Feature-owned source directory." + }, + { + "name": "lightning-directories", + "groups": ["lightning"], + "files": [], + "prefixes": ["Bitkit/Views/Transfer/", "Bitkit/Views/Offline/"], + "reason": "Feature-owned source directory." + }, + { + "name": "lnurl-directories", + "groups": ["lnurl"], + "files": [], + "prefixes": ["Bitkit/Views/Wallets/LnurlWithdraw/"], + "reason": "Feature-owned source directory." + }, + { + "name": "recovery-directories", + "groups": ["recovery"], + "files": [], + "prefixes": [ + "Bitkit/Views/Backup/", + "Bitkit/Views/Onboarding/", + "Bitkit/Views/Recovery/" + ], + "reason": "Feature-owned source directory." + }, + { + "name": "pubky-paykit-directories", + "groups": ["pubky-paykit"], + "files": [], + "prefixes": [ + "Bitkit/Views/Profile/", + "Bitkit/Views/Contacts/", + "Bitkit/Views/Shop/", + "Bitkit/Views/Subscriptions/", + "Bitkit/Views/PaymentRequests/" + ], + "reason": "Feature-owned source directory." + }, + { + "name": "security-directories", + "groups": ["security"], + "files": [], + "prefixes": ["Bitkit/Views/Settings/Security/", "Bitkit/Views/Security/"], + "reason": "Feature-owned source directory." + }, + { + "name": "fees-directories", + "groups": ["fees"], + "files": [], + "prefixes": ["Bitkit/Views/Settings/TransactionSpeed/"], + "reason": "Feature-owned source directory." + }, + { + "name": "support-directories", + "groups": ["support"], + "files": [], + "prefixes": ["Bitkit/Views/Settings/Support/"], + "reason": "Feature-owned source directory." + }, + { + "name": "devtools-directories", + "groups": ["devtools"], + "files": [], + "prefixes": ["Bitkit/Views/Settings/ProbingTool/"], + "reason": "Feature-owned source directory." + }, + { + "name": "tags-directories", + "groups": ["tags"], + "files": [], + "prefixes": ["Bitkit/Components/Tags/"], + "reason": "Feature-owned source directory." + }, + { + "name": "activity-directories", + "groups": ["activity"], + "files": [], + "prefixes": [ + "Bitkit/Components/Activity/", + "Bitkit/Views/Wallets/Activity/" + ], + "reason": "Feature-owned source directory." + }, + { + "name": "files-activity", + "groups": ["activity"], + "files": [ + "Bitkit/Models/ActivityDisplayConstants.swift", + "Bitkit/ViewModels/ActivityItemViewModel.swift", + "Bitkit/ViewModels/ActivityListViewModel.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-addresses", + "groups": ["addresses"], + "files": [ + "Bitkit/Extensions/LDKNode+AddressType.swift", + "Bitkit/Views/Settings/Advanced/AddressTypePreferenceView.swift", + "Bitkit/Views/Settings/Advanced/AddressViewer.swift", + "Bitkit/Views/Settings/Advanced/CoinSelectionSettingsView.swift", + "Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-currency", + "groups": ["currency"], + "files": [ + "Bitkit/Components/AmountSlider.swift", + "Bitkit/Components/AvailableAmount.swift", + "Bitkit/Components/MoneyCell.swift", + "Bitkit/Components/MoneyStack.swift", + "Bitkit/Components/MoneyText.swift", + "Bitkit/Components/NumberPad.swift", + "Bitkit/Components/NumberPadActionButton.swift", + "Bitkit/Components/NumberPadTextField.swift", + "Bitkit/Extensions/FixedWidthInteger+Saturating.swift", + "Bitkit/Models/Currency.swift", + "Bitkit/Services/CurrencyService.swift", + "Bitkit/Utilities/CurrencyFormatter.swift", + "Bitkit/ViewModels/CurrencyViewModel.swift", + "Bitkit/Views/Settings/General/DefaultUnitSettingsView.swift", + "Bitkit/Views/Settings/General/LocalCurrencySettingsView.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-devtools", + "groups": ["devtools"], + "files": [ + "Bitkit/Views/Settings/DevSettingsView.swift", + "Bitkit/Views/Settings/LdkDebugScreen.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-electrum", + "groups": ["electrum"], + "files": [ + "Bitkit/Models/ElectrumServer.swift", + "Bitkit/Services/ElectrumConfigService.swift", + "Bitkit/ViewModels/Extensions/SettingsViewModel+Electrum.swift", + "Bitkit/Views/Settings/Advanced/ElectrumSettingsScreen.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-fees", + "groups": ["fees"], + "files": [ + "Bitkit/Components/FeeDisplayRow.swift", + "Bitkit/Components/FeeItem.swift", + "Bitkit/Extensions/BroadcastError+Connectivity.swift", + "Bitkit/Managers/FeeEstimatesManager.swift", + "Bitkit/Models/TransactionSpeed.swift", + "Bitkit/Views/Wallets/Send/SendFeeCustom.swift", + "Bitkit/Views/Wallets/Send/SendFeeRate.swift", + "Bitkit/Views/Wallets/Sheets/BoostSheet.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-gossip", + "groups": ["gossip"], + "files": [ + "Bitkit/Services/RgsConfigService.swift", + "Bitkit/ViewModels/Extensions/SettingsViewModel+Rgs.swift", + "Bitkit/Views/Settings/Advanced/RgsSettingsScreen.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-hardware", + "groups": ["hardware"], + "files": [ + "Bitkit/Components/HardwareWalletsGrid.swift", + "Bitkit/Components/RemoveHwWalletDialog.swift", + "Bitkit/Extensions/HwError+Cancellation.swift", + "Bitkit/Extensions/HwWalletVendor+UI.swift", + "Bitkit/Extensions/JadeError+Cancellation.swift", + "Bitkit/Extensions/LDKNode+Jade.swift", + "Bitkit/Extensions/TrezorDevice+DisplayName.swift", + "Bitkit/Extensions/TrezorError+Cancellation.swift", + "Bitkit/Extensions/TrezorPublicKeyResponse+StoredKey.swift", + "Bitkit/Managers/HwDeviceSessioning.swift", + "Bitkit/Managers/HwWalletManager.swift", + "Bitkit/Managers/JadeManager.swift", + "Bitkit/Managers/TrezorManager.swift", + "Bitkit/Models/HwFunding.swift", + "Bitkit/Models/HwWallet.swift", + "Bitkit/Models/HwWalletId.swift", + "Bitkit/Models/HwWalletVendor.swift", + "Bitkit/Models/JadeDevice.swift", + "Bitkit/Services/HwKnownDeviceMatching.swift", + "Bitkit/Services/HwKnownDeviceStorage.swift", + "Bitkit/Services/HwSnapshotMerge.swift", + "Bitkit/Services/OnChainHwService.swift", + "Bitkit/Services/WatchOnlyAccountService.swift", + "Bitkit/Utilities/HwDevicePath.swift", + "Bitkit/Utilities/HwEngagedSession.swift", + "Bitkit/Utilities/HwErrorPresenter.swift", + "Bitkit/Utilities/TrezorErrorPresenter.swift", + "Bitkit/ViewModels/HwFundingSigner.swift", + "Bitkit/Views/Settings/Advanced/WatchOnlyAccountsView.swift", + "Bitkit/Views/Settings/General/HardwareWalletsSettingsScreen.swift", + "Bitkit/Views/Sheets/HardwareConnect/HwFoundView.swift", + "Bitkit/Views/Sheets/HardwareConnect/HwPairCodeView.swift", + "Bitkit/Views/Sheets/HardwareConnect/HwPairedView.swift", + "Bitkit/Views/Sheets/HardwareConnect/HwSearchingAnimation.swift", + "Bitkit/Views/Sheets/HardwareConnect/HwSearchingView.swift", + "Bitkit/Views/Sheets/HardwareConnectSheet.swift", + "Bitkit/Views/Sheets/HardwarePairingSheet.swift", + "Bitkit/Views/Sheets/RenameHardwareWalletSheet.swift", + "Bitkit/Views/Wallets/HardwareWalletScreen.swift", + "Bitkit/Views/Wallets/Send/HwSendSignView.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-hardware-recovery", + "groups": ["hardware", "recovery"], + "files": [ + "Bitkit/Services/HwActivityTagBackup.swift", + "Bitkit/Views/Sheets/HardwareConnect/HwPassphraseView.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-lightning", + "groups": ["lightning"], + "files": [ + "Bitkit/Components/IncomingTransfer.swift", + "Bitkit/Components/LightningChannel.swift", + "Bitkit/Components/Trezor/HwWalletConnectionIcon.swift", + "Bitkit/Extensions/ChannelDetails+Extensions.swift", + "Bitkit/Extensions/IBtChannel+Mock.swift", + "Bitkit/Extensions/LightningBalance+Extensions.swift", + "Bitkit/Extensions/NodeStatus.swift", + "Bitkit/Extensions/ShortChannelId.swift", + "Bitkit/Managers/NetworkMonitor.swift", + "Bitkit/Managers/TransferTrackingManager.swift", + "Bitkit/Models/LnPeer.swift", + "Bitkit/Models/Transfer.swift", + "Bitkit/Models/TransferType.swift", + "Bitkit/Services/BoltzService.swift", + "Bitkit/Services/SamRockService.swift", + "Bitkit/Services/TransferService.swift", + "Bitkit/Services/TransferStorage.swift", + "Bitkit/Utilities/LightningAmountConversion.swift", + "Bitkit/Utilities/NetworkValidationHelper.swift", + "Bitkit/Utilities/NodeRestarter.swift", + "Bitkit/ViewModels/BlocktankViewModel.swift", + "Bitkit/ViewModels/ChannelDetailsViewModel.swift", + "Bitkit/ViewModels/Extensions/ChannelDetails.swift", + "Bitkit/ViewModels/TransferViewModel.swift", + "Bitkit/Views/Settings/Advanced/ChannelDisplayable.swift", + "Bitkit/Views/Settings/Advanced/CloseConnectionConfirmation.swift", + "Bitkit/Views/Settings/Advanced/LightningConnectionDetailView.swift", + "Bitkit/Views/Settings/Advanced/LightningConnectionsView.swift", + "Bitkit/Views/Settings/BlocktankRegtestView.swift", + "Bitkit/Views/Settings/ChannelOrders.swift", + "Bitkit/Views/Settings/SwapsListView.swift", + "Bitkit/Views/Sheets/BTCPayConnectionSheet.swift", + "Bitkit/Views/Sheets/ForceTransferSheet.swift", + "Bitkit/Views/Sheets/TransferUnavailableSheet.swift", + "Bitkit/Views/Wallets/Receive/ReceiveTransferUnavailable.swift", + "Bitkit/Views/Wallets/Sheets/ConnectionClosedSheet.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-lnurl", + "groups": ["lnurl"], + "files": [ + "Bitkit/Extensions/LnurlPayData+Amount.swift", + "Bitkit/Utilities/Lnurl.swift", + "Bitkit/Views/Sheets/LnurlAuth/LnurlAuthSheet.swift", + "Bitkit/Views/Transfer/LnurlChannel.swift", + "Bitkit/Views/Wallets/Send/LnurlPayAmount.swift", + "Bitkit/Views/Wallets/Send/LnurlPayConfirm.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-pubky-paykit", + "groups": ["pubky-paykit"], + "files": [ + "Bitkit/Components/CenteredProfileHeader.swift", + "Bitkit/Components/ContactAvatarLetter.swift", + "Bitkit/Components/PaykitAcceptedMethods.swift", + "Bitkit/Components/PaykitAmountText.swift", + "Bitkit/Components/PaymentRequestInvoiceNote.swift", + "Bitkit/Components/PaymentRequestSummary.swift", + "Bitkit/Components/ProfileEditFormView.swift", + "Bitkit/Components/ProfileNameField.swift", + "Bitkit/Components/PubkyChoiceRow.swift", + "Bitkit/Components/PubkyContactAvatar.swift", + "Bitkit/Components/PubkyContactRow.swift", + "Bitkit/Components/PubkyImage.swift", + "Bitkit/Components/ShopWebView.swift", + "Bitkit/Extensions/Activity+Contact.swift", + "Bitkit/FeatureFlags/PaykitFeatureFlags.swift", + "Bitkit/Managers/ContactsManager.swift", + "Bitkit/Managers/PubkyProfileManager.swift", + "Bitkit/Managers/ShopContactImporter.swift", + "Bitkit/Models/PaykitAmount.swift", + "Bitkit/Models/PaykitRequestPricing.swift", + "Bitkit/Models/PaykitUsdt.swift", + "Bitkit/Models/PubkyAuthRequest.swift", + "Bitkit/Models/PubkyContactLink.swift", + "Bitkit/Models/PubkyProfile.swift", + "Bitkit/Models/PubkyPublicKeyFormat.swift", + "Bitkit/Services/ContactPaymentsService.swift", + "Bitkit/Services/PaykitAllowance.swift", + "Bitkit/Services/PaykitAllowanceExecutor.swift", + "Bitkit/Services/PaykitAllowanceManager.swift", + "Bitkit/Services/PaykitBitcoinRequestPricing.swift", + "Bitkit/Services/PaykitIssuerInterop.swift", + "Bitkit/Services/PaykitPaymentProofService.swift", + "Bitkit/Services/PaykitPaymentRequestService.swift", + "Bitkit/Services/PaykitReceivedPaymentContacts.swift", + "Bitkit/Services/PaykitSubscription.swift", + "Bitkit/Services/PaykitSubscriptionProposal.swift", + "Bitkit/Services/PaykitUsdtPaymentService.swift", + "Bitkit/Services/PrivatePaykitAddressReservationStore.swift", + "Bitkit/Services/PrivatePaykitService+Contacts.swift", + "Bitkit/Services/PrivatePaykitService+Endpoints.swift", + "Bitkit/Services/PrivatePaykitService+Errors.swift", + "Bitkit/Services/PrivatePaykitService+Invoices.swift", + "Bitkit/Services/PrivatePaykitService+Models.swift", + "Bitkit/Services/PrivatePaykitService+Payments.swift", + "Bitkit/Services/PrivatePaykitService+State.swift", + "Bitkit/Services/PrivatePaykitService.swift", + "Bitkit/Services/PubkyService.swift", + "Bitkit/Services/PublicPaykitService.swift", + "Bitkit/Utilities/PaykitAmountEntryConversion.swift", + "Bitkit/Utilities/PaykitPaymentActivity.swift", + "Bitkit/Utilities/ShopOrigin.swift", + "Bitkit/Utilities/ShopPaymentRequest.swift", + "Bitkit/Utilities/SubscriptionClock.swift", + "Bitkit/Utilities/Testing/ContactImportUITestFixture.swift", + "Bitkit/Views/Sheets/PubkyAuthApproval/PubkyAuthApprovalSheet.swift", + "Bitkit/Views/Wallets/Activity/AssignActivityContactView.swift", + "Bitkit/Views/Wallets/Send/InitialSubscriptionPaymentProgress.swift", + "Bitkit/Views/Wallets/Send/SendContactHeaderAvatar.swift", + "Bitkit/Views/Wallets/Send/SendContactSelectView.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-pubky-paykit-recovery", + "groups": ["pubky-paykit", "recovery"], + "files": [ + "Bitkit/Models/PaykitAllowanceStateBackup.swift", + "Bitkit/Models/PaykitPaymentStateBackup.swift", + "Bitkit/Models/PaykitUsdtStateBackup.swift", + "Bitkit/Services/PrivatePaykitService+Backup.swift", + "Bitkit/Utilities/Testing/ProfileRecoveryUITestFixture.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-pubky-paykit-security", + "groups": ["pubky-paykit", "security"], + "files": ["Bitkit/Utilities/SharedPubkyKeychain.swift"], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-receive", + "groups": ["receive"], + "files": [ + "Bitkit/Components/CopyAddressCard.swift", + "Bitkit/Components/Scanner.swift", + "Bitkit/Managers/CameraManager.swift", + "Bitkit/Managers/ScannerManager.swift", + "Bitkit/Models/ReceiveLiquidityDecision.swift", + "Bitkit/Utilities/QRCodePayload.swift", + "Bitkit/Views/Onboarding/SeedQRCodeScannerView.swift", + "Bitkit/Views/Scanner/ScannerScreen.swift", + "Bitkit/Views/Scanner/ScannerSheet.swift", + "Bitkit/Views/Settings/ProbingTool/ProbingToolScannerSheet.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-recovery", + "groups": ["recovery"], + "files": [ + "Bitkit/Components/OnboardingContent.swift", + "Bitkit/Components/SeedInputAccessory.swift", + "Bitkit/Components/SeedTextField.swift", + "Bitkit/Managers/TimedSheets/BackupTimedSheet.swift", + "Bitkit/Models/BackupCategory.swift", + "Bitkit/Models/BackupItemStatus.swift", + "Bitkit/Models/BackupPayloads.swift", + "Bitkit/Models/SettingsBackupConfig.swift", + "Bitkit/Services/AndroidBackupJson.swift", + "Bitkit/Services/BackupFieldMigration.swift", + "Bitkit/Services/BackupService.swift", + "Bitkit/Services/MigrationsService.swift", + "Bitkit/Services/RNBackupClient.swift", + "Bitkit/Services/UsdtRecoveryBackupStore.swift", + "Bitkit/Services/VssBackupClient.swift", + "Bitkit/Services/VssStoreIdProvider.swift", + "Bitkit/Utilities/AppDataMigrations.swift", + "Bitkit/Utilities/InstallationMarker.swift", + "Bitkit/Utilities/SeedQRCodeDecoder.swift", + "Bitkit/ViewModels/BackupViewModel.swift", + "Bitkit/Views/OnboardingView.swift", + "Bitkit/Views/Settings/DevSettings/LegacyRnRecoveryScreen.swift", + "Bitkit/Views/Settings/DevSettings/VssDebugScreen.swift", + "Bitkit/Views/Settings/Security/DataBackupsScreen.swift", + "Bitkit/Views/Transfer/Hardware/HwPassphrasePromptSheet.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-security", + "groups": ["security"], + "files": [ + "Bitkit/Components/PinInput.swift", + "Bitkit/Components/ScreenshotPreventMask.swift", + "Bitkit/Utilities/BiometricAuth.swift", + "Bitkit/Utilities/Keychain.swift", + "Bitkit/ViewModels/Extensions/SettingsViewModel+PIN.swift", + "Bitkit/Views/Settings/SecuritySettingsView.swift", + "Bitkit/Views/Sheets/ForgotPinSheet.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-send", + "groups": ["send"], + "files": [ + "Bitkit/Components/PaymentAddressInput.swift", + "Bitkit/Components/PaymentCelebration.swift", + "Bitkit/Components/PaymentReviewIllustration.swift", + "Bitkit/Components/SendSectionView.swift", + "Bitkit/Extensions/PaymentDetails.swift", + "Bitkit/Extensions/PaymentFailureReason+UserMessage.swift", + "Bitkit/Managers/TimedSheets/QuickpayTimedSheet.swift", + "Bitkit/Services/OnchainSendAttemptService.swift", + "Bitkit/Utilities/Bip21Utils.swift", + "Bitkit/Utilities/PaymentNavigationHelper.swift", + "Bitkit/Utilities/QuickPayLimits.swift", + "Bitkit/Utilities/QuickPayPaymentCoordinator.swift", + "Bitkit/Utilities/QuickPaySpendStore.swift", + "Bitkit/ViewModels/AmountInputViewModel.swift", + "Bitkit/Views/Sheets/QuickpaySheet.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-settings", + "groups": ["settings"], + "files": [ + "Bitkit/Components/SettingsRow.swift", + "Bitkit/ViewModels/SettingsViewModel.swift", + "Bitkit/Views/Settings/Advanced/AdvancedSettingsView.swift", + "Bitkit/Views/Settings/GeneralSettingsView.swift", + "Bitkit/Views/Settings/MainSettingsScreen.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-status", + "groups": ["status"], + "files": [ + "Bitkit/Components/AppStatus.swift", + "Bitkit/Utilities/AppStatusHelper.swift", + "Bitkit/Views/Settings/AppStatusView.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-support", + "groups": ["support"], + "files": [ + "Bitkit/Services/LogService.swift", + "Bitkit/Services/ZipService.swift", + "Bitkit/Utilities/Logger.swift", + "Bitkit/Views/Settings/LogView.swift", + "Bitkit/Views/Settings/SupportScreen.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-tags", + "groups": ["tags"], + "files": [ + "Bitkit/Components/NoteTextEditor.swift", + "Bitkit/Managers/TagManager.swift", + "Bitkit/Views/Settings/General/TagSettingsView.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-widgets", + "groups": ["widgets"], + "files": [ + "Bitkit/Constants/WidgetEnv.swift", + "Bitkit/Managers/CalculatorInputManager.swift", + "Bitkit/Managers/SuggestionsManager.swift", + "Bitkit/Models/BitcoinFacts.swift", + "Bitkit/Models/BlocksWidgetData.swift", + "Bitkit/Models/BlocksWidgetFields.swift", + "Bitkit/Models/BlocksWidgetOptions.swift", + "Bitkit/Models/CalculatorWidgetData.swift", + "Bitkit/Models/NewsWidgetData.swift", + "Bitkit/Models/NewsWidgetOptions.swift", + "Bitkit/Models/PriceWidgetData.swift", + "Bitkit/Models/PriceWidgetOptions.swift", + "Bitkit/Models/WeatherWidgetData.swift", + "Bitkit/Models/WeatherWidgetOptions.swift", + "Bitkit/ViewModels/WidgetsViewModel.swift", + "Bitkit/Views/Home/HomeWidgetsView.swift", + "Bitkit/Views/Settings/General/WidgetsSettingsScreen.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "files-widgets-recovery", + "groups": ["widgets", "recovery"], + "files": [ + "Bitkit/Components/WidgetsOnboardingView.swift", + "Bitkit/Utilities/WidgetsBackupConverter.swift" + ], + "prefixes": [], + "reason": "Feature-specific source; reviewed against callers and existing case coverage." + }, + { + "name": "usdt", + "groups": [], + "files": [ + "Bitkit/Managers/TimedSheets/UsdtIntroTimedSheet.swift", + "Bitkit/Managers/UsdtWalletManager.swift", + "Bitkit/Models/PaykitUsdt.swift", + "Bitkit/Models/PaykitUsdtStateBackup.swift", + "Bitkit/Models/UsdtDisplayBalanceCache.swift", + "Bitkit/Models/UsdtPreference.swift", + "Bitkit/Models/UsdtPreparedQuote.swift", + "Bitkit/Services/PaykitUsdtPaymentService.swift", + "Bitkit/Services/UsdtRecoveryBackupStore.swift", + "Bitkit/Views/Sheets/UsdtIntroSheet.swift", + "Bitkit/Views/Wallets/UsdtActivityDetail.swift", + "Bitkit/Views/Wallets/UsdtDepositHistoryView.swift", + "Bitkit/Views/Wallets/UsdtNetworkPicker.swift", + "Bitkit/Views/Wallets/UsdtReceiveAmountView.swift", + "Bitkit/Views/Wallets/UsdtReceiveView.swift", + "Bitkit/Views/Wallets/UsdtSendView.swift", + "Bitkit/Views/Wallets/UsdtSupportActions.swift", + "Bitkit/Views/Wallets/UsdtWalletScreen.swift" + ], + "prefixes": [], + "reason": "Keep Bitcoin regression gate; no existing automated iOS USDT case.", + "coverageNote": "USDT changes: existing Bitcoin/Paykit cases do not prove USDT behavior; relevant native/integration coverage is required." + }, + { + "name": "presentation-and-resources", + "groups": [], + "files": [], + "prefixes": [ + "Bitkit/Styles/", + "Bitkit/Assets.xcassets/", + "Bitkit/Preview Content/", + "Bitkit/Resources/", + "Bitkit/Fonts/", + "Bitkit/Components/Button/", + "Bitkit/Components/TabBar/" + ], + "reason": "Presentation changes run the base wallet/payment gate; dedicated native checks retain their coverage." + }, + { + "name": "non-app-checks", + "groups": [], + "files": [ + ".gitignore", + ".swiftformat", + "LICENSE", + "Justfile", + "manually-test-payments.sh", + "run.sh", + "ExportOptions.plist", + "scripts/pre-commit", + "scripts/collect-changelog.sh", + "scripts/preview-changelog.sh", + "scripts/setup-hooks.sh", + "scripts/test-plural-localizations.swift", + "scripts/validate-translations.js", + "scripts/build-usdt-local.sh" + ], + "prefixes": [ + "Docs/", + "journeys/", + "changelog.d/", + "BitkitTests/", + "BitkitUITests/", + ".agents/", + ".claude/", + ".cursor/", + "test-push-server/" + ], + "skip": true, + "reason": "Documentation, native tests or developer tooling: existing native/lint checks apply; no app source change." + }, + { + "name": "selection-policy", + "groups": [], + "files": [ + ".github/e2e-policy.json", + ".github/scripts/e2e-selection.cjs", + ".github/scripts/e2e-selection.test.cjs", + ".github/workflows/e2e-selection.yml" + ], + "prefixes": [], + "skip": true, + "reason": "Cheap policy/selection tests validate rule changes; device validation is dispatched explicitly." + }, + { + "name": "other-ci", + "groups": [], + "files": [ + ".github/ISSUE_TEMPLATE/bug_report.yml", + ".github/ISSUE_TEMPLATE/feature_request.yml", + ".github/copilot-instructions.md", + ".github/dependabot.yml", + ".github/pull_request_template.md", + ".github/scripts/retarget-execution.cjs", + ".github/scripts/retarget-execution.test.cjs", + ".github/scripts/retarget-tests.cjs", + ".github/scripts/retarget-tests.test.cjs", + ".github/scripts/test-fixtures/retarget-execution.cjs", + ".github/workflows/ai-device-tests.yml", + ".github/workflows/claude-code-review.yml", + ".github/workflows/claude.yml", + ".github/workflows/e2e-retarget.yml", + ".github/workflows/integration-tests.yml", + ".github/workflows/lint.yml", + ".github/workflows/retarget-execution-cleanup.yml", + ".github/workflows/unit-tests.yml", + ".github/workflows/validate-translations.yml" + ], + "prefixes": [], + "skip": true, + "reason": "Other CI and repository metadata do not change the app; their own checks apply." + }, + { + "name": "notification-extension", + "groups": [], + "files": [ + "BitkitNotification/BitkitNotification.entitlements", + "BitkitNotification/Info.plist", + "BitkitNotification/NotificationService.swift" + ], + "prefixes": [], + "reason": "Base payment regression; notification-specific behavior remains native/device coverage." + }, + { + "name": "localization-build", + "groups": [], + "files": ["scripts/generate-plural-localizations.swift"], + "prefixes": [], + "reason": "Base gate and existing translation/build checks." + }, + { + "name": "widget-entitlements", + "groups": ["widgets"], + "files": ["BitkitWidgetExtension.entitlements"], + "prefixes": [] + }, + { + "name": "shared-retry-and-lifecycle", + "groups": [], + "files": [ + "Bitkit/Utilities/RetryHelper.swift", + "Bitkit/Utilities/BackgroundTaskScheduling.swift", + "Bitkit/Utilities/Errors.swift" + ], + "prefixes": [], + "full": true, + "reason": "Shared error/retry/background lifecycle can affect wallet startup, payments and recovery." + }, + { + "name": "payment-encoding", + "groups": ["send", "pubky-paykit", "hardware"], + "files": [ + "Bitkit/Extensions/HexBytes.swift", + "Bitkit/Utilities/URLValidationPattern.swift" + ], + "prefixes": [], + "reason": "Payment/key encoding and external request validation shared across payment routes." + }, + { + "name": "lnurl-string-parsing", + "groups": ["lnurl"], + "files": ["Bitkit/Extensions/String+Utilities.swift"], + "prefixes": [], + "reason": "removingLightningSchemes parses LNURL inputs." + }, + { + "name": "scanner-sheet", + "groups": ["receive"], + "files": ["Bitkit/Components/QR.swift"], + "prefixes": ["Bitkit/Views/Scanner/"], + "reason": "Receiving/scan entry points." + }, + { + "name": "tag-sheet", + "groups": ["tags"], + "files": ["Bitkit/Views/Wallets/Sheets/AddTagSheet.swift"], + "prefixes": [] + }, + { + "name": "shared-presentation", + "groups": [], + "files": [ + "Bitkit/Components/ActivityIndicator.swift", + "Bitkit/Components/BlurView.swift", + "Bitkit/Components/BottomActionBar.swift", + "Bitkit/Components/CheckboxRow.swift", + "Bitkit/Components/CircularIcon.swift", + "Bitkit/Components/ClipboardCopyPopup.swift", + "Bitkit/Components/CustomSlider.swift", + "Bitkit/Components/Divider.swift", + "Bitkit/Components/DrawerView.swift", + "Bitkit/Components/EllipseLoader.swift", + "Bitkit/Components/EmptyStateView.swift", + "Bitkit/Components/Header.swift", + "Bitkit/Components/IconButton.swift", + "Bitkit/Components/InsetHeaderScrollView.swift", + "Bitkit/Components/LabeledDetailCell.swift", + "Bitkit/Components/NavigationBar.swift", + "Bitkit/Components/NodeStateView.swift", + "Bitkit/Components/NotificationPreview.swift", + "Bitkit/Components/RadioGroup.swift", + "Bitkit/Components/RectangleButton.swift", + "Bitkit/Components/SegmentedControl.swift", + "Bitkit/Components/ShareSheet.swift", + "Bitkit/Components/SheetIntro.swift", + "Bitkit/Components/SnapCarousel.swift", + "Bitkit/Components/Social.swift", + "Bitkit/Components/StatusRow.swift", + "Bitkit/Components/SuggestionCard.swift", + "Bitkit/Components/SwipeButton.swift", + "Bitkit/Components/TabViewDots.swift", + "Bitkit/Components/TextField.swift", + "Bitkit/Components/ToastView.swift", + "Bitkit/Components/Tooltip.swift", + "Bitkit/Components/WalletBalanceView.swift", + "Bitkit/Components/WrappingHStack.swift", + "Bitkit/Extensions/IBtOrder+Mock.swift", + "Bitkit/Extensions/TextEditor+DismissOnReturn.swift", + "Bitkit/Extensions/UINavigationController+SwipeBack.swift", + "Bitkit/Extensions/UIScreen.swift", + "Bitkit/Extensions/View+Accessibility.swift", + "Bitkit/Extensions/View+SafeArea.swift", + "Bitkit/Extensions/View+SwipeGestures.swift", + "Bitkit/LaunchScreen.storyboard", + "Bitkit/Managers/KeyboardManager.swift", + "Bitkit/Managers/LanguageManager.swift", + "Bitkit/Managers/PushNotificationManager.swift", + "Bitkit/Managers/TimedSheets/AppUpdateTimedSheet.swift", + "Bitkit/Managers/TimedSheets/HighBalanceTimedSheet.swift", + "Bitkit/Managers/TimedSheets/NotificationsTimedSheet.swift", + "Bitkit/Managers/TimedSheets/TimedSheetManager.swift", + "Bitkit/Managers/ToastWindowManager.swift", + "Bitkit/Models/SupportedLanguage.swift", + "Bitkit/Models/Toast.swift", + "Bitkit/Services/AppUpdateService.swift", + "Bitkit/Utilities/BlockExplorerHelper.swift", + "Bitkit/Utilities/ClipboardPrompt.swift", + "Bitkit/Utilities/DateFormatterHelpers.swift", + "Bitkit/Utilities/Haptics.swift", + "Bitkit/Utilities/LocalizeHelpers.swift", + "Bitkit/Views/AppUpdateScreen.swift", + "Bitkit/Views/BuyBitcoinView.swift", + "Bitkit/Views/ComingSoon.swift", + "Bitkit/Views/Home/HomeWalletView.swift", + "Bitkit/Views/HomeScreen.swift", + "Bitkit/Views/Settings/General/LanguageSettingsScreen.swift", + "Bitkit/Views/Settings/Notifications/NotificationsIntro.swift", + "Bitkit/Views/Settings/Notifications/NotificationsSettings.swift", + "Bitkit/Views/Sheets/AppUpdateSheet.swift", + "Bitkit/Views/Sheets/HighBalanceSheet.swift", + "Bitkit/Views/Sheets/NotificationsSheet.swift", + "Bitkit/Views/Sheets/Sheet.swift", + "Bitkit/Views/SplashView.swift", + "Bitkit/Views/Wallets/SavingsWalletScreen.swift", + "Bitkit/Views/Wallets/Sheets/ReceivedTx.swift", + "Bitkit/Views/Wallets/SpendingWalletScreen.swift" + ], + "prefixes": [], + "reason": "Reviewed display/notification/update/UI-shell files: base gate checks app access and wallet/payment interaction; native tests cover detailed presentation. New files require classification." + } + ] +} diff --git a/.github/scripts/e2e-selection.cjs b/.github/scripts/e2e-selection.cjs new file mode 100644 index 000000000..fcc0d08d8 --- /dev/null +++ b/.github/scripts/e2e-selection.cjs @@ -0,0 +1,331 @@ +const fs = require("node:fs"); +const path = require("node:path"); +const crypto = require("node:crypto"); +const { execFileSync } = require("node:child_process"); +const POLICY_PATH = ".github/e2e-policy.json"; +const policy = require("../e2e-policy.json"); + +function validatePolicy(p, cases) { + if ( + p.schemaVersion !== 1 || + !Array.isArray(p.base) || + !p.base.length || + !p.groups || + !Array.isArray(p.rules) + ) + throw Error("Invalid iOS E2E policy"); + const available = new Map( + cases + .filter( + (c) => c.kind === "test" && c.platforms.ios.disposition === "required", + ) + .map((c) => [c.id, c]), + ); + for (const [name, ids] of Object.entries({ base: p.base, ...p.groups })) { + if (!Array.isArray(ids) || !ids.length || new Set(ids).size !== ids.length) + throw Error(`Invalid case group: ${name}`); + for (const id of ids) { + if (!available.has(id)) + throw Error(`Policy group ${name} requires unavailable case: ${id}`); + if ( + name === "base" && + available.get(id).platforms.ios.backend !== "local" + ) + throw Error(`Non-local base case: ${id}`); + } + } + const names = new Set(); + for (const r of p.rules) { + if ( + !r.name || + names.has(r.name) || + !Array.isArray(r.files) || + !Array.isArray(r.prefixes) || + !Array.isArray(r.groups) + ) + throw Error("Invalid/duplicate policy rule"); + names.add(r.name); + if (!r.files.length && !r.prefixes.length) + throw Error(`Empty path rule: ${r.name}`); + for (const f of [...r.files, ...r.prefixes]) { + if ( + typeof f !== "string" || + !f || + f.startsWith("/") || + f.includes("..") || + f.includes("*") || + f.includes("\0") + ) + throw Error(`Invalid policy path: ${f}`); + } + if (r.prefixes.some((f) => !f.endsWith("/"))) + throw Error(`Prefix must end with /: ${r.name}`); + if ( + (r.full !== undefined && typeof r.full !== "boolean") || + (r.skip !== undefined && typeof r.skip !== "boolean") + ) + throw Error(`Invalid policy action: ${r.name}`); + if (r.skip && (r.full || r.groups.length)) + throw Error(`Skip rule selects tests: ${r.name}`); + for (const g of r.groups) + if (!p.groups[g]) throw Error(`Unknown case group: ${g}`); + } +} + +function matchingRules(p, file) { + if (file.endsWith(".md")) + return [{ name: "documentation", skip: true, groups: [] }]; + return p.rules.filter( + (r) => + r.files.includes(file) || + r.prefixes.some((prefix) => file.startsWith(prefix)), + ); +} + +function validateTree(p, files, cases) { + validatePolicy(p, cases); + const unknown = files.filter((f) => !matchingRules(p, f).length); + const stale = p.rules.flatMap((r) => [ + ...r.files.filter((f) => !files.includes(f)), + ...r.prefixes.filter((prefix) => !files.some((f) => f.startsWith(prefix))), + ]); + if (unknown.length || stale.length) + throw Error( + `Update ${POLICY_PATH} in this iOS PR.\nUnclassified paths: ${unknown.join(", ") || "none"}\nStale policy paths: ${[...new Set(stale)].join(", ") || "none"}`, + ); +} + +function select( + cases, + { + mode = "auto", + files = [], + shards = 1, + release = false, + currentPolicy = policy, + previousPolicy, + } = {}, +) { + if (!["auto", "base", "full", "nightly"].includes(mode)) + throw Error("Invalid selection mode"); + if (![1, 2].includes(shards)) + throw Error("Only one or two shards are supported"); + if ( + !Array.isArray(files) || + files.some((f) => typeof f !== "string" || !f || f.includes("\0")) + ) + throw Error("Invalid changed paths"); + validatePolicy(currentPolicy, cases); + if (previousPolicy) validatePolicy(previousPolicy, cases); + const policies = previousPolicy + ? [currentPolicy, previousPolicy] + : [currentPolicy]; + const runnable = cases.filter( + (c) => c.kind === "test" && c.platforms.ios.disposition === "required", + ); + const local = runnable.filter((c) => c.platforms.ios.backend === "local"); + const selected = new Set(currentPolicy.base); + const reasons = [], + coverageNotes = new Set(); + let full = mode === "full" || mode === "nightly"; + let code = mode !== "auto" || release; + if (mode === "auto") { + const unknown = []; + for (const file of files) { + let matched = false; + for (const [index, p] of policies.entries()) { + for (const rule of matchingRules(p, file)) { + matched = true; + reasons.push({ + file, + rule: rule.name, + policy: index ? "base" : "tested", + action: rule.skip ? "skip" : rule.full ? "full" : "base+groups", + groups: rule.groups, + }); + if (rule.skip) continue; + code = true; + full ||= !!rule.full; + for (const g of rule.groups) + for (const id of p.groups[g]) selected.add(id); + if (rule.coverageNote) coverageNotes.add(rule.coverageNote); + } + } + if (!matched) unknown.push(file); + } + if (unknown.length) + throw Error( + `Unclassified changed paths: ${unknown.join(", ")}. Update ${POLICY_PATH} in the iOS PR; no paid jobs were selected.`, + ); + // A policy edit cannot silently waive an existing base case for an app-code change. + if (code && previousPolicy) + for (const id of previousPolicy.base) selected.add(id); + } + if (release) + for (const c of runnable) + if (c.platforms.ios.pack === "migration") selected.add(c.id); + if (full) for (const c of runnable) selected.add(c.id); + const expected = code ? runnable.filter((c) => selected.has(c.id)) : []; + const ids = expected + .filter((c) => c.platforms.ios.backend === "local") + .map((c) => c.id) + .sort(); + const bins = Array.from({ length: shards }, () => []); + for (const file of [ + ...new Set(local.filter((c) => ids.includes(c.id)).map((c) => c.file)), + ].sort()) { + const group = local + .filter((c) => c.file === file && ids.includes(c.id)) + .map((c) => c.id); + bins.sort((a, b) => a.length - b.length); + bins[0].push(...group); + } + return { + schemaVersion: 1, + platform: "ios", + mode, + code, + base: currentPolicy.base, + ids, + matrix: bins + .filter((b) => b.length) + .map((ids, i) => ({ name: `local-${i + 1}`, ids: ids.sort() })), + staging: expected.some( + (c) => + c.platforms.ios.backend === "regtest" && + c.platforms.ios.pack !== "migration", + ), + migration: expected.some((c) => c.platforms.ios.pack === "migration"), + reasons, + coverageNotes: [...coverageNotes], + gaps: cases + .filter((c) => c.platforms.ios.disposition === "gap") + .map((c) => ({ id: c.id, ...c.platforms.ios })), + }; +} + +function main() { + const app = path.resolve(__dirname, "../.."); + const e2e = path.resolve( + process.env.E2E_TEST_ROOT || path.join(app, "../bitkit-e2e-tests"), + ); + const git = (...args) => + execFileSync("git", ["-C", app, ...args], { encoding: "utf8" }).trim(); + const inventory = JSON.parse( + fs.readFileSync(path.join(e2e, "test/inventory.json"), "utf8"), + ); + const tracked = execFileSync("git", ["-C", app, "ls-files", "-z"], { + encoding: "utf8", + }) + .split("\0") + .filter(Boolean); + validateTree(policy, tracked, inventory.cases); + if (process.argv[2] === "check") { + console.log( + `iOS policy: all ${tracked.length} tracked paths classified; all case references valid`, + ); + return; + } + const tested = git("rev-parse", "HEAD"); + const event = JSON.parse( + fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"), + ); + let pr = event.pull_request; + const manualPr = process.env.E2E_MANUAL_PR; + if (manualPr) { + if (!/^\d+$/.test(manualPr)) throw Error("Invalid PR number"); + pr = JSON.parse( + execFileSync( + "gh", + ["api", `repos/${process.env.GITHUB_REPOSITORY}/pulls/${manualPr}`], + { encoding: "utf8" }, + ), + ); + } + const base = pr?.base?.sha; + if ( + pr && + git("show", "-s", "--format=%P", "HEAD") !== `${base} ${pr.head.sha}` + ) + throw Error("PR merge does not match current base and head"); + const files = base + ? execFileSync( + "git", + ["-C", app, "diff", "--no-renames", "--name-only", "-z", base, tested], + { encoding: "utf8" }, + ) + .split("\0") + .filter(Boolean) + : []; + let previousPolicy; + // Only the initial policy introduction has no base policy. A Git error must fail. + if (base && git("ls-tree", base, "--", POLICY_PATH)) + previousPolicy = JSON.parse(git("show", `${base}:${POLICY_PATH}`)); + const mode = + process.env.GITHUB_EVENT_NAME === "schedule" + ? "nightly" + : process.env.GITHUB_EVENT_NAME === "pull_request" + ? "auto" + : process.env.E2E_SELECTION || "full"; + if (mode === "auto" && !base) + throw Error("Automatic manual selection requires a PR number"); + const result = select(inventory.cases, { + mode, + files, + previousPolicy, + shards: + process.env.GITHUB_EVENT_NAME === "workflow_dispatch" + ? Number(process.env.E2E_SHARDS || 1) + : 1, + release: + /^release-/.test(pr?.head?.ref || "") || + /^release-/.test(pr?.base?.ref || ""), + }); + result.appSha = tested; + result.e2eSha = execFileSync("git", ["-C", e2e, "rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(); + result.policy = { + path: POLICY_PATH, + appSha: tested, + sha256: crypto + .createHash("sha256") + .update(fs.readFileSync(path.join(app, POLICY_PATH))) + .digest("hex"), + }; + fs.writeFileSync("selection.json", JSON.stringify(result, null, 2) + "\n"); + if (process.env.GITHUB_OUTPUT) + for (const key of [ + "code", + "matrix", + "staging", + "migration", + "appSha", + "e2eSha", + ]) + fs.appendFileSync( + process.env.GITHUB_OUTPUT, + `${key}=${typeof result[key] === "object" ? JSON.stringify(result[key]) : result[key]}\n`, + ); + if (process.env.GITHUB_STEP_SUMMARY) + fs.appendFileSync( + process.env.GITHUB_STEP_SUMMARY, + `## iOS E2E selection\n\nApp/policy: ${tested}; tests: ${result.e2eSha}\n\nLocal cases (${result.ids.length}): ${result.ids.join(", ") || "none"}\n\nStaging: ${result.staging}; migration: ${result.migration}; shards: ${result.matrix.length}\n\n${result.reasons.map((r) => `- ${r.file}: ${r.rule} (${r.policy})`).join("\n")}\n\nCoverage notes: ${result.coverageNotes.join("; ") || "none"}\n\nKnown gaps: ${result.gaps.map((g) => g.id).join(", ")}\n`, + ); + console.log(JSON.stringify(result, null, 2)); +} +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} +module.exports = { + policy, + select, + matchingRules, + validatePolicy, + validateTree, +}; diff --git a/.github/scripts/e2e-selection.test.cjs b/.github/scripts/e2e-selection.test.cjs new file mode 100644 index 000000000..c2b43255f --- /dev/null +++ b/.github/scripts/e2e-selection.test.cjs @@ -0,0 +1,293 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFileSync, spawnSync } = require("node:child_process"); +const { + policy, + select, + validatePolicy, + validateTree, +} = require("./e2e-selection.cjs"); +const app = path.resolve(__dirname, "../.."); +const e2e = path.resolve( + process.env.E2E_TEST_ROOT || path.join(app, "../bitkit-e2e-tests"), +); +const cases = JSON.parse( + fs.readFileSync(path.join(e2e, "test/inventory.json"), "utf8"), +).cases; +const tracked = execFileSync("git", ["-C", app, "ls-files", "-z"], { + encoding: "utf8", +}) + .split("\0") + .filter(Boolean); +const pick = (files, options = {}) => select(cases, { files, ...options }); + +test("policy covers the actual iOS tree and contains no stale paths or case IDs", () => + validateTree(policy, tracked, cases)); +test("base remains exactly the five existing cases", () => + assert.deepEqual([...policy.base].sort(), [ + "boost_2", + "lightning_1", + "send_1", + "send_2", + "transfer_2", + ])); + +for (const [file, added, staging, migration] of [ + [ + "Bitkit/Services/Widgets/NewsService.swift", + ["widgets_1", "widgets_2", "widgets_3", "settings_12"], + false, + false, + ], + [ + "Bitkit/Models/NewsWidgetData.swift", + ["widgets_1", "widgets_2", "widgets_3", "settings_12"], + false, + false, + ], + ["Bitkit/Services/PaykitPaymentRequestService.swift", [], true, false], + ["Bitkit/Managers/ContactsManager.swift", [], true, false], + [ + "Bitkit/Services/Trezor/TrezorService.swift", + ["hardware_wallet_1", "hardware_wallet_2", "hardware_wallet_4"], + false, + false, + ], + [ + "Bitkit/Managers/FeeEstimatesManager.swift", + ["boost_1", "onchain_3", "settings_03"], + false, + false, + ], + [ + "Bitkit/Services/BackupService.swift", + ["backup_1", "onboarding_1", "onboarding_2", "security_1", "settings_07"], + false, + true, + ], + [ + "Bitkit/Services/ElectrumConfigService.swift", + ["settings_10"], + false, + false, + ], + ["Bitkit/Services/RgsConfigService.swift", ["settings_11"], false, false], +]) + test(`actual ${file} selects exact domain coverage`, () => { + assert.ok(tracked.includes(file), "Example must exist in the app"); + const s = pick([file]); + assert.deepEqual(s.ids, [...new Set([...policy.base, ...added])].sort()); + assert.equal(s.staging, staging); + assert.equal(s.migration, migration); + }); + +test("on-chain service expands payment coverage without unrelated staging/migration", () => { + const s = pick(["Bitkit/Services/OnchainSendAttemptService.swift"]); + assert.deepEqual( + s.ids, + [...new Set([...policy.base, ...policy.groups.send])].sort(), + ); + assert.equal(s.staging, false); + assert.equal(s.migration, false); +}); +for (const f of [ + "Bitkit/Services/CoreService.swift", + "Bitkit/ViewModels/WalletViewModel.swift", + "Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", +]) + test(`explicit shared dependency ${f} expands all lanes`, () => { + const s = pick([f]); + assert.equal(s.ids.length, 42); + assert.equal(s.staging, true); + assert.equal(s.migration, true); + }); +test("unknown service/new area fails cheaply, never silently selects base or full", () => { + for (const f of [ + "Bitkit/Services/NewService.swift", + "Bitkit/NewArea/Payment.swift", + ]) + assert.throws(() => pick([f]), /Unclassified.*e2e-policy/s); +}); +test("new files inside a feature directory inherit its classification", () => { + assert.deepEqual( + pick(["Bitkit/Services/Widgets/NewService.swift"]).ids, + pick(["Bitkit/Services/Widgets/NewsService.swift"]).ids, + ); +}); +test("removed paths and obsolete IDs cannot leave stale rules unnoticed", () => { + assert.throws( + () => + validateTree( + policy, + tracked.filter((f) => f !== "Bitkit/Services/CoreService.swift"), + cases, + ), + /Stale policy paths.*CoreService/s, + ); + assert.throws( + () => + validateTree( + policy, + [...tracked, "Bitkit/Services/NewService.swift"], + cases, + ), + /Unclassified paths.*NewService/s, + ); + assert.throws( + () => + validatePolicy( + policy, + cases.filter((c) => c.id !== "send_2"), + ), + /unavailable case: send_2/, + ); +}); +test("renames preserve old domain and include new domain with base policy", () => { + const old = structuredClone(policy), + current = structuredClone(policy); + const f = "Bitkit/Services/PaykitPaymentRequestService.swift"; + for (const r of current.rules) r.files = r.files.filter((x) => x !== f); + const s = pick([f, "Bitkit/Services/Widgets/Request.swift"], { + currentPolicy: current, + previousPolicy: old, + }); + assert.equal(s.staging, true); + assert.ok(s.ids.includes("widgets_1")); + assert.ok(s.reasons.some((r) => r.file === f && r.policy === "base")); +}); +test("changing the policy cannot waive existing coverage on the same code diff", () => { + const current = structuredClone(policy); + for (const r of current.rules) + if (r.files.includes("Bitkit/Services/PaykitPaymentRequestService.swift")) + r.groups = []; + const s = pick(["Bitkit/Services/PaykitPaymentRequestService.swift"], { + currentPolicy: current, + previousPolicy: policy, + }); + assert.equal(s.staging, true); +}); +test("docs/native-test/policy-only changes need no paid jobs", () => { + for (const f of [ + "Docs/e2e-ci.md", + "BitkitTests/CurrencyTests.swift", + ".github/e2e-policy.json", + ]) { + const s = pick([f]); + assert.equal(s.code, false); + assert.deepEqual(s.matrix, []); + } +}); +test("USDT selection records missing coverage instead of pretending all Bitcoin tests cover it", () => { + const s = pick(["Bitkit/Managers/UsdtWalletManager.swift"]); + assert.deepEqual(s.ids, [...policy.base].sort()); + assert.ok(s.coverageNotes[0].includes("USDT")); +}); +test("nightly/full include all existing applicable lanes with no changes; gaps stay explicit", () => { + for (const mode of ["full", "nightly"]) { + const s = pick([], { mode }); + assert.equal(s.ids.length, 42); + assert.equal(s.staging, true); + assert.equal(s.migration, true); + assert.ok(s.gaps.some((g) => g.id === "transfer_1")); + assert.ok(!s.ids.includes("transfer_1")); + } +}); +test("two shards partition cases exactly and retain spec lifecycle grouping", () => { + const s = pick([], { mode: "full", shards: 2 }); + assert.equal(s.matrix.length, 2); + assert.deepEqual(s.matrix.flatMap((x) => x.ids).sort(), s.ids); + for (const f of new Set(cases.map((c) => c.file))) { + const ids = cases.filter((c) => c.file === f).map((c) => c.id); + assert.ok( + s.matrix.filter((b) => b.ids.some((id) => ids.includes(id))).length <= 1, + ); + } +}); + +test("actual CLI uses tested app policy and pinned E2E inventory; rejects drift before output", (t) => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), "ios-selection-")); + t.after(() => fs.rmSync(temp, { recursive: true, force: true })); + const root = path.join(temp, "app"); + fs.mkdirSync(path.join(root, ".github/scripts"), { recursive: true }); + fs.copyFileSync( + path.join(__dirname, "e2e-selection.cjs"), + path.join(root, ".github/scripts/e2e-selection.cjs"), + ); + // A tiny app-owned policy proves the CLI does not load rules from the E2E checkout. + const p = { + schemaVersion: 1, + base: policy.base, + groups: { widgets: policy.groups.widgets }, + rules: [ + { + name: "widget-source", + files: ["Widget.swift"], + prefixes: [], + groups: ["widgets"], + }, + { + name: "policy", + files: [".github/e2e-policy.json", ".github/scripts/e2e-selection.cjs"], + prefixes: [], + groups: [], + skip: true, + }, + ], + }; + fs.writeFileSync( + path.join(root, ".github/e2e-policy.json"), + JSON.stringify(p), + ); + fs.writeFileSync(path.join(root, "Widget.swift"), "// fixture"); + execFileSync("git", ["init", "-q", root]); + execFileSync("git", ["-C", root, "add", "."]); + // Only need a tree/HEAD for identity; use the already signed app commit as HEAD + // in a shared object repository, rather than create an unsigned test commit. + const objects = execFileSync( + "git", + ["-C", app, "rev-parse", "--path-format=absolute", "--git-path", "objects"], + { encoding: "utf8" }, + ).trim(); + fs.mkdirSync(path.join(root, ".git/objects/info"), { recursive: true }); + fs.writeFileSync( + path.join(root, ".git/objects/info/alternates"), + objects + "\n", + ); + const head = execFileSync("git", ["-C", app, "rev-parse", "HEAD"], { + encoding: "utf8", + }).trim(); + execFileSync("git", ["-C", root, "update-ref", "HEAD", head]); + const event = path.join(temp, "event.json"); + fs.writeFileSync(event, "{}"); + const env = { + ...process.env, + E2E_TEST_ROOT: e2e, + GITHUB_EVENT_PATH: event, + GITHUB_EVENT_NAME: "workflow_dispatch", + E2E_SELECTION: "base", + }; + const run = () => + spawnSync( + process.execPath, + [path.join(root, ".github/scripts/e2e-selection.cjs")], + { cwd: temp, env, encoding: "utf8" }, + ); + const r = run(); + assert.equal(r.status, 0, r.stderr); + const s = JSON.parse( + fs.readFileSync(path.join(temp, "selection.json"), "utf8"), + ); + assert.equal(s.policy.appSha, head); + assert.equal(s.policy.path, ".github/e2e-policy.json"); + assert.deepEqual(s.ids, [...policy.base].sort()); + fs.unlinkSync(path.join(temp, "selection.json")); + fs.writeFileSync(path.join(root, "Unknown.swift"), "// new"); + execFileSync("git", ["-C", root, "add", "Unknown.swift"]); + const bad = run(); + assert.notEqual(bad.status, 0); + assert.match(bad.stderr, /Unclassified paths: Unknown/); + assert.ok(!fs.existsSync(path.join(temp, "selection.json"))); +}); diff --git a/.github/workflows/e2e-selection.yml b/.github/workflows/e2e-selection.yml new file mode 100644 index 000000000..ecdd30efe --- /dev/null +++ b/.github/workflows/e2e-selection.yml @@ -0,0 +1,63 @@ +name: E2E selection policy +on: + pull_request: + push: + branches: [master] + workflow_dispatch: +permissions: + contents: read + pull-requests: read +concurrency: + group: e2e-policy-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +env: + GIT_LFS_SKIP_SMUDGE: "1" +jobs: + e2e-branch: + uses: synonymdev/bitkit-e2e-tests/.github/workflows/determine-e2e-branch.yml@main + with: + app_branch: ${{ github.head_ref || github.ref_name }} + e2e_branch_input: default-feature-branch + policy: + needs: e2e-branch + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + with: + path: app-ci + fetch-depth: 0 + - uses: synonymdev/bitkit-e2e-tests/.github/actions/resolve-e2e-ref@main + id: e2e-ref + with: + selected: ${{ needs.e2e-branch.outputs.branch }} + e2e_branch_input: default-feature-branch + - uses: actions/checkout@v7 + with: + repository: synonymdev/bitkit-e2e-tests + ref: ${{ steps.e2e-ref.outputs.ref }} + path: e2e-ci + sparse-checkout: test/inventory.json + sparse-checkout-cone-mode: false + - uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Check app paths and test references + env: + E2E_TEST_ROOT: ${{ github.workspace }}/e2e-ci + run: node app-ci/.github/scripts/e2e-selection.cjs check + - name: Test selection against the real iOS tree + env: + E2E_TEST_ROOT: ${{ github.workspace }}/e2e-ci + run: node --test app-ci/.github/scripts/e2e-selection.test.cjs + - name: Preview selection for this revision + env: + E2E_TEST_ROOT: ${{ github.workspace }}/e2e-ci + E2E_SELECTION: base + GH_TOKEN: ${{ github.token }} + run: node app-ci/.github/scripts/e2e-selection.cjs + - uses: actions/upload-artifact@v7 + with: + name: e2e-policy-selection + path: selection.json + retention-days: 7 diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 9a184b491..59aef2fbd 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -103,16 +103,15 @@ jobs: - uses: actions/setup-node@v7 with: node-version: 22 - - name: Select shared gate and affected packs + - name: Select iOS gate and affected packs id: selection env: - PLATFORM: ios - E2E_APP_ROOT: ${{ format('{0}/app-ci', github.workspace) }} + E2E_TEST_ROOT: ${{ format('{0}/e2e-ci', github.workspace) }} E2E_SELECTION: ${{ inputs.retarget_run != '' && 'auto' || inputs.selection || 'full' }} E2E_SHARDS: ${{ inputs.shards || '1' }} E2E_MANUAL_PR: ${{ inputs.pr }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: node e2e-ci/scripts/ci-selection.cjs + run: node app-ci/.github/scripts/e2e-selection.cjs - name: Upload selection if: always() uses: actions/upload-artifact@v7 diff --git a/Docs/e2e-ci.md b/Docs/e2e-ci.md index 26c4b0ce1..25a57f2e2 100644 --- a/Docs/e2e-ci.md +++ b/Docs/e2e-ci.md @@ -1,8 +1,38 @@ # iOS E2E CI -`e2e-tests.yml` is the entry point for PR, manual and nightly E2E selection. The shared policy and runner live in `synonymdev/bitkit-e2e-tests`; see [selection and reporting](https://github.com/synonymdev/bitkit-e2e-tests/blob/main/docs/inventory-reporting.md). +`e2e-tests.yml` is the entry point for PR, manual and nightly E2E selection. This repo owns [the path policy](../.github/e2e-policy.json), [selector](../.github/scripts/e2e-selection.cjs) and [policy tests](../.github/scripts/e2e-selection.test.cjs). The shared E2E repo owns case definitions, inventory, fixtures and execution/reporting, not iOS paths. -The base gate is exactly `send_1`, `send_2`, `lightning_1`, `transfer_2`, `boost_2`. Changed files automatically add risk coverage. Broad or unclassified changes expand to all applicable lanes. The required `e2e-status` aggregates every selected job. Existing unit and native integration workflows remain separate and unchanged. Android is unchanged. +The base gate is exactly `send_1`, `send_2`, `lightning_1`, `transfer_2`, `boost_2`. Changed files automatically add risk coverage. The required `e2e-status` aggregates every selected job. Existing unit and native integration workflows remain separate and unchanged. Android is unchanged. + +## Policy ownership and maintenance + +The selector loads `.github/e2e-policy.json` from the **tested app checkout**, not the E2E branch. `selection.json` records that app/policy SHA, the policy file digest, E2E SHA, exact case IDs and matching rules. Feature directories inherit their domain classification; services, managers, models and utilities use explicit files. There is no directory-wide `Services/` or `Models/` escalation. + +| Actual changed path | Coverage beyond the five-case base | +| --- | --- | +| `Bitkit/Services/Widgets/NewsService.swift` | `widgets_1–3`, `settings_12` | +| `Bitkit/Services/PaykitPaymentRequestService.swift` | Six Pubky/Paykit staging cases; no migration | +| `Bitkit/Services/Trezor/TrezorService.swift` | Three active hardware cases; no staging/migration | +| `Bitkit/Managers/FeeEstimatesManager.swift` | `boost_1`, `onchain_3`, `settings_03` | +| `Bitkit/Services/BackupService.swift` | Five onboarding/backup/security cases plus six migration cases; no staging | +| `Bitkit/Services/CoreService.swift` | Full coverage: shared wallet/account state affects all lanes | + +Only named app-wide wallet/lifecycle files and build/dependency/E2E orchestration configuration select all lanes. At the policy introduction, this means 27 of 619 `Bitkit` Swift files, including three of 76 service files (`CoreService`, `LightningService`, `ServiceQueue`). These are a source-tree audit, not a runtime or billing estimate. Changes to several domains union their coverage and execute overlapping cases once. + +Every PR, including drafts and stacks, gets a cheap Linux `E2E selection policy` check. It verifies every tracked path has a classification, explicit file/prefix entries still exist, and referenced cases are runnable in the selected E2E inventory. Main selection runs the same validation before any paid build. A new unclassified service or moved file fails with its path and a request to update the policy in the same iOS PR. It does not silently select base or launch every lane. Old and new rename paths contribute rules from both the base and tested policy, retaining coverage when a path mapping changes during a refactor. + +Documentation, native-test and selection-policy-only changes need no device jobs; existing native/lint checks and policy tests cover them. Presentation/resource changes run the base gate. USDT-specific paths record the lack of existing USDT E2E coverage and keep Bitcoin regression coverage; running unrelated Bitcoin tests would not prove USDT behavior. Native/integration tests must cover those feature changes. + +When adding/moving code, edit this JSON in the same PR, use an existing feature directory or add an explicit file classification with its reason, then run: + +```sh +E2E_TEST_ROOT=../bitkit-e2e-tests node .github/scripts/e2e-selection.cjs check +E2E_TEST_ROOT=../bitkit-e2e-tests node --test .github/scripts/e2e-selection.test.cjs +``` + +The E2E checkout must contain the matching inventory. Add a real-path regression example when introducing a new domain mapping. New coverage belongs in the shared E2E repo; app paths do not. + +## Execution Every night at 03:00 UTC the entry point runs all applicable local, staging and extended migration cases, even for an unchanged revision. The existing staging/migration workflows provide reusable execution and retain standalone manual dispatch. They no longer schedule duplicate runs. Known skipped cases and unsupported mainnet fixtures are reported as gaps, not successful coverage. From af3545eb432ef1228e1f045ce8824b0e73a4b787 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 3 Nov 2026 22:43:25 +0100 Subject: [PATCH 09/18] Retain shared scanner, backup and payment consumers in policy --- .github/e2e-policy.json | 72 ++++++++++++++++++++++++++ .github/scripts/e2e-selection.test.cjs | 47 +++++++++++++++-- Docs/e2e-ci.md | 6 ++- 3 files changed, 120 insertions(+), 5 deletions(-) diff --git a/.github/e2e-policy.json b/.github/e2e-policy.json index b49fd73f3..24e310a7e 100644 --- a/.github/e2e-policy.json +++ b/.github/e2e-policy.json @@ -1010,6 +1010,78 @@ ], "prefixes": [], "reason": "Reviewed display/notification/update/UI-shell files: base gate checks app access and wallet/payment interaction; native tests cover detailed presentation. New files require classification." + }, + { + "name": "shared-keychain", + "groups": [], + "files": ["Bitkit/Utilities/Keychain.swift"], + "prefixes": [], + "reason": "Owns mnemonic/passphrase, PIN and Pubky/Paykit persistence used across wallet, recovery and profile lanes.", + "full": true + }, + { + "name": "shared-scanner-routes", + "groups": ["receive", "pubky-paykit", "electrum", "hardware"], + "files": [ + "Bitkit/Managers/ScannerManager.swift", + "Bitkit/Managers/CameraManager.swift", + "Bitkit/Utilities/QRCodePayload.swift", + "Bitkit/Components/Scanner.swift", + "Bitkit/Views/Scanner/ManualScanPrompt.swift" + ], + "prefixes": [], + "reason": "ScannerManager routes main/send/addContact/electrum input; pubky_profile_3/_5 and settings_10 assert routes not covered by Bitcoin cases; hardware sending also accepts scanned recipients." + }, + { + "name": "wallet-backup-profile-state", + "groups": ["pubky-paykit"], + "files": [ + "Bitkit/Services/BackupService.swift", + "Bitkit/Services/VssBackupClient.swift", + "Bitkit/Services/VssStoreIdProvider.swift", + "Bitkit/Services/AndroidBackupJson.swift", + "Bitkit/Services/BackupFieldMigration.swift", + "Bitkit/Models/BackupPayloads.swift", + "Bitkit/Utilities/AppDataMigrations.swift", + "Bitkit/Views/Onboarding/RestoreWalletView.swift", + "Bitkit/Views/Onboarding/InitializingWalletView.swift", + "Bitkit/Views/Recovery/RecoveryScreen.swift" + ], + "prefixes": [], + "reason": "Shared wallet backup transport/payload/restore retains Pubky session and contacts; pubky_profile_2/_4 assert this persistence." + }, + { + "name": "shared-paykit-payment-flow", + "groups": ["pubky-paykit"], + "files": [ + "Bitkit/Services/OnchainSendAttemptService.swift", + "Bitkit/Utilities/PaymentNavigationHelper.swift", + "Bitkit/Views/Wallets/Send/SendAmountView.swift", + "Bitkit/Views/Wallets/Send/SendConfirmationView.swift", + "Bitkit/Views/Wallets/Send/SendSheet.swift", + "Bitkit/Views/Wallets/Send/SendEnterManuallyView.swift", + "Bitkit/Components/NumberPad.swift", + "Bitkit/Components/NumberPadTextField.swift", + "Bitkit/ViewModels/AmountInputViewModel.swift", + "Bitkit/Utilities/CurrencyFormatter.swift" + ], + "prefixes": [], + "reason": "paykit_1 enters and pays a public on-chain contact through the shared amount/confirmation/dispatch path." + }, + { + "name": "shared-hardware-send-flow", + "groups": ["hardware"], + "files": [ + "Bitkit/Views/Wallets/Send/SendAmountView.swift", + "Bitkit/Views/Wallets/Send/SendConfirmationView.swift", + "Bitkit/Views/Wallets/Send/SendSheet.swift", + "Bitkit/Views/Wallets/Send/SendFeeCustom.swift", + "Bitkit/Views/Wallets/Send/SendFeeRate.swift", + "Bitkit/Views/Wallets/Send/SendUtxoSelectionView.swift", + "Bitkit/Utilities/PaymentNavigationHelper.swift" + ], + "prefixes": [], + "reason": "hardware_wallet_4 uses the shared send flow and fee review before device signing." } ] } diff --git a/.github/scripts/e2e-selection.test.cjs b/.github/scripts/e2e-selection.test.cjs index c2b43255f..987163514 100644 --- a/.github/scripts/e2e-selection.test.cjs +++ b/.github/scripts/e2e-selection.test.cjs @@ -65,7 +65,7 @@ for (const [file, added, staging, migration] of [ [ "Bitkit/Services/BackupService.swift", ["backup_1", "onboarding_1", "onboarding_2", "security_1", "settings_07"], - false, + true, true, ], [ @@ -84,17 +84,18 @@ for (const [file, added, staging, migration] of [ assert.equal(s.migration, migration); }); -test("on-chain service expands payment coverage without unrelated staging/migration", () => { +test("on-chain service retains public Paykit dispatch coverage without migration", () => { const s = pick(["Bitkit/Services/OnchainSendAttemptService.swift"]); assert.deepEqual( s.ids, [...new Set([...policy.base, ...policy.groups.send])].sort(), ); - assert.equal(s.staging, false); + assert.equal(s.staging, true); assert.equal(s.migration, false); }); for (const f of [ "Bitkit/Services/CoreService.swift", + "Bitkit/Utilities/Keychain.swift", "Bitkit/ViewModels/WalletViewModel.swift", "Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", ]) @@ -104,6 +105,46 @@ for (const f of [ assert.equal(s.staging, true); assert.equal(s.migration, true); }); +test("scanner entry points retain Pubky routes, Electrum input and hardware recipients", () => { + for (const file of [ + "Bitkit/Managers/ScannerManager.swift", + "Bitkit/Views/Scanner/ManualScanPrompt.swift", + "Bitkit/Utilities/QRCodePayload.swift", + ]) { + const s = pick([file]); + assert.ok(s.ids.includes("settings_10")); + assert.ok(s.ids.includes("hardware_wallet_4")); + assert.equal(s.staging, true, "pubky_profile_3/_5 exercise scanner routes"); + assert.equal(s.migration, false); + } +}); +test("backup transport, payload and restore retain profile persistence assertions", () => { + for (const file of [ + "Bitkit/Services/VssBackupClient.swift", + "Bitkit/Models/BackupPayloads.swift", + "Bitkit/Views/Onboarding/RestoreWalletView.swift", + ]) { + const s = pick([file]); + assert.equal( + s.staging, + true, + "pubky_profile_2/_4 restore profile/contact state", + ); + assert.equal(s.migration, true); + } +}); +test("shared send entry/confirmation retains Paykit and hardware consumers", () => { + for (const file of [ + "Bitkit/Utilities/PaymentNavigationHelper.swift", + "Bitkit/Views/Wallets/Send/SendAmountView.swift", + "Bitkit/Views/Wallets/Send/SendConfirmationView.swift", + ]) { + const s = pick([file]); + assert.equal(s.staging, true, "paykit_1 pays through this shared flow"); + assert.ok(s.ids.includes("hardware_wallet_4")); + assert.equal(s.migration, false); + } +}); test("unknown service/new area fails cheaply, never silently selects base or full", () => { for (const f of [ "Bitkit/Services/NewService.swift", diff --git a/Docs/e2e-ci.md b/Docs/e2e-ci.md index 25a57f2e2..14560f023 100644 --- a/Docs/e2e-ci.md +++ b/Docs/e2e-ci.md @@ -14,10 +14,12 @@ The selector loads `.github/e2e-policy.json` from the **tested app checkout**, n | `Bitkit/Services/PaykitPaymentRequestService.swift` | Six Pubky/Paykit staging cases; no migration | | `Bitkit/Services/Trezor/TrezorService.swift` | Three active hardware cases; no staging/migration | | `Bitkit/Managers/FeeEstimatesManager.swift` | `boost_1`, `onchain_3`, `settings_03` | -| `Bitkit/Services/BackupService.swift` | Five onboarding/backup/security cases plus six migration cases; no staging | +| `Bitkit/Services/BackupService.swift` | Five onboarding/backup/security cases plus six migration and six Pubky/Paykit staging cases | | `Bitkit/Services/CoreService.swift` | Full coverage: shared wallet/account state affects all lanes | -Only named app-wide wallet/lifecycle files and build/dependency/E2E orchestration configuration select all lanes. At the policy introduction, this means 27 of 619 `Bitkit` Swift files, including three of 76 service files (`CoreService`, `LightningService`, `ServiceQueue`). These are a source-tree audit, not a runtime or billing estimate. Changes to several domains union their coverage and execute overlapping cases once. +Scanner routing explicitly includes Pubky/Electrum and hardware coverage. Backup transport/payload/restore includes profile persistence. The global Keychain is an explicit full-coverage dependency because it owns wallet secrets and Pubky/Paykit state. Shared amount/confirmation and on-chain dispatch include the existing Paykit contact-payment case. These mappings follow actual callers and assertions, not filename similarity. + +Only named app-wide wallet/lifecycle files and build/dependency/E2E orchestration configuration select all lanes. At the policy introduction, this means 28 of 619 `Bitkit` Swift files, including three of 76 service files (`CoreService`, `LightningService`, `ServiceQueue`). These are a source-tree audit, not a runtime or billing estimate. Changes to several domains union their coverage and execute overlapping cases once. Every PR, including drafts and stacks, gets a cheap Linux `E2E selection policy` check. It verifies every tracked path has a classification, explicit file/prefix entries still exist, and referenced cases are runnable in the selected E2E inventory. Main selection runs the same validation before any paid build. A new unclassified service or moved file fails with its path and a request to update the policy in the same iOS PR. It does not silently select base or launch every lane. Old and new rename paths contribute rules from both the base and tested policy, retaining coverage when a path mapping changes during a refactor. From 0eb027de7f2d24dd9e1a4134552c17a14997089e Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 3 Nov 2026 22:46:44 +0100 Subject: [PATCH 10/18] Cover all scanner entry views in iOS selection policy --- .github/e2e-policy.json | 4 ++-- .github/scripts/e2e-selection.test.cjs | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/e2e-policy.json b/.github/e2e-policy.json index 24e310a7e..6b120dbb2 100644 --- a/.github/e2e-policy.json +++ b/.github/e2e-policy.json @@ -918,10 +918,10 @@ }, { "name": "scanner-sheet", - "groups": ["receive"], + "groups": ["receive", "pubky-paykit", "electrum", "hardware"], "files": ["Bitkit/Components/QR.swift"], "prefixes": ["Bitkit/Views/Scanner/"], - "reason": "Receiving/scan entry points." + "reason": "ScannerSheet/ScannerScreen and manual prompt share main/send/addContact/electrum routes; new scanner entry views retain every existing domain." }, { "name": "tag-sheet", diff --git a/.github/scripts/e2e-selection.test.cjs b/.github/scripts/e2e-selection.test.cjs index 987163514..d0c32698f 100644 --- a/.github/scripts/e2e-selection.test.cjs +++ b/.github/scripts/e2e-selection.test.cjs @@ -108,7 +108,10 @@ for (const f of [ test("scanner entry points retain Pubky routes, Electrum input and hardware recipients", () => { for (const file of [ "Bitkit/Managers/ScannerManager.swift", + "Bitkit/Views/Scanner/ScannerSheet.swift", + "Bitkit/Views/Scanner/ScannerScreen.swift", "Bitkit/Views/Scanner/ManualScanPrompt.swift", + "Bitkit/Views/Scanner/NewScannerEntry.swift", "Bitkit/Utilities/QRCodePayload.swift", ]) { const s = pick([file]); From 1a4512f76052dd648d7c430a634372a566cbc7da Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 3 Nov 2026 23:43:18 +0100 Subject: [PATCH 11/18] ci: allow expanded iOS suites to finish within bounded budgets --- .github/scripts/e2e-selection.cjs | 13 ++++++++++++- .github/scripts/e2e-selection.test.cjs | 18 ++++++++++++++++++ .github/scripts/namespace-stack.sh | 7 ++++++- .github/workflows/e2e-tests.yml | 4 +++- Docs/e2e-ci.md | 4 +++- 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/.github/scripts/e2e-selection.cjs b/.github/scripts/e2e-selection.cjs index fcc0d08d8..9d1357b8c 100644 --- a/.github/scripts/e2e-selection.cjs +++ b/.github/scripts/e2e-selection.cjs @@ -189,7 +189,18 @@ function select( ids, matrix: bins .filter((b) => b.length) - .map((ids, i) => ({ name: `local-${i + 1}`, ids: ids.sort() })), + .map((ids, i) => { + // The five-case base fits 40 minutes; expanded packs need room for + // hardware, recovery and settings journeys on the same simulator. + const attemptTimeoutMinutes = ids.length > 5 ? 90 : 40; + return { + name: `local-${i + 1}`, + ids: ids.sort(), + attemptTimeoutMinutes, + jobTimeoutMinutes: 3 * attemptTimeoutMinutes + 15, + backendLifetime: attemptTimeoutMinutes > 40 ? "5h" : "4h", + }; + }), staging: expected.some( (c) => c.platforms.ios.backend === "regtest" && diff --git a/.github/scripts/e2e-selection.test.cjs b/.github/scripts/e2e-selection.test.cjs index d0c32698f..987e36ba1 100644 --- a/.github/scripts/e2e-selection.test.cjs +++ b/.github/scripts/e2e-selection.test.cjs @@ -335,3 +335,21 @@ test("actual CLI uses tested app policy and pinned E2E inventory; rejects drift assert.match(bad.stderr, /Unclassified paths: Unknown/); assert.ok(!fs.existsSync(path.join(temp, "selection.json"))); }); + +test("local budgets cover three attempts plus setup, with a backend that outlives the job", () => { + const base = select(cases, { mode: "base" }).matrix[0]; + assert.equal(base.attemptTimeoutMinutes, 40); + assert.equal(base.jobTimeoutMinutes, 135); + assert.equal(base.backendLifetime, "4h"); + for (const shards of [1, 2]) { + const full = select(cases, { mode: "full", shards }); + assert.equal(full.matrix.length, shards); + for (const shard of full.matrix) { + assert.equal(shard.attemptTimeoutMinutes, 90); + assert.equal(shard.jobTimeoutMinutes, 285); + assert.equal(shard.backendLifetime, "5h"); + assert.ok(shard.jobTimeoutMinutes > 3 * shard.attemptTimeoutMinutes); + assert.ok(parseInt(shard.backendLifetime) * 60 > shard.jobTimeoutMinutes); + } + } +}); diff --git a/.github/scripts/namespace-stack.sh b/.github/scripts/namespace-stack.sh index ee2266276..99a4adb19 100755 --- a/.github/scripts/namespace-stack.sh +++ b/.github/scripts/namespace-stack.sh @@ -26,7 +26,12 @@ up() { install_compose # The runner sets DOCKER_DEFAULT_PLATFORM=linux/arm64, so the instance must be arm64. - nsc create --bare --machine_type linux/arm64:8x16 --duration 4h --cidfile "$instance_file" + local duration="${NAMESPACE_STACK_DURATION:-4h}" + case "$duration" in + 4h|5h) ;; + *) echo "Unsupported Namespace stack lifetime: $duration" >&2; return 1 ;; + esac + nsc create --bare --machine_type linux/arm64:8x16 --duration "$duration" --cidfile "$instance_file" # `attach-context --background` times out before its socket appears; the foreground form works. nohup nsc docker attach-context --to "$(cat "$instance_file")" --name namespace-stack \ diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 59aef2fbd..696a8e5fd 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -264,6 +264,8 @@ jobs: E2E_BACKEND_REVISION: local-docker E2E_VARIANT: local-trezor E2E_CASE_IDS: ${{ toJSON(matrix.shard.ids) }} + E2E_ATTEMPT_TIMEOUT_MINUTES: ${{ matrix.shard.attemptTimeoutMinutes }} + NAMESPACE_STACK_DURATION: ${{ matrix.shard.backendLifetime }} RECORD_VIDEO: "true" strategy: fail-fast: false @@ -388,7 +390,7 @@ jobs: - name: Tear down regtest stack if: always() run: .github/scripts/namespace-stack.sh down - timeout-minutes: 135 + timeout-minutes: ${{ matrix.shard.jobTimeoutMinutes }} e2e-status: if: always() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) name: e2e-status diff --git a/Docs/e2e-ci.md b/Docs/e2e-ci.md index 14560f023..569cfa242 100644 --- a/Docs/e2e-ci.md +++ b/Docs/e2e-ci.md @@ -47,6 +47,8 @@ gh workflow run e2e-tests.yml --ref codex/264-inventory-shadow \ To test a PR merge, including a stacked PR, also pass `-f pr=NUMBER -f selection=auto`. The merge must match the PR's actual base and head. `selection=full` exercises all lanes. The optional `shards=2` experiment applies only to manual local execution; automatic runs use one shard. Each shard has isolated infrastructure and shares the same built local app. Staging/migration share one network app build. -Attempts are bounded to three, stop on success, and retain first-failure evidence. Missing selected cases or failed selected jobs fail the aggregate status. Builds are capped at 40 minutes, execution jobs at 135 minutes and runner attempts at 40 minutes. Diagnostic artifacts expire after seven days. Compare end-to-end duration and billed Mac/backend usage before making two shards automatic. +Attempts are bounded to three, stop on success, and retain first-failure evidence. Missing selected cases or failed selected jobs fail the aggregate status. Builds are capped at 40 minutes; execution budgets depend on the selected pack as described below. Diagnostic artifacts expire after seven days. Compare end-to-end duration and billed Mac/backend usage before making two shards automatic. Land the E2E producer first. Validate these branches before merging this consumer. The migration fixture workflow is pinned to the producer commit that supports immutable source selection, allowing full branch validation before either PR merges. There is no shadow workflow and no additional smoke-test replacement. + +Expanded local selections (more than five cases per shard) allow 90 minutes per attempt, up to three attempts, within a 285-minute job and five-hour backend expiry. Base-sized local selections retain 40-minute attempts and a 135-minute job; staging/migration retain their existing limits. The iOS selector owns these budgets and the shared runner seals its attempt limit into the run plan. Limits are ceilings, not reservations: success stops retries and cleanup destroys the backend immediately. From 2117bd2bd0ad2997c2b4763f6fa4adb70cc94d61 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Tue, 3 Nov 2026 23:52:25 +0100 Subject: [PATCH 12/18] ci: budget the complete iOS staging pack --- .github/workflows/e2e-staging.yml | 4 +++- Docs/e2e-ci.md | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-staging.yml b/.github/workflows/e2e-staging.yml index 53126d912..5eda4b65a 100644 --- a/.github/workflows/e2e-staging.yml +++ b/.github/workflows/e2e-staging.yml @@ -142,7 +142,8 @@ jobs: e2e_branch_input: ${{ inputs.e2e_branch || 'default-feature-branch' }} e2e-tests-staging: runs-on: macos-latest - timeout-minutes: 135 + # Three complete six-case packs, plus setup and artifact collection. + timeout-minutes: 285 needs: - build-staging - e2e-branch @@ -169,6 +170,7 @@ jobs: E2E_BACKEND_REVISION: regtest-stag0 E2E_VARIANT: network-regtest E2E_CASE_IDS: ${{ toJSON(matrix.shard.ids) }} + E2E_ATTEMPT_TIMEOUT_MINUTES: "90" RECORD_VIDEO: "true" SIMULATOR_NAME: iPhone 17 SIMULATOR_OS_VERSION: "26.2" diff --git a/Docs/e2e-ci.md b/Docs/e2e-ci.md index 569cfa242..345907bb6 100644 --- a/Docs/e2e-ci.md +++ b/Docs/e2e-ci.md @@ -51,4 +51,4 @@ Attempts are bounded to three, stop on success, and retain first-failure evidenc Land the E2E producer first. Validate these branches before merging this consumer. The migration fixture workflow is pinned to the producer commit that supports immutable source selection, allowing full branch validation before either PR merges. There is no shadow workflow and no additional smoke-test replacement. -Expanded local selections (more than five cases per shard) allow 90 minutes per attempt, up to three attempts, within a 285-minute job and five-hour backend expiry. Base-sized local selections retain 40-minute attempts and a 135-minute job; staging/migration retain their existing limits. The iOS selector owns these budgets and the shared runner seals its attempt limit into the run plan. Limits are ceilings, not reservations: success stops retries and cleanup destroys the backend immediately. +Expanded local selections (more than five cases per shard) allow 90 minutes per attempt, up to three attempts, within a 285-minute job and five-hour backend expiry. Base-sized local selections retain 40-minute attempts and a 135-minute job; the six-case staging pack also uses 90-minute attempts within 285 minutes. Individual migration cases retain 40-minute attempts and 135-minute jobs. The iOS selector owns these budgets and the shared runner seals its attempt limit into the run plan. Limits are ceilings, not reservations: success stops retries and cleanup destroys the backend immediately. From f4f09ff82527b5304e490a3c56ecad4e879652be Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 4 Nov 2026 01:04:42 +0100 Subject: [PATCH 13/18] ci: retain iOS migration preparation diagnostics for seven days --- .github/workflows/e2e_migration.yml | 3 ++- Docs/e2e-ci.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index f05e4c7de..dafc73ded 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -203,7 +203,7 @@ jobs: - e2e-branch - resolve-e2e-ref - migration-plan - uses: synonymdev/bitkit-e2e-tests/.github/workflows/migration-wallet-setup.yml@39f8964ee28c801874bae1cc7667505c9bb03065 + uses: synonymdev/bitkit-e2e-tests/.github/workflows/migration-wallet-setup.yml@e49cfab3c25b8bfe18c59ae471405d0b6d45bdfc strategy: fail-fast: false matrix: @@ -215,6 +215,7 @@ jobs: setup_type: ${{ matrix.scenario.setup_type }} scenario_name: ${{ matrix.scenario.name }} e2e_sha: ${{ needs.migration-plan.outputs.e2eSha }} + diagnostics_retention_days: 7 rn-tests: runs-on: macos-latest needs: diff --git a/Docs/e2e-ci.md b/Docs/e2e-ci.md index 345907bb6..4b332e535 100644 --- a/Docs/e2e-ci.md +++ b/Docs/e2e-ci.md @@ -47,7 +47,7 @@ gh workflow run e2e-tests.yml --ref codex/264-inventory-shadow \ To test a PR merge, including a stacked PR, also pass `-f pr=NUMBER -f selection=auto`. The merge must match the PR's actual base and head. `selection=full` exercises all lanes. The optional `shards=2` experiment applies only to manual local execution; automatic runs use one shard. Each shard has isolated infrastructure and shares the same built local app. Staging/migration share one network app build. -Attempts are bounded to three, stop on success, and retain first-failure evidence. Missing selected cases or failed selected jobs fail the aggregate status. Builds are capped at 40 minutes; execution budgets depend on the selected pack as described below. Diagnostic artifacts expire after seven days. Compare end-to-end duration and billed Mac/backend usage before making two shards automatic. +Attempts are bounded to three, stop on success, and retain first-failure evidence. Missing selected cases or failed selected jobs fail the aggregate status. Builds are capped at 40 minutes; execution budgets depend on the selected pack as described below. Diagnostic artifacts, including RN wallet preparation logs, expire after seven days; prepared wallet environments expire after one day. Compare end-to-end duration and billed Mac/backend usage before making two shards automatic. Land the E2E producer first. Validate these branches before merging this consumer. The migration fixture workflow is pinned to the producer commit that supports immutable source selection, allowing full branch validation before either PR merges. There is no shadow workflow and no additional smoke-test replacement. From cb95ac79ef4c7d0b45900c95d5628c2ba4e0e6e4 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 4 Nov 2026 09:09:42 +0100 Subject: [PATCH 14/18] ci: allow 110 minutes for large local iOS packs --- .github/scripts/e2e-selection.cjs | 8 ++++---- .github/scripts/e2e-selection.test.cjs | 6 +++--- .github/scripts/namespace-stack.sh | 2 +- Docs/e2e-ci.md | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/scripts/e2e-selection.cjs b/.github/scripts/e2e-selection.cjs index 9d1357b8c..86fd9c8de 100644 --- a/.github/scripts/e2e-selection.cjs +++ b/.github/scripts/e2e-selection.cjs @@ -190,15 +190,15 @@ function select( matrix: bins .filter((b) => b.length) .map((ids, i) => { - // The five-case base fits 40 minutes; expanded packs need room for - // hardware, recovery and settings journeys on the same simulator. - const attemptTimeoutMinutes = ids.length > 5 ? 90 : 40; + // The hosted 42-case pack reached the widget cases at minute 90. + const largePack = ids.length > 30; + const attemptTimeoutMinutes = largePack ? 110 : ids.length > 5 ? 90 : 40; return { name: `local-${i + 1}`, ids: ids.sort(), attemptTimeoutMinutes, jobTimeoutMinutes: 3 * attemptTimeoutMinutes + 15, - backendLifetime: attemptTimeoutMinutes > 40 ? "5h" : "4h", + backendLifetime: largePack ? "6h" : ids.length > 5 ? "5h" : "4h", }; }), staging: expected.some( diff --git a/.github/scripts/e2e-selection.test.cjs b/.github/scripts/e2e-selection.test.cjs index 987e36ba1..fb6893aa0 100644 --- a/.github/scripts/e2e-selection.test.cjs +++ b/.github/scripts/e2e-selection.test.cjs @@ -345,9 +345,9 @@ test("local budgets cover three attempts plus setup, with a backend that outlive const full = select(cases, { mode: "full", shards }); assert.equal(full.matrix.length, shards); for (const shard of full.matrix) { - assert.equal(shard.attemptTimeoutMinutes, 90); - assert.equal(shard.jobTimeoutMinutes, 285); - assert.equal(shard.backendLifetime, "5h"); + assert.equal(shard.attemptTimeoutMinutes, shards === 1 ? 110 : 90); + assert.equal(shard.jobTimeoutMinutes, shards === 1 ? 345 : 285); + assert.equal(shard.backendLifetime, shards === 1 ? "6h" : "5h"); assert.ok(shard.jobTimeoutMinutes > 3 * shard.attemptTimeoutMinutes); assert.ok(parseInt(shard.backendLifetime) * 60 > shard.jobTimeoutMinutes); } diff --git a/.github/scripts/namespace-stack.sh b/.github/scripts/namespace-stack.sh index 99a4adb19..da384919b 100755 --- a/.github/scripts/namespace-stack.sh +++ b/.github/scripts/namespace-stack.sh @@ -28,7 +28,7 @@ up() { # The runner sets DOCKER_DEFAULT_PLATFORM=linux/arm64, so the instance must be arm64. local duration="${NAMESPACE_STACK_DURATION:-4h}" case "$duration" in - 4h|5h) ;; + 4h|5h|6h) ;; *) echo "Unsupported Namespace stack lifetime: $duration" >&2; return 1 ;; esac nsc create --bare --machine_type linux/arm64:8x16 --duration "$duration" --cidfile "$instance_file" diff --git a/Docs/e2e-ci.md b/Docs/e2e-ci.md index 4b332e535..4177216a8 100644 --- a/Docs/e2e-ci.md +++ b/Docs/e2e-ci.md @@ -51,4 +51,4 @@ Attempts are bounded to three, stop on success, and retain first-failure evidenc Land the E2E producer first. Validate these branches before merging this consumer. The migration fixture workflow is pinned to the producer commit that supports immutable source selection, allowing full branch validation before either PR merges. There is no shadow workflow and no additional smoke-test replacement. -Expanded local selections (more than five cases per shard) allow 90 minutes per attempt, up to three attempts, within a 285-minute job and five-hour backend expiry. Base-sized local selections retain 40-minute attempts and a 135-minute job; the six-case staging pack also uses 90-minute attempts within 285 minutes. Individual migration cases retain 40-minute attempts and 135-minute jobs. The iOS selector owns these budgets and the shared runner seals its attempt limit into the run plan. Limits are ceilings, not reservations: success stops retries and cleanup destroys the backend immediately. +Local shards with 6–30 cases allow 90 minutes per attempt within a 285-minute job and five-hour backend expiry. Larger shards allow 110 minutes per attempt within a 345-minute job and six-hour backend expiry. Both sizes allow three attempts. Base-sized local selections retain 40-minute attempts and a 135-minute job; the six-case staging pack also uses 90-minute attempts within 285 minutes. Individual migration cases retain 40-minute attempts and 135-minute jobs. The iOS selector owns these budgets and the shared runner seals its attempt limit into the run plan. Limits are ceilings, not reservations: success stops retries and cleanup destroys the backend immediately. From a43207f13557c3ae2d157d68639b8a4e69ad9535 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 4 Nov 2026 10:13:56 +0100 Subject: [PATCH 15/18] ci: run iOS migrations nightly and on release pushes --- .github/e2e-policy.json | 8 +------- .github/scripts/e2e-selection.cjs | 13 +++++++++---- .github/scripts/e2e-selection.test.cjs | 25 +++++++++++++++++++------ .github/workflows/e2e-tests.yml | 3 ++- .github/workflows/e2e_migration.yml | 11 +++++++---- Docs/e2e-ci.md | 10 +++++----- 6 files changed, 43 insertions(+), 27 deletions(-) diff --git a/.github/e2e-policy.json b/.github/e2e-policy.json index 6b120dbb2..91ba7e956 100644 --- a/.github/e2e-policy.json +++ b/.github/e2e-policy.json @@ -48,13 +48,7 @@ "onboarding_1", "onboarding_2", "security_1", - "settings_07", - "migration_native_restore", - "migration_native_upgrade", - "migration_rn_restore", - "migration_rn_upgrade", - "migration_3", - "migration_4" + "settings_07" ], "security": ["security_1", "settings_06", "settings_07"], "widgets": ["widgets_1", "widgets_2", "widgets_3", "settings_12"], diff --git a/.github/scripts/e2e-selection.cjs b/.github/scripts/e2e-selection.cjs index 86fd9c8de..2775c6aba 100644 --- a/.github/scripts/e2e-selection.cjs +++ b/.github/scripts/e2e-selection.cjs @@ -102,6 +102,7 @@ function select( files = [], shards = 1, release = false, + pr = false, currentPolicy = policy, previousPolicy, } = {}, @@ -120,8 +121,14 @@ function select( const policies = previousPolicy ? [currentPolicy, previousPolicy] : [currentPolicy]; + // Release pushes run the dedicated migration workflow. This workflow adds + // migration only in a nightly run (or its explicit branch rehearsal). + const includeMigration = !pr && mode === "nightly"; const runnable = cases.filter( - (c) => c.kind === "test" && c.platforms.ios.disposition === "required", + (c) => + c.kind === "test" && + c.platforms.ios.disposition === "required" && + (includeMigration || c.platforms.ios.pack !== "migration"), ); const local = runnable.filter((c) => c.platforms.ios.backend === "local"); const selected = new Set(currentPolicy.base); @@ -161,9 +168,6 @@ function select( if (code && previousPolicy) for (const id of previousPolicy.base) selected.add(id); } - if (release) - for (const c of runnable) - if (c.platforms.ios.pack === "migration") selected.add(c.id); if (full) for (const c of runnable) selected.add(c.id); const expected = code ? runnable.filter((c) => selected.has(c.id)) : []; const ids = expected @@ -284,6 +288,7 @@ function main() { mode, files, previousPolicy, + pr: Boolean(pr), shards: process.env.GITHUB_EVENT_NAME === "workflow_dispatch" ? Number(process.env.E2E_SHARDS || 1) diff --git a/.github/scripts/e2e-selection.test.cjs b/.github/scripts/e2e-selection.test.cjs index fb6893aa0..7431b66bd 100644 --- a/.github/scripts/e2e-selection.test.cjs +++ b/.github/scripts/e2e-selection.test.cjs @@ -66,7 +66,7 @@ for (const [file, added, staging, migration] of [ "Bitkit/Services/BackupService.swift", ["backup_1", "onboarding_1", "onboarding_2", "security_1", "settings_07"], true, - true, + false, ], [ "Bitkit/Services/ElectrumConfigService.swift", @@ -99,11 +99,11 @@ for (const f of [ "Bitkit/ViewModels/WalletViewModel.swift", "Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", ]) - test(`explicit shared dependency ${f} expands all lanes`, () => { + test(`explicit shared dependency ${f} expands local and staging coverage`, () => { const s = pick([f]); assert.equal(s.ids.length, 42); assert.equal(s.staging, true); - assert.equal(s.migration, true); + assert.equal(s.migration, false); }); test("scanner entry points retain Pubky routes, Electrum input and hardware recipients", () => { for (const file of [ @@ -133,7 +133,7 @@ test("backup transport, payload and restore retain profile persistence assertion true, "pubky_profile_2/_4 restore profile/contact state", ); - assert.equal(s.migration, true); + assert.equal(s.migration, false); } }); test("shared send entry/confirmation retains Paykit and hardware consumers", () => { @@ -229,12 +229,25 @@ test("USDT selection records missing coverage instead of pretending all Bitcoin assert.deepEqual(s.ids, [...policy.base].sort()); assert.ok(s.coverageNotes[0].includes("USDT")); }); -test("nightly/full include all existing applicable lanes with no changes; gaps stay explicit", () => { +test("migration is excluded from all PR gates even when the base policy requests it", () => { + const previousPolicy = structuredClone(policy); + previousPolicy.groups.recovery.push("migration_rn_upgrade"); + const files = ["Bitkit/Services/MigrationsService.swift", ".github/workflows/e2e_migration.yml"]; + for (const mode of ["auto", "base", "full", "nightly"]) { + for (const release of [false, true]) { + const result = pick(files, { mode, pr: true, release, previousPolicy }); + assert.equal(result.migration, false); + assert.equal(result.staging, mode !== "base"); + assert.equal(result.ids.length, mode === "base" ? 5 : 42); + } + } +}); +test("nightly includes migration while manual full includes local/staging; gaps stay explicit", () => { for (const mode of ["full", "nightly"]) { const s = pick([], { mode }); assert.equal(s.ids.length, 42); assert.equal(s.staging, true); - assert.equal(s.migration, true); + assert.equal(s.migration, mode === "nightly"); assert.ok(s.gaps.some((g) => g.id === "transfer_1")); assert.ok(!s.ids.includes("transfer_1")); } diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 696a8e5fd..f1f02bed1 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -33,12 +33,13 @@ on: description: Recovery helper branch or tag default: master selection: - description: "Test selection: auto for a PR merge, base, or full" + description: "auto: PR changes; base: five cases; full: local/staging; nightly: rehearse all lanes on a branch" type: choice options: - full - auto - base + - nightly default: full shards: description: Manual local-shard cost experiment; automatic runs use one diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index dafc73ded..09d5bda7c 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -1,5 +1,7 @@ name: E2E Migration (iOS) on: + push: + branches: ["release-*"] workflow_dispatch: inputs: e2e_branch: @@ -40,6 +42,7 @@ env: TERM: xterm-256color FORCE_COLOR: 1 GIT_LFS_SKIP_SMUDGE: "1" + MIGRATION_EXTENDED: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/heads/release-')) || inputs.extended_rn || false }} SIMULATOR_NAME: iPhone 17 IOS_VERSION: "26.2" concurrency: @@ -47,6 +50,7 @@ concurrency: cancel-in-progress: true jobs: build: + if: github.event_name != 'push' || !github.event.deleted runs-on: ${{ inputs.app_artifact != '' && 'ubuntu-latest' || 'macos-latest' }} steps: - name: Checkout @@ -146,6 +150,7 @@ jobs: outputs: appSha: ${{ inputs.app_ref || steps.app.outputs.sha }} e2e-branch: + if: github.event_name != 'push' || !github.event.deleted uses: synonymdev/bitkit-e2e-tests/.github/workflows/determine-e2e-branch.yml@main with: app_branch: ${{ github.head_ref || github.ref_name }} @@ -193,7 +198,7 @@ jobs: run: python3 scripts/migration-matrix.py ios env: PREVIOUS_NATIVE_VERSION: ${{ inputs.previous_native_version }} - EXTENDED_RN: ${{ inputs.extended_rn || false }} + EXTENDED_RN: ${{ env.MIGRATION_EXTENDED }} - name: Pin resolved E2E source id: tests-sha run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" @@ -230,7 +235,6 @@ jobs: name: ios RN - ${{ matrix.scenario.version }} - ${{ matrix.scenario.name }} env: BACKEND: regtest - MIGRATION_EXTENDED: ${{ inputs.extended_rn || false }} PLATFORM: ios AUT_FILENAME: bitkit.app SIMULATOR_NAME: iPhone 17 @@ -404,7 +408,6 @@ jobs: name: ios native - ${{ matrix.scenario.version }} - ${{ matrix.scenario.name }} env: BACKEND: regtest - MIGRATION_EXTENDED: ${{ inputs.extended_rn || false }} PLATFORM: ios AUT_FILENAME: bitkit.app SIMULATOR_NAME: iPhone 17 @@ -549,7 +552,7 @@ jobs: retention-days: 7 timeout-minutes: 135 migration-result: - if: always() + if: always() && (github.event_name != 'push' || !github.event.deleted) needs: - build - e2e-branch diff --git a/Docs/e2e-ci.md b/Docs/e2e-ci.md index 4177216a8..e9c287723 100644 --- a/Docs/e2e-ci.md +++ b/Docs/e2e-ci.md @@ -14,12 +14,12 @@ The selector loads `.github/e2e-policy.json` from the **tested app checkout**, n | `Bitkit/Services/PaykitPaymentRequestService.swift` | Six Pubky/Paykit staging cases; no migration | | `Bitkit/Services/Trezor/TrezorService.swift` | Three active hardware cases; no staging/migration | | `Bitkit/Managers/FeeEstimatesManager.swift` | `boost_1`, `onchain_3`, `settings_03` | -| `Bitkit/Services/BackupService.swift` | Five onboarding/backup/security cases plus six migration and six Pubky/Paykit staging cases | -| `Bitkit/Services/CoreService.swift` | Full coverage: shared wallet/account state affects all lanes | +| `Bitkit/Services/BackupService.swift` | Five onboarding/backup/security cases plus six Pubky/Paykit staging cases | +| `Bitkit/Services/CoreService.swift` | All local and staging cases | Scanner routing explicitly includes Pubky/Electrum and hardware coverage. Backup transport/payload/restore includes profile persistence. The global Keychain is an explicit full-coverage dependency because it owns wallet secrets and Pubky/Paykit state. Shared amount/confirmation and on-chain dispatch include the existing Paykit contact-payment case. These mappings follow actual callers and assertions, not filename similarity. -Only named app-wide wallet/lifecycle files and build/dependency/E2E orchestration configuration select all lanes. At the policy introduction, this means 28 of 619 `Bitkit` Swift files, including three of 76 service files (`CoreService`, `LightningService`, `ServiceQueue`). These are a source-tree audit, not a runtime or billing estimate. Changes to several domains union their coverage and execute overlapping cases once. +Only named app-wide wallet/lifecycle files and build/dependency/E2E orchestration configuration select all PR-eligible local and staging cases. At the policy introduction, this means 28 of 619 `Bitkit` Swift files, including three of 76 service files (`CoreService`, `LightningService`, `ServiceQueue`). These are a source-tree audit, not a runtime or billing estimate. Changes to several domains union their coverage and execute overlapping cases once. Every PR, including drafts and stacks, gets a cheap Linux `E2E selection policy` check. It verifies every tracked path has a classification, explicit file/prefix entries still exist, and referenced cases are runnable in the selected E2E inventory. Main selection runs the same validation before any paid build. A new unclassified service or moved file fails with its path and a request to update the policy in the same iOS PR. It does not silently select base or launch every lane. Old and new rename paths contribute rules from both the base and tested policy, retaining coverage when a path mapping changes during a refactor. @@ -36,7 +36,7 @@ The E2E checkout must contain the matching inventory. Add a real-path regression ## Execution -Every night at 03:00 UTC the entry point runs all applicable local, staging and extended migration cases, even for an unchanged revision. The existing staging/migration workflows provide reusable execution and retain standalone manual dispatch. They no longer schedule duplicate runs. Known skipped cases and unsupported mainnet fixtures are reported as gaps, not successful coverage. +Every night at 03:00 UTC the entry point runs all applicable local, staging and extended migration cases, even for an unchanged revision. The existing staging/migration workflows provide reusable execution and retain standalone manual dispatch. A push to a `release-*` branch runs all six migrations in the dedicated migration workflow, including the extended RN cases. Known skipped cases and unsupported mainnet fixtures are reported as gaps, not successful coverage. Manual validation uses the branch containing the changed workflow: @@ -45,7 +45,7 @@ gh workflow run e2e-tests.yml --ref codex/264-inventory-shadow \ -f e2e_branch=codex/264-inventory-shadow -f selection=base -f shards=1 ``` -To test a PR merge, including a stacked PR, also pass `-f pr=NUMBER -f selection=auto`. The merge must match the PR's actual base and head. `selection=full` exercises all lanes. The optional `shards=2` experiment applies only to manual local execution; automatic runs use one shard. Each shard has isolated infrastructure and shares the same built local app. Staging/migration share one network app build. +To test a PR merge, including a stacked PR, also pass `-f pr=NUMBER -f selection=auto`. The merge must match the PR's actual base and head. `selection=full` runs the local and staging suites. To reproduce the nightly including migration, dispatch `selection=nightly` on a branch. PR merges always use local and staging coverage. The optional `shards=2` experiment applies only to manual local execution; automatic runs use one shard. Each shard has isolated infrastructure and shares the same built local app. Staging/migration share one network app build. Attempts are bounded to three, stop on success, and retain first-failure evidence. Missing selected cases or failed selected jobs fail the aggregate status. Builds are capped at 40 minutes; execution budgets depend on the selected pack as described below. Diagnostic artifacts, including RN wallet preparation logs, expire after seven days; prepared wallet environments expire after one day. Compare end-to-end duration and billed Mac/backend usage before making two shards automatic. From 5ba352f782e1c0d31c6540091639e119ee493632 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 4 Nov 2026 10:18:30 +0100 Subject: [PATCH 16/18] ci: verify current PR merges before running branch code --- .github/scripts/e2e-selection.cjs | 36 +++++++++++++------------- .github/scripts/e2e-selection.test.cjs | 14 ++++++++++ .github/workflows/e2e-tests.yml | 23 +++++++++++++++- Docs/e2e-ci.md | 2 +- 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/.github/scripts/e2e-selection.cjs b/.github/scripts/e2e-selection.cjs index 2775c6aba..6ee445390 100644 --- a/.github/scripts/e2e-selection.cjs +++ b/.github/scripts/e2e-selection.cjs @@ -219,6 +219,16 @@ function select( }; } +function currentPrMerge(number, parents, api) { + if (!/^[1-9][0-9]*$/.test(String(number))) throw Error("Invalid PR number"); + const pr = api(`pulls/${number}`); + if (pr.state !== "open") throw Error("PR is no longer open"); + const base = api(`branches/${encodeURIComponent(pr.base.ref)}`).commit.sha; + if (parents !== `${base} ${pr.head.sha}`) + throw Error("PR merge does not match current base and head"); + return { pr, base }; +} + function main() { const app = path.resolve(__dirname, "../.."); const e2e = path.resolve( @@ -245,24 +255,13 @@ function main() { const event = JSON.parse( fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"), ); - let pr = event.pull_request; - const manualPr = process.env.E2E_MANUAL_PR; - if (manualPr) { - if (!/^\d+$/.test(manualPr)) throw Error("Invalid PR number"); - pr = JSON.parse( - execFileSync( - "gh", - ["api", `repos/${process.env.GITHUB_REPOSITORY}/pulls/${manualPr}`], - { encoding: "utf8" }, - ), - ); - } - const base = pr?.base?.sha; - if ( - pr && - git("show", "-s", "--format=%P", "HEAD") !== `${base} ${pr.head.sha}` - ) - throw Error("PR merge does not match current base and head"); + const prNumber = process.env.E2E_MANUAL_PR || event.pull_request?.number; + const api = (resource) => JSON.parse(execFileSync( + "gh", ["api", `repos/${process.env.GITHUB_REPOSITORY}/${resource}`], { encoding: "utf8" }, + )); + const { pr, base } = prNumber + ? currentPrMerge(prNumber, git("show", "-s", "--format=%P", "HEAD"), api) + : {}; const files = base ? execFileSync( "git", @@ -339,6 +338,7 @@ if (require.main === module) { } } module.exports = { + currentPrMerge, policy, select, matchingRules, diff --git a/.github/scripts/e2e-selection.test.cjs b/.github/scripts/e2e-selection.test.cjs index 7431b66bd..bd53504f5 100644 --- a/.github/scripts/e2e-selection.test.cjs +++ b/.github/scripts/e2e-selection.test.cjs @@ -9,6 +9,7 @@ const { select, validatePolicy, validateTree, + currentPrMerge, } = require("./e2e-selection.cjs"); const app = path.resolve(__dirname, "../.."); const e2e = path.resolve( @@ -229,6 +230,19 @@ test("USDT selection records missing coverage instead of pretending all Bitcoin assert.deepEqual(s.ids, [...policy.base].sort()); assert.ok(s.coverageNotes[0].includes("USDT")); }); +test("reruns verify the live branch tip and open PR head, including stacked PRs", () => { + const pr = { state: "open", base: {ref: "feature/stack", sha: "old"}, head: {sha: "head"} }; + const api = (path) => { + if (path === "pulls/42") return pr; + assert.equal(path, "branches/feature%2Fstack"); + return {commit: {sha: "current"}}; + }; + assert.deepEqual(currentPrMerge(42, "current head", api), {pr, base: "current"}); + assert.throws(() => currentPrMerge(42, "old head", api), /current base and head/); + assert.throws(() => currentPrMerge(42, "current stale-head", api), /current base and head/); + pr.state = "closed"; + assert.throws(() => currentPrMerge(42, "current head", api), /no longer open/); +}); test("migration is excluded from all PR gates even when the base policy requests it", () => { const previousPolicy = structuredClone(policy); previousPolicy.groups.recovery.push("migration_rn_upgrade"); diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index f1f02bed1..b26b3bd1d 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -83,10 +83,31 @@ jobs: appSha: ${{ steps.selection.outputs.appSha }} e2eSha: ${{ steps.selection.outputs.e2eSha }} steps: + - name: Verify manual PR before checking out its code + id: manual-pr + if: github.event_name == 'workflow_dispatch' && inputs.pr != '' + uses: actions/github-script@v9 + env: + PR_NUMBER: ${{ inputs.pr }} + EXPECTED_HEAD: ${{ inputs.head_sha }} + EXPECTED_MERGE: ${{ inputs.merge_commit }} + with: + result-encoding: string + script: | + const n = process.env.PR_NUMBER; + if (!/^[1-9][0-9]*$/.test(n)) throw new Error('Invalid PR number'); + const {owner, repo} = context.repo; + const {data: pr} = await github.rest.pulls.get({owner, repo, pull_number: Number(n)}); + if (pr.state !== 'open' || pr.head.repo?.id !== context.payload.repository.id) + throw new Error('Manual PR validation requires an open PR from this repository'); + if (!pr.merge_commit_sha || (process.env.EXPECTED_MERGE && process.env.EXPECTED_MERGE !== pr.merge_commit_sha) || + (process.env.EXPECTED_HEAD && process.env.EXPECTED_HEAD !== pr.head.sha)) + throw new Error('Manual PR merge or head changed'); + return pr.merge_commit_sha; - name: Checkout tested app uses: actions/checkout@v7 with: - ref: ${{ inputs.merge_commit || (inputs.pr && format('refs/pull/{0}/merge', inputs.pr)) || github.sha }} + ref: ${{ steps.manual-pr.outputs.result || github.sha }} path: app-ci fetch-depth: 0 - name: Resolve E2E tests ref diff --git a/Docs/e2e-ci.md b/Docs/e2e-ci.md index e9c287723..46f577a15 100644 --- a/Docs/e2e-ci.md +++ b/Docs/e2e-ci.md @@ -45,7 +45,7 @@ gh workflow run e2e-tests.yml --ref codex/264-inventory-shadow \ -f e2e_branch=codex/264-inventory-shadow -f selection=base -f shards=1 ``` -To test a PR merge, including a stacked PR, also pass `-f pr=NUMBER -f selection=auto`. The merge must match the PR's actual base and head. `selection=full` runs the local and staging suites. To reproduce the nightly including migration, dispatch `selection=nightly` on a branch. PR merges always use local and staging coverage. The optional `shards=2` experiment applies only to manual local execution; automatic runs use one shard. Each shard has isolated infrastructure and shares the same built local app. Staging/migration share one network app build. +To test an open same-repository PR merge, including a stacked PR, also pass `-f pr=NUMBER -f selection=auto`. The merge must match the PR's actual base and head. `selection=full` runs the local and staging suites. To reproduce the nightly including migration, dispatch `selection=nightly` on a branch. PR merges always use local and staging coverage. The optional `shards=2` experiment applies only to manual local execution; automatic runs use one shard. Each shard has isolated infrastructure and shares the same built local app. Staging/migration share one network app build. Attempts are bounded to three, stop on success, and retain first-failure evidence. Missing selected cases or failed selected jobs fail the aggregate status. Builds are capped at 40 minutes; execution budgets depend on the selected pack as described below. Diagnostic artifacts, including RN wallet preparation logs, expire after seven days; prepared wallet environments expire after one day. Compare end-to-end duration and billed Mac/backend usage before making two shards automatic. From 94088e98663fce11400e265274b56bed181caea5 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 4 Nov 2026 12:46:58 +0100 Subject: [PATCH 17/18] ci: separate migration from the E2E workflow --- .github/scripts/e2e-selection.cjs | 13 ++--- .github/scripts/e2e-selection.test.cjs | 50 +++++++++++------- .github/workflows/e2e-tests.yml | 23 ++------ .github/workflows/e2e_migration.yml | 72 +++++--------------------- Docs/e2e-ci.md | 4 +- 5 files changed, 52 insertions(+), 110 deletions(-) diff --git a/.github/scripts/e2e-selection.cjs b/.github/scripts/e2e-selection.cjs index 6ee445390..204ede308 100644 --- a/.github/scripts/e2e-selection.cjs +++ b/.github/scripts/e2e-selection.cjs @@ -102,7 +102,6 @@ function select( files = [], shards = 1, release = false, - pr = false, currentPolicy = policy, previousPolicy, } = {}, @@ -121,14 +120,11 @@ function select( const policies = previousPolicy ? [currentPolicy, previousPolicy] : [currentPolicy]; - // Release pushes run the dedicated migration workflow. This workflow adds - // migration only in a nightly run (or its explicit branch rehearsal). - const includeMigration = !pr && mode === "nightly"; const runnable = cases.filter( (c) => c.kind === "test" && c.platforms.ios.disposition === "required" && - (includeMigration || c.platforms.ios.pack !== "migration"), + c.platforms.ios.pack !== "migration", ); const local = runnable.filter((c) => c.platforms.ios.backend === "local"); const selected = new Set(currentPolicy.base); @@ -210,7 +206,6 @@ function select( c.platforms.ios.backend === "regtest" && c.platforms.ios.pack !== "migration", ), - migration: expected.some((c) => c.platforms.ios.pack === "migration"), reasons, coverageNotes: [...coverageNotes], gaps: cases @@ -281,13 +276,14 @@ function main() { : process.env.GITHUB_EVENT_NAME === "pull_request" ? "auto" : process.env.E2E_SELECTION || "full"; + if (mode === "nightly" && process.env.GITHUB_EVENT_NAME !== "schedule") + throw Error("Nightly mode requires a scheduled event"); if (mode === "auto" && !base) throw Error("Automatic manual selection requires a PR number"); const result = select(inventory.cases, { mode, files, previousPolicy, - pr: Boolean(pr), shards: process.env.GITHUB_EVENT_NAME === "workflow_dispatch" ? Number(process.env.E2E_SHARDS || 1) @@ -314,7 +310,6 @@ function main() { "code", "matrix", "staging", - "migration", "appSha", "e2eSha", ]) @@ -325,7 +320,7 @@ function main() { if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync( process.env.GITHUB_STEP_SUMMARY, - `## iOS E2E selection\n\nApp/policy: ${tested}; tests: ${result.e2eSha}\n\nLocal cases (${result.ids.length}): ${result.ids.join(", ") || "none"}\n\nStaging: ${result.staging}; migration: ${result.migration}; shards: ${result.matrix.length}\n\n${result.reasons.map((r) => `- ${r.file}: ${r.rule} (${r.policy})`).join("\n")}\n\nCoverage notes: ${result.coverageNotes.join("; ") || "none"}\n\nKnown gaps: ${result.gaps.map((g) => g.id).join(", ")}\n`, + `## iOS E2E selection\n\nApp/policy: ${tested}; tests: ${result.e2eSha}\n\nLocal cases (${result.ids.length}): ${result.ids.join(", ") || "none"}\n\nStaging: ${result.staging}; shards: ${result.matrix.length}\n\n${result.reasons.map((r) => `- ${r.file}: ${r.rule} (${r.policy})`).join("\n")}\n\nCoverage notes: ${result.coverageNotes.join("; ") || "none"}\n\nKnown gaps: ${result.gaps.map((g) => g.id).join(", ")}\n`, ); console.log(JSON.stringify(result, null, 2)); } diff --git a/.github/scripts/e2e-selection.test.cjs b/.github/scripts/e2e-selection.test.cjs index bd53504f5..4510befbd 100644 --- a/.github/scripts/e2e-selection.test.cjs +++ b/.github/scripts/e2e-selection.test.cjs @@ -25,6 +25,15 @@ const tracked = execFileSync("git", ["-C", app, "ls-files", "-z"], { .filter(Boolean); const pick = (files, options = {}) => select(cases, { files, ...options }); +test("the E2E workflow has no migration job, dependency, output or reusable-workflow call", () => { + const workflows = path.join(app, ".github/workflows"); + const e2e = fs.readFileSync(path.join(workflows, "e2e-tests.yml"), "utf8"); + const migration = fs.readFileSync(path.join(workflows, "e2e_migration.yml"), "utf8"); + assert.doesNotMatch(e2e, /migration/i); + assert.doesNotMatch(migration, /^ workflow_call:/m); + assert.match(migration, /^ schedule:/m); + assert.match(migration, /^ push:\n branches: \["release-\*"\]/m); +}); test("policy covers the actual iOS tree and contains no stale paths or case IDs", () => validateTree(policy, tracked, cases)); test("base remains exactly the five existing cases", () => @@ -36,53 +45,47 @@ test("base remains exactly the five existing cases", () => "transfer_2", ])); -for (const [file, added, staging, migration] of [ +for (const [file, added, staging] of [ [ "Bitkit/Services/Widgets/NewsService.swift", ["widgets_1", "widgets_2", "widgets_3", "settings_12"], false, - false, ], [ "Bitkit/Models/NewsWidgetData.swift", ["widgets_1", "widgets_2", "widgets_3", "settings_12"], false, - false, ], - ["Bitkit/Services/PaykitPaymentRequestService.swift", [], true, false], - ["Bitkit/Managers/ContactsManager.swift", [], true, false], + ["Bitkit/Services/PaykitPaymentRequestService.swift", [], true], + ["Bitkit/Managers/ContactsManager.swift", [], true], [ "Bitkit/Services/Trezor/TrezorService.swift", ["hardware_wallet_1", "hardware_wallet_2", "hardware_wallet_4"], false, - false, ], [ "Bitkit/Managers/FeeEstimatesManager.swift", ["boost_1", "onchain_3", "settings_03"], false, - false, ], [ "Bitkit/Services/BackupService.swift", ["backup_1", "onboarding_1", "onboarding_2", "security_1", "settings_07"], true, - false, ], [ "Bitkit/Services/ElectrumConfigService.swift", ["settings_10"], false, - false, ], - ["Bitkit/Services/RgsConfigService.swift", ["settings_11"], false, false], + ["Bitkit/Services/RgsConfigService.swift", ["settings_11"], false], ]) test(`actual ${file} selects exact domain coverage`, () => { assert.ok(tracked.includes(file), "Example must exist in the app"); const s = pick([file]); assert.deepEqual(s.ids, [...new Set([...policy.base, ...added])].sort()); assert.equal(s.staging, staging); - assert.equal(s.migration, migration); + assert.equal(Object.hasOwn(s, "migration"), false); }); test("on-chain service retains public Paykit dispatch coverage without migration", () => { @@ -92,7 +95,7 @@ test("on-chain service retains public Paykit dispatch coverage without migration [...new Set([...policy.base, ...policy.groups.send])].sort(), ); assert.equal(s.staging, true); - assert.equal(s.migration, false); + assert.equal(Object.hasOwn(s, "migration"), false); }); for (const f of [ "Bitkit/Services/CoreService.swift", @@ -104,7 +107,7 @@ for (const f of [ const s = pick([f]); assert.equal(s.ids.length, 42); assert.equal(s.staging, true); - assert.equal(s.migration, false); + assert.equal(Object.hasOwn(s, "migration"), false); }); test("scanner entry points retain Pubky routes, Electrum input and hardware recipients", () => { for (const file of [ @@ -119,7 +122,7 @@ test("scanner entry points retain Pubky routes, Electrum input and hardware reci assert.ok(s.ids.includes("settings_10")); assert.ok(s.ids.includes("hardware_wallet_4")); assert.equal(s.staging, true, "pubky_profile_3/_5 exercise scanner routes"); - assert.equal(s.migration, false); + assert.equal(Object.hasOwn(s, "migration"), false); } }); test("backup transport, payload and restore retain profile persistence assertions", () => { @@ -134,7 +137,7 @@ test("backup transport, payload and restore retain profile persistence assertion true, "pubky_profile_2/_4 restore profile/contact state", ); - assert.equal(s.migration, false); + assert.equal(Object.hasOwn(s, "migration"), false); } }); test("shared send entry/confirmation retains Paykit and hardware consumers", () => { @@ -146,7 +149,7 @@ test("shared send entry/confirmation retains Paykit and hardware consumers", () const s = pick([file]); assert.equal(s.staging, true, "paykit_1 pays through this shared flow"); assert.ok(s.ids.includes("hardware_wallet_4")); - assert.equal(s.migration, false); + assert.equal(Object.hasOwn(s, "migration"), false); } }); test("unknown service/new area fails cheaply, never silently selects base or full", () => { @@ -250,18 +253,18 @@ test("migration is excluded from all PR gates even when the base policy requests for (const mode of ["auto", "base", "full", "nightly"]) { for (const release of [false, true]) { const result = pick(files, { mode, pr: true, release, previousPolicy }); - assert.equal(result.migration, false); + assert.equal(Object.hasOwn(result, "migration"), false); assert.equal(result.staging, mode !== "base"); assert.equal(result.ids.length, mode === "base" ? 5 : 42); } } }); -test("nightly includes migration while manual full includes local/staging; gaps stay explicit", () => { +test("nightly and manual full select local/staging only; gaps stay explicit", () => { for (const mode of ["full", "nightly"]) { const s = pick([], { mode }); assert.equal(s.ids.length, 42); assert.equal(s.staging, true); - assert.equal(s.migration, mode === "nightly"); + assert.equal(Object.hasOwn(s, "migration"), false); assert.ok(s.gaps.some((g) => g.id === "transfer_1")); assert.ok(!s.ids.includes("transfer_1")); } @@ -346,6 +349,15 @@ test("actual CLI uses tested app policy and pinned E2E inventory; rejects drift [path.join(root, ".github/scripts/e2e-selection.cjs")], { cwd: temp, env, encoding: "utf8" }, ); + env.E2E_SELECTION = "nightly"; + const manualNightly = run(); + assert.notEqual(manualNightly.status, 0); + assert.match(manualNightly.stderr, /requires a scheduled event/); + env.GITHUB_EVENT_NAME = "schedule"; + assert.equal(run().status, 0); + assert.ok(!Object.hasOwn(JSON.parse(fs.readFileSync(path.join(temp, "selection.json"))), "migration")); + env.GITHUB_EVENT_NAME = "workflow_dispatch"; + env.E2E_SELECTION = "base"; const r = run(); assert.equal(r.status, 0, r.stderr); const s = JSON.parse( diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b26b3bd1d..bc4ed77e9 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -33,13 +33,12 @@ on: description: Recovery helper branch or tag default: master selection: - description: "auto: PR changes; base: five cases; full: local/staging; nightly: rehearse all lanes on a branch" + description: "auto: PR changes; base: five cases; full: all local/staging cases" type: choice options: - full - auto - base - - nightly default: full shards: description: Manual local-shard cost experiment; automatic runs use one @@ -79,7 +78,6 @@ jobs: code: ${{ steps.selection.outputs.code }} matrix: ${{ steps.selection.outputs.matrix }} staging: ${{ steps.selection.outputs.staging }} - migration: ${{ steps.selection.outputs.migration }} appSha: ${{ steps.selection.outputs.appSha }} e2eSha: ${{ steps.selection.outputs.e2eSha }} steps: @@ -422,7 +420,6 @@ jobs: - build-local - e2e-tests-local - staging - - migration - build-network steps: - name: Require all selected jobs @@ -435,8 +432,7 @@ jobs: assert j['detect-changes']['result']=='success', 'Selection did not succeed' s=j['detect-changes']['outputs'] required=['build-local','e2e-tests-local'] if s['code']=='true' else [] - if s['staging']=='true' or s['migration']=='true': required.append('build-network') - required += [k for k in ['staging','migration'] if s[k]=='true'] + if s['staging']=='true': required += ['build-network', 'staging'] failed={k:j[k]['result'] for k in required if j[k]['result']!='success'} assert not failed, f'Selected jobs did not pass: {failed}' print('All selected E2E jobs passed' if required else 'No app code changes') @@ -487,19 +483,6 @@ jobs: app_artifact: bitkit-e2e-ios-network_${{ github.run_number }} post_to_slack: ${{ github.event_name == 'schedule' }} secrets: inherit - migration: - needs: - - detect-changes - - build-network - if: needs.detect-changes.outputs.migration == 'true' - uses: ./.github/workflows/e2e_migration.yml - with: - app_ref: ${{ needs.detect-changes.outputs.appSha }} - e2e_ref: ${{ needs.detect-changes.outputs.e2eSha }} - extended_rn: true - app_artifact: bitkit-e2e-ios-network_${{ github.run_number }} - post_to_slack: ${{ github.event_name == 'schedule' }} - secrets: inherit build-network: env: SIMULATOR_NAME: iPhone 17 @@ -589,4 +572,4 @@ jobs: appSha: ${{ steps.app.outputs.sha }} needs: - detect-changes - if: needs.detect-changes.outputs.staging == 'true' || needs.detect-changes.outputs.migration == 'true' + if: needs.detect-changes.outputs.staging == 'true' diff --git a/.github/workflows/e2e_migration.yml b/.github/workflows/e2e_migration.yml index 09d5bda7c..1dbf1c56b 100644 --- a/.github/workflows/e2e_migration.yml +++ b/.github/workflows/e2e_migration.yml @@ -1,5 +1,7 @@ name: E2E Migration (iOS) on: + schedule: + - cron: "0 3 * * *" push: branches: ["release-*"] workflow_dispatch: @@ -21,28 +23,11 @@ on: type: boolean default: false required: false - workflow_call: - inputs: - app_ref: - type: string - required: true - e2e_ref: - type: string - required: true - extended_rn: - type: boolean - default: true - app_artifact: - type: string - default: "" - post_to_slack: - type: boolean - default: false env: TERM: xterm-256color FORCE_COLOR: 1 GIT_LFS_SKIP_SMUDGE: "1" - MIGRATION_EXTENDED: ${{ (github.event_name == 'push' && startsWith(github.ref, 'refs/heads/release-')) || inputs.extended_rn || false }} + MIGRATION_EXTENDED: ${{ github.event_name == 'schedule' || github.event_name == 'push' || inputs.extended_rn || false }} SIMULATOR_NAME: iPhone 17 IOS_VERSION: "26.2" concurrency: @@ -51,22 +36,19 @@ concurrency: jobs: build: if: github.event_name != 'push' || !github.event.deleted - runs-on: ${{ inputs.app_artifact != '' && 'ubuntu-latest' || 'macos-latest' }} + runs-on: macos-latest steps: - name: Checkout uses: actions/checkout@v7 with: - ref: ${{ inputs.app_ref || github.sha }} - if: inputs.app_artifact == '' + ref: ${{ github.sha }} - name: Record selected revision id: app run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - if: inputs.app_artifact == '' - name: Set up Xcode uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: "26.2" - if: inputs.app_artifact == '' - name: System Information run: | echo "=== System Information ===" @@ -75,20 +57,16 @@ jobs: echo "" echo "Xcode Version:" xcodebuild -version - if: inputs.app_artifact == '' - name: Install xcbeautify run: | brew install xcbeautify - if: inputs.app_artifact == '' - name: Swift packages uses: ./.github/actions/swift-packages - if: inputs.app_artifact == '' - name: Pre-start simulator run: | echo "⏱️ Starting simulator at $(date)" xcrun simctl boot "${{ env.SIMULATOR_NAME }}" || true echo "✅ Simulator started at $(date)" - if: inputs.app_artifact == '' - name: Build iOS app (regtest) env: GITHUB_ACTOR: ${{ github.actor }} @@ -119,13 +97,11 @@ jobs: SWIFT_ACTIVE_COMPILATION_CONDITIONS='$(inherited) E2E_BUILD' \ -allowProvisioningUpdates \ build - if: inputs.app_artifact == '' - name: Prepare app for E2E tests (regtest) run: | # Copy the .app bundle to the expected location and name mkdir -p e2e-app cp -r DerivedData/Build/Products/Debug-iphonesimulator/Bitkit.app e2e-app/bitkit.app - if: inputs.app_artifact == '' - name: Record app build identity run: |- python3 - <<'PY' @@ -133,22 +109,15 @@ jobs: from pathlib import Path Path('e2e-app/e2e-build.json').write_text(json.dumps({'appSha': subprocess.check_output(['git','rev-parse','HEAD'],text=True).strip(), 'platform':'ios', 'backend':'regtest'})) PY - if: inputs.app_artifact == '' - name: Upload iOS app (regtest) uses: actions/upload-artifact@v7 with: name: bitkit-e2e-migration-ios_${{ github.run_number }} path: e2e-app/ retention-days: 7 - if: inputs.app_artifact == '' - - name: Reuse the network app build - if: inputs.app_artifact != '' - env: - APP_ARTIFACT: ${{ inputs.app_artifact }} - run: echo "Using app artifact $APP_ARTIFACT" timeout-minutes: 40 outputs: - appSha: ${{ inputs.app_ref || steps.app.outputs.sha }} + appSha: ${{ steps.app.outputs.sha }} e2e-branch: if: github.event_name != 'push' || !github.event.deleted uses: synonymdev/bitkit-e2e-tests/.github/workflows/determine-e2e-branch.yml@main @@ -160,7 +129,7 @@ jobs: - e2e-branch runs-on: ubuntu-latest outputs: - ref: ${{ inputs.e2e_ref || steps.e2e-ref.outputs.ref }} + ref: ${{ steps.e2e-ref.outputs.ref }} steps: - name: Resolve E2E tests ref id: e2e-ref @@ -168,7 +137,6 @@ jobs: with: selected: ${{ needs.e2e-branch.outputs.branch }} e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - if: inputs.e2e_ref == '' timeout-minutes: 135 migration-plan: needs: @@ -187,12 +155,11 @@ jobs: with: selected: ${{ needs.e2e-branch.outputs.branch }} e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - if: inputs.e2e_ref == '' - name: Checkout E2E tests uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests - ref: ${{ inputs.e2e_ref || steps.e2e-ref.outputs.ref || 'main' }} + ref: ${{ steps.e2e-ref.outputs.ref || 'main' }} - name: Resolve migration cases and baseline id: plan run: python3 scripts/migration-matrix.py ios @@ -246,13 +213,6 @@ jobs: E2E_VARIANT: network-regtest-migration E2E_CASE_TAG: ${{ matrix.scenario.grep }} steps: - - name: Resolve E2E tests ref - id: e2e-ref - uses: synonymdev/bitkit-e2e-tests/.github/actions/resolve-e2e-ref@main - with: - selected: ${{ needs.e2e-branch.outputs.branch }} - e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - if: inputs.e2e_ref == '' - name: Clone E2E tests uses: actions/checkout@v7 with: @@ -262,7 +222,7 @@ jobs: - name: Download iOS app uses: actions/download-artifact@v8 with: - name: ${{ inputs.app_artifact || format('bitkit-e2e-migration-ios_{0}', github.run_number) }} + name: bitkit-e2e-migration-ios_${{ github.run_number }} path: bitkit-e2e-tests/aut - name: Download migration env uses: actions/download-artifact@v8 @@ -419,13 +379,6 @@ jobs: E2E_VARIANT: network-regtest-migration E2E_CASE_TAG: ${{ matrix.scenario.grep }} steps: - - name: Resolve E2E tests ref - id: e2e-ref - uses: synonymdev/bitkit-e2e-tests/.github/actions/resolve-e2e-ref@main - with: - selected: ${{ needs.e2e-branch.outputs.branch }} - e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - if: inputs.e2e_ref == '' - name: Clone E2E tests uses: actions/checkout@v7 with: @@ -435,7 +388,7 @@ jobs: - name: Download iOS app uses: actions/download-artifact@v8 with: - name: ${{ inputs.app_artifact || format('bitkit-e2e-migration-ios_{0}', github.run_number) }} + name: bitkit-e2e-migration-ios_${{ github.run_number }} path: bitkit-e2e-tests/aut - name: Download migration source working-directory: bitkit-e2e-tests @@ -576,7 +529,7 @@ jobs: PYTHON timeout-minutes: 135 slack-report: - if: always() && inputs.post_to_slack == true + if: always() && (github.event_name == 'schedule' || inputs.post_to_slack == true) runs-on: ubuntu-latest needs: - build @@ -595,12 +548,11 @@ jobs: with: selected: ${{ needs.e2e-branch.outputs.branch }} e2e_branch_input: ${{ github.event.inputs.e2e_branch || 'default-feature-branch' }} - if: inputs.e2e_ref == '' - name: Checkout E2E tests uses: actions/checkout@v7 with: repository: synonymdev/bitkit-e2e-tests - ref: ${{ inputs.e2e_ref || steps.e2e-ref.outputs.ref || 'main' }} + ref: ${{ steps.e2e-ref.outputs.ref || 'main' }} - name: Post Slack summary env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_STAGING }} diff --git a/Docs/e2e-ci.md b/Docs/e2e-ci.md index 46f577a15..178bfffe3 100644 --- a/Docs/e2e-ci.md +++ b/Docs/e2e-ci.md @@ -36,7 +36,7 @@ The E2E checkout must contain the matching inventory. Add a real-path regression ## Execution -Every night at 03:00 UTC the entry point runs all applicable local, staging and extended migration cases, even for an unchanged revision. The existing staging/migration workflows provide reusable execution and retain standalone manual dispatch. A push to a `release-*` branch runs all six migrations in the dedicated migration workflow, including the extended RN cases. Known skipped cases and unsupported mainnet fixtures are reported as gaps, not successful coverage. +Two independent workflows run at 03:00 UTC. `e2e-tests.yml` runs 42 local and six staging cases. `e2e_migration.yml` builds its own network app and runs all six migrations with its own result. The migration workflow also runs on each push to `release-*` and supports its own manual dispatch for diagnosis. Manual validation uses the branch containing the changed workflow: @@ -45,7 +45,7 @@ gh workflow run e2e-tests.yml --ref codex/264-inventory-shadow \ -f e2e_branch=codex/264-inventory-shadow -f selection=base -f shards=1 ``` -To test an open same-repository PR merge, including a stacked PR, also pass `-f pr=NUMBER -f selection=auto`. The merge must match the PR's actual base and head. `selection=full` runs the local and staging suites. To reproduce the nightly including migration, dispatch `selection=nightly` on a branch. PR merges always use local and staging coverage. The optional `shards=2` experiment applies only to manual local execution; automatic runs use one shard. Each shard has isolated infrastructure and shares the same built local app. Staging/migration share one network app build. +To test an open same-repository PR merge, including a stacked PR, also pass `-f pr=NUMBER -f selection=auto`. The merge must match the PR's actual base and head. `selection=full` runs the local and staging suites. Manual E2E and PR merge runs use local and staging coverage. The optional `shards=2` experiment applies only to manual local execution; automatic runs use one shard. Each shard has isolated infrastructure and shares the same built local app. The staging job uses its network app build. Attempts are bounded to three, stop on success, and retain first-failure evidence. Missing selected cases or failed selected jobs fail the aggregate status. Builds are capped at 40 minutes; execution budgets depend on the selected pack as described below. Diagnostic artifacts, including RN wallet preparation logs, expire after seven days; prepared wallet environments expire after one day. Compare end-to-end duration and billed Mac/backend usage before making two shards automatic. From 7380b9490674320d54de621ae2ae28ef664268d1 Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Wed, 4 Nov 2026 16:09:11 +0100 Subject: [PATCH 18/18] ci: restore three visible iOS E2E attempts --- .github/workflows/e2e-staging.yml | 28 ++++++++++++++++++++++++---- .github/workflows/e2e-tests.yml | 26 +++++++++++++++++++++++--- Docs/e2e-ci.md | 2 +- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/.github/workflows/e2e-staging.yml b/.github/workflows/e2e-staging.yml index 5eda4b65a..fdf0758e7 100644 --- a/.github/workflows/e2e-staging.yml +++ b/.github/workflows/e2e-staging.yml @@ -142,7 +142,7 @@ jobs: e2e_branch_input: ${{ inputs.e2e_branch || 'default-feature-branch' }} e2e-tests-staging: runs-on: macos-latest - # Three complete six-case packs, plus setup and artifact collection. + # Up to three attempts, retrying failed cases, plus setup and artifact collection. timeout-minutes: 285 needs: - build-staging @@ -236,11 +236,31 @@ jobs: - name: Prepare exact run plan working-directory: bitkit-e2e-tests run: node --experimental-strip-types scripts/ci-run.ts prepare - - name: Run selected tests (up to three attempts) - id: tests + - name: Run E2E Tests 1 + id: test1 + continue-on-error: true + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts attempt + env: + ATTEMPT: "1" + RECORD_VIDEO: "true" + - name: Run E2E Tests 2 + id: test2 + continue-on-error: true + if: steps.test1.outcome == 'failure' + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts attempt + env: + ATTEMPT: "2" + RECORD_VIDEO: "true" + - name: Run E2E Tests 3 + id: test3 + continue-on-error: true + if: steps.test1.outcome == 'failure' && steps.test2.outcome == 'failure' working-directory: bitkit-e2e-tests - run: node --experimental-strip-types scripts/ci-run.ts run + run: node --experimental-strip-types scripts/ci-run.ts attempt env: + ATTEMPT: "3" RECORD_VIDEO: "true" - name: Report all attempts if: always() diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index bc4ed77e9..7f0468602 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -390,11 +390,31 @@ jobs: - name: Prepare exact run plan working-directory: bitkit-e2e-tests run: node --experimental-strip-types scripts/ci-run.ts prepare - - name: Run selected tests (up to three attempts) - id: tests + - name: Run E2E Tests 1 + id: test1 + continue-on-error: true working-directory: bitkit-e2e-tests - run: node --experimental-strip-types scripts/ci-run.ts run + run: node --experimental-strip-types scripts/ci-run.ts attempt env: + ATTEMPT: "1" + RECORD_VIDEO: "true" + - name: Run E2E Tests 2 + id: test2 + continue-on-error: true + if: steps.test1.outcome == 'failure' + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts attempt + env: + ATTEMPT: "2" + RECORD_VIDEO: "true" + - name: Run E2E Tests 3 + id: test3 + continue-on-error: true + if: steps.test1.outcome == 'failure' && steps.test2.outcome == 'failure' + working-directory: bitkit-e2e-tests + run: node --experimental-strip-types scripts/ci-run.ts attempt + env: + ATTEMPT: "3" RECORD_VIDEO: "true" - name: Report all attempts if: always() diff --git a/Docs/e2e-ci.md b/Docs/e2e-ci.md index 178bfffe3..c836ff6b6 100644 --- a/Docs/e2e-ci.md +++ b/Docs/e2e-ci.md @@ -47,7 +47,7 @@ gh workflow run e2e-tests.yml --ref codex/264-inventory-shadow \ To test an open same-repository PR merge, including a stacked PR, also pass `-f pr=NUMBER -f selection=auto`. The merge must match the PR's actual base and head. `selection=full` runs the local and staging suites. Manual E2E and PR merge runs use local and staging coverage. The optional `shards=2` experiment applies only to manual local execution; automatic runs use one shard. Each shard has isolated infrastructure and shares the same built local app. The staging job uses its network app build. -Attempts are bounded to three, stop on success, and retain first-failure evidence. Missing selected cases or failed selected jobs fail the aggregate status. Builds are capped at 40 minutes; execution budgets depend on the selected pack as described below. Diagnostic artifacts, including RN wallet preparation logs, expire after seven days; prepared wallet environments expire after one day. Compare end-to-end duration and billed Mac/backend usage before making two shards automatic. +Local and staging jobs have three visible steps: `Run E2E Tests 1`, `2`, and `3`. They stop on success; `ciIt()` skips cases passed earlier in the same run so only failed or uncompleted cases repeat. The final report combines current-run passes, retains first-failure evidence, and fails the job when all three attempts fail. Missing selected cases or failed selected jobs fail the aggregate status. Builds are capped at 40 minutes; execution budgets depend on the selected pack as described below. Diagnostic artifacts, including RN wallet preparation logs, expire after seven days; prepared wallet environments expire after one day. Compare end-to-end duration and billed Mac/backend usage before making two shards automatic. Land the E2E producer first. Validate these branches before merging this consumer. The migration fixture workflow is pinned to the producer commit that supports immutable source selection, allowing full branch validation before either PR merges. There is no shadow workflow and no additional smoke-test replacement.