Post GitHub releases to Bluesky, LinkedIn and X with GitHub Actions
Add one workflow file that runs on release: published and sends the release name and URL to OpenTweet's POST /api/v1/posts with "platforms": ["x", "bluesky", "linkedin"]. One curl call posts to all three networks. Keep the text at or under 280 characters, counting the URL at full length, and it fits every one.
The workflow needs one repository secret, your OpenTweet API key. The Bluesky and LinkedIn connections live in OpenTweet, not in your repository.
Set it up in four steps
- 1
Connect accounts and create an API key
In OpenTweet, connect X, Bluesky and LinkedIn. LinkedIn posts go to your personal profile. Create an API key; it starts with ot_.
- 2
Add the key as a repository secret
In the repository, open Settings, then Secrets and variables, then Actions, and add a secret named OPENTWEET_API_KEY.
- 3
Add the workflow file
Save the YAML below as .github/workflows/announce-release.yml and commit it to the default branch.
- 4
Publish a release
The job runs, posts, and prints one result per network in its log.
The workflow file
It posts something like widget v2.1.0 is out: https://github.com/acme/widget/releases/tag/v2.1.0 and uses the tag when the release has no name.
name: Announce release
on:
release:
types: [published]
jobs:
announce:
if: ${{ !github.event.release.prerelease }}
runs-on: ubuntu-latest
steps:
- name: Post to X, Bluesky and LinkedIn
env:
OPENTWEET_API_KEY: ${{ secrets.OPENTWEET_API_KEY }}
REPO: ${{ github.event.repository.name }}
NAME: ${{ github.event.release.name || github.event.release.tag_name }}
RELEASE_URL: ${{ github.event.release.html_url }}
run: |
TEXT="$REPO $NAME is out: $RELEASE_URL"
jq -n --arg text "$TEXT" \
'{text: $text, platforms: ["x", "bluesky", "linkedin"], publish_now: true}' \
| curl -sS -X POST https://opentweet.io/api/v1/posts \
-H "Authorization: Bearer $OPENTWEET_API_KEY" \
-H "Content-Type: application/json" \
--data @- > response.json
jq '.posts[0].results // .results // .' response.json
jq -e '(.posts[0].results // .results // []) | length > 0 and all(.status == "published")' response.json > /dev/null- The event fields go through env, not straight into the script. A release title is user input, and passing it as an environment variable keeps it from being run as shell.
jq --argthen builds the JSON, so quotes in a title cannot break the body. - The last two lines report and check. The first prints the per-network results, or the whole response if the call failed. The second fails the job unless every network came back published, so a skip shows up as a failed run.
- Prereleases are left out. GitHub fires
publishedfor prereleases too. Delete theif:line to announce them.
The release post contains a link
Posts with links are on the Advanced and Agency plans, not Pro. On Pro, OpenTweet removes the URL from the text before publishing, on every network, so the post would read "widget v2.1.0 is out:" with nothing after it. URL post credits, if you have them, cover posts past the daily allowance. Otherwise change the text on Pro to something that does not need the link. Compare plans.Release created by another workflow?
GitHub does not start new workflow runs from events caused by theGITHUB_TOKEN. If a release job creates the release with GITHUB_TOKEN, this workflow never fires. Create the release with a personal access token or a GitHub App token.Staying under every limit
Each network counts differently. X weighs text and counts any URL as 23. Bluesky allows 300 graphemes and counts a link at full length through the API. LinkedIn allows 3,000 UTF-16 code units. A GitHub release URL is always longer than 23 characters, so if the whole text is 280 characters or fewer as typed, it fits all three. Emoji break that rule on X, where most count as 2.
When a post does not fit a network, OpenTweet skips that network with a skip_reason and publishes everywhere else. Nothing is truncated.
| Release text | X | Bluesky | |
|---|---|---|---|
| Text over 280 weighted characters | Rejected: the whole request fails with 400 validation_failed, unless the account has X Premium | Nothing publishes while X is a target | Nothing publishes while X is a target |
| Text over 300 characters with the URL counted in full | Depends on its own count | Skipped with a reason, the others still publish | Published |
| Text over 3,000 UTF-16 code units | Rejected without Premium, so nothing publishes | Skipped | Skipped |
| Text at or under 280 characters as typed, plain text | Published | Published | Published |
A clean run prints this in the job log:
[
{
"platform": "x",
"status": "published",
"url": "https://x.com/you/status/1834000000000000000"
},
{
"platform": "bluesky",
"status": "published",
"url": "https://bsky.app/profile/you.bsky.social/post/3l5x7k2qz3c2t"
},
{
"platform": "linkedin",
"status": "published",
"url": "https://www.linkedin.com/feed/update/urn:li:share:7300000000000000000/"
}
]A skipped entry has "status": "skipped" and a skip_reason. A failed one has "status": "failed" and an error. If nothing was published at all, the API answers 502 with publish_failed and the same per-network results at the top level. Re-running the job sends the same text again, and OpenTweet answers 409 duplicate_content instead of posting it twice.
Longer release notes: one call per network
The v1 API takes one shared text per call, so the notes cannot go to LinkedIn in the same call as a short X post. Replace the steps block with two steps: a short post to X and Bluesky, and the notes to LinkedIn.
steps:
- name: Short post to X and Bluesky
env:
OPENTWEET_API_KEY: ${{ secrets.OPENTWEET_API_KEY }}
NAME: ${{ github.event.release.name || github.event.release.tag_name }}
RELEASE_URL: ${{ github.event.release.html_url }}
run: |
jq -n --arg text "$NAME is out: $RELEASE_URL" \
'{text: $text, platforms: ["x", "bluesky"], publish_now: true}' \
| curl -sS -X POST https://opentweet.io/api/v1/posts \
-H "Authorization: Bearer $OPENTWEET_API_KEY" \
-H "Content-Type: application/json" \
--data @-
- name: Release notes to LinkedIn
env:
OPENTWEET_API_KEY: ${{ secrets.OPENTWEET_API_KEY }}
NAME: ${{ github.event.release.name || github.event.release.tag_name }}
NOTES: ${{ github.event.release.body }}
RELEASE_URL: ${{ github.event.release.html_url }}
run: |
jq -n --arg name "$NAME" --arg notes "$NOTES" --arg url "$RELEASE_URL" \
'{text: ($name + "\n\n" + $notes + "\n\n" + $url), platforms: ["linkedin"], publish_now: true}' \
| curl -sS -X POST https://opentweet.io/api/v1/posts \
-H "Authorization: Bearer $OPENTWEET_API_KEY" \
-H "Content-Type: application/json" \
--data @-LinkedIn takes up to 3,000 characters counted in UTF-16 code units, so notes past that are skipped there with a reason. Release notes are written in Markdown and LinkedIn shows plain text, so check how yours read before you send them. Only roughly the first 2 or 3 lines show before LinkedIn's "see more", which is why the release name goes first.
Frequently asked questions
Does this post prereleases?
Not as written. The published type fires for prereleases too, so the job has an if condition that skips them. Delete that line to announce prereleases as well.
Why did the workflow not run when my release automation created the release?
GitHub does not start new workflow runs from events caused by the GITHUB_TOKEN. If a release is created by another workflow using GITHUB_TOKEN, this one never fires. Create the release with a personal access token or a GitHub App token instead.
Can I post the full release notes?
To LinkedIn, yes, up to 3,000 characters. X allows 280 (25,000 on Premium) and Bluesky 300, and the v1 API takes one shared text per call, so send a short post to X and Bluesky and a second call with the notes to LinkedIn.
What happens if the text is too long for Bluesky?
Bluesky is skipped with a skip_reason and the post still goes out on X and LinkedIn. Nothing is truncated. The last line of the workflow fails the job on any skip, so GitHub tells you.
Do I need a Bluesky app password or a LinkedIn developer app?
No. You connect Bluesky and your LinkedIn personal profile once in OpenTweet. The workflow only needs the OpenTweet API key as a repository secret.
Which OpenTweet plan do I need?
Bluesky and LinkedIn are on every plan, from $11.99/mo, with a 7-day free trial. The release post contains a link, and posts with links are on the Advanced and Agency plans. On Pro the URL is removed before publishing, unless you have URL post credits, which cover posts beyond the daily allowance.
Keep exploring
More ways to post to X, Bluesky and LinkedIn from your tools.
Cross-posting API docs
The platforms parameter, the results array, and every skip reason.
Cross-posting
One draft to X, Bluesky and LinkedIn, with a result per network.
X vs Bluesky vs LinkedIn limits
How each network counts text, links and emoji, side by side.
n8n: post to all three
The same call from an n8n workflow, with an importable JSON.
GitHub releases to X
OpenTweet's GitHub connector, with AI-written release posts and no YAML.
Bluesky scheduler
Schedule Bluesky posts and threads alongside X.
LinkedIn scheduler
Schedule posts to your LinkedIn personal profile.
OpenTweet for n8n
Posting from any n8n workflow with one HTTP Request node.
Ship the release, the posts go out
Connect X, Bluesky and LinkedIn once, add one secret, and every release gets announced. From $11.99/mo, with Bluesky and LinkedIn on every plan.
7-day free trial. Cancel anytime.