publish #6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }} |