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
96 changes: 89 additions & 7 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
# archives + checksums + SBOMs + keyless cosign signatures, an optional
# Homebrew cask, and a GitHub build-provenance attestation over checksums.txt,
# then adopts and publishes the draft release.
# - A repo with a zensical.toml takes the `docs` job: it rebuilds the Zensical
# site (uv run zensical build) and publishes it to GitHub Pages. This is keyed
# off the same config-presence detection as the goreleaser path and is
# independent of it, so a repo can ship binaries AND publish docs from one
# release. Callers must grant `pages: write` for it (see the permissions note
# below); the consuming repo must also set Settings -> Pages -> GitHub Actions.
# - Every other repo's release is just the release-please cut. A committed dist/
# is verified for freshness in CI (reusable ci.yaml), not here.
#
Expand All @@ -32,6 +38,8 @@
# The consuming repo provides release-please-config.json + .release-please-manifest.json.
# Go repos add a .goreleaser.yaml (release-type: go, draft: true); Actions repos that
# commit dist/ set vanity-tags: true so their @v1/@v1.1 consumers track each release.
# Repos that publish docs add a zensical.toml plus pyproject.toml + uv.lock at the
# repo root (the docs job renders docs/ with `uv run zensical build`).
#
# Runs via workflow_call from a thin caller (examples/release.yaml) that owns the
# push trigger and grants the release permissions.
Expand Down Expand Up @@ -110,8 +118,9 @@ on:

# Granted per job below. Note for callers: GitHub validates a reusable workflow's
# permissions as the union of every job and ignores `if:`, so a caller must grant
# the goreleaser job's id-token/attestations/artifact-metadata even on the non-Go
# path where that job is skipped -- see examples/release.yaml.
# the goreleaser job's id-token/attestations/artifact-metadata and the docs job's
# pages:write even on the paths where those jobs are skipped -- see
# examples/release.yaml.
permissions: {}

# concurrent release-please runs can open duplicate release PRs; queue them.
Expand All @@ -136,8 +145,9 @@ jobs:
major: ${{ steps.release-please.outputs.major }}
minor: ${{ steps.release-please.outputs.minor }}
patch: ${{ steps.release-please.outputs.patch }}
# internal: truthy hash that picks the goreleaser vs publish job (not a workflow_call output)
# internal: truthy hashes that pick the goreleaser/publish path and gate the docs job (not workflow_call outputs)
goreleaser: ${{ steps.detect.outputs.goreleaser }}
zensical: ${{ steps.detect.outputs.zensical }}
steps:
# checked out so the goreleaser detection below can read the workspace; a
# job-level `if:` on the downstream jobs runs before their checkout and
Expand Down Expand Up @@ -169,11 +179,17 @@ jobs:
permission-contents: write
permission-pull-requests: write

# The goreleaser output is the config-file hash (empty when absent), used
# downstream as a truthy flag to pick the goreleaser vs publish job.
- name: Detect goreleaser config
# Each output is a config-file hash (empty when the file is absent), used
# downstream as a truthy flag: goreleaser picks the goreleaser vs publish job;
# zensical gates the docs job. hashFiles returns a fixed-format hash, never
# caller-controlled text, so interpolating it into the run script is injection-safe.
- name: Detect goreleaser and zensical config
id: detect
run: echo "goreleaser=${{ hashFiles('.goreleaser.yaml', '.goreleaser.yml') }}" >> "$GITHUB_OUTPUT"
run: |
{
echo "goreleaser=${{ hashFiles('.goreleaser.yaml', '.goreleaser.yml') }}"
echo "zensical=${{ hashFiles('zensical.toml') }}"
} >> "$GITHUB_OUTPUT"

# First pass cuts the release only; the release PR is built in a second pass
# below, once the tag exists.
Expand Down Expand Up @@ -273,6 +289,72 @@ jobs:
with:
subject-checksums: ./dist/checksums.txt

