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
271 changes: 18 additions & 253 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,6 @@
# Release pipeline — fires on `v*` tag push.
#
# Pure OIDC where possible. No PAT, no GitHub App token, no cross-repo
# write. Cosign signing is keyless via Sigstore Fulcio; SLSA provenance
# and SBOM attestations publish to GitHub's native attestation store
# (`gh attestation verify` resolves them) and to the public Rekor
# transparency log (Sigstore-native verification works too).
#
# Cross-repo formula bump lives in the tap repo (webflow/homebrew-
# ctxcop), which polls THIS repo's signed releases via a scheduled
# workflow, verifies the cosign signature against the workflow
# identity below, and opens an internal PR in itself. Zero stored
# credentials in either repo.
# Release pipeline — fires on `v*` tag push. Source-only pending macOS
# notarization; see PR #5. Build/cosign/SBOM/attest pipeline is in git
# history to restore once that's wired in.

name: release

Expand All @@ -24,8 +14,6 @@ permissions:

concurrency:
group: release-${{ github.ref }}
# Never cancel a release-in-progress; the cost of a half-finished
# release artifact set is much higher than letting two race.
cancel-in-progress: false

jobs:
Expand All @@ -40,222 +28,30 @@ jobs:
- name: verify signed annotated tag
run: ./.github/scripts/verify-release-tag.sh

# --- 2. Build per-platform binaries (reproducible flags) ----------
build:
# --- 2. Publish a source-only GitHub Release ----------------------
publish-source-release:
needs: validate-tag
runs-on: ${{ matrix.runner }}
timeout-minutes: 10
strategy:
fail-fast: true
matrix:
include:
- { goos: linux, goarch: amd64, runner: ubuntu-latest }
- { goos: linux, goarch: arm64, runner: ubuntu-latest }
- { goos: darwin, goarch: amd64, runner: macos-latest }
- { goos: darwin, goarch: arm64, runner: macos-latest }
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
check-latest: false

- name: build (reproducible)
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: '0'
GOFLAGS: -mod=readonly
run: |
set -euo pipefail
VERSION="${GITHUB_REF#refs/tags/}"
COMMIT="$(git rev-parse --verify HEAD)"
# SOURCE_DATE_EPOCH from the tag's commit timestamp — same
# value every rebuild → bit-identical binary across runs.
SOURCE_DATE_EPOCH="$(git log -1 --pretty=%ct "$COMMIT")"
BIN="ctxcop_${VERSION}_${GOOS}_${GOARCH}"
go build \
-trimpath \
-ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${SOURCE_DATE_EPOCH}" \
-o "${BIN}" \
./cmd/ctxcop
# Cross-platform sha256 helper (linux uses sha256sum, macos uses shasum -a 256).
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "${BIN}" > "${BIN}.sha256"
else
shasum -a 256 "${BIN}" > "${BIN}.sha256"
fi
ls -la "${BIN}" "${BIN}.sha256"

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ctxcop-${{ matrix.goos }}-${{ matrix.goarch }}
path: ctxcop_*
if-no-files-found: error
retention-days: 1

# --- 3. Sign, SBOM, attest, publish -------------------------------
sign-attest-publish:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 15
# Manual approval gate: the `release` environment carries a
# required-reviewer protection (the tag-pusher plus
# @webflow/infrastructure-security, with `prevent_self_review: true`),
# so the run halts here until a reviewer other than whoever pushed
# the tag approves.
environment: release
timeout-minutes: 5
environment: release # required-reviewer gate, prevent_self_review
permissions:
contents: write # upload artifacts to the GitHub Release
id-token: write # cosign keyless OIDC + attest-* OIDC
attestations: write # actions/attest-build-provenance + sbom
contents: write # create the GitHub Release
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: go.mod
check-latest: false

- name: verify betterleaks dep matches audit
# Audit mitigation #7. Refuses to sign if go.sum drifts from
# the audited SHA without a corresponding THIRD_PARTY_AUDIT.md
# review-log update.
run: ./.github/scripts/verify-betterleaks-sha.sh

- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist/
merge-multiple: true

- name: install syft (checksum-verified)
run: |
set -euo pipefail
SYFT_VERSION=v1.44.0
SYFT_SHA256=0e91737aee2b5baf1d255b959630194a302335d848ff97bb07921eb6205b5f5a
curl -fL -o /tmp/syft.tar.gz \
"https://github.com/anchore/syft/releases/download/${SYFT_VERSION}/syft_${SYFT_VERSION#v}_linux_amd64.tar.gz"
echo "${SYFT_SHA256} /tmp/syft.tar.gz" | sha256sum -c
sudo tar -xzf /tmp/syft.tar.gz -C /usr/local/bin syft
syft version

- uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: v2.4.2

- name: sign + sbom + checksum per binary
run: |
set -euo pipefail
cd dist
for bin in ctxcop_*; do
# Skip the .sha256 files the build job emitted.
case "$bin" in *.sha256) continue ;; esac
echo "==> ${bin}"

# SBOM in both formats (CycloneDX = SCA-tool default;
# SPDX = procurement/federal supply-chain default).
syft "${bin}" -o cyclonedx-json="${bin}.sbom.cdx.json"
syft "${bin}" -o spdx-json="${bin}.sbom.spdx.json"

# Cosign keyless sign-blob. Bundle format combines sig +
# cert + Rekor entry into a single file consumers verify
# with `cosign verify-blob --bundle`.
COSIGN_EXPERIMENTAL=1 cosign sign-blob --yes \
--bundle "${bin}.bundle" \
--output-signature "${bin}.sig" \
--output-certificate "${bin}.crt" \
"${bin}"

# Recompute sha256 in canonical Linux-runner format so
# checksums.txt is consistent across mac-built and linux-
# built binaries.
sha256sum "${bin}" > "${bin}.sha256"
done
# Concatenate per-binary checksums into one checksums.txt.
cat *.sha256 > checksums.txt
ls -la

- name: self-verify cosign signatures
# Catch any future regression in our own verify path by
# actually running the documented verify command against the
# bundle we just emitted. If this fails, the release is
# broken in a way no downstream consumer would catch.
run: |
set -euo pipefail
cd dist
IDENTITY_RE='^https://github.com/webflow/ctxcop/\.github/workflows/release\.yml@refs/tags/.+$'
for bin in ctxcop_*; do
case "$bin" in *.sha256|*.sig|*.crt|*.bundle|*.sbom.*) continue ;; esac
echo "==> verify ${bin}"
cosign verify-blob \
--certificate-identity-regexp "${IDENTITY_RE}" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--bundle "${bin}.bundle" \
"${bin}"
done

- name: attest-build-provenance (GitHub-native)
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-path: 'dist/ctxcop_*[!_]*[!.]'

- name: attest-sbom (GitHub-native, CycloneDX)
# One attest call per binary because attest-sbom binds an SBOM
# to a single subject digest; matrix-style multi-subject is
# not supported on this action today.
run: |
set -euo pipefail
for bin in dist/ctxcop_*; do
case "$bin" in *.sha256|*.sig|*.crt|*.bundle|*.sbom.*) continue ;; esac
sbom="${bin}.sbom.cdx.json"
echo "==> attest-sbom $(basename "${bin}")"
# gh attestation: equivalent to actions/attest-sbom but
# callable in a per-binary loop. Both go to the same
# GitHub-native attestation store.
gh attestation generate \
--predicate "${sbom}" \
--predicate-type 'https://cyclonedx.org/specification/overview/' \
--subject-path "${bin}" \
--owner webflow
done
env:
GH_TOKEN: ${{ github.token }}
continue-on-error: true
# Fallback if `gh attestation generate` isn't available on the
# runner's gh version: the cosign-signed SBOM via attest below
# provides equivalent verification.

- name: cosign attest SBOMs (Sigstore-native)
run: |
set -euo pipefail
cd dist
for bin in ctxcop_*; do
case "$bin" in *.sha256|*.sig|*.crt|*.bundle|*.sbom.*) continue ;; esac
cosign attest-blob --yes \
--predicate "${bin}.sbom.cdx.json" \
--type cyclonedx \
--bundle "${bin}.sbom.cdx.bundle" \
"${bin}"
done

- name: publish GitHub Release
- name: publish GitHub Release (source only)
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF#refs/tags/}"
PRERELEASE=""
# Treat rc.* / alpha / beta as prerelease automatically so the
# tap-side pull-poller can skip them by default.
if [[ "${VERSION}" =~ -(rc|alpha|beta)\. ]]; then
PRERELEASE=--prerelease
fi

