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
18 changes: 10 additions & 8 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,6 @@ jobs:
node-version-file: ${{ inputs.node-version-file }}
cache: npm

- name: Install node packages
if: hashFiles('package.json')
run: npm ci

# uv is our Python toolchain -- gate on pyproject.toml (every uv project has
# one). uv reads requires-python / .python-version and downloads a matching
# interpreter on demand when the Makefile runs `uv run` / `uv sync`; the cache
Expand All @@ -122,6 +118,16 @@ jobs:
- name: Run ${{ matrix.target }}
run: make ${{ matrix.target }}

# Repos that commit their build output -- a JavaScript Action shipping a
# checked-in dist/, which GitHub runs directly with no build at consumption
# time -- must keep that artifact in sync with source. After `make build`
# regenerates dist/, a non-empty diff means the committed copy is stale; fail
# the PR so it never reaches main (and never gets released). Gated on dist/
# existing, so it is a no-op for the repos that don't commit one.
- name: Verify dist/ is up to date
if: ${{ matrix.target == 'build' && hashFiles('dist/**') != '' }}
run: git diff --exit-code -- dist

# Coverage is optional: gated on the `coverage` input (off for repos that
# emit none, e.g. the skills library), and even when on, `if-no-files-found:
# ignore` keeps a repo that happens to produce nothing from erroring -- the
Expand Down Expand Up @@ -216,10 +222,6 @@ jobs:
node-version-file: ${{ inputs.node-version-file }}
cache: npm

- name: Install node packages
if: hashFiles('package.json')
run: npm ci

- name: Setup uv
if: hashFiles('pyproject.toml')
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
Expand Down
97 changes: 29 additions & 68 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
# 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.
# - Every other repo takes the `publish` job: it rebuilds via `make build` and
# verifies a committed dist/ reproduces from source.
# - 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.
#
# Either job, when the caller sets vanity-tags: true, moves the floating major and
# minor tags (v1 and v1.1) that consumers pin.
# A final vanity-tags job, when the caller sets vanity-tags: true, moves the
# floating major and minor tags (v1 and v1.1) that consumers pin -- it needs the
# goreleaser job, so on the Go path the tags advance only after that job has
# published the release, and on every other path (goreleaser skipped) it runs
# straight after release-please.
#
# Detection is a single hashFiles output on the release-please job, because a
# job-level `if:` runs before checkout and can't see the workspace. The two-pass
Expand All @@ -26,9 +29,9 @@
# empty otherwise). This lets a caller append a release-only job instead of, e.g.,
# rebuilding docs on every push to main.
#
# The consuming repo provides release-please-config.json + .release-please-manifest.json
# and a Makefile `build` target (a no-op is fine). Go repos add a .goreleaser.yaml
# (release-type: go, draft: true); Actions repos commit dist/ and set vanity-tags: true.
# 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.
#
# Runs via workflow_call from a thin caller (examples/release.yaml) that owns the
# push trigger and grants the release permissions.
Expand All @@ -42,11 +45,6 @@ on:
required: false
default: go.mod
type: string
node-version-file:
description: File the Node version is read from (passed to setup-node).
required: false
default: .node-version
type: string
vanity-tags:
description: >-
Move the floating major and minor tags (v1 and v1.1) to the release commit, for any release type. Off by
Expand Down Expand Up @@ -112,7 +110,7 @@ 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 publish
# the goreleaser job's id-token/attestations/artifact-metadata even on the non-Go
# path where that job is skipped -- see examples/release.yaml.
permissions: {}

Expand Down Expand Up @@ -275,69 +273,32 @@ jobs:
with:
subject-checksums: ./dist/checksums.txt

- name: Move vanity tags
if: ${{ inputs.vanity-tags }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.release-please.outputs.tag_name }}
SHA: ${{ needs.release-please.outputs.sha }}
run: |
set -eu
MAJOR="${TAG%%.*}" # v1
MINOR="${TAG%.*}" # v1.1
for ref in "$MAJOR" "$MINOR"; do
gh api "repos/$GITHUB_REPOSITORY/git/refs/tags/$ref" -X PATCH -f sha="$SHA" -F force=true ||
gh api "repos/$GITHUB_REPOSITORY/git/refs" -f ref="refs/tags/$ref" -f sha="$SHA"
done

