26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
348 lines
14 KiB
YAML
348 lines
14 KiB
YAML
name: release
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- main
|
|
|
|
permissions:
|
|
contents: write
|
|
pull-requests: write
|
|
|
|
concurrency:
|
|
group: release-${{ github.ref }}
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
release-please:
|
|
runs-on: ubuntu-latest
|
|
outputs:
|
|
release_created: ${{ steps.release.outputs.release_created }}
|
|
tag_name: ${{ steps.release.outputs.tag_name }}
|
|
version: ${{ steps.release.outputs.version }}
|
|
steps:
|
|
- uses: googleapis/release-please-action@v4
|
|
id: release
|
|
with:
|
|
config-file: release-please-config.json
|
|
manifest-file: .release-please-manifest.json
|
|
|
|
# macOS artifacts are built and Developer ID signed on a macOS runner (codesign
|
|
# is macOS-only). Signing happens before archiving and before the checksums job
|
|
# so the published tarball and its SHA-256 both cover the signed binary. The
|
|
# signing certificate is gated behind the release-signing environment and never
|
|
# leaves the runner. The "macOS Release Signing" section of AGENTS.md owns the
|
|
# permanent-identity invariant and the full signing contract.
|
|
build-darwin:
|
|
runs-on: macos-latest
|
|
environment: release-signing
|
|
needs: release-please
|
|
if: needs.release-please.outputs.release_created == 'true'
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- goos: darwin
|
|
goarch: amd64
|
|
- goos: darwin
|
|
goarch: arm64
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
ref: ${{ needs.release-please.outputs.tag_name }}
|
|
|
|
- uses: actions/setup-go@v6
|
|
with:
|
|
go-version-file: go.mod
|
|
|
|
- name: Build darwin binary
|
|
env:
|
|
GOOS: darwin
|
|
GOARCH: ${{ matrix.goarch }}
|
|
TAG: ${{ needs.release-please.outputs.tag_name }}
|
|
UMAMI_HOST: https://a.kunchenguid.com
|
|
UMAMI_WEBSITE_ID: f959e889-92f5-4121-8a1f-571b10861198
|
|
run: |
|
|
set -euo pipefail
|
|
mkdir -p dist
|
|
DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
COMMIT="$(git rev-parse --short=7 HEAD)"
|
|
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \
|
|
go build -ldflags "-X github.com/kunchenguid/no-mistakes/internal/buildinfo.Version=${TAG} -X github.com/kunchenguid/no-mistakes/internal/buildinfo.Commit=${COMMIT} -X github.com/kunchenguid/no-mistakes/internal/buildinfo.Date=${DATE} -X github.com/kunchenguid/no-mistakes/internal/buildinfo.TelemetryHost=${UMAMI_HOST} -X github.com/kunchenguid/no-mistakes/internal/buildinfo.TelemetryWebsiteID=${UMAMI_WEBSITE_ID}" \
|
|
-o "dist/no-mistakes" ./cmd/no-mistakes
|
|
|
|
# Import the Developer ID Application cert into an ephemeral keychain locked
|
|
# with a runtime-generated password, discover exactly one signing identity
|
|
# (fail closed otherwise), and sign with a fixed identifier, hardened runtime,
|
|
# and secure timestamp. The cert (CSC_LINK) is base64 and only ever touches
|
|
# a RUNNER_TEMP file that is deleted immediately after import.
|
|
- name: Import Developer ID certificate and sign
|
|
env:
|
|
CSC_LINK: ${{ secrets.CSC_LINK }}
|
|
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
|
|
run: |
|
|
set -euo pipefail
|
|
TEAM_ID="9T2J7MNUP9"
|
|
if [ -z "${CSC_LINK:-}" ] || [ -z "${CSC_KEY_PASSWORD:-}" ]; then
|
|
echo "::error::CSC_LINK/CSC_KEY_PASSWORD signing secrets are missing; refusing to publish an unsigned macOS artifact" >&2
|
|
exit 1
|
|
fi
|
|
KEYCHAIN_PATH="$RUNNER_TEMP/nm-signing.keychain-db"
|
|
KEYCHAIN_PASSWORD="$(openssl rand -base64 24)"
|
|
CERT_PATH="$RUNNER_TEMP/nm-developer-id.p12"
|
|
echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV"
|
|
|
|
# Reconstruct the cert from the base64 secret into RUNNER_TEMP only.
|
|
printf '%s' "$CSC_LINK" | tr -d '[:space:]' | openssl base64 -A -d > "$CERT_PATH"
|
|
|
|
# Ephemeral keychain: create, unlock, import scoped to codesign, and add
|
|
# to the search list so codesign can find the private key.
|
|
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
|
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
|
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
|
|
security import "$CERT_PATH" -P "$CSC_KEY_PASSWORD" -f pkcs12 \
|
|
-k "$KEYCHAIN_PATH" -T /usr/bin/codesign
|
|
security set-key-partition-list -S apple-tool:,apple:,codesign: \
|
|
-s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" >/dev/null
|
|
# Prepend the ephemeral keychain to the user search list, preserving the
|
|
# existing (space-free) entries. Word splitting here is intentional.
|
|
# shellcheck disable=SC2046
|
|
security list-keychains -d user -s "$KEYCHAIN_PATH" \
|
|
$(security list-keychains -d user | sed 's/["]//g')
|
|
rm -f "$CERT_PATH"
|
|
|
|
# Fail closed unless exactly one Developer ID Application identity for the
|
|
# expected Team ID is present.
|
|
IDENTITIES="$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" \
|
|
| grep 'Developer ID Application' || true)"
|
|
COUNT="$(printf '%s\n' "$IDENTITIES" | grep -c 'Developer ID Application' || true)"
|
|
if [ "$COUNT" -ne 1 ]; then
|
|
echo "::error::expected exactly one Developer ID Application identity, found $COUNT" >&2
|
|
exit 1
|
|
fi
|
|
if ! printf '%s\n' "$IDENTITIES" | grep -q "($TEAM_ID)"; then
|
|
echo "::error::signing identity does not belong to Team ID $TEAM_ID" >&2
|
|
exit 1
|
|
fi
|
|
IDENTITY_HASH="$(printf '%s\n' "$IDENTITIES" | awk 'NR==1 {print $2}')"
|
|
|
|
codesign --force --timestamp --options runtime \
|
|
--identifier com.kunchenguid.no-mistakes \
|
|
--keychain "$KEYCHAIN_PATH" \
|
|
--sign "$IDENTITY_HASH" \
|
|
"dist/no-mistakes"
|
|
|
|
# Strict verification gate: any missing or ambiguous property fails the
|
|
# release before the artifact is archived or uploaded.
|
|
- name: Verify Developer ID signature
|
|
env:
|
|
GOARCH: ${{ matrix.goarch }}
|
|
run: |
|
|
set -euo pipefail
|
|
TEAM_ID="9T2J7MNUP9"
|
|
BIN="dist/no-mistakes"
|
|
|
|
case "$GOARCH" in
|
|
amd64) EXPECTED_ARCH="x86_64" ;;
|
|
arm64) EXPECTED_ARCH="arm64" ;;
|
|
*) echo "::error::unexpected GOARCH $GOARCH" >&2; exit 1 ;;
|
|
esac
|
|
|
|
# 1. Signature valid and strict.
|
|
codesign --verify --strict --verbose=2 "$BIN"
|
|
|
|
# 2. Developer ID identity + Team ID + permanent identifier, not ad-hoc.
|
|
SIG="$(codesign -dvvv "$BIN" 2>&1)"
|
|
printf '%s\n' "$SIG"
|
|
grep -q 'Authority=Developer ID Application' <<<"$SIG"
|
|
grep -q "TeamIdentifier=$TEAM_ID" <<<"$SIG"
|
|
grep -q 'Identifier=com.kunchenguid.no-mistakes' <<<"$SIG"
|
|
if grep -qi 'adhoc' <<<"$SIG"; then
|
|
echo "::error::signature is ad-hoc" >&2; exit 1
|
|
fi
|
|
|
|
# 3. Hardened runtime enabled.
|
|
if ! grep -Eq 'flags=0x[0-9a-f]+\([^)]*runtime[^)]*\)' <<<"$SIG"; then
|
|
echo "::error::hardened runtime flag not set" >&2; exit 1
|
|
fi
|
|
|
|
# 4. Secure (RFC-3161) timestamp present.
|
|
TIMESTAMP="$(sed -n 's/^Timestamp=//p' <<<"$SIG")"
|
|
if [[ -z "$TIMESTAMP" ]] || grep -qi '^none$' <<<"$TIMESTAMP"; then
|
|
echo "::error::secure timestamp is missing" >&2; exit 1
|
|
fi
|
|
|
|
# 5. Designated requirement is identity-based, never a content hash.
|
|
DR="$(codesign -d -r- "$BIN" 2>&1)"
|
|
printf '%s\n' "$DR"
|
|
grep -q 'anchor apple generic' <<<"$DR"
|
|
# The Team ID starts with a digit so codesign quotes it, but accept the
|
|
# unquoted form too so a codesign quirk cannot fail a valid release.
|
|
grep -Eq "leaf\[subject.OU][[:space:]]*=[[:space:]]*\"?$TEAM_ID\"?" <<<"$DR"
|
|
grep -q 'identifier "com.kunchenguid.no-mistakes"' <<<"$DR"
|
|
if grep -q 'cdhash H' <<<"$DR"; then
|
|
echo "::error::designated requirement is content-based (cdhash), not identity-based" >&2; exit 1
|
|
fi
|
|
|
|
# 6. Correct architecture for this matrix leg.
|
|
ARCHS="$(lipo -archs "$BIN")"
|
|
if [ "$ARCHS" != "$EXPECTED_ARCH" ]; then
|
|
echo "::error::architecture mismatch: got '$ARCHS', want '$EXPECTED_ARCH'" >&2; exit 1
|
|
fi
|
|
|
|
- name: Archive signed binary
|
|
env:
|
|
GOOS: darwin
|
|
GOARCH: ${{ matrix.goarch }}
|
|
TAG: ${{ needs.release-please.outputs.tag_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
ARCHIVE="dist/no-mistakes-${TAG}-${GOOS}-${GOARCH}.tar.gz"
|
|
tar -C dist -czf "$ARCHIVE" no-mistakes
|
|
echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV"
|
|
|
|
- name: Upload release asset
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ needs.release-please.outputs.tag_name }}
|
|
run: gh release upload "$TAG" "$ARCHIVE" --clobber
|
|
|
|
- uses: actions/upload-artifact@v7
|
|
with:
|
|
name: archive-${{ matrix.goos }}-${{ matrix.goarch }}
|
|
path: ${{ env.ARCHIVE }}
|
|
|
|
# Tear down the ephemeral keychain on success and failure alike.
|
|
- name: Clean up signing keychain
|
|
if: always()
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -n "${KEYCHAIN_PATH:-}" ]; then
|
|
security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true
|
|
fi
|
|
rm -f "$RUNNER_TEMP/nm-developer-id.p12" 2>/dev/null || true
|
|
|
|
build-and-upload:
|
|
runs-on: ubuntu-latest
|
|
needs: release-please
|
|
if: needs.release-please.outputs.release_created == 'true'
|
|
strategy:
|
|
fail-fast: false
|
|
matrix:
|
|
include:
|
|
- goos: linux
|
|
goarch: amd64
|
|
- goos: linux
|
|
goarch: arm64
|
|
- goos: windows
|
|
goarch: amd64
|
|
- goos: windows
|
|
goarch: arm64
|
|
steps:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
ref: ${{ needs.release-please.outputs.tag_name }}
|
|
|
|
- uses: actions/setup-go@v6
|
|
with:
|
|
go-version-file: go.mod
|
|
|
|
- name: Build archive
|
|
env:
|
|
GOOS: ${{ matrix.goos }}
|
|
GOARCH: ${{ matrix.goarch }}
|
|
TAG: ${{ needs.release-please.outputs.tag_name }}
|
|
UMAMI_HOST: https://a.kunchenguid.com
|
|
UMAMI_WEBSITE_ID: f959e889-92f5-4121-8a1f-571b10861198
|
|
run: |
|
|
set -euo pipefail
|
|
mkdir -p dist
|
|
DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
COMMIT="$(git rev-parse --short=7 HEAD)"
|
|
BIN="no-mistakes"
|
|
OUT="dist/${BIN}"
|
|
if [ "$GOOS" = "windows" ]; then
|
|
BIN="${BIN}.exe"
|
|
OUT="dist/${BIN}"
|
|
fi
|
|
CGO_ENABLED=0 GOOS="$GOOS" GOARCH="$GOARCH" \
|
|
go build -ldflags "-X github.com/kunchenguid/no-mistakes/internal/buildinfo.Version=${TAG} -X github.com/kunchenguid/no-mistakes/internal/buildinfo.Commit=${COMMIT} -X github.com/kunchenguid/no-mistakes/internal/buildinfo.Date=${DATE} -X github.com/kunchenguid/no-mistakes/internal/buildinfo.TelemetryHost=${UMAMI_HOST} -X github.com/kunchenguid/no-mistakes/internal/buildinfo.TelemetryWebsiteID=${UMAMI_WEBSITE_ID}" \
|
|
-o "$OUT" ./cmd/no-mistakes
|
|
if [ "$GOOS" = "windows" ]; then
|
|
ARCHIVE="dist/no-mistakes-${TAG}-${GOOS}-${GOARCH}.zip"
|
|
(
|
|
cd dist
|
|
zip -q "$(basename "$ARCHIVE")" "$BIN"
|
|
)
|
|
else
|
|
ARCHIVE="dist/no-mistakes-${TAG}-${GOOS}-${GOARCH}.tar.gz"
|
|
tar -C dist -czf "$ARCHIVE" "$BIN"
|
|
fi
|
|
echo "ARCHIVE=$ARCHIVE" >> "$GITHUB_ENV"
|
|
|
|
- name: Upload release asset
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
TAG: ${{ needs.release-please.outputs.tag_name }}
|
|
run: gh release upload "$TAG" "$ARCHIVE" --clobber
|
|
|
|
- uses: actions/upload-artifact@v7
|
|
with:
|
|
name: archive-${{ matrix.goos }}-${{ matrix.goarch }}
|
|
path: ${{ env.ARCHIVE }}
|
|
|
|
checksums:
|
|
runs-on: ubuntu-latest
|
|
needs:
|
|
- release-please
|
|
- build-darwin
|
|
- build-and-upload
|
|
if: |
|
|
!cancelled() &&
|
|
needs.release-please.result == 'success' &&
|
|
needs.build-darwin.result == 'success' &&
|
|
needs.build-and-upload.result == 'success' &&
|
|
needs.release-please.outputs.release_created == 'true'
|
|
steps:
|
|
- uses: actions/download-artifact@v8
|
|
with:
|
|
path: dist
|
|
merge-multiple: true
|
|
|
|
- name: Generate checksums
|
|
run: |
|
|
set -euo pipefail
|
|
(
|
|
cd dist
|
|
sha256sum no-mistakes-* > ../checksums.txt
|
|
)
|
|
|
|
- name: Upload checksums
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
GH_REPO: ${{ github.repository }}
|
|
TAG: ${{ needs.release-please.outputs.tag_name }}
|
|
run: gh release upload "$TAG" checksums.txt --clobber
|
|
|
|
finalize:
|
|
runs-on: ubuntu-latest
|
|
needs:
|
|
- release-please
|
|
- build-darwin
|
|
- build-and-upload
|
|
- checksums
|
|
if: |
|
|
!cancelled() &&
|
|
needs.release-please.result == 'success' &&
|
|
needs.build-darwin.result == 'success' &&
|
|
needs.build-and-upload.result == 'success' &&
|
|
needs.checksums.result == 'success' &&
|
|
needs.release-please.outputs.release_created == 'true'
|
|
steps:
|
|
- name: Publish draft release as prerelease
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
GH_REPO: ${{ github.repository }}
|
|
TAG: ${{ needs.release-please.outputs.tag_name }}
|
|
run: gh release edit "$TAG" --draft=false --prerelease=true
|