Shared CI/CD for Go repositories across the strongo, dal-go, sneat-co,
ingitdb, and bots-go-framework orgs: reusable GitHub workflows and a
composite action that run get, vet, build, test, lint, optional
coverage, and automatic SemVer tagging.
Renamed: this repo was
strongo/go-ci-action. GitHub redirects the old path, so existinguses: strongo/go-ci-action/...references keep working. New references should usestrongo/cicd; the Renovate preset below migrates the old name for you.
| File | Kind | Purpose |
|---|---|---|
.github/workflows/workflow.yml |
Reusable workflow (workflow_call) |
Full Go CI: lint, test (+coverage), build, and version bump. The primary entry point. |
.github/workflows/release.yml |
Reusable workflow (workflow_call) |
GoReleaser release flow (tag + goreleaser release). |
.github/workflows/validate-published-artifact.yml |
Reusable workflow (workflow_call) |
Read-only, exact-tag validation of already published CLI archives. |
action.yml |
Composite action | Single-job CI for callers that want CI steps inline in their own job. |
default.json |
Renovate preset | Shareable config consumers extends to auto-track this repo's tag (see below). |
Pinning @main means one bad commit to the shared workflow breaks every
consumer's CI at the same time — there is no blast-radius firebreak. Pin to a
version tag instead:
jobs:
ci:
uses: strongo/cicd/.github/workflows/workflow.yml@v1 # moving major tag
secrets:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}Two pinning styles are supported:
@v1— moving major tag (recommended default). A lightweight tag that the maintainer advances deliberately to the latest backward-compatible release. You automatically get fixes without opening a PR, but a bad release only reaches you whenv1is advanced (not on every push tomain).@v1.x.y— exact release (maximum control). Pin an immutable release and let Renovate open a PR to bump it (see below). Each bump runs through your own CI before merging, giving you a full per-repo firebreak and an audit trail.
@main still works and stays supported for backward compatibility, but is
discouraged for the reasons above.
Existing
@mainconsumers are not being mass-migrated. Adopt@v1(or the Renovate preset) gradually, per repo, on your own schedule.
The reusable Go workflow restores a single cache of Go modules and compiled Go packages in both parallel jobs. Only Build & test may save a missing cache key, and only after a successful build and test run. This avoids a cold-cache race in which both jobs attempt to reserve and upload the same key.
The key is determined before Go commands run, from the runner OS/architecture,
Go 1.26, CGO setting, and the selected module's go.sum; the matching primary
key is also used for the sole save. The reusable workflow enables the separate
golangci-lint-action analysis cache by default. Callers can set
golangci_lint_cache: false when cache transfer is slower than linting for a
particular repository. golangci_lint_cache_invalidation_interval controls how
many days a lint-cache namespace remains reusable and defaults to the action's
seven-day policy.
The composite action keeps that linter cache disabled; it has a separate input
surface and is not part of this reusable-workflow timing policy. It uses go mod download all rather than go get ./..., so CI never rewrites a consumer's
dependency requirements.
Set GOPRIVATE to the private module prefixes and goprivate_git_hosts to the
matching Git URL prefixes that may receive GH_TOKEN. The latter accepts
comma- or newline-separated host/owner entries:
jobs:
ci:
uses: strongo/cicd/.github/workflows/workflow.yml@v1
with:
GOPRIVATE: github.com/sneat-co,github.com/sneat-games
goprivate_git_hosts: |
github.com/sneat-co
github.com/sneat-games
secrets:
GH_TOKEN: ${{ secrets.PRIVATE_MODULES_TOKEN }}Each plural entry must include an owner or narrower repository path. A
host-wide entry such as github.com is rejected, preventing the private-module
token from being attached to unrelated GitHub fetches. The same inputs are
available on release.yml.
goprivate_git_host remains available for existing single-prefix callers. Its
historical github.com default is preserved for backward compatibility, but
new and migrated callers should use the scoped plural input.
release.yml runs the GoReleaser flow: checkout (full history) → setup-go →
optional auto-tag → goreleaser release --clean against your repo's own
.goreleaser.yaml. Two trigger styles are supported:
- Push to
main— git-cliff calculates the next version from conventional commits and the shared workflow releases the new tag (continuous delivery). - Push a
vX.Y.Ztag — the auto-bump step is skipped (the tag already fixes the version) and GoReleaser releases that exact tag. Use this for an explicit, human-gated "cut a release by pushing a tag" flow.
Publishers that push to other repos (Homebrew, Scoop, WinGet, AUR) need
credentials the default GITHUB_TOKEN can't provide. Pass them as optional
secrets; GoReleaser reads only the ones your config references:
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
uses: strongo/cicd/.github/workflows/release.yml@v1
with:
go_version: '1.26.5' # optional; defaults to '1.26'
# goreleaser_extra_args: '--skip=chocolatey,snapcraft' # optional
secrets:
GORELEASER_GITHUB_TOKEN: ${{ secrets.MY_GORELEASER_PAT }} # brew/scoop/winget
WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }} # optional, if separate
AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} # optionalReference the forwarded credentials in .goreleaser.yaml as
{{ .Env.GORELEASER_GITHUB_TOKEN }}, {{ .Env.WINGET_GITHUB_TOKEN }}, and
{{ .Env.AUR_SSH_PRIVATE_KEY }}.
Publishers that need a different runner or extra tooling this ubuntu job
lacks — Chocolatey (choco, Windows-only), Snapcraft (snapcraft), or native
macOS signing (xcrun/codesign) — cannot run here. Keep them as a small
per-repo job (needs: this one) and add --skip=chocolatey,snapcraft via
goreleaser_extra_args so this job doesn't try to run them.
The incident this exists for (2026-07): specscore-cli v0.24.0 released
clean — every CI check green — but the Homebrew-cask-installed binary was
silently blocked by macOS Gatekeeper on every invocation for every user. CI
had tested the source, built it, and even uploaded the release artifact; it
had never run the thing it published. A local curl + run of the exact
same release asset worked instantly, which is what made this so easy to
ship: the bug lived entirely in the install path, not the binary.
release.yml now downloads and exercises what it just published, in three
layers, all under artifact_smoke_test (default on):
- Run the published GitHub release asset. Downloads the archive for
each platform in
artifact_smoke_test_platforms(default: linux/amd64 onubuntu-latest, darwin/arm64 onmacos-latest; matched by the*_<os>_<arch>.{tar.gz,tgz,zip}archive extension, never a same-named checksum/signature/SBOM sidecar), extracts it, and runs<binary> --version(configurable viaartifact_smoke_test_command, for a CLI whose default invocation doesn't support a bare--version) under an explicit watchdog (artifact_smoke_test_timeout_seconds, default 30s) — a hang is killed and reported as a clear failure, never an eventual multi-hour job timeout. Once the binary is found and executed, this is always a hard failure if it hangs, exits non-zero, or prints nothing. This proves the artifact is a working binary; it does not prove a Homebrew-cask user can run it — a plain download carries nocom.apple.quarantineattribute, so this layer alone passed for v0.24.0. - Assert macOS code signing / notarization (
codesign -dv,spctl -a -vv -t install) against the same darwin binary — static inspection, no execution, no Gatekeeper risk in the check itself. Uses assessment typeinstall, not the defaultexecute/exec: a bare CLI binary is not an app bundle, so the default type rejects even a genuinely notarized one (measured directly against a real notarizedingitdb-clibinary — see the "macOS notarization" section below). It also checks the output forsource=Notarized Developer ID, since exit 0 alone only proves Developer ID signing, not notarization. This is what actually would have caught v0.24.0: an ad-hoc signature with no Developer ID. brew install --caskand run the installed binary (artifact_smoke_test_homebrew_cask, default on) — auto-detected from this repo's ownhomebrew_casks:config, no-op otherwise. The only layer that reproduces Homebrew's quarantine attribute, i.e. the only layer that actually reproduces the incident end-to-end.
"Could not test" is never "tested and broken." This workflow is reused
by repos not enumerated here, including libraries and Docker-only releases
with no per-arch archive, so a naming/shape mismatch must never be read as
"the release is broken." Every layer distinguishes the two explicitly: no
matching release asset, an unrecognized archive format, no binary found
inside it, an unresolvable binary name (see the tag_prefix note below), or
a failed brew tap/brew install --cask (transient Homebrew/network issue,
tap-visibility timing, a cask-not-found mismatch) all log ::warning:: and
are skipped, unconditionally — never a failure, regardless of
require_notarized_macos. Only "the binary was found and actually executed,
and it hung / exited non-zero / printed nothing" fails a release: always for
layer 1, and (once installed) gated by require_notarized_macos for layers
2/3. This split is what makes shipping artifact_smoke_test: true by
default safe as well as meaningful — a default-off check would have
prevented nothing, but a default-on check that can't tell "untestable" from
"broken" would have started failing releases it never should have.
Every network step that isn't already bounded by the watchdog above
(gh release download, brew tap/brew install --cask) has its own
timeout too (120s / 300s respectively), plus each smoke-test job has a
timeout-minutes well under GitHub's 6-hour default job timeout — the same
"a hang must fail fast and legibly, never eventually" rationale extended to
every step that talks to a network, not just the binary invocation itself.
Layers 2 and 3 currently only warn, by design. No org consumer currently
has a verified notarized release path. A default hard-fail would red every
unsigned CLI release on day one and just get disabled fleet-wide instead of
fixed. These layers log a ::warning:: naming exactly what's missing (for
example, an ad-hoc signature, TeamIdentifier: not set, or spctl rejection)
so the gap stays visible. Set require_notarized_macos: true per repository
only after the exact published path has passed the proof gate below. A valid
Developer ID signature and Apple notarization remain the intended end state,
not an optional extra.
jobs:
release:
uses: strongo/cicd/.github/workflows/release.yml@v1
with:
# All of the below are optional; shown at their defaults.
# artifact_smoke_test: true
# artifact_smoke_test_binary: '' # '' infers from .goreleaser.y*ml / repo name
# artifact_smoke_test_command: '--version'
# artifact_smoke_test_timeout_seconds: 30
# artifact_smoke_test_homebrew_cask: true
# require_notarized_macos: false # flip once this repo is actually notarized
secrets: { ... }Existing @v1 callers get layer 1 automatically (hard-fail) and layers 2/3
automatically (warn-only) the next time the maintainer advances the v1
tag — no config changes required. A repo whose binary name the inference
guesses wrong (multi-binary repos only check the first builds[] entry;
repos where the executable doesn't match project_name or a -cli-stripped
repo name) should set artifact_smoke_test_binary explicitly. A repo that
doesn't publish a runnable CLI binary at all should set
artifact_smoke_test: false.
Use validate-published-artifact.yml when an already published CLI archive
needs to be executed again without creating a tag or release. This is separate
from release.yml because reusable-workflow permissions can only stay the same
or become more restrictive: a contents: read validation caller cannot call a
workflow containing a contents: write release job, even when that job would be
conditionally skipped.
The validation workflow has only contents: read, requires the exact release
tag and executable name, downloads archives only from that tag, and hard-fails
unless every configured platform reaches and successfully executes the
published binary. It has no GoReleaser, tag creation, publishing, or Homebrew
cask path.
permissions:
contents: read
jobs:
validate:
uses: strongo/cicd/.github/workflows/validate-published-artifact.yml@v1
permissions:
contents: read
with:
release_tag: v0.33.0
artifact_binary: my-cli
artifact_command: --versionBinary-name inference reads .goreleaser.y*ml/goreleaser.y*ml at the repo
root. A repo using tag_prefix for a subdirectory module (e.g.
"ingitdb/v", see "Automatic version tagging" below) whose GoReleaser
config lives in that subdirectory instead won't be found there — inference
deliberately does not fall back to guessing from the repo name in that
specific case (a subdirectory module's name isn't the repo's name), so the
whole smoke test is skipped with a warning instead of testing, or failing
on, an unverified guess. Set artifact_smoke_test_binary explicitly to
enable it for that shape of repo. (No current consumer of this workflow
combines a subdirectory tag_prefix with this fallback path — checked
directly against every one at the time this was written — but the workflow
is reused by repos not enumerated here, so this stays defensive.)
Known residual gap (confirmed empirically, not assumed): GitHub-hosted
macOS runners have no interactive user session, so the real, indefinite
"Apple could not verify…" Gatekeeper dialog a logged-in user hits has
nowhere to render. Layer 2 (static codesign/spctl inspection) is
unaffected by this — it never executes the binary. But layer 3 (brew install --cask + run) was tested directly against the actual quarantined,
ad-hoc-signed specscore-cli v0.24.0 Homebrew-cask binary on a real
GitHub-hosted macos-latest runner, and it did not hang: the process
completed after a ~30s Gatekeeper assessment delay and exited 0 with valid
output — right at the edge of the default artifact_smoke_test_timeout_seconds.
So layer 3's pass/fail is not reliable evidence of what a real interactive
user experiences on this runner type — a pass there does not mean
Homebrew-cask users aren't blocked. Layer 2 is the reliable, deterministic
signal; treat layer 3 as corroborating only. This is exactly why layer 2 is
positioned as the primary gate.
These are ecosystem-wide .goreleaser.yaml standards so all our CLIs package
and update identically. New repos MUST follow them; existing repos are migrated
as they're touched.
Use homebrew_casks: — not the deprecated brews: — in .goreleaser.yaml.
Decided 2026-07-17; applied to ingitdb-cli and specscore-cli.
- Why. We ship prebuilt binaries, not source; casks are Homebrew's home for
prebuilt artifacts, and GoReleaser has deprecated
brews:(it emits a warning and will be removed).goreleaser checkfails onbrews:in current versions. - Install command becomes
brew install --cask <tap>/<name>(the tap-qualified form also resolves without--cask, so it's a soft change). - Linux tradeoff — accepted. Homebrew casks are macOS-only; Linux users
install via our
curl … | shscript (orgo install), notbrew, so dropping the Linux-brew path costs us nothing. - Cask fidelity limits. GoReleaser's cask schema has no
install/testhook, so a per-manifest--versionsmoke test can't be carried over. The tap gains aCasks/tree; a leftoverFormula/<name>.rbstops updating and can be pruned once. - Self-update gotcha. If the CLI has a self-update path that detects
Homebrew installs, it MUST treat
/Caskroom/as Homebrew-managed: Apple Silicon casks live under/opt/homebrew/Caskroom/…but Intel casks under/usr/local/Caskroom/…, which matches no other Homebrew marker.
strongo/cicd owns the reusable signing orchestration: the GoReleaser and
signer version pins, optional Apple-secret forwarding, the pre-publication
proof gate, retained signing evidence, and the rule that decides when public
release mutation may begin. The consumer repository owns its product-specific
.goreleaser.yml, binary/build IDs, cask metadata, repository secret mapping,
and the decision to opt into the proven signing mode.
The cross-platform path uses quill inside GoReleaser: it reads a Developer ID
Application .p12, embeds a signature in each Mach-O binary, and submits that
binary to Apple's notarization service while the main release job remains on
Ubuntu. This is the desired architecture; quill is still the intended
destination once its exact output is proven runnable. Native codesign and
notarytool on a macOS runner remain the fallback architecture if the
cross-platform signer cannot satisfy the same contract.
Until the proof exists, consumers ship the existing ad-hoc-signed cask path
and may retain GoReleaser's documented post-install quarantine-removal hook.
A dormant notarize.macos example is not release evidence and must not be
copied into a production consumer as though it were a verified reference.
WB CLI v0.66.1 exposed the failure tracked by
issue #66. GoReleaser v2.18.0
reported both signing and successful notarization for its Go 1.27 darwin/arm64
binary, but the published executable failed
codesign --verify --deep --strict --verbose=4 with an invalid embedded
signature and macOS killed every invocation with status 137.
Do not attribute that incident to Go 1.27's Darwin deployment metadata. The
working WB CLI v0.66.0 control was also built with Go 1.27 and carried the same
LC_BUILD_VERSION values (minos 13.0, SDK 26.2); its linker-provided ad-hoc
signature verified and it executed normally. The discriminating change was
enabling the quill signing path. The defect has not yet been isolated to a
specific line inside quill, so linker overrides are not an approved remedy.
WB temporarily disables that signing path and retains its proven cask quarantine-removal fallback. Re-enable it only after issue #66 closes with all of this evidence against one exact candidate:
- A non-public Go 1.27 pilot uses the same certificate, notary credentials, GoReleaser version, quill version, and workflow route as production.
- Before any tag, GitHub Release, or cask mutation, a clean macOS runner runs
deep strict
codesign,spctl -a -t install -vvand checks forsource=Notarized Developer ID, then executes<binary> --versionand requires exit 0 with non-empty output. - The receipt retains the exact candidate digest, toolchain and signer versions, certificate identity metadata, notarization result, and macOS verification output.
- Only after that gate passes may the consumer enable the signer and set
require_notarized_macos: true.
The existing post-release layers remain valuable defense in depth, but they cannot substitute for this gate: once publication has happened, they are an alarm rather than a fence.
Add the shared preset to a consumer repo's renovate.json so Renovate keeps the
strongo/cicd reference current — and rewrites any lingering
strongo/go-ci-action reference onto strongo/cicd@v1 — automatically:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
"github>strongo/cicd"
]
}The preset (default.json in this repo):
- Groups and auto-updates the
strongo/cicdreusable-workflow / action ref, advancing@v1.x.ypins (and following the moving@v1) as releases are cut. - Auto-merges those bumps through a PR gated by your CI, so a broken release fails your build and blocks the merge — the firebreak — instead of landing silently.
- Replaces legacy
strongo/go-ci-actionreferences withstrongo/cicd@v1, automating the rename find-and-replace.
Override anything you like (e.g. disable automerge) in your own renovate.json
after the extends.
On a push/merge to main, the go_bump job (workflow) / tag step (action) uses
[git-cliff] to calculate a new SemVer version from conventional commits since
the last matching tag. The shared workflow then applies its guard and pushes
the tag with Git:
We deliberately use git-cliff rather than semantic-release or an npm-oriented
tag action. It is a language-neutral Git-history tool: callers need neither
package.json nor any project-specific release metadata. It only proposes a
version; this repository's explicit guard remains the authority that creates a
tag, preserving our v0 policy and default_bump contract.
| Commit type since last tag | Result |
|---|---|
fix: |
patch bump (v1.2.3 → v1.2.4) |
feat: |
minor bump (v1.2.3 → v1.3.0) |
feat!: / BREAKING CHANGE: |
major only if allow_major_version_bump: true; otherwise capped to a minor bump (see guard below) |
| docs/chore/ci/refactor only | default_bump decides (see below) |
git-cliff is configured to make a feat!: / BREAKING CHANGE: commit a
minor bump while the current major is zero. The shared guard independently
caps a proposed major bump when allow_major_version_bump is false. This keeps
a pre-1.0 module on v0 unless a maintainer deliberately cuts v1.
The reusable workflow.yml (go_bump), release.yml (CD bump path), and the
composite action.yml all guard against this: they compute a proposed version
without writing a tag, and
when allow_major_version_bump is false (the default) a bump that would raise
the major version is capped to a minor bump of the previous version
(v0.64.2 → v0.65.0; v1.5.0 → v1.6.0) with a ::warning:: in the log. Pre-1.0
this is exactly right — a breaking change is a minor bump until you deliberately
cut v1.0.0. To intend a real major, either set allow_major_version_bump: true
or push an explicit vX.0.0 tag (a tag push bypasses the bump entirely).
git-cliff derives the bump from the commit range lastTag..HEAD. Two
things must be true for it to see your feat:/fix: commits:
-
Full history and tags. The reusable workflows check out with
fetch-depth: 0, and the composite action now fetches complete history and tags before calculating. With only the default shallow clone, git-cliff seesHEAD's message — so aMerge pull request #N …merge commit (which is never conventional-commit-shaped) produced no bump and no tag, even when the merged branch was full offeat:/fix:commits. This was the cause of releases needing hand-cut tags. Composite-action callers no longer need to configure a special checkout depth themselves. -
Conventional commits reach
main. Choose one, and set it in your repo's Settings → General → Pull Requests:- Squash merge + "Default to PR title" for the squash commit message, and
write conventional PR titles (
feat: …,fix: …). The single squashed commit is then conventional. (Recommended — simplest and most robust.) - Merge commits, with conventional commits on your branches.
fetch-depth: 0lets the action read them behind the merge commit.
Either way, enabling a linear-history / conventional-PR-title convention makes tagging deterministic.
- Squash merge + "Default to PR title" for the squash commit message, and
write conventional PR titles (
Controls what happens when no commit since the last tag implies a bump (docs/chore/ci-only changes):
- Reusable
workflow.yml:default_bump: 'patch'by default (a push/merge tomainalways cuts at least a patch tag — preserves prior behaviour). Setdefault_bump: 'false'to tag only onfeat:/fix:/breaking commits. - Composite
action.yml:default_bump: 'false'by default (tags only on conventional commits). Set to'patch'/'minor'/'major'to always bump.
Tags are cut automatically by this repo's own CI (v1.x.y). To publish or advance
the moving v1 major tag after a release lands on main:
git fetch --tags origin
# point v1 at the newest v1.x.y release (or origin/main)
git tag -f v1 "$(git tag -l 'v1.*.*' | sort -V | tail -1)"
git push -f origin v1Advance v1 only to releases you've verified are backward-compatible.
We build with our own tooling:
- SpecScore — specify requirements as
SpecScore.mdartifacts - SpecStudio — author & manage specs across their lifecycle
- inGitDB — store structured data in Git where applicable
- DALgo — data access layer for Go
- cover100.dev — drive toward 100% test coverage
- DataTug — query & explore data