feat: post-release artifact smoke test (three layers) + macOS notarization pass-through - #43
Merged
Merged
Conversation
Temporary workflow_dispatch workflow to validate the layer 1/2/3 logic on a real GitHub-hosted macos-latest runner against the known-bad specscore-cli v0.24.0 release, before it lands in release.yml. Will be removed in a follow-up commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016oLc6NHcbs832y44pANFyB
workflow_dispatch requires the workflow to exist on the default branch before it can be dispatched via API; use a push trigger scoped to this branch instead so it runs without touching main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016oLc6NHcbs832y44pANFyB
GitHub Actions runs `shell: bash` steps as `bash -e {0}` by default,
regardless of what the script itself sets. `set -uo pipefail` does not
turn errexit back off, so `output="$(run_with_timeout ...)"` and
`spctl_out="$(spctl ...)"` aborted the step the instant the wrapped
command returned non-zero -- before the warn-vs-fail branching logic
ever ran. Caught by actually running the layer 2 signing check on a
GitHub-hosted macos-latest runner against the known-bad specscore-cli
v0.24.0 binary (see PR description for the run).
Also add the notarize.macos secret pass-through (MACOS_SIGN_P12,
MACOS_SIGN_PASSWORD, NOTARIZE_ISSUER_ID, NOTARIZE_KEY_ID, NOTARIZE_KEY)
and document the post-release smoke test in the README.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oLc6NHcbs832y44pANFyB
… workflow
Ran the layer 2 (codesign/spctl) and layer 3 (brew install --cask + run)
logic on a real GitHub-hosted macos-latest runner against the actual
quarantined, ad-hoc-signed specscore-cli v0.24.0 Homebrew-cask binary:
- Layer 2 correctly flags it (TeamIdentifier: not set, spctl rejected) --
the reliable, deterministic signal, unaffected by session state since
it's static inspection and never executes the binary.
- Layer 3 did NOT reproduce the real, indefinite Gatekeeper dialog a
logged-in user hits -- the process completed after a ~30s assessment
delay and exited 0. A GitHub Actions runner has no interactive session
for the dialog to render on, so a pass here is not reliable evidence a
real user isn't blocked. Documented as a known, confirmed gap rather
than assumed; layer 2 is positioned as primary, layer 3 as
corroborating only.
Removes the throwaway `_tmp_validate_smoke_layers.yml` workflow used to run
that validation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016oLc6NHcbs832y44pANFyB
actionlint + shellcheck caught: two intentional-but-unmarked unquoted word-splits (SMOKE_CMD, matching the existing goreleaser_extra_args convention) and a run of individual >> \$GITHUB_OUTPUT redirects that reads more clearly combined. No behavior change; repo now lints clean end to end (verified with actionlint against all workflow files). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016oLc6NHcbs832y44pANFyB
Fixes a conceptual gap found in review: the smoke test was conflating "I could not verify this" with "I tested this and it is broken" -- only the second justifies failing a release, and the check was failing on the first too, fleet-wide, with no consumer opt-in. - Layer 1/3 download and extraction: no matching release asset, an unrecognized archive format, or no binary found inside it now logs ::warning:: and skips (exit 0) instead of hard-failing. This workflow is consumed by repos not enumerated here, including libraries and Docker-only releases with no per-arch archive -- a naming/shape mismatch is not evidence of a broken release. Once the binary is actually found and executed, a hang/non-zero-exit/empty-output is unchanged: always a hard failure for layer 1, severity-gated by require_notarized_macos for layers 2/3. - Layer 3's `brew install --cask` step ran under plain `set -euo pipefail` with no severity gate at all, so a transient Homebrew/network failure or a cask-name mismatch hard-failed every cask-publishing consumer's release regardless of require_notarized_macos. Now gated through the same could-not-test taxonomy: install failure warns and skips the rest of the job. - Asset selection now matches only real archive extensions (.tar.gz/.tgz/.zip) via explicit `gh release download --pattern` flags, never a same-named checksum/signature/SBOM sidecar that could previously be picked by nondeterministic `find` order and flap the result between identical releases. - gh release download and brew tap/install are now wrapped in their own watchdog timeouts (120s / 300s), plus each smoke-test job gets an explicit timeout-minutes well under GitHub's 6-hour default -- the same network step should never rely on that default rationale already applied to the binary invocation itself. - resolve_artifact_smoke_test now reads tag_prefix and refuses to guess a binary name from the repo name when tag_prefix looks like a subdirectory-module path (e.g. "ingitdb/v") and no config was found at the repo root -- the module's own name, not the repo's, is what matters. Checked directly against every current consumer at the time this was written: none combine that, but the workflow is reused by repos not enumerated here, so this stays defensive rather than assumed safe. Includes a throwaway validation workflow proving both the happy path still works after this rewrite and the could-not-test path now exits green against the real specscore-cli v0.24.0 release (removed in a follow-up commit once confirmed). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016oLc6NHcbs832y44pANFyB
…moved) Direct validation of finding 3: a brew install --cask failure must warn and exit 0, never hard-fail. Removes the already-validated taxonomy temp workflow in the same commit (happy path + could-not-test path both confirmed green on a real GitHub-hosted runner in the prior push). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016oLc6NHcbs832y44pANFyB
Confirmed green in CI (brew_install_failure_path job succeeded despite a deliberately wrong cask name -- the install step warned and exited 0, per finding 3's fix). No longer needed in the shipped branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016oLc6NHcbs832y44pANFyB
trakhimenok
added a commit
to ingitdb/ingitdb-cli
that referenced
this pull request
Jul 26, 2026
Pins the immutable tag containing strongo/cicd#43 rather than the floating `v1`, which lags main by 18 commits. Moving `v1` would ship 17 unrelated release-machinery changes — monotonic version calculation, a language-neutral release calculator, combined build/test jobs — alongside this, and a notarization failure would then be ambiguous. The point of this pilot is to change one variable: whether the Apple credentials still work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016oLc6NHcbs832y44pANFyB
trakhimenok
added a commit
to ingitdb/ingitdb-cli
that referenced
this pull request
Jul 26, 2026
* ci: activate macOS signing + notarization The notarize: block in .goreleaser.yaml has been dormant since it was written, gated on MACOS_SIGN_P12 and skipped because the shared strongo/cicd release workflow never forwarded the five Apple secrets. strongo/cicd#43 forwards them; this passes them through from the caller, which is the last missing link. Unsigned darwin binaries are ad-hoc signed with no Team Identifier, so macOS blocks them on the Homebrew cask install path with "Apple could not verify ... is free of malware" — a dialog invisible from a shell, which is how it went unnoticed. specscore/specscore-cli#86 has the full diagnosis. ingitdb-cli is the deliberate pilot: it is the only repo already holding these secrets, so a stale-credential failure lands on one CLI rather than the fleet. The credentials are still unverified — a prior publish-homebrew job failed — so the comment now says step 2 is done and step 1, verifying them, is what the next release tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016oLc6NHcbs832y44pANFyB * ci: pin strongo/cicd@v1.11.0 for the notarization pilot Pins the immutable tag containing strongo/cicd#43 rather than the floating `v1`, which lags main by 18 commits. Moving `v1` would ship 17 unrelated release-machinery changes — monotonic version calculation, a language-neutral release calculator, combined build/test jobs — alongside this, and a notarization failure would then be ambiguous. The point of this pilot is to change one variable: whether the Apple credentials still work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016oLc6NHcbs832y44pANFyB --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a post-release check to
release.ymlthat verifies a published CLI binary actually runs, so a broken artifact fails the release run instead of shipping silently. Prompted by a real incident:specscore-cliv0.24.0 released fully green, but the Homebrew-cask-installed binary was silently blocked by macOS Gatekeeper for every user.Root cause (confirmed, corrected mid-investigation): not a build/hang bug in the binary. The raw published release asset runs fine. The Go CLIs in this org are ad-hoc signed and not notarized (
codesign -dv→Identifier=a.out,TeamIdentifier=not set). Homebrew's cask installer appliescom.apple.quarantineto the installed binary; macOS Gatekeeper then blocks an ad-hoc-signed, unnotarized binary the instant it's quarantined, and this is fleet-wide, not specscore-specific.Three layers (all under
artifact_smoke_test, default on)codesign -dv,spctl -a -vv) — no execution, no Gatekeeper risk in the check itself. This is what would have caught v0.24.0. The primary, reliable, deterministic signal.brew install --cask+ run the installed binary — auto-detected from the repo's own.goreleaser.yaml, no-op otherwise. The only layer that reproduces Homebrew's quarantine attribute.Severity, and the taxonomy that governs it (see "Review round 2" below): once a binary is actually found and executed, layer 1 always hard-fails on hang/non-zero-exit/empty-output; layers 2/3 do the same but gated by
require_notarized_macos(default false — nothing in this org is notarized yet, so a default hard-fail would red every release fleet-wide on day one). But finding the binary to test in the first place — a matching release asset, a recognizable archive, the binary inside it, a workingbrew install --cask— is never guaranteed across every repo this workflow will ever be attached to, and failing to find it is not evidence of a broken release. That distinction is the round-2 fix below.Also included (same underlying fix — makes the thing layer 2 detects actually fixable): forwards five previously-declared-but-unwired secrets (
MACOS_SIGN_P12,MACOS_SIGN_PASSWORD,NOTARIZE_ISSUER_ID,NOTARIZE_KEY_ID,NOTARIZE_KEY) into GoReleaser's env, so a repo'snotarize.macosblock (e.g.ingitdb-cli's, dormant since it was written for exactly this reason) can actually activate.Review round 2: could-not-test vs. tested-and-broken
First review caught a real conceptual gap: the check was conflating "I could not verify this" with "I tested this and it is broken" — only the second justifies failing a release, and the first was failing it too, fleet-wide, with no opt-in. Confirmed live against a real public release (
gh release download --pattern <non-matching>exits 1 and propagates underset -euo pipefail) and fixed:brew install --cask— now logs::warning::(naming exactly what was looked for and what was found) and skips (exit 0), always, regardless ofrequire_notarized_macos. "Tested and broken" — found and executed, then hung / exited non-zero / printed nothing — is unchanged: always a hard failure for layer 1,require_notarized_macos-gated for layers 2/3.artifact_smoke_test_command(already configurable, default--version) is the escape hatch for a CLI whose default invocation doesn't support a bare--version.brew install --caskstep was an unconditional hard fail (set -euo pipefail, no severity gate at all —require_notarized_macos: falsegave zero protection). A transient Homebrew/network failure, tap-visibility timing right after GoReleaser pushes the cask manifest, or a cask-name mismatch now warns and skips the rest of the job instead.findexcluded only.txt, so--pattern "*_linux_amd64.*"could also match a same-named.sig/.sbom/.intoto.jsonlsidecar, with filesystem order deciding which one got picked — flapping pass/fail across identical releases.gh release downloadnow uses explicit--patternflags for.tar.gz/.tgz/.ziponly; sidecars are never even downloaded.gh release download(120s) andbrew tap/brew install --cask(300s) previously had no watchdog, bounded only by GitHub's 6-hour default job timeout — exactly what the binary-invocation watchdog exists to avoid relying on. Each smoke-test job also now has an explicittimeout-minutes(10/15/20) as a backstop.resolve_artifact_smoke_testnow readstag_prefix; if it looks like a subdirectory-module path (e.g."ingitdb/v") and no GoReleaser config is found at the repo root, binary-name inference refuses to guess from the repo name (a subdirectory module's name isn't the repo's) and the whole smoke test is skipped with a warning instead. Checked directly against every current consumer of this workflow: none combine that today, but the workflow is reused by repos not enumerated here, so this stays defensive rather than assumed safe.Re-validated in CI after the rewrite, same method as before (throwaway workflows, removed before this PR, run on real GitHub-hosted runners against the real specscore-cli v0.24.0 release):
linux/riscv64, doesn't exist for specscore-cli): download step warns, job is green — confirms finding 1/2's fix.Validated in CI, not just locally (round 1)
Ran the layer 2/3 logic on a real GitHub-hosted
macos-latestrunner against the real, known-badspecscore-cliv0.24.0 Homebrew-cask binary:TeamIdentifier: not set,spctlrejected (exit 3).That same CI run also caught a real bug before it shipped: GitHub Actions' default
shell: bashinvocation isbash -e {0}regardless of what the script itself sets, soset -uo pipefailalone does not disable errexit —output="$(run_with_timeout ...)"andspctl_out="$(spctl ...)"were aborting the step before the warn-vs-fail branching logic ever ran. Fixed with an explicitset +ein every custom script. Verified clean withactionlint(shellcheck-backed) against all workflow files throughout, including after the round-2 rewrite.Platform coverage
artifact_smoke_test_platforms(JSON array of{runner, goos, goarch}, consumed as a job matrix). Extend/trim as needed.artifact_smoke_test_platformscan be extended to add it).What a consumer sets
Nothing, to get layer 1 (hard-fail once tested) and layers 2/3 (warn-only once tested) automatically — all seven new inputs are optional with backward-compatible defaults. Existing
@v1callers are unaffected until the maintainer advances thev1tag (see "Releasing this repo" in the README); no@v1caller needs any YAML change.To customize:
Binary-name inference reads the repo's own
.goreleaser.y*ml(builds[0].binary, falling back toproject_name) at the released tag, falling back further to the repo name (stripping a-clisuffix, or the org name for a repo literally namedcli) only if that config can't be read. Verified against every current consumer's real.goreleaser.yaml(specscore-cli,ingitdb-cli,datatug-cli,filetug,codegrapher,chatwright/cli,sneat-dev/wb,synchestra-vm,synchestra-servers). One known limitation: a multi-binary repo (synchestra-servers, which builds two binaries) only smoke-tests the firstbuilds[]entry by default — setartifact_smoke_test_binaryexplicitly there.For the maintainer: retagging
This repo is consumed via
strongo/cicd/.github/workflows/release.yml@v1, a moving tag advanced deliberately (see README, "Releasing this repo"). After merging, existing@v1callers get none of this untilv1is advanced:git fetch --tags origin git tag -f v1 "$(git tag -l 'v1.*.*' | sort -V | tail -1)" git push -f origin v1Once advanced, every
@v1consumer's next release run gets layer 1 (hard-fail once tested) and layers 2/3 (warn-only once tested) automatically, with no action required on their part.Not merging
Per instructions: no merge, no tag. Review and merge (or hold) at your discretion.
Co-Authored-By: Claude Opus 5 noreply@anthropic.com
Claude-Session: https://claude.ai/code/session_016oLc6NHcbs832y44pANFyB