diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0c279ec..41b9856 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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. # @@ -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. @@ -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. @@ -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 @@ -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. @@ -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 diff --git a/.github/workflows/self-release.yaml b/.github/workflows/self-release.yaml index 5b02884..1a9013a 100644 --- a/.github/workflows/self-release.yaml +++ b/.github/workflows/self-release.yaml @@ -11,14 +11,15 @@ 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 @@ -26,12 +27,14 @@ permissions: 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: diff --git a/README.md b/README.md index 943b9cb..587af23 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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: @@ -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 @@ -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`. diff --git a/examples/release.yaml b/examples/release.yaml index 40ad6c2..e619082 100644 --- a/examples/release.yaml +++ b/examples/release.yaml @@ -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". @@ -20,7 +23,10 @@ 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 @@ -28,12 +34,14 @@ permissions: 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: