diff --git a/.github/actions/prepare-linkedin-release-post/action.yml b/.github/actions/prepare-linkedin-release-post/action.yml new file mode 100644 index 0000000..afef024 --- /dev/null +++ b/.github/actions/prepare-linkedin-release-post/action.yml @@ -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" diff --git a/.github/actions/prepare-linkedin-release-post/prepare.py b/.github/actions/prepare-linkedin-release-post/prepare.py new file mode 100644 index 0000000..24df321 --- /dev/null +++ b/.github/actions/prepare-linkedin-release-post/prepare.py @@ -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() + "..." + + +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()) diff --git a/.github/actions/send-linkedin-post/action.yml b/.github/actions/send-linkedin-post/action.yml new file mode 100644 index 0000000..bc8f78d --- /dev/null +++ b/.github/actions/send-linkedin-post/action.yml @@ -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" diff --git a/.github/actions/send-linkedin-post/send.py b/.github/actions/send-linkedin-post/send.py new file mode 100644 index 0000000..a3f0df6 --- /dev/null +++ b/.github/actions/send-linkedin-post/send.py @@ -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()) diff --git a/.github/workflows/publish-linkedin-release-shared.yml b/.github/workflows/publish-linkedin-release-shared.yml new file mode 100644 index 0000000..710877d --- /dev/null +++ b/.github/workflows/publish-linkedin-release-shared.yml @@ -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') + 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 }} diff --git a/.github/workflows/publish-linkedin-release.yml b/.github/workflows/publish-linkedin-release.yml new file mode 100644 index 0000000..354ac17 --- /dev/null +++ b/.github/workflows/publish-linkedin-release.yml @@ -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 }} diff --git a/README.md b/README.md index 16b2dca..5d4c879 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,51 @@ Baseline sends one notification when a release or prerelease is published. The release tag links to the GitHub Release. Pass the chat ID as an Actions variable and the bot token as a secret. +## Publish releases on LinkedIn + +Create `.github/workflows/publish-linkedin-release.yml`: + + + +```yaml +name: Publish LinkedIn release +on: + release: + types: [published] +jobs: + publish: + uses: rubykatzen/baseline/.github/workflows/publish-linkedin-release-shared.yml@v0.17.2 + with: + telegram-chat-id: ${{ vars.TELEGRAM_CHAT_ID }} + secrets: + linkedin-access-token: ${{ secrets.LINKEDIN_ACCESS_TOKEN }} + telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} +``` + + + +Baseline publishes stable releases to the authenticated member's public LinkedIn +feed. It converts the repository name, description, topics, release name, body, +and URL to LinkedIn's `little` text format. Repository topics become PascalCase +hashtags. +The workflow resolves the member identity through OpenID Connect and reports the +resulting LinkedIn post URN. Drafts and prereleases are skipped. + +`telegram-chat-id` and `telegram-bot-token` are optional. When both are set, the +workflow sends a Telegram message reporting whether the LinkedIn post succeeded +or failed. Omit them to skip these notifications. + +Create a LinkedIn developer application with the `Share on LinkedIn` and `Sign +In with LinkedIn using OpenID Connect` products. Generate a member token with +the `openid`, `profile`, and `w_member_social` scopes, then store it as the +`LINKEDIN_ACCESS_TOKEN` Actions secret. LinkedIn member access tokens normally +expire after 60 days and must be replaced manually unless the application has +partner-only programmatic refresh access. + +The publish request is sent once and is not retried automatically. Do not rerun +a failed job until confirming that LinkedIn did not create the post; a connection +failure after LinkedIn accepts the request can otherwise produce a duplicate. + ## Notify Telegram about closed issues Issue notifications are optional and can use a different Telegram channel from diff --git a/test/test_linkedin_release.py b/test/test_linkedin_release.py new file mode 100644 index 0000000..93673cb --- /dev/null +++ b/test/test_linkedin_release.py @@ -0,0 +1,318 @@ +import importlib.util +import io +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import yaml + +BASELINE_ROOT = Path(__file__).parent.parent + + +def load_action_module(action, module): + path = BASELINE_ROOT / ".github" / "actions" / action / module + spec = importlib.util.spec_from_file_location(action, path) + loaded = importlib.util.module_from_spec(spec) + spec.loader.exec_module(loaded) + return loaded + + +PREPARE = load_action_module("prepare-linkedin-release-post", "prepare.py") +SEND = load_action_module("send-linkedin-post", "send.py") + + +class FakeResponse: + def __init__(self, body=b"", headers=None, status=200): + self.body = body + self.headers = headers or {} + self.status = status + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def read(self, _size=-1): + return self.body + + +class LinkedInReleasePostTest(unittest.TestCase): + def setUp(self): + self.repository_description = "Homogeneous development baseline." + self.topics = ["github-actions", "release-please", "python"] + self.release = { + "tag": "v0.18.0", + "name": "v0.18.0", + "url": "https://github.com/owner/repo/releases/tag/v0.18.0", + "body": ( + "## [0.18.0](https://github.com/owner/repo/compare/v0.17.0...v0.18.0) (2026-09-01)\n\n" + "### Features\n\n" + "* publish releases to LinkedIn " + "([#193](https://github.com/owner/repo/issues/193))\n\n" + "Release automation stays deterministic." + ), + } + + def test_formats_release_please_body_as_little_text(self): + message = PREPARE.format_published( + "owner/repo", + self.release, + repository_description=self.repository_description, + topics=self.topics, + ) + + self.assertEqual( + message, + "owner/repo v0.18.0\n\n" + "Homogeneous development baseline.\n\n" + "Features\n\n" + "• publish releases to LinkedIn \\(\\#193\\)\n\n" + "Release automation stays deterministic.\n\n" + "https://github.com/owner/repo/releases/tag/v0.18.0\n\n" + "#GithubActions #ReleasePlease #Python", + ) + self.assertNotIn("[", message) + + def test_uses_distinct_release_name(self): + self.release["name"] = "LinkedIn publishing" + + message = PREPARE.format_published("owner/repo", self.release) + + self.assertTrue(message.startswith("owner/repo LinkedIn publishing\n\n")) + + def test_formats_release_without_body(self): + self.release["body"] = "" + + message = PREPARE.format_published( + "owner/repo", + self.release, + repository_description=self.repository_description, + topics=self.topics, + ) + + self.assertEqual( + message, + "owner/repo v0.18.0\n\n" + "Homogeneous development baseline.\n\n" + "https://github.com/owner/repo/releases/tag/v0.18.0\n\n" + "#GithubActions #ReleasePlease #Python", + ) + + def test_escapes_little_reserved_characters(self): + self.release["body"] = "Use @name, #tag, *bold*, and snake_case." + + message = PREPARE.format_published("owner/repo", self.release) + + self.assertIn(r"Use \@name, \#tag, \*bold\*, and snake\_case.", message) + + def test_normalizes_and_deduplicates_repository_topics(self): + self.assertEqual( + PREPARE.format_hashtags(["github-actions", "c-plus-plus", "github-actions", "---"]), + "#GithubActions #CPlusPlus", + ) + + def test_truncates_body_and_preserves_release_url(self): + self.release["body"] = "x" * 4000 + + message = PREPARE.format_published( + "owner/repo", + self.release, + repository_description=self.repository_description, + topics=self.topics, + ) + + self.assertLessEqual(len(message), PREPARE.LINKEDIN_POST_LIMIT) + self.assertIn("...\n\n", message) + self.assertIn(self.release["url"], message) + self.assertTrue(message.endswith("#GithubActions #ReleasePlease #Python")) + + def test_writes_multiline_github_output(self): + with tempfile.NamedTemporaryFile() as output, patch.dict(os.environ, {"GITHUB_OUTPUT": output.name}): + PREPARE.write_output("first\nsecond") + value = Path(output.name).read_text() + + self.assertRegex(value, r"^message<