Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/actions/prepare-linkedin-release-post/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Prepare LinkedIn release post
description: Prepare a LinkedIn post for a published release.
outputs:
message:
description: Prepared post text.
value: ${{ steps.prepare.outputs.message }}
runs:
using: composite
steps:
- id: prepare
shell: bash
env:
REPOSITORY: ${{ github.repository }}
TAG_NAME: ${{ github.event.release.tag_name }}
RELEASE_NAME: ${{ github.event.release.name }}
RELEASE_URL: ${{ github.event.release.html_url }}
RELEASE_BODY: ${{ github.event.release.body }}
REPOSITORY_DESCRIPTION: ${{ github.event.repository.description }}
REPOSITORY_TOPICS: ${{ toJSON(github.event.repository.topics) }}
run: python3 "${{ github.action_path }}/prepare.py"
135 changes: 135 additions & 0 deletions .github/actions/prepare-linkedin-release-post/prepare.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
import json
import os
import re
import uuid

LINKEDIN_POST_LIMIT = 2800
MARKDOWN_HEADING = re.compile(r"^(#{1,6})\s+")
MARKDOWN_LINK = re.compile(r"\[([^]]+)]\([^)]*\)")
MARKDOWN_LIST_ITEM = re.compile(r"^(?:[-+*]|\d+\.)\s+")
LITTLE_RESERVED = frozenset("|{}@[]()<>#\\*_~")


def truncate(value, limit):
if len(value) <= limit:
return value
return value[: limit - 3].rstrip() + "..."
Comment on lines +14 to +17


def escape_little_text(value):
return "".join(f"\\{character}" if character in LITTLE_RESERVED else character for character in value)


def truncate_little_text(value, limit):
escaped = escape_little_text(value)
if len(escaped) <= limit:
return escaped

available = limit - 3
result = []
length = 0
for character in value:
token = f"\\{character}" if character in LITTLE_RESERVED else character
if length + len(token) > available:
break
result.append(token)
length += len(token)
return "".join(result).rstrip() + "..."


def is_release_heading(value, tag):
version = re.escape(tag.removeprefix("v"))
return bool(re.fullmatch(rf"v?{version}(?:\s+\(\d{{4}}-\d{{2}}-\d{{2}}\))?", value))


def release_description(value, tag=""):
lines = []
for source_line in value.strip().splitlines():
source_line = source_line.strip()
if not source_line:
if lines and lines[-1]:
lines.append("")
continue

heading = bool(MARKDOWN_HEADING.match(source_line))
list_item = bool(MARKDOWN_LIST_ITEM.match(source_line))
line = MARKDOWN_HEADING.sub("", source_line)
line = MARKDOWN_LIST_ITEM.sub("", line)
line = MARKDOWN_LINK.sub(r"\1", line)
if not line or line in ("---", "***"):
continue
if heading and not any(lines) and tag and is_release_heading(line, tag):
continue

lines.append(f"• {line}" if list_item else line)

while lines and not lines[-1]:
lines.pop()
return "\n".join(lines)


def format_hashtags(topics):
hashtags = []
for topic in topics:
name = "".join(word.capitalize() for word in re.findall(r"[A-Za-z0-9]+", topic))
hashtag = f"#{name}" if name else ""
if hashtag and hashtag not in hashtags:
hashtags.append(hashtag)
return " ".join(hashtags)


def format_published(repository, release, repository_description="", topics=None, limit=LINKEDIN_POST_LIMIT):
tag = release["tag"]
header = escape_little_text(f"{repository} {release.get('name') or tag}")
footer = escape_little_text(release["url"])
hashtags = format_hashtags(topics or [])
if repository_description:
header = f"{header}\n\n{escape_little_text(repository_description)}"
if hashtags:
footer = f"{footer}\n\n{hashtags}"

description = release_description(release.get("body") or "", tag=tag)
fixed = f"{header}\n\n{footer}"
if not description:
return truncate(fixed, limit)

description_limit = limit - len(fixed) - 2
if description_limit <= 0:
return truncate(fixed, limit)
return f"{header}\n\n{truncate_little_text(description, description_limit)}\n\n{footer}"


def write_output(message):
delimiter = f"ghdelim_{uuid.uuid4().hex}"
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
print(f"message<<{delimiter}", file=output)
print(message, file=output)
print(delimiter, file=output)


def main():
release = {
"tag": os.environ["TAG_NAME"],
"name": os.environ.get("RELEASE_NAME") or "",
"url": os.environ["RELEASE_URL"],
"body": os.environ.get("RELEASE_BODY") or "",
}
topics = json.loads(os.environ.get("REPOSITORY_TOPICS") or "[]")
if topics is None:
topics = []
if not isinstance(topics, list) or not all(isinstance(topic, str) for topic in topics):
raise ValueError("repository topics must be a JSON array of strings")
write_output(
format_published(
os.environ["REPOSITORY"],
release,
repository_description=os.environ.get("REPOSITORY_DESCRIPTION") or "",
topics=topics,
)
)
return 0


if __name__ == "__main__":
raise SystemExit(main())
22 changes: 22 additions & 0 deletions .github/actions/send-linkedin-post/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Send LinkedIn post
description: Publish a text post to an authenticated LinkedIn member feed.
inputs:
message:
description: Post text.
required: true
linkedin-access-token:
description: LinkedIn member OAuth access token.
required: true
outputs:
post-id:
description: Published LinkedIn post URN.
value: ${{ steps.send.outputs.post-id }}
runs:
using: composite
steps:
- id: send
shell: bash
env:
MESSAGE: ${{ inputs.message }}
LINKEDIN_ACCESS_TOKEN: ${{ inputs.linkedin-access-token }}
run: python3 "${{ github.action_path }}/send.py"
102 changes: 102 additions & 0 deletions .github/actions/send-linkedin-post/send.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
#!/usr/bin/env python3
import json
import os
import sys
import urllib.error
import urllib.request