# Build the Zensical documentation site and publish it to GitHub Pages -- the
# zensical.toml counterpart to the goreleaser path. It only needs release-please
# (not goreleaser), so on a repo that both ships binaries and publishes docs the
# two run in parallel; on a docs-only repo goreleaser is skipped and this runs
# straight off release-please.
#
# `uv run zensical build` re-renders docs/ into ./site with no language build: a
# repo whose docs embed generated reference (e.g. a committed CLI/man dump) ships
# that output, so this needs no toolchain beyond uv (it reads uv.lock +
# .python-version and provisions the interpreter on demand). The consuming repo
# must set Settings -> Pages -> Source -> GitHub Actions for the deploy to land.
#
# Same trusted-trigger allowlist as goreleaser: this checks out a computed ref and
# runs the repo's locked deps, so it is gated to vetted events and fails closed on
# any other (defense in depth for a reusable workflow that cannot see its caller's
# trigger).
docs:
needs: release-please
if: >-
needs.release-please.outputs.release_created && needs.release-please.outputs.zensical &&
contains(fromJSON('["push","workflow_dispatch","schedule"]'), github.event_name)
runs-on: ubuntu-latest
permissions:
# check out the source to build the site
contents: read
# publish the GitHub Pages deployment
pages: write
# OIDC token the deploy-pages action exchanges for the Pages deploy
id-token: write
# serialize Pages deployments; never cancel an in-flight publish
concurrency:
group: pages
cancel-in-progress: false
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- name: Checkout repository at release tag
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# build the exact commit the release was tagged at
ref: ${{ needs.release-please.outputs.tag_name }}
persist-credentials: false # read-only checkout; don't leave the token in .git/config

- name: Setup uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
enable-cache: true

# uv run resolves the locked deps (uv.lock) and the .python-version toolchain
# on demand, then Zensical renders docs/ into ./site.
- name: Build site
run: uv run zensical build

- name: Configure Pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0

- name: Upload Pages artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: site

- name: Deploy to GitHub Pages
id: deploy
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0

# Move the floating major/minor tags (v1, v1.1) that consumers pin onto the new
# release commit -- one implementation shared by both paths, ordered last. It
# needs goreleaser, so on the Go path the tags advance only after that job has
Expand Down
21 changes: 12 additions & 9 deletions .github/workflows/self-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,30 @@ on:
push:
branches: [main]

# Ceiling for the reusable workflow's release-please + publish jobs. GitHub
# resolves a reusable workflow's permissions statically at startup: it takes the
# union of every job's declared permissions and ignores `if:`. So the caller must
# grant what the goreleaser job declares (id-token / attestations /
# artifact-metadata) even though that job is skipped here (no .goreleaser.yaml) --
# dropping them startup_failures the whole run. No executed job realizes them at
# runtime: release-please and publish declare their own narrower job-level
# permissions, and the goreleaser job never runs.
# Ceiling for the reusable workflow's release-please + goreleaser/docs/publish jobs.
# GitHub resolves a reusable workflow's permissions statically at startup: it takes
# the union of every job's declared permissions and ignores `if:`. So the caller
# must grant what the goreleaser job declares (id-token / attestations /
# artifact-metadata) and what the docs job declares (pages) even though both are
# skipped here (no .goreleaser.yaml, no zensical.toml) -- dropping them
# startup_failures the whole run. No executed job realizes them at runtime:
# release-please and publish declare their own narrower job-level permissions, and
# the goreleaser and docs jobs never run.
permissions:
# release-please cuts the release/tag; publish moves the vanity tags
contents: write
# release-please creates pr labels via the issues api
issues: write
# release-please opens the release pull request
pull-requests: write
# cosign keyless signing
# cosign keyless signing (goreleaser) + the docs job's Pages OIDC deploy
id-token: write
# github build-provenance attestation
attestations: write
# artifact storage record for the attestation
artifact-metadata: write
# publish the docs site to GitHub Pages (docs job, gated on a zensical.toml)
pages: write

jobs:
release:
Expand Down
44 changes: 27 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ Copy a caller from [`examples/`](examples/) into your repo's `.github/workflows/
All reusable workflows live in [`.github/workflows/`](.github/workflows/) and are `workflow_call`-only. Every external
action is pinned to a full commit SHA; Dependabot keeps the pins fresh.

