From 927365056d83cf44171cedc06938c4e2a56d1e9f Mon Sep 17 00:00:00 2001 From: Michael Pursifull Date: Wed, 15 Jul 2026 18:31:47 -0500 Subject: [PATCH 1/7] ci: add fork-friendly release ops (sign, notarize, brew publish, upstream sync) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the ArcavenAE/jira-cli fork-friendly release-ops pipeline (docs/specs/fork-friendly-release-ops.md there) to the wirerust fork: - sign-and-publish.yml: five channels — develop push → alpha (wirerust-a, builds from source), v*-dev.* → wirerust-d, v*-beta.* → wirerust-b, v*-rc.* → wirerust-rc, v*.*.* → wirerust. Apple codesign + notarize + staple, .pkg/.dmg packaging, Homebrew tap formula publish. All jobs gated on SIGNING_ENABLED / HOMEBREW_TAP_REPO repository variables. - sync-upstream.yml: scheduled merge from Zious11/wirerust (main/develop/factory-artifacts) with protected-file auto-resolution via .github/local-workflows.txt. Gated on SYNC_UPSTREAM_REPO. - backfill-release.yml: manual-dispatch build+release+sign for an existing tag. No scheduled gap-fill automation is installed. - signing-guard.yml: fork-local CI job running scripts/check-signing-workflow-injection.sh (CWE-77 YAML-aware scanner) — hosted as a separate workflow instead of a ci.yml job so the upstream-shared ci.yml stays conflict-free. - Formula templates (wirerust, -a, -b, -d, -rc) for parallel channel installs; packaging/Info.plist + create-app/dmg/pkg scripts adapted to the wirerust binary and com.arcavenae.wirerust bundle id. Differences from the jira-cli original: no embedded-OAuth build env or smoke checks (wirerust has none), backfill matrix matches upstream release.yml targets (no aarch64-linux cross build). --- .github/local-workflows.txt | 30 + .github/workflows/backfill-release.yml | 452 ++++++++++++ .github/workflows/sign-and-publish.yml | 737 ++++++++++++++++++++ .github/workflows/signing-guard.yml | 46 ++ .github/workflows/sync-upstream.yml | 146 ++++ Formula/wirerust-a.rb | 30 + Formula/wirerust-b.rb | 30 + Formula/wirerust-d.rb | 30 + Formula/wirerust-rc.rb | 30 + Formula/wirerust.rb | 23 + packaging/Info.plist | 28 + scripts/check-signing-workflow-injection.sh | 524 ++++++++++++++ scripts/create-app.sh | 33 + scripts/create-dmg.sh | 28 + scripts/create-pkg.sh | 31 + 15 files changed, 2198 insertions(+) create mode 100644 .github/local-workflows.txt create mode 100644 .github/workflows/backfill-release.yml create mode 100644 .github/workflows/sign-and-publish.yml create mode 100644 .github/workflows/signing-guard.yml create mode 100644 .github/workflows/sync-upstream.yml create mode 100644 Formula/wirerust-a.rb create mode 100644 Formula/wirerust-b.rb create mode 100644 Formula/wirerust-d.rb create mode 100644 Formula/wirerust-rc.rb create mode 100644 Formula/wirerust.rb create mode 100644 packaging/Info.plist create mode 100755 scripts/check-signing-workflow-injection.sh create mode 100755 scripts/create-app.sh create mode 100755 scripts/create-dmg.sh create mode 100755 scripts/create-pkg.sh diff --git a/.github/local-workflows.txt b/.github/local-workflows.txt new file mode 100644 index 00000000..57ac8357 --- /dev/null +++ b/.github/local-workflows.txt @@ -0,0 +1,30 @@ +# Fork-local files — protected during upstream sync. +# +# Used by .github/workflows/sync-upstream.yml: when a scheduled upstream +# merge conflicts on a file listed here, the fork's version wins +# automatically ("ours"); conflicts in any other file stop the sync for +# manual resolution. One path per line, relative to repo root. Comments (#) +# and blank lines are ignored. +# +# These files carry the ArcavenAE fork's release-ops pipeline (Apple code +# signing, notarization, Homebrew tap publishing) ported from the +# fork-friendly release-ops design in ArcavenAE/jira-cli +# (docs/specs/fork-friendly-release-ops.md there). Upstream +# (Zious11/wirerust) does not carry these files today; if it ever adopts +# them in identical form, entries can be pruned. This file lists itself so +# the fork's list survives upstream template changes. +.github/local-workflows.txt +.github/workflows/sign-and-publish.yml +.github/workflows/sync-upstream.yml +.github/workflows/backfill-release.yml +.github/workflows/signing-guard.yml +Formula/wirerust.rb +Formula/wirerust-a.rb +Formula/wirerust-b.rb +Formula/wirerust-d.rb +Formula/wirerust-rc.rb +packaging/Info.plist +scripts/create-app.sh +scripts/create-dmg.sh +scripts/create-pkg.sh +scripts/check-signing-workflow-injection.sh diff --git a/.github/workflows/backfill-release.yml b/.github/workflows/backfill-release.yml new file mode 100644 index 00000000..e6932179 --- /dev/null +++ b/.github/workflows/backfill-release.yml @@ -0,0 +1,452 @@ +# Backfill Release — build, release, sign, and publish for an existing tag. +# +# Use this to create full releases for tags that predate release.yml (or the +# signing pipeline). Trigger once per tag via workflow_dispatch, or let +# release-gap-fill.yml dispatch it automatically. +# +# OPT-IN signing: the sign and homebrew jobs are gated on repository +# variables (SIGNING_ENABLED, HOMEBREW_TAP_REPO) and skip cleanly when +# unset. Without them this workflow still backfills unsigned releases. +# See docs/specs/fork-friendly-release-ops.md. +name: Backfill Release + +on: + workflow_dispatch: + inputs: + tag: + description: 'Git tag to build and release (e.g. v0.3.0)' + required: true + type: string + skip_signing: + description: 'Skip signing and just create the unsigned release' + required: false + type: boolean + default: false + update_homebrew: + description: 'Update Homebrew tap formula (only for latest stable)' + required: false + type: boolean + default: false + +permissions: + contents: read + +jobs: + # --------------------------------------------------------------------------- + # Build — same targets as release.yml + # --------------------------------------------------------------------------- + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + strategy: + matrix: + include: + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ inputs.tag }} + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@c93f4f9c67595668add93d3d6895795ce52d8c2d # stable + with: + targets: ${{ matrix.target }} + + # rust-toolchain.toml pins channel = "stable", which overrides the + # toolchain dtolnay/rust-toolchain installs above. Without this step, + # native (non-cross) builds fail with `error[E0463]: can't find + # crate for 'core'`. Same defensive fix as release.yml. + - name: Ensure target installed (defensive) + shell: bash + run: rustup target add ${{ matrix.target }} + + - name: Build + shell: bash + run: cargo build --release --target ${{ matrix.target }} + + - name: Package + if: runner.os != 'Windows' + shell: bash + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + cd target/${{ matrix.target }}/release + tar czf "../../../wirerust-${RELEASE_TAG}-${{ matrix.target }}.tar.gz" wirerust + cd ../../.. + if command -v sha256sum &>/dev/null; then + sha256sum "wirerust-${RELEASE_TAG}-${{ matrix.target }}.tar.gz" > "wirerust-${RELEASE_TAG}-${{ matrix.target }}.tar.gz.sha256" + else + shasum -a 256 "wirerust-${RELEASE_TAG}-${{ matrix.target }}.tar.gz" > "wirerust-${RELEASE_TAG}-${{ matrix.target }}.tar.gz.sha256" + fi + + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + RELEASE_TAG: ${{ inputs.tag }} + run: Compress-Archive -Path "target/${{ matrix.target }}/release/wirerust.exe" -DestinationPath "wirerust-${env:RELEASE_TAG}-${{ matrix.target }}.zip" + + - name: Checksum (Windows) + if: runner.os == 'Windows' + shell: bash + env: + RELEASE_TAG: ${{ inputs.tag }} + run: sha256sum "wirerust-${RELEASE_TAG}-${{ matrix.target }}.zip" > "wirerust-${RELEASE_TAG}-${{ matrix.target }}.zip.sha256" + + - name: Smoke test (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' # Catches Set-Location failure (directory missing) + Set-Location "target/${{ matrix.target }}/release" + # Use `.\wirerust.exe` (explicit current-directory prefix) — PowerShell does NOT + # search CWD for executables without it, unlike cmd.exe. + # NOTE: $ErrorActionPreference does NOT catch non-zero exit from native + # executables in PS7; the explicit LASTEXITCODE check below is load-bearing. + .\wirerust.exe --version + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: wirerust-${{ matrix.target }} + path: | + wirerust-*.tar.gz + wirerust-*.zip + wirerust-*.sha256 + + # --------------------------------------------------------------------------- + # Release — create or update GitHub Release with unsigned binaries + # --------------------------------------------------------------------------- + release: + name: Create Release + needs: build + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + merge-multiple: true + + - name: Create or update GitHub Release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + TAG="$RELEASE_TAG" + PRERELEASE="" + if [[ "$TAG" == *-* ]]; then + PRERELEASE="--prerelease" + fi + + if gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then + # Release already exists — upload/replace assets without touching notes or flags + DRAFT_STATUS=$(gh release view "$TAG" \ + --repo "${{ github.repository }}" \ + --json isDraft --jq '.isDraft') + if [ "$DRAFT_STATUS" = "true" ]; then + echo "::warning::Release $TAG is a draft. Uploading assets but NOT publishing — curator must manually publish." + fi + gh release upload "$TAG" \ + --repo "${{ github.repository }}" \ + --clobber \ + wirerust-*.tar.gz \ + wirerust-*.zip \ + wirerust-*.sha256 + else + # No release yet — safe to create with auto-generated notes + gh release create "$TAG" \ + --repo "${{ github.repository }}" \ + --title "$TAG" \ + --generate-notes \ + $PRERELEASE \ + wirerust-*.tar.gz \ + wirerust-*.zip \ + wirerust-*.sha256 + fi + + # --------------------------------------------------------------------------- + # Sign — same as sign-and-publish.yml stable channel + # --------------------------------------------------------------------------- + sign: + name: Sign & Notarize + needs: release + if: inputs.skip_signing == false && vars.SIGNING_ENABLED == 'true' + permissions: + contents: write + environment: release + runs-on: macos-latest + outputs: + version: ${{ steps.meta.outputs.version }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: develop # need packaging scripts from develop, not the old tag + persist-credentials: false + + - name: Extract version + id: meta + env: + RELEASE_TAG: ${{ inputs.tag }} + run: | + TAG="$RELEASE_TAG" + VERSION="${TAG#v}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Download release binaries + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + TAG="$RELEASE_TAG" + for target in x86_64-apple-darwin aarch64-apple-darwin; do + ASSET="wirerust-${TAG}-${target}.tar.gz" + echo "Downloading $ASSET..." + gh release download "$TAG" --pattern "$ASSET" --dir . + tar xzf "$ASSET" + mv wirerust "wirerust-darwin-$(echo $target | sed 's/x86_64.*/amd64/;s/aarch64.*/arm64/')" + rm "$ASSET" + done + + - name: Import certificates + env: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_INSTALLER_CERTIFICATE_P12: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_P12 }} + APPLE_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_PASSWORD }} + run: | + security create-keychain -p "" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "" build.keychain + + echo "$APPLE_CERTIFICATE_P12" | base64 --decode > cert.p12 + security import cert.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + rm cert.p12 + + echo "$APPLE_INSTALLER_CERTIFICATE_P12" | base64 --decode > installer-cert.p12 + security import installer-cert.p12 -k build.keychain -P "$APPLE_INSTALLER_CERTIFICATE_PASSWORD" -T /usr/bin/pkgbuild -T /usr/bin/productbuild -T /usr/bin/productsign + rm installer-cert.p12 + + curl -sfo /tmp/DeveloperIDG2CA.cer https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer + security add-certificates -k build.keychain /tmp/DeveloperIDG2CA.cer + rm /tmp/DeveloperIDG2CA.cer + + security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain + + - name: Sign binaries + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp wirerust-darwin-arm64 + codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp wirerust-darwin-amd64 + codesign --verify --deep --strict wirerust-darwin-arm64 + codesign --verify --deep --strict wirerust-darwin-amd64 + + - name: Build packaging artifacts + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY }} + RELEASE_VERSION: ${{ steps.meta.outputs.version }} + run: | + VERSION="$RELEASE_VERSION" + chmod +x scripts/create-app.sh scripts/create-dmg.sh scripts/create-pkg.sh + + for arch in arm64 amd64; do + ./scripts/create-app.sh "wirerust-darwin-${arch}" "$VERSION" . + codesign --force --deep --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp Wirerust.app + ./scripts/create-dmg.sh Wirerust.app "$VERSION" "wirerust-${arch}.dmg" + # Sign the DMG container itself. Required for stapler to attach a + # Gatekeeper-recognized notarization ticket, and routes the + # notarytool submission through Apple's fast path (signed-image + # validation) instead of the slow "discovery" path that hangs at + # pre-submission under burst load. + codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "wirerust-${arch}.dmg" + ./scripts/create-pkg.sh "wirerust-darwin-${arch}" "$VERSION" "$APPLE_INSTALLER_IDENTITY" "wirerust-${arch}.pkg" + rm -rf Wirerust.app + done + + - name: Notarize + env: + APPLE_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_NOTARIZATION_APPLE_ID }} + APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} + APPLE_NOTARIZATION_TEAM_ID: ${{ secrets.APPLE_NOTARIZATION_TEAM_ID }} + run: | + for ARTIFACT in wirerust-arm64.pkg wirerust-amd64.pkg wirerust-arm64.dmg wirerust-amd64.dmg; do + echo "Notarizing $ARTIFACT..." + xcrun notarytool submit "$ARTIFACT" \ + --apple-id "$APPLE_NOTARIZATION_APPLE_ID" \ + --password "$APPLE_NOTARIZATION_PASSWORD" \ + --team-id "$APPLE_NOTARIZATION_TEAM_ID" \ + --wait --timeout 14400 + xcrun stapler staple "$ARTIFACT" + done + + - name: Verify signatures (Gatekeeper + codesign) + run: | + set -eo pipefail + CS_OUT=$(mktemp) + SPCTL_OUT=$(mktemp) + trap 'rm -f "$CS_OUT" "$SPCTL_OUT"' EXIT + # Bare Mach-O binaries: stapler can't attach to a bare binary + # (Apple TN3147), so `spctl --assess --type execute` would + # report "Unnotarized Developer ID". Verify the load-bearing + # properties directly via codesign: Developer ID Application + # identity, stable Team Identifier, and hardened runtime flag. + for BIN in wirerust-darwin-arm64 wirerust-darwin-amd64; do + echo "::group::Verify $BIN" + codesign -dvv "$BIN" 2>&1 | tee "$CS_OUT" + # GHA log-masks the leaf cert CN (it matches APPLE_SIGNING_IDENTITY + # secret), so `Authority=Developer ID Application: ...` becomes + # `Authority=***`. Anchor instead on the intermediate cert in the + # Developer ID chain, which is a public Apple CA name and never + # masked. Its presence proves the chain. + grep -q "^Authority=Developer ID Certification Authority$" "$CS_OUT" \ + || { echo "::error::$BIN missing Developer ID chain (intermediate CA)"; exit 1; } + # TeamIdentifier value matches APPLE_NOTARIZATION_TEAM_ID and is + # masked to "***" — accept either a real team-id format or the + # masked marker, but reject "not set" (the ad-hoc sentinel). + grep -qE "^TeamIdentifier=([A-Z0-9]{6,}|\*+)$" "$CS_OUT" \ + || { echo "::error::$BIN: TeamIdentifier missing, ad-hoc, or unexpected format"; exit 1; } + grep -qE "^CodeDirectory.*flags=0x[0-9a-f]+\(.*runtime.*\)" "$CS_OUT" \ + || { echo "::error::$BIN missing hardened runtime (--options runtime) flag"; exit 1; } + echo "::endgroup::" + done + # Stapled containers: spctl --assess returns "accepted + # source=Notarized Developer ID" when signed + notarized + + # stapled. .pkg → --type install; .dmg → --type open. + for PKG in wirerust-arm64.pkg wirerust-amd64.pkg; do + echo "::group::Verify $PKG" + spctl --assess --type install --verbose=4 "$PKG" 2>&1 | tee "$SPCTL_OUT" + grep -q "source=Notarized Developer ID" "$SPCTL_OUT" \ + || { echo "::error::$PKG not notarized (spctl source unexpected)"; exit 1; } + echo "::endgroup::" + done + # `spctl --assess --type open` on a notarized .dmg returns + # "rejected: source=Insufficient Context" on macOS 15+ — Gatekeeper + # assesses the *mounted* contents, not the .dmg file. `stapler + # validate` is the canonical check: it verifies the notarization + # ticket is locally attached to the .dmg and references a valid + # Apple notarization record. Non-zero exit on missing/invalid + # staple fails the step. + for DMG in wirerust-arm64.dmg wirerust-amd64.dmg; do + echo "::group::Verify $DMG" + xcrun stapler validate "$DMG" + echo "::endgroup::" + done + + - name: Generate checksums + run: | + for f in wirerust-darwin-arm64 wirerust-darwin-amd64 wirerust-arm64.pkg wirerust-amd64.pkg wirerust-arm64.dmg wirerust-amd64.dmg; do + shasum -a 256 "$f" > "${f}.sha256" + done + + - name: Upload signed artifacts to release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + TAG="$RELEASE_TAG" + gh release upload "$TAG" \ + wirerust-darwin-arm64 \ + wirerust-darwin-amd64 \ + wirerust-arm64.pkg \ + wirerust-amd64.pkg \ + wirerust-arm64.dmg \ + wirerust-amd64.dmg \ + wirerust-*.sha256 \ + --clobber + + - name: Cleanup keychain + if: always() + run: security delete-keychain build.keychain || true + + # --------------------------------------------------------------------------- + # Homebrew — update stable tap formula + # --------------------------------------------------------------------------- + homebrew: + name: Update Homebrew Tap + needs: sign + if: needs.sign.result == 'success' && inputs.update_homebrew == true && vars.HOMEBREW_TAP_REPO != '' + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: develop # need Formula/wirerust.rb template from develop + persist-credentials: false + + - name: Download signed binaries from release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + TAG="$RELEASE_TAG" + gh release download "$TAG" --pattern "wirerust-darwin-arm64" --dir . + gh release download "$TAG" --pattern "wirerust-darwin-amd64" --dir . + + - name: Update stable formula + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + HOMEBREW_TAP_REPO: ${{ vars.HOMEBREW_TAP_REPO }} + RELEASE_VERSION: ${{ needs.sign.outputs.version }} + RELEASE_TAG: ${{ inputs.tag }} + run: | + VERSION="$RELEASE_VERSION" + TAG="$RELEASE_TAG" + + TAP_SHORT="${HOMEBREW_TAP_REPO#*/}" + TAP_NAME="${HOMEBREW_TAP_REPO%%/*}/${TAP_SHORT#homebrew-}" + + SHA256_ARM64=$(shasum -a 256 wirerust-darwin-arm64 | cut -d' ' -f1) + SHA256_AMD64=$(shasum -a 256 wirerust-darwin-amd64 | cut -d' ' -f1) + + git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${HOMEBREW_TAP_REPO}.git" homebrew-tap-repo + cd homebrew-tap-repo + + mkdir -p Formula + cp ../Formula/wirerust.rb Formula/wirerust.rb + + sed -i "s|REPO_PLACEHOLDER|${GITHUB_REPOSITORY}|g" Formula/wirerust.rb + sed -i "s|TAP_PLACEHOLDER|${TAP_NAME}|g" Formula/wirerust.rb + sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" Formula/wirerust.rb + sed -i "s/TAG_PLACEHOLDER/$TAG/g" Formula/wirerust.rb + sed -i "s/SHA256_ARM64_PLACEHOLDER/$SHA256_ARM64/g" Formula/wirerust.rb + sed -i "s/SHA256_AMD64_PLACEHOLDER/$SHA256_AMD64/g" Formula/wirerust.rb + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/wirerust.rb + git diff --cached --quiet && echo "No changes to commit" && exit 0 + git commit -m "Update wirerust to $VERSION" + git push diff --git a/.github/workflows/sign-and-publish.yml b/.github/workflows/sign-and-publish.yml new file mode 100644 index 00000000..8ab35320 --- /dev/null +++ b/.github/workflows/sign-and-publish.yml @@ -0,0 +1,737 @@ +# Sign, Notarize & Publish — Apple code signing + Homebrew tap +# +# Five channels: +# develop push → alpha (wirerust-a formula, builds from source) +# v*-dev.* → dev (wirerust-d formula, signs release binaries) +# v*-beta.* → beta (wirerust-b formula, signs release binaries) +# v*-rc.* → rc (wirerust-rc formula, signs release binaries) +# v*.*.* (no -) → stable (wirerust formula, signs release binaries) +# +# OPT-IN: every job is gated on repository variables. With SIGNING_ENABLED +# unset (the default), this workflow is a no-op — no Apple Developer Program +# setup, secrets, or tap repo are required to host it. Downstream forks that +# publish signed builds set the variables/secrets listed in +# docs/specs/fork-friendly-release-ops.md. +name: Sign & Publish + +on: + push: + branches: [develop] + workflow_run: + workflows: ["Release"] + types: [completed] + +permissions: + contents: read + +jobs: + # --------------------------------------------------------------------------- + # Alpha channel — build + sign on every develop push + # --------------------------------------------------------------------------- + alpha-build: + name: Alpha Build macOS + if: github.event_name == 'push' && vars.SIGNING_ENABLED == 'true' + runs-on: macos-latest + strategy: + matrix: + include: + - target: aarch64-apple-darwin + arch: arm64 + - target: x86_64-apple-darwin + arch: amd64 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@c93f4f9c67595668add93d3d6895795ce52d8c2d # stable + with: + targets: ${{ matrix.target }} + + # rust-toolchain.toml pins channel = "stable", which overrides the + # toolchain dtolnay/rust-toolchain installs above. The target the + # action installed sits on the wrong toolchain; without this step + # the build fails with `error[E0463]: can't find crate for 'core'`. + # Same defensive fix as release.yml. + - name: Ensure target installed (defensive) + shell: bash + run: rustup target add ${{ matrix.target }} + + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + key: ${{ matrix.target }} + + - name: Build + run: cargo build --release --target ${{ matrix.target }} + + - name: Rename binary + run: cp target/${{ matrix.target }}/release/wirerust wirerust-a-darwin-${{ matrix.arch }} + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: alpha-${{ matrix.arch }} + path: wirerust-a-darwin-${{ matrix.arch }} + + alpha-sign: + name: Alpha Sign & Notarize + needs: alpha-build + if: needs.alpha-build.result == 'success' + permissions: + contents: write + environment: release + runs-on: macos-latest + outputs: + version: ${{ steps.meta.outputs.version }} + tag: ${{ steps.meta.outputs.tag }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Generate alpha version + id: meta + env: + COMMIT_SHA: ${{ github.sha }} + GH_TOKEN: ${{ github.token }} + run: | + # Atomic alpha-tag reservation via GitHub API (no TOCTOU race). + # Spec: docs/specs/fork-friendly-release-ops.md § "Atomic alpha-tag creation" + # Step 1: inputs bound from env: above (CWE-77 rule). + + # Step 2: compute seed hint (starting point only — correctness does NOT + # depend on its accuracy; the atomic reservation loop is the sole guarantor). + DATE=$(date -u +%Y%m%d) + EXISTING=$(git ls-remote --tags origin "refs/tags/alpha-${DATE}.*" | wc -l | tr -d ' ') + SEQ=$((EXISTING + 1)) + + # Steps 3 + 4: atomic reservation with bounded retry. + # HTTP 201 → reserved. HTTP 422 → ref exists, increment SEQ and retry. + # Any other non-zero exit → fatal (exit 1 with diagnostic). + # MAX_ATTEMPTS=10 (includes first attempt in step 3). + # NEVER re-count remote tags on retry — increment from just-rejected SEQ only. + MAX_ATTEMPTS=10 + ATTEMPT=1 + TAG="" + GH_API_ERR_FILE=$(mktemp) + trap 'rm -f "$GH_API_ERR_FILE"' EXIT + + while [ "$ATTEMPT" -le "$MAX_ATTEMPTS" ]; do + CANDIDATE="alpha-${DATE}.${SEQ}" + echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}: reserving tag ${CANDIDATE}..." + if gh api --method POST \ + "/repos/${{ github.repository }}/git/refs" \ + -f "ref=refs/tags/${CANDIDATE}" \ + -f "sha=${COMMIT_SHA}" \ + 2>"$GH_API_ERR_FILE"; then + # HTTP 201 — reservation succeeded + TAG="$CANDIDATE" + echo "Reserved tag: $TAG" + break + else + ERR_BODY=$(cat "$GH_API_ERR_FILE") + # gh CLI exits non-zero for both 422 (already exists) and other errors. + # Distinguish 422 by the "already exists" message in the error body. + if echo "$ERR_BODY" | grep -qi "already exists\|Reference already exists"; then + echo "Tag ${CANDIDATE} already exists (HTTP 422), incrementing SEQ..." + SEQ=$((SEQ + 1)) + ATTEMPT=$((ATTEMPT + 1)) + else + echo "::error::Fatal error reserving tag ${CANDIDATE}: ${ERR_BODY}" + exit 1 + fi + fi + done + + # Step 4 exhaustion — silent success and || true are PROHIBITED on exhaustion. + if [ -z "$TAG" ]; then + echo "::error::alpha tag reservation failed after ${MAX_ATTEMPTS} attempts — burst contention ceiling exceeded; retry when concurrent workflow runs settle" + exit 1 + fi + + # Step 5: export reserved name. + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$TAG" >> "$GITHUB_OUTPUT" + echo "Alpha release: $TAG" + + - name: Download alpha binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + merge-multiple: true + + - name: Import certificates + env: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_INSTALLER_CERTIFICATE_P12: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_P12 }} + APPLE_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_PASSWORD }} + run: | + security create-keychain -p "" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "" build.keychain + + echo "$APPLE_CERTIFICATE_P12" | base64 --decode > cert.p12 + security import cert.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + rm cert.p12 + + echo "$APPLE_INSTALLER_CERTIFICATE_P12" | base64 --decode > installer-cert.p12 + security import installer-cert.p12 -k build.keychain -P "$APPLE_INSTALLER_CERTIFICATE_PASSWORD" -T /usr/bin/pkgbuild -T /usr/bin/productbuild -T /usr/bin/productsign + rm installer-cert.p12 + + curl -sfo /tmp/DeveloperIDG2CA.cer https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer + security add-certificates -k build.keychain /tmp/DeveloperIDG2CA.cer + rm /tmp/DeveloperIDG2CA.cer + + security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain + + - name: Sign binaries + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp wirerust-a-darwin-arm64 + codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp wirerust-a-darwin-amd64 + codesign --verify --deep --strict wirerust-a-darwin-arm64 + codesign --verify --deep --strict wirerust-a-darwin-amd64 + + - name: Build packaging artifacts + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY }} + RELEASE_VERSION: ${{ steps.meta.outputs.version }} + run: | + VERSION="$RELEASE_VERSION" + chmod +x scripts/create-app.sh scripts/create-dmg.sh scripts/create-pkg.sh + + for arch in arm64 amd64; do + ./scripts/create-app.sh "wirerust-a-darwin-${arch}" "$VERSION" . + codesign --force --deep --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp Wirerust.app + ./scripts/create-dmg.sh Wirerust.app "$VERSION" "wirerust-a-${arch}.dmg" + # Sign the DMG container itself. Required for stapler to attach a + # Gatekeeper-recognized notarization ticket, and routes the + # notarytool submission through Apple's fast path (signed-image + # validation) instead of the slow "discovery" path that hangs at + # pre-submission under burst load (observed in run 27797831466). + codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "wirerust-a-${arch}.dmg" + ./scripts/create-pkg.sh "wirerust-a-darwin-${arch}" "$VERSION" "$APPLE_INSTALLER_IDENTITY" "wirerust-a-${arch}.pkg" + rm -rf Wirerust.app + done + + - name: Notarize + env: + APPLE_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_NOTARIZATION_APPLE_ID }} + APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} + APPLE_NOTARIZATION_TEAM_ID: ${{ secrets.APPLE_NOTARIZATION_TEAM_ID }} + run: | + for ARTIFACT in wirerust-a-arm64.pkg wirerust-a-amd64.pkg wirerust-a-arm64.dmg wirerust-a-amd64.dmg; do + echo "Notarizing $ARTIFACT..." + xcrun notarytool submit "$ARTIFACT" \ + --apple-id "$APPLE_NOTARIZATION_APPLE_ID" \ + --password "$APPLE_NOTARIZATION_PASSWORD" \ + --team-id "$APPLE_NOTARIZATION_TEAM_ID" \ + --wait --timeout 14400 + xcrun stapler staple "$ARTIFACT" + done + + - name: Verify signatures (Gatekeeper + codesign) + run: | + set -eo pipefail + CS_OUT=$(mktemp) + SPCTL_OUT=$(mktemp) + trap 'rm -f "$CS_OUT" "$SPCTL_OUT"' EXIT + # Bare Mach-O binaries: stapler can't attach to a bare binary + # (Apple TN3147), so `spctl --assess --type execute` would + # report "Unnotarized Developer ID". Verify the load-bearing + # properties directly via codesign: Developer ID Application + # identity, stable Team Identifier, and hardened runtime flag. + for BIN in wirerust-a-darwin-arm64 wirerust-a-darwin-amd64; do + echo "::group::Verify $BIN" + codesign -dvv "$BIN" 2>&1 | tee "$CS_OUT" + # GHA log-masks the leaf cert CN (it matches APPLE_SIGNING_IDENTITY + # secret), so `Authority=Developer ID Application: ...` becomes + # `Authority=***`. Anchor instead on the intermediate cert in the + # Developer ID chain, which is a public Apple CA name and never + # masked. Its presence proves the chain. + grep -q "^Authority=Developer ID Certification Authority$" "$CS_OUT" \ + || { echo "::error::$BIN missing Developer ID chain (intermediate CA)"; exit 1; } + # TeamIdentifier value matches APPLE_NOTARIZATION_TEAM_ID and is + # masked to "***" — accept either a real team-id format or the + # masked marker, but reject "not set" (the ad-hoc sentinel). + grep -qE "^TeamIdentifier=([A-Z0-9]{6,}|\*+)$" "$CS_OUT" \ + || { echo "::error::$BIN: TeamIdentifier missing, ad-hoc, or unexpected format"; exit 1; } + grep -qE "^CodeDirectory.*flags=0x[0-9a-f]+\(.*runtime.*\)" "$CS_OUT" \ + || { echo "::error::$BIN missing hardened runtime (--options runtime) flag"; exit 1; } + echo "::endgroup::" + done + # Stapled containers: spctl --assess returns "accepted + # source=Notarized Developer ID" when signed + notarized + + # stapled. .pkg → --type install; .dmg → --type open. + for PKG in wirerust-a-arm64.pkg wirerust-a-amd64.pkg; do + echo "::group::Verify $PKG" + spctl --assess --type install --verbose=4 "$PKG" 2>&1 | tee "$SPCTL_OUT" + grep -q "source=Notarized Developer ID" "$SPCTL_OUT" \ + || { echo "::error::$PKG not notarized (spctl source unexpected)"; exit 1; } + echo "::endgroup::" + done + # `spctl --assess --type open` on a notarized .dmg returns + # "rejected: source=Insufficient Context" on macOS 15+ — Gatekeeper + # assesses the *mounted* contents, not the .dmg file. `stapler + # validate` is the canonical check: it verifies the notarization + # ticket is locally attached to the .dmg and references a valid + # Apple notarization record. Non-zero exit on missing/invalid + # staple fails the step. + for DMG in wirerust-a-arm64.dmg wirerust-a-amd64.dmg; do + echo "::group::Verify $DMG" + xcrun stapler validate "$DMG" + echo "::endgroup::" + done + + - name: Generate checksums + run: | + for f in wirerust-a-darwin-arm64 wirerust-a-darwin-amd64 wirerust-a-arm64.pkg wirerust-a-amd64.pkg wirerust-a-arm64.dmg wirerust-a-amd64.dmg; do + shasum -a 256 "$f" > "${f}.sha256" + done + + - name: Create alpha release + env: + GH_TOKEN: ${{ github.token }} + HOMEBREW_TAP_REPO: ${{ vars.HOMEBREW_TAP_REPO }} + RELEASE_TAG: ${{ steps.meta.outputs.tag }} + run: | + TAG="$RELEASE_TAG" + + BINARY_LINE="download below" + if [ -n "$HOMEBREW_TAP_REPO" ]; then + TAP_SHORT="${HOMEBREW_TAP_REPO#*/}" + TAP_NAME="${HOMEBREW_TAP_REPO%%/*}/${TAP_SHORT#homebrew-}" + BINARY_LINE="\`brew install ${TAP_NAME}/wirerust-a\` or download below" + fi + + NOTES="**Channel:** alpha (develop branch) + **Binary:** ${BINARY_LINE} + **Commit:** ${{ github.sha }} + **Signed:** Apple Developer ID (notarized)" + + gh release create "$TAG" \ + --title "wirerust-a $TAG" \ + --notes "$NOTES" \ + --prerelease \ + wirerust-a-darwin-arm64 \ + wirerust-a-darwin-amd64 \ + wirerust-a-arm64.pkg \ + wirerust-a-amd64.pkg \ + wirerust-a-arm64.dmg \ + wirerust-a-amd64.dmg \ + wirerust-a-*.sha256 + + - name: Cleanup keychain + if: always() + run: security delete-keychain build.keychain || true + + alpha-homebrew: + name: Alpha Update Homebrew Tap + needs: alpha-sign + if: needs.alpha-sign.result == 'success' && vars.HOMEBREW_TAP_REPO != '' + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Download signed binaries from release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.alpha-sign.outputs.tag }} + run: | + TAG="$RELEASE_TAG" + gh release download "$TAG" --pattern "wirerust-a-darwin-arm64" --dir . + gh release download "$TAG" --pattern "wirerust-a-darwin-amd64" --dir . + + - name: Update alpha formula + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + HOMEBREW_TAP_REPO: ${{ vars.HOMEBREW_TAP_REPO }} + RELEASE_VERSION: ${{ needs.alpha-sign.outputs.version }} + RELEASE_TAG: ${{ needs.alpha-sign.outputs.tag }} + run: | + VERSION="$RELEASE_VERSION" + TAG="$RELEASE_TAG" + + TAP_SHORT="${HOMEBREW_TAP_REPO#*/}" + TAP_NAME="${HOMEBREW_TAP_REPO%%/*}/${TAP_SHORT#homebrew-}" + + SHA256_ARM64=$(shasum -a 256 wirerust-a-darwin-arm64 | cut -d' ' -f1) + SHA256_AMD64=$(shasum -a 256 wirerust-a-darwin-amd64 | cut -d' ' -f1) + + git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${HOMEBREW_TAP_REPO}.git" homebrew-tap-repo + cd homebrew-tap-repo + + mkdir -p Formula + cp ../Formula/wirerust-a.rb Formula/wirerust-a.rb + + sed -i "s|REPO_PLACEHOLDER|${GITHUB_REPOSITORY}|g" Formula/wirerust-a.rb + sed -i "s|TAP_PLACEHOLDER|${TAP_NAME}|g" Formula/wirerust-a.rb + sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" Formula/wirerust-a.rb + sed -i "s/TAG_PLACEHOLDER/$TAG/g" Formula/wirerust-a.rb + sed -i "s/SHA256_ARM64_PLACEHOLDER/$SHA256_ARM64/g" Formula/wirerust-a.rb + sed -i "s/SHA256_AMD64_PLACEHOLDER/$SHA256_AMD64/g" Formula/wirerust-a.rb + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Formula/wirerust-a.rb + git diff --cached --quiet && echo "No changes to commit" && exit 0 + git commit -m "Update wirerust-a (alpha) to $VERSION" + git push + + # --------------------------------------------------------------------------- + # Stable channel — sign release binaries after Release workflow + # --------------------------------------------------------------------------- + stable-sign: + name: Stable Sign & Notarize + if: >- + github.event_name == 'workflow_run' + && github.event.workflow_run.conclusion == 'success' + && vars.SIGNING_ENABLED == 'true' + permissions: + contents: write + environment: release + runs-on: macos-latest + outputs: + version: ${{ steps.meta.outputs.version }} + tag: ${{ steps.meta.outputs.tag }} + channel: ${{ steps.meta.outputs.channel }} + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Extract release metadata + id: meta + env: + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + run: | + TAG="$HEAD_BRANCH" + VERSION="${TAG#v}" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + # Determine channel and formula from tag suffix + if [[ "$TAG" == *-dev.* ]]; then + echo "channel=dev" >> "$GITHUB_OUTPUT" + elif [[ "$TAG" == *-rc.* ]]; then + echo "channel=rc" >> "$GITHUB_OUTPUT" + elif [[ "$TAG" == *-beta.* ]]; then + echo "channel=b" >> "$GITHUB_OUTPUT" + elif [[ "$TAG" == *-* ]]; then + echo "channel=unknown" >> "$GITHUB_OUTPUT" + else + echo "channel=stable" >> "$GITHUB_OUTPUT" + fi + echo "Release: $TAG (version $VERSION)" + + - name: Download release binaries + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.meta.outputs.tag }} + run: | + TAG="$RELEASE_TAG" + for target in x86_64-apple-darwin aarch64-apple-darwin; do + ASSET="wirerust-${TAG}-${target}.tar.gz" + echo "Downloading $ASSET..." + gh release download "$TAG" --pattern "$ASSET" --dir . + tar xzf "$ASSET" + mv wirerust "wirerust-darwin-$(echo $target | sed 's/x86_64.*/amd64/;s/aarch64.*/arm64/')" + rm "$ASSET" + done + + - name: Import certificates + env: + APPLE_CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_INSTALLER_CERTIFICATE_P12: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_P12 }} + APPLE_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_PASSWORD }} + run: | + security create-keychain -p "" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "" build.keychain + + echo "$APPLE_CERTIFICATE_P12" | base64 --decode > cert.p12 + security import cert.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + rm cert.p12 + + echo "$APPLE_INSTALLER_CERTIFICATE_P12" | base64 --decode > installer-cert.p12 + security import installer-cert.p12 -k build.keychain -P "$APPLE_INSTALLER_CERTIFICATE_PASSWORD" -T /usr/bin/pkgbuild -T /usr/bin/productbuild -T /usr/bin/productsign + rm installer-cert.p12 + + curl -sfo /tmp/DeveloperIDG2CA.cer https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer + security add-certificates -k build.keychain /tmp/DeveloperIDG2CA.cer + rm /tmp/DeveloperIDG2CA.cer + + security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain + + - name: Sign binaries + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + run: | + codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp wirerust-darwin-arm64 + codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp wirerust-darwin-amd64 + codesign --verify --deep --strict wirerust-darwin-arm64 + codesign --verify --deep --strict wirerust-darwin-amd64 + + - name: Build packaging artifacts + env: + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY }} + RELEASE_VERSION: ${{ steps.meta.outputs.version }} + run: | + VERSION="$RELEASE_VERSION" + chmod +x scripts/create-app.sh scripts/create-dmg.sh scripts/create-pkg.sh + + for arch in arm64 amd64; do + ./scripts/create-app.sh "wirerust-darwin-${arch}" "$VERSION" . + codesign --force --deep --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp Wirerust.app + ./scripts/create-dmg.sh Wirerust.app "$VERSION" "wirerust-${arch}.dmg" + # Sign the DMG container itself. Required for stapler to attach a + # Gatekeeper-recognized notarization ticket, and routes the + # notarytool submission through Apple's fast path (signed-image + # validation) instead of the slow "discovery" path that hangs at + # pre-submission under burst load. + codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "wirerust-${arch}.dmg" + ./scripts/create-pkg.sh "wirerust-darwin-${arch}" "$VERSION" "$APPLE_INSTALLER_IDENTITY" "wirerust-${arch}.pkg" + rm -rf Wirerust.app + done + + - name: Notarize + env: + APPLE_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_NOTARIZATION_APPLE_ID }} + APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} + APPLE_NOTARIZATION_TEAM_ID: ${{ secrets.APPLE_NOTARIZATION_TEAM_ID }} + run: | + for ARTIFACT in wirerust-arm64.pkg wirerust-amd64.pkg wirerust-arm64.dmg wirerust-amd64.dmg; do + echo "Notarizing $ARTIFACT..." + xcrun notarytool submit "$ARTIFACT" \ + --apple-id "$APPLE_NOTARIZATION_APPLE_ID" \ + --password "$APPLE_NOTARIZATION_PASSWORD" \ + --team-id "$APPLE_NOTARIZATION_TEAM_ID" \ + --wait --timeout 14400 + xcrun stapler staple "$ARTIFACT" + done + + - name: Verify signatures (Gatekeeper + codesign) + run: | + set -eo pipefail + CS_OUT=$(mktemp) + SPCTL_OUT=$(mktemp) + trap 'rm -f "$CS_OUT" "$SPCTL_OUT"' EXIT + # Bare Mach-O binaries: stapler can't attach to a bare binary + # (Apple TN3147), so `spctl --assess --type execute` would + # report "Unnotarized Developer ID". Verify the load-bearing + # properties directly via codesign: Developer ID Application + # identity, stable Team Identifier, and hardened runtime flag. + for BIN in wirerust-darwin-arm64 wirerust-darwin-amd64; do + echo "::group::Verify $BIN" + codesign -dvv "$BIN" 2>&1 | tee "$CS_OUT" + # GHA log-masks the leaf cert CN (it matches APPLE_SIGNING_IDENTITY + # secret), so `Authority=Developer ID Application: ...` becomes + # `Authority=***`. Anchor instead on the intermediate cert in the + # Developer ID chain, which is a public Apple CA name and never + # masked. Its presence proves the chain. + grep -q "^Authority=Developer ID Certification Authority$" "$CS_OUT" \ + || { echo "::error::$BIN missing Developer ID chain (intermediate CA)"; exit 1; } + # TeamIdentifier value matches APPLE_NOTARIZATION_TEAM_ID and is + # masked to "***" — accept either a real team-id format or the + # masked marker, but reject "not set" (the ad-hoc sentinel). + grep -qE "^TeamIdentifier=([A-Z0-9]{6,}|\*+)$" "$CS_OUT" \ + || { echo "::error::$BIN: TeamIdentifier missing, ad-hoc, or unexpected format"; exit 1; } + grep -qE "^CodeDirectory.*flags=0x[0-9a-f]+\(.*runtime.*\)" "$CS_OUT" \ + || { echo "::error::$BIN missing hardened runtime (--options runtime) flag"; exit 1; } + echo "::endgroup::" + done + # Stapled containers: spctl --assess returns "accepted + # source=Notarized Developer ID" when signed + notarized + + # stapled. .pkg → --type install; .dmg → --type open. + for PKG in wirerust-arm64.pkg wirerust-amd64.pkg; do + echo "::group::Verify $PKG" + spctl --assess --type install --verbose=4 "$PKG" 2>&1 | tee "$SPCTL_OUT" + grep -q "source=Notarized Developer ID" "$SPCTL_OUT" \ + || { echo "::error::$PKG not notarized (spctl source unexpected)"; exit 1; } + echo "::endgroup::" + done + # `spctl --assess --type open` on a notarized .dmg returns + # "rejected: source=Insufficient Context" on macOS 15+ — Gatekeeper + # assesses the *mounted* contents, not the .dmg file. `stapler + # validate` is the canonical check: it verifies the notarization + # ticket is locally attached to the .dmg and references a valid + # Apple notarization record. Non-zero exit on missing/invalid + # staple fails the step. + for DMG in wirerust-arm64.dmg wirerust-amd64.dmg; do + echo "::group::Verify $DMG" + xcrun stapler validate "$DMG" + echo "::endgroup::" + done + + - name: Generate checksums + run: | + for f in wirerust-darwin-arm64 wirerust-darwin-amd64 wirerust-arm64.pkg wirerust-amd64.pkg wirerust-arm64.dmg wirerust-amd64.dmg; do + shasum -a 256 "$f" > "${f}.sha256" + done + + - name: Upload signed artifacts to release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ steps.meta.outputs.tag }} + run: | + TAG="$RELEASE_TAG" + gh release upload "$TAG" \ + wirerust-darwin-arm64 \ + wirerust-darwin-amd64 \ + wirerust-arm64.pkg \ + wirerust-amd64.pkg \ + wirerust-arm64.dmg \ + wirerust-amd64.dmg \ + wirerust-*.sha256 \ + --clobber + + - name: Update release title and notes + env: + GH_TOKEN: ${{ github.token }} + HOMEBREW_TAP_REPO: ${{ vars.HOMEBREW_TAP_REPO }} + RELEASE_TAG: ${{ steps.meta.outputs.tag }} + RELEASE_CHANNEL: ${{ steps.meta.outputs.channel }} + run: | + TAG="$RELEASE_TAG" + CHANNEL="$RELEASE_CHANNEL" + + # Map channel to formula name + case "$CHANNEL" in + stable) FORMULA="wirerust" ;; + dev) FORMULA="wirerust-d" ;; + rc) FORMULA="wirerust-rc" ;; + b) FORMULA="wirerust-b" ;; + *) FORMULA="wirerust" ;; + esac + + BINARY_LINE="download below" + if [ -n "$HOMEBREW_TAP_REPO" ]; then + TAP_SHORT="${HOMEBREW_TAP_REPO#*/}" + TAP_NAME="${HOMEBREW_TAP_REPO%%/*}/${TAP_SHORT#homebrew-}" + BINARY_LINE="\`brew install ${TAP_NAME}/${FORMULA}\` or download below" + fi + + # Get existing release notes + EXISTING_NOTES=$(gh release view "$TAG" --json body --jq '.body') + + # Append signing info + NOTES="${EXISTING_NOTES} + + --- + **Channel:** ${CHANNEL} + **Binary:** ${BINARY_LINE} + **Signed:** Apple Developer ID (notarized)" + + gh release edit "$TAG" \ + --title "${FORMULA} ${TAG}" \ + --notes "$NOTES" + + - name: Cleanup keychain + if: always() + run: security delete-keychain build.keychain || true + + stable-homebrew: + name: Update Homebrew Tap + needs: stable-sign + if: >- + needs.stable-sign.result == 'success' + && needs.stable-sign.outputs.channel != 'unknown' + && vars.HOMEBREW_TAP_REPO != '' + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Download signed binaries from release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ needs.stable-sign.outputs.tag }} + run: | + TAG="$RELEASE_TAG" + gh release download "$TAG" --pattern "wirerust-darwin-arm64" --dir . + gh release download "$TAG" --pattern "wirerust-darwin-amd64" --dir . + + - name: Update formula for channel + env: + HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + HOMEBREW_TAP_REPO: ${{ vars.HOMEBREW_TAP_REPO }} + RELEASE_VERSION: ${{ needs.stable-sign.outputs.version }} + RELEASE_TAG: ${{ needs.stable-sign.outputs.tag }} + RELEASE_CHANNEL: ${{ needs.stable-sign.outputs.channel }} + run: | + set -euo pipefail + VERSION="$RELEASE_VERSION" + TAG="$RELEASE_TAG" + CHANNEL="$RELEASE_CHANNEL" + + # Map channel to formula name + case "$CHANNEL" in + stable) FORMULA="wirerust" ;; + dev) FORMULA="wirerust-d" ;; + rc) FORMULA="wirerust-rc" ;; + b) FORMULA="wirerust-b" ;; + *) echo "Unknown channel: $CHANNEL, skipping"; exit 0 ;; + esac + + TAP_SHORT="${HOMEBREW_TAP_REPO#*/}" + TAP_NAME="${HOMEBREW_TAP_REPO%%/*}/${TAP_SHORT#homebrew-}" + + SHA256_ARM64=$(shasum -a 256 wirerust-darwin-arm64 | cut -d' ' -f1) + SHA256_AMD64=$(shasum -a 256 wirerust-darwin-amd64 | cut -d' ' -f1) + + git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${HOMEBREW_TAP_REPO}.git" homebrew-tap-repo + cd homebrew-tap-repo + + mkdir -p Formula + cp "../Formula/${FORMULA}.rb" "Formula/${FORMULA}.rb" + + sed -i "s|REPO_PLACEHOLDER|${GITHUB_REPOSITORY}|g" "Formula/${FORMULA}.rb" + sed -i "s|TAP_PLACEHOLDER|${TAP_NAME}|g" "Formula/${FORMULA}.rb" + sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" "Formula/${FORMULA}.rb" + sed -i "s/TAG_PLACEHOLDER/$TAG/g" "Formula/${FORMULA}.rb" + sed -i "s/SHA256_ARM64_PLACEHOLDER/$SHA256_ARM64/g" "Formula/${FORMULA}.rb" + sed -i "s/SHA256_AMD64_PLACEHOLDER/$SHA256_AMD64/g" "Formula/${FORMULA}.rb" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add "Formula/${FORMULA}.rb" + git diff --cached --quiet && echo "No changes to commit" && exit 0 + git commit -m "Update ${FORMULA} to $VERSION" + git push diff --git a/.github/workflows/signing-guard.yml b/.github/workflows/signing-guard.yml new file mode 100644 index 00000000..cb3f6836 --- /dev/null +++ b/.github/workflows/signing-guard.yml @@ -0,0 +1,46 @@ +# Signing Workflow Injection Guard — fork-local CI gate. +# +# Runs scripts/check-signing-workflow-injection.sh (YAML-structure-aware +# CWE-77 scanner) against the secret-bearing release-ops workflows +# (sign-and-publish.yml, backfill-release.yml). In ArcavenAE/jira-cli this +# guard is a job inside the shared ci.yml; wirerust's ci.yml is +# upstream-owned, so the fork hosts the guard as a separate fork-local +# workflow to keep upstream syncs conflict-free. Spec: +# ArcavenAE/jira-cli docs/specs/fork-friendly-release-ops.md +# § "Required CI regression guard". +name: Signing Guard + +on: + pull_request: + paths: + - '.github/workflows/**' + - 'scripts/check-signing-workflow-injection.sh' + push: + branches: [develop, main] + paths: + - '.github/workflows/**' + - 'scripts/check-signing-workflow-injection.sh' + +permissions: + contents: read + +jobs: + check-signing-workflow-injection: + name: Signing Workflow Injection Guard + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Run injection guard (YAML-aware) + run: bash scripts/check-signing-workflow-injection.sh + + - name: Run injection guard negative fixture self-test + run: bash scripts/check-signing-workflow-injection.sh --self-test diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 00000000..18afe560 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,146 @@ +# Sync Upstream — keep a fork's branches merged with its upstream repository. +# +# FORK-SUPPORT INFRASTRUCTURE: this workflow is a no-op unless the +# SYNC_UPSTREAM_REPO repository variable is set (e.g. "Zious11/jira-cli" in a +# downstream fork). Hosting it here means forks stay aligned without each one +# carrying a divergent copy. Fork-local files that should survive a sync are +# listed in .github/local-workflows.txt. See +# docs/specs/fork-friendly-release-ops.md. +name: Sync Upstream + +on: + schedule: + - cron: '0 */4 * * *' + workflow_dispatch: + +permissions: + contents: write + # No issues:write — conflicts are reported via GITHUB_STEP_SUMMARY + exit 1 + # instead of issue creation, so this works even when the Issues feature is + # disabled or the workflow token is read-only. See "Report unresolved + # conflict". + +jobs: + sync: + # No-op unless this repo is a fork that opted in by setting the variable. + if: vars.SYNC_UPSTREAM_REPO != '' + runs-on: ubuntu-latest + strategy: + matrix: + # main + develop: fork authors local CI/release commits on top of + # upstream, so the merge logic + protected-files handling applies. + # factory-artifacts: upstream-only content (spec snapshots feeding + # ci.yml's spec-guard job). Fork has no local edits; merge is + # always a fast-forward. + branch: [main, develop, factory-artifacts] + fail-fast: false + steps: + - name: Harden the runner (Audit all outbound calls) + uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 + with: + egress-policy: audit + + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ matrix.branch }} + fetch-depth: 0 + ssh-key: ${{ secrets.SYNC_UPSTREAM_SSH_KEY }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Fetch upstream + run: | + git remote add upstream "https://github.com/${{ vars.SYNC_UPSTREAM_REPO }}.git" + git fetch upstream ${{ matrix.branch }} --tags + + - name: Check if sync needed + id: check + run: | + if git merge-base --is-ancestor "upstream/${{ matrix.branch }}" HEAD; then + echo "needed=false" >> "$GITHUB_OUTPUT" + else + echo "needed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Merge upstream + if: steps.check.outputs.needed == 'true' + id: merge + run: | + set -euo pipefail + + # Read protected files list before merge attempt + PROTECTED="" + if [[ -f .github/local-workflows.txt ]]; then + PROTECTED=$(grep -v '^#' .github/local-workflows.txt | grep -v '^[[:space:]]*$') + fi + + # Attempt merge + if git merge "upstream/${{ matrix.branch }}" --no-edit; then + echo "result=success" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Merge failed — check if all conflicts are in protected files + CONFLICTED=$(git diff --name-only --diff-filter=U) + UNRESOLVED="" + + for file in $CONFLICTED; do + if [[ -n "$PROTECTED" ]] && echo "$PROTECTED" | grep -qxF "$file"; then + echo "::notice::Protected file conflict resolved (ours): $file" + git checkout --ours "$file" + git add "$file" + else + UNRESOLVED="${UNRESOLVED:+$UNRESOLVED }$file" + fi + done + + if [[ -n "$UNRESOLVED" ]]; then + echo "result=conflict" >> "$GITHUB_OUTPUT" + echo "files=$UNRESOLVED" >> "$GITHUB_OUTPUT" + git merge --abort + else + git commit --no-edit + echo "result=success" >> "$GITHUB_OUTPUT" + fi + + - name: Report unresolved conflict + if: steps.merge.outputs.result == 'conflict' + run: | + { + echo "# :warning: Upstream sync conflict on \`${{ matrix.branch }}\`" + echo + echo "Automated upstream sync hit merge conflicts in non-protected files." + echo + echo "**Branch:** \`${{ matrix.branch }}\`" + echo "**Conflicted files:** \`${{ steps.merge.outputs.files }}\`" + echo + echo "## Resolve manually" + echo '```sh' + echo 'git fetch upstream' + echo 'git checkout ${{ matrix.branch }}' + echo 'git merge upstream/${{ matrix.branch }}' + echo '# resolve conflicts (cherry-pick upstream improvements,' + echo '# keep fork hardening; do NOT add to local-workflows.txt)' + echo 'git push' + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + # Exit non-zero so the run fails visibly in the Actions tab and + # the repo owner's notifications fire. We do NOT call gh issue + # create: forks may have the Issues feature disabled, and with + # default_workflow_permissions "read" the GITHUB_TOKEN can't create + # issues even with permissions: issues:write declared. The job + # summary above is the durable signal; the run failure is the alert. + exit 1 + + - name: Push branch + if: steps.merge.outputs.result == 'success' + run: | + git push origin ${{ matrix.branch }} + + - name: Sync tags + run: | + git push origin --tags \ No newline at end of file diff --git a/Formula/wirerust-a.rb b/Formula/wirerust-a.rb new file mode 100644 index 00000000..343b5310 --- /dev/null +++ b/Formula/wirerust-a.rb @@ -0,0 +1,30 @@ +class WirerustA < Formula + desc "Fast PCAP forensics and network triage CLI tool written in Rust (alpha ch)" + homepage "https://github.com/REPO_PLACEHOLDER" + version "VERSION_PLACEHOLDER" + license "MIT" + + if Hardware::CPU.arm? + url "https://github.com/REPO_PLACEHOLDER/releases/download/TAG_PLACEHOLDER/wirerust-a-darwin-arm64" + sha256 "SHA256_ARM64_PLACEHOLDER" + else + url "https://github.com/REPO_PLACEHOLDER/releases/download/TAG_PLACEHOLDER/wirerust-a-darwin-amd64" + sha256 "SHA256_AMD64_PLACEHOLDER" + end + + def install + binary_name = Hardware::CPU.arm? ? "wirerust-a-darwin-arm64" : "wirerust-a-darwin-amd64" + bin.install binary_name => "wirerust-a" + end + + def caveats + <<~EOS + wirerust-a is the alpha channel. Updates on every push to develop. + For stable: brew install TAP_PLACEHOLDER/wirerust + EOS + end + + test do + assert_match "wirerust", shell_output("#{bin}/wirerust-a --version 2>&1") + end +end diff --git a/Formula/wirerust-b.rb b/Formula/wirerust-b.rb new file mode 100644 index 00000000..29d69e95 --- /dev/null +++ b/Formula/wirerust-b.rb @@ -0,0 +1,30 @@ +class WirerustB < Formula + desc "Fast PCAP forensics and network triage CLI tool written in Rust (beta ch)" + homepage "https://github.com/REPO_PLACEHOLDER" + version "VERSION_PLACEHOLDER" + license "MIT" + + if Hardware::CPU.arm? + url "https://github.com/REPO_PLACEHOLDER/releases/download/TAG_PLACEHOLDER/wirerust-darwin-arm64" + sha256 "SHA256_ARM64_PLACEHOLDER" + else + url "https://github.com/REPO_PLACEHOLDER/releases/download/TAG_PLACEHOLDER/wirerust-darwin-amd64" + sha256 "SHA256_AMD64_PLACEHOLDER" + end + + def install + binary_name = Hardware::CPU.arm? ? "wirerust-darwin-arm64" : "wirerust-darwin-amd64" + bin.install binary_name => "wirerust-b" + end + + def caveats + <<~EOS + wirerust-b is the beta channel. Updates on every v*-beta.* tag. + For stable: brew install TAP_PLACEHOLDER/wirerust + EOS + end + + test do + assert_match "wirerust", shell_output("#{bin}/wirerust-b --version 2>&1") + end +end diff --git a/Formula/wirerust-d.rb b/Formula/wirerust-d.rb new file mode 100644 index 00000000..9160bfff --- /dev/null +++ b/Formula/wirerust-d.rb @@ -0,0 +1,30 @@ +class WirerustD < Formula + desc "Fast PCAP forensics and network triage CLI tool written in Rust (dev ch)" + homepage "https://github.com/REPO_PLACEHOLDER" + version "VERSION_PLACEHOLDER" + license "MIT" + + if Hardware::CPU.arm? + url "https://github.com/REPO_PLACEHOLDER/releases/download/TAG_PLACEHOLDER/wirerust-darwin-arm64" + sha256 "SHA256_ARM64_PLACEHOLDER" + else + url "https://github.com/REPO_PLACEHOLDER/releases/download/TAG_PLACEHOLDER/wirerust-darwin-amd64" + sha256 "SHA256_AMD64_PLACEHOLDER" + end + + def install + binary_name = Hardware::CPU.arm? ? "wirerust-darwin-arm64" : "wirerust-darwin-amd64" + bin.install binary_name => "wirerust-d" + end + + def caveats + <<~EOS + wirerust-d is the dev channel. Updates on every v*-dev.* tag. + For stable: brew install TAP_PLACEHOLDER/wirerust + EOS + end + + test do + assert_match "wirerust", shell_output("#{bin}/wirerust-d --version 2>&1") + end +end diff --git a/Formula/wirerust-rc.rb b/Formula/wirerust-rc.rb new file mode 100644 index 00000000..35a225ed --- /dev/null +++ b/Formula/wirerust-rc.rb @@ -0,0 +1,30 @@ +class WirerustRc < Formula + desc "Fast PCAP forensics and network triage CLI tool written in Rust (rc ch)" + homepage "https://github.com/REPO_PLACEHOLDER" + version "VERSION_PLACEHOLDER" + license "MIT" + + if Hardware::CPU.arm? + url "https://github.com/REPO_PLACEHOLDER/releases/download/TAG_PLACEHOLDER/wirerust-darwin-arm64" + sha256 "SHA256_ARM64_PLACEHOLDER" + else + url "https://github.com/REPO_PLACEHOLDER/releases/download/TAG_PLACEHOLDER/wirerust-darwin-amd64" + sha256 "SHA256_AMD64_PLACEHOLDER" + end + + def install + binary_name = Hardware::CPU.arm? ? "wirerust-darwin-arm64" : "wirerust-darwin-amd64" + bin.install binary_name => "wirerust-rc" + end + + def caveats + <<~EOS + wirerust-rc is the release-candidate channel. Updates on every v*-rc.* tag. + For stable: brew install TAP_PLACEHOLDER/wirerust + EOS + end + + test do + assert_match "wirerust", shell_output("#{bin}/wirerust-rc --version 2>&1") + end +end diff --git a/Formula/wirerust.rb b/Formula/wirerust.rb new file mode 100644 index 00000000..d6a6f03d --- /dev/null +++ b/Formula/wirerust.rb @@ -0,0 +1,23 @@ +class Wirerust < Formula + desc "Fast PCAP forensics and network triage CLI tool written in Rust" + homepage "https://github.com/REPO_PLACEHOLDER" + version "VERSION_PLACEHOLDER" + license "MIT" + + if Hardware::CPU.arm? + url "https://github.com/REPO_PLACEHOLDER/releases/download/TAG_PLACEHOLDER/wirerust-darwin-arm64" + sha256 "SHA256_ARM64_PLACEHOLDER" + else + url "https://github.com/REPO_PLACEHOLDER/releases/download/TAG_PLACEHOLDER/wirerust-darwin-amd64" + sha256 "SHA256_AMD64_PLACEHOLDER" + end + + def install + binary_name = Hardware::CPU.arm? ? "wirerust-darwin-arm64" : "wirerust-darwin-amd64" + bin.install binary_name => "wirerust" + end + + test do + assert_match "wirerust", shell_output("#{bin}/wirerust --version 2>&1") + end +end diff --git a/packaging/Info.plist b/packaging/Info.plist new file mode 100644 index 00000000..6277931c --- /dev/null +++ b/packaging/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleIdentifier + com.arcavenae.wirerust + CFBundleName + Wirerust + CFBundleDisplayName + Wirerust + CFBundleExecutable + wirerust + CFBundleVersion + VERSION_PLACEHOLDER + CFBundleShortVersionString + VERSION_PLACEHOLDER + CFBundlePackageType + APPL + CFBundleInfoDictionaryVersion + 6.0 + LSMinimumSystemVersion + 12.0 + LSUIElement + + NSHighResolutionCapable + + + diff --git a/scripts/check-signing-workflow-injection.sh b/scripts/check-signing-workflow-injection.sh new file mode 100755 index 00000000..a2a63060 --- /dev/null +++ b/scripts/check-signing-workflow-injection.sh @@ -0,0 +1,524 @@ +#!/usr/bin/env bash +# check-signing-workflow-injection.sh — YAML-structure-aware CI regression guard +# +# PURPOSE: Detects inline ${{ context }} expansions in run: script bodies inside +# jobs that have secrets or `contents: write` permissions in scope. These inline +# expansions are a CWE-77 shell injection risk when the context value is +# attacker-controlled (e.g. github.event.workflow_run.head_branch, inputs.*). +# +# TOOLING CHOICE: Uses Python 3 (standard library + PyYAML). +# Rationale: python3 is pre-installed on all GitHub Actions ubuntu/macos +# runners; PyYAML ships with the runner image — requires no CI install step. +# `yq` and `zizmor` are alternatives but require installation steps. +# `actionlint` is also an alternative but heavy and not pre-installed. +# +# YAML-STRUCTURE-AWARE: parses the YAML document and iterates jobs.*.steps[].run +# to extract run: block bodies. A naive line-oriented grep is INSUFFICIENT +# (cannot delimit run: scope, misses ${{ split across lines in block scalars). +# +# SCOPE: both sign-and-publish.yml and backfill-release.yml. +# Scope is COMPUTED STRUCTURALLY per-job — NOT from a hardcoded job-name list. +# A job is in scope when it meets ANY of: +# (a) the job body contains any `secrets.*` reference (in any key under the job), +# (b) the job-level `permissions.contents` is `write`, OR +# the workflow-level `permissions.contents` is `write`, OR +# (c) the job references a named `environment:` key. +# +# NOTE on criterion (b): only EXPLICIT `contents: write` is flagged. Jobs that +# simply inherit the workflow-default `contents: read` are NOT considered in +# scope on that criterion alone (they can still be in scope via (a) or (c)). +# +# ALLOWLIST (safe to inline — format-constrained values with no shell metacharacters): +# github.sha, github.run_id, github.run_number, +# github.repository, github.repository_owner +# Additionally, matrix.* and runner.* are safe (author/platform-controlled). +# +# DEFAULT-DENY rule: EVERY context expression not on the allowlist or in +# matrix.*/runner.* MUST be env-bound. This includes steps.*.outputs.* and +# needs.*.outputs.* — these can launder attacker-controlled values through +# multi-hop derivation chains that a guard cannot reliably trace. +# +# GUARD SCOPE NOTE: MUST NOT flag context expansions in env:, with:, or if: +# YAML keys — ONLY those textually inside run: script bodies. +# +# FAIL-CLOSED behaviour: +# exit 1 — flagged violations found +# exit 2 — YAML parse error, missing PyYAML, unreadable file, +# or zero in-scope jobs detected in a workflow file +# (sentinel for broken structural detection / renamed jobs) +# +# NEGATIVE FIXTURE: pass --self-test to run the built-in negative fixture +# (proves the detector is not a no-op per TD-VSDD-057 false-green prevention). +# +# USAGE: +# scripts/check-signing-workflow-injection.sh # scan hardened workflows +# scripts/check-signing-workflow-injection.sh --self-test # run negative fixture + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +SIGN_WORKFLOW="${REPO_ROOT}/.github/workflows/sign-and-publish.yml" +BACKFILL_WORKFLOW="${REPO_ROOT}/.github/workflows/backfill-release.yml" + +# Validate script syntax (catches accidental bash syntax errors in this wrapper) +bash -n "${BASH_SOURCE[0]}" + +SELF_TEST_MODE=false +if [ "${1:-}" = "--self-test" ]; then + SELF_TEST_MODE=true +fi + +# ============================================================ +# Python3 YAML-structure-aware scanner (inline, no temp file) +# ============================================================ +run_python_guard() { + python3 - "$@" <<'PYEOF' +import sys +import re +import os + +# --------------------------------------------------------------------------- +# Preflight: explicit PyYAML import check (fail-closed with clear message). +# PyYAML is pre-installed on GitHub Actions ubuntu/macos runners; this check +# provides a clear error rather than an AttributeError if it's ever missing. +# --------------------------------------------------------------------------- +try: + import yaml +except ImportError: + print("ERROR: PyYAML not available. Install with: pip install pyyaml", file=sys.stderr) + sys.exit(2) + +# --------------------------------------------------------------------------- +# Allowlist: context values safe to inline (format-constrained — no shell +# metacharacters possible due to GitHub naming rules or fixed numeric format). +# --------------------------------------------------------------------------- +ALLOWLIST = frozenset({ + 'github.sha', + 'github.run_id', + 'github.run_number', + 'github.repository', + 'github.repository_owner', +}) + + +def is_high_risk(expression): + """ + Returns True if the expression inside ${{ ... }} is high-risk (not on allowlist). + + DEFAULT-DENY policy per spec § "No inline context data in shell run-blocks": + - Allowlist: github.sha, github.run_id, github.run_number, + github.repository, github.repository_owner + - matrix.* and runner.* are also safe (author/platform-controlled). + - EVERYTHING ELSE must be env-bound — including steps.*.outputs.* + and needs.*.outputs.* which can launder attacker-controlled values + through multi-hop derivation chains. + """ + expr = expression.strip() + # Normalize internal whitespace (handles ${{ split across lines in block scalars) + expr_normalized = re.sub(r'\s+', ' ', expr) + # Explicit allowlist match + if expr_normalized in ALLOWLIST: + return False + # matrix.* values are workflow-author-defined static literals (author-controlled) + if re.match(r'^matrix\.[a-zA-Z0-9_.-]+$', expr_normalized): + return False + # runner.* values are runner-provided metadata (platform-controlled) + if re.match(r'^runner\.[a-zA-Z0-9_.-]+$', expr_normalized): + return False + # Everything else is HIGH-RISK — default-deny. + # This includes steps.*.outputs.* and needs.*.outputs.* because they can + # launder attacker-controlled values (e.g. stable-sign.outputs.tag derived + # from github.event.workflow_run.head_branch). A guard cannot reliably trace + # cross-job derivation chains; the safe rule is to bind ALL non-allowlisted + # expressions via step env:. + return True + + +def find_inline_expressions(run_body): + """ + Finds all ${{ ... }} expressions inside a run: script body. + Returns list of (expression_normalized, is_flagged) tuples. + """ + results = [] + for m in re.finditer(r'\$\{\{(.*?)\}\}', str(run_body), re.DOTALL): + raw_expr = m.group(1) + expr_normalized = re.sub(r'\s+', ' ', raw_expr).strip() + flagged = is_high_risk(expr_normalized) + results.append((expr_normalized, flagged)) + return results + + +def yaml_contains_secrets(obj, depth=0): + """ + Recursively searches a YAML subtree for any secrets.* reference. + Returns True if any string value matches the pattern ${{ secrets.* }} + (dot notation) or ${{ secrets['NAME'] }} / ${{ secrets["NAME"] }} + (index notation — L-PASS2-02). + """ + if depth > 20: # guard against pathological nesting + return False + if isinstance(obj, str): + # Match both secrets.NAME (dot) and secrets['NAME'] / secrets["NAME"] (index) + return bool(re.search(r'\$\{\{[^}]*secrets[\.\[]', obj)) + if isinstance(obj, dict): + for v in obj.values(): + if yaml_contains_secrets(v, depth + 1): + return True + if isinstance(obj, list): + for item in obj: + if yaml_contains_secrets(item, depth + 1): + return True + return False + + +def job_is_in_scope(job_id, job_def, workflow_perms): + """ + Determines whether a job is in scope for injection scanning. + + Criteria (any one is sufficient): + (a) the job subtree contains any `secrets.*` reference in any key + (b) job-level permissions.contents == 'write' OR + workflow-level permissions.contents == 'write' (explicit only) + (c) the job has a named `environment:` key + + Returns (bool, str) — (in_scope, reason_description) + """ + if job_def is None: + return False, '' + + # Criterion (a): secrets.* used anywhere in the job + if yaml_contains_secrets(job_def): + return True, 'uses secrets.*' + + # Criterion (b): explicit contents: write at job or workflow level. + # Also catches permissions: write-all (string form) which grants write + # to all scopes including contents (L-PASS2-01). + job_perms = job_def.get('permissions', {}) or {} + if isinstance(job_perms, str): + # String form: 'write-all' grants all permissions including contents:write + if job_perms in ('write-all', 'write'): + return True, f'job-level permissions: {job_perms}' + elif isinstance(job_perms, dict): + if job_perms.get('contents') == 'write': + return True, 'job-level permissions.contents: write' + if isinstance(workflow_perms, str): + # String form at workflow level: 'write-all' propagates to all jobs + if workflow_perms in ('write-all', 'write'): + return True, f'workflow-level permissions: {workflow_perms}' + elif isinstance(workflow_perms, dict): + if workflow_perms.get('contents') == 'write': + return True, 'workflow-level permissions.contents: write' + + # Criterion (c): named environment + if job_def.get('environment') is not None: + return True, f"environment: {job_def.get('environment')!r}" + + return False, '' + + +def scan_workflow_doc(doc, filename): + """ + Scans a parsed workflow YAML document. + Computes in-scope jobs structurally (criteria a/b/c above). + Returns (in_scope_job_count, run_block_count, total_expressions, flagged_list). + flagged_list: list of (job_id, step_name, expr) tuples. + """ + if not doc or 'jobs' not in doc: + # Return a 5-tuple to match the normal return — callers always unpack 5 + # (M-PASS2-01: 4-tuple here caused ValueError at unpack sites). + return 0, 0, 0, [], [] + + workflow_perms = doc.get('permissions', {}) or {} + run_block_count = 0 + total_expressions = 0 + flagged = [] + in_scope_jobs = [] + + jobs = doc.get('jobs', {}) or {} + for job_id, job_def in jobs.items(): + in_scope, reason = job_is_in_scope(job_id, job_def, workflow_perms) + if not in_scope: + continue + + in_scope_jobs.append((job_id, reason)) + + steps = (job_def or {}).get('steps', []) or [] + for step in steps: + if step is None: + continue + run_body = step.get('run') + if run_body is None: + continue + run_block_count += 1 + step_name = step.get('name', '') + expressions = find_inline_expressions(str(run_body)) + for expr, is_flagged_expr in expressions: + total_expressions += 1 + if is_flagged_expr: + flagged.append((job_id, step_name, expr)) + + return len(in_scope_jobs), run_block_count, total_expressions, flagged, in_scope_jobs + + +def run_self_test(): + """ + Extended negative fixture: proves the detector fires on all required cases + and does NOT fire on safe cases (TD-VSDD-057 false-green prevention). + + Assertions: + 1. Flags an in-scope github.event.* inline in a run: body. + 2. Does NOT flag env:/with:/if: sites (only run: bodies are checked). + 3. Does NOT flag allowlisted values (github.sha). + 4. Does NOT flag matrix.* / runner.* values. + 5. (NEW C-2) Flags a secrets-using job that would have been OMITTED by the + old hardcoded job-name list — proving scope is structural. + 6. (NEW H-1) Flags a needs.*.outputs.* laundered value inline in a run: body. + 7. (M-PASS2-01) Empty/no-jobs document returns 5-tuple (no ValueError) and + zero in-scope jobs — verifying the fail-closed path is clean. + """ + fixture_yaml = """ +permissions: + contents: read +jobs: + stable-sign: + environment: release + steps: + - name: Violating step with github.event injection risk + run: | + TAG="${{ github.event.pull_request.title }}" + echo "tag=$TAG" + - name: Safe step with allowlisted value + run: | + echo "sha=${{ github.sha }}" + - name: "Safe step: env-bound value does not count as run: injection" + env: + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + run: | + TAG="$HEAD_BRANCH" + - name: "Safe step: matrix.* is exempt" + run: | + echo "target=${{ matrix.target }}" + - name: "Safe step: runner.* is exempt" + run: | + echo "os=${{ runner.os }}" + + # NEW: a job that uses secrets but is NOT in the hardcoded list. + # Old guard (SCOPED_JOBS_BY_FILE) would miss this entirely. + # Structural detection MUST catch it via criterion (a). + new-secrets-job: + env: + MY_SECRET: ${{ secrets.SOME_SECRET }} + steps: + - name: Violating step in previously-missed job + run: | + TAG="${{ needs.some-job.outputs.value }}" + echo "tag=$TAG" + + # NEW: a job with needs.*.outputs.* laundered value inline — H-1. + stable-homebrew: + permissions: + contents: write + steps: + - name: Violating step with laundered needs output + run: | + TAG="${{ needs.stable-sign.outputs.tag }}" + echo "tag=$TAG" + - name: Safe step with allowlisted github.repository + run: | + echo "repo=${{ github.repository }}" +""" + print("=== NEGATIVE FIXTURE SELF-TEST ===") + print("Fixture contains:") + print(" [1] run: body with ${{ github.event.pull_request.title }} [SHOULD FAIL]") + print(" [2] run: body with ${{ github.sha }} [allowlisted, SHOULD PASS]") + print(" [3] env: key with ${{ github.event.workflow_run.head_branch }} [env: not run:, SHOULD PASS]") + print(" [4] run: body with ${{ matrix.target }} [matrix.*, SHOULD PASS]") + print(" [5] run: body with ${{ runner.os }} [runner.*, SHOULD PASS]") + print(" [6] new-secrets-job (not in old hardcoded list) run: with ${{ needs.*.outputs.* }} [SHOULD FAIL — C-2 structural scope]") + print(" [7] stable-homebrew contents:write run: with ${{ needs.stable-sign.outputs.tag }} [SHOULD FAIL — H-1 laundered output]") + print(" [8] stable-homebrew run: with ${{ github.repository }} [allowlisted, SHOULD PASS]") + print() + + doc = yaml.safe_load(fixture_yaml) + in_scope_count, rb, te, flagged, in_scope_jobs = scan_workflow_doc(doc, '') + + print(f"Structural scope detection found {in_scope_count} in-scope job(s):") + for jid, reason in in_scope_jobs: + print(f" - {jid}: {reason}") + print(f"Scanned {rb} run-block(s), {te} total ${{{{}}}} expression(s) in run: bodies") + print() + + # Assertion A: exactly 3 flagged expressions + # [1] github.event.pull_request.title (in stable-sign) + # [6] needs.some-job.outputs.value (in new-secrets-job — C-2 structural scope) + # [7] needs.stable-sign.outputs.tag (in stable-homebrew — H-1 laundered) + expected_flagged = 3 + if len(flagged) != expected_flagged: + print(f"FAIL: expected {expected_flagged} flagged expression(s), got {len(flagged)}") + if not flagged: + print(" CRITICAL: detector did NOT flag any violations — guard is a no-op!") + else: + for job_id, step_name, expr in flagged: + print(f" [FLAGGED] job={job_id} step='{step_name}': ${{{{ {expr} }}}}") + sys.exit(1) + + # Assertion B: all three expected expressions were caught + flagged_exprs = [expr for _, _, expr in flagged] + checks = [ + ('event.pull_request.title', "github.event.* inline in stable-sign"), + ('needs.some-job.outputs.value', "needs.*.outputs.* in new-secrets-job (C-2: structural scope catches previously-missed job)"), + ('needs.stable-sign.outputs.tag', "needs.stable-sign.outputs.tag in stable-homebrew (H-1: laundered output)"), + ] + all_ok = True + for needle, description in checks: + found = any(needle in expr for expr in flagged_exprs) + status = "PASS" if found else "FAIL" + print(f" [{status}] {description}") + if not found: + all_ok = False + + # Assertion C: in-scope count covers new-secrets-job (structural, not hardcoded) + in_scope_ids = {jid for jid, _ in in_scope_jobs} + if 'new-secrets-job' not in in_scope_ids: + print(" [FAIL] new-secrets-job was NOT classified as in-scope (C-2 structural scope broken)") + all_ok = False + else: + print(" [PASS] new-secrets-job classified in-scope via structural secrets detection (C-2)") + + if not all_ok: + sys.exit(1) + + # Assertion D: empty / no-jobs document — fail-closed path (M-PASS2-01). + # scan_workflow_doc must return a valid 5-tuple (no ValueError) with zero + # in-scope jobs when given an empty doc or a doc lacking top-level `jobs:`. + print() + print("=== ASSERTION D: empty/no-jobs fail-closed path (M-PASS2-01) ===") + empty_doc_cases = [ + ('null YAML (empty file)', None), + ('doc with no jobs key', {'on': 'push', 'name': 'test'}), + ('doc with empty jobs mapping', {'jobs': {}}), + ] + d_ok = True + for case_label, test_doc in empty_doc_cases: + try: + result = scan_workflow_doc(test_doc, '') + if len(result) != 5: + print(f" [FAIL] {case_label}: returned {len(result)}-tuple, expected 5 (M-PASS2-01)") + d_ok = False + continue + in_scope_c, _, _, _, in_scope_j = result + if in_scope_c != 0 or in_scope_j != []: + print(f" [FAIL] {case_label}: expected 0 in-scope jobs, got {in_scope_c}") + d_ok = False + else: + print(f" [PASS] {case_label}: 5-tuple returned, 0 in-scope jobs, no crash") + except Exception as exc: + print(f" [FAIL] {case_label}: raised {type(exc).__name__}: {exc}") + d_ok = False + + if not d_ok: + print("FAIL: empty/no-jobs fail-closed path is broken (M-PASS2-01)") + sys.exit(1) + + print() + print(f"PASS: detector correctly flagged {len(flagged)} violation(s), " + f"did NOT flag allowlisted/env-bound/matrix/runner values.") + print("PASS: empty/no-jobs fail-closed path returns clean 5-tuple (M-PASS2-01).") + sys.exit(0) + + +def main(): + args = sys.argv[1:] + + if '--self-test' in args: + run_self_test() + return # run_self_test exits directly + + # Expect exactly 2 positional file arguments + files = [a for a in args if not a.startswith('--')] + if len(files) < 2: + print("Usage: check-signing-workflow-injection.sh [sign-and-publish.yml] [backfill-release.yml]", + file=sys.stderr) + sys.exit(2) + + sign_workflow, backfill_workflow = files[0], files[1] + + total_run_blocks = 0 + total_expressions = 0 + all_flagged = [] + workflow_files = [sign_workflow, backfill_workflow] + + for filepath in workflow_files: + fname = os.path.basename(filepath) + try: + with open(filepath, 'r') as f: + raw = f.read() + except OSError as e: + print(f"ERROR: Cannot read {filepath}: {e}", file=sys.stderr) + sys.exit(2) + + try: + doc = yaml.safe_load(raw) + except yaml.YAMLError as e: + print(f"ERROR: YAML parse error in {filepath}: {e}", file=sys.stderr) + sys.exit(2) + + in_scope_count, rb, te, flagged, in_scope_jobs = scan_workflow_doc(doc, fname) + + # Fail-closed: zero in-scope jobs is a sentinel for broken detection + if in_scope_count == 0: + print(f"ERROR: {fname}: structural scope detection found ZERO in-scope jobs.", + file=sys.stderr) + print(f" This is a sentinel for broken detection (e.g. renamed jobs, empty workflow).", + file=sys.stderr) + print(f" Each workflow that handles secrets/signing MUST have at least one in-scope job.", + file=sys.stderr) + sys.exit(2) + + total_run_blocks += rb + total_expressions += te + for job_id, step_name, expr in flagged: + all_flagged.append((fname, job_id, step_name, expr)) + + scope_summary = ', '.join(f"{jid}({reason})" for jid, reason in in_scope_jobs) + print(f" {fname}: {in_scope_count} in-scope job(s), {rb} run-blocks, " + f"{te} ${{{{}}}} expressions") + print(f" In-scope: {scope_summary}") + + print() + print(f"Summary: scanned {total_run_blocks} run-blocks across {len(workflow_files)} files, " + f"{total_expressions} total ${{{{}}}} expressions scanned, " + f"{len(all_flagged)} inline high-risk expansion(s) flagged") + + if all_flagged: + print() + print("FAILURE: inline high-risk context expansions found in run: script bodies:") + for fname, job_id, step_name, expr in all_flagged: + print(f" [{fname}] job={job_id}, step='{step_name}': ${{{{ {expr} }}}}") + print() + print("FIX: bind the value via step env: and reference as a quoted shell variable.") + print(" Example:") + print(" env:") + print(" HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}") + print(" run: |") + print(' TAG="$HEAD_BRANCH"') + print(" See docs/specs/fork-friendly-release-ops.md § 'No inline context data'") + sys.exit(1) + + print("PASS: no inline high-risk expansions found in run: bodies of in-scope jobs.") + sys.exit(0) + + +if __name__ == '__main__': + main() +PYEOF +} + +if [ "$SELF_TEST_MODE" = "true" ]; then + run_python_guard --self-test +else + echo "check-signing-workflow-injection: scanning signing workflow files..." + run_python_guard "$SIGN_WORKFLOW" "$BACKFILL_WORKFLOW" +fi diff --git a/scripts/create-app.sh b/scripts/create-app.sh new file mode 100755 index 00000000..af8b3b8a --- /dev/null +++ b/scripts/create-app.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -euo pipefail + +# create-app.sh - Create a macOS .app bundle +# Usage: ./scripts/create-app.sh + +BINARY_PATH="${1:?Usage: create-app.sh }" +VERSION="${2:?Version required}" +OUTPUT_DIR="${3:?Output directory required}" + +if [ ! -f "$BINARY_PATH" ]; then + echo "Error: binary not found: $BINARY_PATH" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +APP_BUNDLE="${OUTPUT_DIR}/Wirerust.app" + +rm -rf "$APP_BUNDLE" +mkdir -p "$APP_BUNDLE/Contents/MacOS" +mkdir -p "$APP_BUNDLE/Contents/Resources" + +cp "$BINARY_PATH" "$APP_BUNDLE/Contents/MacOS/wirerust" +chmod +x "$APP_BUNDLE/Contents/MacOS/wirerust" + +sed "s/VERSION_PLACEHOLDER/$VERSION/g" "$PROJECT_ROOT/packaging/Info.plist" \ + > "$APP_BUNDLE/Contents/Info.plist" + +echo "APPL????" > "$APP_BUNDLE/Contents/PkgInfo" + +echo "Created app bundle: $APP_BUNDLE" diff --git a/scripts/create-dmg.sh b/scripts/create-dmg.sh new file mode 100755 index 00000000..29e32360 --- /dev/null +++ b/scripts/create-dmg.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -euo pipefail + +# create-dmg.sh - Create a macOS .dmg disk image +# Usage: ./scripts/create-dmg.sh + +APP_PATH="${1:?Usage: create-dmg.sh }" +VERSION="${2:?Version required}" +OUTPUT_PATH="${3:?Output path required}" + +if [ ! -d "$APP_PATH" ]; then + echo "Error: app bundle not found: $APP_PATH" >&2 + exit 1 +fi + +STAGING_DIR=$(mktemp -d) +trap 'rm -rf "$STAGING_DIR"' EXIT + +cp -R "$APP_PATH" "$STAGING_DIR/" +ln -s /Applications "$STAGING_DIR/Applications" + +rm -f "$OUTPUT_PATH" +hdiutil create -volname "Wirerust $VERSION" \ + -srcfolder "$STAGING_DIR" \ + -ov -format UDZO \ + "$OUTPUT_PATH" + +echo "Created dmg: $OUTPUT_PATH" diff --git a/scripts/create-pkg.sh b/scripts/create-pkg.sh new file mode 100755 index 00000000..73bd42b3 --- /dev/null +++ b/scripts/create-pkg.sh @@ -0,0 +1,31 @@ +#!/bin/bash +set -euo pipefail + +# create-pkg.sh - Create a signed macOS pkg installer +# Usage: ./scripts/create-pkg.sh + +BINARY_PATH="${1:?Usage: create-pkg.sh }" +VERSION="${2:?Version required}" +SIGNING_IDENTITY="${3:?Signing identity required}" +OUTPUT_PATH="${4:?Output path required}" + +if [ ! -f "$BINARY_PATH" ]; then + echo "Error: binary not found: $BINARY_PATH" >&2 + exit 1 +fi + +STAGING_DIR=$(mktemp -d) +trap 'rm -rf "$STAGING_DIR"' EXIT + +mkdir -p "$STAGING_DIR/usr/local/bin" +cp "$BINARY_PATH" "$STAGING_DIR/usr/local/bin/wirerust" +chmod +x "$STAGING_DIR/usr/local/bin/wirerust" + +pkgbuild --root "$STAGING_DIR" \ + --identifier com.arcavenae.wirerust \ + --version "$VERSION" \ + --install-location / \ + --sign "$SIGNING_IDENTITY" \ + "$OUTPUT_PATH" + +echo "Created pkg: $OUTPUT_PATH" From 9c38eb24a2d5cb42f65d62e67f3a17992b31800c Mon Sep 17 00:00:00 2001 From: Michael Pursifull Date: Wed, 15 Jul 2026 18:38:06 -0500 Subject: [PATCH 2/7] ci: pass toolchain explicitly to SHA-pinned rust-toolchain action The SHA-pinned dtolnay/rust-toolchain ref cannot infer the toolchain from the ref name and defaulted to rustc 1.85.0; wirerust requires 1.91. jira-cli masks this via rust-toolchain.toml (channel = stable), which wirerust does not carry. Applies to sign-and-publish alpha-build and backfill-release build. Refs: run 29458958576 --- .github/workflows/backfill-release.yml | 5 +++++ .github/workflows/sign-and-publish.yml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/backfill-release.yml b/.github/workflows/backfill-release.yml index e6932179..03504175 100644 --- a/.github/workflows/backfill-release.yml +++ b/.github/workflows/backfill-release.yml @@ -64,6 +64,11 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@c93f4f9c67595668add93d3d6895795ce52d8c2d # stable with: + # With a SHA-pinned action ref, dtolnay/rust-toolchain cannot infer + # the toolchain from the ref name — pass it explicitly. (jira-cli + # gets away without this because its rust-toolchain.toml pins the + # channel; wirerust has no rust-toolchain.toml.) + toolchain: stable targets: ${{ matrix.target }} # rust-toolchain.toml pins channel = "stable", which overrides the diff --git a/.github/workflows/sign-and-publish.yml b/.github/workflows/sign-and-publish.yml index 8ab35320..0dd998ae 100644 --- a/.github/workflows/sign-and-publish.yml +++ b/.github/workflows/sign-and-publish.yml @@ -52,6 +52,11 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@c93f4f9c67595668add93d3d6895795ce52d8c2d # stable with: + # With a SHA-pinned action ref, dtolnay/rust-toolchain cannot infer + # the toolchain from the ref name — pass it explicitly. (jira-cli + # gets away without this because its rust-toolchain.toml pins the + # channel; wirerust has no rust-toolchain.toml.) + toolchain: stable targets: ${{ matrix.target }} # rust-toolchain.toml pins channel = "stable", which overrides the From 8532cadae528432f24bbb0404e5d5ea6e1395204 Mon Sep 17 00:00:00 2001 From: Michael Pursifull Date: Wed, 15 Jul 2026 18:40:27 -0500 Subject: [PATCH 3/7] ci: pin rust-toolchain action to master so the toolchain input applies The previous SHA is a snapshot of the action's 1.85.0 versioned branch, which hardcodes its toolchain and rejects the toolchain input ('Unexpected input(s) toolchain'). Pin master, which requires and honors an explicit toolchain spec. Refs: run 29459130005 --- .github/workflows/backfill-release.yml | 11 ++++++----- .github/workflows/sign-and-publish.yml | 11 ++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/.github/workflows/backfill-release.yml b/.github/workflows/backfill-release.yml index 03504175..8f92d7cc 100644 --- a/.github/workflows/backfill-release.yml +++ b/.github/workflows/backfill-release.yml @@ -62,12 +62,13 @@ jobs: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@c93f4f9c67595668add93d3d6895795ce52d8c2d # stable + # Pinned to master, which accepts an explicit `toolchain` input. The + # SHA-pinned versioned-branch snapshot used in jira-cli hardcodes its + # toolchain (1.85.0) and ignores the input; jira-cli gets away with it + # because its rust-toolchain.toml pins channel = stable, but wirerust + # carries no rust-toolchain.toml (requires rustc 1.91+). + uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master with: - # With a SHA-pinned action ref, dtolnay/rust-toolchain cannot infer - # the toolchain from the ref name — pass it explicitly. (jira-cli - # gets away without this because its rust-toolchain.toml pins the - # channel; wirerust has no rust-toolchain.toml.) toolchain: stable targets: ${{ matrix.target }} diff --git a/.github/workflows/sign-and-publish.yml b/.github/workflows/sign-and-publish.yml index 0dd998ae..0273afc0 100644 --- a/.github/workflows/sign-and-publish.yml +++ b/.github/workflows/sign-and-publish.yml @@ -50,12 +50,13 @@ jobs: persist-credentials: false - name: Install Rust - uses: dtolnay/rust-toolchain@c93f4f9c67595668add93d3d6895795ce52d8c2d # stable + # Pinned to master, which accepts an explicit `toolchain` input. The + # SHA-pinned versioned-branch snapshot used in jira-cli hardcodes its + # toolchain (1.85.0) and ignores the input; jira-cli gets away with it + # because its rust-toolchain.toml pins channel = stable, but wirerust + # carries no rust-toolchain.toml (requires rustc 1.91+). + uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master with: - # With a SHA-pinned action ref, dtolnay/rust-toolchain cannot infer - # the toolchain from the ref name — pass it explicitly. (jira-cli - # gets away without this because its rust-toolchain.toml pins the - # channel; wirerust has no rust-toolchain.toml.) toolchain: stable targets: ${{ matrix.target }} From bc83e896bd73a64457f1a20253bce10c46e23620 Mon Sep 17 00:00:00 2001 From: Michael Pursifull Date: Wed, 15 Jul 2026 19:01:04 -0500 Subject: [PATCH 4/7] chore(formula): shorten descriptions for brew desc-length headroom Homebrew's desc cop caps the description at 80 characters. The channel-suffix pattern makes long base descriptions a latent audit failure (jr-a sits at 79/80 today). Drop the redundant 'tool written in Rust' and use short channel suffixes so every variant stays under 55 characters. --- Formula/wirerust-a.rb | 2 +- Formula/wirerust-b.rb | 2 +- Formula/wirerust-d.rb | 2 +- Formula/wirerust-rc.rb | 2 +- Formula/wirerust.rb | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Formula/wirerust-a.rb b/Formula/wirerust-a.rb index 343b5310..82b8ae90 100644 --- a/Formula/wirerust-a.rb +++ b/Formula/wirerust-a.rb @@ -1,5 +1,5 @@ class WirerustA < Formula - desc "Fast PCAP forensics and network triage CLI tool written in Rust (alpha ch)" + desc "Fast PCAP forensics and network triage CLI (alpha)" homepage "https://github.com/REPO_PLACEHOLDER" version "VERSION_PLACEHOLDER" license "MIT" diff --git a/Formula/wirerust-b.rb b/Formula/wirerust-b.rb index 29d69e95..1f9d8004 100644 --- a/Formula/wirerust-b.rb +++ b/Formula/wirerust-b.rb @@ -1,5 +1,5 @@ class WirerustB < Formula - desc "Fast PCAP forensics and network triage CLI tool written in Rust (beta ch)" + desc "Fast PCAP forensics and network triage CLI (beta)" homepage "https://github.com/REPO_PLACEHOLDER" version "VERSION_PLACEHOLDER" license "MIT" diff --git a/Formula/wirerust-d.rb b/Formula/wirerust-d.rb index 9160bfff..9a702a4a 100644 --- a/Formula/wirerust-d.rb +++ b/Formula/wirerust-d.rb @@ -1,5 +1,5 @@ class WirerustD < Formula - desc "Fast PCAP forensics and network triage CLI tool written in Rust (dev ch)" + desc "Fast PCAP forensics and network triage CLI (dev)" homepage "https://github.com/REPO_PLACEHOLDER" version "VERSION_PLACEHOLDER" license "MIT" diff --git a/Formula/wirerust-rc.rb b/Formula/wirerust-rc.rb index 35a225ed..bcf4f218 100644 --- a/Formula/wirerust-rc.rb +++ b/Formula/wirerust-rc.rb @@ -1,5 +1,5 @@ class WirerustRc < Formula - desc "Fast PCAP forensics and network triage CLI tool written in Rust (rc ch)" + desc "Fast PCAP forensics and network triage CLI (rc)" homepage "https://github.com/REPO_PLACEHOLDER" version "VERSION_PLACEHOLDER" license "MIT" diff --git a/Formula/wirerust.rb b/Formula/wirerust.rb index d6a6f03d..c6cad006 100644 --- a/Formula/wirerust.rb +++ b/Formula/wirerust.rb @@ -1,5 +1,5 @@ class Wirerust < Formula - desc "Fast PCAP forensics and network triage CLI tool written in Rust" + desc "Fast PCAP forensics and network triage CLI" homepage "https://github.com/REPO_PLACEHOLDER" version "VERSION_PLACEHOLDER" license "MIT" From 0df9716d3da09eb310379942921cb63b1846a3d4 Mon Sep 17 00:00:00 2001 From: Michael Pursifull Date: Wed, 15 Jul 2026 19:14:16 -0500 Subject: [PATCH 5/7] docs(formula): carry Homebrew desc audit rules as template comments These templates are the copy source for the next repo's release ops; the comment travels with the copy so the 80-char desc cap (including channel suffix) is visible at authoring time. --- Formula/wirerust-a.rb | 2 ++ Formula/wirerust-b.rb | 2 ++ Formula/wirerust-d.rb | 2 ++ Formula/wirerust-rc.rb | 2 ++ Formula/wirerust.rb | 2 ++ 5 files changed, 10 insertions(+) diff --git a/Formula/wirerust-a.rb b/Formula/wirerust-a.rb index 82b8ae90..e6207de3 100644 --- a/Formula/wirerust-a.rb +++ b/Formula/wirerust-a.rb @@ -1,4 +1,6 @@ class WirerustA < Formula + # Homebrew desc audit: <= 80 chars (incl. any channel suffix), capitalized, + # no leading article, must not start with the formula name, no trailing period. desc "Fast PCAP forensics and network triage CLI (alpha)" homepage "https://github.com/REPO_PLACEHOLDER" version "VERSION_PLACEHOLDER" diff --git a/Formula/wirerust-b.rb b/Formula/wirerust-b.rb index 1f9d8004..810b3c3d 100644 --- a/Formula/wirerust-b.rb +++ b/Formula/wirerust-b.rb @@ -1,4 +1,6 @@ class WirerustB < Formula + # Homebrew desc audit: <= 80 chars (incl. any channel suffix), capitalized, + # no leading article, must not start with the formula name, no trailing period. desc "Fast PCAP forensics and network triage CLI (beta)" homepage "https://github.com/REPO_PLACEHOLDER" version "VERSION_PLACEHOLDER" diff --git a/Formula/wirerust-d.rb b/Formula/wirerust-d.rb index 9a702a4a..d81e539f 100644 --- a/Formula/wirerust-d.rb +++ b/Formula/wirerust-d.rb @@ -1,4 +1,6 @@ class WirerustD < Formula + # Homebrew desc audit: <= 80 chars (incl. any channel suffix), capitalized, + # no leading article, must not start with the formula name, no trailing period. desc "Fast PCAP forensics and network triage CLI (dev)" homepage "https://github.com/REPO_PLACEHOLDER" version "VERSION_PLACEHOLDER" diff --git a/Formula/wirerust-rc.rb b/Formula/wirerust-rc.rb index bcf4f218..edb8b968 100644 --- a/Formula/wirerust-rc.rb +++ b/Formula/wirerust-rc.rb @@ -1,4 +1,6 @@ class WirerustRc < Formula + # Homebrew desc audit: <= 80 chars (incl. any channel suffix), capitalized, + # no leading article, must not start with the formula name, no trailing period. desc "Fast PCAP forensics and network triage CLI (rc)" homepage "https://github.com/REPO_PLACEHOLDER" version "VERSION_PLACEHOLDER" diff --git a/Formula/wirerust.rb b/Formula/wirerust.rb index c6cad006..5ffe7844 100644 --- a/Formula/wirerust.rb +++ b/Formula/wirerust.rb @@ -1,4 +1,6 @@ class Wirerust < Formula + # Homebrew desc audit: <= 80 chars (incl. any channel suffix), capitalized, + # no leading article, must not start with the formula name, no trailing period. desc "Fast PCAP forensics and network triage CLI" homepage "https://github.com/REPO_PLACEHOLDER" version "VERSION_PLACEHOLDER" From 82acb9262ea1f4829abcadedd351a16a6b8275f9 Mon Sep 17 00:00:00 2001 From: Michael Pursifull Date: Wed, 15 Jul 2026 19:38:56 -0500 Subject: [PATCH 6/7] docs(ci): make local-workflows.txt header repo-neutral for upstreaming --- .github/local-workflows.txt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/local-workflows.txt b/.github/local-workflows.txt index 57ac8357..c5b99dc2 100644 --- a/.github/local-workflows.txt +++ b/.github/local-workflows.txt @@ -6,13 +6,14 @@ # manual resolution. One path per line, relative to repo root. Comments (#) # and blank lines are ignored. # -# These files carry the ArcavenAE fork's release-ops pipeline (Apple code -# signing, notarization, Homebrew tap publishing) ported from the -# fork-friendly release-ops design in ArcavenAE/jira-cli -# (docs/specs/fork-friendly-release-ops.md there). Upstream -# (Zious11/wirerust) does not carry these files today; if it ever adopts -# them in identical form, entries can be pruned. This file lists itself so -# the fork's list survives upstream template changes. +# These entries are insurance, not divergence: each file below carries the +# opt-in release-ops pipeline (Apple code signing, notarization, Homebrew +# tap publishing, fork sync) in a form offered upstream verbatim, following +# the fork-friendly release-ops design from the jira-cli project +# (docs/specs/fork-friendly-release-ops.md there). While the two copies are +# identical, no conflict can occur; entries can be pruned as upstream +# adopts them. This file lists itself so the fork's list survives upstream +# template changes. .github/local-workflows.txt .github/workflows/sign-and-publish.yml .github/workflows/sync-upstream.yml From 3643b0a93494117e9d07feb748a0f58a4e0c73f2 Mon Sep 17 00:00:00 2001 From: Michael Pursifull Date: Tue, 8 Sep 2026 17:51:56 -0500 Subject: [PATCH 7/7] ci: address review on #407 (OS guard, unknown channel, guard scope, dead refs, dedup) Blocking items from the 2026-09-06 review, plus the duplication item and the four minors. 1. Homebrew formulae had no OS guard. All five now declare `depends_on :macos`. The release pipeline builds only darwin-arm64 and darwin-amd64, so on Linux the Hardware::CPU.arm? else-branch downloaded a Mach-O that could not run. Also removes the stable formula's `version` line. Its tag is v, so brew scans the same value from the URL and `brew audit --strict` rejects the duplicate. The four prerelease templates keep theirs; their tags do not scan to the declared value. Verified by rendering all five with realistic tags in a scratch tap: `brew audit --strict` and `brew style` clean on each. 2. Unknown-channel tags no longer advertise a `brew install` line. The default case arm now leaves FORMULA empty, so the install line stays "download below". The release title falls back to the bare project name rather than to a formula that was never updated. 3. Injection-guard scope gap. `sync-upstream.yml`'s conflict report now binds `steps.merge.outputs.files` through `env:`, and every other context in that workflow's run bodies is bound too. The guard self-discovers: it scans every workflow in .github/workflows/ on its existing structural criteria, with sign-and-publish.yml and backfill-release.yml kept as required files whose zero-in-scope result is still the broken-detection sentinel. Discovery finds sync-upstream.yml, ci.yml and release.yml. Negative control: reintroducing the inline expression makes the guard fail and name the exact step. 4. Dead spec references. All seven now point at a commit-pinned permalink to the external design doc rather than a path that does not exist here. 5. Deduplication. The signing logic was byte-identical between stable-sign and the backfill sign job, and differed from alpha-sign only by the binary prefix; the tap update was near-identical across three jobs. Both are now scripts: scripts/sign-and-notarize.sh and scripts/update-homebrew-formula.sh, with `set -euo pipefail` in both, which is the drift the review named. The signing script takes subcommands rather than running end to end, so each phase stays its own workflow step and its secrets stay scoped to that step. A single entry point would put the notarization credentials in scope for signing and the signing identity in scope for notarization. Verified: the step sequence is unchanged in both workflows, and per-step env keys are identical before and after apart from one added COMMIT_SHA. Minors: release notes are built with printf so continuation lines carry no YAML indentation and stop rendering as a code block; sync-upstream.yml gains its trailing newline; the rust-toolchain pin moves to the SHA open PR #451 uses, so whichever lands first the other needs no reconciliation. Not done: flipping harden-runner to `egress-policy: block`. Block mode needs a per-repo allowlist, and the base repo has run these jobs in audit mode with no detections history to build one from, so turning it on blind is how the signing jobs start failing on an unrelated day. Happy to do it as a follow-up once there is a run history to derive the allowlist from. Claude-Session: https://claude.ai/code/session_01YV58wMepvhR6qNYZhwPJkV --- .github/local-workflows.txt | 11 +- .github/workflows/backfill-release.yml | 146 +-------- .github/workflows/sign-and-publish.yml | 344 ++++---------------- .github/workflows/signing-guard.yml | 4 +- .github/workflows/sync-upstream.yml | 50 ++- Formula/wirerust-a.rb | 7 + Formula/wirerust-b.rb | 7 + Formula/wirerust-d.rb | 7 + Formula/wirerust-rc.rb | 7 + Formula/wirerust.rb | 13 +- scripts/check-signing-workflow-injection.sh | 105 ++++-- scripts/sign-and-notarize.sh | 191 +++++++++++ scripts/update-homebrew-formula.sh | 75 +++++ 13 files changed, 506 insertions(+), 461 deletions(-) create mode 100755 scripts/sign-and-notarize.sh create mode 100755 scripts/update-homebrew-formula.sh diff --git a/.github/local-workflows.txt b/.github/local-workflows.txt index c5b99dc2..9c0cb571 100644 --- a/.github/local-workflows.txt +++ b/.github/local-workflows.txt @@ -9,10 +9,11 @@ # These entries are insurance, not divergence: each file below carries the # opt-in release-ops pipeline (Apple code signing, notarization, Homebrew # tap publishing, fork sync) in a form offered upstream verbatim, following -# the fork-friendly release-ops design from the jira-cli project -# (docs/specs/fork-friendly-release-ops.md there). While the two copies are -# identical, no conflict can occur; entries can be pruned as upstream -# adopts them. This file lists itself so the fork's list survives upstream +# the fork-friendly release-ops design from the jira-cli project, +# documented at (commit-pinned) +# https://github.com/ArcavenAE/jira-cli/blob/f85647bdef1bf77f85ce1440dcfd9b9dd0413093/docs/specs/fork-friendly-release-ops.md +# While the two copies are identical, no conflict can occur; entries can be +# pruned as upstream adopts them. This file lists itself so the fork's list survives upstream # template changes. .github/local-workflows.txt .github/workflows/sign-and-publish.yml @@ -29,3 +30,5 @@ scripts/create-app.sh scripts/create-dmg.sh scripts/create-pkg.sh scripts/check-signing-workflow-injection.sh +scripts/sign-and-notarize.sh +scripts/update-homebrew-formula.sh diff --git a/.github/workflows/backfill-release.yml b/.github/workflows/backfill-release.yml index 8f92d7cc..93ddd7ed 100644 --- a/.github/workflows/backfill-release.yml +++ b/.github/workflows/backfill-release.yml @@ -7,7 +7,8 @@ # OPT-IN signing: the sign and homebrew jobs are gated on repository # variables (SIGNING_ENABLED, HOMEBREW_TAP_REPO) and skip cleanly when # unset. Without them this workflow still backfills unsigned releases. -# See docs/specs/fork-friendly-release-ops.md. +# External design doc (commit-pinned): +# https://github.com/ArcavenAE/jira-cli/blob/f85647bdef1bf77f85ce1440dcfd9b9dd0413093/docs/specs/fork-friendly-release-ops.md name: Backfill Release on: @@ -67,7 +68,7 @@ jobs: # toolchain (1.85.0) and ignores the input; jira-cli gets away with it # because its rust-toolchain.toml pins channel = stable, but wirerust # carries no rust-toolchain.toml (requires rustc 1.91+). - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master + uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master, 2026-07-16; matches open PR #451 with: toolchain: stable targets: ${{ matrix.target }} @@ -245,131 +246,32 @@ jobs: APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_INSTALLER_CERTIFICATE_P12: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_P12 }} APPLE_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_PASSWORD }} - run: | - security create-keychain -p "" build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p "" build.keychain - - echo "$APPLE_CERTIFICATE_P12" | base64 --decode > cert.p12 - security import cert.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign - rm cert.p12 - - echo "$APPLE_INSTALLER_CERTIFICATE_P12" | base64 --decode > installer-cert.p12 - security import installer-cert.p12 -k build.keychain -P "$APPLE_INSTALLER_CERTIFICATE_PASSWORD" -T /usr/bin/pkgbuild -T /usr/bin/productbuild -T /usr/bin/productsign - rm installer-cert.p12 - - curl -sfo /tmp/DeveloperIDG2CA.cer https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer - security add-certificates -k build.keychain /tmp/DeveloperIDG2CA.cer - rm /tmp/DeveloperIDG2CA.cer - - security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain + run: scripts/sign-and-notarize.sh import-certs - name: Sign binaries env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - run: | - codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp wirerust-darwin-arm64 - codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp wirerust-darwin-amd64 - codesign --verify --deep --strict wirerust-darwin-arm64 - codesign --verify --deep --strict wirerust-darwin-amd64 + run: scripts/sign-and-notarize.sh sign-binaries wirerust - name: Build packaging artifacts env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY }} RELEASE_VERSION: ${{ steps.meta.outputs.version }} - run: | - VERSION="$RELEASE_VERSION" - chmod +x scripts/create-app.sh scripts/create-dmg.sh scripts/create-pkg.sh - - for arch in arm64 amd64; do - ./scripts/create-app.sh "wirerust-darwin-${arch}" "$VERSION" . - codesign --force --deep --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp Wirerust.app - ./scripts/create-dmg.sh Wirerust.app "$VERSION" "wirerust-${arch}.dmg" - # Sign the DMG container itself. Required for stapler to attach a - # Gatekeeper-recognized notarization ticket, and routes the - # notarytool submission through Apple's fast path (signed-image - # validation) instead of the slow "discovery" path that hangs at - # pre-submission under burst load. - codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "wirerust-${arch}.dmg" - ./scripts/create-pkg.sh "wirerust-darwin-${arch}" "$VERSION" "$APPLE_INSTALLER_IDENTITY" "wirerust-${arch}.pkg" - rm -rf Wirerust.app - done + run: scripts/sign-and-notarize.sh package wirerust "$RELEASE_VERSION" - name: Notarize env: APPLE_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_NOTARIZATION_APPLE_ID }} APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} APPLE_NOTARIZATION_TEAM_ID: ${{ secrets.APPLE_NOTARIZATION_TEAM_ID }} - run: | - for ARTIFACT in wirerust-arm64.pkg wirerust-amd64.pkg wirerust-arm64.dmg wirerust-amd64.dmg; do - echo "Notarizing $ARTIFACT..." - xcrun notarytool submit "$ARTIFACT" \ - --apple-id "$APPLE_NOTARIZATION_APPLE_ID" \ - --password "$APPLE_NOTARIZATION_PASSWORD" \ - --team-id "$APPLE_NOTARIZATION_TEAM_ID" \ - --wait --timeout 14400 - xcrun stapler staple "$ARTIFACT" - done + run: scripts/sign-and-notarize.sh notarize wirerust - name: Verify signatures (Gatekeeper + codesign) - run: | - set -eo pipefail - CS_OUT=$(mktemp) - SPCTL_OUT=$(mktemp) - trap 'rm -f "$CS_OUT" "$SPCTL_OUT"' EXIT - # Bare Mach-O binaries: stapler can't attach to a bare binary - # (Apple TN3147), so `spctl --assess --type execute` would - # report "Unnotarized Developer ID". Verify the load-bearing - # properties directly via codesign: Developer ID Application - # identity, stable Team Identifier, and hardened runtime flag. - for BIN in wirerust-darwin-arm64 wirerust-darwin-amd64; do - echo "::group::Verify $BIN" - codesign -dvv "$BIN" 2>&1 | tee "$CS_OUT" - # GHA log-masks the leaf cert CN (it matches APPLE_SIGNING_IDENTITY - # secret), so `Authority=Developer ID Application: ...` becomes - # `Authority=***`. Anchor instead on the intermediate cert in the - # Developer ID chain, which is a public Apple CA name and never - # masked. Its presence proves the chain. - grep -q "^Authority=Developer ID Certification Authority$" "$CS_OUT" \ - || { echo "::error::$BIN missing Developer ID chain (intermediate CA)"; exit 1; } - # TeamIdentifier value matches APPLE_NOTARIZATION_TEAM_ID and is - # masked to "***" — accept either a real team-id format or the - # masked marker, but reject "not set" (the ad-hoc sentinel). - grep -qE "^TeamIdentifier=([A-Z0-9]{6,}|\*+)$" "$CS_OUT" \ - || { echo "::error::$BIN: TeamIdentifier missing, ad-hoc, or unexpected format"; exit 1; } - grep -qE "^CodeDirectory.*flags=0x[0-9a-f]+\(.*runtime.*\)" "$CS_OUT" \ - || { echo "::error::$BIN missing hardened runtime (--options runtime) flag"; exit 1; } - echo "::endgroup::" - done - # Stapled containers: spctl --assess returns "accepted - # source=Notarized Developer ID" when signed + notarized + - # stapled. .pkg → --type install; .dmg → --type open. - for PKG in wirerust-arm64.pkg wirerust-amd64.pkg; do - echo "::group::Verify $PKG" - spctl --assess --type install --verbose=4 "$PKG" 2>&1 | tee "$SPCTL_OUT" - grep -q "source=Notarized Developer ID" "$SPCTL_OUT" \ - || { echo "::error::$PKG not notarized (spctl source unexpected)"; exit 1; } - echo "::endgroup::" - done - # `spctl --assess --type open` on a notarized .dmg returns - # "rejected: source=Insufficient Context" on macOS 15+ — Gatekeeper - # assesses the *mounted* contents, not the .dmg file. `stapler - # validate` is the canonical check: it verifies the notarization - # ticket is locally attached to the .dmg and references a valid - # Apple notarization record. Non-zero exit on missing/invalid - # staple fails the step. - for DMG in wirerust-arm64.dmg wirerust-amd64.dmg; do - echo "::group::Verify $DMG" - xcrun stapler validate "$DMG" - echo "::endgroup::" - done + run: scripts/sign-and-notarize.sh verify wirerust - name: Generate checksums - run: | - for f in wirerust-darwin-arm64 wirerust-darwin-amd64 wirerust-arm64.pkg wirerust-amd64.pkg wirerust-arm64.dmg wirerust-amd64.dmg; do - shasum -a 256 "$f" > "${f}.sha256" - done + run: scripts/sign-and-notarize.sh checksums wirerust - name: Upload signed artifacts to release env: @@ -428,31 +330,5 @@ jobs: RELEASE_VERSION: ${{ needs.sign.outputs.version }} RELEASE_TAG: ${{ inputs.tag }} run: | - VERSION="$RELEASE_VERSION" - TAG="$RELEASE_TAG" - - TAP_SHORT="${HOMEBREW_TAP_REPO#*/}" - TAP_NAME="${HOMEBREW_TAP_REPO%%/*}/${TAP_SHORT#homebrew-}" - - SHA256_ARM64=$(shasum -a 256 wirerust-darwin-arm64 | cut -d' ' -f1) - SHA256_AMD64=$(shasum -a 256 wirerust-darwin-amd64 | cut -d' ' -f1) - - git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${HOMEBREW_TAP_REPO}.git" homebrew-tap-repo - cd homebrew-tap-repo - - mkdir -p Formula - cp ../Formula/wirerust.rb Formula/wirerust.rb - - sed -i "s|REPO_PLACEHOLDER|${GITHUB_REPOSITORY}|g" Formula/wirerust.rb - sed -i "s|TAP_PLACEHOLDER|${TAP_NAME}|g" Formula/wirerust.rb - sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" Formula/wirerust.rb - sed -i "s/TAG_PLACEHOLDER/$TAG/g" Formula/wirerust.rb - sed -i "s/SHA256_ARM64_PLACEHOLDER/$SHA256_ARM64/g" Formula/wirerust.rb - sed -i "s/SHA256_AMD64_PLACEHOLDER/$SHA256_AMD64/g" Formula/wirerust.rb - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Formula/wirerust.rb - git diff --cached --quiet && echo "No changes to commit" && exit 0 - git commit -m "Update wirerust to $VERSION" - git push + scripts/update-homebrew-formula.sh \ + wirerust wirerust "$RELEASE_VERSION" "$RELEASE_TAG" diff --git a/.github/workflows/sign-and-publish.yml b/.github/workflows/sign-and-publish.yml index 0273afc0..c140bb69 100644 --- a/.github/workflows/sign-and-publish.yml +++ b/.github/workflows/sign-and-publish.yml @@ -10,8 +10,9 @@ # OPT-IN: every job is gated on repository variables. With SIGNING_ENABLED # unset (the default), this workflow is a no-op — no Apple Developer Program # setup, secrets, or tap repo are required to host it. Downstream forks that -# publish signed builds set the variables/secrets listed in -# docs/specs/fork-friendly-release-ops.md. +# publish signed builds set the variables/secrets listed in the external +# design doc (commit-pinned): +# https://github.com/ArcavenAE/jira-cli/blob/f85647bdef1bf77f85ce1440dcfd9b9dd0413093/docs/specs/fork-friendly-release-ops.md name: Sign & Publish on: @@ -55,7 +56,7 @@ jobs: # toolchain (1.85.0) and ignores the input; jira-cli gets away with it # because its rust-toolchain.toml pins channel = stable, but wirerust # carries no rust-toolchain.toml (requires rustc 1.91+). - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master + uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master, 2026-07-16; matches open PR #451 with: toolchain: stable targets: ${{ matrix.target }} @@ -113,7 +114,8 @@ jobs: GH_TOKEN: ${{ github.token }} run: | # Atomic alpha-tag reservation via GitHub API (no TOCTOU race). - # Spec: docs/specs/fork-friendly-release-ops.md § "Atomic alpha-tag creation" + # Spec § "Atomic alpha-tag creation (no TOCTOU)": + # https://github.com/ArcavenAE/jira-cli/blob/f85647bdef1bf77f85ce1440dcfd9b9dd0413093/docs/specs/fork-friendly-release-ops.md # Step 1: inputs bound from env: above (CWE-77 rule). # Step 2: compute seed hint (starting point only — correctness does NOT @@ -182,137 +184,39 @@ jobs: APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_INSTALLER_CERTIFICATE_P12: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_P12 }} APPLE_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_PASSWORD }} - run: | - security create-keychain -p "" build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p "" build.keychain - - echo "$APPLE_CERTIFICATE_P12" | base64 --decode > cert.p12 - security import cert.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign - rm cert.p12 - - echo "$APPLE_INSTALLER_CERTIFICATE_P12" | base64 --decode > installer-cert.p12 - security import installer-cert.p12 -k build.keychain -P "$APPLE_INSTALLER_CERTIFICATE_PASSWORD" -T /usr/bin/pkgbuild -T /usr/bin/productbuild -T /usr/bin/productsign - rm installer-cert.p12 - - curl -sfo /tmp/DeveloperIDG2CA.cer https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer - security add-certificates -k build.keychain /tmp/DeveloperIDG2CA.cer - rm /tmp/DeveloperIDG2CA.cer - - security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain + run: scripts/sign-and-notarize.sh import-certs - name: Sign binaries env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - run: | - codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp wirerust-a-darwin-arm64 - codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp wirerust-a-darwin-amd64 - codesign --verify --deep --strict wirerust-a-darwin-arm64 - codesign --verify --deep --strict wirerust-a-darwin-amd64 + run: scripts/sign-and-notarize.sh sign-binaries wirerust-a - name: Build packaging artifacts env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY }} RELEASE_VERSION: ${{ steps.meta.outputs.version }} - run: | - VERSION="$RELEASE_VERSION" - chmod +x scripts/create-app.sh scripts/create-dmg.sh scripts/create-pkg.sh - - for arch in arm64 amd64; do - ./scripts/create-app.sh "wirerust-a-darwin-${arch}" "$VERSION" . - codesign --force --deep --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp Wirerust.app - ./scripts/create-dmg.sh Wirerust.app "$VERSION" "wirerust-a-${arch}.dmg" - # Sign the DMG container itself. Required for stapler to attach a - # Gatekeeper-recognized notarization ticket, and routes the - # notarytool submission through Apple's fast path (signed-image - # validation) instead of the slow "discovery" path that hangs at - # pre-submission under burst load (observed in run 27797831466). - codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "wirerust-a-${arch}.dmg" - ./scripts/create-pkg.sh "wirerust-a-darwin-${arch}" "$VERSION" "$APPLE_INSTALLER_IDENTITY" "wirerust-a-${arch}.pkg" - rm -rf Wirerust.app - done + run: scripts/sign-and-notarize.sh package wirerust-a "$RELEASE_VERSION" - name: Notarize env: APPLE_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_NOTARIZATION_APPLE_ID }} APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} APPLE_NOTARIZATION_TEAM_ID: ${{ secrets.APPLE_NOTARIZATION_TEAM_ID }} - run: | - for ARTIFACT in wirerust-a-arm64.pkg wirerust-a-amd64.pkg wirerust-a-arm64.dmg wirerust-a-amd64.dmg; do - echo "Notarizing $ARTIFACT..." - xcrun notarytool submit "$ARTIFACT" \ - --apple-id "$APPLE_NOTARIZATION_APPLE_ID" \ - --password "$APPLE_NOTARIZATION_PASSWORD" \ - --team-id "$APPLE_NOTARIZATION_TEAM_ID" \ - --wait --timeout 14400 - xcrun stapler staple "$ARTIFACT" - done + run: scripts/sign-and-notarize.sh notarize wirerust-a - name: Verify signatures (Gatekeeper + codesign) - run: | - set -eo pipefail - CS_OUT=$(mktemp) - SPCTL_OUT=$(mktemp) - trap 'rm -f "$CS_OUT" "$SPCTL_OUT"' EXIT - # Bare Mach-O binaries: stapler can't attach to a bare binary - # (Apple TN3147), so `spctl --assess --type execute` would - # report "Unnotarized Developer ID". Verify the load-bearing - # properties directly via codesign: Developer ID Application - # identity, stable Team Identifier, and hardened runtime flag. - for BIN in wirerust-a-darwin-arm64 wirerust-a-darwin-amd64; do - echo "::group::Verify $BIN" - codesign -dvv "$BIN" 2>&1 | tee "$CS_OUT" - # GHA log-masks the leaf cert CN (it matches APPLE_SIGNING_IDENTITY - # secret), so `Authority=Developer ID Application: ...` becomes - # `Authority=***`. Anchor instead on the intermediate cert in the - # Developer ID chain, which is a public Apple CA name and never - # masked. Its presence proves the chain. - grep -q "^Authority=Developer ID Certification Authority$" "$CS_OUT" \ - || { echo "::error::$BIN missing Developer ID chain (intermediate CA)"; exit 1; } - # TeamIdentifier value matches APPLE_NOTARIZATION_TEAM_ID and is - # masked to "***" — accept either a real team-id format or the - # masked marker, but reject "not set" (the ad-hoc sentinel). - grep -qE "^TeamIdentifier=([A-Z0-9]{6,}|\*+)$" "$CS_OUT" \ - || { echo "::error::$BIN: TeamIdentifier missing, ad-hoc, or unexpected format"; exit 1; } - grep -qE "^CodeDirectory.*flags=0x[0-9a-f]+\(.*runtime.*\)" "$CS_OUT" \ - || { echo "::error::$BIN missing hardened runtime (--options runtime) flag"; exit 1; } - echo "::endgroup::" - done - # Stapled containers: spctl --assess returns "accepted - # source=Notarized Developer ID" when signed + notarized + - # stapled. .pkg → --type install; .dmg → --type open. - for PKG in wirerust-a-arm64.pkg wirerust-a-amd64.pkg; do - echo "::group::Verify $PKG" - spctl --assess --type install --verbose=4 "$PKG" 2>&1 | tee "$SPCTL_OUT" - grep -q "source=Notarized Developer ID" "$SPCTL_OUT" \ - || { echo "::error::$PKG not notarized (spctl source unexpected)"; exit 1; } - echo "::endgroup::" - done - # `spctl --assess --type open` on a notarized .dmg returns - # "rejected: source=Insufficient Context" on macOS 15+ — Gatekeeper - # assesses the *mounted* contents, not the .dmg file. `stapler - # validate` is the canonical check: it verifies the notarization - # ticket is locally attached to the .dmg and references a valid - # Apple notarization record. Non-zero exit on missing/invalid - # staple fails the step. - for DMG in wirerust-a-arm64.dmg wirerust-a-amd64.dmg; do - echo "::group::Verify $DMG" - xcrun stapler validate "$DMG" - echo "::endgroup::" - done + run: scripts/sign-and-notarize.sh verify wirerust-a - name: Generate checksums - run: | - for f in wirerust-a-darwin-arm64 wirerust-a-darwin-amd64 wirerust-a-arm64.pkg wirerust-a-amd64.pkg wirerust-a-arm64.dmg wirerust-a-amd64.dmg; do - shasum -a 256 "$f" > "${f}.sha256" - done + run: scripts/sign-and-notarize.sh checksums wirerust-a - name: Create alpha release env: GH_TOKEN: ${{ github.token }} HOMEBREW_TAP_REPO: ${{ vars.HOMEBREW_TAP_REPO }} RELEASE_TAG: ${{ steps.meta.outputs.tag }} + COMMIT_SHA: ${{ github.sha }} run: | TAG="$RELEASE_TAG" @@ -323,10 +227,14 @@ jobs: BINARY_LINE="\`brew install ${TAP_NAME}/wirerust-a\` or download below" fi - NOTES="**Channel:** alpha (develop branch) - **Binary:** ${BINARY_LINE} - **Commit:** ${{ github.sha }} - **Signed:** Apple Developer ID (notarized)" + # printf, not a multi-line quoted assignment: continuation lines + # inside a YAML block scalar carry the block's indentation into the + # value, and GitHub renders indented lines as a code block. + NOTES="$(printf '%s\n' \ + "**Channel:** alpha (develop branch)" \ + "**Binary:** ${BINARY_LINE}" \ + "**Commit:** ${COMMIT_SHA}" \ + "**Signed:** Apple Developer ID (notarized)")" gh release create "$TAG" \ --title "wirerust-a $TAG" \ @@ -377,34 +285,8 @@ jobs: RELEASE_VERSION: ${{ needs.alpha-sign.outputs.version }} RELEASE_TAG: ${{ needs.alpha-sign.outputs.tag }} run: | - VERSION="$RELEASE_VERSION" - TAG="$RELEASE_TAG" - - TAP_SHORT="${HOMEBREW_TAP_REPO#*/}" - TAP_NAME="${HOMEBREW_TAP_REPO%%/*}/${TAP_SHORT#homebrew-}" - - SHA256_ARM64=$(shasum -a 256 wirerust-a-darwin-arm64 | cut -d' ' -f1) - SHA256_AMD64=$(shasum -a 256 wirerust-a-darwin-amd64 | cut -d' ' -f1) - - git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${HOMEBREW_TAP_REPO}.git" homebrew-tap-repo - cd homebrew-tap-repo - - mkdir -p Formula - cp ../Formula/wirerust-a.rb Formula/wirerust-a.rb - - sed -i "s|REPO_PLACEHOLDER|${GITHUB_REPOSITORY}|g" Formula/wirerust-a.rb - sed -i "s|TAP_PLACEHOLDER|${TAP_NAME}|g" Formula/wirerust-a.rb - sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" Formula/wirerust-a.rb - sed -i "s/TAG_PLACEHOLDER/$TAG/g" Formula/wirerust-a.rb - sed -i "s/SHA256_ARM64_PLACEHOLDER/$SHA256_ARM64/g" Formula/wirerust-a.rb - sed -i "s/SHA256_AMD64_PLACEHOLDER/$SHA256_AMD64/g" Formula/wirerust-a.rb - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add Formula/wirerust-a.rb - git diff --cached --quiet && echo "No changes to commit" && exit 0 - git commit -m "Update wirerust-a (alpha) to $VERSION" - git push + scripts/update-homebrew-formula.sh \ + wirerust-a wirerust-a "$RELEASE_VERSION" "$RELEASE_TAG" alpha # --------------------------------------------------------------------------- # Stable channel — sign release binaries after Release workflow @@ -478,131 +360,32 @@ jobs: APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_INSTALLER_CERTIFICATE_P12: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_P12 }} APPLE_INSTALLER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_INSTALLER_CERTIFICATE_PASSWORD }} - run: | - security create-keychain -p "" build.keychain - security default-keychain -s build.keychain - security unlock-keychain -p "" build.keychain - - echo "$APPLE_CERTIFICATE_P12" | base64 --decode > cert.p12 - security import cert.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign - rm cert.p12 - - echo "$APPLE_INSTALLER_CERTIFICATE_P12" | base64 --decode > installer-cert.p12 - security import installer-cert.p12 -k build.keychain -P "$APPLE_INSTALLER_CERTIFICATE_PASSWORD" -T /usr/bin/pkgbuild -T /usr/bin/productbuild -T /usr/bin/productsign - rm installer-cert.p12 - - curl -sfo /tmp/DeveloperIDG2CA.cer https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer - security add-certificates -k build.keychain /tmp/DeveloperIDG2CA.cer - rm /tmp/DeveloperIDG2CA.cer - - security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain + run: scripts/sign-and-notarize.sh import-certs - name: Sign binaries env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - run: | - codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp wirerust-darwin-arm64 - codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp wirerust-darwin-amd64 - codesign --verify --deep --strict wirerust-darwin-arm64 - codesign --verify --deep --strict wirerust-darwin-amd64 + run: scripts/sign-and-notarize.sh sign-binaries wirerust - name: Build packaging artifacts env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} APPLE_INSTALLER_IDENTITY: ${{ secrets.APPLE_INSTALLER_IDENTITY }} RELEASE_VERSION: ${{ steps.meta.outputs.version }} - run: | - VERSION="$RELEASE_VERSION" - chmod +x scripts/create-app.sh scripts/create-dmg.sh scripts/create-pkg.sh - - for arch in arm64 amd64; do - ./scripts/create-app.sh "wirerust-darwin-${arch}" "$VERSION" . - codesign --force --deep --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp Wirerust.app - ./scripts/create-dmg.sh Wirerust.app "$VERSION" "wirerust-${arch}.dmg" - # Sign the DMG container itself. Required for stapler to attach a - # Gatekeeper-recognized notarization ticket, and routes the - # notarytool submission through Apple's fast path (signed-image - # validation) instead of the slow "discovery" path that hangs at - # pre-submission under burst load. - codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "wirerust-${arch}.dmg" - ./scripts/create-pkg.sh "wirerust-darwin-${arch}" "$VERSION" "$APPLE_INSTALLER_IDENTITY" "wirerust-${arch}.pkg" - rm -rf Wirerust.app - done + run: scripts/sign-and-notarize.sh package wirerust "$RELEASE_VERSION" - name: Notarize env: APPLE_NOTARIZATION_APPLE_ID: ${{ secrets.APPLE_NOTARIZATION_APPLE_ID }} APPLE_NOTARIZATION_PASSWORD: ${{ secrets.APPLE_NOTARIZATION_PASSWORD }} APPLE_NOTARIZATION_TEAM_ID: ${{ secrets.APPLE_NOTARIZATION_TEAM_ID }} - run: | - for ARTIFACT in wirerust-arm64.pkg wirerust-amd64.pkg wirerust-arm64.dmg wirerust-amd64.dmg; do - echo "Notarizing $ARTIFACT..." - xcrun notarytool submit "$ARTIFACT" \ - --apple-id "$APPLE_NOTARIZATION_APPLE_ID" \ - --password "$APPLE_NOTARIZATION_PASSWORD" \ - --team-id "$APPLE_NOTARIZATION_TEAM_ID" \ - --wait --timeout 14400 - xcrun stapler staple "$ARTIFACT" - done + run: scripts/sign-and-notarize.sh notarize wirerust - name: Verify signatures (Gatekeeper + codesign) - run: | - set -eo pipefail - CS_OUT=$(mktemp) - SPCTL_OUT=$(mktemp) - trap 'rm -f "$CS_OUT" "$SPCTL_OUT"' EXIT - # Bare Mach-O binaries: stapler can't attach to a bare binary - # (Apple TN3147), so `spctl --assess --type execute` would - # report "Unnotarized Developer ID". Verify the load-bearing - # properties directly via codesign: Developer ID Application - # identity, stable Team Identifier, and hardened runtime flag. - for BIN in wirerust-darwin-arm64 wirerust-darwin-amd64; do - echo "::group::Verify $BIN" - codesign -dvv "$BIN" 2>&1 | tee "$CS_OUT" - # GHA log-masks the leaf cert CN (it matches APPLE_SIGNING_IDENTITY - # secret), so `Authority=Developer ID Application: ...` becomes - # `Authority=***`. Anchor instead on the intermediate cert in the - # Developer ID chain, which is a public Apple CA name and never - # masked. Its presence proves the chain. - grep -q "^Authority=Developer ID Certification Authority$" "$CS_OUT" \ - || { echo "::error::$BIN missing Developer ID chain (intermediate CA)"; exit 1; } - # TeamIdentifier value matches APPLE_NOTARIZATION_TEAM_ID and is - # masked to "***" — accept either a real team-id format or the - # masked marker, but reject "not set" (the ad-hoc sentinel). - grep -qE "^TeamIdentifier=([A-Z0-9]{6,}|\*+)$" "$CS_OUT" \ - || { echo "::error::$BIN: TeamIdentifier missing, ad-hoc, or unexpected format"; exit 1; } - grep -qE "^CodeDirectory.*flags=0x[0-9a-f]+\(.*runtime.*\)" "$CS_OUT" \ - || { echo "::error::$BIN missing hardened runtime (--options runtime) flag"; exit 1; } - echo "::endgroup::" - done - # Stapled containers: spctl --assess returns "accepted - # source=Notarized Developer ID" when signed + notarized + - # stapled. .pkg → --type install; .dmg → --type open. - for PKG in wirerust-arm64.pkg wirerust-amd64.pkg; do - echo "::group::Verify $PKG" - spctl --assess --type install --verbose=4 "$PKG" 2>&1 | tee "$SPCTL_OUT" - grep -q "source=Notarized Developer ID" "$SPCTL_OUT" \ - || { echo "::error::$PKG not notarized (spctl source unexpected)"; exit 1; } - echo "::endgroup::" - done - # `spctl --assess --type open` on a notarized .dmg returns - # "rejected: source=Insufficient Context" on macOS 15+ — Gatekeeper - # assesses the *mounted* contents, not the .dmg file. `stapler - # validate` is the canonical check: it verifies the notarization - # ticket is locally attached to the .dmg and references a valid - # Apple notarization record. Non-zero exit on missing/invalid - # staple fails the step. - for DMG in wirerust-arm64.dmg wirerust-amd64.dmg; do - echo "::group::Verify $DMG" - xcrun stapler validate "$DMG" - echo "::endgroup::" - done + run: scripts/sign-and-notarize.sh verify wirerust - name: Generate checksums - run: | - for f in wirerust-darwin-arm64 wirerust-darwin-amd64 wirerust-arm64.pkg wirerust-amd64.pkg wirerust-arm64.dmg wirerust-amd64.dmg; do - shasum -a 256 "$f" > "${f}.sha256" - done + run: scripts/sign-and-notarize.sh checksums wirerust - name: Upload signed artifacts to release env: @@ -630,35 +413,47 @@ jobs: TAG="$RELEASE_TAG" CHANNEL="$RELEASE_CHANNEL" - # Map channel to formula name + # Map channel to formula name. An unrecognised tag suffix classifies + # as `unknown`, and the stable-homebrew job's `if:` excludes that + # channel, so no formula is ever updated for it. Leaving FORMULA + # empty is what keeps the notes from advertising a `brew install` + # line for a version that was never pushed to any formula. case "$CHANNEL" in stable) FORMULA="wirerust" ;; dev) FORMULA="wirerust-d" ;; rc) FORMULA="wirerust-rc" ;; b) FORMULA="wirerust-b" ;; - *) FORMULA="wirerust" ;; + *) FORMULA="" ;; esac BINARY_LINE="download below" - if [ -n "$HOMEBREW_TAP_REPO" ]; then + if [ -n "$FORMULA" ] && [ -n "$HOMEBREW_TAP_REPO" ]; then TAP_SHORT="${HOMEBREW_TAP_REPO#*/}" TAP_NAME="${HOMEBREW_TAP_REPO%%/*}/${TAP_SHORT#homebrew-}" BINARY_LINE="\`brew install ${TAP_NAME}/${FORMULA}\` or download below" fi + # The title names the project, not an install target, so it falls + # back to the bare project name rather than to a formula. + TITLE_NAME="${FORMULA:-wirerust}" + # Get existing release notes EXISTING_NOTES=$(gh release view "$TAG" --json body --jq '.body') # Append signing info - NOTES="${EXISTING_NOTES} - - --- - **Channel:** ${CHANNEL} - **Binary:** ${BINARY_LINE} - **Signed:** Apple Developer ID (notarized)" + # printf for the same reason as the alpha block above: the + # continuation lines would otherwise carry ten spaces of YAML + # indentation and render as a code block. + NOTES="$(printf '%s\n' \ + "${EXISTING_NOTES}" \ + "" \ + "---" \ + "**Channel:** ${CHANNEL}" \ + "**Binary:** ${BINARY_LINE}" \ + "**Signed:** Apple Developer ID (notarized)")" gh release edit "$TAG" \ - --title "${FORMULA} ${TAG}" \ + --title "${TITLE_NAME} ${TAG}" \ --notes "$NOTES" - name: Cleanup keychain @@ -703,41 +498,16 @@ jobs: RELEASE_CHANNEL: ${{ needs.stable-sign.outputs.channel }} run: | set -euo pipefail - VERSION="$RELEASE_VERSION" - TAG="$RELEASE_TAG" - CHANNEL="$RELEASE_CHANNEL" - # Map channel to formula name - case "$CHANNEL" in + # Map channel to formula name. `unknown` never reaches here (this + # job's `if:` excludes it), so the arm is a belt on the job gate. + case "$RELEASE_CHANNEL" in stable) FORMULA="wirerust" ;; dev) FORMULA="wirerust-d" ;; rc) FORMULA="wirerust-rc" ;; b) FORMULA="wirerust-b" ;; - *) echo "Unknown channel: $CHANNEL, skipping"; exit 0 ;; + *) echo "Unknown channel: $RELEASE_CHANNEL, skipping"; exit 0 ;; esac - TAP_SHORT="${HOMEBREW_TAP_REPO#*/}" - TAP_NAME="${HOMEBREW_TAP_REPO%%/*}/${TAP_SHORT#homebrew-}" - - SHA256_ARM64=$(shasum -a 256 wirerust-darwin-arm64 | cut -d' ' -f1) - SHA256_AMD64=$(shasum -a 256 wirerust-darwin-amd64 | cut -d' ' -f1) - - git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${HOMEBREW_TAP_REPO}.git" homebrew-tap-repo - cd homebrew-tap-repo - - mkdir -p Formula - cp "../Formula/${FORMULA}.rb" "Formula/${FORMULA}.rb" - - sed -i "s|REPO_PLACEHOLDER|${GITHUB_REPOSITORY}|g" "Formula/${FORMULA}.rb" - sed -i "s|TAP_PLACEHOLDER|${TAP_NAME}|g" "Formula/${FORMULA}.rb" - sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" "Formula/${FORMULA}.rb" - sed -i "s/TAG_PLACEHOLDER/$TAG/g" "Formula/${FORMULA}.rb" - sed -i "s/SHA256_ARM64_PLACEHOLDER/$SHA256_ARM64/g" "Formula/${FORMULA}.rb" - sed -i "s/SHA256_AMD64_PLACEHOLDER/$SHA256_AMD64/g" "Formula/${FORMULA}.rb" - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add "Formula/${FORMULA}.rb" - git diff --cached --quiet && echo "No changes to commit" && exit 0 - git commit -m "Update ${FORMULA} to $VERSION" - git push + scripts/update-homebrew-formula.sh \ + "$FORMULA" wirerust "$RELEASE_VERSION" "$RELEASE_TAG" diff --git a/.github/workflows/signing-guard.yml b/.github/workflows/signing-guard.yml index cb3f6836..f7240b0c 100644 --- a/.github/workflows/signing-guard.yml +++ b/.github/workflows/signing-guard.yml @@ -5,8 +5,8 @@ # (sign-and-publish.yml, backfill-release.yml). In ArcavenAE/jira-cli this # guard is a job inside the shared ci.yml; wirerust's ci.yml is # upstream-owned, so the fork hosts the guard as a separate fork-local -# workflow to keep upstream syncs conflict-free. Spec: -# ArcavenAE/jira-cli docs/specs/fork-friendly-release-ops.md +# workflow to keep upstream syncs conflict-free. Spec (commit-pinned): +# https://github.com/ArcavenAE/jira-cli/blob/f85647bdef1bf77f85ce1440dcfd9b9dd0413093/docs/specs/fork-friendly-release-ops.md # § "Required CI regression guard". name: Signing Guard diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml index 18afe560..a63bdf47 100644 --- a/.github/workflows/sync-upstream.yml +++ b/.github/workflows/sync-upstream.yml @@ -4,8 +4,10 @@ # SYNC_UPSTREAM_REPO repository variable is set (e.g. "Zious11/jira-cli" in a # downstream fork). Hosting it here means forks stay aligned without each one # carrying a divergent copy. Fork-local files that should survive a sync are -# listed in .github/local-workflows.txt. See -# docs/specs/fork-friendly-release-ops.md. +# listed in .github/local-workflows.txt. The design this implements is +# documented externally (it originated in the jira-cli project and is not +# vendored here); commit-pinned so the link cannot drift: +# https://github.com/ArcavenAE/jira-cli/blob/f85647bdef1bf77f85ce1440dcfd9b9dd0413093/docs/specs/fork-friendly-release-ops.md name: Sync Upstream on: @@ -53,14 +55,22 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" - name: Fetch upstream + # `vars.*` is operator-configured and not on the guard's + # format-constrained allowlist, so it is env-bound like any other + # non-allowlisted context. + env: + UPSTREAM_REPO: ${{ vars.SYNC_UPSTREAM_REPO }} + SYNC_BRANCH: ${{ matrix.branch }} run: | - git remote add upstream "https://github.com/${{ vars.SYNC_UPSTREAM_REPO }}.git" - git fetch upstream ${{ matrix.branch }} --tags + git remote add upstream "https://github.com/${UPSTREAM_REPO}.git" + git fetch upstream "${SYNC_BRANCH}" --tags - name: Check if sync needed id: check + env: + SYNC_BRANCH: ${{ matrix.branch }} run: | - if git merge-base --is-ancestor "upstream/${{ matrix.branch }}" HEAD; then + if git merge-base --is-ancestor "upstream/${SYNC_BRANCH}" HEAD; then echo "needed=false" >> "$GITHUB_OUTPUT" else echo "needed=true" >> "$GITHUB_OUTPUT" @@ -69,6 +79,8 @@ jobs: - name: Merge upstream if: steps.check.outputs.needed == 'true' id: merge + env: + SYNC_BRANCH: ${{ matrix.branch }} run: | set -euo pipefail @@ -79,7 +91,7 @@ jobs: fi # Attempt merge - if git merge "upstream/${{ matrix.branch }}" --no-edit; then + if git merge "upstream/${SYNC_BRANCH}" --no-edit; then echo "result=success" >> "$GITHUB_OUTPUT" exit 0 fi @@ -109,20 +121,30 @@ jobs: - name: Report unresolved conflict if: steps.merge.outputs.result == 'conflict' + # Every context expression is env-bound rather than interpolated into + # the run body. The conflicted-file list is derived from upstream + # content, and this job holds `contents: write` plus the sync deploy + # key, so an inline `${{ }}` here would be a shell-injection sink + # (CWE-94/CWE-78) reachable through a compromised operator-configured + # upstream. The branch name is bound for the same reason and for + # consistency with the workflow's stated posture. + env: + SYNC_BRANCH: ${{ matrix.branch }} + CONFLICTED_FILES: ${{ steps.merge.outputs.files }} run: | { - echo "# :warning: Upstream sync conflict on \`${{ matrix.branch }}\`" + echo "# :warning: Upstream sync conflict on \`${SYNC_BRANCH}\`" echo echo "Automated upstream sync hit merge conflicts in non-protected files." echo - echo "**Branch:** \`${{ matrix.branch }}\`" - echo "**Conflicted files:** \`${{ steps.merge.outputs.files }}\`" + echo "**Branch:** \`${SYNC_BRANCH}\`" + echo "**Conflicted files:** \`${CONFLICTED_FILES}\`" echo echo "## Resolve manually" echo '```sh' echo 'git fetch upstream' - echo 'git checkout ${{ matrix.branch }}' - echo 'git merge upstream/${{ matrix.branch }}' + echo "git checkout ${SYNC_BRANCH}" + echo "git merge upstream/${SYNC_BRANCH}" echo '# resolve conflicts (cherry-pick upstream improvements,' echo '# keep fork hardening; do NOT add to local-workflows.txt)' echo 'git push' @@ -138,9 +160,11 @@ jobs: - name: Push branch if: steps.merge.outputs.result == 'success' + env: + SYNC_BRANCH: ${{ matrix.branch }} run: | - git push origin ${{ matrix.branch }} + git push origin "${SYNC_BRANCH}" - name: Sync tags run: | - git push origin --tags \ No newline at end of file + git push origin --tags diff --git a/Formula/wirerust-a.rb b/Formula/wirerust-a.rb index e6207de3..13b0b97c 100644 --- a/Formula/wirerust-a.rb +++ b/Formula/wirerust-a.rb @@ -6,6 +6,13 @@ class WirerustA < Formula version "VERSION_PLACEHOLDER" license "MIT" + # These formulae ship only Mach-O binaries: the release pipeline builds + # darwin-arm64 and darwin-amd64 and nothing else. Without this guard a + # Linux `brew install` falls through the Hardware::CPU.arm? else-branch, + # downloads the darwin-amd64 Mach-O, and fails at exec time with no + # explanation. Declaring the dependency makes brew refuse up front. + depends_on :macos + if Hardware::CPU.arm? url "https://github.com/REPO_PLACEHOLDER/releases/download/TAG_PLACEHOLDER/wirerust-a-darwin-arm64" sha256 "SHA256_ARM64_PLACEHOLDER" diff --git a/Formula/wirerust-b.rb b/Formula/wirerust-b.rb index 810b3c3d..6343d3e9 100644 --- a/Formula/wirerust-b.rb +++ b/Formula/wirerust-b.rb @@ -6,6 +6,13 @@ class WirerustB < Formula version "VERSION_PLACEHOLDER" license "MIT" + # These formulae ship only Mach-O binaries: the release pipeline builds + # darwin-arm64 and darwin-amd64 and nothing else. Without this guard a + # Linux `brew install` falls through the Hardware::CPU.arm? else-branch, + # downloads the darwin-amd64 Mach-O, and fails at exec time with no + # explanation. Declaring the dependency makes brew refuse up front. + depends_on :macos + if Hardware::CPU.arm? url "https://github.com/REPO_PLACEHOLDER/releases/download/TAG_PLACEHOLDER/wirerust-darwin-arm64" sha256 "SHA256_ARM64_PLACEHOLDER" diff --git a/Formula/wirerust-d.rb b/Formula/wirerust-d.rb index d81e539f..f896df09 100644 --- a/Formula/wirerust-d.rb +++ b/Formula/wirerust-d.rb @@ -6,6 +6,13 @@ class WirerustD < Formula version "VERSION_PLACEHOLDER" license "MIT" + # These formulae ship only Mach-O binaries: the release pipeline builds + # darwin-arm64 and darwin-amd64 and nothing else. Without this guard a + # Linux `brew install` falls through the Hardware::CPU.arm? else-branch, + # downloads the darwin-amd64 Mach-O, and fails at exec time with no + # explanation. Declaring the dependency makes brew refuse up front. + depends_on :macos + if Hardware::CPU.arm? url "https://github.com/REPO_PLACEHOLDER/releases/download/TAG_PLACEHOLDER/wirerust-darwin-arm64" sha256 "SHA256_ARM64_PLACEHOLDER" diff --git a/Formula/wirerust-rc.rb b/Formula/wirerust-rc.rb index edb8b968..15ea63cb 100644 --- a/Formula/wirerust-rc.rb +++ b/Formula/wirerust-rc.rb @@ -6,6 +6,13 @@ class WirerustRc < Formula version "VERSION_PLACEHOLDER" license "MIT" + # These formulae ship only Mach-O binaries: the release pipeline builds + # darwin-arm64 and darwin-amd64 and nothing else. Without this guard a + # Linux `brew install` falls through the Hardware::CPU.arm? else-branch, + # downloads the darwin-amd64 Mach-O, and fails at exec time with no + # explanation. Declaring the dependency makes brew refuse up front. + depends_on :macos + if Hardware::CPU.arm? url "https://github.com/REPO_PLACEHOLDER/releases/download/TAG_PLACEHOLDER/wirerust-darwin-arm64" sha256 "SHA256_ARM64_PLACEHOLDER" diff --git a/Formula/wirerust.rb b/Formula/wirerust.rb index 5ffe7844..83a9eb79 100644 --- a/Formula/wirerust.rb +++ b/Formula/wirerust.rb @@ -3,9 +3,20 @@ class Wirerust < Formula # no leading article, must not start with the formula name, no trailing period. desc "Fast PCAP forensics and network triage CLI" homepage "https://github.com/REPO_PLACEHOLDER" - version "VERSION_PLACEHOLDER" + # No `version` here: the stable tag is v, so brew scans the + # version from the URL and `brew audit --strict` rejects the duplicate + # ("`version 0.13.2` is redundant with version scanned from URL"). + # The prerelease formulae keep theirs: their tags do not scan to the + # declared value. license "MIT" + # These formulae ship only Mach-O binaries: the release pipeline builds + # darwin-arm64 and darwin-amd64 and nothing else. Without this guard a + # Linux `brew install` falls through the Hardware::CPU.arm? else-branch, + # downloads the darwin-amd64 Mach-O, and fails at exec time with no + # explanation. Declaring the dependency makes brew refuse up front. + depends_on :macos + if Hardware::CPU.arm? url "https://github.com/REPO_PLACEHOLDER/releases/download/TAG_PLACEHOLDER/wirerust-darwin-arm64" sha256 "SHA256_ARM64_PLACEHOLDER" diff --git a/scripts/check-signing-workflow-injection.sh b/scripts/check-signing-workflow-injection.sh index a2a63060..4cb41942 100755 --- a/scripts/check-signing-workflow-injection.sh +++ b/scripts/check-signing-workflow-injection.sh @@ -16,7 +16,12 @@ # to extract run: block bodies. A naive line-oriented grep is INSUFFICIENT # (cannot delimit run: scope, misses ${{ split across lines in block scalars). # -# SCOPE: both sign-and-publish.yml and backfill-release.yml. +# SCOPE: sign-and-publish.yml and backfill-release.yml are REQUIRED (each must +# yield at least one in-scope job; zero is a broken-detection sentinel). Every +# other workflow in .github/workflows/ is DISCOVERED and scanned on the same +# structural criteria, with zero in-scope jobs treated as the normal case +# rather than a sentinel. Nothing has to be added to a list for a workflow to +# be checked: meeting the criteria below is what puts it in scope. # Scope is COMPUTED STRUCTURALLY per-job — NOT from a hardcoded job-name list. # A job is in scope when it meets ANY of: # (a) the job body contains any `secrets.*` reference (in any key under the job), @@ -51,7 +56,7 @@ # (proves the detector is not a no-op per TD-VSDD-057 false-green prevention). # # USAGE: -# scripts/check-signing-workflow-injection.sh # scan hardened workflows +# scripts/check-signing-workflow-injection.sh # scan required + discovered # scripts/check-signing-workflow-injection.sh --self-test # run negative fixture set -euo pipefail @@ -436,19 +441,67 @@ def main(): run_self_test() return # run_self_test exits directly - # Expect exactly 2 positional file arguments - files = [a for a in args if not a.startswith('--')] - if len(files) < 2: - print("Usage: check-signing-workflow-injection.sh [sign-and-publish.yml] [backfill-release.yml]", + # Positional arguments are the REQUIRED files: each must yield at least + # one in-scope job, and zero is the broken-detection sentinel. + # + # --discover adds every other workflow in to the scan. Those + # are DISCOVERED files: a workflow that handles no secrets and holds no + # write permission legitimately has zero in-scope jobs, so the sentinel + # does not apply to them. This is what closes the scope gap that let + # sync-upstream.yml hold `contents: write` plus a deploy key while never + # being scanned. A workflow only has to meet the in-scope criteria to be + # checked; nobody has to remember to add it to a list. + required = [] + discover_dirs = [] + i = 0 + while i < len(args): + a = args[i] + if a == '--discover': + # Consume the directory operand so it can never be mistaken for a + # required positional; that mistake made the directory itself a + # scan target and produced an "Is a directory" read error. + if i + 1 >= len(args): + print("ERROR: --discover requires a directory argument", file=sys.stderr) + sys.exit(2) + discover_dirs.append(args[i + 1]) + i += 2 + continue + if a.startswith('--'): + i += 1 + continue + required.append(a) + i += 1 + + if len(required) < 2: + print("Usage: check-signing-workflow-injection.sh " + "[--discover ]", file=sys.stderr) sys.exit(2) - sign_workflow, backfill_workflow = files[0], files[1] + discovered = [] + seen = {os.path.realpath(f) for f in required} + for d in discover_dirs: + try: + names = sorted(os.listdir(d)) + except OSError as e: + print(f"ERROR: Cannot list {d}: {e}", file=sys.stderr) + sys.exit(2) + for name in names: + if not name.endswith(('.yml', '.yaml')): + continue + full = os.path.join(d, name) + real = os.path.realpath(full) + if real in seen: + continue + seen.add(real) + discovered.append(full) total_run_blocks = 0 total_expressions = 0 all_flagged = [] - workflow_files = [sign_workflow, backfill_workflow] + workflow_files = list(required) + discovered + required_set = {os.path.realpath(f) for f in required} + discovered_in_scope = 0 for filepath in workflow_files: fname = os.path.basename(filepath) @@ -467,15 +520,25 @@ def main(): in_scope_count, rb, te, flagged, in_scope_jobs = scan_workflow_doc(doc, fname) - # Fail-closed: zero in-scope jobs is a sentinel for broken detection + is_required = os.path.realpath(filepath) in required_set + + # Fail-closed, for REQUIRED files only: zero in-scope jobs is a + # sentinel for broken detection. A discovered workflow with no + # secrets and no write permission is expected to score zero and is + # simply skipped. if in_scope_count == 0: - print(f"ERROR: {fname}: structural scope detection found ZERO in-scope jobs.", - file=sys.stderr) - print(f" This is a sentinel for broken detection (e.g. renamed jobs, empty workflow).", - file=sys.stderr) - print(f" Each workflow that handles secrets/signing MUST have at least one in-scope job.", - file=sys.stderr) - sys.exit(2) + if is_required: + print(f"ERROR: {fname}: structural scope detection found ZERO in-scope jobs.", + file=sys.stderr) + print(f" This is a sentinel for broken detection (e.g. renamed jobs, empty workflow).", + file=sys.stderr) + print(f" Each workflow that handles secrets/signing MUST have at least one in-scope job.", + file=sys.stderr) + sys.exit(2) + continue + + if not is_required: + discovered_in_scope += 1 total_run_blocks += rb total_expressions += te @@ -488,7 +551,9 @@ def main(): print(f" In-scope: {scope_summary}") print() - print(f"Summary: scanned {total_run_blocks} run-blocks across {len(workflow_files)} files, " + print(f"Summary: scanned {total_run_blocks} run-blocks across " + f"{len(required)} required + {discovered_in_scope} discovered in-scope file(s) " + f"({len(discovered)} discovered file(s) examined), " f"{total_expressions} total ${{{{}}}} expressions scanned, " f"{len(all_flagged)} inline high-risk expansion(s) flagged") @@ -504,7 +569,8 @@ def main(): print(" HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}") print(" run: |") print(' TAG="$HEAD_BRANCH"') - print(" See docs/specs/fork-friendly-release-ops.md § 'No inline context data'") + print(" Spec: 'No inline context data in shell run-blocks (CWE-77)' in") + print(" https://github.com/ArcavenAE/jira-cli/blob/f85647bdef1bf77f85ce1440dcfd9b9dd0413093/docs/specs/fork-friendly-release-ops.md") sys.exit(1) print("PASS: no inline high-risk expansions found in run: bodies of in-scope jobs.") @@ -520,5 +586,6 @@ if [ "$SELF_TEST_MODE" = "true" ]; then run_python_guard --self-test else echo "check-signing-workflow-injection: scanning signing workflow files..." - run_python_guard "$SIGN_WORKFLOW" "$BACKFILL_WORKFLOW" + run_python_guard "$SIGN_WORKFLOW" "$BACKFILL_WORKFLOW" \ + --discover "${REPO_ROOT}/.github/workflows" fi diff --git a/scripts/sign-and-notarize.sh b/scripts/sign-and-notarize.sh new file mode 100755 index 00000000..ea17fc65 --- /dev/null +++ b/scripts/sign-and-notarize.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash +# sign-and-notarize.sh — Apple codesign / notarize / verify steps, factored out +# of the three call sites that used to carry byte-identical copies. +# +# WHY A SUBCOMMAND SCRIPT AND NOT ONE FUNCTION (or a composite action): +# each phase runs as its own workflow step so that its secrets stay scoped to +# that step. The notarization credentials are in scope for `notarize` and +# nowhere else; the signing identity is in scope for `sign-binaries` and +# `package` and nowhere else. Collapsing the phases into a single entry point +# would put every secret in scope for every phase, which is a larger change +# than the duplication it removes. So the workflow keeps its step boundaries +# and each step calls one subcommand. +# +# CALL SITES: +# .github/workflows/sign-and-publish.yml alpha-sign (prefix wirerust-a) +# .github/workflows/sign-and-publish.yml stable-sign (prefix wirerust) +# .github/workflows/backfill-release.yml sign (prefix wirerust) +# The three differ only in the binary-name prefix. Before this script the +# stable and backfill copies were byte-identical and the alpha copy differed +# by the prefix and one comment, and `set -euo pipefail` had already drifted +# between them. +# +# ENVIRONMENT (supplied by the calling step's `env:`, never read from args): +# import-certs APPLE_CERTIFICATE_P12, APPLE_CERTIFICATE_PASSWORD, +# APPLE_INSTALLER_CERTIFICATE_P12, +# APPLE_INSTALLER_CERTIFICATE_PASSWORD +# sign-binaries APPLE_SIGNING_IDENTITY +# package APPLE_SIGNING_IDENTITY, APPLE_INSTALLER_IDENTITY +# notarize APPLE_NOTARIZATION_APPLE_ID, APPLE_NOTARIZATION_PASSWORD, +# APPLE_NOTARIZATION_TEAM_ID +# verify (none) +# checksums (none) +# +# USAGE: +# scripts/sign-and-notarize.sh import-certs +# scripts/sign-and-notarize.sh sign-binaries +# scripts/sign-and-notarize.sh package +# scripts/sign-and-notarize.sh notarize +# scripts/sign-and-notarize.sh verify +# scripts/sign-and-notarize.sh checksums +# +# is the binary basename stem: `wirerust` or `wirerust-a`. + +set -euo pipefail + +usage() { + sed -n '2,/^set -euo/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//;$d' >&2 + exit 2 +} + +require_prefix() { + if [ -z "${1:-}" ]; then + echo "ERROR: subcommand requires a argument (wirerust or wirerust-a)" >&2 + exit 2 + fi +} + +cmd_import_certs() { + security create-keychain -p "" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "" build.keychain + + echo "$APPLE_CERTIFICATE_P12" | base64 --decode > cert.p12 + security import cert.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + rm cert.p12 + + echo "$APPLE_INSTALLER_CERTIFICATE_P12" | base64 --decode > installer-cert.p12 + security import installer-cert.p12 -k build.keychain -P "$APPLE_INSTALLER_CERTIFICATE_PASSWORD" -T /usr/bin/pkgbuild -T /usr/bin/productbuild -T /usr/bin/productsign + rm installer-cert.p12 + + curl -sfo /tmp/DeveloperIDG2CA.cer https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer + security add-certificates -k build.keychain /tmp/DeveloperIDG2CA.cer + rm /tmp/DeveloperIDG2CA.cer + + security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain +} + +cmd_sign_binaries() { + local prefix="$1" + codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp "${prefix}-darwin-arm64" + codesign --force --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp "${prefix}-darwin-amd64" + codesign --verify --deep --strict "${prefix}-darwin-arm64" + codesign --verify --deep --strict "${prefix}-darwin-amd64" +} + +cmd_package() { + local prefix="$1" version="$2" + chmod +x scripts/create-app.sh scripts/create-dmg.sh scripts/create-pkg.sh + + for arch in arm64 amd64; do + ./scripts/create-app.sh "${prefix}-darwin-${arch}" "$version" . + codesign --force --deep --options runtime --sign "$APPLE_SIGNING_IDENTITY" --timestamp Wirerust.app + ./scripts/create-dmg.sh Wirerust.app "$version" "${prefix}-${arch}.dmg" + # Sign the DMG container itself. Required for stapler to attach a + # Gatekeeper-recognized notarization ticket, and routes the + # notarytool submission through Apple's fast path (signed-image + # validation) instead of the slow "discovery" path that hangs at + # pre-submission under burst load (observed in run 27797831466). + codesign --force --sign "$APPLE_SIGNING_IDENTITY" --timestamp "${prefix}-${arch}.dmg" + ./scripts/create-pkg.sh "${prefix}-darwin-${arch}" "$version" "$APPLE_INSTALLER_IDENTITY" "${prefix}-${arch}.pkg" + rm -rf Wirerust.app + done +} + +cmd_notarize() { + local prefix="$1" + for ARTIFACT in "${prefix}-arm64.pkg" "${prefix}-amd64.pkg" "${prefix}-arm64.dmg" "${prefix}-amd64.dmg"; do + echo "Notarizing $ARTIFACT..." + xcrun notarytool submit "$ARTIFACT" \ + --apple-id "$APPLE_NOTARIZATION_APPLE_ID" \ + --password "$APPLE_NOTARIZATION_PASSWORD" \ + --team-id "$APPLE_NOTARIZATION_TEAM_ID" \ + --wait --timeout 14400 + xcrun stapler staple "$ARTIFACT" + done +} + +cmd_verify() { + local prefix="$1" + local CS_OUT SPCTL_OUT + CS_OUT=$(mktemp) + SPCTL_OUT=$(mktemp) + trap 'rm -f "$CS_OUT" "$SPCTL_OUT"' EXIT + # Bare Mach-O binaries: stapler can't attach to a bare binary + # (Apple TN3147), so `spctl --assess --type execute` would + # report "Unnotarized Developer ID". Verify the load-bearing + # properties directly via codesign: Developer ID Application + # identity, stable Team Identifier, and hardened runtime flag. + for BIN in "${prefix}-darwin-arm64" "${prefix}-darwin-amd64"; do + echo "::group::Verify $BIN" + codesign -dvv "$BIN" 2>&1 | tee "$CS_OUT" + # GHA log-masks the leaf cert CN (it matches APPLE_SIGNING_IDENTITY + # secret), so `Authority=Developer ID Application: ...` becomes + # `Authority=***`. Anchor instead on the intermediate cert in the + # Developer ID chain, which is a public Apple CA name and never + # masked. Its presence proves the chain. + grep -q "^Authority=Developer ID Certification Authority$" "$CS_OUT" \ + || { echo "::error::$BIN missing Developer ID chain (intermediate CA)"; exit 1; } + # TeamIdentifier value matches APPLE_NOTARIZATION_TEAM_ID and is + # masked to "***" — accept either a real team-id format or the + # masked marker, but reject "not set" (the ad-hoc sentinel). + grep -qE "^TeamIdentifier=([A-Z0-9]{6,}|\*+)$" "$CS_OUT" \ + || { echo "::error::$BIN: TeamIdentifier missing, ad-hoc, or unexpected format"; exit 1; } + grep -qE "^CodeDirectory.*flags=0x[0-9a-f]+\(.*runtime.*\)" "$CS_OUT" \ + || { echo "::error::$BIN missing hardened runtime (--options runtime) flag"; exit 1; } + echo "::endgroup::" + done + # Stapled containers: spctl --assess returns "accepted + # source=Notarized Developer ID" when signed + notarized + + # stapled. .pkg → --type install; .dmg → --type open. + for PKG in "${prefix}-arm64.pkg" "${prefix}-amd64.pkg"; do + echo "::group::Verify $PKG" + spctl --assess --type install --verbose=4 "$PKG" 2>&1 | tee "$SPCTL_OUT" + grep -q "source=Notarized Developer ID" "$SPCTL_OUT" \ + || { echo "::error::$PKG not notarized (spctl source unexpected)"; exit 1; } + echo "::endgroup::" + done + # `spctl --assess --type open` on a notarized .dmg returns + # "rejected: source=Insufficient Context" on macOS 15+ — Gatekeeper + # assesses the *mounted* contents, not the .dmg file. `stapler + # validate` is the canonical check: it verifies the notarization + # ticket is locally attached to the .dmg and references a valid + # Apple notarization record. Non-zero exit on missing/invalid + # staple fails the step. + for DMG in "${prefix}-arm64.dmg" "${prefix}-amd64.dmg"; do + echo "::group::Verify $DMG" + xcrun stapler validate "$DMG" + echo "::endgroup::" + done +} + +cmd_checksums() { + local prefix="$1" + for f in "${prefix}-darwin-arm64" "${prefix}-darwin-amd64" \ + "${prefix}-arm64.pkg" "${prefix}-amd64.pkg" \ + "${prefix}-arm64.dmg" "${prefix}-amd64.dmg"; do + shasum -a 256 "$f" > "${f}.sha256" + done +} + +case "${1:-}" in + import-certs) cmd_import_certs ;; + sign-binaries) require_prefix "${2:-}"; cmd_sign_binaries "$2" ;; + package) require_prefix "${2:-}" + [ -n "${3:-}" ] || { echo "ERROR: package requires " >&2; exit 2; } + cmd_package "$2" "$3" ;; + notarize) require_prefix "${2:-}"; cmd_notarize "$2" ;; + verify) require_prefix "${2:-}"; cmd_verify "$2" ;; + checksums) require_prefix "${2:-}"; cmd_checksums "$2" ;; + *) usage ;; +esac diff --git a/scripts/update-homebrew-formula.sh b/scripts/update-homebrew-formula.sh new file mode 100755 index 00000000..893d031f --- /dev/null +++ b/scripts/update-homebrew-formula.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# update-homebrew-formula.sh — render a formula template into the tap repo and +# push it, factored out of the three call sites that used to carry near-identical +# copies (alpha-homebrew and stable-homebrew in sign-and-publish.yml, and the +# homebrew job in backfill-release.yml). +# +# The three differed only in which formula and which binary prefix they used, +# and `set -euo pipefail` had already drifted between them: two had it, one +# did not. +# +# ENVIRONMENT (supplied by the calling step's `env:`): +# HOMEBREW_TAP_TOKEN push credential for the tap repo +# HOMEBREW_TAP_REPO owner/homebrew- of the tap to update +# GITHUB_REPOSITORY set by the runner; substituted for REPO_PLACEHOLDER +# +# USAGE: +# scripts/update-homebrew-formula.sh [label] +# +# wirerust | wirerust-a | wirerust-d | wirerust-b | wirerust-rc +# basename stem of the signed binaries in the CWD +# value for VERSION_PLACEHOLDER +# value for TAG_PLACEHOLDER (the release tag) +# [label] optional channel word for the commit message, e.g. "alpha" + +set -euo pipefail + +if [ "$#" -lt 4 ]; then + echo "Usage: $0 [label]" >&2 + exit 2 +fi + +FORMULA="$1" +BIN_PREFIX="$2" +VERSION="$3" +TAG="$4" +LABEL="${5:-}" + +: "${HOMEBREW_TAP_TOKEN:?HOMEBREW_TAP_TOKEN is required}" +: "${HOMEBREW_TAP_REPO:?HOMEBREW_TAP_REPO is required}" +: "${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" + +TAP_SHORT="${HOMEBREW_TAP_REPO#*/}" +TAP_NAME="${HOMEBREW_TAP_REPO%%/*}/${TAP_SHORT#homebrew-}" + +SHA256_ARM64=$(shasum -a 256 "${BIN_PREFIX}-darwin-arm64" | cut -d' ' -f1) +SHA256_AMD64=$(shasum -a 256 "${BIN_PREFIX}-darwin-amd64" | cut -d' ' -f1) + +git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/${HOMEBREW_TAP_REPO}.git" homebrew-tap-repo +cd homebrew-tap-repo + +mkdir -p Formula +cp "../Formula/${FORMULA}.rb" "Formula/${FORMULA}.rb" + +sed -i "s|REPO_PLACEHOLDER|${GITHUB_REPOSITORY}|g" "Formula/${FORMULA}.rb" +sed -i "s|TAP_PLACEHOLDER|${TAP_NAME}|g" "Formula/${FORMULA}.rb" +# The stable formula carries no `version` line: its tag is v, so brew +# scans the version from the URL and `brew audit --strict` rejects an explicit +# duplicate. This substitution is a no-op there and load-bearing for the +# prerelease formulae, whose tags do not scan to the declared value. +sed -i "s/VERSION_PLACEHOLDER/$VERSION/g" "Formula/${FORMULA}.rb" +sed -i "s/TAG_PLACEHOLDER/$TAG/g" "Formula/${FORMULA}.rb" +sed -i "s/SHA256_ARM64_PLACEHOLDER/$SHA256_ARM64/g" "Formula/${FORMULA}.rb" +sed -i "s/SHA256_AMD64_PLACEHOLDER/$SHA256_AMD64/g" "Formula/${FORMULA}.rb" + +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" +git add "Formula/${FORMULA}.rb" +git diff --cached --quiet && echo "No changes to commit" && exit 0 + +if [ -n "$LABEL" ]; then + git commit -m "Update ${FORMULA} (${LABEL}) to $VERSION" +else + git commit -m "Update ${FORMULA} to $VERSION" +fi +git push