Skip to content

publish

publish #6

Workflow file for this run

name: publish
# Builds, pushes, and releases the OpenBlog Docker image.
#
# Two trigger paths share the same pipeline:
#
# 1. Push a semver tag (`v*.*.*`) → use that tag verbatim
# $ git tag v0.1.0 && git push origin v0.1.0
#
# 2. workflow_dispatch (manual "Run workflow" from the Actions tab) →
# pick the bump type or supply a custom version
# - patch → 0.1.0 → 0.1.1
# - minor → 0.1.0 → 0.2.0
# - major → 0.1.0 → 1.0.0
# - none → re-publish the current version unchanged
# - custom → e.g. "1.2.3" or "2.0.0-rc.1" overrides everything
#
# Image lands at:
# ghcr.io/<owner>/<repo>:<version>
# ghcr.io/<owner>/<repo>:<major>.<minor>
# ghcr.io/<owner>/<repo>:<major>
# ghcr.io/<owner>/<repo>:latest (only for stable releases; skipped for -rc/-beta)
#
# GitHub Release page created at:
# https://github.com/<owner>/<repo>/releases/tag/v<version>
#
# Multi-arch: linux/amd64, linux/arm64
# Caching: GHA cache (layers) + BuildKit cache mounts (pnpm store within build)
#
# One-time setup:
# - GHCR package visibility: Public (Settings → Packages → openblog → Public)
# - Workflow permissions: Read and write (Settings → Actions → General)
on:
push:
tags: ["v*.*.*"]
workflow_dispatch:
inputs:
version_bump:
description: "Version bump from the latest semver tag"
required: true
type: choice
default: "patch"
options:
- patch
- minor
- major
- none
custom_version:
description: >-
Custom version (e.g. 1.2.3 or 2.0.0-rc.1). Overrides version_bump.
Leave empty to use the bump selector.
required: false
type: string
permissions:
contents: write
packages: write
pull-requests: read
attestations: write
id-token: write
# Single in-flight run per ref. Prevents the bot's own tag-push from
# triggering a second concurrent build.
concurrency:
group: publish-${{ github.ref }}
cancel-in-progress: false
# Skip re-runs triggered by the bot's own tag push. When workflow_dispatch
# pushes the git tag (`git push origin v0.2.0`), the resulting push event
# has actor = "github-actions[bot]". We don't want that second run to
# rebuild and re-release — the first run already did. Each job carries
# the same `if` guard:
# if: github.actor != 'github-actions[bot]'
jobs:
# ──────────────────────────────────────────────────────────────────────────
# 0. Fast gate: lint + format + typecheck. Runs on every trigger.
# ──────────────────────────────────────────────────────────────────────────
lint:
name: Lint, format, typecheck
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
# Install pnpm BEFORE actions/setup-node. The `cache: pnpm`
# option below runs `pnpm --version` to compute the cache key,
# so pnpm must already be on PATH — install it via corepack
# using the runner's default Node first.
- name: Activate pnpm via corepack
run: |
corepack enable
corepack prepare pnpm@11.9.0 --activate
- name: Setup Node 26
uses: actions/setup-node@v4
with:
node-version: 26
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Run pnpm check
run: pnpm run check
# ──────────────────────────────────────────────────────────────────────────
# 1. Resolve the version string. Push tag → use it. Manual → compute.
# ──────────────────────────────────────────────────────────────────────────
version:
name: Resolve version
needs: lint
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
version: ${{ steps.compute.outputs.version }}
tag: ${{ steps.compute.outputs.tag }}
major_minor: ${{ steps.compute.outputs.major_minor }}
major: ${{ steps.compute.outputs.major }}
is_prerelease: ${{ steps.compute.outputs.is_prerelease }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- id: compute
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
# Tag was pushed directly — use it as-is.
VERSION="${GITHUB_REF#refs/tags/v}"
else
# workflow_dispatch — apply the chosen bump or custom value.
BUMP="${{ inputs.version_bump }}"
CUSTOM="${{ inputs.custom_version }}"
if [ -n "$CUSTOM" ]; then
# Strip leading v/V in case the user included it
VERSION="${CUSTOM#v}"
VERSION="${VERSION#V}"
else
# Find the latest stable semver tag (excludes pre-releases)
LATEST=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| head -n1 || true)
case "$BUMP" in
none)
if [ -z "$LATEST" ]; then
VERSION="0.1.0"
else
VERSION="${LATEST#v}"
fi
;;
patch|minor|major)
if [ -z "$LATEST" ]; then
# No existing tag — start somewhere reasonable.
case "$BUMP" in
major) VERSION="1.0.0" ;;
*) VERSION="0.1.0" ;;
esac
else
V="${LATEST#v}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$V"
case "$BUMP" in
major) VERSION="$((MAJOR+1)).0.0" ;;
minor) VERSION="${MAJOR}.$((MINOR+1)).0" ;;
patch) VERSION="${MAJOR}.${MINOR}.$((PATCH+1))" ;;
esac
fi
;;
*)
echo "::error::Unknown bump type: $BUMP"
exit 1
;;
esac
fi
fi
# Validate
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Computed version '$VERSION' is not valid semver (e.g. 1.2.3 or 2.0.0-rc.1)"
exit 1
fi
# Extract major / minor for image tags. Use bash regex match
# against the start of $VERSION so this works for both stable
# (0.1.0) and pre-release (2.0.0-rc.1) versions.
if [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
MAJOR_MINOR="${MAJOR}.${MINOR}"
else
echo "::error::Cannot extract major/minor from '$VERSION'"
exit 1
fi
IS_PRERELEASE="false"
if [[ "$VERSION" == *"-"* ]]; then
IS_PRERELEASE="true"
fi
{
echo "version=$VERSION"
echo "tag=v$VERSION"
echo "major_minor=$MAJOR_MINOR"
echo "major=$MAJOR"
echo "is_prerelease=$IS_PRERELEASE"
} >> "$GITHUB_OUTPUT"
echo "::group::Resolved version"
echo " version: $VERSION"
echo " tag: v$VERSION"
echo " major.minor: $MAJOR_MINOR"
echo " major: $MAJOR"
echo " pre-release: $IS_PRERELEASE"
echo "::endgroup::"
# ──────────────────────────────────────────────────────────────────────────
# 2. Build & push the multi-arch image.
# ──────────────────────────────────────────────────────────────────────────
build:
name: Build & push image
needs: version
if: github.actor != 'github-actions[bot]'
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
attestations: write
id-token: write
outputs:
digest: ${{ steps.build.outputs.digest }}
repo_lc: ${{ steps.tags.outputs.repo_lc }}
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Tags are constructed directly from the version job's outputs instead
# of using docker/metadata-action's `type=semver` template. Reason:
# metadata-action's semver template requires a tag-push event to extract
# {{version}}/{{major}}/{{minor}} from the ref. On workflow_dispatch
# the ref is the branch (refs/heads/main), so the template resolves to
# empty and build-push-action fails with "tag is needed when pushing
# to registry". Computing tags here works for both trigger paths.
#
# `latest` is added only for stable releases (is_prerelease != 'true').
# Pre-release versions (anything with a hyphen, e.g. 2.0.0-rc.1) skip
# the `latest` tag.
- name: Compute image tags
id: tags
env:
VERSION: ${{ needs.version.outputs.version }}
MAJOR_MINOR: ${{ needs.version.outputs.major_minor }}
MAJOR: ${{ needs.version.outputs.major }}
IS_PRERELEASE: ${{ needs.version.outputs.is_prerelease }}
GITHUB_REPOSITORY_RAW: ${{ github.repository }}
run: |
set -euo pipefail
# Docker requires the repository portion of an image tag to be
# all-lowercase. github.repository preserves the owner/repo
# casing as it appears in the GitHub URL (e.g. "IamCoder18/
# OpenBlog"), so we lowercase it before constructing tags.
#
# Note: GitHub Actions expressions do NOT have a `lower()`
# function, so the conversion happens in bash here. The result
# is exposed as `repo_lc` for downstream steps and the release
# job to reuse (e.g. for the org.opencontainers.image.source
# label and the `docker pull` examples in the release body).
REPO_LC="$(printf '%s' "$GITHUB_REPOSITORY_RAW" | tr '[:upper:]' '[:lower:]')"
TAGS="ghcr.io/${REPO_LC}:${VERSION}"
TAGS="${TAGS},ghcr.io/${REPO_LC}:${MAJOR_MINOR}"
TAGS="${TAGS},ghcr.io/${REPO_LC}:${MAJOR}"
if [ "$IS_PRERELEASE" != "true" ]; then
TAGS="${TAGS},ghcr.io/${REPO_LC}:latest"
fi
{
echo "tags=${TAGS}"
echo "repo_lc=${REPO_LC}"
echo "is_prerelease=${IS_PRERELEASE}"
} >> "$GITHUB_OUTPUT"
echo "Computed tags:"
echo "$TAGS" | tr ',' '\n' | sed 's/^/ /'
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.tags.outputs.tags }}
labels: |
org.opencontainers.image.title=OpenBlog
org.opencontainers.image.description=Self-hostable AI-agent-friendly blog platform
org.opencontainers.image.source=${{ github.server_url }}/${{ steps.tags.outputs.repo_lc }}
org.opencontainers.image.licenses=MIT
org.opencontainers.image.version=${{ needs.version.outputs.version }}
provenance: mode=max
sbom: true
cache-from: type=gha
cache-to: type=gha,mode=max
# ──────────────────────────────────────────────────────────────────────────
# 3. Create the GitHub Release. Skipped on PRs (no push happened).
# ──────────────────────────────────────────────────────────────────────────
release:
name: Create GitHub Release
needs: [version, build]
if: github.actor != 'github-actions[bot]' && github.event_name != 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
pull-requests: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
# On workflow_dispatch, push the git tag so `softprops/action-gh-release`
# can point at it. This re-triggers the workflow once; the second run is
# skipped by the `if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'`
# guard on the release job AND by the bot-actor check below.
- name: Create + push git tag (workflow_dispatch only)
if: github.event_name == 'workflow_dispatch'
shell: bash
run: |
set -euo pipefail
TAG="${{ needs.version.outputs.tag }}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
# Idempotent: only create if not already present.
if ! git rev-parse "$TAG" >/dev/null 2>&1; then
git tag -a "$TAG" -m "Release $TAG" "$GITHUB_SHA"
git push origin "$TAG"
echo "Tag $TAG created and pushed."
else
echo "Tag $TAG already exists locally — skipping creation."
fi
- name: Resolve previous semver tag for changelog
id: tags
shell: bash
run: |
TAG="${GITHUB_REF#refs/tags/}"
# On workflow_dispatch, GITHUB_REF is the branch (main), so resolve
# the tag from the version job output instead.
if [ -z "$TAG" ] || [ "$TAG" = "main" ] || ! git rev-parse "$TAG" >/dev/null 2>&1; then
TAG="${{ needs.version.outputs.tag }}"
fi
PREV=$(git tag --sort=-version:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${TAG}$" \
| head -n1 || true)
{
echo "tag=${TAG}"
echo "previous=${PREV}"
} >> "$GITHUB_OUTPUT"
- name: Create release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ needs.version.outputs.tag }}
name: "OpenBlog ${{ needs.version.outputs.tag }}"
generate_release_notes: true
prerelease: ${{ needs.version.outputs.is_prerelease == 'true' }}
body: |
## Docker image
```bash
docker pull ghcr.io/${{ needs.build.outputs.repo_lc }}:${{ needs.version.outputs.tag }}
docker pull ghcr.io/${{ needs.build.outputs.repo_lc }}:latest
```
Image digest: `${{ needs.build.outputs.digest }}`
See [`docs/api.md`](./docs/api.md) for the HTTP API and
[`README.md`](./README.md) for installation / deployment.
## What's changed
_Auto-generated from PRs since ${{ steps.tags.outputs.previous || 'the beginning' }}_.
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}