diff --git a/.github/workflows/canary-release.yml b/.github/workflows/canary-release.yml new file mode 100644 index 0000000..989830f --- /dev/null +++ b/.github/workflows/canary-release.yml @@ -0,0 +1,272 @@ +name: Canary Release + +on: + workflow_dispatch: + +permissions: {} + +jobs: + setup: + name: Compute canary version + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + version: ${{ steps.version.outputs.version }} + tag: ${{ steps.version.outputs.tag }} + sha: ${{ steps.version.outputs.sha }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Compute canary version + id: version + run: | + base=$(grep -m1 'Version.*=' internal/buildinfo/version.go | sed 's/.*"\(.*\)".*/\1/') + if [ -z "$base" ]; then + echo "::error::Could not extract Version from internal/buildinfo/version.go" + exit 1 + fi + sha=$(git rev-parse --short=7 HEAD) + version="${base}-canary.${{ github.run_number }}.${sha}" + tag="canary-${{ github.run_number }}-${sha}" + { + echo "version=${version}" + echo "tag=${tag}" + echo "sha=${sha}" + } >> "$GITHUB_OUTPUT" + echo "Canary version: ${version}" + echo "Canary tag: ${tag}" + + build-linux: + name: Build (Linux) + needs: setup + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + + - name: Run GoReleaser (linux) + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + with: + distribution: goreleaser + version: latest + args: release --config .goreleaser.canary.linux.yml --clean --skip=publish,announce,sign,validate + env: + GORELEASER_CURRENT_TAG: v${{ needs.setup.outputs.version }} + + - name: Smoke test linux_amd64 binary + run: | + bin=$(find dist -type f -name 'stepsecurity-dev-machine-guard-*-linux_amd64' | head -1) + if [ -z "$bin" ]; then + echo "::error::linux_amd64 binary not found" + find dist -type f + exit 1 + fi + chmod +x "$bin" + "$bin" --version + + - name: Upload linux artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: canary-linux + path: | + dist/stepsecurity-dev-machine-guard-*-linux_amd64 + dist/stepsecurity-dev-machine-guard-*-linux_arm64 + dist/stepsecurity-dev-machine-guard-*-amd64.deb + dist/stepsecurity-dev-machine-guard-*-arm64.deb + dist/stepsecurity-dev-machine-guard-*-amd64.rpm + dist/stepsecurity-dev-machine-guard-*-arm64.rpm + if-no-files-found: error + retention-days: 7 + + build-darwin: + name: Build (macOS) + needs: setup + runs-on: macos-latest + permissions: + contents: read + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0 + with: + go-version-file: go.mod + + - name: Run GoReleaser (darwin) + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0 + with: + distribution: goreleaser + version: latest + args: release --config .goreleaser.canary.darwin.yml --clean --skip=publish,announce,sign,validate + env: + GORELEASER_CURRENT_TAG: v${{ needs.setup.outputs.version }} + + - name: Ad-hoc sign darwin universal binary + run: | + bin=$(find dist -type f -name 'stepsecurity-dev-machine-guard-*-darwin' ! -name '*.bundle' | head -1) + if [ -z "$bin" ]; then + echo "::error::darwin universal binary not found" + find dist -type f + exit 1 + fi + codesign --sign - --options runtime --force "$bin" + codesign --verify --verbose "$bin" + + - name: Smoke test darwin binary + run: | + bin=$(find dist -type f -name 'stepsecurity-dev-machine-guard-*-darwin' ! -name '*.bundle' | head -1) + chmod +x "$bin" + "$bin" --version + + - name: Upload darwin artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: canary-darwin + path: dist/stepsecurity-dev-machine-guard-*-darwin + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish canary prerelease + needs: [setup, build-linux, build-darwin] + runs-on: ubuntu-latest + environment: canary-release + permissions: + contents: write + id-token: write + attestations: write + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@fa2e9d605c4eeb9fcad4c99c224cee0c6c7f3594 # v2.16.0 + with: + egress-policy: audit + + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Download linux artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: canary-linux + path: artifacts + + - name: Download darwin artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: canary-darwin + path: artifacts + + - name: Push canary tag + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + if git rev-parse "refs/tags/${{ needs.setup.outputs.tag }}" >/dev/null 2>&1; then + echo "::error::Tag ${{ needs.setup.outputs.tag }} already exists" + exit 1 + fi + git tag "${{ needs.setup.outputs.tag }}" + git push origin "${{ needs.setup.outputs.tag }}" + + - name: Install cosign + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + + - name: Sign artifacts with Sigstore + shell: bash + run: | + sign_with_retry() { + local blob="$1" + local bundle="${blob}.bundle" + for attempt in 1 2 3; do + if cosign sign-blob "$blob" --bundle "$bundle" --yes; then + return 0 + fi + echo "::warning::Signing attempt $attempt failed for $(basename "$blob"), retrying in 10s..." + sleep 10 + done + echo "::error::Signing failed for $(basename "$blob") after 3 attempts" + return 1 + } + + shopt -s nullglob + for f in artifacts/*; do + case "$f" in + *.bundle) continue ;; + esac + sign_with_retry "$f" + done + + - name: Create prerelease and upload artifacts + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + notes_file="$(mktemp)" + cat > "$notes_file" < + description: Detect compromised tools on developer machines (canary build, not for production use) + license: Apache-2.0 + formats: + - deb + - rpm + ids: + - stepsecurity-dev-machine-guard + bindir: /usr/local/bin + file_name_template: "stepsecurity-dev-machine-guard-{{ .Version }}-{{ .Arch }}" + +release: + disable: true diff --git a/docs/canary-release.md b/docs/canary-release.md new file mode 100644 index 0000000..a317e03 --- /dev/null +++ b/docs/canary-release.md @@ -0,0 +1,107 @@ +# Canary Releases + +Canary builds are **internal pre-releases** used to test changes before cutting a real release. They build, smoke-test, sign, and publish artifacts for **Linux and macOS only** — Windows and Apple notarization are deliberately skipped to keep the loop fast. + +> Back to [README](../README.md) | See also: [release-process.md](release-process.md) + +--- + +## What's different from a real release + +| | Real release (`Release` workflow) | Canary (`Canary Release` workflow) | +|---|---|---| +| Platforms | macOS + Windows + Linux | macOS + Linux | +| Version source | `internal/buildinfo/version.go` (must be bumped) | `version.go` value + `-canary..` (no bump) | +| Git tag | `v1.11.1` (semver) | `canary--` (lightweight) | +| Draft → publish | Draft, manually notarized, then published | Published immediately as `prerelease` | +| macOS signing | Apple Developer ID + notarization (manual) | Ad-hoc `codesign -s -` (CI only) | +| Sigstore cosign | Yes | Yes | +| SLSA attestation | Yes | Yes | +| Marked as `latest` | Yes | No | +| Who can publish | Anyone with `workflow_dispatch` access | Only reviewers of the `canary-release` GitHub environment | + +Production code paths (`.github/workflows/release.yml`, `.goreleaser.yml`, `version.go`, `CHANGELOG.md`) are **not touched** by canaries. + +--- + +## One-time setup (maintainers) + +Canary publishing is gated by a GitHub Actions **environment** with required reviewers. Configure it once per repo: + +1. Go to **Settings → Environments → New environment**, name it `canary-release`. +2. Under **Deployment protection rules**, enable **Required reviewers** and add the maintainers allowed to cut canaries. +3. Save. + +After this, the `build-linux` and `build-darwin` jobs run as soon as the workflow is dispatched, but the `publish` job pauses for reviewer approval before pushing the tag, creating the prerelease, signing, or attesting. + +--- + +## How to cut a canary + +1. Go to **Actions → Canary Release** in the GitHub UI. +2. Click **Run workflow** on the branch you want to canary (usually `main`, but any branch works). +3. Wait for `build-linux` and `build-darwin` to finish (~5 min, run in parallel). +4. The `publish` job will pause for approval — an authorized reviewer must approve it in the workflow UI. +5. Once approved, the release appears under [Releases](https://github.com/step-security/dev-machine-guard/releases) marked `Pre-release`. + +The canary tag, version, and direct link to the run appear in the release notes. + +--- + +## Versioning + +If `version.go` says `1.11.1` and the workflow is run #42 against commit `abc1234`: + +- **Version (embedded in binary `--version` output):** `1.11.1-canary.42.abc1234` +- **Git tag:** `canary-42-abc1234` (lightweight, distinct from semver release tags) +- **Artifact prefix:** `stepsecurity-dev-machine-guard-1.11.1-canary.42.abc1234-` + +The lightweight tag scheme prevents canary tags from being mistaken for real releases when sorted or listed. + +--- + +## Artifacts published + +``` +stepsecurity-dev-machine-guard--darwin (universal, ad-hoc signed) +stepsecurity-dev-machine-guard--darwin.bundle +stepsecurity-dev-machine-guard--linux_amd64 +stepsecurity-dev-machine-guard--linux_amd64.bundle +stepsecurity-dev-machine-guard--linux_arm64 +stepsecurity-dev-machine-guard--linux_arm64.bundle +stepsecurity-dev-machine-guard--amd64.deb +stepsecurity-dev-machine-guard--amd64.deb.bundle +stepsecurity-dev-machine-guard--arm64.deb +stepsecurity-dev-machine-guard--arm64.deb.bundle +stepsecurity-dev-machine-guard--amd64.rpm +stepsecurity-dev-machine-guard--amd64.rpm.bundle +stepsecurity-dev-machine-guard--arm64.rpm +stepsecurity-dev-machine-guard--arm64.rpm.bundle +``` + +--- + +## Running a canary + +**Linux:** + +```bash +VERSION="1.11.1-canary.42.abc1234" +gh release download "canary-42-abc1234" --repo step-security/dev-machine-guard \ + --pattern "stepsecurity-dev-machine-guard-${VERSION}-linux_amd64*" +chmod +x "stepsecurity-dev-machine-guard-${VERSION}-linux_amd64" +./"stepsecurity-dev-machine-guard-${VERSION}-linux_amd64" --version +``` + +**macOS** (Gatekeeper blocks ad-hoc signed binaries on first launch): + +```bash +VERSION="1.11.1-canary.42.abc1234" +gh release download "canary-42-abc1234" --repo step-security/dev-machine-guard \ + --pattern "stepsecurity-dev-machine-guard-${VERSION}-darwin*" +xattr -d com.apple.quarantine "stepsecurity-dev-machine-guard-${VERSION}-darwin" +chmod +x "stepsecurity-dev-machine-guard-${VERSION}-darwin" +./"stepsecurity-dev-machine-guard-${VERSION}-darwin" --version +``` + +**Verification** works the same way as production — see [release-process.md § Verifying a Release](release-process.md#verifying-a-release).