From 74cb36d13337e8c844a33ff3c9645898e5877212 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Tue, 4 Aug 2026 18:19:37 +0100 Subject: [PATCH 1/3] Let .env.local override .env, and resolve duplicate keys last-wins setup_storefront_env took the first match for a duplicated key while run_maestro took the last. Last-wins is the predictable rule: in a file kept by hand, the last uncommented assignment is the active one. .env.local now overrides .env per key. Nothing writes to .env.local, so it survives every sync and gives a developer a place to point the sample apps at their own store. Write decisions about .env deliberately ignore the overlay. Resolved values carry overrides, so writing them back would make an override permanent. While .env.local exists, .env is left alone and the reason is reported. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/setup_storefront_env | 87 ++++++++++++++++++--- scripts/test_setup_storefront_env | 126 +++++++++++++++++++++++++++++- 2 files changed, 201 insertions(+), 12 deletions(-) diff --git a/scripts/setup_storefront_env b/scripts/setup_storefront_env index 0e9461a93..844627273 100755 --- a/scripts/setup_storefront_env +++ b/scripts/setup_storefront_env @@ -5,6 +5,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_ENV="${ROOT_DIR}/.env" +ROOT_ENV_LOCAL="${ROOT_DIR}/.env.local" ANDROID_ENV="${ROOT_DIR}/platforms/android/samples/CheckoutKitAndroidDemo/.env" SWIFT_DEMO_XCCONFIG="${ROOT_DIR}/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig" SWIFT_ACCELERATED_XCCONFIG="${ROOT_DIR}/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/Storefront.xcconfig" @@ -95,15 +96,37 @@ read_env_value() { sub(/[[:space:]]*$/, "", candidate) if (candidate == key) { value = substr(line, index(line, "=") + 1) - print value - exit + found = 1 } } + END { if (found) print value } ' "$file")" strip_outer_quotes "$raw_value" } +env_keys() { + local file="$1" + + [[ -f "$file" ]] || return 0 + + awk ' + /^[[:space:]]*#/ || /^[[:space:]]*\/\// || /^[[:space:]]*$/ { next } + $0 !~ /=/ { next } + { + line = $0 + sub(/^[[:space:]]*/, "", line) + candidate = line + sub(/=.*/, "", candidate) + sub(/[[:space:]]*$/, "", candidate) + if (candidate != "" && !(candidate in seen)) { + seen[candidate] = 1 + print candidate + } + } + ' "$file" +} + env_has_key() { local key="$1" local file="$2" @@ -128,6 +151,36 @@ env_has_key() { ' "$file" } +# Value resolution reads .env.local ahead of .env, so an entry there wins. +# Decisions about writing .env deliberately do not use these: .env.local must +# never be baked into .env, or the override would become permanent. +read_root_value() { + local key="$1" + + if env_has_key "$key" "$ROOT_ENV_LOCAL"; then + read_env_value "$key" "$ROOT_ENV_LOCAL" + return 0 + fi + + read_env_value "$key" "$ROOT_ENV" +} + +root_has_key() { + local key="$1" + + env_has_key "$key" "$ROOT_ENV_LOCAL" || env_has_key "$key" "$ROOT_ENV" +} + +report_local_overrides() { + local keys + + keys="$(env_keys "$ROOT_ENV_LOCAL" | paste -sd, - | sed -e 's/,/, /g')" + [[ -n "$keys" ]] || return 0 + + echo ".env.local overrides .env for: ${keys}" + echo "Nothing writes to .env.local, so remove a line there to fall back to .env." +} + is_missing_required_value() { local value="$1" @@ -176,7 +229,7 @@ required_config_value() { local file_value local env_value - file_value="$(read_env_value "$key" "$ROOT_ENV")" + file_value="$(read_root_value "$key")" if ! is_missing_required_value "$file_value"; then printf '%s' "$file_value" return 0 @@ -193,8 +246,8 @@ root_or_source_value() { shift local root_value - if env_has_key "$key" "$ROOT_ENV"; then - root_value="$(read_env_value "$key" "$ROOT_ENV")" + if root_has_key "$key"; then + root_value="$(read_root_value "$key")" if ! is_placeholder_value "$root_value"; then printf '%s' "$root_value" return 0 @@ -209,8 +262,8 @@ root_or_source_nonempty_value() { shift local root_value - if env_has_key "$key" "$ROOT_ENV"; then - root_value="$(read_env_value "$key" "$ROOT_ENV")" + if root_has_key "$key"; then + root_value="$(read_root_value "$key")" if [[ -n "$root_value" ]] && ! is_placeholder_value "$root_value"; then printf '%s' "$root_value" return 0 @@ -418,8 +471,8 @@ load_values() { "$(read_env_value STOREFRONT_ACCESS_TOKEN "$SWIFT_ACCELERATED_XCCONFIG")")" API_VERSION_VALUE="$(first_config_value \ - "$(read_env_value API_VERSION "$ROOT_ENV")" \ - "$(read_env_value STOREFRONT_VERSION "$ROOT_ENV")" \ + "$(read_root_value API_VERSION)" \ + "$(read_root_value STOREFRONT_VERSION)" \ "$(env_fallback API_VERSION)" \ "$(env_fallback STOREFRONT_VERSION)" \ "$(read_env_value API_VERSION "$ANDROID_ENV")" \ @@ -700,8 +753,8 @@ ensure_root_env() { exit 1 fi - if is_missing_required_value "$(read_env_value STOREFRONT_DOMAIN "$ROOT_ENV")" || - is_missing_required_value "$(read_env_value STOREFRONT_ACCESS_TOKEN "$ROOT_ENV")"; then + if is_missing_required_value "$(read_root_value STOREFRONT_DOMAIN)" || + is_missing_required_value "$(read_root_value STOREFRONT_ACCESS_TOKEN)"; then echo "Root .env is missing required storefront configuration." >&2 exit 1 fi @@ -735,6 +788,17 @@ ensure_root_env() { echo "Normalizing root storefront configuration at .env." fi + # Resolved values carry .env.local overrides, so writing them back would make an + # override permanent. Leave .env alone and say why instead. + if [[ -f "$ROOT_ENV_LOCAL" ]]; then + if [[ "$root_needs_write" == "true" ]]; then + echo "Leaving .env alone because .env.local exists. Remove .env.local to let .env be rewritten." + fi + + load_values + return 0 + fi + if [[ "$root_needs_write" == "true" ]]; then collect_missing_values generate_root_env >"$ROOT_ENV" @@ -794,6 +858,7 @@ check_generated_files() { echo "Sample app storefront configuration is up to date." } +report_local_overrides ensure_root_env if [[ "$mode" == "check" ]]; then diff --git a/scripts/test_setup_storefront_env b/scripts/test_setup_storefront_env index c7c16a2e7..5f917a212 100755 --- a/scripts/test_setup_storefront_env +++ b/scripts/test_setup_storefront_env @@ -62,6 +62,39 @@ assert_not_contains() { fi } +write_canonical_root_env() { + local path="$1" + local domain="$2" + + cat >"$path" <>"$fixture/.env" + + "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + assert_output_is_sanitized "$output" + + assert_contains "$android_env" "STOREFRONT_DOMAIN=later-store.example.myshopify.com" + assert_not_contains "$android_env" "synthetic-store.example.myshopify.com" +} + +test_env_local_overrides_root_env() { + local fixture output android_env root_before local_before + fixture="$(make_fixture)" + output="$fixture/output.log" + android_env="$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" + root_before="$fixture/root_before" + local_before="$fixture/local_before" + + write_canonical_root_env "$fixture/.env" synthetic-store.example.myshopify.com + cat >"$fixture/.env.local" <<'EOF' +STOREFRONT_DOMAIN=overridden-store.example.myshopify.com +STOREFRONT_ACCESS_TOKEN=overridden-token +EOF + cp "$fixture/.env" "$root_before" + cp "$fixture/.env.local" "$local_before" + + "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + assert_output_is_sanitized "$output" + + assert_contains "$android_env" "STOREFRONT_DOMAIN=overridden-store.example.myshopify.com" + assert_contains "$android_env" "STOREFRONT_ACCESS_TOKEN=overridden-token" + assert_contains "$android_env" "EMAIL=checkout-kit@example.com" + assert_file_is_unchanged "$fixture/.env" "$root_before" + assert_file_is_unchanged "$fixture/.env.local" "$local_before" +} + +test_env_local_warning_names_only_keys() { + local fixture output + fixture="$(make_fixture)" + output="$fixture/output.log" + + write_canonical_root_env "$fixture/.env" synthetic-store.example.myshopify.com + cat >"$fixture/.env.local" <<'EOF' +STOREFRONT_DOMAIN=overridden-store.example.myshopify.com +STOREFRONT_ACCESS_TOKEN=overridden-token +EOF + + "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + assert_output_is_sanitized "$output" + + assert_contains "$output" ".env.local" + assert_contains "$output" "STOREFRONT_DOMAIN" + assert_contains "$output" "STOREFRONT_ACCESS_TOKEN" + assert_not_contains "$output" "EMAIL" +} + +test_env_local_is_never_baked_into_root_env() { + local fixture output root_before + fixture="$(make_fixture)" + output="$fixture/output.log" + root_before="$fixture/root_before" + + cat >"$fixture/.env" <<'EOF' +STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com +STOREFRONT_ACCESS_TOKEN=synthetic-token +EOF + printf 'STOREFRONT_DOMAIN=%s\n' overridden-store.example.myshopify.com >"$fixture/.env.local" + cp "$fixture/.env" "$root_before" + + "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + assert_output_is_sanitized "$output" + + assert_file_is_unchanged "$fixture/.env" "$root_before" + assert_contains "$output" "Leaving .env alone" + assert_contains "$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" \ + "STOREFRONT_DOMAIN=overridden-store.example.myshopify.com" +} + test_merchant_identifier_propagates_to_sample_apps test_sample_projects_read_generated_merchant_identifier test_sync_and_check @@ -445,5 +565,9 @@ test_blank_customer_account_api_version_defaults test_development_team_follows_env_and_clears test_development_team_is_added_to_an_existing_env test_migration_from_platform_config +test_duplicate_keys_resolve_last_wins +test_env_local_overrides_root_env +test_env_local_warning_names_only_keys +test_env_local_is_never_baked_into_root_env echo "setup_storefront_env synthetic tests passed." From 1c518479025d51c088c061e74dd725da0b879fa7 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Tue, 4 Aug 2026 18:51:04 +0100 Subject: [PATCH 2/3] Generate .env from ejson instead of writing it scripts/generate_env_files decrypts config/secrets/*.ejson into .env and e2e/.env, so setup_storefront_env becomes read-only towards .env. A developer who keeps their own store in .env.local now keeps it: nothing overwrites the file that a sync used to rewrite. The two optional prompt flags go away with the writing. CI callers already supply their values through the process environment, so they only needed the flag to suppress a prompt that no longer exists. scripts/migrate_env_to_local preserves a hand-written .env as .env.local on the first `dev up` after this change. STOREFRONT_MERCHANT_IDENTIFIER leaves the shared storefront config: it belongs beside each sample app's own Apple Pay entitlement. Co-Authored-By: Claude Opus 5 (1M context) Assisted-By: devx/252dfd24-6c25-4bb4-8463-27702ec564eb --- .github/workflows/android-test.yml | 6 +- .github/workflows/rn-test-android.yml | 2 +- .github/workflows/swift-test-workflow.yml | 2 +- dev.yml | 17 +- e2e/scripts/bitrise_ci_helpers | 2 +- scripts/copy_worktree_env | 25 +- scripts/generate_env_files | 143 +++++++ scripts/migrate_env_to_local | 57 +++ scripts/secrets_edit | 24 ++ scripts/setup_dev_workspace | 11 +- scripts/setup_storefront_env | 304 ++------------ scripts/test/secrets_edit_test.rb | 69 +++- scripts/test_generate_env_files | 335 ++++++++++++++++ scripts/test_setup_storefront_env | 461 ++++++++++++---------- 14 files changed, 944 insertions(+), 514 deletions(-) create mode 100755 scripts/generate_env_files create mode 100755 scripts/migrate_env_to_local create mode 100755 scripts/test_generate_env_files diff --git a/.github/workflows/android-test.yml b/.github/workflows/android-test.yml index a8515775f..95ff37f1b 100644 --- a/.github/workflows/android-test.yml +++ b/.github/workflows/android-test.yml @@ -34,7 +34,7 @@ jobs: - name: Setup sample app environment id: sample_env if: ${{ !cancelled() }} - run: ${{ github.workspace }}/scripts/setup_storefront_env --skip-optional-prompts + run: ${{ github.workspace }}/scripts/setup_storefront_env env: STOREFRONT_DOMAIN: example.myshopify.com STOREFRONT_ACCESS_TOKEN: test-token @@ -113,7 +113,7 @@ jobs: cache: 'gradle' - name: Setup sample app environment - run: ${{ github.workspace }}/scripts/setup_storefront_env --skip-optional-prompts + run: ${{ github.workspace }}/scripts/setup_storefront_env env: STOREFRONT_DOMAIN: example.myshopify.com STOREFRONT_ACCESS_TOKEN: test-token @@ -146,7 +146,7 @@ jobs: run: ./gradlew detekt - name: Setup sample app environment - run: ${{ github.workspace }}/scripts/setup_storefront_env --skip-optional-prompts + run: ${{ github.workspace }}/scripts/setup_storefront_env env: STOREFRONT_DOMAIN: example.myshopify.com STOREFRONT_ACCESS_TOKEN: test-token diff --git a/.github/workflows/rn-test-android.yml b/.github/workflows/rn-test-android.yml index 219607b4b..9d14f165d 100644 --- a/.github/workflows/rn-test-android.yml +++ b/.github/workflows/rn-test-android.yml @@ -69,6 +69,6 @@ jobs: echo "JAVA_HOME: $JAVA_HOME" java -version javac -version - ${{ github.workspace }}/scripts/setup_storefront_env --skip-optional-prompts + ${{ github.workspace }}/scripts/setup_storefront_env pnpm module build pnpm sample test:android --no-daemon diff --git a/.github/workflows/swift-test-workflow.yml b/.github/workflows/swift-test-workflow.yml index 115199108..9f04f602a 100644 --- a/.github/workflows/swift-test-workflow.yml +++ b/.github/workflows/swift-test-workflow.yml @@ -84,7 +84,7 @@ jobs: env: STOREFRONT_DOMAIN: example.myshopify.com STOREFRONT_ACCESS_TOKEN: test-token - run: ${{ github.workspace }}/scripts/setup_storefront_env --skip-optional-prompts + run: ${{ github.workspace }}/scripts/setup_storefront_env - if: ${{ inputs.setup-storefront-env || inputs.summarize-tests }} uses: ./.github/actions/setup-mint diff --git a/dev.yml b/dev.yml index 08378e016..afce98a5f 100644 --- a/dev.yml +++ b/dev.yml @@ -32,17 +32,25 @@ up: met?: ./scripts/secrets_setup --check meet: "true" - custom: - name: Copy root env into worktree + name: Copy .env, .dev.env, local.properties into worktree met?: ./scripts/copy_worktree_env --check meet: ./scripts/copy_worktree_env + - custom: + name: Keep a hand-written .env as .env.local + met?: ./scripts/migrate_env_to_local --check + meet: ./scripts/migrate_env_to_local + - custom: + name: Generate env files from config/secrets + met?: ./scripts/generate_env_files --check + meet: ./scripts/generate_env_files - custom: name: Install the pinned Maestro version met?: ./scripts/install_maestro --check meet: ./scripts/install_maestro - custom: name: Run Checkout Kit workspace setup - met?: ./scripts/setup_dev_workspace --check --skip-optional-prompts - meet: ./scripts/setup_dev_workspace --skip-optional-prompts + met?: ./scripts/setup_dev_workspace --check + meet: ./scripts/setup_dev_workspace - tophat_mobile - custom: name: Configure Tophat Quick Launch items @@ -59,6 +67,7 @@ open: check: ejson-plaintext: ./scripts/ejson_lint + generate-env-tests: ./scripts/test_generate_env_files storefront-env-tests: ./scripts/test_setup_storefront_env ruby-script-tests: ./scripts/test_ruby maestro-hide-keyboard-lint: ./e2e/scripts/check_hide_keyboard_usage @@ -98,7 +107,7 @@ commands: esac copy-env: - desc: Copy the root .env into the current worktree so `dev up` can regenerate sample config + desc: Copy .dev.env, .env.local, local.properties into worktree run: ./scripts/copy_worktree_env secrets: diff --git a/e2e/scripts/bitrise_ci_helpers b/e2e/scripts/bitrise_ci_helpers index a05c14be8..a9d3fc06f 100755 --- a/e2e/scripts/bitrise_ci_helpers +++ b/e2e/scripts/bitrise_ci_helpers @@ -22,7 +22,7 @@ e2e_configure_storefront() { : "${STOREFRONT_DOMAIN:?STOREFRONT_DOMAIN is required. Check https://app.bitrise.io/app/f51f9054-053e-40f1-81e9-ae727567ae76/workflow_editor#!/secrets and enable Expose for pull requests.}" : "${STOREFRONT_ACCESS_TOKEN:?STOREFRONT_ACCESS_TOKEN is required. Check https://app.bitrise.io/app/f51f9054-053e-40f1-81e9-ae727567ae76/workflow_editor#!/secrets and enable Expose for pull requests.}" e2e_log "Configuring storefront environment" - ./scripts/setup_storefront_env --skip-optional-prompts + ./scripts/setup_storefront_env } e2e_nightly_commit_window() { diff --git a/scripts/copy_worktree_env b/scripts/copy_worktree_env index 573e0df12..1b5e3d1d9 100755 --- a/scripts/copy_worktree_env +++ b/scripts/copy_worktree_env @@ -2,12 +2,12 @@ set -euo pipefail -# Copies the repo-root .env (and other root-level machine-local config, if -# present) from the main checkout into the current git worktree, so that a -# subsequent `dev up` can regenerate all nested sample app configuration from it. +# Copies root-level machine-local configuration from the main checkout into the +# current git worktree: .env.local, .dev.env, and local.properties. Generated +# files such as .env and e2e/.env are deliberately not copied. # # Runs automatically as a `dev up` step. With --check it reports (without -# copying) whether the current worktree is missing any root env files: exit 0 +# copying) whether the current worktree is missing any of those files: exit 0 # when nothing is needed (main checkout, or already seeded) and non-zero when a # copy is required, so it can serve as a `dev up` met? probe. # @@ -33,11 +33,18 @@ if [[ "${CURRENT_ROOT}" == "${MAIN_ROOT}" ]]; then exit 0 fi -# Root-level, gitignored source-of-truth files. Nested sample config -# (Android/Swift/RN) is intentionally omitted: `dev up` regenerates it from the -# root .env via scripts/setup_storefront_env. +# Root-level, gitignored files that nothing generates. Anything generated is left +# out, because `dev up` rebuilds it in this worktree anyway: +# +# .env scripts/generate_env_files, from config/secrets/demo.ejson +# e2e/.env scripts/generate_env_files, from config/secrets/e2e.ejson +# sample app config scripts/setup_storefront_env, from .env +# +# Copying a generated .env would be worse than useless: the next step, +# scripts/migrate_env_to_local, would read it as hand-written and freeze that +# stale copy as .env.local, which then overrides the real one for good. ROOT_FILES=( - ".env" + ".env.local" ".dev.env" "local.properties" ) @@ -73,7 +80,7 @@ done [[ "${mode}" == "check" ]] && exit 0 if [[ "${copied_any}" != "true" ]]; then - echo "No new root env files to copy." + echo "No new machine-local files to copy." fi echo "Next: run \`dev up\` in this worktree to regenerate sample app config." diff --git a/scripts/generate_env_files b/scripts/generate_env_files new file mode 100755 index 000000000..adaab0bc9 --- /dev/null +++ b/scripts/generate_env_files @@ -0,0 +1,143 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Writes the gitignored env files that the sample apps and the E2E suite read, +# by decrypting the committed files under config/secrets. +# +# config/secrets/demo.ejson -> .env (sample apps, via setup_storefront_env) +# config/secrets/e2e.ejson -> e2e/.env (Maestro suite, via e2e/scripts/run_maestro) +# +# Runs automatically as a `dev up` step. With --check it reports (without writing) +# whether either file is missing or out of date, so it serves as a met? probe. +# +# Two failures look alike and are not: +# +# ejson2env missing -> a broken setup. `dev up` installs it, so this exits +# non-zero and says so. +# private key missing -> a supported state. Anyone outside the GCP project has +# no key and needs none; they write .env by hand. This +# exits 0 and reports the skip, so `dev up` completes. +# +# Each file is rendered to a temporary path and moved into place, so a failed +# decrypt can never truncate an env file that already works. +# +# This command prints key file names, paths and status only; it never prints a +# configured value. + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +KEYDIR="${EJSON_KEYDIR:-/opt/ejson/keys}" + +# name:source:destination. The name is what `dev secrets edit` takes. +PAIRS=( + "demo:config/secrets/demo.ejson:.env" + "e2e:config/secrets/e2e.ejson:e2e/.env" +) + +mode="generate" +if [[ "${1:-}" == "--check" ]]; then + mode="check" +elif [[ $# -gt 0 ]]; then + echo "Usage: scripts/generate_env_files [--check]" >&2 + exit 1 +fi + +public_key_of() { + sed -n 's/.*"_public_key"[[:space:]]*:[[:space:]]*"\([0-9a-f]\{64\}\)".*/\1/p' "$1" | head -n 1 +} + +generated_header() { + local name="$1" + local source_rel="$2" + local dest_rel="$3" + + cat </dev/null 2>&1; then + echo "generate_env_files: ejson2env is not installed, so no env file can be generated." >&2 + echo "generate_env_files: run \`dev up\` to install it." >&2 + exit 1 +fi + +stale=() +generated=() +skipped=() + +for pair in "${PAIRS[@]}"; do + name="${pair%%:*}" + rest="${pair#*:}" + source_rel="${rest%%:*}" + dest_rel="${rest#*:}" + + source_path="${ROOT_DIR}/${source_rel}" + dest_path="${ROOT_DIR}/${dest_rel}" + + if [[ ! -f "${source_path}" ]]; then + echo "generate_env_files: ${source_rel} does not exist, so ${dest_rel} is left alone." + skipped+=("${dest_rel}") + continue + fi + + public_key="$(public_key_of "${source_path}")" + if [[ -z "${public_key}" ]]; then + echo "generate_env_files: ${source_rel} has no _public_key, so ${dest_rel} cannot be generated." >&2 + exit 1 + fi + + if [[ ! -f "${KEYDIR}/${public_key}" ]]; then + echo "generate_env_files: no private key for ${public_key} in ${KEYDIR}, so ${dest_rel} is left alone." + skipped+=("${dest_rel}") + continue + fi + + rendered="$(mktemp)" + + if ! { + generated_header "${name}" "${source_rel}" "${dest_rel}" + ejson2env --quiet --keydir "${KEYDIR}" "${source_path}" + } >"${rendered}" 2>/dev/null; then + rm -f "${rendered}" + echo "generate_env_files: could not decrypt ${source_rel}, so ${dest_rel} is unchanged." >&2 + echo "generate_env_files: check that the private key for ${public_key} is the current one." >&2 + exit 1 + fi + + if [[ "${mode}" == "check" ]]; then + if [[ ! -f "${dest_path}" ]] || ! cmp -s "${dest_path}" "${rendered}"; then + stale+=("${dest_rel}") + fi + + rm -f "${rendered}" + continue + fi + + mkdir -p "$(dirname "${dest_path}")" + mv "${rendered}" "${dest_path}" + generated+=("${dest_rel}") +done + +if [[ "${mode}" == "check" ]]; then + if [[ "${#stale[@]}" -gt 0 ]]; then + echo "generate_env_files: missing or out of date: ${stale[*]}" >&2 + exit 1 + fi + + echo "generate_env_files: generated env files are up to date." + exit 0 +fi + +if [[ "${#generated[@]}" -gt 0 ]]; then + echo "generate_env_files: wrote ${generated[*]}" +fi + +if [[ "${#skipped[@]}" -gt 0 ]]; then + echo "generate_env_files: your own values in ${skipped[*]} stay under your control." +fi diff --git a/scripts/migrate_env_to_local b/scripts/migrate_env_to_local new file mode 100755 index 000000000..b0efcae49 --- /dev/null +++ b/scripts/migrate_env_to_local @@ -0,0 +1,57 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# TODO: remove after 2026-09-01, once everyone has migrated. +# +# Keeps a hand-written .env from before scripts/generate_env_files existed. +# +# .env used to be the file a developer edited. It is now generated from +# config/secrets/demo.ejson, so the first `dev up` after this change would +# overwrite it. Copying it to .env.local first preserves those values, because +# .env.local overrides .env and nothing ever writes to it. +# +# Runs from `dev up` only, before generate_env_files, and only when .env.local is +# absent. It therefore acts at most once per checkout. +# +# This command prints file paths and status only; it never prints a configured +# value. + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +ROOT_ENV="${ROOT_DIR}/.env" +ROOT_ENV_LOCAL="${ROOT_DIR}/.env.local" + +mode="migrate" +if [[ "${1:-}" == "--check" ]]; then + mode="check" +elif [[ $# -gt 0 ]]; then + echo "Usage: scripts/migrate_env_to_local [--check]" >&2 + exit 1 +fi + +nothing_to_do() { + [[ "${mode}" == "check" ]] && exit 0 + + echo "migrate_env_to_local: $1" + exit 0 +} + +if [[ -e "${ROOT_ENV_LOCAL}" ]]; then + nothing_to_do ".env.local already exists, so nothing is copied." +fi + +if [[ ! -f "${ROOT_ENV}" ]]; then + nothing_to_do "no .env to migrate." +fi + +if [[ "${mode}" == "check" ]]; then + echo "migrate_env_to_local: .env has no .env.local yet" + exit 1 +fi + +cp "${ROOT_ENV}" "${ROOT_ENV_LOCAL}" + +echo "migrate_env_to_local: copied .env to .env.local, because .env is now generated." +echo "migrate_env_to_local: .env.local wins over .env, so your values keep working." +echo "migrate_env_to_local: delete .env.local to follow the shared config instead." diff --git a/scripts/secrets_edit b/scripts/secrets_edit index 04df86649..f1d72a905 100755 --- a/scripts/secrets_edit +++ b/scripts/secrets_edit @@ -124,6 +124,28 @@ def relative(path) path.delete_prefix("#{REPO_ROOT}/") end +# An edit is only useful once the sample apps see it, so the generated env files +# are refreshed here instead of needing a second command. +# +# Only for files under config/secrets: those are the ones the env files come from. +# Editing an .ejson file elsewhere must leave them alone. +# +# A failure here is reported but not fatal. The edit is already encrypted and +# committable, and generate_env_files says what to do about its own problem. +def regenerate_env_files(path) + # realpath on both sides: REPO_ROOT comes from __dir__, which is already resolved, + # so a symlinked path would otherwise never match. + return unless File.realpath(path).start_with?("#{File.realpath(SECRETS_DIR)}/") + + script = File.join(REPO_ROOT, "scripts", "generate_env_files") + out, status = Open3.capture2e(script) + print out + + return if status.success? + + warn CliOutput.suggestion("the edit is saved; rerun `dev up` once that is fixed") +end + subcommand, target, *extra = ARGV if ["--help", "-h", "help"].include?(subcommand) @@ -184,4 +206,6 @@ Dir.mktmpdir("secrets-edit") do |workspace| puts "Updated #{relative(path)}. #{changed.length} value(s) changed:" changed.each { |key| puts " #{key}" } puts "Commit the file to share the change." + + regenerate_env_files(path) end diff --git a/scripts/setup_dev_workspace b/scripts/setup_dev_workspace index 3adb8ca98..f0ab653e7 100755 --- a/scripts/setup_dev_workspace +++ b/scripts/setup_dev_workspace @@ -5,7 +5,6 @@ set -o pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" mode="sync" -storefront_prompt_arg="--skip-optional-prompts" step_names=() step_statuses=() @@ -18,7 +17,7 @@ gemfile_lock_aligned=1 usage() { cat <&2 - exit 1 - fi - - while [[ -z "$value" ]]; do - read -r -s -p "${label}: " value - echo - value="$(printf '%s' "$value" | trim)" - done - - printf '%s' "$value" -} - -prompt_optional() { - local label="$1" - local value="" - - if [[ ! -t 0 ]]; then - return 0 - fi - - read -r -s -p "${label} (optional, press Enter to skip): " value - echo - printf '%s' "$value" | trim -} - load_values() { STOREFRONT_DOMAIN_VALUE="$(first_config_value \ "$(required_config_value STOREFRONT_DOMAIN)" \ @@ -484,7 +364,7 @@ load_values() { REACT_NATIVE_APPLE_PAY_MERCHANT_IDENTIFIER_VALUE="$(root_or_source_value REACT_NATIVE_APPLE_PAY_MERCHANT_IDENTIFIER \ "$(env_fallback REACT_NATIVE_APPLE_PAY_MERCHANT_IDENTIFIER)" \ - "$(read_env_value STOREFRONT_MERCHANT_IDENTIFIER "$ROOT_ENV")" \ + "$(read_root_value STOREFRONT_MERCHANT_IDENTIFIER)" \ "$(env_fallback STOREFRONT_MERCHANT_IDENTIFIER)" \ "$(read_env_value APPLE_PAY_MERCHANT_IDENTIFIER "$REACT_NATIVE_ENV")" \ "$(read_env_value STOREFRONT_MERCHANT_IDENTIFIER "$REACT_NATIVE_ENV")" \ @@ -492,18 +372,21 @@ load_values() { SWIFT_APPLE_PAY_MERCHANT_IDENTIFIER_VALUE="$(root_or_source_value SWIFT_APPLE_PAY_MERCHANT_IDENTIFIER \ "$(env_fallback SWIFT_APPLE_PAY_MERCHANT_IDENTIFIER)" \ - "$(read_env_value STOREFRONT_MERCHANT_IDENTIFIER "$ROOT_ENV")" \ + "$(read_root_value STOREFRONT_MERCHANT_IDENTIFIER)" \ "$(env_fallback STOREFRONT_MERCHANT_IDENTIFIER)" \ "$(read_env_value APPLE_PAY_MERCHANT_IDENTIFIER "$SWIFT_DEMO_XCCONFIG")" \ "$(read_env_value STOREFRONT_MERCHANT_IDENTIFIER "$SWIFT_DEMO_XCCONFIG")")" - CUSTOMER_ACCOUNT_API_CLIENT_ID_VALUE="$(root_or_source_value CUSTOMER_ACCOUNT_API_CLIENT_ID \ + # Nonempty, not plain: a blank line in .env means ejson has no value yet, so the + # process environment must still be able to supply one. Neither chain ends in a + # default, so a key that is genuinely unset still resolves to blank. + CUSTOMER_ACCOUNT_API_CLIENT_ID_VALUE="$(root_or_source_nonempty_value CUSTOMER_ACCOUNT_API_CLIENT_ID \ "$(env_fallback CUSTOMER_ACCOUNT_API_CLIENT_ID)" \ "$(read_env_value CUSTOMER_ACCOUNT_API_CLIENT_ID "$ANDROID_ENV")" \ "$(read_env_value CUSTOMER_ACCOUNT_API_CLIENT_ID "$REACT_NATIVE_ENV")" \ "$(read_env_value CUSTOMER_ACCOUNT_API_CLIENT_ID "$SWIFT_DEMO_XCCONFIG")")" - CUSTOMER_ACCOUNT_API_SHOP_ID_VALUE="$(root_or_source_value CUSTOMER_ACCOUNT_API_SHOP_ID \ + CUSTOMER_ACCOUNT_API_SHOP_ID_VALUE="$(root_or_source_nonempty_value CUSTOMER_ACCOUNT_API_SHOP_ID \ "$(env_fallback CUSTOMER_ACCOUNT_API_SHOP_ID)" \ "$(derive_customer_account_api_shop_id)" \ "$(read_env_value CUSTOMER_ACCOUNT_API_SHOP_ID "$REACT_NATIVE_ENV")" \ @@ -535,31 +418,15 @@ load_values() { "$(env_fallback DEVELOPMENT_TEAM)")" } -collect_missing_values() { - fill_optional_values_from_environment || true - +# Not fatal. The generated files are still written, so a partly configured +# checkout builds; the sample app then fails at its first network call instead. +report_missing_required_values() { if is_missing_required_value "$STOREFRONT_DOMAIN_VALUE"; then - STOREFRONT_DOMAIN_VALUE="$(prompt_required "Storefront domain")" + echo "No usable STOREFRONT_DOMAIN. Set it in .env.local, or run \`dev up\` to generate .env." >&2 fi if is_missing_required_value "$STOREFRONT_ACCESS_TOKEN_VALUE"; then - STOREFRONT_ACCESS_TOKEN_VALUE="$(prompt_required "Storefront access token")" - fi - - if [[ "$prompt_optional_values" == "true" && -z "$REACT_NATIVE_APPLE_PAY_MERCHANT_IDENTIFIER_VALUE" ]]; then - REACT_NATIVE_APPLE_PAY_MERCHANT_IDENTIFIER_VALUE="$(prompt_optional "React Native Apple Pay merchant identifier")" - fi - - if [[ "$prompt_optional_values" == "true" && -z "$SWIFT_APPLE_PAY_MERCHANT_IDENTIFIER_VALUE" ]]; then - SWIFT_APPLE_PAY_MERCHANT_IDENTIFIER_VALUE="$(prompt_optional "Swift Apple Pay merchant identifier")" - fi - - if [[ "$prompt_optional_values" == "true" && -z "$CUSTOMER_ACCOUNT_API_CLIENT_ID_VALUE" ]]; then - CUSTOMER_ACCOUNT_API_CLIENT_ID_VALUE="$(prompt_optional "Customer Account API client ID")" - fi - - if [[ "$prompt_optional_values" == "true" && -z "$CUSTOMER_ACCOUNT_API_SHOP_ID_VALUE" ]]; then - CUSTOMER_ACCOUNT_API_SHOP_ID_VALUE="$(prompt_optional "Customer Account API shop ID")" + echo "No usable STOREFRONT_ACCESS_TOKEN. Set it in .env.local, or run \`dev up\` to generate .env." >&2 fi } @@ -576,66 +443,18 @@ write_xcconfig_assignment() { printf '%s = %s\n' "$1" "$2" } -generate_root_env() { - cat <&2 - exit 1 - fi - - if is_missing_required_value "$(read_root_value STOREFRONT_DOMAIN)" || - is_missing_required_value "$(read_root_value STOREFRONT_ACCESS_TOKEN)"; then - echo "Root .env is missing required storefront configuration." >&2 - exit 1 - fi - - if ! root_has_canonical_keys; then - echo "Root .env is missing canonical storefront configuration keys." >&2 - exit 1 - fi - - if customer_account_api_version_needs_default; then - echo "Root .env has a blank Customer Account API version." >&2 - exit 1 - fi - - return 0 - fi - - local root_needs_write="false" - if [[ ! -f "$ROOT_ENV" ]]; then - root_needs_write="true" - echo "Creating root storefront configuration at .env." - elif is_missing_required_value "$(read_env_value STOREFRONT_DOMAIN "$ROOT_ENV")" || - is_missing_required_value "$(read_env_value STOREFRONT_ACCESS_TOKEN "$ROOT_ENV")"; then - root_needs_write="true" - echo "Updating root storefront configuration at .env." - elif ! root_has_canonical_keys; then - root_needs_write="true" - echo "Normalizing root storefront configuration at .env." - elif customer_account_api_version_needs_default; then - root_needs_write="true" - echo "Normalizing root storefront configuration at .env." - fi - - # Resolved values carry .env.local overrides, so writing them back would make an - # override permanent. Leave .env alone and say why instead. - if [[ -f "$ROOT_ENV_LOCAL" ]]; then - if [[ "$root_needs_write" == "true" ]]; then - echo "Leaving .env alone because .env.local exists. Remove .env.local to let .env be rewritten." - fi - - load_values - return 0 - fi - - if [[ "$root_needs_write" == "true" ]]; then - collect_missing_values - generate_root_env >"$ROOT_ENV" - elif fill_optional_values_from_environment; then - echo "Updating optional storefront configuration at .env." - generate_root_env >"$ROOT_ENV" - elif optional_values_need_prompt; then - echo "Updating optional storefront configuration at .env." - collect_missing_values - generate_root_env >"$ROOT_ENV" - fi - - load_values -} - write_generated_files() { generate_android_env >"$ANDROID_ENV" generate_swift_demo_xcconfig >"$SWIFT_DEMO_XCCONFIG" @@ -859,10 +608,11 @@ check_generated_files() { } report_local_overrides -ensure_root_env +load_values if [[ "$mode" == "check" ]]; then check_generated_files else + report_missing_required_values write_generated_files fi diff --git a/scripts/test/secrets_edit_test.rb b/scripts/test/secrets_edit_test.rb index b60b077f1..38e3fd1c5 100644 --- a/scripts/test/secrets_edit_test.rb +++ b/scripts/test/secrets_edit_test.rb @@ -149,6 +149,38 @@ def test_an_unknown_subcommand_names_itself_and_shows_help assert_includes out, "dev secrets edit" end + # An edit is only useful once it reaches the sample apps, so secrets_edit runs + # generate_env_files itself. These two tests use a throwaway repository tree, so + # the regeneration cannot touch the real .env. + def test_editing_a_secrets_file_regenerates_the_env_files + skip "ejson2env is not installed" unless system("command -v ejson2env >/dev/null 2>&1") + + root = fake_repo_root + out, status = run_in(root, %w[API_VERSION=2026-10]) + + assert_equal 0, status, "secrets_edit failed:\n#{out}" + assert_path_exists File.join(root, ".env") + assert_includes File.read(File.join(root, ".env")), "API_VERSION=2026-10" + end + + def test_a_failed_regeneration_still_keeps_the_edit + root = fake_repo_root + out, status = run_in(root, %w[API_VERSION=2026-10], path: ejson_only_path) + + assert_equal 0, status, "an edit that cannot be applied locally must still be committable:\n#{out}" + assert_equal "2026-10", decrypt(File.join(root, "config", "secrets", "demo.ejson")).fetch("API_VERSION") + refute_path_exists File.join(root, ".env") + end + + # The real .env lives beside the real config/secrets. Editing an .ejson file + # anywhere else must not rewrite it. + def test_editing_a_file_outside_config_secrets_leaves_the_env_files_alone + out, = edit(%w[API_VERSION=2026-10]) + + refute_includes out, "generate_env_files" + refute_path_exists File.join(@dir, ".env") + end + def test_an_unknown_name_is_rejected out, status = run_script(editor: setting_editor([]), target: "nope") @@ -199,6 +231,39 @@ def run_script(editor:, target: @path, keydir: @keydir) [out, status.exitstatus] end + # A throwaway copy of the parts of the repository that secrets_edit reaches, so + # the regeneration it triggers writes into the temporary tree instead of here. + def fake_repo_root + root = File.join(@dir, "repo") + FileUtils.mkdir_p(File.join(root, "scripts")) + FileUtils.mkdir_p(File.join(root, "config", "secrets")) + + FileUtils.cp(SCRIPT, File.join(root, "scripts")) + FileUtils.cp(File.join(REPO_ROOT, "scripts", "generate_env_files"), File.join(root, "scripts")) + FileUtils.cp_r(File.join(REPO_ROOT, "scripts", "lib"), File.join(root, "scripts")) + FileUtils.cp(@path, File.join(root, "config", "secrets", "demo.ejson")) + + root + end + + def run_in(root, assignments, path: nil) + env = {"EJSON_KEYDIR" => @keydir, "EDITOR" => setting_editor(assignments), "NO_COLOR" => "1"} + env["PATH"] = path if path + target = File.join(root, "config", "secrets", "demo.ejson") + out, status = Open3.capture2e(env, File.join(root, "scripts", "secrets_edit"), "edit", target, chdir: root) + [out, status.exitstatus] + end + + # Enough to run the edit but not the regeneration, so the two failures stay + # distinguishable without stubbing either binary. + def ejson_only_path + bin = File.join(@dir, "bin") + FileUtils.mkdir_p(bin) + FileUtils.ln_s(`command -v ejson`.strip, File.join(bin, "ejson")) unless File.exist?(File.join(bin, "ejson")) + + "#{bin}:/usr/bin:/bin" + end + def setting_editor(assignments) fake_editor(<<~RUBY) values = #{assignments.inspect}.to_h { |pair| pair.split("=", 2) } @@ -254,8 +319,8 @@ def write_encrypted(environment) raise "encrypt failed: #{out}" unless status.success? end - def decrypt - out, status = Open3.capture2e({"EJSON_KEYDIR" => @keydir}, "ejson", "decrypt", @path) + def decrypt(path = @path) + out, status = Open3.capture2e({"EJSON_KEYDIR" => @keydir}, "ejson", "decrypt", path) raise "decrypt failed: #{out}" unless status.success? JSON.parse(out).fetch("environment") diff --git a/scripts/test_generate_env_files b/scripts/test_generate_env_files new file mode 100755 index 000000000..91254fa50 --- /dev/null +++ b/scripts/test_generate_env_files @@ -0,0 +1,335 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Synthetic tests for scripts/generate_env_files and scripts/migrate_env_to_local. +# +# Every fixture gets its own ejson keypair in its own keydir, so these tests +# exercise the real ejson and ejson2env binaries without ever touching the +# repository keys or the committed ciphertext. +# +# Values here are synthetic. assert_output_is_sanitized fails the suite if any of +# them reaches the command output, because these scripts must report key names +# and paths only. + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Holds enough of the platform to run the scripts, but not enough to find the +# real ejson2env, which is how the missing-binary path gets tested. +MINIMAL_PATH="/usr/bin:/bin" + +fixtures=() + +cleanup() { + local fixture + if [[ "${#fixtures[@]}" -eq 0 ]]; then + return + fi + + for fixture in "${fixtures[@]}"; do + rm -rf "$fixture" + done +} + +trap cleanup EXIT + +fail() { + echo "test_generate_env_files: $1" >&2 + exit 1 +} + +require_binary() { + command -v "$1" >/dev/null 2>&1 || + fail "$1 is not installed, so these tests cannot run. Run \`dev up\`." +} + +make_fixture() { + local fixture public_key + fixture="$(mktemp -d "${TMPDIR:-/tmp}/checkout-kit-generate-env.XXXXXX")" + fixtures+=("$fixture") + + mkdir -p "$fixture/scripts" "$fixture/config/secrets" "$fixture/e2e" "$fixture/keys" + + cp "$REPO_ROOT/scripts/generate_env_files" "$fixture/scripts/generate_env_files" + cp "$REPO_ROOT/scripts/migrate_env_to_local" "$fixture/scripts/migrate_env_to_local" + chmod +x "$fixture/scripts/generate_env_files" "$fixture/scripts/migrate_env_to_local" + + public_key="$(EJSON_KEYDIR="$fixture/keys" ejson keygen -w)" + + write_secrets_file "$fixture/config/secrets/demo.ejson" "$public_key" \ + STOREFRONT_DOMAIN synthetic-store.example.myshopify.com \ + STOREFRONT_ACCESS_TOKEN synthetic-token + write_secrets_file "$fixture/config/secrets/e2e.ejson" "$public_key" \ + STOREFRONT_DOMAIN synthetic-e2e-store.example.myshopify.com \ + E2E_CUSTOMER_ACCOUNT_EMAIL synthetic-e2e@example.com + + printf '%s\n' "$fixture" +} + +write_secrets_file() { + local path="$1" + local public_key="$2" + shift 2 + + { + printf '{\n' + printf ' "_public_key": "%s",\n' "$public_key" + printf ' "_description": "Synthetic fixture.",\n' + printf ' "environment": {\n' + while [[ $# -gt 0 ]]; do + printf ' "%s": "%s"' "$1" "$2" + shift 2 + [[ $# -eq 0 ]] || printf ',' + printf '\n' + done + printf ' }\n' + printf '}\n' + } >"$path" + + ejson encrypt "$path" >/dev/null +} + +run_generate() { + local fixture="$1" + shift + + EJSON_KEYDIR="$fixture/keys" "$fixture/scripts/generate_env_files" "$@" +} + +assert_file_exists() { + [[ -f "$1" ]] || fail "expected file is missing: $1" +} + +assert_file_absent() { + [[ ! -e "$1" ]] || fail "file must not exist: $1" +} + +assert_contains() { + local path="$1" + local pattern="$2" + + grep -Fq "$pattern" "$path" || fail "expected content was not found: $path: $pattern" +} + +assert_not_contains() { + local path="$1" + local pattern="$2" + + if grep -Fq "$pattern" "$path"; then + fail "unexpected content was found: $path: $pattern" + fi +} + +assert_files_match() { + cmp -s "$1" "$2" || fail "files differ but must be byte-identical: $1 $2" +} + +assert_output_is_sanitized() { + local output_path="$1" + local value + + for value in \ + synthetic-store.example.myshopify.com \ + synthetic-token \ + synthetic-e2e-store.example.myshopify.com \ + synthetic-e2e@example.com \ + hand-written-store.example.myshopify.com; do + if grep -Fq "$value" "$output_path"; then + fail "command output included a configured value" + fi + done +} + +test_generates_both_env_files() { + local fixture output + fixture="$(make_fixture)" + output="$fixture/output.log" + + run_generate "$fixture" >"$output" 2>&1 + + assert_output_is_sanitized "$output" + assert_file_exists "$fixture/.env" + assert_file_exists "$fixture/e2e/.env" + + assert_contains "$fixture/.env" "STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com" + assert_contains "$fixture/.env" "STOREFRONT_ACCESS_TOKEN=synthetic-token" + assert_contains "$fixture/e2e/.env" "E2E_CUSTOMER_ACCOUNT_EMAIL=synthetic-e2e@example.com" + + # Each file must say it is generated, or someone edits it and loses the change. + assert_contains "$fixture/.env" "dev secrets edit demo" + assert_contains "$fixture/e2e/.env" "dev secrets edit e2e" + + # ejson2env reads the environment object only, so the metadata keys stay behind. + assert_not_contains "$fixture/.env" "_public_key" + assert_not_contains "$fixture/.env" "Synthetic fixture." +} + +test_check_passes_when_current_and_fails_when_stale() { + local fixture output + fixture="$(make_fixture)" + output="$fixture/output.log" + + run_generate "$fixture" >"$output" 2>&1 + + run_generate "$fixture" --check >"$output" 2>&1 || + fail "--check failed straight after a successful generate" + assert_output_is_sanitized "$output" + + printf '%s\n' "STOREFRONT_DOMAIN=stale" >"$fixture/.env" + + if run_generate "$fixture" --check >"$output" 2>&1; then + fail "--check passed with a stale .env" + fi + assert_output_is_sanitized "$output" +} + +test_generating_twice_leaves_the_file_byte_identical() { + local fixture output first + fixture="$(make_fixture)" + output="$fixture/output.log" + first="$fixture/first" + + run_generate "$fixture" >"$output" 2>&1 + cp "$fixture/.env" "$first" + + run_generate "$fixture" >"$output" 2>&1 + assert_files_match "$fixture/.env" "$first" +} + +test_missing_ejson2env_fails_and_names_dev_up() { + local fixture output + fixture="$(make_fixture)" + output="$fixture/output.log" + + if PATH="$MINIMAL_PATH" run_generate "$fixture" >"$output" 2>&1; then + fail "generate_env_files succeeded without ejson2env installed" + fi + + assert_output_is_sanitized "$output" + assert_contains "$output" "dev up" + assert_file_absent "$fixture/.env" +} + +test_missing_private_key_skips_without_failing() { + local fixture output + fixture="$(make_fixture)" + output="$fixture/output.log" + + rm -f "$fixture"/keys/* + + run_generate "$fixture" >"$output" 2>&1 || + fail "a missing private key must not fail; it is a supported state" + + assert_output_is_sanitized "$output" + assert_contains "$output" "no private key" + assert_file_absent "$fixture/.env" +} + +test_missing_private_key_passes_check() { + local fixture output + fixture="$(make_fixture)" + output="$fixture/output.log" + + rm -f "$fixture"/keys/* + + run_generate "$fixture" --check >"$output" 2>&1 || + fail "--check must pass when no key is installed, so dev up still completes" + assert_output_is_sanitized "$output" +} + +test_failed_decrypt_leaves_an_existing_env_file_intact() { + local fixture output before + fixture="$(make_fixture)" + output="$fixture/output.log" + before="$fixture/before" + + printf 'STOREFRONT_DOMAIN=%s\n' hand-written-store.example.myshopify.com >"$fixture/.env" + cp "$fixture/.env" "$before" + + rm -f "$fixture"/keys/* + run_generate "$fixture" >"$output" 2>&1 || true + + assert_output_is_sanitized "$output" + assert_files_match "$fixture/.env" "$before" +} + +test_migrate_copies_root_env_to_env_local_once() { + local fixture output after_first + fixture="$(make_fixture)" + output="$fixture/output.log" + after_first="$fixture/after_first" + + printf 'STOREFRONT_DOMAIN=%s\n' hand-written-store.example.myshopify.com >"$fixture/.env" + + "$fixture/scripts/migrate_env_to_local" >"$output" 2>&1 + assert_output_is_sanitized "$output" + assert_file_exists "$fixture/.env.local" + assert_files_match "$fixture/.env.local" "$fixture/.env" + cp "$fixture/.env.local" "$after_first" + + # A second run must be a no-op, because dev up runs this on every invocation. + "$fixture/scripts/migrate_env_to_local" >"$output" 2>&1 + assert_output_is_sanitized "$output" + assert_files_match "$fixture/.env.local" "$after_first" +} + +test_migrate_never_overwrites_an_existing_env_local() { + local fixture output before + fixture="$(make_fixture)" + output="$fixture/output.log" + before="$fixture/before" + + printf 'STOREFRONT_DOMAIN=%s\n' hand-written-store.example.myshopify.com >"$fixture/.env" + printf 'STOREFRONT_DOMAIN=%s\n' synthetic-store.example.myshopify.com >"$fixture/.env.local" + cp "$fixture/.env.local" "$before" + + "$fixture/scripts/migrate_env_to_local" >"$output" 2>&1 + + assert_output_is_sanitized "$output" + assert_files_match "$fixture/.env.local" "$before" +} + +test_migrate_does_nothing_without_a_root_env() { + local fixture output + fixture="$(make_fixture)" + output="$fixture/output.log" + + "$fixture/scripts/migrate_env_to_local" >"$output" 2>&1 + + assert_output_is_sanitized "$output" + assert_file_absent "$fixture/.env.local" +} + +test_migrate_check_reports_whether_a_copy_is_needed() { + local fixture output + fixture="$(make_fixture)" + output="$fixture/output.log" + + "$fixture/scripts/migrate_env_to_local" --check >"$output" 2>&1 || + fail "--check must pass when there is no .env to migrate" + + printf 'STOREFRONT_DOMAIN=%s\n' hand-written-store.example.myshopify.com >"$fixture/.env" + + if "$fixture/scripts/migrate_env_to_local" --check >"$output" 2>&1; then + fail "--check passed while a migration was still pending" + fi + assert_output_is_sanitized "$output" + assert_file_absent "$fixture/.env.local" +} + +require_binary ejson +require_binary ejson2env + +test_generates_both_env_files +test_check_passes_when_current_and_fails_when_stale +test_generating_twice_leaves_the_file_byte_identical +test_missing_ejson2env_fails_and_names_dev_up +test_missing_private_key_skips_without_failing +test_missing_private_key_passes_check +test_failed_decrypt_leaves_an_existing_env_file_intact +test_migrate_copies_root_env_to_env_local_once +test_migrate_never_overwrites_an_existing_env_local +test_migrate_does_nothing_without_a_root_env +test_migrate_check_reports_whether_a_copy_is_needed + +echo "generate_env_files synthetic tests passed." diff --git a/scripts/test_setup_storefront_env b/scripts/test_setup_storefront_env index 5f917a212..9850d8840 100755 --- a/scripts/test_setup_storefront_env +++ b/scripts/test_setup_storefront_env @@ -2,6 +2,16 @@ set -euo pipefail +# Synthetic tests for scripts/setup_storefront_env. +# +# The central guarantee is that .env is only ever read. Shopify employees get it +# from scripts/generate_env_files; everyone else writes it by hand. Either way a +# run here must leave it byte-identical, so most tests below assert that as well +# as the generated output. +# +# Values here are synthetic. assert_output_is_sanitized fails the suite if any of +# them reaches the command output, because this script reports paths only. + REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" fixtures=() @@ -42,8 +52,32 @@ make_fixture() { printf '%s\n' "$fixture" } +android_env_of() { + printf '%s\n' "$1/platforms/android/samples/CheckoutKitAndroidDemo/.env" +} + +react_native_env_of() { + printf '%s\n' "$1/platforms/react-native/sample/.env" +} + +react_native_xcconfig_of() { + printf '%s\n' "$1/platforms/react-native/sample/ios/Storefront.xcconfig" +} + +swift_demo_xcconfig_of() { + printf '%s\n' "$1/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig" +} + +swift_accelerated_xcconfig_of() { + printf '%s\n' "$1/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/Storefront.xcconfig" +} + assert_file_exists() { - [[ -f "$1" ]] || fail "expected generated file is missing" + [[ -f "$1" ]] || fail "expected generated file is missing: $1" +} + +assert_file_absent() { + [[ ! -e "$1" ]] || fail "file must not be created: $1" } assert_contains() { @@ -58,7 +92,7 @@ assert_not_contains() { local pattern="$2" if grep -Fq -- "$pattern" "$path"; then - fail "unexpected generated file content was found" + fail "unexpected generated file content was found: $path: $pattern" fi } @@ -69,7 +103,8 @@ write_canonical_root_env() { cat >"$path" <"$output" 2>&1 + write_canonical_root_env "$fixture/.env" synthetic-store.example.myshopify.com + cp "$fixture/.env" "$root_before" + + "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 assert_output_is_sanitized "$output" - assert_contains "$fixture/.env" \ - "REACT_NATIVE_APPLE_PAY_MERCHANT_IDENTIFIER=synthetic-react-native-merchant" - assert_contains "$fixture/.env" \ - "SWIFT_APPLE_PAY_MERCHANT_IDENTIFIER=synthetic-swift-merchant" - assert_contains "$fixture/platforms/react-native/sample/.env" \ + assert_file_is_unchanged "$fixture/.env" "$root_before" + assert_contains "$(react_native_env_of "$fixture")" \ 'APPLE_PAY_MERCHANT_IDENTIFIER="synthetic-react-native-merchant"' - assert_contains "$react_native_xcconfig" \ + assert_contains "$(react_native_xcconfig_of "$fixture")" \ "APPLE_PAY_MERCHANT_IDENTIFIER = synthetic-react-native-merchant" - assert_contains "$swift_xcconfig" \ + assert_contains "$(swift_demo_xcconfig_of "$fixture")" \ "APPLE_PAY_MERCHANT_IDENTIFIER = synthetic-swift-merchant" } @@ -178,276 +210,258 @@ test_sample_projects_read_generated_merchant_identifier() { } test_sync_and_check() { - local fixture output + local fixture output root_before fixture="$(make_fixture)" output="$fixture/output.log" + root_before="$fixture/root_before" - STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com \ - STOREFRONT_ACCESS_TOKEN=synthetic-token \ - REACT_NATIVE_APPLE_PAY_MERCHANT_IDENTIFIER=synthetic-react-native-merchant \ - SWIFT_APPLE_PAY_MERCHANT_IDENTIFIER=synthetic-swift-merchant \ - CUSTOMER_ACCOUNT_API_CLIENT_ID=synthetic-client \ - CUSTOMER_ACCOUNT_API_SHOP_ID=synthetic-shop \ - "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 + write_canonical_root_env "$fixture/.env" synthetic-store.example.myshopify.com + cp "$fixture/.env" "$root_before" + + "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 assert_output_is_sanitized "$output" - assert_file_exists "$fixture/.env" - assert_file_exists "$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" - assert_file_exists "$fixture/platforms/react-native/sample/.env" - assert_file_exists "$fixture/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig" - assert_file_exists "$fixture/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/Storefront.xcconfig" - assert_not_contains "$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" "APPLE_PAY_MERCHANT_IDENTIFIER" - assert_not_contains "$fixture/platforms/react-native/sample/.env" "CUSTOMER_ACCOUNT_API_VERSION" - assert_not_contains "$fixture/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig" "CUSTOMER_ACCOUNT_API_VERSION" - assert_not_contains "$fixture/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/Storefront.xcconfig" "APPLE_PAY_MERCHANT_IDENTIFIER" - assert_not_contains "$fixture/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/Storefront.xcconfig" "CUSTOMER_ACCOUNT_API_CLIENT_ID" - assert_not_contains "$fixture/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/Storefront.xcconfig" "EMAIL" + assert_file_is_unchanged "$fixture/.env" "$root_before" + assert_file_exists "$(android_env_of "$fixture")" + assert_file_exists "$(react_native_env_of "$fixture")" + assert_file_exists "$(react_native_xcconfig_of "$fixture")" + assert_file_exists "$(swift_demo_xcconfig_of "$fixture")" + assert_file_exists "$(swift_accelerated_xcconfig_of "$fixture")" + + assert_not_contains "$(react_native_env_of "$fixture")" "CUSTOMER_ACCOUNT_API_VERSION" + assert_not_contains "$(swift_demo_xcconfig_of "$fixture")" "CUSTOMER_ACCOUNT_API_VERSION" + assert_not_contains "$(swift_accelerated_xcconfig_of "$fixture")" "CUSTOMER_ACCOUNT_API_VERSION" + assert_not_contains "$(swift_accelerated_xcconfig_of "$fixture")" "CUSTOMER_ACCOUNT_API_CLIENT_ID" + assert_not_contains "$(swift_accelerated_xcconfig_of "$fixture")" "EMAIL" "$fixture/scripts/setup_storefront_env" --check >"$output" 2>&1 assert_output_is_sanitized "$output" + # A changed .env must reach every generated file on the next run. awk ' /^API_VERSION=/ { print "API_VERSION=2026-01"; next } { print } ' "$fixture/.env" >"$fixture/.env.next" mv "$fixture/.env.next" "$fixture/.env" + cp "$fixture/.env" "$root_before" "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 assert_output_is_sanitized "$output" - assert_contains "$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" "API_VERSION=2026-01" - assert_contains "$fixture/platforms/react-native/sample/.env" "API_VERSION=\"2026-01\"" - assert_contains "$fixture/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig" "API_VERSION = 2026-01" - assert_contains "$fixture/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/Storefront.xcconfig" "API_VERSION = 2026-01" + assert_file_is_unchanged "$fixture/.env" "$root_before" + assert_contains "$(android_env_of "$fixture")" "API_VERSION=2026-01" + assert_contains "$(react_native_env_of "$fixture")" "API_VERSION=\"2026-01\"" + assert_contains "$(swift_demo_xcconfig_of "$fixture")" "API_VERSION = 2026-01" + assert_contains "$(swift_accelerated_xcconfig_of "$fixture")" "API_VERSION = 2026-01" - printf '%s\n' "stale" >"$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" + printf '%s\n' "stale" >"$(android_env_of "$fixture")" if "$fixture/scripts/setup_storefront_env" --check >"$output" 2>&1; then fail "stale generated config passed --check" fi assert_output_is_sanitized "$output" } -test_required_values_only() { - local fixture output +# The core guarantee of this phase. scripts/generate_env_files owns .env now, so a +# sync that rewrote it would either destroy a hand-written file or bake in a +# .env.local override permanently. +test_root_env_is_never_written() { + local fixture output root_before fixture="$(make_fixture)" output="$fixture/output.log" + root_before="$fixture/root_before" - STOREFRONT_DOMAIN=https://synthetic-store.example.myshopify.com/ \ - STOREFRONT_ACCESS_TOKEN=synthetic-token \ - "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + # Deliberately non-canonical: two keys only, no trailing buyer identity block. + # The old script rewrote .env in exactly this case, so this fixture is what + # makes the assertion bite. + cat >"$fixture/.env" <<'EOF' +STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com +STOREFRONT_ACCESS_TOKEN=synthetic-token +EOF + cp "$fixture/.env" "$root_before" - assert_output_is_sanitized "$output" - assert_file_exists "$fixture/.env" - assert_contains "$fixture/.env" "STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com" - assert_contains "$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" "STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com" - assert_contains "$fixture/platforms/react-native/sample/.env" "STOREFRONT_DOMAIN=\"synthetic-store.example.myshopify.com\"" - assert_contains "$fixture/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig" "STOREFRONT_DOMAIN = synthetic-store.example.myshopify.com" - assert_contains "$fixture/.env" "REACT_NATIVE_APPLE_PAY_MERCHANT_IDENTIFIER=" - assert_contains "$fixture/.env" "SWIFT_APPLE_PAY_MERCHANT_IDENTIFIER=" - assert_contains "$fixture/.env" "CUSTOMER_ACCOUNT_API_CLIENT_ID=" - assert_contains "$fixture/.env" "CUSTOMER_ACCOUNT_API_SHOP_ID=" - assert_contains "$fixture/.env" "CUSTOMER_ACCOUNT_API_VERSION=2026-04" - assert_contains "$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" "CUSTOMER_ACCOUNT_API_VERSION=2026-04" - assert_not_contains "$fixture/platforms/react-native/sample/.env" "CUSTOMER_ACCOUNT_API_VERSION" - assert_not_contains "$fixture/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig" "CUSTOMER_ACCOUNT_API_VERSION" - assert_not_contains "$fixture/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/Storefront.xcconfig" "CUSTOMER_ACCOUNT_API_VERSION" + CUSTOMER_ACCOUNT_API_CLIENT_ID=synthetic-client \ + CUSTOMER_ACCOUNT_API_SHOP_ID=synthetic-shop \ + "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 - "$fixture/scripts/setup_storefront_env" --check >"$output" 2>&1 assert_output_is_sanitized "$output" + assert_file_is_unchanged "$fixture/.env" "$root_before" + + # The generated files are still complete, from defaults and the process env. + assert_contains "$(android_env_of "$fixture")" "CUSTOMER_ACCOUNT_API_VERSION=2026-04" + assert_contains "$(android_env_of "$fixture")" "CUSTOMER_ACCOUNT_API_CLIENT_ID=synthetic-client" + + "$fixture/scripts/setup_storefront_env" --check >"$output" 2>&1 || + fail "--check failed for a sparse .env that had just been synced" } -test_optional_sync_updates_blank_optional_values_after_required_setup() { +test_absent_root_env_is_not_created() { local fixture output fixture="$(make_fixture)" output="$fixture/output.log" STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com \ STOREFRONT_ACCESS_TOKEN=synthetic-token \ - "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 - - assert_output_is_sanitized "$output" - assert_contains "$fixture/.env" "REACT_NATIVE_APPLE_PAY_MERCHANT_IDENTIFIER=" - assert_contains "$fixture/.env" "SWIFT_APPLE_PAY_MERCHANT_IDENTIFIER=" - assert_contains "$fixture/.env" "CUSTOMER_ACCOUNT_API_CLIENT_ID=" - assert_contains "$fixture/.env" "CUSTOMER_ACCOUNT_API_SHOP_ID=" - - REACT_NATIVE_APPLE_PAY_MERCHANT_IDENTIFIER=synthetic-react-native-merchant \ - SWIFT_APPLE_PAY_MERCHANT_IDENTIFIER=synthetic-swift-merchant \ - CUSTOMER_ACCOUNT_API_CLIENT_ID=synthetic-client \ - CUSTOMER_ACCOUNT_API_SHOP_ID=synthetic-shop \ "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 assert_output_is_sanitized "$output" - assert_contains "$fixture/.env" "REACT_NATIVE_APPLE_PAY_MERCHANT_IDENTIFIER=synthetic-react-native-merchant" - assert_contains "$fixture/.env" "SWIFT_APPLE_PAY_MERCHANT_IDENTIFIER=synthetic-swift-merchant" - assert_contains "$fixture/.env" "CUSTOMER_ACCOUNT_API_CLIENT_ID=synthetic-client" - assert_contains "$fixture/.env" "CUSTOMER_ACCOUNT_API_SHOP_ID=synthetic-shop" - assert_contains "$fixture/platforms/react-native/sample/.env" "APPLE_PAY_MERCHANT_IDENTIFIER=\"synthetic-react-native-merchant\"" - assert_contains "$fixture/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig" "CUSTOMER_ACCOUNT_API_CLIENT_ID = synthetic-client" - assert_contains "$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" "CUSTOMER_ACCOUNT_API_REDIRECT_URI=shop.synthetic-shop.app://callback" + assert_file_absent "$fixture/.env" + assert_contains "$(android_env_of "$fixture")" "STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com" } -test_shared_merchant_identifier_migrates_to_app_specific_keys() { - local fixture output +# The external contributor: no dev, no ejson, no key, a .env written by hand from +# .env.example. Nothing may overwrite it, and the sample apps must still build. +test_hand_written_root_env_survives_and_still_configures_the_samples() { + local fixture output root_before fixture="$(make_fixture)" output="$fixture/output.log" + root_before="$fixture/root_before" cat >"$fixture/.env" <<'EOF' -STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com -STOREFRONT_ACCESS_TOKEN=synthetic-token -STOREFRONT_MERCHANT_IDENTIFIER=synthetic-merchant +# My own store. Written by hand from .env.example. +STOREFRONT_DOMAIN=hand-written-store.example.myshopify.com +STOREFRONT_ACCESS_TOKEN=hand-written-token EOF + cp "$fixture/.env" "$root_before" - "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + PATH="/usr/bin:/bin" "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 assert_output_is_sanitized "$output" + assert_file_is_unchanged "$fixture/.env" "$root_before" - assert_not_contains "$fixture/.env" "STOREFRONT_MERCHANT_IDENTIFIER=" - assert_contains "$fixture/.env" \ - "REACT_NATIVE_APPLE_PAY_MERCHANT_IDENTIFIER=synthetic-merchant" - assert_contains "$fixture/.env" \ - "SWIFT_APPLE_PAY_MERCHANT_IDENTIFIER=synthetic-merchant" - assert_contains "$fixture/platforms/react-native/sample/.env" \ - 'APPLE_PAY_MERCHANT_IDENTIFIER="synthetic-merchant"' - assert_contains "$fixture/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig" \ - "APPLE_PAY_MERCHANT_IDENTIFIER = synthetic-merchant" + assert_contains "$(android_env_of "$fixture")" "STOREFRONT_DOMAIN=hand-written-store.example.myshopify.com" + assert_contains "$(react_native_env_of "$fixture")" "STOREFRONT_ACCESS_TOKEN=\"hand-written-token\"" + assert_contains "$(swift_demo_xcconfig_of "$fixture")" "STOREFRONT_DOMAIN = hand-written-store.example.myshopify.com" + + PATH="/usr/bin:/bin" "$fixture/scripts/setup_storefront_env" --check >"$output" 2>&1 || + fail "--check failed for a hand-written .env that had just been synced" + assert_file_is_unchanged "$fixture/.env" "$root_before" } -test_normalizes_existing_root_env() { +# Reporting a missing value must not abort. The generated files still get written, +# so the sample app builds and fails at its first network call instead. +test_missing_required_values_are_reported_without_writing_root_env() { local fixture output fixture="$(make_fixture)" output="$fixture/output.log" - cat >"$fixture/.env" <<'EOF' -STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com -STOREFRONT_ACCESS_TOKEN=synthetic-token -EOF - - if "$fixture/scripts/setup_storefront_env" --check >"$output" 2>&1; then - fail "non-canonical root .env passed --check" - fi - assert_output_is_sanitized "$output" - - "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 - assert_output_is_sanitized "$output" - - assert_contains "$fixture/.env" "CUSTOMER_ACCOUNT_API_VERSION=2026-04" - assert_contains "$fixture/.env" "EMAIL=checkout-kit@example.com" + "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 - "$fixture/scripts/setup_storefront_env" --check >"$output" 2>&1 assert_output_is_sanitized "$output" + assert_file_absent "$fixture/.env" + assert_contains "$output" "No usable STOREFRONT_DOMAIN" + assert_contains "$output" "No usable STOREFRONT_ACCESS_TOKEN" + assert_contains "$output" ".env.local" + assert_file_exists "$(android_env_of "$fixture")" } -test_blank_customer_account_api_version_defaults() { +test_normalizes_the_storefront_domain() { local fixture output fixture="$(make_fixture)" output="$fixture/output.log" - cat >"$fixture/.env" <<'EOF' -STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com -STOREFRONT_ACCESS_TOKEN=synthetic-token -REACT_NATIVE_APPLE_PAY_MERCHANT_IDENTIFIER= -SWIFT_APPLE_PAY_MERCHANT_IDENTIFIER= -API_VERSION=2026-04 -CUSTOMER_ACCOUNT_API_CLIENT_ID=synthetic-client -CUSTOMER_ACCOUNT_API_SHOP_ID=synthetic-shop -CUSTOMER_ACCOUNT_API_VERSION= -EMAIL=checkout-kit@example.com -ADDRESS_1=650 King Street -ADDRESS_2=Shopify HQ -CITY=Toronto -COMPANY=Shopify -COUNTRY=CA -FIRST_NAME=Evelyn -LAST_NAME=Hartley -PROVINCE=ON -ZIP=M5V 1M7 -PHONE=+14165550100 -EOF + STOREFRONT_DOMAIN=https://synthetic-store.example.myshopify.com/ \ + STOREFRONT_ACCESS_TOKEN=synthetic-token \ + "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 - "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 assert_output_is_sanitized "$output" + assert_contains "$(android_env_of "$fixture")" "STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com" + assert_contains "$(react_native_env_of "$fixture")" "STOREFRONT_DOMAIN=\"synthetic-store.example.myshopify.com\"" + assert_contains "$(swift_demo_xcconfig_of "$fixture")" "STOREFRONT_DOMAIN = synthetic-store.example.myshopify.com" + assert_contains "$(android_env_of "$fixture")" "CUSTOMER_ACCOUNT_API_VERSION=2026-04" + assert_not_contains "$(react_native_env_of "$fixture")" "CUSTOMER_ACCOUNT_API_VERSION" - assert_contains "$fixture/.env" "CUSTOMER_ACCOUNT_API_VERSION=2026-04" - assert_contains "$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" "CUSTOMER_ACCOUNT_API_VERSION=2026-04" - assert_contains "$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" "/2026-04/graphql" + "$fixture/scripts/setup_storefront_env" --check >"$output" 2>&1 + assert_output_is_sanitized "$output" } -# project.yml reads $(DEVELOPMENT_TEAM) from the generated xcconfig. Setup applies no -# default team, so a clone with no DEVELOPMENT_TEAM in .env gets a blank one and fails at -# signing. A value in .env has to win, and clearing that value has to clear the xcconfig -# again rather than recover the previous team from the generated file. -test_development_team_follows_env_and_clears() { - local fixture output demo_xcconfig accelerated_xcconfig +test_process_environment_fills_blank_optional_values() { + local fixture output root_before fixture="$(make_fixture)" output="$fixture/output.log" - demo_xcconfig="$fixture/platforms/swift/Samples/CheckoutKitSwiftDemo/Storefront.xcconfig" - accelerated_xcconfig="$fixture/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/Storefront.xcconfig" - - STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com \ - STOREFRONT_ACCESS_TOKEN=synthetic-token \ - "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 - - assert_output_is_sanitized "$output" - assert_contains "$demo_xcconfig" "DEVELOPMENT_TEAM =" - assert_not_contains "$demo_xcconfig" "DEVELOPMENT_TEAM = A7XGC83MZE" - # .env has to carry the key as well, or the next normalizing rewrite drops the override. - assert_contains "$fixture/.env" "DEVELOPMENT_TEAM=" + root_before="$fixture/root_before" + write_canonical_root_env "$fixture/.env" synthetic-store.example.myshopify.com awk ' - /^DEVELOPMENT_TEAM=/ { print "DEVELOPMENT_TEAM=SYNTHETIC9"; next } + /^CUSTOMER_ACCOUNT_API_CLIENT_ID=/ { print "CUSTOMER_ACCOUNT_API_CLIENT_ID="; next } + /^CUSTOMER_ACCOUNT_API_SHOP_ID=/ { print "CUSTOMER_ACCOUNT_API_SHOP_ID="; next } { print } ' "$fixture/.env" >"$fixture/.env.next" mv "$fixture/.env.next" "$fixture/.env" + cp "$fixture/.env" "$root_before" + + CUSTOMER_ACCOUNT_API_CLIENT_ID=synthetic-client \ + CUSTOMER_ACCOUNT_API_SHOP_ID=synthetic-shop \ + "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 - "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 assert_output_is_sanitized "$output" - assert_contains "$demo_xcconfig" "DEVELOPMENT_TEAM = SYNTHETIC9" - assert_contains "$accelerated_xcconfig" "DEVELOPMENT_TEAM = SYNTHETIC9" - assert_contains "$fixture/.env" "DEVELOPMENT_TEAM=SYNTHETIC9" + assert_file_is_unchanged "$fixture/.env" "$root_before" + assert_contains "$(swift_demo_xcconfig_of "$fixture")" "CUSTOMER_ACCOUNT_API_CLIENT_ID = synthetic-client" + assert_contains "$(android_env_of "$fixture")" "CUSTOMER_ACCOUNT_API_REDIRECT_URI=shop.synthetic-shop.app://callback" +} +test_blank_customer_account_api_version_defaults() { + local fixture output android_env root_before + fixture="$(make_fixture)" + output="$fixture/output.log" + android_env="$(android_env_of "$fixture")" + root_before="$fixture/root_before" + + write_canonical_root_env "$fixture/.env" synthetic-store.example.myshopify.com awk ' - /^DEVELOPMENT_TEAM=/ { print "DEVELOPMENT_TEAM="; next } + /^CUSTOMER_ACCOUNT_API_VERSION=/ { print "CUSTOMER_ACCOUNT_API_VERSION="; next } { print } ' "$fixture/.env" >"$fixture/.env.next" mv "$fixture/.env.next" "$fixture/.env" + cp "$fixture/.env" "$root_before" - "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 assert_output_is_sanitized "$output" - assert_not_contains "$demo_xcconfig" "SYNTHETIC9" - assert_not_contains "$accelerated_xcconfig" "SYNTHETIC9" + + assert_file_is_unchanged "$fixture/.env" "$root_before" + assert_contains "$android_env" "CUSTOMER_ACCOUNT_API_VERSION=2026-04" + assert_contains "$android_env" "/2026-04/graphql" } -# A clone whose .env predates DEVELOPMENT_TEAM holds every other canonical key, so the -# normalizing rewrite has to notice the one absent key. Otherwise dev up leaves .env alone -# and the developer never sees the key they are meant to override. -test_development_team_is_added_to_an_existing_env() { - local fixture output accelerated_xcconfig +# project.yml reads $(DEVELOPMENT_TEAM) from the generated xcconfig. Setup applies no +# default team, so a clone whose .env omits the key gets a blank one and fails at signing. +# .env.local has to win so a second Apple team can build the sample, and dropping that +# override has to blank the xcconfig again rather than recover the team from it. +test_development_team_follows_env_and_clears() { + local fixture output demo_xcconfig accelerated_xcconfig fixture="$(make_fixture)" output="$fixture/output.log" - accelerated_xcconfig="$fixture/platforms/swift/Samples/ShopifyAcceleratedCheckoutsApp/Storefront.xcconfig" + demo_xcconfig="$(swift_demo_xcconfig_of "$fixture")" + accelerated_xcconfig="$(swift_accelerated_xcconfig_of "$fixture")" - STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com \ - STOREFRONT_ACCESS_TOKEN=synthetic-token \ - "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + write_canonical_root_env "$fixture/.env" synthetic-store.example.myshopify.com - grep -v '^DEVELOPMENT_TEAM=' "$fixture/.env" >"$fixture/.env.next" - mv "$fixture/.env.next" "$fixture/.env" + "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 + assert_output_is_sanitized "$output" + assert_contains "$demo_xcconfig" "DEVELOPMENT_TEAM =" + assert_contains "$accelerated_xcconfig" "DEVELOPMENT_TEAM =" + assert_not_contains "$demo_xcconfig" "DEVELOPMENT_TEAM = A7XGC83MZE" + assert_not_contains "$accelerated_xcconfig" "DEVELOPMENT_TEAM = A7XGC83MZE" - if "$fixture/scripts/setup_storefront_env" --check >"$output" 2>&1; then - fail "root .env without DEVELOPMENT_TEAM passed --check" - fi + printf 'DEVELOPMENT_TEAM=SYNTHETIC9\n' >"$fixture/.env.local" + + "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 assert_output_is_sanitized "$output" + assert_contains "$demo_xcconfig" "DEVELOPMENT_TEAM = SYNTHETIC9" + assert_contains "$accelerated_xcconfig" "DEVELOPMENT_TEAM = SYNTHETIC9" + + rm "$fixture/.env.local" - "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 assert_output_is_sanitized "$output" - assert_contains "$fixture/.env" "DEVELOPMENT_TEAM=" - assert_contains "$accelerated_xcconfig" "DEVELOPMENT_TEAM =" + assert_not_contains "$demo_xcconfig" "SYNTHETIC9" + assert_not_contains "$accelerated_xcconfig" "SYNTHETIC9" } -test_migration_from_platform_config() { +# Generated platform config is the last fallback, so a checkout that still has it +# but has lost .env rebuilds the same values instead of silently blanking them. +test_existing_platform_config_seeds_the_missing_values() { local fixture output android_env fixture="$(make_fixture)" output="$fixture/output.log" - android_env="$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" + android_env="$(android_env_of "$fixture")" cat >"$android_env" <<'EOF' STOREFRONT_DOMAIN=migrated-store.example.myshopify.com @@ -462,9 +476,11 @@ EOF "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 assert_output_is_sanitized "$output" - assert_file_exists "$fixture/.env" - assert_contains "$fixture/.env" "API_VERSION=2026-04" - assert_contains "$fixture/.env" "CUSTOMER_ACCOUNT_API_VERSION=2026-04" + assert_file_absent "$fixture/.env" + assert_contains "$android_env" "STOREFRONT_DOMAIN=migrated-store.example.myshopify.com" + assert_contains "$android_env" "API_VERSION=2026-04" + assert_contains "$android_env" "CUSTOMER_ACCOUNT_API_VERSION=2026-04" + assert_contains "$(swift_demo_xcconfig_of "$fixture")" "CUSTOMER_ACCOUNT_API_CLIENT_ID = migrated-client" "$fixture/scripts/setup_storefront_env" --check >"$output" 2>&1 assert_output_is_sanitized "$output" @@ -474,12 +490,12 @@ test_duplicate_keys_resolve_last_wins() { local fixture output android_env fixture="$(make_fixture)" output="$fixture/output.log" - android_env="$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" + android_env="$(android_env_of "$fixture")" write_canonical_root_env "$fixture/.env" synthetic-store.example.myshopify.com printf 'STOREFRONT_DOMAIN=%s\n' later-store.example.myshopify.com >>"$fixture/.env" - "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 assert_output_is_sanitized "$output" assert_contains "$android_env" "STOREFRONT_DOMAIN=later-store.example.myshopify.com" @@ -490,7 +506,7 @@ test_env_local_overrides_root_env() { local fixture output android_env root_before local_before fixture="$(make_fixture)" output="$fixture/output.log" - android_env="$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" + android_env="$(android_env_of "$fixture")" root_before="$fixture/root_before" local_before="$fixture/local_before" @@ -502,7 +518,7 @@ EOF cp "$fixture/.env" "$root_before" cp "$fixture/.env.local" "$local_before" - "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 assert_output_is_sanitized "$output" assert_contains "$android_env" "STOREFRONT_DOMAIN=overridden-store.example.myshopify.com" @@ -523,7 +539,7 @@ STOREFRONT_DOMAIN=overridden-store.example.myshopify.com STOREFRONT_ACCESS_TOKEN=overridden-token EOF - "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 assert_output_is_sanitized "$output" assert_contains "$output" ".env.local" @@ -532,6 +548,8 @@ EOF assert_not_contains "$output" "EMAIL" } +# An override must never be copied into .env, or it would outlive .env.local and +# then keep applying after that file is deleted. test_env_local_is_never_baked_into_root_env() { local fixture output root_before fixture="$(make_fixture)" @@ -545,29 +563,58 @@ EOF printf 'STOREFRONT_DOMAIN=%s\n' overridden-store.example.myshopify.com >"$fixture/.env.local" cp "$fixture/.env" "$root_before" - "$fixture/scripts/setup_storefront_env" --skip-optional-prompts >"$output" 2>&1 + "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 assert_output_is_sanitized "$output" assert_file_is_unchanged "$fixture/.env" "$root_before" - assert_contains "$output" "Leaving .env alone" - assert_contains "$fixture/platforms/android/samples/CheckoutKitAndroidDemo/.env" \ + assert_not_contains "$fixture/.env" "overridden-store.example.myshopify.com" + assert_contains "$(android_env_of "$fixture")" \ "STOREFRONT_DOMAIN=overridden-store.example.myshopify.com" } -test_merchant_identifier_propagates_to_sample_apps +test_legacy_merchant_identifier_configures_both_samples_without_rewriting_root_env() { + local fixture output root_before + fixture="$(make_fixture)" + output="$fixture/output.log" + root_before="$fixture/root_before" + + cat >"$fixture/.env" <<'EOF' +STOREFRONT_DOMAIN=synthetic-store.example.myshopify.com +STOREFRONT_ACCESS_TOKEN=synthetic-token +STOREFRONT_MERCHANT_IDENTIFIER=synthetic-merchant +EOF + cp "$fixture/.env" "$root_before" + + "$fixture/scripts/setup_storefront_env" >"$output" 2>&1 + + assert_output_is_sanitized "$output" + assert_file_is_unchanged "$fixture/.env" "$root_before" + assert_contains "$(react_native_env_of "$fixture")" \ + 'APPLE_PAY_MERCHANT_IDENTIFIER="synthetic-merchant"' + assert_contains "$(react_native_xcconfig_of "$fixture")" \ + "APPLE_PAY_MERCHANT_IDENTIFIER = synthetic-merchant" + assert_contains "$(swift_demo_xcconfig_of "$fixture")" \ + "APPLE_PAY_MERCHANT_IDENTIFIER = synthetic-merchant" + assert_not_contains "$(android_env_of "$fixture")" "synthetic-merchant" + assert_not_contains "$(swift_accelerated_xcconfig_of "$fixture")" "synthetic-merchant" +} + +test_merchant_identifiers_propagate_without_rewriting_root_env test_sample_projects_read_generated_merchant_identifier test_sync_and_check -test_required_values_only -test_optional_sync_updates_blank_optional_values_after_required_setup -test_shared_merchant_identifier_migrates_to_app_specific_keys -test_normalizes_existing_root_env +test_root_env_is_never_written +test_absent_root_env_is_not_created +test_hand_written_root_env_survives_and_still_configures_the_samples +test_missing_required_values_are_reported_without_writing_root_env +test_normalizes_the_storefront_domain +test_process_environment_fills_blank_optional_values test_blank_customer_account_api_version_defaults test_development_team_follows_env_and_clears -test_development_team_is_added_to_an_existing_env -test_migration_from_platform_config +test_existing_platform_config_seeds_the_missing_values test_duplicate_keys_resolve_last_wins test_env_local_overrides_root_env test_env_local_warning_names_only_keys test_env_local_is_never_baked_into_root_env +test_legacy_merchant_identifier_configures_both_samples_without_rewriting_root_env echo "setup_storefront_env synthetic tests passed." From aa596d1ce34e27ca61acbd5a4f9a79f781ce9987 Mon Sep 17 00:00:00 2001 From: Kieran Osgood Date: Fri, 21 Aug 2026 12:23:53 +0100 Subject: [PATCH 3/3] fix: move to staff store for local config --- config/secrets/demo.ejson | 4 +- dev.yml | 2 +- scripts/copy_worktree_env | 28 ++------ scripts/generate_env_files | 37 +++------- scripts/migrate_env_to_local | 36 ++++------ scripts/secrets_edit | 12 +--- scripts/test/secrets_edit_test.rb | 34 +-------- scripts/test_generate_env_files | 113 +++++++++--------------------- 8 files changed, 67 insertions(+), 199 deletions(-) diff --git a/config/secrets/demo.ejson b/config/secrets/demo.ejson index 1c9bc5375..c024e55ad 100644 --- a/config/secrets/demo.ejson +++ b/config/secrets/demo.ejson @@ -2,8 +2,8 @@ "_public_key": "58b34b9a2be67c206423293ba2c0317e6fbe8f727f7ac124ff62349d36fa0136", "_description": "Storefront config for the sample apps. Generates .env, run `dev up` to propagate these values to the sample apps.", "environment": { - "STOREFRONT_DOMAIN": "EJ[1:Vsi9n4WYYvGd935C37cvRzygtUXcpBHu1CkCTFFUqzQ=:MDf5DnXyI+E2j9n1zeMMNprubZ0QpUSS:Ratyqcyc9xd5Ha14St4V0YFjKbt8DhbLK5cOC5GSbGdFyBnHuvtm5mdomVFHqCetPuulRsk=]", - "STOREFRONT_ACCESS_TOKEN": "EJ[1:Vsi9n4WYYvGd935C37cvRzygtUXcpBHu1CkCTFFUqzQ=:31/QaDjKTxu+CgWP1+Pps3/TSphwS16+:oWNA0Zg7iehiUz/kzXPI2sgBV4us6UNEtj2akQTeb3+vF0XRVYSjJfUIa6wqL2Sf]", + "STOREFRONT_DOMAIN": "EJ[1:l7BO8tiknXaR3V2OCydmWpvD6Nsd2/6ZKXWEdXagawo=:afgDjqCHl4uwBnLq0ghL3BAuVs4D2gd+:oyP/5BKL3/TM64sPlmdo5q78zCgKcxzYCqBZWNbCuDjGYPA4vdVfNKVe95dgGhyNYt8=]", + "STOREFRONT_ACCESS_TOKEN": "EJ[1:l7BO8tiknXaR3V2OCydmWpvD6Nsd2/6ZKXWEdXagawo=:AC4QzxArJXQcTI09aHjNqZfiLF2kkZb2:S5NFZID+ysX9l2/22mgCuxv9GxAbAoEr8VcAGTdHcyEVfeVk5u0awb1/Fl3hkPzP]", "API_VERSION": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:tYFS86UJtujPHsIZHB/u4wKb2isFXQFC:/a2Zafq6GTzkympf135XmS+ZHh7L3Zg=]", "CUSTOMER_ACCOUNT_API_CLIENT_ID": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:YbjANfMN0CrNMGQ8mHuX7LOSZunMDGl+:KjleSaYcWwtnMkZ3U8WSGNsXq23sOpyD4YYv16WhXhLdyoy3bfe8ShQXE/SOLPdswlo3WA==]", "CUSTOMER_ACCOUNT_API_SHOP_ID": "EJ[1:COcqDoNiIoRpNVpn5RtY+/98SiWXneivqVxsvjS2j1M=:KjkLuMIgiMHdO1kBj24PTWjmumLq0trf:PFTqWETJCU6Rubg8Zc0+eQT1S5fEBVNDfYOG]", diff --git a/dev.yml b/dev.yml index afce98a5f..cde55c85c 100644 --- a/dev.yml +++ b/dev.yml @@ -32,7 +32,7 @@ up: met?: ./scripts/secrets_setup --check meet: "true" - custom: - name: Copy .env, .dev.env, local.properties into worktree + name: Copy .env.local, .dev.env, local.properties into worktree met?: ./scripts/copy_worktree_env --check meet: ./scripts/copy_worktree_env - custom: diff --git a/scripts/copy_worktree_env b/scripts/copy_worktree_env index 1b5e3d1d9..a10ed8235 100755 --- a/scripts/copy_worktree_env +++ b/scripts/copy_worktree_env @@ -2,17 +2,8 @@ set -euo pipefail -# Copies root-level machine-local configuration from the main checkout into the -# current git worktree: .env.local, .dev.env, and local.properties. Generated -# files such as .env and e2e/.env are deliberately not copied. -# -# Runs automatically as a `dev up` step. With --check it reports (without -# copying) whether the current worktree is missing any of those files: exit 0 -# when nothing is needed (main checkout, or already seeded) and non-zero when a -# copy is required, so it can serve as a `dev up` met? probe. -# -# This command prints file paths and status only; it never prints configured -# values. +# Copies gitignored machine-local configuration from the main checkout into the +# current worktree. Generated env files are excluded because `dev up` rebuilds them. mode="copy" if [[ "${1:-}" == "--check" ]]; then @@ -27,22 +18,13 @@ COMMON_GIT_DIR="$(git rev-parse --path-format=absolute --git-common-dir)" MAIN_ROOT="$(dirname "${COMMON_GIT_DIR}")" if [[ "${CURRENT_ROOT}" == "${MAIN_ROOT}" ]]; then - # In the main checkout there is never anything to copy. [[ "${mode}" == "check" ]] && exit 0 echo "Already in the main checkout (${CURRENT_ROOT}); nothing to copy." exit 0 fi -# Root-level, gitignored files that nothing generates. Anything generated is left -# out, because `dev up` rebuilds it in this worktree anyway: -# -# .env scripts/generate_env_files, from config/secrets/demo.ejson -# e2e/.env scripts/generate_env_files, from config/secrets/e2e.ejson -# sample app config scripts/setup_storefront_env, from .env -# -# Copying a generated .env would be worse than useless: the next step, -# scripts/migrate_env_to_local, would read it as hand-written and freeze that -# stale copy as .env.local, which then overrides the real one for good. +# Copying .env would let migrate_env_to_local preserve stale generated values as +# local overrides before this worktree regenerates them. ROOT_FILES=( ".env.local" ".dev.env" @@ -65,7 +47,6 @@ for rel in "${ROOT_FILES[@]}"; do continue fi - # A source file exists in the main checkout but is missing here. if [[ "${mode}" == "check" ]]; then echo "missing: ${rel}" exit 1 @@ -76,7 +57,6 @@ for rel in "${ROOT_FILES[@]}"; do copied_any="true" done -# Check mode reached here with nothing missing: the worktree is already seeded. [[ "${mode}" == "check" ]] && exit 0 if [[ "${copied_any}" != "true" ]]; then diff --git a/scripts/generate_env_files b/scripts/generate_env_files index adaab0bc9..65cac7f72 100755 --- a/scripts/generate_env_files +++ b/scripts/generate_env_files @@ -2,33 +2,14 @@ set -euo pipefail -# Writes the gitignored env files that the sample apps and the E2E suite read, -# by decrypting the committed files under config/secrets. -# -# config/secrets/demo.ejson -> .env (sample apps, via setup_storefront_env) -# config/secrets/e2e.ejson -> e2e/.env (Maestro suite, via e2e/scripts/run_maestro) -# -# Runs automatically as a `dev up` step. With --check it reports (without writing) -# whether either file is missing or out of date, so it serves as a met? probe. -# -# Two failures look alike and are not: -# -# ejson2env missing -> a broken setup. `dev up` installs it, so this exits -# non-zero and says so. -# private key missing -> a supported state. Anyone outside the GCP project has -# no key and needs none; they write .env by hand. This -# exits 0 and reports the skip, so `dev up` completes. -# -# Each file is rendered to a temporary path and moved into place, so a failed -# decrypt can never truncate an env file that already works. -# -# This command prints key file names, paths and status only; it never prints a -# configured value. +# Generates .env and e2e/.env from committed EJSON secrets. --check reports stale +# output without writing. A missing private key is supported for contributors who +# use their own config; other decrypt failures preserve existing files and fail. ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" KEYDIR="${EJSON_KEYDIR:-/opt/ejson/keys}" -# name:source:destination. The name is what `dev secrets edit` takes. +# name:source:destination PAIRS=( "demo:config/secrets/demo.ejson:.env" "e2e:config/secrets/e2e.ejson:e2e/.env" @@ -55,10 +36,14 @@ generated_header() { # Generated by scripts/generate_env_files from ${source_rel}. # Do not edit this file: the next \`dev up\` overwrites it. # -# To change a value for everyone: \`dev secrets edit ${name}\`, then commit ${source_rel}. -# To change a value for yourself only: put it in ${dest_rel}.local, which nothing writes. - +# Shared changes: \`dev secrets edit ${name}\`, then commit ${source_rel}. EOF + + if [[ "${dest_rel}" == ".env" ]]; then + echo "# Gitignored overrides: .env.local." + fi + + echo } if ! command -v ejson2env >/dev/null 2>&1; then diff --git a/scripts/migrate_env_to_local b/scripts/migrate_env_to_local index b0efcae49..6d442dfa2 100755 --- a/scripts/migrate_env_to_local +++ b/scripts/migrate_env_to_local @@ -2,20 +2,8 @@ set -euo pipefail -# TODO: remove after 2026-09-01, once everyone has migrated. -# -# Keeps a hand-written .env from before scripts/generate_env_files existed. -# -# .env used to be the file a developer edited. It is now generated from -# config/secrets/demo.ejson, so the first `dev up` after this change would -# overwrite it. Copying it to .env.local first preserves those values, because -# .env.local overrides .env and nothing ever writes to it. -# -# Runs from `dev up` only, before generate_env_files, and only when .env.local is -# absent. It therefore acts at most once per checkout. -# -# This command prints file paths and status only; it never prints a configured -# value. +# Preserves a hand-written .env as .env.local before .env becomes generated. +# Existing .env.local files are never overwritten. ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -24,30 +12,30 @@ ROOT_ENV_LOCAL="${ROOT_DIR}/.env.local" mode="migrate" if [[ "${1:-}" == "--check" ]]; then - mode="check" + mode="check" elif [[ $# -gt 0 ]]; then - echo "Usage: scripts/migrate_env_to_local [--check]" >&2 - exit 1 + echo "Usage: scripts/migrate_env_to_local [--check]" >&2 + exit 1 fi nothing_to_do() { - [[ "${mode}" == "check" ]] && exit 0 + [[ "${mode}" == "check" ]] && exit 0 - echo "migrate_env_to_local: $1" - exit 0 + echo "migrate_env_to_local: $1" + exit 0 } if [[ -e "${ROOT_ENV_LOCAL}" ]]; then - nothing_to_do ".env.local already exists, so nothing is copied." + nothing_to_do ".env.local already exists, so nothing is copied." fi if [[ ! -f "${ROOT_ENV}" ]]; then - nothing_to_do "no .env to migrate." + nothing_to_do "no .env to migrate." fi if [[ "${mode}" == "check" ]]; then - echo "migrate_env_to_local: .env has no .env.local yet" - exit 1 + echo "migrate_env_to_local: .env has no .env.local yet" + exit 1 fi cp "${ROOT_ENV}" "${ROOT_ENV_LOCAL}" diff --git a/scripts/secrets_edit b/scripts/secrets_edit index f1d72a905..489c44768 100755 --- a/scripts/secrets_edit +++ b/scripts/secrets_edit @@ -124,17 +124,9 @@ def relative(path) path.delete_prefix("#{REPO_ROOT}/") end -# An edit is only useful once the sample apps see it, so the generated env files -# are refreshed here instead of needing a second command. -# -# Only for files under config/secrets: those are the ones the env files come from. -# Editing an .ejson file elsewhere must leave them alone. -# -# A failure here is reported but not fatal. The edit is already encrypted and -# committable, and generate_env_files says what to do about its own problem. +# Regenerate after edits under config/secrets. Failure does not discard the +# encrypted edit. def regenerate_env_files(path) - # realpath on both sides: REPO_ROOT comes from __dir__, which is already resolved, - # so a symlinked path would otherwise never match. return unless File.realpath(path).start_with?("#{File.realpath(SECRETS_DIR)}/") script = File.join(REPO_ROOT, "scripts", "generate_env_files") diff --git a/scripts/test/secrets_edit_test.rb b/scripts/test/secrets_edit_test.rb index 38e3fd1c5..3348443dd 100644 --- a/scripts/test/secrets_edit_test.rb +++ b/scripts/test/secrets_edit_test.rb @@ -149,9 +149,6 @@ def test_an_unknown_subcommand_names_itself_and_shows_help assert_includes out, "dev secrets edit" end - # An edit is only useful once it reaches the sample apps, so secrets_edit runs - # generate_env_files itself. These two tests use a throwaway repository tree, so - # the regeneration cannot touch the real .env. def test_editing_a_secrets_file_regenerates_the_env_files skip "ejson2env is not installed" unless system("command -v ejson2env >/dev/null 2>&1") @@ -159,21 +156,9 @@ def test_editing_a_secrets_file_regenerates_the_env_files out, status = run_in(root, %w[API_VERSION=2026-10]) assert_equal 0, status, "secrets_edit failed:\n#{out}" - assert_path_exists File.join(root, ".env") assert_includes File.read(File.join(root, ".env")), "API_VERSION=2026-10" end - def test_a_failed_regeneration_still_keeps_the_edit - root = fake_repo_root - out, status = run_in(root, %w[API_VERSION=2026-10], path: ejson_only_path) - - assert_equal 0, status, "an edit that cannot be applied locally must still be committable:\n#{out}" - assert_equal "2026-10", decrypt(File.join(root, "config", "secrets", "demo.ejson")).fetch("API_VERSION") - refute_path_exists File.join(root, ".env") - end - - # The real .env lives beside the real config/secrets. Editing an .ejson file - # anywhere else must not rewrite it. def test_editing_a_file_outside_config_secrets_leaves_the_env_files_alone out, = edit(%w[API_VERSION=2026-10]) @@ -231,8 +216,6 @@ def run_script(editor:, target: @path, keydir: @keydir) [out, status.exitstatus] end - # A throwaway copy of the parts of the repository that secrets_edit reaches, so - # the regeneration it triggers writes into the temporary tree instead of here. def fake_repo_root root = File.join(@dir, "repo") FileUtils.mkdir_p(File.join(root, "scripts")) @@ -246,24 +229,13 @@ def fake_repo_root root end - def run_in(root, assignments, path: nil) + def run_in(root, assignments) env = {"EJSON_KEYDIR" => @keydir, "EDITOR" => setting_editor(assignments), "NO_COLOR" => "1"} - env["PATH"] = path if path target = File.join(root, "config", "secrets", "demo.ejson") out, status = Open3.capture2e(env, File.join(root, "scripts", "secrets_edit"), "edit", target, chdir: root) [out, status.exitstatus] end - # Enough to run the edit but not the regeneration, so the two failures stay - # distinguishable without stubbing either binary. - def ejson_only_path - bin = File.join(@dir, "bin") - FileUtils.mkdir_p(bin) - FileUtils.ln_s(`command -v ejson`.strip, File.join(bin, "ejson")) unless File.exist?(File.join(bin, "ejson")) - - "#{bin}:/usr/bin:/bin" - end - def setting_editor(assignments) fake_editor(<<~RUBY) values = #{assignments.inspect}.to_h { |pair| pair.split("=", 2) } @@ -319,8 +291,8 @@ def write_encrypted(environment) raise "encrypt failed: #{out}" unless status.success? end - def decrypt(path = @path) - out, status = Open3.capture2e({"EJSON_KEYDIR" => @keydir}, "ejson", "decrypt", path) + def decrypt + out, status = Open3.capture2e({"EJSON_KEYDIR" => @keydir}, "ejson", "decrypt", @path) raise "decrypt failed: #{out}" unless status.success? JSON.parse(out).fetch("environment") diff --git a/scripts/test_generate_env_files b/scripts/test_generate_env_files index 91254fa50..d093d8fc8 100755 --- a/scripts/test_generate_env_files +++ b/scripts/test_generate_env_files @@ -111,15 +111,6 @@ assert_contains() { grep -Fq "$pattern" "$path" || fail "expected content was not found: $path: $pattern" } -assert_not_contains() { - local path="$1" - local pattern="$2" - - if grep -Fq "$pattern" "$path"; then - fail "unexpected content was found: $path: $pattern" - fi -} - assert_files_match() { cmp -s "$1" "$2" || fail "files differ but must be byte-identical: $1 $2" } @@ -157,11 +148,11 @@ test_generates_both_env_files() { # Each file must say it is generated, or someone edits it and loses the change. assert_contains "$fixture/.env" "dev secrets edit demo" + assert_contains "$fixture/.env" "Gitignored overrides: .env.local" assert_contains "$fixture/e2e/.env" "dev secrets edit e2e" - - # ejson2env reads the environment object only, so the metadata keys stay behind. - assert_not_contains "$fixture/.env" "_public_key" - assert_not_contains "$fixture/.env" "Synthetic fixture." + if grep -Fq ".env.local" "$fixture/e2e/.env"; then + fail "e2e/.env advertises an unsupported local override" + fi } test_check_passes_when_current_and_fails_when_stale() { @@ -183,19 +174,6 @@ test_check_passes_when_current_and_fails_when_stale() { assert_output_is_sanitized "$output" } -test_generating_twice_leaves_the_file_byte_identical() { - local fixture output first - fixture="$(make_fixture)" - output="$fixture/output.log" - first="$fixture/first" - - run_generate "$fixture" >"$output" 2>&1 - cp "$fixture/.env" "$first" - - run_generate "$fixture" >"$output" 2>&1 - assert_files_match "$fixture/.env" "$first" -} - test_missing_ejson2env_fails_and_names_dev_up() { local fixture output fixture="$(make_fixture)" @@ -210,11 +188,14 @@ test_missing_ejson2env_fails_and_names_dev_up() { assert_file_absent "$fixture/.env" } -test_missing_private_key_skips_without_failing() { - local fixture output +test_missing_private_key_skips_and_preserves_existing_env() { + local fixture output before fixture="$(make_fixture)" output="$fixture/output.log" + before="$fixture/before" + printf 'STOREFRONT_DOMAIN=%s\n' hand-written-store.example.myshopify.com >"$fixture/.env" + cp "$fixture/.env" "$before" rm -f "$fixture"/keys/* run_generate "$fixture" >"$output" 2>&1 || @@ -222,23 +203,15 @@ test_missing_private_key_skips_without_failing() { assert_output_is_sanitized "$output" assert_contains "$output" "no private key" - assert_file_absent "$fixture/.env" -} - -test_missing_private_key_passes_check() { - local fixture output - fixture="$(make_fixture)" - output="$fixture/output.log" - - rm -f "$fixture"/keys/* + assert_files_match "$fixture/.env" "$before" run_generate "$fixture" --check >"$output" 2>&1 || - fail "--check must pass when no key is installed, so dev up still completes" + fail "--check must pass when no key is installed" assert_output_is_sanitized "$output" } -test_failed_decrypt_leaves_an_existing_env_file_intact() { - local fixture output before +test_failed_decrypt_preserves_existing_env() { + local fixture output before key_file fixture="$(make_fixture)" output="$fixture/output.log" before="$fixture/before" @@ -246,30 +219,40 @@ test_failed_decrypt_leaves_an_existing_env_file_intact() { printf 'STOREFRONT_DOMAIN=%s\n' hand-written-store.example.myshopify.com >"$fixture/.env" cp "$fixture/.env" "$before" - rm -f "$fixture"/keys/* - run_generate "$fixture" >"$output" 2>&1 || true + key_file="$(find "$fixture/keys" -type f -print -quit)" + chmod u+w "$key_file" + printf 'invalid-private-key\n' >"$key_file" + + if run_generate "$fixture" >"$output" 2>&1; then + fail "generate_env_files succeeded with an invalid private key" + fi assert_output_is_sanitized "$output" assert_files_match "$fixture/.env" "$before" } -test_migrate_copies_root_env_to_env_local_once() { +test_migrate_lifecycle() { local fixture output after_first fixture="$(make_fixture)" output="$fixture/output.log" after_first="$fixture/after_first" + "$fixture/scripts/migrate_env_to_local" >"$output" 2>&1 + assert_file_absent "$fixture/.env.local" + printf 'STOREFRONT_DOMAIN=%s\n' hand-written-store.example.myshopify.com >"$fixture/.env" + if "$fixture/scripts/migrate_env_to_local" --check >"$output" 2>&1; then + fail "--check passed while a migration was pending" + fi "$fixture/scripts/migrate_env_to_local" >"$output" 2>&1 assert_output_is_sanitized "$output" - assert_file_exists "$fixture/.env.local" assert_files_match "$fixture/.env.local" "$fixture/.env" cp "$fixture/.env.local" "$after_first" - # A second run must be a no-op, because dev up runs this on every invocation. "$fixture/scripts/migrate_env_to_local" >"$output" 2>&1 - assert_output_is_sanitized "$output" + "$fixture/scripts/migrate_env_to_local" --check >"$output" 2>&1 || + fail "--check failed after migration" assert_files_match "$fixture/.env.local" "$after_first" } @@ -289,47 +272,15 @@ test_migrate_never_overwrites_an_existing_env_local() { assert_files_match "$fixture/.env.local" "$before" } -test_migrate_does_nothing_without_a_root_env() { - local fixture output - fixture="$(make_fixture)" - output="$fixture/output.log" - - "$fixture/scripts/migrate_env_to_local" >"$output" 2>&1 - - assert_output_is_sanitized "$output" - assert_file_absent "$fixture/.env.local" -} - -test_migrate_check_reports_whether_a_copy_is_needed() { - local fixture output - fixture="$(make_fixture)" - output="$fixture/output.log" - - "$fixture/scripts/migrate_env_to_local" --check >"$output" 2>&1 || - fail "--check must pass when there is no .env to migrate" - - printf 'STOREFRONT_DOMAIN=%s\n' hand-written-store.example.myshopify.com >"$fixture/.env" - - if "$fixture/scripts/migrate_env_to_local" --check >"$output" 2>&1; then - fail "--check passed while a migration was still pending" - fi - assert_output_is_sanitized "$output" - assert_file_absent "$fixture/.env.local" -} - require_binary ejson require_binary ejson2env test_generates_both_env_files test_check_passes_when_current_and_fails_when_stale -test_generating_twice_leaves_the_file_byte_identical test_missing_ejson2env_fails_and_names_dev_up -test_missing_private_key_skips_without_failing -test_missing_private_key_passes_check -test_failed_decrypt_leaves_an_existing_env_file_intact -test_migrate_copies_root_env_to_env_local_once +test_missing_private_key_skips_and_preserves_existing_env +test_failed_decrypt_preserves_existing_env +test_migrate_lifecycle test_migrate_never_overwrites_an_existing_env_local -test_migrate_does_nothing_without_a_root_env -test_migrate_check_reports_whether_a_copy_is_needed echo "generate_env_files synthetic tests passed."