diff --git a/.github/local-workflows.txt b/.github/local-workflows.txt
new file mode 100644
index 00000000..c5b99dc2
--- /dev/null
+++ b/.github/local-workflows.txt
@@ -0,0 +1,31 @@
+# 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 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
+.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..8f92d7cc
--- /dev/null
+++ b/.github/workflows/backfill-release.yml
@@ -0,0 +1,458 @@
+# 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
+ # 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:
+ toolchain: stable
+ 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/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
diff --git a/.github/workflows/sign-and-publish.yml b/.github/workflows/sign-and-publish.yml
new file mode 100644
index 00000000..0273afc0
--- /dev/null
+++ b/.github/workflows/sign-and-publish.yml
@@ -0,0 +1,743 @@
+# 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
+ # 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:
+ toolchain: stable
+ 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..e6207de3
--- /dev/null
+++ b/Formula/wirerust-a.rb
@@ -0,0 +1,32 @@
+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"
+ 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..810b3c3d
--- /dev/null
+++ b/Formula/wirerust-b.rb
@@ -0,0 +1,32 @@
+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"
+ 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..d81e539f
--- /dev/null
+++ b/Formula/wirerust-d.rb
@@ -0,0 +1,32 @@
+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"
+ 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..edb8b968
--- /dev/null
+++ b/Formula/wirerust-rc.rb
@@ -0,0 +1,32 @@
+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"
+ 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..5ffe7844
--- /dev/null
+++ b/Formula/wirerust.rb
@@ -0,0 +1,25 @@
+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"
+ 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"