Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# CI
#
# yamllint covers action.yml as well as the workflows. The shared actions repo
# lints only .github/workflows/, which leaves its composite action.yml files
# unchecked — worth not inheriting, since action.yml is the whole product here.
#
# actionlint and zizmor start non-blocking, matching linchpin/actions. Tighten
# them once the first release has settled.

name: CI

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
lint:
name: Lint
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v6

- name: actionlint
continue-on-error: true # TODO: make blocking once the first release settles
uses: docker://rhysd/actionlint:latest
with:
args: -color

- name: yamllint
uses: ibiqlik/action-yamllint@v3
with:
file_or_dir: action.yml .github/workflows/
config_file: .ymllint.yml

- name: shellcheck
run: shellcheck release-post.sh

- name: zizmor security audit
continue-on-error: true # TODO: make blocking once the first release settles
run: pipx run zizmor .github/workflows/ action.yml

smoke:
name: Smoke test
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v6

# Proves the action is wired up end to end — inputs resolve, the script
# runs, outputs come back — without needing credentials or a live site.
- name: Dry run
id: dry
uses: ./
with:
site-url: https://example.invalid
product: Smoke Test
repository: linchpin/release-post
tag: v0.0.0-smoke
release-notes: |
## [0.0.0](https://example.invalid/compare/v0.0.0...v0.0.0) (2026-01-01)

### Features ✨

* a change that did not happen
release-url: https://example.invalid/releases/tag/v0.0.0-smoke
wp-user: smoke
wp-app-password: smoke
dry-run: "true"

- name: Check outputs
env:
ACTION: ${{ steps.dry.outputs.action }}
REASON: ${{ steps.dry.outputs.skipped-reason }}
run: |
if [ "$ACTION" != "skipped" ] || [ "$REASON" != "dry_run" ]; then
echo "::error::Expected a skipped/dry_run result, got '$ACTION'/'$REASON'"
exit 1
fi
echo "::notice::Dry run reported $ACTION ($REASON)"
81 changes: 81 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Release
#
# release-please cuts immutable vX.Y.Z releases from main. When one publishes,
# update-major-tag force-moves the floating major tag (v1 -> v1.2.3) so
# `uses: linchpin/release-post@v1` always resolves to the newest v1 — the same
# contract GitHub's own actions offer.
#
# ⚠️ A major tag is never created while a branch of the same name exists, or the
# ref would be ambiguous. If that ever happens: delete the branch, then re-run
# this workflow via workflow_dispatch with the release tag.

name: Release

on:
push:
branches:
- main
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: "Existing release tag to sync the floating major tag to (e.g. v1.0.0)"
required: true
type: string

permissions:
contents: read

jobs:
release-please:
name: Release Please
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
pull-requests: write
steps:
- uses: googleapis/release-please-action@v5
with:
token: ${{ secrets.GH_BOT_TOKEN || github.token }}

