From 665ed89bd48d63e68baae0d753b43b3c1281a9e4 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Tue, 1 Sep 2026 00:53:23 +0200 Subject: [PATCH 1/4] feat: publish releases to LinkedIn --- .../prepare-linkedin-release-post/action.yml | 18 ++ .../prepare-linkedin-release-post/prepare.py | 84 ++++++ .github/actions/send-linkedin-post/action.yml | 22 ++ .github/actions/send-linkedin-post/send.py | 102 +++++++ .../publish-linkedin-release-shared.yml | 33 +++ .../workflows/publish-linkedin-release.yml | 9 + README.md | 36 +++ test/test_linkedin_release.py | 249 ++++++++++++++++++ 8 files changed, 553 insertions(+) create mode 100644 .github/actions/prepare-linkedin-release-post/action.yml create mode 100644 .github/actions/prepare-linkedin-release-post/prepare.py create mode 100644 .github/actions/send-linkedin-post/action.yml create mode 100644 .github/actions/send-linkedin-post/send.py create mode 100644 .github/workflows/publish-linkedin-release-shared.yml create mode 100644 .github/workflows/publish-linkedin-release.yml create mode 100644 test/test_linkedin_release.py 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..43381b7 --- /dev/null +++ b/.github/actions/prepare-linkedin-release-post/action.yml @@ -0,0 +1,18 @@ +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 }} + 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..929fcf0 --- /dev/null +++ b/.github/actions/prepare-linkedin-release-post/prepare.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +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+") + + +def truncate(value, limit): + if len(value) <= limit: + return value + return value[: limit - 3].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_published(repository, release, limit=LINKEDIN_POST_LIMIT): + tag = release["tag"] + title = f"{repository} {release.get('name') or tag}" + url = release["url"] + description = release_description(release.get("body") or "", tag=tag) + fixed = f"{title}\n\n{url}" + if not description: + return truncate(fixed, limit) + + description_limit = limit - len(fixed) - 2 + if description_limit <= 0: + return truncate(fixed, limit) + return f"{title}\n\n{truncate(description, description_limit)}\n\n{url}" + + +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 "", + } + write_output(format_published(os.environ["REPOSITORY"], release)) + 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..8e33077 --- /dev/null +++ b/.github/workflows/publish-linkedin-release-shared.yml @@ -0,0 +1,33 @@ +name: Publish LinkedIn release (shared) +on: + workflow_call: + secrets: + linkedin-access-token: + required: true + 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 }} diff --git a/.github/workflows/publish-linkedin-release.yml b/.github/workflows/publish-linkedin-release.yml new file mode 100644 index 0000000..b537837 --- /dev/null +++ b/.github/workflows/publish-linkedin-release.yml @@ -0,0 +1,9 @@ +name: Publish LinkedIn release +on: + release: + types: [published] +jobs: + publish: + uses: ./.github/workflows/publish-linkedin-release-shared.yml + secrets: + linkedin-access-token: ${{ secrets.LINKEDIN_ACCESS_TOKEN }} diff --git a/README.md b/README.md index 16b2dca..d54ef67 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,42 @@ 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 + secrets: + linkedin-access-token: ${{ secrets.LINKEDIN_ACCESS_TOKEN }} +``` + + + +Baseline publishes stable releases to the authenticated member's public LinkedIn +feed. It converts the release name, body, and URL to a plain-text post, resolves +the member identity through OpenID Connect, and reports the resulting LinkedIn +post URN. Drafts and prereleases are skipped. + +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..82fdf82 --- /dev/null +++ b/test/test_linkedin_release.py @@ -0,0 +1,249 @@ +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.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_plain_text(self): + message = PREPARE.format_published("owner/repo", self.release) + + self.assertEqual( + message, + "owner/repo v0.18.0\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", + ) + 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) + + self.assertEqual( + message, + "owner/repo v0.18.0\n\nhttps://github.com/owner/repo/releases/tag/v0.18.0", + ) + + def test_truncates_body_and_preserves_release_url(self): + self.release["body"] = "x" * 4000 + + message = PREPARE.format_published("owner/repo", self.release) + + self.assertEqual(len(message), PREPARE.LINKEDIN_POST_LIMIT) + self.assertIn("...\n\n", message) + self.assertTrue(message.endswith(self.release["url"])) + + 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< Date: Tue, 1 Sep 2026 01:16:00 +0200 Subject: [PATCH 2/4] feat: enrich LinkedIn release posts --- .../prepare-linkedin-release-post/action.yml | 2 + .../prepare-linkedin-release-post/prepare.py | 63 +++++++++++++++++-- README.md | 7 ++- test/test_linkedin_release.py | 56 ++++++++++++++--- 4 files changed, 110 insertions(+), 18 deletions(-) diff --git a/.github/actions/prepare-linkedin-release-post/action.yml b/.github/actions/prepare-linkedin-release-post/action.yml index 43381b7..afef024 100644 --- a/.github/actions/prepare-linkedin-release-post/action.yml +++ b/.github/actions/prepare-linkedin-release-post/action.yml @@ -15,4 +15,6 @@ runs: 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 index 929fcf0..7a5447d 100644 --- a/.github/actions/prepare-linkedin-release-post/prepare.py +++ b/.github/actions/prepare-linkedin-release-post/prepare.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import json import os import re import uuid @@ -7,6 +8,7 @@ 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): @@ -15,6 +17,27 @@ def truncate(value, limit): 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)) @@ -46,19 +69,35 @@ def release_description(value, tag=""): return "\n".join(lines) -def format_published(repository, release, limit=LINKEDIN_POST_LIMIT): +def format_hashtags(topics): + hashtags = [] + for topic in topics: + name = re.sub(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"] - title = f"{repository} {release.get('name') or tag}" - url = release["url"] + 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"{title}\n\n{url}" + 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"{title}\n\n{truncate(description, description_limit)}\n\n{url}" + return f"{header}\n\n{truncate_little_text(description, description_limit)}\n\n{footer}" def write_output(message): @@ -76,7 +115,19 @@ def main(): "url": os.environ["RELEASE_URL"], "body": os.environ.get("RELEASE_BODY") or "", } - write_output(format_published(os.environ["REPOSITORY"], release)) + 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 diff --git a/README.md b/README.md index d54ef67..c075207 100644 --- a/README.md +++ b/README.md @@ -193,9 +193,10 @@ jobs: Baseline publishes stable releases to the authenticated member's public LinkedIn -feed. It converts the release name, body, and URL to a plain-text post, resolves -the member identity through OpenID Connect, and reports the resulting LinkedIn -post URN. Drafts and prereleases are skipped. +feed. It converts the repository name, description, topics, release name, body, +and URL to LinkedIn's `little` text format. Repository topics become hashtags. +The workflow resolves the member identity through OpenID Connect and reports the +resulting LinkedIn post URN. Drafts and prereleases are skipped. Create a LinkedIn developer application with the `Share on LinkedIn` and `Sign In with LinkedIn using OpenID Connect` products. Generate a member token with diff --git a/test/test_linkedin_release.py b/test/test_linkedin_release.py index 82fdf82..6ae03ac 100644 --- a/test/test_linkedin_release.py +++ b/test/test_linkedin_release.py @@ -42,6 +42,8 @@ def read(self, _size=-1): 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", @@ -55,16 +57,23 @@ def setUp(self): ), } - def test_formats_release_please_body_as_plain_text(self): - message = PREPARE.format_published("owner/repo", self.release) + 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" + "• publish releases to LinkedIn \\(\\#193\\)\n\n" "Release automation stays deterministic.\n\n" - "https://github.com/owner/repo/releases/tag/v0.18.0", + "https://github.com/owner/repo/releases/tag/v0.18.0\n\n" + "#githubactions #releaseplease #python", ) self.assertNotIn("[", message) @@ -78,21 +87,48 @@ def test_uses_distinct_release_name(self): def test_formats_release_without_body(self): self.release["body"] = "" - message = PREPARE.format_published("owner/repo", self.release) + 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\nhttps://github.com/owner/repo/releases/tag/v0.18.0", + "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) + message = PREPARE.format_published( + "owner/repo", + self.release, + repository_description=self.repository_description, + topics=self.topics, + ) - self.assertEqual(len(message), PREPARE.LINKEDIN_POST_LIMIT) + self.assertLessEqual(len(message), PREPARE.LINKEDIN_POST_LIMIT) self.assertIn("...\n\n", message) - self.assertTrue(message.endswith(self.release["url"])) + 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}): @@ -238,6 +274,8 @@ def test_actions_keep_formatting_and_delivery_separate(self): send_script = (action_root / "send-linkedin-post" / "send.py").read_text() self.assertIn('run: python3 "${{ github.action_path }}/prepare.py"', prepare_action) + self.assertIn("REPOSITORY_DESCRIPTION: ${{ github.event.repository.description }}", prepare_action) + self.assertIn("REPOSITORY_TOPICS: ${{ toJSON(github.event.repository.topics) }}", prepare_action) self.assertNotIn("api.linkedin.com", prepare_action) self.assertIn('run: python3 "${{ github.action_path }}/send.py"', send_action) self.assertIn("LINKEDIN_ACCESS_TOKEN", send_action) From 6412b34e67afc9a0f89e20cd393b41348c3235a1 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Tue, 1 Sep 2026 12:35:38 +0200 Subject: [PATCH 3/4] feat: format LinkedIn hashtags as PascalCase --- .github/actions/prepare-linkedin-release-post/prepare.py | 2 +- README.md | 3 ++- test/test_linkedin_release.py | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.github/actions/prepare-linkedin-release-post/prepare.py b/.github/actions/prepare-linkedin-release-post/prepare.py index 7a5447d..24df321 100644 --- a/.github/actions/prepare-linkedin-release-post/prepare.py +++ b/.github/actions/prepare-linkedin-release-post/prepare.py @@ -72,7 +72,7 @@ def release_description(value, tag=""): def format_hashtags(topics): hashtags = [] for topic in topics: - name = re.sub(r"[^A-Za-z0-9]", "", topic) + 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) diff --git a/README.md b/README.md index c075207..fb9aa15 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,8 @@ jobs: 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 hashtags. +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. diff --git a/test/test_linkedin_release.py b/test/test_linkedin_release.py index 6ae03ac..d8deed8 100644 --- a/test/test_linkedin_release.py +++ b/test/test_linkedin_release.py @@ -73,7 +73,7 @@ def test_formats_release_please_body_as_little_text(self): "• 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", + "#GithubActions #ReleasePlease #Python", ) self.assertNotIn("[", message) @@ -99,7 +99,7 @@ def test_formats_release_without_body(self): "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", + "#GithubActions #ReleasePlease #Python", ) def test_escapes_little_reserved_characters(self): @@ -112,7 +112,7 @@ def test_escapes_little_reserved_characters(self): def test_normalizes_and_deduplicates_repository_topics(self): self.assertEqual( PREPARE.format_hashtags(["github-actions", "c-plus-plus", "github-actions", "---"]), - "#githubactions #cplusplus", + "#GithubActions #CPlusPlus", ) def test_truncates_body_and_preserves_release_url(self): @@ -128,7 +128,7 @@ def test_truncates_body_and_preserves_release_url(self): 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")) + 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}): From 40bc9616c438b4eb372d1f2e7918be0ab01553b5 Mon Sep 17 00:00:00 2001 From: Yuriy Kirillov Date: Wed, 2 Sep 2026 11:45:44 +0200 Subject: [PATCH 4/4] feat: notify Telegram about LinkedIn release publish results Adds optional telegram-chat-id/telegram-bot-token to the shared workflow so consumers get a Telegram message on success or failure without wiring a separate notification workflow. Co-Authored-By: Claude Sonnet 5 --- .../publish-linkedin-release-shared.yml | 36 ++++++++++++++++++ .../workflows/publish-linkedin-release.yml | 3 ++ README.md | 7 ++++ test/test_linkedin_release.py | 37 +++++++++++++++++-- 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-linkedin-release-shared.yml b/.github/workflows/publish-linkedin-release-shared.yml index 8e33077..710877d 100644 --- a/.github/workflows/publish-linkedin-release-shared.yml +++ b/.github/workflows/publish-linkedin-release-shared.yml @@ -1,9 +1,17 @@ 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. @@ -31,3 +39,31 @@ jobs: 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 index b537837..354ac17 100644 --- a/.github/workflows/publish-linkedin-release.yml +++ b/.github/workflows/publish-linkedin-release.yml @@ -5,5 +5,8 @@ on: 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 fb9aa15..5d4c879 100644 --- a/README.md +++ b/README.md @@ -186,8 +186,11 @@ on: 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 }} ``` @@ -199,6 +202,10 @@ 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 diff --git a/test/test_linkedin_release.py b/test/test_linkedin_release.py index d8deed8..93673cb 100644 --- a/test/test_linkedin_release.py +++ b/test/test_linkedin_release.py @@ -238,8 +238,16 @@ def test_shared_workflow_publishes_stable_releases(self): workflow_call = workflow[True]["workflow_call"] job = workflow["jobs"]["publish"] - self.assertEqual(set(workflow_call["secrets"]), {"linkedin-access-token"}) - self.assertNotIn("inputs", workflow_call) + self.assertEqual(set(workflow_call["secrets"]), {"linkedin-access-token", "telegram-bot-token"}) + self.assertFalse(workflow_call["secrets"]["telegram-bot-token"]["required"]) + self.assertEqual( + workflow_call["inputs"]["telegram-chat-id"], + { + "description": "Telegram chat ID that receives publish notifications. Omit to skip notifications.", + "required": False, + "type": "string", + }, + ) self.assertEqual(workflow_call["outputs"]["post-id"]["value"], "${{ jobs.publish.outputs.post-id }}") self.assertIn("github.event_name == 'release'", job["if"]) self.assertIn("github.event.release.prerelease == false", job["if"]) @@ -253,6 +261,25 @@ def test_shared_workflow_publishes_stable_releases(self): ], ) + def test_shared_workflow_notifies_telegram_when_chat_id_is_set(self): + workflow = self.load_workflow("publish-linkedin-release-shared.yml") + job = workflow["jobs"]["notify"] + + self.assertEqual(job["needs"], "publish") + self.assertIn("inputs.telegram-chat-id != ''", job["if"]) + self.assertIn("needs.publish.result == 'success'", job["if"]) + self.assertIn("needs.publish.result == 'failure'", job["if"]) + self.assertEqual(job["permissions"], {}) + self.assertEqual( + [step["uses"] for step in job["steps"]], + ["$/.github/actions/send-telegram-message", "$/.github/actions/send-telegram-message"], + ) + for step in job["steps"]: + self.assertEqual(step["with"]["telegram-bot-token"], "${{ secrets.telegram-bot-token }}") + self.assertEqual(step["with"]["telegram-chat-id"], "${{ inputs.telegram-chat-id }}") + self.assertEqual(job["steps"][0]["if"], "needs.publish.result == 'success'") + self.assertEqual(job["steps"][1]["if"], "needs.publish.result == 'failure'") + def test_baseline_caller_passes_token_explicitly(self): path = BASELINE_ROOT / ".github" / "workflows" / "publish-linkedin-release.yml" content = path.read_text() @@ -261,9 +288,13 @@ def test_baseline_caller_passes_token_explicitly(self): self.assertIn("release:\n types: [published]", content) self.assertEqual(job["uses"], "./.github/workflows/publish-linkedin-release-shared.yml") + self.assertEqual(job["with"], {"telegram-chat-id": "${{ vars.TELEGRAM_CHAT_ID }}"}) self.assertEqual( job["secrets"], - {"linkedin-access-token": "${{ secrets.LINKEDIN_ACCESS_TOKEN }}"}, + { + "linkedin-access-token": "${{ secrets.LINKEDIN_ACCESS_TOKEN }}", + "telegram-bot-token": "${{ secrets.TELEGRAM_BOT_TOKEN }}", + }, ) self.assertNotIn("secrets: inherit", content)