publish:
needs: release-please
# Same trusted-trigger allowlist as the goreleaser job above (fails closed on
# any event type not vetted here).
# 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
# published and attested the release (never pointing @v1 at a half-finished
# release); on every other path goreleaser is skipped and this runs straight
# after release-please. The release was already cut by release-please and dist/
# freshness is enforced in CI, so there is nothing to build or verify -- a pure
# tag move via the API, no checkout or toolchain.
#
# `!cancelled()` is required so a skipped goreleaser (the non-Go path) does not
# skip this job by dependency; the result guard then proceeds only when goreleaser
# succeeded or was skipped, never when it failed -- so a failed release never
# advances the tags. Same trusted-trigger allowlist as goreleaser (fails closed on
# any event type not vetted here).
vanity-tags:
needs: [release-please, goreleaser]
if: >-
needs.release-please.outputs.release_created && !needs.release-please.outputs.goreleaser &&
!cancelled() && inputs.vanity-tags && needs.release-please.outputs.release_created &&
(needs.goreleaser.result == 'success' || needs.goreleaser.result == 'skipped') &&
contains(fromJSON('["push","workflow_dispatch","schedule"]'), github.event_name)
runs-on: ubuntu-latest
permissions:
# move vanity tags
contents: write
steps:
- name: Checkout repository at release tag
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.release-please.outputs.tag_name }}
persist-credentials: false # build + verify only; vanity tags move via the GITHUB_TOKEN env

# Only a root go.mod (not the universal addlicense go.work/tools/go.mod) marks
# a repo that builds Go; a `make build` for a non-Go repo needs no toolchain.
- name: Setup go
if: hashFiles('go.mod')
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version-file: ${{ inputs.go-version-file }}
cache: true

- name: Setup node
if: hashFiles('package.json')
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ${{ inputs.node-version-file }}
cache: npm

- name: Install node packages
if: hashFiles('package.json')
run: npm ci

- name: Run build pipeline
run: make build

# Verify a committed dist/ reproduces from source. Vacuous (and harmless) for
# repos that don't commit a dist/ -- the pathspec simply matches nothing.
- name: Verify dist/ is up to date
run: git diff --exit-code -- dist

- name: Move vanity tags
if: ${{ inputs.vanity-tags }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.release-please.outputs.tag_name }}
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/self-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
# SPDX-License-Identifier: MIT

# This repo's own release, dogfooding the reusable release workflow by local path.
# With no .goreleaser.yaml it takes the publish branch: release-please cuts the
# release (release-type: simple), `make build` is a no-op, and vanity-tags moves
# the floating v1 / v1.1 tags that consumers of these reusable workflows pin.
# With no .goreleaser.yaml it takes the non-Go path: release-please cuts the release
# (release-type: simple), then -- because vanity-tags is true -- the vanity-tags job
# moves the floating v1 / v1.1 tags that consumers of these reusable workflows pin.
name: Release

on:
Expand Down
63 changes: 33 additions & 30 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, 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`) or `dist/` rebuild + verify; optional 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`); 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 |

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 All @@ -31,8 +31,9 @@ link beneath it for the fully-commented version.

_Any repo._ Runs the canonical Makefile gates — `lint`, `build`, `test` — as one parallel job each, sets up only the
toolchains the repo has (a root `go.mod` → Go, `package.json` → Node), and uploads coverage to Codecov from a job
isolated from PR-built code. An opt-in `e2e` job runs `make e2e`. A caller may add product-specific jobs (e.g.
`integration`) alongside the `ci` job.
isolated from PR-built code. A repo that commits its build output has its `dist/` verified up to date after `make build`
(so a stale committed artifact fails the PR). An opt-in `e2e` job runs `make e2e`. A caller may add product-specific
jobs (e.g. `integration`) alongside the `ci` job.

