GitHub Actions: Release-to-Social With Human Approval
To connect GitHub Actions to social media safely, freeze the announcement before the approval job starts. Show its exact text and account ID, then send the reviewed payload only after a human approves. Keep a durable record so rerunning CI does not quietly create a second announcement.
Developer workflow: a published release, one reviewed X announcement, and a recoverable receipt.
In short
- Prepare the complete request without a PostSider credential and show its SHA-256 digest before review.
- Keep the organization key in a protected GitHub environment, available only to the publishing job.
- Save intent before the API write. Read saved post IDs on reruns; stop when the earlier outcome is uncertain.
I build PostSider. This example uses its REST API for scheduling, with the announcement supplied by your repository. No model is required. It addresses the release-to-announcement handoff, rather than repeating the first API scheduling tutorial or the Cursor release-notes workflow.
Freeze a small announcement instead of publishing an entire changelog
Treat release notes as input data, not as shell commands or permission to publish. Extract a short announcement and a fixed UTC appointment, then serialize the complete request once.
The downloadable Python script reads GITHUB_EVENT_PATH, GitHub’s JSON event file. Add these lines to a stable release’s notes, using a future date when you run it:
Social: Faster exports are here. See what changed in this release.
Social-Date: 2026-11-12T14:00:00Z
The script adds the release URL. Other changelog paragraphs stay out of the social media post. It rejects duplicate fields, HTML, and dates without Z. Its conservative example policy caps the resulting text at 240 characters, including the URL. That is this script’s limit, not a claim about every account’s platform allowance.
For the selected X integration, the request includes type: "schedule", date, shortLink: false, and tags: []. Each content entry has image: []; settings include __type: "x" and who_can_reply_post: "everyone". These fields match the inspected request DTO and provider settings. Another network needs its own settings and media rules.
The workflow never interpolates a release body into a shell expression. Python reads JSON, and shell-looking text remains text. The preparation summary HTML-escapes the request so markup cannot hide what the reviewer is supposed to see.
Configure a real human gate before adding the credential
A GitHub environment name alone does not require approval. Configure required reviewers on social-production before storing the key there.
GitHub’s deployment review documentation explains when jobs receive environment secrets. It also limits availability: on Free, Pro, and Team, required reviewers are available only for public repositories. Check your repository’s visibility and plan. If the required gate is unavailable, stop here rather than running this example with an unprotected environment.
Download the complete workflow, Python script, and local tests. The example uses Python 3.11 or later and only its standard library.
Set it up in this order:
- Copy
release_social.pyto.github/scripts/release_social.py. Copyrelease-to-social.ymlto.github/workflows/release-to-social.yml. - Connect the intended X account in PostSider. Confirm its integration ID and profile in the dashboard or through
GET /public/v1/integrations; do not select the first account automatically. Set repository variableSOCIAL_CHANNEL_IDto that ID. - Create a dedicated GitHub issue whose entire body is the JSON below. Set repository variable
SOCIAL_LEDGER_ISSUEto its issue number. Issues must be enabled, and repository policy must permit this workflow’sissues: writetoken permission. - Configure the environment’s reviewers. Where available, prevent self-approval and administrator bypass. Store the organization API key as environment secret
POSTSIDER_API_KEY, not as a repository secret.
{"schema":1,"entries":{}}
Protect the default branch and review changes to both example files. Limit who can publish release tags and which tags may deploy to the environment. A release-triggered workflow is itself executable code from the release context; checking out trusted scripts does not make an altered workflow harmless.
Separate preparation from the secret-bearing job
The preparation job must finish before the publishing job requests approval. Its summary contains the exact payload and digest; the downstream job receives that frozen bundle rather than fetching the release notes again.
This is the complete workflow. The checked-out script is included in the download, not an omitted implementation step:
name: Release announcement with human approval
on:
release:
types: [published]
permissions:
contents: read
# Every workflow writing this ledger must use this same group.
concurrency:
group: postsider-release-ledger
cancel-in-progress: false
jobs:
prepare:
if: ${{ !github.event.release.prerelease }}
runs-on: ubuntu-24.04
outputs:
bundle: ${{ steps.freeze.outputs.bundle }}
digest: ${{ steps.freeze.outputs.digest }}
code_sha: ${{ steps.code.outputs.commit }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
id: code
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Freeze and display the exact request
id: freeze
env:
SOCIAL_CHANNEL_ID: ${{ vars.SOCIAL_CHANNEL_ID }}
run: python3 .github/scripts/release_social.py prepare
publish:
needs: prepare
runs-on: ubuntu-24.04
environment: social-production
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.prepare.outputs.code_sha }}
persist-credentials: false
- name: Schedule once, or read the saved post
env:
BUNDLE_B64: ${{ needs.prepare.outputs.bundle }}
EXPECTED_DIGEST: ${{ needs.prepare.outputs.digest }}
SOCIAL_LEDGER_ISSUE: ${{ vars.SOCIAL_LEDGER_ISSUE }}
GH_TOKEN: ${{ github.token }}
POSTSIDER_API_KEY: ${{ secrets.POSTSIDER_API_KEY }}
run: python3 .github/scripts/release_social.py publish
The checkout SHA corresponds to the official checkout v7.0.1 release. Both jobs use the same script commit. The preparation job has no PostSider key, and neither shell command contains release text.
Before clicking Approve and deploy, open the preparation summary. Check the announcement against the release, confirm the account ID and UTC time, and inspect the payload digest. GitHub approves a job’s execution. It does not decide whether someone actually reviewed the text.
The script recomputes the payload’s digest after the gate. This binds the request to the bytes shown earlier within the trusted workflow. It does not defend against a maintainer replacing the workflow or changing the environment rules.
A durable ledger makes reruns a decision instead of a second launch
Use a stable release-and-account identity, not github.run_id or github.run_attempt. This example’s key combines repository ID, release ID, integration ID, and the fixed revision r1.
The payload digest is deliberately absent from that key. Editing the announcement changes its digest but keeps the same operation identity. A prior reservation then causes refusal instead of producing a fresh key and another post. The script does not automatically advance revisions.
Before its first create request, the publishing job saves the exact payload and key in the issue ledger, marked uncertain. It reads the issue back to confirm that reservation. Only then does it send POST /public/v1/posts.
After a successful response, it saves the returned post ID before checking the stored post. Subsequent runs with the same payload use that ID for readback. They skip creation entirely.
If the runner dies after reserving intent but before saving an ID, the next attempt stops. That includes the awkward case where the runner died before sending anything. Accepting a manual investigation is safer than guessing whether an unseen write happened.
The issue is durable state, not a database transaction. Keep an access-controlled backup and do not delete entries, reset its body, or point the variable at a fresh issue after use. Missing or invalid history stops the script. A valid-looking ledger whose history was manually erased cannot be detected automatically.
All automated writers must share the concurrency group. People and unrelated tools must not edit the ledger during a run. GitHub concurrency is not a durable release queue; monitor canceled or superseded runs. The script also stops before its ledger body grows beyond its configured size cap, rather than discarding old history.
An uncertain result requires reconciliation, even with an idempotency key
Idempotency reduces duplicate creation, but it does not promise exactly-once publication. This example makes no automatic create retries after a failure.
For organization-key authentication, the API receives the raw key in Authorization, without Bearer. GitHub’s token uses its own separate authorization header; the two credentials are not interchangeable.
PostSider’s inspected idempotency service compares a request hash and can return a completed response for a matching key. The inspected service and schema do not specify an expiry interval, which is not a promise of permanent retention. The create handler can also release a claim after an exception. Keep the ledger independent of assumptions about API record lifetime. The idempotency guide covers the broader retry problem.
The script stops on HTTP errors, including conflicts and rate limiting. Resolve validation or access failures before another write; do not treat an organization publishing pause as permission to resume publishing. For an uncertain entry, inspect PostSider’s dashboard or list posts around the saved UTC appointment. Compare the account and exact content. An empty list response alone is not proof that nothing was created.
If you identify the original post, record its verified ID in the existing ledger entry under controlled operator review. Preserve the payload and key. If history remains unclear, leave the attempt stopped. Do not delete the reservation to make CI green.
Readback proves stored scheduling details, not future publication
The create response returns an array of post IDs and integration IDs. Read the exact ID through GET /public/v1/posts/:id; do not substitute the most recent post in the account.
The script compares the returned account, content, settings, empty media list, and appointment with the reviewed payload. It accepts QUEUE as a matching scheduled record. Other states requiring attention, including ERROR, fail without creating a replacement.
Scheduling continues asynchronously after creation. Check the same ID after its appointment and inspect the destination publication URL when available. On a later rerun, the script can read a PUBLISHED record, but requires a nonempty destination URL. It does not open that URL or independently verify what the network displays.
An expired appointment before the first write also stops. The script never moves it to tomorrow behind the reviewer’s back. Change the proposed date and repeat review before any attempt; once an operation has been reserved, reconcile that operation first.
The dashboard gives a human somewhere to investigate the saved record. For API setup and the product’s developer entry points, use the PostSider developer page.
Local fixture tests verify the script’s decisions, not GitHub deployment behavior
The example was executed locally against synthetic fixtures, including a loopback HTTP server. No live PostSider request, social media draft, or publication was created during those tests.
From the extracted download directory, run:
python3 test_release_social.py
The tests exercise preparation through the script’s command-line entry point and run its scheduling state machine against simulated GitHub issue and PostSider endpoints. They check unchanged reruns, edited content, lost receipts, and failed ledger writes. They also test digest tampering, expired appointments, and shell-looking release text remaining inert.
The workflow YAML was parsed locally. These checks do not execute GitHub’s hosted runner, its required-reviewer rules, or the production API’s validation and publishing path. Those remain deployment checks for your repository and connected account. Start with a separately authorized controlled announcement after confirming the environment gate actually blocks the publishing job.
Keep the first version narrow. One reviewed announcement with a traceable post ID is a better release workflow than an unattended launch that nobody can safely rerun.
Lukasz Blania is the founder of PostSider. He builds social media scheduling tools with a dashboard and developer API.
Frequently asked questions
Does approving a GitHub Actions job approve the announcement text?
GitHub approves permission to run the job. The reviewer must inspect the preparation summary first. This example displays the complete request and its SHA-256 digest before approval, then sends those same payload bytes.
Can a private repository use required environment reviewers?
Availability depends on GitHub plan and repository visibility. GitHub documents required reviewers as public-repository-only on Free, Pro, and Team. Check the current deployment review documentation before relying on this gate.
Will rerunning the workflow create another social media post?
A completed attempt with a saved post ID only reads that post again. A changed payload or an uncertain attempt stops. This depends on preserving the durable ledger and serializing all writers; it is not an exactly-once publication guarantee.
Does this workflow need an AI model?
No. Python reads a Social line and an explicit UTC date from the release notes, adds the release URL, and freezes the request. PostSider schedules the supplied content; it does not generate posts or images.