| Workflow | Platform | What it does |
| ------------------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------- |
| [`ci.yaml`](#ciyaml) | any | canonical Makefile gates (lint/build/test) per job, committed `dist/` verified, toolchains by detection, Codecov upload |
| [`security.yaml`](#securityyaml) | any | CodeQL over actions + go (autobuild) + javascript-typescript, language matrix by detection |
| [`release.yaml`](#releaseyaml) | any | release-please (two-pass) → GoReleaser (if `.goreleaser.yaml`); optional floating `v1`/`v1.1` vanity tags |
| [`merge.yaml`](#mergeyaml) | any | signature-preserving fast-forward merge — `/merge` now, or `/auto-merge` (comment/label) when approved + green |
| [`merge-review-ack.yaml`](#merge-review-ackyaml) | any | companion to `merge.yaml` — lets fork PRs auto-merge promptly when approved after CI is green |
| [`merge-notice.yaml`](#merge-noticeyaml) | any | posts a one-time "this repo merges via `/merge`" comment on new PRs |
| [`dependabot-merge.yaml`](#dependabot-mergeyaml) | any | auto-approves Dependabot minor/patch PRs and fast-forwards them once CI is green |
| Workflow | Platform | What it does |
| ------------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------- |
| [`ci.yaml`](#ciyaml) | any | canonical Makefile gates (lint/build/test) per job, committed `dist/` verified, toolchains by detection, Codecov upload |
| [`security.yaml`](#securityyaml) | any | CodeQL over actions + go (autobuild) + javascript-typescript, language matrix by detection |
| [`release.yaml`](#releaseyaml) | any | release-please (two-pass) → GoReleaser (if `.goreleaser.yaml`) + Zensical docs to Pages (if `zensical.toml`); vanity tags |
| [`merge.yaml`](#mergeyaml) | any | signature-preserving fast-forward merge — `/merge` now, or `/auto-merge` (comment/label) when approved + green |
| [`merge-review-ack.yaml`](#merge-review-ackyaml) | any | companion to `merge.yaml` — lets fork PRs auto-merge promptly when approved after CI is green |
| [`merge-notice.yaml`](#merge-noticeyaml) | any | posts a one-time "this repo merges via `/merge`" comment on new PRs |
| [`dependabot-merge.yaml`](#dependabot-mergeyaml) | any | auto-approves Dependabot minor/patch PRs and fast-forwards them once CI is green |

Each workflow below lists its inputs, secrets, and the permission ceiling the **caller** must grant — a reusable
workflow's jobs cannot exceed the permissions of the job that calls them. The snippet is the minimal caller; follow the
Expand Down Expand Up @@ -92,8 +92,11 @@ Full example: [`examples/security.yaml`](examples/security.yaml).

_Any repo._ Runs release-please (two-pass), then branches by detection: a repo with a `.goreleaser.yaml` runs GoReleaser
(archives, checksums, SBOMs, cosign signatures, optional Homebrew cask, SLSA attestation); every other repo's release is
just the release-please cut. With `vanity-tags: true` it also moves the floating major and minor tags (`v1` and `v1.1`).
A committed `dist/` is verified for freshness in [`ci.yaml`](#ciyaml) on every PR, not at release time.
just the release-please cut. A repo with a `zensical.toml` also rebuilds its Zensical docs site
(`uv run zensical build`) and publishes it to GitHub Pages — gated on an actual release and keyed off config presence
exactly like the GoReleaser path, so it is independent of GoReleaser (a repo can ship binaries and docs from one
release). With `vanity-tags: true` it also moves the floating major and minor tags (`v1` and `v1.1`). A committed
`dist/` is verified for freshness in [`ci.yaml`](#ciyaml) on every PR, not at release time.

- **Inputs:** `go-version-file` (default `go.mod`), `vanity-tags` (default `false`; move the floating `v1` / `v1.1` tags
— set it for Actions/reusable repos whose consumers pin `@v1`), `app-client-id` (optional; author the release as a
Expand All @@ -106,10 +109,15 @@ A committed `dist/` is verified for freshness in [`ci.yaml`](#ciyaml) on every P
`workflow_run` events — GitHub's recursion guard suppresses them — so [`merge.yaml`](#mergeyaml)'s auto-merge, which
is keyed on `workflow_run`, never retriggers when its checks go green and the PR only lands on the hourly sweep.
Authoring as the App restores the trigger. Skip both inputs if you don't auto-merge release PRs.
- **Publishing docs:** add a `zensical.toml` (plus `pyproject.toml` + `uv.lock`) at the repo root and set **Settings →
Pages → Source → GitHub Actions**. On each release the `docs` job runs `uv run zensical build` and deploys `./site` to
Pages. It renders `docs/` only — no language build — so a repo whose docs embed generated reference (e.g. a CLI/man
dump) must commit that output. Nothing to configure beyond the file and the Pages source.
- **Permissions (caller grants):** `contents: write`, `issues: write`, `pull-requests: write`, `id-token: write`,
`attestations: write`, `artifact-metadata: write`. Grant all six even without a `.goreleaser.yaml`: GitHub resolves a
reusable workflow's permissions as the union of every job and ignores `if:`, so the skipped GoReleaser job's
`id-token` / `attestations` / `artifact-metadata` are still required or the run fails at startup.
`attestations: write`, `artifact-metadata: write`, `pages: write`. Grant all seven even without a `.goreleaser.yaml`
or `zensical.toml`: GitHub resolves a reusable workflow's permissions as the union of every job and ignores `if:`, so
the skipped GoReleaser job's `id-token` / `attestations` / `artifact-metadata` and the docs job's `pages` are still
required or the run fails at startup.

```yaml
on:
Expand All @@ -119,9 +127,10 @@ permissions:
contents: write
issues: write
pull-requests: write
id-token: write # cosign keyless signing
id-token: write # cosign keyless signing + docs Pages OIDC deploy
attestations: write # github build-provenance attestation
artifact-metadata: write # artifact storage record for the attestation
pages: write # publish docs to GitHub Pages (when a zensical.toml exists)
jobs:
release:
uses: bitwise-media-group/github-workflows/.github/workflows/release.yaml@v2
Expand Down Expand Up @@ -297,8 +306,9 @@ toolchains from the files at the repo root:
to exclude it.
- **Release** — `release-please-config.json` + `.release-please-manifest.json`; a `.goreleaser.yaml`
(`release-type: go`, `draft: true`) selects the GoReleaser path, otherwise the release is just the release-please cut.
Set `vanity-tags: true` to move the floating `v1` / `v1.1` tags (after GoReleaser when present); a committed `dist/`
is verified in CI, not here.
A `zensical.toml` selects the docs path (rebuild the Zensical site and publish to Pages; needs Pages set to GitHub
Actions and `pages: write`). Set `vanity-tags: true` to move the floating `v1` / `v1.1` tags (after GoReleaser when
present); a committed `dist/` is verified in CI, not here.

A caller may mix a reusable-workflow job with normal jobs — e.g. a Go CLI keeps its product-specific `integration` /
`e2e` jobs in the same `ci.yaml` that calls the reusable `ci.yaml`.
Expand Down
18 changes: 13 additions & 5 deletions examples/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
# the push trigger and grants the release permissions; the reusable release
# workflow runs release-please (two-pass) then, when a .goreleaser.yaml exists,
# GoReleaser (archives, checksums, SBOMs, cosign signatures, optional Homebrew
# cask, SLSA attestation). With vanity-tags: true it moves the floating v1 / v1.1
# tags that consumers pin. A committed dist/ is verified in CI, not at release.
# cask, SLSA attestation), and when a zensical.toml exists, rebuilds the docs site
# and publishes it to GitHub Pages. With vanity-tags: true it moves the floating
# v1 / v1.1 tags that consumers pin. A committed dist/ is verified in CI, not at
# release.
#
# The repo must provide release-please-config.json + .release-please-manifest.json.
# Go repos add a .goreleaser.yaml (release-type: go, draft: true); Actions repos
# that commit dist/ set vanity-tags: true.
# that commit dist/ set vanity-tags: true; repos that publish docs add a
# zensical.toml (+ pyproject.toml + uv.lock) and enable Pages from GitHub Actions.
#
# Pin @v2 to a release tag or full commit SHA -- see README "Pinning".

Expand All @@ -20,20 +23,25 @@ on:
push:
branches: [main]

# ceiling for the reusable workflow's release-please + goreleaser/publish jobs
# Ceiling for the reusable workflow's release-please + goreleaser/publish/docs jobs.
# GitHub resolves a reusable workflow's permissions as the union of every job and
# ignores `if:`, so grant every scope below even when a job is skipped (no
# .goreleaser.yaml, no zensical.toml) -- a missing scope startup-fails the whole run.
permissions:
# create the release/tag, move vanity tags, and upload assets
contents: write
# release-please creates pr labels via the issues api
issues: write
# release-please opens the release pull request
pull-requests: write
# cosign keyless signing
# cosign keyless signing (goreleaser) + the docs job's Pages OIDC deploy
id-token: write
# github build-provenance attestation
attestations: write
# artifact storage record for the attestation
artifact-metadata: write
# publish the docs site to GitHub Pages (docs job, when a zensical.toml exists)
pages: write

jobs:
release:
Expand Down
Loading