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 1a9cae912117d0f9a35f4a2749e50de643744be5 Mon Sep 17 00:00:00 2001 From: Michael Pursifull Date: Tue, 4 Aug 2026 14:27:35 -0500 Subject: [PATCH 7/7] ci: pin dtolnay/rust-toolchain action, keep channels via toolchain input The stable and nightly refs were the repo's two documented pin-gate exemptions because the ref name carried the channel. Pinning the action commit (2c7215f, master 2026-08-04) and selecting the channel through the explicit toolchain input keeps rolling-channel semantics while closing the mutable-ref surface. The action-pin-gate allowlist is now empty and the gate validates every remote ref. Prepares for org-level sha_pinning_required, which has no allowlist mechanism. --- .github/workflows/ci.yml | 31 ++++++++++++++++++------------- .github/workflows/release.yml | 3 ++- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe7c7262..58a5b2b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master 2026-08-04; channel via toolchain input + with: + toolchain: stable - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - run: cargo test --all-targets @@ -51,8 +53,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master 2026-08-04; channel via toolchain input with: + toolchain: stable components: clippy - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - run: cargo clippy --all-targets -- -D warnings @@ -62,8 +65,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master 2026-08-04; channel via toolchain input with: + toolchain: stable components: rustfmt - run: cargo fmt --all --check @@ -94,7 +98,7 @@ jobs: RUSTFLAGS: "" steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: dtolnay/rust-toolchain@nightly + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master 2026-08-04; channel via toolchain input with: # Pin to a specific, known-good dated nightly. See job comment above. toolchain: nightly-2026-05-21 @@ -136,7 +140,9 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master 2026-08-04; channel via toolchain input + with: + toolchain: stable - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1 - name: Install cargo-audit run: cargo install cargo-audit --locked @@ -331,11 +337,10 @@ jobs: # Mutable refs (e.g. @v6, @v2.9.1, @stable) can be silently moved by the upstream # maintainer, allowing a compromised tag to execute arbitrary code in CI. # - # Documented exemption: dtolnay/rust-toolchain@stable and @nightly are intentionally - # exempt. The rust-toolchain action is a channel-selection installer whose entire - # purpose is to track the rolling stable/nightly channel; pinning it to a SHA would - # defeat that purpose. These two refs are tracked for separate resolution and are - # explicitly allowlisted in the gate script below. + # The former dtolnay/rust-toolchain@stable/@nightly exemption is retired: those + # sites now pin the action commit and select the rolling channel via the explicit + # 'toolchain' input, so channel tracking survives the pin and the gate validates + # every remote ref with an empty allowlist. action-pin-gate: name: Action pin gate runs-on: ubuntu-latest @@ -348,9 +353,9 @@ jobs: set -euo pipefail # Allowlisted branch-ref actions (documented exemptions only). - # dtolnay/rust-toolchain@stable and @nightly: channel-selection installer; - # pinning to SHA would defeat its purpose of tracking rolling toolchain channels. - ALLOWLIST="dtolnay/rust-toolchain@stable dtolnay/rust-toolchain@nightly" + # Currently empty: the dtolnay channel refs moved to a pinned action + # commit with the channel selected via the explicit 'toolchain' input. + ALLOWLIST="" # PG-W71-CI-SCAN-GUARDS / SEC-001: scan-target existence guard. # Mirrors the trust-boundary and help-provenance-gate patterns. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index abed368c..a58b08f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,8 +46,9 @@ jobs: steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master 2026-08-04; channel via toolchain input with: + toolchain: stable targets: ${{ matrix.target }} - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1