Local publishing workflow scaffold for WaterApps LinkedIn content.
Purpose:
- keep post drafts in versioned files
- validate copy + links + UTM parameters
- preview final post text
- support dry-run publishing now
- add real LinkedIn API posting later (credentials/scopes required)
This is a scaffold. It supports:
validatepreviewlist(queue view: drafts/approved/published)move(change post status and queue folder)publish --dry-runpublish --live(API hook, requires LinkedIn app credentials and approved access)- GitHub Actions for validation and optional scheduled batch publishing
cd /Users/varunau/Projects/waterapps/instances/water-apps/waterapps-linkedin-publisher
python3 publisher.py validate posts/sample_audit_ready_cicd.json
python3 publisher.py preview posts/sample_audit_ready_cicd.json
python3 publisher.py list
python3 publisher.py move posts/sample_audit_ready_cicd.json --to drafts
python3 publisher.py publish posts/sample_audit_ready_cicd.json --dry-run
python3 linkedin_auth_helper.py auth-url --redirect-uri "https://YOUR_CALLBACK"Posts are stored as JSON for zero-dependency parsing.
Queue folders are available under posts/:
posts/drafts/posts/approved/posts/published/posts/logs/publish_log.jsonl(created on publish runs)
Required fields:
idstatusaudienceheadlinebody
Optional fields:
cta_urlutm(object)tags(array)notes
See posts/sample_audit_ready_cicd.json.
LinkedIn posting requires:
- a LinkedIn app
- approved product/scopes for posting
- access token
- author URN (profile or organization)
Set environment variables (see .env.example) before --live.
Detailed setup notes: docs/LINKEDIN_API_SETUP.md
Use linkedin_auth_helper.py to assist with:
- generating OAuth authorization URLs
- exchanging an auth code for an access token
- fetching
/v2/me - printing
urn:li:person:<id>forLINKEDIN_AUTHOR_URN
Examples:
# Start a local callback listener (recommended)
python3 linkedin_auth_helper.py listen-callback --host 127.0.0.1 --port 8080 --path /callback
# Generate auth URL (uses LINKEDIN_CLIENT_ID from env if not provided)
# IMPORTANT: register this exact redirect URI in LinkedIn app settings first
python3 linkedin_auth_helper.py auth-url --redirect-uri "http://127.0.0.1:8080/callback"
# Optional: include expected state verification
python3 linkedin_auth_helper.py auth-url --redirect-uri "http://127.0.0.1:8080/callback" --state "YOUR_STATE"
python3 linkedin_auth_helper.py listen-callback --host 127.0.0.1 --port 8080 --path /callback --expected-state "YOUR_STATE"
# Extract code from the full browser callback URL (recommended)
python3 linkedin_auth_helper.py extract-code --url "https://localhost/callback?code=REAL_CODE&state=REAL_STATE"
# Exchange auth code for token
python3 linkedin_auth_helper.py exchange-code \
--client-id "$LINKEDIN_CLIENT_ID" \
--client-secret "$LINKEDIN_CLIENT_SECRET" \
--redirect-uri "https://localhost/callback" \
--code "AUTH_CODE_FROM_CALLBACK"
# Resolve person URN from token (requires profile scope support on your app)
python3 linkedin_auth_helper.py person-urn --access-token "$LINKEDIN_ACCESS_TOKEN"Common mistake to avoid:
- The
statevalue printed byauth-urlis not the OAuthcode. - Use
extract-codeon the full callback URL after LinkedIn redirects your browser. redirect_uriinauth-urlandexchange-codemust match the registered LinkedIn app callback URL exactly.- Some LinkedIn apps are not authorized for
openid/profile; the helper now defaults tow_member_socialonly. - If you need
person-urnlookup and your app supports it, pass--scopes "openid profile w_member_social"(or an approved profile-read scope) explicitly.
Included workflows:
.github/workflows/linkedin-content-quality.yml- validates all queued post JSON files on push/PR
.github/workflows/linkedin-batch-publish.yml- manual
workflow_dispatchbatch publish (dry-run/live) - optional scheduled posting (disabled by default)
- manual
To enable unattended scheduled posting:
- Add repo secrets:
LINKEDIN_ACCESS_TOKENLINKEDIN_AUTHOR_URN
- Add repo variable:
LINKEDIN_AUTOPUBLISH_ENABLED=true
- (Optional) Add repo variable:
LINKEDIN_BATCH_LIMIT=1
Recommended rollout:
- Start with workflow_dispatch +
dry-run - Test workflow_dispatch +
live - Only then enable scheduled autopublish
publishdefaults to dry-run- copy validation warns on likely issues (length, missing UTM on WaterApps URLs)
- no scraping or outreach automation features are included
- live publish does not move files automatically unless
--move-to-publishedis set
Use scripts/linkedin_oauth_setup.py to run the local OAuth flow (browser approval still required), exchange the code, and update GitHub secrets/vars via gh.
export LINKEDIN_CLIENT_ID="<your-client-id>"
export LINKEDIN_CLIENT_SECRET="<your-client-secret>"
python3 scripts/linkedin_oauth_setup.pyIt will attempt to resolve and store LINKEDIN_AUTHOR_URN automatically when the token has profile-read permissions.
Shell fallback (older implementation) is still available:
./scripts/linkedin_oauth_setup.sh