From aaa75ed70a1a0d5f879012013ffba8579a869bbe Mon Sep 17 00:00:00 2001 From: "Deavon M. McCaffery" Date: Mon, 29 Jun 2026 02:18:52 +0100 Subject: [PATCH 1/3] fix(ci): verify committed dist/ after build; drop redundant npm ci Add a build-job step that runs git diff --exit-code -- dist after make build, gated on a committed dist/ existing (hashFiles dist/**), so a repo that ships a checked-in dist/ (e.g. a JavaScript Action) fails the PR when the committed artifact drifts from source. This catches staleness pre-merge instead of after a release is already cut, and is a no-op for repos that do not commit a dist/. Drop the npm ci steps from the make and e2e jobs: the Makefile targets install their own dependencies, so the workflow only sets up the Node toolchain rather than pre-installing. Keeps the Makefile the single build boundary. Signed-off-by: Deavon M. McCaffery --- .github/workflows/ci.yaml | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 136da95..5c60d4b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 @@ -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 @@ -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 From f68fb688e2d5bb02a9bb4237a03d3412849bbb31 Mon Sep 17 00:00:00 2001 From: "Deavon M. McCaffery" Date: Mon, 29 Jun 2026 02:18:53 +0100 Subject: [PATCH 2/3] fix(release): verify dist/ in CI, not after the release is cut The publish job rebuilt via make build and ran git diff -- dist only after release-please had already created the release and tag, so a stale dist/ could not gate the release -- it just left the vanity tags unmoved. That verification now lives in ci.yaml (pre-merge), so drop the build and verify from release entirely. With nothing left to build or verify, the publish job collapsed to a pure vanity-tag move. Replace it and the goreleaser job's duplicate tag step with a single vanity-tags job that needs both release-please and goreleaser: one implementation for both paths, and -- because it needs goreleaser -- the tags advance only after GoReleaser has published on the Go path, never pointing the floating major tag at a half-finished release. The not-cancelled guard keeps the job eligible when goreleaser is skipped (non-Go path); the result check runs the move only when goreleaser succeeded or was skipped, never on failure. Also drop the now-unused node-version-file input: it only ever fed the publish job's setup-node, which is gone. No consumer passes it to the release workflow, so this is not a breaking change in practice. Signed-off-by: Deavon M. McCaffery --- .github/workflows/release.yaml | 97 +++++++++-------------------- .github/workflows/self-release.yaml | 6 +- examples/release.yaml | 11 ++-- 3 files changed, 37 insertions(+), 77 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 178abf8..0c279ec 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 @@ -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. @@ -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 @@ -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: {} @@ -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 }} diff --git a/.github/workflows/self-release.yaml b/.github/workflows/self-release.yaml index 0425635..5b02884 100644 --- a/.github/workflows/self-release.yaml +++ b/.github/workflows/self-release.yaml @@ -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: diff --git a/examples/release.yaml b/examples/release.yaml index 5cdb3ca..40ad6c2 100644 --- a/examples/release.yaml +++ b/examples/release.yaml @@ -5,13 +5,12 @@ # 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) -- otherwise it rebuilds via `make build` and verifies a -# committed dist/. With vanity-tags: true it also moves the floating v1 / v1.1 -# tags. +# 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. # -# The repo must provide 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/. +# 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. # # Pin @v2 to a release tag or full commit SHA -- see README "Pinning". From 5c254dd9f1f6c47b7ee9a73153523ee8d59733fb Mon Sep 17 00:00:00 2001 From: "Deavon M. McCaffery" Date: Mon, 29 Jun 2026 02:18:54 +0100 Subject: [PATCH 3/3] docs: update README for CI-side dist verification and release flow Reflect the relocation of the committed-dist/ check into ci.yaml, the removal of release-time build and verify, the single consolidated vanity-tags job, and that CI no longer runs npm ci (the Makefile installs its own deps). Drop the stale node-version-file entry from the release inputs in the catalog. Signed-off-by: Deavon M. McCaffery --- README.md | 63 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 33 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 9d0d0e5..943b9cb 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, 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 @@ -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 @@ -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`). @@ -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 ` 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`. @@ -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