# Pull the matching changelog section, if present, for the
# release-notes body.
notes_file=$(mktemp)
if grep -q "^## \[${VERSION#v}\]" CHANGELOG.md; then
awk -v v="${VERSION#v}" '
Expand All @@ -266,47 +62,16 @@ jobs:
else
echo "_No CHANGELOG entry for ${VERSION}; see commit log._" > "$notes_file"
fi
{
echo
echo "---"
echo "No prebuilt binaries: macOS Gatekeeper rejects an"
echo "ad-hoc-signed binary, and Developer ID codesigning +"
echo "notarization isn't wired into this pipeline yet."
echo "Build from source: \`go build ./cmd/ctxcop\`."
} >> "$notes_file"

gh release create "${VERSION}" \
--title "${VERSION}" \
--notes-file "$notes_file" \
$PRERELEASE \
dist/ctxcop_*

- name: write release-manifest.json
# The tap-side pull-poller reads this manifest from the
# Release assets to discover what to bump. JSON, not freeform,
# so it's robust to release-notes formatting changes.
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF#refs/tags/}"
COMMIT="$(git rev-parse --verify HEAD)"
cd dist
# Build a per-binary record: filename, sha256, urls.
{
echo "{"
echo " \"version\": \"${VERSION}\","
echo " \"commit\": \"${COMMIT}\","
echo " \"signing_workflow\": \"webflow/ctxcop/.github/workflows/release.yml@refs/tags/${VERSION}\","
echo " \"artifacts\": ["
first=1
for bin in ctxcop_*; do
case "$bin" in *.sha256|*.sig|*.crt|*.bundle|*.sbom.*) continue ;; esac
[ $first -eq 1 ] || echo " ,"
first=0
digest="$(sha256sum "${bin}" | awk '{print $1}')"
echo " {"
echo " \"name\": \"${bin}\","
echo " \"sha256\": \"${digest}\","
echo " \"url\": \"https://github.com/webflow/ctxcop/releases/download/${VERSION}/${bin}\","
echo " \"sig_url\":\"https://github.com/webflow/ctxcop/releases/download/${VERSION}/${bin}.bundle\","
echo " \"sbom_url\":\"https://github.com/webflow/ctxcop/releases/download/${VERSION}/${bin}.sbom.cdx.json\""
echo -n " }"
done
echo
echo " ]"
echo "}"
} > release-manifest.json
gh release upload "${VERSION}" release-manifest.json
$PRERELEASE
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ development history behind it — four internal release lines plus a
hardening pass done ahead of open-sourcing — is preserved in
[docs/dev-log/CHANGELOG-pre-1.0.md](docs/dev-log/CHANGELOG-pre-1.0.md).

## [Unreleased]

### Removed
- **Prebuilt release binaries.** v0.1.0's macOS binaries were only
ad-hoc/linker-signed, which Gatekeeper rejects outright once a binary
has been through a download/quarantine flow. We're enrolled in the
Apple Developer Program but codesigning + notarization aren't wired
into the release pipeline yet, so releases are source-only
(`go install` or build from source) until that lands. The
build/cosign/SBOM/attest pipeline that produced v0.1.0's binaries is
intact in git history and will come back once the cert is in place.

## [0.1.0] — Unreleased

ctxcop's first public release.
Expand Down
21 changes: 8 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,17 @@ overlays.

## Install

Build from source for now — prebuilt binary releases are on hold until
macOS Developer ID codesigning + notarization are wired into the release
pipeline (Gatekeeper rejects the ad-hoc-signed binaries a plain `go build`
produces once they've been through a download/quarantine flow; a locally
built binary isn't affected).

```sh
# 1. Pre-built signed binary from a tagged release.
# cosign-signed, SLSA Build L2 provenance. See SECURITY.md for the
# verification one-liner.
tag=$(basename "$(curl -fsSLo /dev/null -w '%{url_effective}' \
https://github.com/webflow/ctxcop/releases/latest)") # e.g. v0.1.0
os=$(uname -s | tr '[:upper:]' '[:lower:]') # Darwin->darwin, Linux->linux
arch=$(uname -m); case "$arch" in x86_64) arch=amd64 ;; aarch64|arm64) arch=arm64 ;; esac
curl -fL -o ctxcop \
"https://github.com/webflow/ctxcop/releases/download/${tag}/ctxcop_${tag}_${os}_${arch}"
chmod +x ctxcop && mv ctxcop /usr/local/bin/

# 2. go install.
# 1. go install.
go install github.com/webflow/ctxcop/cmd/ctxcop@latest

# 3. From source.
# 2. From source.
git clone https://github.com/webflow/ctxcop && cd ctxcop
go build -o /usr/local/bin/ctxcop ./cmd/ctxcop
```
Expand Down
Loading