GitHub Actions workflow

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. 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. 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. 3

    Add the workflow file

    Save the YAML below as .github/workflows/announce-release.yml and commit it to the default branch.

  4. 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.

.github/workflows/announce-release.yml
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 --arg then 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 published for prereleases too. Delete the if: 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 the GITHUB_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 textXBlueskyLinkedIn
Text over 280 weighted charactersRejected: the whole request fails with 400 validation_failed, unless the account has X PremiumNothing publishes while X is a targetNothing publishes while X is a target
Text over 300 characters with the URL counted in fullDepends on its own countSkipped with a reason, the others still publishPublished
Text over 3,000 UTF-16 code unitsRejected without Premium, so nothing publishesSkippedSkipped
Text at or under 280 characters as typed, plain textPublishedPublishedPublished

A clean run prints this in the job log:

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.

.github/workflows/announce-release.yml (steps)
    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.

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.