diff --git a/.github/workflows/app-discovery-qa.yml b/.github/workflows/app-discovery-qa.yml
index de838efa..76671d1a 100644
--- a/.github/workflows/app-discovery-qa.yml
+++ b/.github/workflows/app-discovery-qa.yml
@@ -10,6 +10,8 @@ on:
- scripts/build_catalog.py
- site_counts.json
- tests/catalog-growth.test.mjs
+ - scripts/publish_app_discovery.sh
+ - scripts/check_app_discovery_live.sh
- .github/workflows/app-discovery-qa.yml
push:
branches: [main]
@@ -21,6 +23,8 @@ on:
- scripts/build_catalog.py
- site_counts.json
- tests/catalog-growth.test.mjs
+ - scripts/publish_app_discovery.sh
+ - scripts/check_app_discovery_live.sh
- .github/workflows/app-discovery-qa.yml
permissions:
contents: read
@@ -38,5 +42,7 @@ jobs:
python-version: '3.12'
- name: Confirm catalog generator remains idempotent
run: python3 scripts/build_catalog.py --check
+ - name: Verify non-destructive release scripts have valid shell syntax
+ run: bash -n scripts/publish_app_discovery.sh scripts/check_app_discovery_live.sh
- name: Verify search, referral links and guide listings
run: node --test tests/catalog-growth.test.mjs
diff --git a/docs/APP_DISCOVERY_GROWTH_RELEASE_2026-10-27.md b/docs/APP_DISCOVERY_GROWTH_RELEASE_2026-10-27.md
new file mode 100644
index 00000000..b10f732b
--- /dev/null
+++ b/docs/APP_DISCOVERY_GROWTH_RELEASE_2026-10-27.md
@@ -0,0 +1,53 @@
+# App acquisition release and experiment plan (2026-10-27)
+
+## Source of truth / deployment reality
+
+- App catalog: **92** non-hidden Android listings in `apps_data.json` as of site count 2026-10-12. Do not call all 117 created apps Play-published. Hidden entries must never be promoted automatically.
+- Production `appdevelopsk.com` is served by nginx on the VPS (`/var/www/html`). GitHub repository `appdevelopsk/appdevelopsk.github.io` **main is not itself the live site**. GitHub Pages publishes from a separate `pages-blank` branch and is not a replacement for the VPS.
+- The source PR #4 introduced in `/apps` a searchable, accessible catalog and two new canonical SEO guides. **No successful HTTPS production deployment was established by merging it.**
+
+## Deployment with the existing local `pickly-vps` SSH alias
+
+From a **fresh main** checkout of this site repository, after checking working tree status:
+
+```bash
+git pull --ff-only
+bash scripts/publish_app_discovery.sh --dry-run
+# Review the four changed destination paths. Never pass --delete.
+bash scripts/publish_app_discovery.sh --apply
+bash scripts/check_app_discovery_live.sh
+```
+
+Only these files transfer: `apps.html`, `sitemap.xml`, `guides/quick-games/index.html`, `guides/daily-habits/index.html`. Every other VPS file, including privacy, support, policy, payment and ads assets, stays untouched. To override environment use `APP_SITE_DEPLOY_HOST` and `APP_SITE_DEPLOY_ROOT` with a known-safe absolute path. The script refuses unknown flags/invalid targets.
+
+**Publish gate:** a dry-run is not publication. Only mark live once `check_app_discovery_live.sh` reports HTTP 200 and matches `id="find-app"`, both guide canonical URLs, guide click event and sitemap entries. After manual publication consider Google Search Console URL inspection and sitemap submission if the `appdevelopsk.com` property is available.
+
+## Growth experiment — acquisition, not vanity pageviews
+
+| Funnel stage | Event / source | Decision |
+|---|---|---|
+| Website entry | GA4 landing page, organic / referral / direct, `appdevelopsk.com/apps` and `/guides/*` | New landing sessions and engaged sessions by source |
+| Discovery | `catalog_search_used`, `catalog_filter_used` | What proportion of visitors use discovery controls? These events must not include raw search terms |
+| Store intent | `catalog_store_click`, `guide_store_click` with app slug | Store clicks by guide and category vs catalog views |
+| Play install | Google Play Console listing visitors, acquisitions and Install Referrer campaign (`apps_catalog`, `quick_games`, `daily_habits`) | Conversion separately for each campaign, geography and released package |
+| First app value | App GA4 events (first post, first game result, first photo, etc.) | Do newly acquired users finish core action? |
+| Repeat value | Mature D1/D7 cohort with sufficient n, crash-free | Keep acquisition spend/effort only if app delivers retention |
+
+Do not sum the 117 apps' 7-day active-user counts as unique people. Do not interpret clicks as downloads, installs as activated users, or app-source merges as store releases. If a campaign has fewer than ~100 store visitors or D7 cohort n<30, call it directional/inconclusive. Monitor the first 7 days to catch broken links; compare at least 28-day periods for conversion trends while accounting for rollout date and channel mix.
+
+## Safe search-indexing / content integrity
+
+- All guides recommend only 4 existing **non-hidden** catalog apps each; their descriptions are sourced from the public catalog. Guides have no made-up star ratings, screenshots, money claims or app feature promises.
+- All cards remain server-rendered so crawlers and no-JavaScript users still see published links. Filters run only when JS works.
+- URLs use Play Install Referrer with guide-specific campaigns. Keep referrer and GA4 attribution separate; the actual install remains subject to Google Play behavior.
+- If an app is delisted or renamed, edit `apps_data.json`, `scripts/build_catalog.py` and the affected guide together, then run Node tests and `python3 scripts/build_catalog.py --check`.
+
+## Release checklist
+
+- [x] Source changes merged to main (PR #4)
+- [x] Static website tests and build-catalog consistency passed on PR #4
+- [ ] VPS rsync dry-run reviewed by deploy operator
+- [ ] VPS content uploaded, actual HTTPS endpoint verified
+- [ ] Sitemap submitted/indexing checked in Search Console
+- [ ] GA4 DebugView events observed without search query payload
+- [ ] Google Play listing traffic / acquisition comparison recorded after meaningful sample
diff --git a/scripts/check_app_discovery_live.sh b/scripts/check_app_discovery_live.sh
new file mode 100644
index 00000000..bc94cb9d
--- /dev/null
+++ b/scripts/check_app_discovery_live.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# Read-only production smoke. Run after the VPS rsync; no deploy or credentials.
+set -euo pipefail
+base="${APP_SITE_PUBLIC_BASE:-https://appdevelopsk.com}"
+[[ "$base" == 'https://appdevelopsk.com' ]] || { echo 'Expected canonical https://appdevelopsk.com' >&2; exit 2; }
+tmp=$(mktemp -d)
+trap 'rm -rf "$tmp"' EXIT
+ua='Mozilla/5.0 (compatible; SKAppsGrowthCheck/1.0)'
+fetch() {
+ local route="$1" filename="$2"
+ local code
+ code=$(curl -sSL --compressed -A "$ua" --max-time 20 --retry 2 --retry-delay 2 \
+ -o "$tmp/$filename" -w '%{http_code}' "$base$route")
+ [[ "$code" == 200 ]] || { echo "FAIL HTTP $code $base$route" >&2; exit 1; }
+}
+fetch '/apps' 'apps.html'
+grep -Fq 'id="find-app"' "$tmp/apps.html" || { echo 'FAIL catalog search is stale' >&2; exit 1; }
+grep -Fq 'catalog_store_click' "$tmp/apps.html" || { echo 'FAIL store-click attribution is stale' >&2; exit 1; }
+for slug in quick-games daily-habits; do
+ fetch "/guides/$slug/" "$slug.html"
+ grep -Fq "https://appdevelopsk.com/guides/$slug/" "$tmp/$slug.html" || { echo "FAIL guide $slug content/canonical mismatch" >&2; exit 1; }
+ grep -Fq 'guide_store_click' "$tmp/$slug.html" || { echo "FAIL $slug click measurement missing" >&2; exit 1; }
+done
+fetch '/sitemap.xml' 'sitemap.xml'
+for slug in quick-games daily-habits; do
+ grep -Fq "https://appdevelopsk.com/guides/$slug/" "$tmp/sitemap.xml" || { echo "FAIL sitemap missing $slug" >&2; exit 1; }
+done
+echo 'PASS: VPS catalog, two guides, click-tracking markers and sitemap all current (HTTP 200)'
diff --git a/scripts/publish_app_discovery.sh b/scripts/publish_app_discovery.sh
new file mode 100644
index 00000000..b157a397
--- /dev/null
+++ b/scripts/publish_app_discovery.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+# Publish *only* the app-discovery growth files to the VPS backing appdevelopsk.com.
+# Default is a non-destructive dry-run. --apply is an intentional human action.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+mode="${1:---dry-run}"
+case "$mode" in
+ --dry-run) flags=(--dry-run) ;;
+ --apply) flags=() ;;
+ *) echo 'Usage: scripts/publish_app_discovery.sh [--dry-run|--apply]' >&2; exit 2 ;;
+esac
+host="${APP_SITE_DEPLOY_HOST:-pickly-vps}"
+root="${APP_SITE_DEPLOY_ROOT:-/var/www/html}"
+# Never evaluate an arbitrary SSH target or remote shell path.
+if [[ ! "$host" =~ ^[a-zA-Z0-9_.@-]+$ || ! "$root" =~ ^/var/www/[a-zA-Z0-9_./-]+$ ]]; then
+ echo 'Invalid APP_SITE_DEPLOY_HOST or APP_SITE_DEPLOY_ROOT' >&2; exit 2
+fi
+files=(apps.html sitemap.xml guides/quick-games/index.html guides/daily-habits/index.html)
+for file in "${files[@]}"; do
+ [[ -s "$file" ]] || { echo "Missing source file: $file" >&2; exit 2; }
+done
+echo "Source checkout: $(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
+echo "Target: $host:$root; mode: $mode"
+# --relative recreates only the listed nested file paths. No --delete.
+rsync -avR --itemize-changes "${flags[@]}" -- "${files[@]}" "$host:$root/"
+if [[ "$mode" == '--apply' ]]; then
+ echo 'Files transferred. Check CDN/VPS output (the publish command does not prove it is live):'
+ echo ' bash scripts/check_app_discovery_live.sh'
+fi
diff --git a/sitemap.xml b/sitemap.xml
index 043a1c23..bbc387e2 100644
--- a/sitemap.xml
+++ b/sitemap.xml
@@ -5,7 +5,7 @@
https://appdevelopsk.com/contact2026-08-18yearly0.4
https://appdevelopsk.com/privacy2026-08-18yearly0.4
https://appdevelopsk.com/terms2026-08-18yearly0.4
- https://appdevelopsk.com/apps2026-08-18weekly0.9
+ https://appdevelopsk.com/apps2026-10-27weekly0.9
https://appdevelopsk.com/support/2026-08-18weekly0.6
https://appdevelopsk.com/privacy-policy/2026-08-18weekly0.3
https://appdevelopsk.com/support/30sec-challenge/2026-08-18monthly0.8