diff --git a/.github/scripts/install-maestro.sh b/.github/scripts/install-maestro.sh new file mode 100755 index 0000000..28c05da --- /dev/null +++ b/.github/scripts/install-maestro.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION=2.6.0 +ARCHIVE_SHA256=80185105a5d7e227e3b3fbcf225f45b312508ea676a9fc8e1b1aa1cac8b9ff6e +DOWNLOAD_URL="https://github.com/mobile-dev-inc/Maestro/releases/download/cli-${VERSION}/maestro.zip" + +INSTALL_DIR=${1:?"usage: install-maestro.sh INSTALL_DIR"} +case "$INSTALL_DIR" in + /|"") + echo "error: refusing unsafe Maestro install directory" >&2 + exit 1 + ;; +esac + +if ! java -version >/dev/null 2>&1; then + echo "error: Maestro requires a working Java runtime" >&2 + exit 1 +fi + +TEMP_DIR=$(mktemp -d "${RUNNER_TEMP:-/tmp}/pegada-maestro.XXXXXX") +trap 'rm -rf "$TEMP_DIR"' EXIT +ARCHIVE="$TEMP_DIR/maestro.zip" + +curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \ + --retry 3 --retry-all-errors \ + --output "$ARCHIVE" "$DOWNLOAD_URL" +printf '%s %s\n' "$ARCHIVE_SHA256" "$ARCHIVE" | shasum -a 256 --check + +unzip -q "$ARCHIVE" -d "$TEMP_DIR/unpacked" +test -x "$TEMP_DIR/unpacked/maestro/bin/maestro" + +mkdir -p "$INSTALL_DIR" +cp -R "$TEMP_DIR/unpacked/maestro/." "$INSTALL_DIR/" +"$INSTALL_DIR/bin/maestro" --version diff --git a/.github/scripts/test_release.py b/.github/scripts/test_release.py index 11fdc7e..d7be667 100644 --- a/.github/scripts/test_release.py +++ b/.github/scripts/test_release.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import re import shutil import subprocess import sys @@ -12,7 +13,11 @@ SCRIPTS = Path(__file__).resolve().parent CHANGELOG_SCRIPT = SCRIPTS / "changelog.py" TAG_SCRIPT = SCRIPTS.parents[1] / "scripts" / "tag-release.sh" +VERIFY_SCRIPT = SCRIPTS / "verify-release-ref.py" +INSTALL_MAESTRO_SCRIPT = SCRIPTS / "install-maestro.sh" MOBILE_DEPLOY_WORKFLOW = SCRIPTS.parents[1] / ".github" / "workflows" / "deploy-mobile.yml" +RELEASE_WORKFLOW = SCRIPTS.parents[1] / ".github" / "workflows" / "release-mobile.yml" +WORKFLOWS = SCRIPTS.parents[1] / ".github" / "workflows" class MobileDeployWorkflowTest(unittest.TestCase): @@ -26,6 +31,45 @@ def test_publish_paths_cover_mobile_runtime_inputs(self) -> None: self.assertNotIn('- ".github/scripts/**"', workflow) +class WorkflowSupplyChainTest(unittest.TestCase): + def test_every_external_action_is_pinned_to_a_commit(self) -> None: + for workflow in WORKFLOWS.glob("*.yml"): + for line in workflow.read_text().splitlines(): + match = re.search(r"\buses:\s*([^\s#]+)", line) + if not match or match.group(1).startswith("./"): + continue + self.assertRegex( + match.group(1), + r"@[0-9a-f]{40}$", + f"mutable action in {workflow.name}: {line.strip()}", + ) + + def test_release_tools_do_not_float_or_pipe_remote_code_to_a_shell(self) -> None: + workflow_text = "\n".join( + workflow.read_text() for workflow in WORKFLOWS.glob("*.yml") + ) + + self.assertNotIn("eas-version: latest", workflow_text) + self.assertNotIn("npx --yes", workflow_text) + for line in workflow_text.splitlines(): + if "run: pnpm install" in line: + self.assertIn("--frozen-lockfile", line) + self.assertNotRegex(workflow_text, r"curl[^\n]*\|[^\n]*(?:bash|sh)") + self.assertIn("eas-version: 23.1.0", workflow_text) + self.assertIn( + "80185105a5d7e227e3b3fbcf225f45b312508ea676a9fc8e1b1aa1cac8b9ff6e", + INSTALL_MAESTRO_SCRIPT.read_text(), + ) + + def test_secret_bearing_release_jobs_need_authorization_and_production(self) -> None: + workflow = RELEASE_WORKFLOW.read_text() + + self.assertIn("authorize-release:", workflow) + self.assertIn("verify-release-ref.py", workflow) + self.assertEqual(workflow.count("needs: authorize-release"), 3) + self.assertEqual(workflow.count("environment: production"), 4) + + class GitRepositoryTest(unittest.TestCase): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -162,6 +206,7 @@ def setUp(self) -> None: (self.repo / "scripts").mkdir() (self.repo / "apps" / "mobile").mkdir(parents=True) shutil.copy(CHANGELOG_SCRIPT, self.repo / ".github" / "scripts" / "changelog.py") + shutil.copy(VERIFY_SCRIPT, self.repo / ".github" / "scripts" / "verify-release-ref.py") shutil.copy(TAG_SCRIPT, self.repo / "scripts" / "tag-release.sh") (self.repo / "apps" / "mobile" / "app.config.ts").write_text( 'const config = { version: "1.1.0", };\n' @@ -182,23 +227,46 @@ def setUp(self) -> None: self.git("push", "-u", "origin", "main") self.git("push", "origin", "v1.0.0") - def test_tag_is_blocked_until_the_generated_changelog_is_merged(self) -> None: + def prepare_valid_release(self) -> None: self.commit("fix(release): keep changelog inside the tag") self.git("push", "origin", "main") first = self.command("./scripts/tag-release.sh", "v1.1.0", check=False) - self.assertEqual(first.returncode, 1) - self.assertIn("Prepared CHANGELOG.md for v1.1.0", first.stdout) - self.assertEqual(self.git("tag", "--list", "v1.1.0"), "") self.git("add", "CHANGELOG.md") self.git("commit", "-m", "docs(release): prepare v1.1.0 changelog") self.git("push", "origin", "main") + self.command("./scripts/tag-release.sh", "v1.1.0") + + def verify_release( + self, + *, + event: str, + ref: str, + submit: str = "false", + sha: str | None = None, + check: bool = True, + ) -> subprocess.CompletedProcess[str]: + return self.command( + sys.executable, + str(self.repo / ".github" / "scripts" / "verify-release-ref.py"), + "--event", + event, + "--ref", + ref, + "--sha", + sha or self.git("rev-parse", "HEAD"), + "--submit", + submit, + "--repo", + "GSTJ/pegada", + check=check, + ) - second = self.command("./scripts/tag-release.sh", "v1.1.0") + def test_tag_is_blocked_until_the_generated_changelog_is_merged(self) -> None: + self.prepare_valid_release() - self.assertIn("Tagged and pushed v1.1.0", second.stdout) remote_target = self.git( "ls-remote", "--tags", "origin", "refs/tags/v1.1.0^{}" ).split()[0] @@ -211,6 +279,56 @@ def test_tag_is_blocked_until_the_generated_changelog_is_merged(self) -> None: self.changelog("--all", "--repo", "GSTJ/pegada"), ) + def test_valid_tag_passes_release_authorization(self) -> None: + self.prepare_valid_release() + + result = self.verify_release( + event="push", ref="refs/tags/v1.1.0", check=False + ) + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("Authorized release tag v1.1.0", result.stdout) + + def test_off_main_tag_is_rejected(self) -> None: + self.git("switch", "-c", "untrusted-release") + self.commit("fix: off-main release") + self.git("tag", "-a", "v1.1.0", "-m", "v1.1.0") + + result = self.verify_release( + event="push", ref="refs/tags/v1.1.0", check=False + ) + + self.assertEqual(result.returncode, 1) + self.assertIn("is not part of origin/main", result.stderr) + + def test_lightweight_release_tag_is_rejected(self) -> None: + self.git("tag", "v1.1.0") + + result = self.verify_release( + event="push", ref="refs/tags/v1.1.0", check=False + ) + + self.assertEqual(result.returncode, 1) + self.assertIn("must be annotated", result.stderr) + + def test_store_submission_requires_a_release_tag(self) -> None: + result = self.verify_release( + event="workflow_dispatch", + ref="refs/heads/main", + submit="true", + check=False, + ) + + self.assertEqual(result.returncode, 1) + self.assertIn("store submission requires", result.stderr) + + def test_manual_main_build_without_submission_is_allowed(self) -> None: + result = self.verify_release( + event="workflow_dispatch", ref="refs/heads/main" + ) + + self.assertIn("Authorized manual build from main", result.stdout) + if __name__ == "__main__": unittest.main() diff --git a/.github/scripts/verify-release-ref.py b/.github/scripts/verify-release-ref.py new file mode 100755 index 0000000..ba830d8 --- /dev/null +++ b/.github/scripts/verify-release-ref.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +"""Reject release runs that did not start from a trusted main commit.""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +CHANGELOG_SCRIPT = ROOT / ".github" / "scripts" / "changelog.py" +TAG_PATTERN = re.compile( + r"^v(?P[0-9]+\.[0-9]+\.[0-9]+)(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?$" +) + + +def command(*args: str, check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + args, + cwd=ROOT, + check=check, + capture_output=True, + text=True, + ) + + +def git(*args: str) -> str: + return command("git", *args).stdout.strip() + + +def reject(message: str) -> None: + print(f"error: {message}", file=sys.stderr) + raise SystemExit(1) + + +def parse_bool(value: str) -> bool: + normalized = value.strip().lower() + if normalized in {"true", "1"}: + return True + if normalized in {"false", "0", ""}: + return False + reject(f"invalid boolean value: {value}") + + +def previous_tag(tag: str) -> str | None: + tags = git("tag", "--list", "v*", "--sort=-creatordate").splitlines() + if tag not in tags: + reject(f"tag {tag} is not present in the checkout") + index = tags.index(tag) + return tags[index + 1] if index + 1 < len(tags) else None + + +def generated_notes(tag: str, previous: str | None, repo: str) -> str: + args = [ + sys.executable, + str(CHANGELOG_SCRIPT), + "--notes", + tag, + "--repo", + repo, + ] + if previous: + args += ["--previous", previous] + return command(*args).stdout + + +def is_breaking(tag: str, previous: str | None) -> bool: + args = [ + sys.executable, + str(CHANGELOG_SCRIPT), + "--is-breaking", + tag, + ] + if previous: + args += ["--previous", previous] + result = command(*args, check=False) + if result.returncode not in {0, 1}: + reject(f"breaking-change check failed for {tag}") + return result.returncode == 0 + + +def tag_message(tag: str) -> str: + raw = command("git", "cat-file", "tag", tag).stdout + headers, separator, message = raw.partition("\n\n") + if not separator or not headers: + reject(f"could not read annotated tag message for {tag}") + return message + + +def verify_changelog(repo: str) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + generated = Path(temp_dir) / "CHANGELOG.md" + command( + sys.executable, + str(CHANGELOG_SCRIPT), + "--all", + "--repo", + repo, + "--output", + str(generated), + ) + if generated.read_bytes() != (ROOT / "CHANGELOG.md").read_bytes(): + reject("CHANGELOG.md does not match the tagged history") + + +def verify_tag(tag: str, sha: str, repo: str) -> None: + match = TAG_PATTERN.fullmatch(tag) + if not match: + reject(f"release tag is not semantic: {tag}") + + tag_ref = f"refs/tags/{tag}" + if git("cat-file", "-t", tag_ref) != "tag": + reject(f"release tag must be annotated: {tag}") + + tag_commit = git("rev-parse", f"{tag_ref}^{{commit}}") + if tag_commit != sha: + reject(f"workflow SHA {sha} does not match {tag} target {tag_commit}") + + config = (ROOT / "apps" / "mobile" / "app.config.ts").read_text() + version = re.search(r'\bversion:\s*"([^"]+)"', config) + if not version: + reject("could not read the mobile version") + if version.group(1) != match.group("version"): + reject( + f"tag version {match.group('version')} does not match mobile version " + f"{version.group(1)}" + ) + + previous = previous_tag(tag) + notes = generated_notes(tag, previous, repo) + title = f"{tag} (contains breaking changes)" if is_breaking(tag, previous) else tag + expected_message = f"{title}\n\n{notes}" + if tag_message(tag) != expected_message: + reject(f"annotated tag message does not match generated notes for {tag}") + + verify_changelog(repo) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--event", required=True) + parser.add_argument("--ref", required=True) + parser.add_argument("--sha", required=True) + parser.add_argument("--submit", default="false") + parser.add_argument("--repo", default="GSTJ/pegada") + args = parser.parse_args() + + sha = git("rev-parse", f"{args.sha}^{{commit}}") + if command( + "git", "merge-base", "--is-ancestor", sha, "refs/remotes/origin/main", check=False + ).returncode: + reject(f"release commit {sha} is not part of origin/main") + + submit = parse_bool(args.submit) + if args.ref == "refs/heads/main": + if args.event != "workflow_dispatch": + reject("main is only valid for a manual build") + if submit: + reject("store submission requires an annotated release tag") + print(f"Authorized manual build from main at {sha}") + return 0 + + prefix = "refs/tags/" + if not args.ref.startswith(prefix): + reject("release runs are limited to main and semantic release tags") + + tag = args.ref.removeprefix(prefix) + if args.event not in {"push", "workflow_dispatch"}: + reject(f"unsupported release event: {args.event}") + + verify_tag(tag, sha, args.repo) + print(f"Authorized release tag {tag} at {sha}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/branch-check.yml b/.github/workflows/branch-check.yml index 4833688..b2ba847 100644 --- a/.github/workflows/branch-check.yml +++ b/.github/workflows/branch-check.yml @@ -28,13 +28,13 @@ jobs: runs-on: ubuntu-latest steps: - name: 🏗 Setup Repo - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: 🏗 Setup PNPM - uses: pnpm/action-setup@v6.0.9 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: 🏗 Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22.x cache: pnpm @@ -53,7 +53,7 @@ jobs: checkup: name: 👮 Checkup needs: dedupe - uses: GSTJ/magic/.github/workflows/ci.yml@v1 + uses: GSTJ/magic/.github/workflows/ci.yml@b4800fa8d1f1f4cc1d954323624baeabf238117e # v1 # Kept out of the shared checkup job: only this one needs a database, and # folding it in would spin up Postgres for typecheck/lint/format too. No @@ -84,13 +84,13 @@ jobs: --health-retries 5 steps: - name: 🏗 Setup Repo - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: 🏗 Setup PNPM - uses: pnpm/action-setup@v6.0.9 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: 🏗 Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22.x cache: pnpm diff --git a/.github/workflows/deploy-mobile.yml b/.github/workflows/deploy-mobile.yml index 30b1cc8..14f052f 100644 --- a/.github/workflows/deploy-mobile.yml +++ b/.github/workflows/deploy-mobile.yml @@ -24,10 +24,10 @@ jobs: steps: - name: 🏗 Setup Repo - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: 🏗 Setup PNPM - uses: pnpm/action-setup@v6.0.9 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: 🏗 Get PNPM store directory id: pnpm-cache @@ -35,7 +35,7 @@ jobs: echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT - name: 🏗 Setup PNPM cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }} key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} @@ -43,12 +43,12 @@ jobs: ${{ runner.os }}-pnpm-store- - name: 🏗 Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22.x - name: "📦 Cache Node Modules" - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 id: cache-node-modules with: path: node_modules @@ -57,11 +57,11 @@ jobs: ${{ runner.os }}-node-modules- - name: 📦 Install Dependencies - run: pnpm install + run: pnpm install --frozen-lockfile # Only done as we are running Typecheck before publishing - name: 🏗 Setup Turborepo Cache - uses: rharkor/caching-for-turbo@v2.5.0 + uses: rharkor/caching-for-turbo@75f8ebf4a43d2c60b23bc2a27082cfea94ffdad9 # v2.5.0 # That shouldn't be necessary if we are running before merging PR's, # but as commits to main are not protected yet and sometimes happen, @@ -70,9 +70,9 @@ jobs: run: pnpm run typecheck - name: 🏗 Setup EAS - uses: expo/expo-github-action@v9 + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9 with: - eas-version: latest + eas-version: 23.1.0 token: ${{ secrets.EXPO_TOKEN }} # `--environment production` pulls EXPO_PUBLIC_* from the EAS env store diff --git a/.github/workflows/e2e-mobile.yml b/.github/workflows/e2e-mobile.yml index 173e30f..e7173cf 100644 --- a/.github/workflows/e2e-mobile.yml +++ b/.github/workflows/e2e-mobile.yml @@ -253,6 +253,7 @@ on: - "packages/**" - ".github/workflows/e2e-mobile.yml" - ".github/scripts/maestro-*.py" + - ".github/scripts/install-maestro.sh" - ".github/scripts/quarantine-lint.py" pull_request: types: [opened, synchronize, reopened, ready_for_review] @@ -261,6 +262,7 @@ on: - "packages/**" - ".github/workflows/e2e-mobile.yml" - ".github/scripts/maestro-*.py" + - ".github/scripts/install-maestro.sh" - ".github/scripts/quarantine-lint.py" workflow_dispatch: schedule: @@ -299,7 +301,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 2 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Lint quarantine file run: | python3 .github/scripts/quarantine-lint.py \ @@ -369,13 +371,13 @@ jobs: xcodebuild -version - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup pnpm - uses: pnpm/action-setup@v6.0.9 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22.x cache: pnpm @@ -384,7 +386,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Setup Java 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: "17" @@ -394,17 +396,18 @@ jobs: # verify whichever .app we produced (swapped or freshly built) # actually boots before we upload it. - name: Cache Maestro - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: - path: ~/.maestro + path: ${{ runner.temp }}/maestro key: maestro-${{ runner.os }}-2.6.0 - name: Install Maestro run: | - if [ ! -x "$HOME/.maestro/bin/maestro" ]; then - curl -fsSL "https://get.maestro.mobile.dev" | MAESTRO_VERSION=2.6.0 bash + MAESTRO_DIR="$RUNNER_TEMP/maestro" + if [ ! -x "$MAESTRO_DIR/bin/maestro" ]; then + .github/scripts/install-maestro.sh "$MAESTRO_DIR" fi - echo "$HOME/.maestro/bin" >> $GITHUB_PATH + echo "$MAESTRO_DIR/bin" >> "$GITHUB_PATH" # @expo/fingerprint hashes exactly the native-affecting inputs # (config plugins' resolved output, native module versions, native @@ -416,7 +419,7 @@ jobs: working-directory: apps/mobile run: | set -euo pipefail - npx --yes @expo/fingerprint fingerprint:generate --platform ios \ + pnpm exec fingerprint fingerprint:generate --platform ios \ > "$RUNNER_TEMP/fingerprint-required.json" FP=$(python3 -c "import json;print(json.load(open('$RUNNER_TEMP/fingerprint-required.json'))['hash'])") echo "FINGERPRINT=$FP" >> "$GITHUB_ENV" @@ -436,7 +439,7 @@ jobs: # stage the .app between app-cache/ and the products dir as needed. - name: Cache built .app (fingerprint-keyed) id: app-cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: apps/mobile/app-cache key: ${{ runner.os }}-app-required-${{ env.FINGERPRINT }} @@ -491,7 +494,7 @@ jobs: # hit. - name: Cache Xcode DerivedData (fallback) if: steps.app-cache.outputs.cache-hit != 'true' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: apps/mobile/ios/build key: ${{ runner.os }}-xcode-deriveddata-required-${{ env.FINGERPRINT }} @@ -504,7 +507,7 @@ jobs: # to compile the swapped-in JS to bytecode; on a miss, pod install # needs it for its own cache. - name: Cache CocoaPods - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: apps/mobile/ios/Pods key: ${{ runner.os }}-pods-${{ hashFiles('apps/mobile/ios/Podfile.lock') }} @@ -737,7 +740,7 @@ jobs: fi - name: Upload .app artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ steps.vars.outputs.app-artifact }} path: apps/mobile/ios/build/Build/Products/Release-iphonesimulator/*.app @@ -760,23 +763,24 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Cache Maestro - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: - path: ~/.maestro + path: ${{ runner.temp }}/maestro key: maestro-${{ runner.os }}-2.6.0 - name: Install Maestro run: | - if [ ! -x "$HOME/.maestro/bin/maestro" ]; then - curl -fsSL "https://get.maestro.mobile.dev" | MAESTRO_VERSION=2.6.0 bash + MAESTRO_DIR="$RUNNER_TEMP/maestro" + if [ ! -x "$MAESTRO_DIR/bin/maestro" ]; then + .github/scripts/install-maestro.sh "$MAESTRO_DIR" fi - echo "$HOME/.maestro/bin" >> $GITHUB_PATH + echo "$MAESTRO_DIR/bin" >> "$GITHUB_PATH" - name: Download built .app - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ${{ needs.build-ios-required.outputs.app-artifact }} path: app-download @@ -928,7 +932,7 @@ jobs: - name: Upload required Maestro artifacts if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: maestro-results-required path: | @@ -964,7 +968,7 @@ jobs: - name: Upload crash diagnostics if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: crash-diagnostics-required path: crash-diagnostics/ @@ -972,7 +976,7 @@ jobs: - name: Upload simulator log on failure if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: simulator-log-required path: simulator-required.logarchive @@ -1053,15 +1057,15 @@ jobs: - name: Checkout if: steps.preflight.outputs.skip != 'true' - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup pnpm if: steps.preflight.outputs.skip != 'true' - uses: pnpm/action-setup@v6.0.9 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Setup Node if: steps.preflight.outputs.skip != 'true' - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22.x cache: pnpm @@ -1072,7 +1076,7 @@ jobs: - name: Setup Java 17 if: steps.preflight.outputs.skip != 'true' - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: "17" @@ -1082,7 +1086,7 @@ jobs: working-directory: apps/mobile run: | set -euo pipefail - npx --yes @expo/fingerprint fingerprint:generate --platform ios \ + pnpm exec fingerprint fingerprint:generate --platform ios \ > "$RUNNER_TEMP/fingerprint-extended.json" FP=$(python3 -c "import json;print(json.load(open('$RUNNER_TEMP/fingerprint-extended.json'))['hash'])") echo "FINGERPRINT=$FP" >> "$GITHUB_ENV" @@ -1093,7 +1097,7 @@ jobs: - name: Cache built .app (fingerprint-keyed) if: steps.preflight.outputs.skip != 'true' id: app-cache - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: apps/mobile/app-cache key: ${{ runner.os }}-app-extended-${{ env.FINGERPRINT }} @@ -1136,7 +1140,7 @@ jobs: # happens post-job. See the required build's cache comments. - name: Cache Xcode DerivedData (fallback) if: steps.preflight.outputs.skip != 'true' && steps.app-cache.outputs.cache-hit != 'true' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: apps/mobile/ios/build key: ${{ runner.os }}-xcode-deriveddata-extended-${{ env.FINGERPRINT }} @@ -1146,7 +1150,7 @@ jobs: - name: Cache CocoaPods if: steps.preflight.outputs.skip != 'true' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: apps/mobile/ios/Pods key: ${{ runner.os }}-pods-${{ hashFiles('apps/mobile/ios/Podfile.lock') }} @@ -1276,18 +1280,19 @@ jobs: # every extended shard downloads and depends on it. - name: Cache Maestro if: steps.preflight.outputs.skip != 'true' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: - path: ~/.maestro + path: ${{ runner.temp }}/maestro key: maestro-${{ runner.os }}-2.6.0 - name: Install Maestro if: steps.preflight.outputs.skip != 'true' run: | - if [ ! -x "$HOME/.maestro/bin/maestro" ]; then - curl -fsSL "https://get.maestro.mobile.dev" | MAESTRO_VERSION=2.6.0 bash + MAESTRO_DIR="$RUNNER_TEMP/maestro" + if [ ! -x "$MAESTRO_DIR/bin/maestro" ]; then + .github/scripts/install-maestro.sh "$MAESTRO_DIR" fi - echo "$HOME/.maestro/bin" >> $GITHUB_PATH + echo "$MAESTRO_DIR/bin" >> "$GITHUB_PATH" # Raised driver-startup timeout + warm-up invocations for the same # reason as the required build's verify step: the first maestro use @@ -1348,7 +1353,7 @@ jobs: - name: Upload .app artifact if: steps.preflight.outputs.skip != 'true' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ steps.vars.outputs.app-artifact }} path: apps/mobile/ios/build/Build/Products/Release-iphonesimulator/*.app @@ -1401,13 +1406,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup pnpm - uses: pnpm/action-setup@v6.0.9 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22.x cache: pnpm @@ -1416,20 +1421,21 @@ jobs: run: pnpm install --frozen-lockfile - name: Cache Maestro - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: - path: ~/.maestro + path: ${{ runner.temp }}/maestro key: maestro-${{ runner.os }}-2.6.0 - name: Install Maestro run: | - if [ ! -x "$HOME/.maestro/bin/maestro" ]; then - curl -fsSL "https://get.maestro.mobile.dev" | MAESTRO_VERSION=2.6.0 bash + MAESTRO_DIR="$RUNNER_TEMP/maestro" + if [ ! -x "$MAESTRO_DIR/bin/maestro" ]; then + .github/scripts/install-maestro.sh "$MAESTRO_DIR" fi - echo "$HOME/.maestro/bin" >> $GITHUB_PATH + echo "$MAESTRO_DIR/bin" >> "$GITHUB_PATH" - name: Download built .app - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ${{ needs.build-ios-extended.outputs.app-artifact }} path: app-download @@ -1527,7 +1533,7 @@ jobs: - name: Upload extended Maestro artifacts (${{ matrix.shard.name }}) if: always() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: maestro-results-extended-${{ matrix.shard.name }} path: | @@ -1544,7 +1550,7 @@ jobs: - name: Upload simulator log on failure if: failure() - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: simulator-log-extended-${{ matrix.shard.name }} path: simulator-extended-${{ matrix.shard.name }}.logarchive @@ -1567,7 +1573,7 @@ jobs: actions: read contents: read steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Compute pass rate per flow env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release-mobile.yml b/.github/workflows/release-mobile.yml index b075c3f..3cb651b 100644 --- a/.github/workflows/release-mobile.yml +++ b/.github/workflows/release-mobile.yml @@ -85,6 +85,33 @@ env: EXPO_NO_TELEMETRY: 1 jobs: + authorize-release: + name: Verify release ref + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout full release history + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Verify main ancestry, tag, and changelog + env: + RELEASE_EVENT: ${{ github.event_name }} + RELEASE_REF: ${{ github.ref }} + RELEASE_SHA: ${{ github.sha }} + RELEASE_SUBMIT: ${{ inputs.submit }} + run: | + set -euo pipefail + git fetch --force origin main:refs/remotes/origin/main + python3 .github/scripts/verify-release-ref.py \ + --event "$RELEASE_EVENT" \ + --ref "$RELEASE_REF" \ + --sha "$RELEASE_SHA" \ + --submit "$RELEASE_SUBMIT" \ + --repo "$GITHUB_REPOSITORY" + # ------------------------------------------------------------------------- # GitHub release + changelog. Deliberately has NO `needs:` on the build # jobs: the notes describe what's in the tag, which is knowable the second @@ -101,6 +128,7 @@ jobs: # ------------------------------------------------------------------------- github-release: name: GitHub release (notes + changelog) + needs: authorize-release if: ${{ github.event_name == 'push' }} runs-on: ubuntu-latest timeout-minutes: 10 @@ -110,7 +138,7 @@ jobs: # Full history and every tag: the generator diffs this tag against the # previous one, which a shallow clone doesn't have. - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 fetch-tags: true @@ -174,48 +202,25 @@ jobs: "${FLAGS[@]}" fi - # Reports, never writes. The "Main" ruleset requires every change to - # `main` to arrive through a squash-merged pull request, so a bot pushing - # a changelog commit straight to the branch gets rejected with GH013 (it - # did, on v1.5.0-rc1's first run) and takes the whole job red AFTER the - # release was already published. Opening the PR from here instead doesn't - # work either: a pull request created with GITHUB_TOKEN does not trigger - # workflows, so its required checks would never report and the PR could - # never merge. - # - # `./scripts/tag-release.sh` regenerates CHANGELOG.md locally while - # cutting the tag, so the file is normally already committed by the time - # this runs. This step exists to catch the case where someone tagged - # without it, and it stays green either way; the release itself does not - # depend on the file. - - name: Check CHANGELOG.md is current + # The authorization job already checks this before the release or build + # jobs can start. Keep the check beside the publishing step too, so a + # later workflow edit cannot quietly split the release body and file. + - name: Confirm CHANGELOG.md is current run: | set -euo pipefail python3 .github/scripts/changelog.py \ --all --repo "$GITHUB_REPOSITORY" --output CHANGELOG.md - - if git diff --quiet -- CHANGELOG.md; then - echo "CHANGELOG.md is current." >> "$GITHUB_STEP_SUMMARY" - exit 0 - fi - - { - echo "### CHANGELOG.md is stale" - echo - echo "Run \`pnpm changelog\` on \`main\` and open a PR with the result." - echo - echo '```diff' - git --no-pager diff -- CHANGELOG.md - echo '```' - } >> "$GITHUB_STEP_SUMMARY" + git diff --exit-code -- CHANGELOG.md # ------------------------------------------------------------------------- # iOS: local EAS build on a macOS runner, production profile. # ------------------------------------------------------------------------- build-ios: name: Build iOS (production, local) + needs: authorize-release if: ${{ github.event_name != 'workflow_dispatch' || inputs.platform == 'all' || inputs.platform == 'ios' }} runs-on: macos-latest + environment: production timeout-minutes: 60 outputs: ipa-artifact: ${{ steps.vars.outputs.ipa-artifact }} @@ -241,7 +246,7 @@ jobs: xcodebuild -version - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # `eas build --local` stages the project into an isolated working # copy (packages/eas-cli/src/vcs/local.ts's makeShallowCopyAsync) @@ -295,10 +300,10 @@ jobs: GOOGLE_SERVICE_INFO_PLIST_B64: ${{ secrets.GOOGLE_SERVICE_INFO_PLIST }} - name: Setup pnpm - uses: pnpm/action-setup@v6.0.9 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22.x cache: pnpm @@ -307,7 +312,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Setup Java 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: "17" @@ -316,16 +321,16 @@ jobs: # its own `pod install` internally during prebuild, this cache just # gives that step a warm CocoaPods cache to pull from. - name: Cache CocoaPods - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: apps/mobile/ios/Pods key: ${{ runner.os }}-pods-${{ hashFiles('pnpm-lock.yaml') }} restore-keys: ${{ runner.os }}-pods- - name: Setup EAS CLI - uses: expo/expo-github-action@v9 + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9 with: - eas-version: latest + eas-version: 23.1.0 token: ${{ secrets.EXPO_TOKEN }} - name: EAS build (iOS, production, local) @@ -339,7 +344,7 @@ jobs: --output "$RUNNER_TEMP/Pegada.ipa" - name: Upload .ipa artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ steps.vars.outputs.ipa-artifact }} path: ${{ runner.temp }}/Pegada.ipa @@ -350,8 +355,10 @@ jobs: # ------------------------------------------------------------------------- build-android: name: Build Android (production, local) + needs: authorize-release if: ${{ github.event_name != 'workflow_dispatch' || inputs.platform == 'all' || inputs.platform == 'android' }} runs-on: ubuntu-latest + environment: production # A cold-cache first run on a 4-core hosted runner (no ~/.gradle to # restore, eas build --local doing its own prebuild on top of # gradlew bundleRelease) plausibly exceeds 60 minutes; this isn't @@ -365,7 +372,7 @@ jobs: run: echo "aab-artifact=android-production-aab" >> "$GITHUB_OUTPUT" - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # See the long comment on the matching iOS step: `eas build --local` # stages an isolated copy of the project that excludes anything @@ -402,10 +409,10 @@ jobs: GOOGLE_SERVICES_JSON_B64: ${{ secrets.GOOGLE_SERVICES_JSON }} - name: Setup pnpm - uses: pnpm/action-setup@v6.0.9 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22.x cache: pnpm @@ -414,7 +421,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Setup Java 17 - uses: actions/setup-java@v5 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin java-version: "17" @@ -431,7 +438,7 @@ jobs: # warm cache behind for the next attempt, timeout or not. - name: Restore Gradle cache id: gradle-cache-restore - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ~/.gradle/caches @@ -440,9 +447,9 @@ jobs: restore-keys: ${{ runner.os }}-gradle- - name: Setup EAS CLI - uses: expo/expo-github-action@v9 + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9 with: - eas-version: latest + eas-version: 23.1.0 token: ${{ secrets.EXPO_TOKEN }} # Root cause of the rc6 hang: the Gradle DAEMON hit its default @@ -510,7 +517,7 @@ jobs: # cache hit (nothing changed, re-saving would be wasted work). - name: Save Gradle cache if: always() && steps.gradle-cache-restore.outputs.cache-hit != 'true' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: | ~/.gradle/caches @@ -518,7 +525,7 @@ jobs: key: ${{ steps.gradle-cache-restore.outputs.cache-primary-key }} - name: Upload .aab artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: ${{ steps.vars.outputs.aab-artifact }} path: ${{ runner.temp }}/pegada.aab @@ -535,13 +542,14 @@ jobs: needs: build-ios if: ${{ github.event_name == 'workflow_dispatch' && inputs.submit == true && (inputs.platform == 'all' || inputs.platform == 'ios') }} runs-on: ubuntu-latest + environment: production timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Download .ipa artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ${{ needs.build-ios.outputs.ipa-artifact }} path: artifact @@ -550,10 +558,10 @@ jobs: # `expo` package -- without an install it dies with "Cannot find # package 'expo'" before ever talking to the store. - name: Setup pnpm - uses: pnpm/action-setup@v6.0.9 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22.x cache: pnpm @@ -562,9 +570,9 @@ jobs: run: pnpm install --frozen-lockfile - name: Setup EAS CLI - uses: expo/expo-github-action@v9 + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9 with: - eas-version: latest + eas-version: 23.1.0 token: ${{ secrets.EXPO_TOKEN }} - name: EAS submit (iOS) @@ -580,13 +588,14 @@ jobs: needs: build-android if: ${{ github.event_name == 'workflow_dispatch' && inputs.submit == true && (inputs.platform == 'all' || inputs.platform == 'android') }} runs-on: ubuntu-latest + environment: production timeout-minutes: 30 steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Download .aab artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: ${{ needs.build-android.outputs.aab-artifact }} path: artifact @@ -594,10 +603,10 @@ jobs: # Same as submit-ios: `eas submit` needs node_modules to evaluate the # Expo config before it can talk to the store. - name: Setup pnpm - uses: pnpm/action-setup@v6.0.9 + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - name: Setup Node - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 22.x cache: pnpm @@ -606,9 +615,9 @@ jobs: run: pnpm install --frozen-lockfile - name: Setup EAS CLI - uses: expo/expo-github-action@v9 + uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9 with: - eas-version: latest + eas-version: 23.1.0 token: ${{ secrets.EXPO_TOKEN }} - name: EAS submit (Android) diff --git a/packages/api/src/services/authentication-service.test.ts b/packages/api/src/services/authentication-service.test.ts index aa82322..6342819 100644 --- a/packages/api/src/services/authentication-service.test.ts +++ b/packages/api/src/services/authentication-service.test.ts @@ -51,22 +51,27 @@ describe("AuthenticationService.checkVerification", () => { ).resolves.toMatchObject({ code: null, codeExpiresAt: null }); }); - it("lets only one concurrent request consume an OTP", async () => { + it("lets only one concurrent request consume each issued OTP", async () => { const user = await seedCode(); - const verify = () => - AuthenticationService.checkVerification({ - email: user.email, - code: "123456", + for (let attempt = 0; attempt < 25; attempt += 1) { + const code = attempt.toString().padStart(6, "0"); + // oxlint-disable-next-line no-await-in-loop -- Each code must be issued before its two consumption requests race. + await prisma.user.update({ + where: { id: user.id }, + data: { code, codeExpiresAt: new Date(Date.now() + 60_000) }, }); + const verify = () => + AuthenticationService.checkVerification({ email: user.email, code }); - const results = await Promise.allSettled([verify(), verify()]); + // oxlint-disable-next-line no-await-in-loop -- Attempts are sequential so one code cannot interfere with the next. + const results = await Promise.allSettled([verify(), verify()]); + const statuses = results.map(({ status }) => status).sort(); - expect(results.filter(({ status }) => status === "fulfilled")).toHaveLength( - 1, - ); - expect(results.filter(({ status }) => status === "rejected")).toHaveLength( - 1, - ); + expect({ attempt, statuses }).toEqual({ + attempt, + statuses: ["fulfilled", "rejected"], + }); + } }); }); diff --git a/packages/api/src/services/authentication-service.ts b/packages/api/src/services/authentication-service.ts index 3edb230..afd6308 100644 --- a/packages/api/src/services/authentication-service.ts +++ b/packages/api/src/services/authentication-service.ts @@ -189,18 +189,21 @@ export class AuthenticationService { return true; } - // Match and clear in one write so a successful OTP cannot be replayed, - // including by two requests that arrive at the same time. - const consumed = await prisma.user.updateMany({ - where: { - email, - code, - codeExpiresAt: { gte: new Date() }, - }, - data: { code: null, codeExpiresAt: null }, - }); - - if (consumed.count !== 1) throw new InvalidOTPCodeError(); + // Prisma 5 splits updateMany into a matching SELECT followed by an UPDATE + // by ID. Keep the OTP predicate on the write so PostgreSQL rechecks it + // after taking the row lock when two requests arrive together. + const consumed = await prisma.$executeRaw` + UPDATE "User" + SET + "code" = NULL, + "codeExpiresAt" = NULL, + "updatedAt" = CURRENT_TIMESTAMP + WHERE "email" = ${email} + AND "code" = ${code} + AND "codeExpiresAt" >= CURRENT_TIMESTAMP + `; + + if (consumed !== 1) throw new InvalidOTPCodeError(); return true; }