LINKEDIN_API_VERSION = "202608"
USERINFO_URL = "https://api.linkedin.com/v2/userinfo"
POSTS_URL = "https://api.linkedin.com/rest/posts"


def open_request(request):
return urllib.request.urlopen(request, timeout=30)


def person_urn(access_token, opener=open_request):
request = urllib.request.Request(
USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
with opener(request) as response:
data = json.load(response)

subject = data.get("sub")
if not isinstance(subject, str) or not subject:
raise ValueError("LinkedIn userinfo response does not contain a member subject")
return f"urn:li:person:{subject}"


def publish_post(access_token, author, message, opener=open_request):
payload = {
"author": author,
"commentary": message,
"visibility": "PUBLIC",
"distribution": {
"feedDistribution": "MAIN_FEED",
"targetEntities": [],
"thirdPartyDistributionChannels": [],
},
"lifecycleState": "PUBLISHED",
"isReshareDisabledByAuthor": False,
}
request = urllib.request.Request(
POSTS_URL,
data=json.dumps(payload, ensure_ascii=False).encode(),
method="POST",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
"Linkedin-Version": LINKEDIN_API_VERSION,
"X-Restli-Protocol-Version": "2.0.0",
},
)
with opener(request) as response:
if response.status != 201:
raise ValueError(f"LinkedIn create-post request returned HTTP {response.status}, expected 201")
post_id = response.headers.get("x-restli-id")

if not post_id:
raise ValueError("LinkedIn create-post response does not contain x-restli-id")
return post_id


def write_output(post_id):
if "\n" in post_id:
raise ValueError("LinkedIn post identifier must be a single line")
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
print(f"post-id={post_id}", file=output)


def write_summary(post_id):
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
if not summary_path:
return
with open(summary_path, "a") as summary:
print(f"LinkedIn post published: `{post_id}`", file=summary)


def error_detail(error):
body = error.read().decode(errors="replace").strip()
return f"LinkedIn API request failed with HTTP {error.code}: {body or error.reason}"


def main():
try:
access_token = os.environ["LINKEDIN_ACCESS_TOKEN"]
author = person_urn(access_token)
post_id = publish_post(access_token, author, os.environ["MESSAGE"])
write_output(post_id)
write_summary(post_id)
except urllib.error.HTTPError as error:
print(error_detail(error), file=sys.stderr)
return 1
except (KeyError, OSError, ValueError, urllib.error.URLError) as error:
print(f"LinkedIn post failed: {error}", file=sys.stderr)
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main())
69 changes: 69 additions & 0 deletions .github/workflows/publish-linkedin-release-shared.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
name: Publish LinkedIn release (shared)
on:
workflow_call:
inputs:
telegram-chat-id:
description: Telegram chat ID that receives publish notifications. Omit to skip notifications.
required: false
type: string
secrets:
linkedin-access-token:
required: true
telegram-bot-token:
description: Telegram bot token. Omit to skip publish notifications.
required: false
outputs:
post-id:
description: Published LinkedIn post URN.
value: ${{ jobs.publish.outputs.post-id }}
concurrency:
group: linkedin-release-${{ github.repository_id }}-${{ github.event.release.id }}
cancel-in-progress: false
jobs:
publish:
if: >-
github.event_name == 'release' &&
github.event.action == 'published' &&
github.event.release.draft == false &&
github.event.release.prerelease == false
runs-on: ubuntu-slim
permissions: {}
outputs:
post-id: ${{ steps.send.outputs.post-id }}
steps:
- id: prepare
uses: $/.github/actions/prepare-linkedin-release-post
- id: send
name: Publish
uses: $/.github/actions/send-linkedin-post
with:
message: ${{ steps.prepare.outputs.message }}
linkedin-access-token: ${{ secrets.linkedin-access-token }}
notify:
needs: publish
if: >-
always() &&
inputs.telegram-chat-id != '' &&
(needs.publish.result == 'success' || needs.publish.result == 'failure')
Comment on lines +44 to +47
runs-on: ubuntu-slim
permissions: {}
steps:
- name: Notify success
if: needs.publish.result == 'success'
uses: $/.github/actions/send-telegram-message
with:
message: >-
${{ github.repository }} — LinkedIn post published for
${{ github.event.release.tag_name }}: ${{ needs.publish.outputs.post-id }}
telegram-bot-token: ${{ secrets.telegram-bot-token }}
telegram-chat-id: ${{ inputs.telegram-chat-id }}
- name: Notify failure
if: needs.publish.result == 'failure'
uses: $/.github/actions/send-telegram-message
with:
message: >-
${{ github.repository }} — failed to publish LinkedIn post for
${{ github.event.release.tag_name }}:
${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
telegram-bot-token: ${{ secrets.telegram-bot-token }}
telegram-chat-id: ${{ inputs.telegram-chat-id }}
12 changes: 12 additions & 0 deletions .github/workflows/publish-linkedin-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: Publish LinkedIn release
on:
release:
types: [published]
jobs:
publish:
uses: ./.github/workflows/publish-linkedin-release-shared.yml
with:
telegram-chat-id: ${{ vars.TELEGRAM_CHAT_ID }}
secrets:
linkedin-access-token: ${{ secrets.LINKEDIN_ACCESS_TOKEN }}
telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }}
Loading