- **Inputs:** `go-version-file` (default `go.mod`), `node-version-file` (default `.node-version`),
`cache-dependency-path` (default `go.sum`; newline-separated lockfiles to key the module cache on), `e2e` (default
Expand Down Expand Up @@ -90,13 +91,13 @@ Full example: [`examples/security.yaml`](examples/security.yaml).
### `release.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 rebuilds via
`make build` and verifies a committed `dist/`. With `vanity-tags: true` it also moves the floating major and minor tags
(`v1` and `v1.1`).
(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.

- **Inputs:** `go-version-file` (default `go.mod`), `node-version-file` (default `.node-version`), `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 GitHub App rather than `GITHUB_TOKEN` — `vars.FF_MERGE_CLIENT_ID`).
- **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
GitHub App rather than `GITHUB_TOKEN` — `vars.FF_MERGE_CLIENT_ID`).
- **Secrets:** `homebrew-tap-token` — optional; only needed if `.goreleaser.yaml` publishes a Homebrew cask to another
repo (`secrets.HOMEBREW_TAP_GITHUB_TOKEN`). `app-private-key` — optional; required only when `app-client-id` is set
(`secrets.FF_MERGE_PRIVATE_KEY`).
Expand Down Expand Up @@ -286,16 +287,18 @@ toolchains from the files at the repo root:
- **Makefile targets** — `lint` (all check-mode static analysis: `prettier --check`, markdownlint, and for Go `go vet` /
`govulncheck`), `build`, `test` (emitting `coverage/cobertura-coverage.xml`, optionally `coverage/junit.xml`), and
`e2e`. Stub any that don't apply as a no-op (`build: ; @:`) so `make <target>` always succeeds; coverage is optional.
- **Toolchain detection** — `setup-go` runs only when a **root `go.mod`** exists; `setup-node` (and `npm ci`) only when
`package.json` exists. A tools-only `go.work` + `tools/go.mod` (for `go tool addlicense`) is universal dev tooling, so
it is **not** a Go-product signal — only a root `go.mod` is.
- **Toolchain detection** — `setup-go` runs only when a **root `go.mod`** exists; `setup-node` only when `package.json`
exists (the `make` targets install their own deps, so the workflow sets up the toolchain but does not run `npm ci`). A
tools-only `go.work` + `tools/go.mod` (for `go tool addlicense`) is universal dev tooling, so it is **not** a
Go-product signal — only a root `go.mod` is.
- **CodeQL** — scans `actions` always, `go` (autobuild, `setup-go` from `go-version-file`) when a root `go.mod` exists,
and `javascript-typescript` when `package.json` exists. A repo with a bundled `dist/` should pass
`config-file: ./.github/codeql/codeql-config.yaml` (copy [`examples/codeql-config.yaml`](examples/codeql-config.yaml))
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 publish path rebuilds via `make build`
and verifies a committed `dist/`. Set `vanity-tags: true` to move the floating `v1` / `v1.1` tags.
(`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 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 All @@ -321,14 +324,14 @@ its ruleset bypass, and the `FF_MERGE_CLIENT_ID` variable + `FF_MERGE_PRIVATE_KE
## Testing changes

This repo dogfoods its own reusable workflows by local path: `self-ci.yaml` calls `ci.yaml` (which detects node only —
there is no root `go.mod` — and runs the canonical `make` gates) and `self-release.yaml` calls `release.yaml` (the
publish path, moving the vanity tags). `self-security.yaml` stays a bespoke `actions`-only scan: the library has no
compilable Go and no JS/TS product source, so the reusable `security.yaml` would add an empty `javascript-typescript`
leg from its tooling-only `package.json`. The `/merge` + auto-merge flows (`self-merge.yaml`), its fork-PR review-ack
companion (`self-merge-review-ack.yaml`), the merge notice (`self-merge-notice.yaml`), and Dependabot auto-merge
(`self-dependabot-merge.yaml`, which keeps the reusable workflows' action pins fresh) dogfood the rest. Validate a
change to a reusable workflow by temporarily pointing a real consumer's caller at a feature branch or SHA
(`@your-branch`) and opening a PR there.
there is no root `go.mod` — and runs the canonical `make` gates) and `self-release.yaml` calls `release.yaml` (no
`.goreleaser.yaml`, so just the release-please cut plus the `vanity-tags` job). `self-security.yaml` stays a bespoke
`actions`-only scan: the library has no compilable Go and no JS/TS product source, so the reusable `security.yaml` would
add an empty `javascript-typescript` leg from its tooling-only `package.json`. The `/merge` + auto-merge flows
(`self-merge.yaml`), its fork-PR review-ack companion (`self-merge-review-ack.yaml`), the merge notice
(`self-merge-notice.yaml`), and Dependabot auto-merge (`self-dependabot-merge.yaml`, which keeps the reusable workflows'
action pins fresh) dogfood the rest. Validate a change to a reusable workflow by temporarily pointing a real consumer's
caller at a feature branch or SHA (`@your-branch`) and opening a PR there.

## Releasing this repo

Expand Down
Loading
Loading