From fd303f6f2bc4ef6fd7cc08e4dece7d413d1e7549 Mon Sep 17 00:00:00 2001
From: Shai Almog <67850168+shai-almog@users.noreply.github.com>
Date: Mon, 19 Oct 2026 15:35:56 +0300
Subject: [PATCH] Preserve social acquisition and coordinate LinkedIn Page
publishing
---
.github/workflows/website-docs.yml | 2 +
AGENTS.md | 9 ++
docs/website/assets/js/cn1-acquisition.js | 84 ++++++++++++
docs/website/assets/js/cn1-crisp.js | 2 +
docs/website/layouts/partials/footer.html | 2 +
docs/website/scripts/test-acquisition.mjs | 65 +++++++++
scripts/website/SOCIAL_OPERATIONS.md | 97 +++++++++++++
.../install_linkedin_coordinator_guard.py | 67 +++++++++
scripts/website/linkedin_page_calendar.py | 129 ++++++++++++++++++
scripts/website/social_campaigns.py | 86 ++++++++++++
scripts/website/test_social_operations.py | 118 ++++++++++++++++
11 files changed, 661 insertions(+)
create mode 100644 docs/website/assets/js/cn1-acquisition.js
create mode 100644 docs/website/scripts/test-acquisition.mjs
create mode 100644 scripts/website/SOCIAL_OPERATIONS.md
create mode 100644 scripts/website/install_linkedin_coordinator_guard.py
create mode 100644 scripts/website/linkedin_page_calendar.py
create mode 100644 scripts/website/social_campaigns.py
create mode 100644 scripts/website/test_social_operations.py
diff --git a/.github/workflows/website-docs.yml b/.github/workflows/website-docs.yml
index 9978768ed55..71b7470569f 100644
--- a/.github/workflows/website-docs.yml
+++ b/.github/workflows/website-docs.yml
@@ -355,6 +355,8 @@ jobs:
set -euo pipefail
node docs/website/scripts/test-build-first-funnel.mjs docs/website/public
node docs/website/scripts/test-cn1-crisp-events.mjs
+ node docs/website/scripts/test-acquisition.mjs
+ python3 -m unittest discover -s scripts/website -p test_social_operations.py
node docs/website/scripts/test-initializr-download-beacon.mjs
node docs/website/scripts/test-initializr-chat-clearance.mjs
diff --git a/AGENTS.md b/AGENTS.md
index e9647cc67bc..c622c5beacc 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,5 +1,14 @@
# Agent Instructions
+## LinkedIn Page coordination
+
+All Codename One Page publication, including ad hoc promotions, goes through the
+existing social publishing chat and its private Page calendar. Do not publish
+directly from another task or the legacy browser syndicator. Submit exact-copy
+candidates for approval; a repository queue entry is not a calendar reservation
+or a new approval. Read `scripts/website/SOCIAL_OPERATIONS.md` before proposing or
+executing Page writes. Keep drafts, approvals and calendar data outside this repo.
+
## Scope
These instructions apply to the entire repository. They are aimed at helping Codex agents build and run the core unit tests that live under `maven/core-unittests`.
diff --git a/docs/website/assets/js/cn1-acquisition.js b/docs/website/assets/js/cn1-acquisition.js
new file mode 100644
index 00000000000..2edb5faf1f8
--- /dev/null
+++ b/docs/website/assets/js/cn1-acquisition.js
@@ -0,0 +1,84 @@
+(() => {
+ // First tagged acquisition, not internal topic-interest or last-click tracking.
+ const KEY = "cn1-acquisition-v1";
+ const TTL = 24 * 60 * 60 * 1000;
+ const FIELDS = ["utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term"];
+ const VALUE = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/;
+ const ENTRY_PATHS = new Set(["/console", "/console/", "/console/index.html",
+ "/secure", "/secure/", "/secure/index.html", "/secure/signup", "/login", "/register"]);
+ let touch = null;
+ const decorated = new Map();
+
+ function consent() {
+ const cookie = document.cookie.split(";").map(x => x.trim())
+ .find(x => x.startsWith("cn1_crisp_consent_v2="));
+ if (cookie) return cookie.slice(cookie.indexOf("=") + 1) === "accepted";
+ try { return localStorage.getItem("cn1-crisp-consent-v2") === "accepted"; }
+ catch (_) { return false; }
+ }
+
+ function valid(candidate) {
+ return candidate && Number.isFinite(candidate.at) && candidate.at <= Date.now() &&
+ Date.now() - candidate.at < TTL && candidate.tags &&
+ ["social", "community", "oss", "email"].includes(candidate.tags.utm_medium) &&
+ VALUE.test(candidate.tags.utm_source || "") && VALUE.test(candidate.tags.utm_campaign || "") &&
+ Object.entries(candidate.tags).every(([k, v]) => FIELDS.includes(k) && VALUE.test(v));
+ }
+
+ function capture() {
+ if (!consent()) {
+ touch = null;
+ try { sessionStorage.removeItem(KEY); } catch (_) { /* storage unavailable */ }
+ return null;
+ }
+ if (valid(touch)) return touch;
+ try { touch = JSON.parse(sessionStorage.getItem(KEY)); } catch (_) { touch = null; }
+ if (valid(touch)) return touch;
+ touch = null;
+ const params = new URLSearchParams(window.location.search);
+ const tags = {};
+ for (const field of FIELDS) {
+ if (params.has(field)) tags[field] = params.get(field);
+ }
+ const candidate = { at: Date.now(), tags };
+ if (valid(candidate)) touch = candidate;
+ try {
+ if (touch) sessionStorage.setItem(KEY, JSON.stringify(touch));
+ else sessionStorage.removeItem(KEY);
+ } catch (_) { /* retain this page's touch when storage is unavailable */ }
+ return touch;
+ }
+
+ function decorate(link) {
+ const current = link.getAttribute("href");
+ const previous = decorated.get(link);
+ // Other scripts (for example pricing) may have changed the destination.
+ const original = previous && previous.result === current ? previous.original : current;
+ if (previous && previous.result === current) link.setAttribute("href", original);
+ decorated.delete(link);
+ const acquisition = capture();
+ if (!acquisition || !original) return;
+ let url;
+ try { url = new URL(original, window.location.href); } catch (_) { return; }
+ if (url.origin !== "https://cloud.codenameone.com" || !ENTRY_PATHS.has(url.pathname)) return;
+ // Replace the whole acquisition tuple, never mix a later CTA campaign with first touch.
+ FIELDS.forEach(field => url.searchParams.delete(field));
+ Object.entries(acquisition.tags).forEach(([key, value]) => url.searchParams.set(key, value));
+ const result = url.href;
+ link.setAttribute("href", result);
+ decorated.set(link, { original, result });
+ }
+
+ function refresh() {
+ capture();
+ document.querySelectorAll("a[href]").forEach(decorate);
+ }
+ refresh();
+ // Covers newly created CTAs, pricing switches, keyboard clicks and middle-clicks.
+ ["click", "auxclick", "contextmenu"].forEach(type => document.addEventListener(type, event => {
+ const link = event.target.closest?.("a[href]");
+ if (link) decorate(link);
+ }, true));
+ window.addEventListener("cn1-consent-changed", refresh);
+ window.addEventListener("pageshow", refresh);
+})();
diff --git a/docs/website/assets/js/cn1-crisp.js b/docs/website/assets/js/cn1-crisp.js
index 76b1ac82807..4e4f8b701e5 100644
--- a/docs/website/assets/js/cn1-crisp.js
+++ b/docs/website/assets/js/cn1-crisp.js
@@ -299,6 +299,7 @@
const acceptConsent = () => {
setConsent("accepted");
+ window.dispatchEvent?.(new Event("cn1-consent-changed"));
closeBanner();
loadCrisp();
reportOssArrival();
@@ -307,6 +308,7 @@
const declineConsent = () => {
setConsent("declined");
+ window.dispatchEvent?.(new Event("cn1-consent-changed"));
clearPendingEvents();
closeBanner();
hideCrisp();
diff --git a/docs/website/layouts/partials/footer.html b/docs/website/layouts/partials/footer.html
index 59e25f6e859..6cbe23b4f5e 100644
--- a/docs/website/layouts/partials/footer.html
+++ b/docs/website/layouts/partials/footer.html
@@ -79,6 +79,7 @@
Community
{{- $cn1Demo := resources.Get "js/cn1-demo.js" | minify | fingerprint }}
{{- $cn1Pricing := resources.Get "js/cn1-pricing.js" | minify | fingerprint }}
{{- $cn1Crisp := resources.Get "js/cn1-crisp.js" | minify | fingerprint }}
+{{- $cn1Acquisition := resources.Get "js/cn1-acquisition.js" | minify | fingerprint }}
{{- $cn1PortStatus := resources.Get "js/cn1-port-status.js" | minify | fingerprint }}
{{- if or (hasPrefix (.RelPermalink | default "") "/demo-") (eq .Layout "demos") }}
@@ -88,6 +89,7 @@ Community
{{- end }}
+
{{- if eq .Layout "port-status" -}}
{{- end -}}
diff --git a/docs/website/scripts/test-acquisition.mjs b/docs/website/scripts/test-acquisition.mjs
new file mode 100644
index 00000000000..d8665a5b7bf
--- /dev/null
+++ b/docs/website/scripts/test-acquisition.mjs
@@ -0,0 +1,65 @@
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import vm from 'node:vm';
+const source = fs.readFileSync(new URL('../assets/js/cn1-acquisition.js', import.meta.url), 'utf8');
+const KEY = 'cn1-acquisition-v1';
+const values = new Map();
+const store = { getItem: k => values.get(k) || null, setItem: (k,v) => values.set(k,v), removeItem: k => values.delete(k) };
+const tags = '?utm_source=bluesky&utm_medium=social&utm_campaign=social-2026-10&utm_content=2026-10-20-v2-b1';
+const link = href => ({ href, getAttribute() { return this.href; }, setAttribute(_,v) { this.href=v; }, closest() { return this; } });
+function load(search=tags, consent='accepted', storage=store) {
+ const links = [link('https://cloud.codenameone.com/console/'),
+ link('https://cloud.codenameone.com/secure/signup?level=pro&mode=annual#pay'),
+ link('https://cloud.codenameone.com/register'), link('https://evil.example/console/'),
+ link('/pricing/'), link('https://cloud.codenameone.com.evil.example/console/'),
+ link('https://cloud.codenameone.com/api/v2/build?utm_source=untouched')];
+ const events = {}, winEvents = {};
+ const document = { cookie: `cn1_crisp_consent_v2=${consent}`, querySelectorAll: () => links,
+ addEventListener: (k,v) => events[k]=v };
+ const window = { location: { search, href:'https://www.codenameone.com/blog/demo/'+search },
+ addEventListener: (k,v) => winEvents[k]=v };
+ vm.runInNewContext(source, {window,document,sessionStorage:storage,localStorage:store,URL,URLSearchParams,Date,Map,Set});
+ return {links,events,winEvents,document};
+}
+function content(l) { return new URL(l.href).searchParams.get('utm_content'); }
+values.clear();
+let s=load();
+assert.equal(content(s.links[0]),'2026-10-20-v2-b1');
+assert.equal(new URL(s.links[1].href).searchParams.get('level'),'pro');
+assert.equal(new URL(s.links[1].href).hash,'#pay');
+assert.equal(content(s.links[2]),'2026-10-20-v2-b1');
+assert.equal(s.links[3].href,'https://evil.example/console/');
+assert.equal(s.links[4].href,'/pricing/');
+assert.equal(s.links[5].href,'https://cloud.codenameone.com.evil.example/console/');
+assert.equal(s.links[6].href,'https://cloud.codenameone.com/api/v2/build?utm_source=untouched');
+assert.equal(content(load('').links[0]),'2026-10-20-v2-b1','survives internal navigation');
+assert.equal(content(load(tags.replace('bluesky','x').replace('-b1','-x1')).links[0]),'2026-10-20-v2-b1','first touch survives later campaign');
+s.links[1].href='https://cloud.codenameone.com/secure/signup?level=pro&mode=monthly';
+s.events.auxclick({target:s.links[1]});
+assert.equal(new URL(s.links[1].href).searchParams.get('mode'),'monthly');
+assert.equal(content(s.links[1]),'2026-10-20-v2-b1');
+s.document.cookie='cn1_crisp_consent_v2=declined';
+s.winEvents['cn1-consent-changed']();
+assert.equal(content(s.links[0]),null,'withdrawal restores untagged links');
+assert.equal(values.has(KEY),false);
+for (const consent of ['declined','']) {
+ s=load(tags,consent);
+ assert.equal(content(s.links[0]),null);
+ assert.equal(values.has(KEY),false);
+}
+s=load(tags,'');
+s.document.cookie='cn1_crisp_consent_v2=accepted';
+s.winEvents['cn1-consent-changed']();
+assert.equal(content(s.links[0]),'2026-10-20-v2-b1','consent acceptance on landing captures tags');
+const old=JSON.parse(values.get(KEY));old.at=Date.now()-25*3600000;values.set(KEY,JSON.stringify(old));
+assert.equal(content(load('').links[0]),null,'expired touch not forwarded');
+values.clear();
+assert.equal(content(load('?utm_source=x&utm_medium=social&utm_campaign=bad%20campaign').links[0]),null);
+assert.equal(content(load('?utm_source=codenameone&utm_medium=website&utm_campaign=homepage').links[0]),null,'internal topic click not acquisition');
+const blocked={getItem(){throw Error();},setItem(){throw Error();},removeItem(){throw Error();}};
+assert.equal(content(load(tags,'accepted',blocked).links[0]),'2026-10-20-v2-b1','current-page fallback');
+values.clear();
+const mixed=load();mixed.links[0].href='https://cloud.codenameone.com/console/?utm_source=other&utm_term=other';
+mixed.events.click({target:mixed.links[0]});
+assert.equal(new URL(mixed.links[0].href).searchParams.get('utm_term'),null,'never mix attribution tuples');
+console.log('Acquisition navigation, consent, expiry, dynamic links and origin-boundary checks passed');
diff --git a/scripts/website/SOCIAL_OPERATIONS.md b/scripts/website/SOCIAL_OPERATIONS.md
new file mode 100644
index 00000000000..c81971d62b2
--- /dev/null
+++ b/scripts/website/SOCIAL_OPERATIONS.md
@@ -0,0 +1,97 @@
+# Social attribution and Page coordination
+
+## A post to a first successful build
+
+Use `social_campaigns.py link` when preparing a draft, before exact-link approval:
+
+```sh
+python3 scripts/website/social_campaigns.py link \
+ --url https://www.codenameone.com/playground/ --source bluesky \
+ --campaign social-2026-10 --item 2026-10-20-v2-b1
+```
+
+`utm_source` distinguishes the account/channel; `utm_campaign` groups the campaign;
+`utm_content` is the unique immutable batch-version/item ID. Keep a private mapping
+to the final platform permalink. Never put emails, handles of readers, or user IDs
+in these parameters. Revising a reviewed link requires review of that version.
+Already published/scheduled items are not silently retagged.
+
+The website's `cn1-acquisition.js` preserves the first tagged acquisition for
+24 hours in consent-gated session storage, across internal navigation. It forwards
+the tuple only to allowlisted HTTPS BuildCloud entry links, including dynamic
+pricing links, preserving the plan and fragment. Internal `utm_medium=website`
+topic-interest links cannot replace acquisition. Declining analytics clears the
+stored touch and restores decorated links. No consent, blocked storage after
+navigation, another device/browser, a closed tab or an expired touch creates a
+coverage gap, not evidence of an organic acquisition.
+
+BuildCloud already captures console UTMs in the authentication session and claims
+them once for a newly created account (password and federated auth). Its direct
+`/login`, `/register` and `/secure/signup` paths must also capture the entry;
+deploy the matching BuildCloud change before calling those journeys verified.
+Existing-user logins are not new acquisitions. The existing
+`/api/v2/console/admin/funnel-cohorts` aggregate groups by source, medium, campaign
+and content and joins durable successful build evidence. No new user-level export
+or tracking service is needed.
+
+During the authorized overnight analytics session, save the existing read-only
+admin aggregate response privately, with collection time and deployment versions.
+Then run:
+
+```sh
+python3 scripts/website/social_campaigns.py report /private/path/funnel.json --window d30
+```
+
+The output retains only aggregate fields. `successful` counts new users in a
+signup cohort who have achieved a successful build, even after a failed first
+attempt; it is not `firstAttemptSucceeded`, build volume, or a causal result.
+Rolling 30/90-day signup cohorts are not seven-day activation event windows.
+Report unknown if diagnostics are missing, and flag disagreement with headline
+activation rather than silently choosing a favorable count. Declined consent,
+missing UTM identities, and historical untagged posts cannot be reconstructed.
+
+## One LinkedIn Page publisher
+
+The existing overnight social publishing workflow owns all Page writes, including
+ad hoc promotions. Other tasks and repository social artifacts supply candidates;
+they must not publish independently. The legacy local browser driver delegates
+LinkedIn to this coordinator before opening a browser. Article syndication is
+unchanged. Source-date-only repository tasks are not rolled to a new date or
+silently approved by being present in the queue.
+
+Private state lives under the social automation's `resources/` directory:
+`linkedin-page-calendar.json` plus the existing approval/publication ledger. The
+calendar is not approval evidence. Every action still requires the human approval
+in the publishing chat. Scripts and backups may live there; no private state is
+committed here.
+
+Before any Page composer or schedule action, within the existing Firefox window:
+
+1. Acquire the existing single-browser-worker lock. Verify the Page identity.
+2. Read the actual live Page feed AND all native scheduled posts, other pending
+ private batches and repository syndication queue/state. Record every relevant
+ outside post/schedule with its actual public instant and permalink/ID. Resolve
+ unknown timestamps before proceeding. Previously scheduled items need
+ verification, never a second publication.
+3. Update the calendar using `record`. Confirm cancellations remotely before
+ recording `cancelled`. A reservation left by a crash remains blocking until
+ reconciled. Record any collision even if a different producer caused it.
+4. Run `audit` with the observation time, evidence file and the inspected time
+ range. It must cover at least 20 hours before AND after the target publication,
+ including future native schedules. Never invent a fresh audit from old state.
+5. Run `reserve --id BATCH-ITEM --at OFFSET-TIMESTAMP --approval-ref CHAT-EVIDENCE`.
+ The tool atomically rejects another reservation within 20 hours, stale native
+ evidence, missed times and duplicate IDs. Planning `check` is not permission
+ to publish. The normal interval remains 24 hours.
+6. Only after success perform the exact approved native action. Immediately
+ `record --status scheduled|published --url VERIFIED-URL-OR-ID --evidence FILE`
+ using that same ID/instant and update the publication ledger. If the outcome
+ is ambiguous, retain the reservation and inspect remotely before retrying.
+
+All commands take `--calendar /private/path/linkedin-page-calendar.json` before
+the subcommand. An absent or corrupt calendar fails closed. Do not invent an empty
+replacement. The CLI never moves, cancels or publishes remote content. A collision
+means defer the candidate for a new approved time, not reshuffle approved posts.
+Manual writes outside the coordinator cannot be prevented by a local script; the
+next native audit must reconcile them. No browser work is permitted during the
+day merely to refresh this calendar.
diff --git a/scripts/website/install_linkedin_coordinator_guard.py b/scripts/website/install_linkedin_coordinator_guard.py
new file mode 100644
index 00000000000..7ce937ed504
--- /dev/null
+++ b/scripts/website/install_linkedin_coordinator_guard.py
@@ -0,0 +1,67 @@
+#!/usr/bin/env python3
+"""Install the local legacy-runner LinkedIn handoff; never starts a browser.
+
+The runner lives outside this repository. Keep a private backup and diff before
+installing. Article platforms keep their existing behavior; queue/state are untouched.
+"""
+import argparse
+import difflib
+from pathlib import Path
+
+MARKER = "LINKEDIN_COORDINATOR_HANDOFF_V1"
+MESSAGE = "LinkedIn is owned by the overnight Firefox coordinator; submit this queue item for exact approval there."
+
+
+def guarded(source):
+ if MARKER in source:
+ return source
+ anchors = [
+ (' site = task["site"]\n driver_fn = DRIVERS.get(site)',
+ f' # {MARKER}: never launch the legacy browser for LinkedIn.\n'
+ ' site = task["site"]\n'
+ ' if site == "linkedin":\n'
+ f' return False, None, {MESSAGE!r}\n'
+ ' driver_fn = DRIVERS.get(site)'),
+ (' """Publish one due LinkedIn post as Shai or the Codename One page."""\n',
+ ' """Legacy entry retained for callers; all LinkedIn writes are centralized."""\n'
+ f' raise RuntimeError({MESSAGE!r})\n'),
+ (' pending = [t for t in unfinished if is_due(t, now)]',
+ ' delegated = [t for t in unfinished if t.get("site") == "linkedin"]\n'
+ ' for t in delegated:\n'
+ f' print("Delegated " + t["id"] + ": " + {MESSAGE!r})\n'
+ ' unfinished = [t for t in unfinished if t.get("site") != "linkedin"]\n'
+ ' pending = [t for t in unfinished if is_due(t, now)]'),
+ ]
+ for before, after in anchors:
+ if source.count(before) != 1:
+ raise ValueError("Runner changed: inspect before installing; no files modified")
+ source = source.replace(before, after)
+ compile(source, '', 'exec')
+ return source
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument('runner', type=Path)
+ p.add_argument('--backup-dir', type=Path, required=True)
+ p.add_argument('--apply', action='store_true')
+ a = p.parse_args()
+ before = a.runner.read_text()
+ after = guarded(before)
+ if before == after:
+ print('Coordinator handoff already installed')
+ return
+ diff = ''.join(difflib.unified_diff(before.splitlines(True), after.splitlines(True),
+ fromfile=str(a.runner), tofile=str(a.runner)))
+ if not a.apply:
+ print(diff)
+ return
+ a.backup_dir.mkdir(parents=True, exist_ok=True)
+ backup = a.backup_dir / 'syndicate_browser.before-linkedin-handoff.py'
+ with backup.open('x') as f: f.write(before)
+ (a.backup_dir / 'linkedin-handoff.patch').write_text(diff)
+ a.runner.write_text(after)
+ print('Installed coordinator handoff; backup: ' + str(backup))
+
+
+if __name__ == '__main__': main()
diff --git a/scripts/website/linkedin_page_calendar.py b/scripts/website/linkedin_page_calendar.py
new file mode 100644
index 00000000000..e7ed7a46ddf
--- /dev/null
+++ b/scripts/website/linkedin_page_calendar.py
@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+"""Private, atomic Page publication reservations. Never publishes or approves content.
+
+One coordinator owns Page writes; other producers submit candidates to it.
+Reservations persist across crashes until remotely reconciled. Calendar files
+must stay outside the public repository. Native feed + scheduled queue evidence
+is required immediately before reserving a slot for a public write.
+"""
+import argparse
+from contextlib import contextmanager
+from datetime import datetime, timedelta, timezone
+import fcntl
+import json
+import os
+from pathlib import Path
+
+GAP = timedelta(hours=20)
+FRESH = timedelta(minutes=15)
+
+
+def instant(value):
+ d = datetime.fromisoformat(value.replace("Z", "+00:00"))
+ if d.tzinfo is None:
+ raise ValueError("A UTC offset is required")
+ return d.astimezone(timezone.utc)
+
+
+def conflicts(state, item_id, at):
+ at = instant(at)
+ return [s for s in state["slots"] if s["id"] != item_id and s["status"] != "cancelled"
+ and abs(at - instant(s["at"])) < GAP]
+
+
+def reserve(state, item_id, at, approval, now):
+ audit = state.get("native_audit", {})
+ if not audit or not timedelta(0) <= now - instant(audit["observed_at"]) <= FRESH:
+ raise ValueError("Fresh native Page feed AND scheduled-post audit required (15 minutes)")
+ target = instant(at)
+ if target < now - timedelta(minutes=1):
+ raise ValueError("Missed publication window; do not roll forward")
+ if instant(audit["from"]) > target - GAP or instant(audit["through"]) < target + GAP:
+ raise ValueError("Native audit must cover the 20 hours before AND after publication")
+ existing = next((s for s in state["slots"] if s["id"] == item_id), None)
+ if existing:
+ if existing["at"] != at or existing["status"] == "cancelled":
+ raise ValueError("Existing ID cannot be moved or revived; review a new version")
+ raise ValueError("Item already recorded/reserved; verify remotely, never publish again")
+ collisions = conflicts(state, item_id, at)
+ if collisions:
+ raise ValueError("Page cadence collision: " + ", ".join(s["id"] for s in collisions))
+ if not approval.strip():
+ raise ValueError("Human approval reference required; calendar is not authorization")
+ row = {"id": item_id, "at": at, "status": "reserved", "approval_ref": approval,
+ "recorded_at": now.isoformat()}
+ state["slots"].append(row)
+ return row
+
+
+@contextmanager
+def calendar(path):
+ # Never start with an empty calendar just because the real file disappeared.
+ path = Path(path)
+ with path.with_suffix(path.suffix + ".lock").open("a") as lock:
+ fcntl.flock(lock, fcntl.LOCK_EX)
+ with path.open() as f:
+ state = json.load(f)
+ if state.get("account") != "linkedin:5041007" or not isinstance(state.get("slots"), list):
+ raise ValueError("Wrong or malformed Page calendar")
+ yield state
+ temp = path.with_suffix(path.suffix + f".{os.getpid()}.tmp")
+ try:
+ temp.write_text(json.dumps(state, indent=2) + "\n")
+ temp.replace(path)
+ finally:
+ temp.unlink(missing_ok=True)
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument("--calendar", required=True)
+ sub = p.add_subparsers(dest="command", required=True)
+ for cmd in ("check", "reserve", "record"):
+ c = sub.add_parser(cmd)
+ c.add_argument("--id", required=True)
+ c.add_argument("--at", required=True)
+ if cmd == "reserve": c.add_argument("--approval-ref", required=True)
+ if cmd == "record":
+ c.add_argument("--status", choices=["published", "scheduled", "cancelled"], required=True)
+ c.add_argument("--evidence", required=True)
+ c.add_argument("--url", required=True)
+ c = sub.add_parser("audit")
+ for name in ("observed-at", "from", "through", "evidence"):
+ c.add_argument("--" + name, required=True)
+ a = p.parse_args()
+ now = datetime.now(timezone.utc)
+ try:
+ with calendar(a.calendar) as state:
+ if a.command == "audit":
+ if not timedelta(0) <= now - instant(a.observed_at) <= FRESH:
+ raise ValueError("Audit must describe a current native observation")
+ if instant(a.__dict__["from"]) >= instant(a.through):
+ raise ValueError("Invalid audit coverage")
+ state["native_audit"] = {"observed_at": a.observed_at, "from": a.__dict__["from"],
+ "through": a.through, "evidence": a.evidence}
+ result = state["native_audit"]
+ elif a.command == "check":
+ result = {"conflicts": conflicts(state, a.id, a.at), "planning_only": True}
+ elif a.command == "reserve":
+ result = reserve(state, a.id, a.at, a.approval_ref, now)
+ else:
+ instant(a.at)
+ existing = next((s for s in state["slots"] if s["id"] == a.id), None)
+ if existing is not None and existing["at"] != a.at:
+ raise ValueError("Timing changed; reconcile explicitly with a new observation ID")
+ row = {"id": a.id, "at": a.at, "status": a.status, "url": a.url,
+ "evidence": a.evidence, "recorded_at": now.isoformat()}
+ # Record outside publications even if they reveal a collision.
+ if existing is not None: existing.update(row)
+ else: state["slots"].append(row)
+ result = row
+ print(json.dumps(result, indent=2))
+ if a.command == "check" and result["conflicts"]: return 1
+ return 0
+ except (ValueError, KeyError, OSError) as e:
+ p.exit(2, str(e) + "\n")
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/website/social_campaigns.py b/scripts/website/social_campaigns.py
new file mode 100644
index 00000000000..8e853a792f1
--- /dev/null
+++ b/scripts/website/social_campaigns.py
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+"""Build reviewed social links and summarize existing aggregate BuildCloud exports.
+
+No network, credentials, public publishing, or raw user export is involved.
+"""
+import argparse
+import json
+import re
+from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
+
+SOURCES = {"bluesky", "mastodon", "x", "linkedin-page", "linkedin-shai",
+ "youtube-codenameone", "youtube-debugagent", "reddit"}
+TOKEN = re.compile(r"^[a-z0-9][a-z0-9_.:-]{0,127}$")
+FIELDS = ("signedUp", "reachedConsole", "attempted", "successful", "active30d")
+
+
+def campaign_link(url, source, campaign, item):
+ parts = urlsplit(url)
+ if parts.scheme != "https" or parts.netloc not in {"www.codenameone.com", "codenameone.com", "cloud.codenameone.com"}:
+ raise ValueError("Only canonical Codename One HTTPS destinations may be tagged")
+ if source not in SOURCES or not TOKEN.fullmatch(campaign) or not TOKEN.fullmatch(item):
+ raise ValueError("Invalid source, campaign or stable batch/item ID")
+ query = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) if not k.startswith("utm_")]
+ query += [("utm_source", source), ("utm_medium", "community" if source == "reddit" else "social"),
+ ("utm_campaign", campaign), ("utm_content", item)]
+ return urlunsplit(parts._replace(query=urlencode(query)))
+
+
+def summarize(payload, window):
+ funnel = payload.get("funnelCohorts", payload)
+ w = funnel.get("windows", {}).get(window)
+ if not isinstance(w, dict) or w.get("diagnosticsAvailable") is False:
+ raise ValueError("Requested cohort/attribution diagnostics unavailable; do not report zero")
+ a = w.get("acquisitionAttribution")
+ if not isinstance(a, dict) or not isinstance(a.get("byCampaign"), list):
+ raise ValueError("Aggregate campaign rows missing; do not report zero")
+ rows, all_signups, all_successes = [], 0, 0
+ for c in a["byCampaign"]:
+ if any(type(c.get(k)) is not int or c[k] < 0 for k in FIELDS):
+ raise ValueError("Incomplete or invalid campaign counts")
+ if not c["successful"] <= c["attempted"] <= c["signedUp"]:
+ raise ValueError("Campaign funnel counts do not reconcile")
+ all_signups += c["signedUp"]
+ all_successes += c["successful"]
+ if c.get("medium") not in {"social", "community"} or c.get("source") not in SOURCES:
+ continue
+ # Explicit field allowlist: never carry recent-user identities into reports.
+ rows.append({k: c.get(k) for k in ("source", "medium", "campaign", "content", *FIELDS)})
+ eligible = a.get("eligibleSignups")
+ if type(eligible) is not int or eligible != all_signups:
+ raise ValueError("Campaign signup denominator does not reconcile")
+ headline = w.get("activated")
+ return {"cohort_window": window, "days": w.get("days"), "eligible_signups": eligible,
+ "attribution_rows": a.get("attributed"), "coverage_pct": a.get("coveragePct"),
+ "campaign_successes_all_sources": all_successes, "headline_activated": headline,
+ "successes_reconcile_with_headline": None if headline is None else headline == all_successes,
+ "social_posts": rows,
+ "social_attributed_new_users_with_successful_build": sum(c["successful"] for c in rows),
+ "definition": "Unique new users in this signup cohort with at least one observed successful build; not build counts, click conversions, or causal lift.",
+ "limitations": ["First-touch attribution; missing/declined/expired/cross-device journeys remain unassigned.",
+ "Rolling signup cohort outcomes as of export, not first-build events occurring in the reporting week.",
+ "A first attempt may fail before the first successful build. Do not use firstAttemptSucceeded as activation."]}
+
+
+def main():
+ p = argparse.ArgumentParser(description=__doc__)
+ sub = p.add_subparsers(dest="command", required=True)
+ link = sub.add_parser("link")
+ for name in ("url", "source", "campaign", "item"):
+ link.add_argument("--" + name, required=True)
+ report = sub.add_parser("report")
+ report.add_argument("export", help="Private saved aggregate funnel-cohorts JSON")
+ report.add_argument("--window", default="d30", choices=["d30", "d90"])
+ a = p.parse_args()
+ try:
+ if a.command == "link":
+ print(campaign_link(a.url, a.source, a.campaign, a.item))
+ else:
+ with open(a.export) as f:
+ print(json.dumps(summarize(json.load(f), a.window), indent=2))
+ except (ValueError, OSError) as e:
+ p.exit(2, str(e) + "\n")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/website/test_social_operations.py b/scripts/website/test_social_operations.py
new file mode 100644
index 00000000000..a8fa3a6bbf5
--- /dev/null
+++ b/scripts/website/test_social_operations.py
@@ -0,0 +1,118 @@
+import copy
+from datetime import datetime, timezone, timedelta
+import json
+from pathlib import Path
+import tempfile
+import unittest
+
+from linkedin_page_calendar import calendar, conflicts, reserve
+from social_campaigns import campaign_link, summarize
+from install_linkedin_coordinator_guard import guarded
+
+
+class CalendarTests(unittest.TestCase):
+ def setUp(self):
+ self.now = datetime(2026, 10, 19, 23, 5, tzinfo=timezone.utc)
+ self.state = {"account": "linkedin:5041007", "slots": [], "native_audit": {
+ "observed_at": self.now.isoformat(), "from": "2026-10-18T00:00:00Z",
+ "through": "2026-10-23T00:00:00Z", "evidence": "feed and scheduled queue"}}
+
+ def test_catches_future_native_schedule_and_published_post(self):
+ self.state['slots'] = [{"id": "outside-producer", "at": "2026-10-20T15:30:00+03:00", "status": "scheduled"}]
+ with self.assertRaisesRegex(ValueError, 'collision'):
+ reserve(self.state, 'tabs', '2026-10-20T20:30:00+03:00', 'chat approval', self.now)
+ self.state['slots'][0]['status'] = 'published'
+ self.assertTrue(conflicts(self.state, 'tabs', '2026-10-20T20:30:00+03:00'))
+
+ def test_exact_twenty_hours_and_timezone_offsets(self):
+ self.state['slots'] = [{"id": "a", "at": "2026-10-20T02:00:00+03:00", "status": "published"}]
+ self.assertFalse(conflicts(self.state, 'b', '2026-10-20T19:00:00Z'))
+ self.assertTrue(conflicts(self.state, 'b', '2026-10-20T18:59:59Z'))
+ # Fall-back DST offsets are compared as instants, not local wall times.
+ self.state['slots'][0]['at'] = '2026-10-24T20:30:00+03:00'
+ self.assertFalse(conflicts(self.state, 'b', '2026-10-25T16:30:00+02:00'))
+
+ def test_crashed_reservation_blocks_retry_and_other_producers(self):
+ reserve(self.state, 'a', '2026-10-20T20:30:00+03:00', 'chat approval', self.now)
+ for item in ['a', 'b']:
+ with self.assertRaises(ValueError):
+ reserve(self.state, item, '2026-10-20T20:30:00+03:00', 'chat approval', self.now)
+
+ def test_stale_audit_missing_future_coverage_and_naive_time_fail_closed(self):
+ with self.assertRaisesRegex(ValueError, 'Fresh'):
+ reserve(self.state, 'a', '2026-10-20T20:30:00+03:00', 'approval', self.now+timedelta(minutes=16))
+ self.state['native_audit']['through'] = '2026-10-20T22:00:00+03:00'
+ with self.assertRaisesRegex(ValueError, 'before AND after'):
+ reserve(self.state, 'a', '2026-10-20T20:30:00+03:00', 'approval', self.now)
+ with self.assertRaises(ValueError): conflicts(self.state, 'a', '2026-10-20T20:30:00')
+
+ def test_atomic_persistence_and_missing_calendar(self):
+ with tempfile.TemporaryDirectory() as td:
+ path = Path(td)/'calendar.json'
+ with self.assertRaises(FileNotFoundError):
+ with calendar(path): pass
+ path.write_text(json.dumps(self.state))
+ with calendar(path) as state:
+ reserve(state, 'a', '2026-10-20T20:30:00+03:00', 'approval', self.now)
+ with self.assertRaises(ValueError):
+ with calendar(path) as state:
+ reserve(state, 'b', '2026-10-20T21:30:00+03:00', 'approval', self.now)
+ self.assertEqual(['a'],[s['id'] for s in json.loads(path.read_text())['slots']])
+
+
+class AttributionTests(unittest.TestCase):
+ def test_link_preserves_destination_and_has_unique_item(self):
+ result=campaign_link('https://www.codenameone.com/demo/?mode=java#code','linkedin-page','social-2026-10','2026-10-20-v2-l1')
+ self.assertIn('mode=java&',result)
+ self.assertIn('utm_content=2026-10-20-v2-l1',result)
+ self.assertTrue(result.endswith('#code'))
+ with self.assertRaises(ValueError): campaign_link('https://evil.example/','x','campaign','post')
+
+ def payload(self):
+ return {'windows':{'d30':{'days':30,'activated':1,'diagnosticsAvailable':True,'acquisitionAttribution':{
+ 'eligibleSignups':2,'attributed':1,'coveragePct':50,'byCampaign':[
+ {'source':'x','medium':'social','campaign':'social-2026-10','content':'2026-10-20-v2-x1',
+ 'signedUp':1,'reachedConsole':1,'attempted':1,'successful':1,'active30d':1,'email':'NEVER EXPORT'},
+ {'source':'unattributed','medium':'','campaign':'','content':'','signedUp':1,'reachedConsole':0,'attempted':0,'successful':0,'active30d':0}]}}}}
+
+ def test_aggregate_activation_and_identity_allowlist(self):
+ report=summarize(self.payload(),'d30')
+ self.assertEqual(1,report['social_attributed_new_users_with_successful_build'])
+ self.assertTrue(report['successes_reconcile_with_headline'])
+ self.assertNotIn('NEVER EXPORT',json.dumps(report))
+ p=self.payload();p['windows']['d30']['activated']=0
+ self.assertFalse(summarize(p,'d30')['successes_reconcile_with_headline'])
+
+ def test_missing_diagnostics_is_unknown_not_zero(self):
+ for p in [{}, {'windows': {'d30': {'diagnosticsAvailable':False}}}]:
+ with self.assertRaises(ValueError):summarize(p,'d30')
+ p=self.payload();del p['windows']['d30']['acquisitionAttribution']['byCampaign'][0]['successful']
+ with self.assertRaises(ValueError): summarize(p,'d30')
+
+
+class LegacyHandoffTests(unittest.TestCase):
+ def test_guard_blocks_before_any_browser_initialization(self):
+ source = '''
+DRIVERS = {}
+def process_linkedin(driver, task, log):
+ """Publish one due LinkedIn post as Shai or the Codename One page."""
+ raise AssertionError("browser reached")
+def process_task(task, log, profile_dir, chrome_version):
+ site = task["site"]
+ driver_fn = DRIVERS.get(site)
+ raise AssertionError("browser reached")
+def main():
+ if True:
+ pending = [t for t in unfinished if is_due(t, now)]
+'''
+ code = guarded(source)
+ self.assertEqual(code, guarded(code))
+ ns={};exec(code,ns)
+ self.assertFalse(ns['process_task']({'site':'linkedin'}, None, None, None)[0])
+ with self.assertRaisesRegex(RuntimeError, 'coordinator'):
+ ns['process_linkedin'](None,{},None)
+ with self.assertRaises(AssertionError):
+ ns['process_task']({'site':'medium'},None,None,None)
+
+
+if __name__ == '__main__': unittest.main()