update-major-tag:
name: Update floating major tag
if: ${{ github.event_name == 'release' || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
steps:
- name: Move major tag to release
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: |
MAJOR="${TAG%%.*}"
if [ "$MAJOR" = "$TAG" ] || ! echo "$TAG" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+'; then
echo "::error::'$TAG' is not a vX.Y.Z release tag"
exit 1
fi

# Guard: never create a tag that shadows an existing branch.
if gh api "repos/$REPO/branches/$MAJOR" --silent 2>/dev/null; then
echo "::warning::Branch '$MAJOR' still exists — skipping the floating tag to avoid an ambiguous ref."
exit 0
fi

SHA=$(gh api "repos/$REPO/commits/$TAG" --jq '.sha')
echo "Pointing $MAJOR at $TAG ($SHA)"
# NOTE: /git/ref/ (singular) is the exact-match endpoint —
# /git/refs/ does prefix matching and would match v1.0.0 for v1.
if gh api "repos/$REPO/git/ref/tags/$MAJOR" --silent 2>/dev/null; then
gh api --method PATCH "repos/$REPO/git/refs/tags/$MAJOR" \
--field sha="$SHA" --field force=true
else
gh api --method POST "repos/$REPO/git/refs" \
--field ref="refs/tags/$MAJOR" --field sha="$SHA"
fi
echo "::notice::$MAJOR now resolves to $TAG"
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.DS_Store
3 changes: 3 additions & 0 deletions .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.0.0"
}
10 changes: 10 additions & 0 deletions .ymllint.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Multi line
config_data: |
extends: default
rules:
document-start:
present: false
line-length:
max: 125
allow-non-breakable-words: true
allow-non-breakable-inline-mappings: false
179 changes: 179 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# release-post

Turn a published GitHub release into a draft blog post on the Linchpin blog.

When release-please publishes a release in a product repo, this action reports it to
[builditbelieveit.com][site] — repository, tag, notes, URL — and the site writes the post:
an overview generated through the Linchpin AI Gateway, followed by the changelog rendered
from the release notes. A human reviews the draft and publishes it.

Nothing is generated in CI. This action only rings the bell and reports where the draft
landed.

```yaml
name: Release blog post

on:
release:
types: [published, edited]

permissions:
contents: read

concurrency:
group: release-post-${{ github.event.release.tag_name }}
cancel-in-progress: false

jobs:
post:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: linchpin/release-post@v1
with:
product: Linchpin Blocks
wp-user: ${{ secrets.RELEASE_POST_WP_USER }}
wp-app-password: ${{ secrets.RELEASE_POST_WP_APP_PASSWORD }}
cf-access-client-id: ${{ secrets.RELEASE_POST_CF_ACCESS_CLIENT_ID }}
cf-access-client-secret: ${{ secrets.RELEASE_POST_CF_ACCESS_CLIENT_SECRET }}
```

Everything except `product` and the credentials is read from the release event.

## What the post looks like

- **Title** — product and version, e.g. *Linchpin Blocks 2.7.0*.
- **Masthead** — the title and a one-line summary, on the standard cyan page header.
(`single.html` renders no post title of its own, so the body carries one.)
- **What's new** — two or three paragraphs written by the gateway from the release notes.
- **Changelog** — release-please's own sections and entries, converted to blocks. Not
generated, so it cannot misreport what shipped. Bare commit-hash links and the internal
`NO-TASK` scope marker are dropped; both are restorable with a filter on the site.
- **Links** — the release on GitHub, and the compare diff when the notes carry one.

It is filed as a draft `post` in the **Releases** category, tagged with the product name.

## Re-running is safe

The endpoint keys on repository plus tag, so a second run finds the post it wrote the first
time. What happens then depends on what has become of it:

| State | Result |
| --- | --- |
| Nothing exists yet | `created` |
| Draft we wrote, notes unchanged | `skipped` (`unchanged`) — no AI request either |
| Draft we wrote, notes changed | `updated` |
| Draft somebody has since edited | `skipped` (`human_edited`) |
| Already published | `skipped` (`already_published`) |
| Prerelease | `skipped` (`prerelease`) |

`force: true` overrides the last three. It rewrites the body and leaves the post's status
alone — forcing a published post rewrites it in place, it does not unpublish it.

## Inputs

| Input | Required | Default | |
| --- | --- | --- | --- |
| `wp-user` | yes | | WordPress username the Application Password belongs to |
| `wp-app-password` | yes | | The Application Password |
| `product` | no | repo name, prettified | Display name for the title and tag |
| `site-url` | no | `https://builditbelieveit.com` | Origin, no trailing slash, no `/wp-json` |
| `cf-access-client-id` | no | | Cloudflare Access service token ID — **required in practice**, see Setup |
| `cf-access-client-secret` | no | | Cloudflare Access service token secret |
| `repository` | no | `github.repository` | |
| `tag` | no | `github.event.release.tag_name` | |
| `release-notes` | no | `github.event.release.body` | |
| `release-url` | no | `github.event.release.html_url` | |
| `prerelease` | no | `github.event.release.prerelease` | |
| `force` | no | `false` | Rewrite even when published or hand-edited |
| `dry-run` | no | `false` | Print the payload, post nothing |
| `fail-on-error` | no | `false` | Fail the job when the post could not be written |

## Outputs

`post-id`, `action` (`created` / `updated` / `skipped` / `error`), `skipped-reason`,
`edit-url`.

The step also writes the outcome and a link to the draft into the job summary.

## Failures are not fatal by default

A release must not be held up because the blog was unreachable, so a failure annotates and
exits 0. Set `fail-on-error: true` where you would rather know loudly.

The most common failure is an HTML response instead of JSON, which almost always means
Cloudflare Access answered instead of WordPress. The log group prints the status,
`content-type`, `cf-ray` and the first 300 bytes so it is obvious at a glance.

## Setup

Four things, once, before the first repo opts in.

1. **A bot user** on the site — role **Author** is enough. It needs `publish_posts`; the
category and tag are created by the endpoint, which does not capability-check terms.
2. **An Application Password** for that user. Store it in 1Password, and set the org
secrets `RELEASE_POST_WP_USER` and `RELEASE_POST_WP_APP_PASSWORD`.
3. **A Cloudflare Access service token**, with a Service Auth policy scoped to
`builditbelieveit.com/wp-json/linchpin/v1/release-post`. A dedicated token keeps the
blast radius on that one path. Store as `RELEASE_POST_CF_ACCESS_CLIENT_ID` and
`RELEASE_POST_CF_ACCESS_CLIENT_SECRET`.
4. **The `Releases` category**, created once so the first run does not have to.

The endpoint lives in `linchpin-functionality` on [linchpin.com][repo] and is gated behind
the `release_posts` module, which is on for the network's main site only.

### This repo is private

Private action repos are invisible to other repos until you say otherwise. Set
**Settings → Actions → General → Access → _Accessible from repositories in the linchpin
organization_**, or every caller fails with "repository not found".

### `release: published` only fires under a PAT

A release-please workflow running as the default `GITHUB_TOKEN` cannot trigger other
workflows, so the `release` event never arrives. `linchpin.com` uses `GH_BOT_TOKEN`
deliberately for this reason.

In a repo that does not, hang the job off release-please directly instead:

```yaml
jobs:
release-please:
outputs:
releases_created: ${{ steps.release.outputs.releases_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
body: ${{ steps.release.outputs.body }}
# …

post:
needs: release-please
if: ${{ needs.release-please.outputs.releases_created == 'true' }}
runs-on: ubuntu-latest
steps:
- uses: linchpin/release-post@v1
with:
product: Linchpin Blocks
tag: ${{ needs.release-please.outputs.tag_name }}
release-notes: ${{ needs.release-please.outputs.body }}
release-url: https://github.com/${{ github.repository }}/releases/tag/${{ needs.release-please.outputs.tag_name }}
wp-user: ${{ secrets.RELEASE_POST_WP_USER }}
wp-app-password: ${{ secrets.RELEASE_POST_WP_APP_PASSWORD }}
cf-access-client-id: ${{ secrets.RELEASE_POST_CF_ACCESS_CLIENT_ID }}
cf-access-client-secret: ${{ secrets.RELEASE_POST_CF_ACCESS_CLIENT_SECRET }}
```

Most release-please workflows only declare `release_created` in their `outputs` block —
`tag_name` and `body` have to be added.

## Releasing this action

release-please owns `version.txt`, `CHANGELOG.md` and the manifest; never edit them by
hand. Merging to `main` opens a release PR, and merging that tags `vX.Y.Z` and moves the
floating `v1` tag so callers pinned to `@v1` pick it up.

> The initial release is pinned with `release-as: "1.0.0"` in `release-please-config.json`
> so the first tag matches the `@v1` contract callers use. **Remove that key once v1.0.0 has
> shipped**, or every subsequent release will try to be 1.0.0 again.

[site]: https://builditbelieveit.com
[repo]: https://github.com/linchpin/linchpin.com
Loading
Loading