From f3d8087a3991c347300283f865f28680e09755de Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 21:18:35 +0200 Subject: [PATCH 1/6] fix(release): use notarization broker Remove Apple credential handling and direct signing from OpenWritr. Authenticate broker artifacts, create a single-use five-asset draft, smoke-test it, and publish only after immutable tag and byte-for-byte checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- .github/github-app.yml | 8 +- .github/workflows/ci.yml | 6 + .github/workflows/release.yml | 500 ------------------ .github/workflows/smoke-test.yml | 43 +- .gitignore | 1 - .release.env.example | 3 - AGENTS.md | 46 +- CHANGELOG.md | 2 +- README.md | 51 +- RELEASE_CHECKLIST.md | 206 ++++---- Sources/OpenWritr/UpdateManager.swift | 8 +- docs/release-smoke-tests.md | 67 ++- docs/self-assessment.md | 22 +- scripts/build-app.sh | 12 +- scripts/make_dmg.sh | 7 - scripts/notarize_app.sh | 77 --- scripts/notarize_dmg.sh | 59 --- scripts/publish_broker_release.sh | 259 ++++++++++ scripts/release_macos.sh | 97 ---- scripts/setup_notarization.sh | 660 ------------------------ scripts/test_verify_broker_artifacts.py | 125 +++++ scripts/verify_broker_artifacts.py | 159 ++++++ 22 files changed, 790 insertions(+), 1628 deletions(-) delete mode 100644 .github/workflows/release.yml delete mode 100644 .release.env.example delete mode 100755 scripts/notarize_app.sh delete mode 100755 scripts/notarize_dmg.sh create mode 100755 scripts/publish_broker_release.sh delete mode 100755 scripts/release_macos.sh delete mode 100755 scripts/setup_notarization.sh create mode 100644 scripts/test_verify_broker_artifacts.py create mode 100755 scripts/verify_broker_artifacts.py diff --git a/.github/github-app.yml b/.github/github-app.yml index 3c9a2c5..2881ed3 100644 --- a/.github/github-app.yml +++ b/.github/github-app.yml @@ -9,9 +9,11 @@ instructions: | The rules that are most often broken: - 1. Never add a build-attestation policy to UpdateManager and never attest the - update DMG. It crashes shipped apps (see #31). - 2. Never publish a release, create or move tags, or change repository settings. + 1. Never add a build-attestation policy to UpdateManager and never attest + either OpenWritr DMG. They share one digest and can crash shipped apps + (see #31). + 2. Never publish a release, create or move tags, dispatch the notarization + broker, run the publication handoff, or change repository settings. 3. Never log transcript text, audio, prompts, or API keys. Validate with `swift build -c release -Xswiftc -warnings-as-errors`, `swiftlint lint --strict`, and `swift test` before proposing any change. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 121e7c3..1c66555 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,3 +34,9 @@ jobs: - name: Test run: swift test + + - name: Validate release tooling + run: | + bash -n scripts/*.sh + PYTHONPATH=scripts python3 -m unittest scripts/test_verify_broker_artifacts.py + python3 -m py_compile scripts/verify_broker_artifacts.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index b07cb00..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,500 +0,0 @@ -name: Release macOS - -on: - push: - tags: - - "v*" - workflow_dispatch: - -permissions: - contents: read - -# One release run per tag at a time: two overlapping runs could test one set of -# assets while the other replaces them before the publish job runs. -concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false - -jobs: - validate-release-ref: - name: Validate release tag reference - runs-on: ubuntu-24.04 - timeout-minutes: 5 - permissions: {} - outputs: - tag: ${{ steps.release.outputs.tag }} - version: ${{ steps.release.outputs.version }} - artifact_name: ${{ steps.release.outputs.artifact_name }} - triggering_sha: ${{ steps.release.outputs.triggering_sha }} - steps: - - name: Resolve release metadata from the triggering tag - id: release - shell: bash - run: | - set -euo pipefail - if [[ "$GITHUB_REF_TYPE" != "tag" || "$GITHUB_REF" != refs/tags/* ]]; then - echo "Release runs must be triggered from an existing tag, not $GITHUB_REF." >&2 - exit 1 - fi - - tag="$GITHUB_REF_NAME" - if [[ "$GITHUB_REF" != "refs/tags/$tag" ]]; then - echo "Release ref does not match its tag name: $GITHUB_REF" >&2 - exit 1 - fi - if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then - echo "Release tag must be a version such as v1.2.1: $tag" >&2 - exit 1 - fi - - version="${tag#v}" - if [[ ! "$GITHUB_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "Trigger SHA is not a full Git commit SHA: $GITHUB_SHA" >&2 - exit 1 - fi - { - echo "tag=$tag" - echo "version=$version" - echo "artifact_name=OpenWritr-v${version}-macOS-arm64" - echo "triggering_sha=$GITHUB_SHA" - } >> "$GITHUB_OUTPUT" - - sign-and-notarize: - name: Build signed and notarized macOS artifacts - needs: validate-release-ref - runs-on: macos-15 - timeout-minutes: 90 - environment: release - permissions: - contents: read - outputs: - tag: ${{ needs.validate-release-ref.outputs.tag }} - version: ${{ needs.validate-release-ref.outputs.version }} - artifact_name: ${{ needs.validate-release-ref.outputs.artifact_name }} - triggering_sha: ${{ needs.validate-release-ref.outputs.triggering_sha }} - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: refs/tags/${{ needs.validate-release-ref.outputs.tag }} - fetch-depth: 0 - persist-credentials: false - - - name: Verify checkout matches the release tag - shell: bash - env: - RELEASE_TAG: ${{ needs.validate-release-ref.outputs.tag }} - TRIGGERING_SHA: ${{ needs.validate-release-ref.outputs.triggering_sha }} - run: | - set -euo pipefail - tag_ref="refs/tags/$RELEASE_TAG" - if ! git show-ref --verify --quiet "$tag_ref"; then - echo "Checked-out repository does not contain $tag_ref." >&2 - exit 1 - fi - - tag_commit="$(git rev-parse --verify "${tag_ref}^{commit}")" - checked_out_commit="$(git rev-parse --verify HEAD)" - if [[ "$checked_out_commit" != "$TRIGGERING_SHA" ]]; then - echo "Checked-out HEAD $checked_out_commit does not match triggering SHA $TRIGGERING_SHA." >&2 - exit 1 - fi - if [[ "$tag_commit" != "$TRIGGERING_SHA" ]]; then - echo "$tag_ref resolves to $tag_commit, not triggering SHA $TRIGGERING_SHA." >&2 - exit 1 - fi - - # Gate: nothing is built, signed, or published for a tag that cannot - # describe itself. Fails before the signing certificate is imported. - - name: Verify bundle version and changelog entry - shell: bash - env: - RELEASE_VERSION: ${{ needs.validate-release-ref.outputs.version }} - run: | - set -euo pipefail - - plist_short="$(plutil -extract CFBundleShortVersionString raw Info.plist)" - plist_build="$(plutil -extract CFBundleVersion raw Info.plist)" - if [[ "$plist_short" != "$RELEASE_VERSION" || "$plist_build" != "$RELEASE_VERSION" ]]; then - echo "Info.plist is $plist_short (build $plist_build) but the tag is $RELEASE_VERSION." >&2 - echo "Bump CFBundleShortVersionString and CFBundleVersion before tagging." >&2 - exit 1 - fi - - # Entries still held under an unreleased heading would ship in no release notes. - held="$(awk ' - tolower($0) ~ /^##[ \t]+\[?unreleased\]?/ { capture = 1; next } - capture && /^## / { exit } - capture { print } - ' CHANGELOG.md | tr -d '[:space:]')" - if [[ -n "$held" ]]; then - echo "CHANGELOG.md still holds entries under Unreleased; promote them into $RELEASE_VERSION." >&2 - exit 1 - fi - - # The published notes are the maintained changelog entry, never text written here. - awk -v version="$RELEASE_VERSION" ' - BEGIN { gsub(/\./, "\\.", version) } - $0 ~ "^## \\[?" version "\\]?([ \t]|$)" { capture = 1; next } - capture && /^## / { exit } - capture { print } - ' CHANGELOG.md > "$RUNNER_TEMP/release-notes.md" - if [[ -z "$(tr -d '[:space:]' < "$RUNNER_TEMP/release-notes.md")" ]]; then - echo "CHANGELOG.md has no entry for $RELEASE_VERSION." >&2 - exit 1 - fi - mkdir -p dist - cp "$RUNNER_TEMP/release-notes.md" dist/release-notes.md - - - name: Import Developer ID certificate - shell: bash - env: - MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }} - MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }} - run: | - set -euo pipefail - if [[ -z "$MACOS_CERTIFICATE" || -z "$MACOS_CERTIFICATE_PWD" ]]; then - echo "MACOS_CERTIFICATE and MACOS_CERTIFICATE_PWD secrets are required." - exit 1 - fi - - signing_dir="$GITHUB_WORKSPACE/.build/ci-signing-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - cert_file="$signing_dir/developer-id.p12" - ca_file="$signing_dir/DeveloperIDG2CA.cer" - keychain_file="$signing_dir/build.keychain-db" - keychain_password="$(openssl rand -base64 32)" - import_succeeded=false - - cleanup_import() { - rm -f -- "$cert_file" "$ca_file" - if [[ "$import_succeeded" != true ]]; then - security delete-keychain "$keychain_file" 2>/dev/null || true - rm -f -- "$keychain_file" - rmdir -- "$signing_dir" 2>/dev/null || true - fi - } - trap cleanup_import EXIT - trap 'exit 129' HUP - trap 'exit 130' INT - trap 'exit 143' TERM - - mkdir -p "$signing_dir" - set +x - printf '%s' "$MACOS_CERTIFICATE" | base64 --decode > "$cert_file" - curl --fail --silent --show-error --location \ - "https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer" \ - --output "$ca_file" - - security create-keychain -p "$keychain_password" "$keychain_file" - security set-keychain-settings -lut 21600 "$keychain_file" - security unlock-keychain -p "$keychain_password" "$keychain_file" - security import "$ca_file" -k "$keychain_file" -T /usr/bin/codesign - security import "$cert_file" -P "$MACOS_CERTIFICATE_PWD" -A -t cert -f pkcs12 -k "$keychain_file" - security set-key-partition-list -S apple-tool:,apple: -k "$keychain_password" "$keychain_file" - existing_keychains=() - while IFS= read -r existing_keychain; do - [[ -n "$existing_keychain" ]] && existing_keychains+=("$existing_keychain") - done < <( - security list-keychains -d user | - sed -E 's/^[[:space:]]*"//; s/"[[:space:]]*$//' - ) - security list-keychains -d user -s "$keychain_file" "${existing_keychains[@]}" - - identity="$( - security find-identity -v -p codesigning "$keychain_file" | - awk -F'"' '/Developer ID Application/ && !identity { identity = $2 } END { print identity }' - )" - if [[ -z "$identity" ]]; then - echo "Developer ID Application identity not found." - exit 1 - fi - - { - echo "CODE_SIGN_IDENTITY=$identity" - echo "OPENWRITR_SIGNING_IDENTITY=$identity" - echo "KEYCHAIN_FILE=$keychain_file" - echo "SIGNING_DIR=$signing_dir" - } >> "$GITHUB_ENV" - import_succeeded=true - - - name: Build Developer ID signed app - shell: bash - env: - OPENWRITR_VERSION: ${{ needs.validate-release-ref.outputs.version }} - run: | - set -euo pipefail - chmod +x scripts/*.sh - scripts/build-app.sh - - - name: Notarize app and package ZIP - shell: bash - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} - APP_PATH: .build/release/OpenWritr.app - ZIP_PATH: dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64.zip - run: | - set -euo pipefail - scripts/notarize_app.sh - - - name: Build signed DMG from stapled app - shell: bash - env: - APP_PATH: .build/release/OpenWritr.app - DMG_PATH: dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64.dmg - REQUIRE_NOTARIZED_APP: "true" - run: | - set -euo pipefail - scripts/make_dmg.sh - - - name: Notarize DMG - shell: bash - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_APP_PASSWORD: ${{ secrets.APPLE_APP_PASSWORD }} - DMG_PATH: dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64.dmg - run: | - set -euo pipefail - scripts/notarize_dmg.sh - - - name: Create AppUpdater-named DMG copy - shell: bash - env: - RELEASE_VERSION: ${{ needs.validate-release-ref.outputs.version }} - run: | - set -euo pipefail - # AppUpdater (in-app updater) expects a release DMG named - # exactly "-.dmg" (no "v" prefix, no arch suffix). - # Reuse the already-signed and notarized bytes rather than - # rebuilding, so no additional signing/notarization is required. - cp "dist/OpenWritr-v${RELEASE_VERSION}-macOS-arm64.dmg" "dist/OpenWritr-${RELEASE_VERSION}.dmg" - - # Do NOT attest this DMG. OpenWritr 1.6.0 was built with an attestation - # policy, and verifying an attestation crashes it (see #31). Without an - # attestation for the digest, 1.6.0 rejects the update cleanly before it - # reaches that code. - - - name: Cleanup keychain - if: always() - shell: bash - run: | - if [[ -n "${KEYCHAIN_FILE:-}" ]]; then - security delete-keychain "$KEYCHAIN_FILE" 2>/dev/null || true - rm -f -- "$KEYCHAIN_FILE" - fi - if [[ -n "${SIGNING_DIR:-}" ]]; then - rmdir -- "$SIGNING_DIR" 2>/dev/null || true - fi - - - name: Verify release artifacts - shell: bash - env: - APP_PATH: .build/release/OpenWritr.app - ASSET_BASE: dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64 - APPUPDATER_DMG: dist/OpenWritr-${{ needs.validate-release-ref.outputs.version }}.dmg - run: | - set -euo pipefail - artifacts=( - "$ASSET_BASE.zip" - "$ASSET_BASE.zip.sha256" - "$ASSET_BASE.dmg" - "$ASSET_BASE.dmg.sha256" - "$APPUPDATER_DMG" - ) - for artifact in "${artifacts[@]}"; do - if [[ ! -f "$artifact" ]]; then - echo "Expected release artifact is missing: $artifact" - exit 1 - fi - done - - codesign --verify --deep --strict --verbose=2 "$APP_PATH" - xcrun stapler validate "$APP_PATH" - spctl --assess --type execute --verbose=2 "$APP_PATH" - codesign --verify --strict --verbose=2 "$ASSET_BASE.dmg" - xcrun stapler validate "$ASSET_BASE.dmg" - spctl --assess --type open --context context:primary-signature --verbose=2 "$ASSET_BASE.dmg" - hdiutil verify "$ASSET_BASE.dmg" - hdiutil verify "$APPUPDATER_DMG" - ( - cd "$(dirname "$ASSET_BASE")" - asset_name="$(basename "$ASSET_BASE")" - shasum -a 256 -c "$asset_name.zip.sha256" - shasum -a 256 -c "$asset_name.dmg.sha256" - ) - - - name: Upload workflow artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: ${{ needs.validate-release-ref.outputs.artifact_name }} - if-no-files-found: error - path: | - dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64.zip - dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64.zip.sha256 - dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64.dmg - dist/OpenWritr-v${{ needs.validate-release-ref.outputs.version }}-macOS-arm64.dmg.sha256 - dist/OpenWritr-${{ needs.validate-release-ref.outputs.version }}.dmg - dist/release-notes.md - - create-draft: - name: Create or update the draft release - needs: sign-and-notarize - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: write - steps: - - name: Checkout tag verification helper - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.sign-and-notarize.outputs.triggering_sha }} - persist-credentials: false - sparse-checkout: | - scripts/verify_release_asset_contract.sh - scripts/verify_remote_release_tag.sh - sparse-checkout-cone-mode: false - - - name: Download verified release artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ needs.sign-and-notarize.outputs.artifact_name }} - path: release-candidate - - - name: Attach verified assets to the draft release - shell: bash - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ needs.sign-and-notarize.outputs.tag }} - RELEASE_VERSION: ${{ needs.sign-and-notarize.outputs.version }} - TRIGGERING_SHA: ${{ needs.sign-and-notarize.outputs.triggering_sha }} - run: | - set -euo pipefail - asset_base="release-candidate/OpenWritr-v${RELEASE_VERSION}-macOS-arm64" - assets=( - "$asset_base.zip" - "$asset_base.zip.sha256" - "$asset_base.dmg" - "$asset_base.dmg.sha256" - "release-candidate/OpenWritr-${RELEASE_VERSION}.dmg" - ) - asset_names=( - "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.zip" - "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.zip.sha256" - "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.dmg" - "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.dmg.sha256" - "OpenWritr-${RELEASE_VERSION}.dmg" - ) - for asset in "${assets[@]}"; do - if [[ ! -f "$asset" ]]; then - echo "Expected release asset is missing: $asset" - exit 1 - fi - done - - bash scripts/verify_remote_release_tag.sh \ - "$RELEASE_TAG" "$TRIGGERING_SHA" "$GITHUB_REPOSITORY" - - # Notes come from the changelog entry checked by the gate above. A new - # release is created as a DRAFT: nobody, including the in-app updater, - # can receive it until the smoke test has passed and the publish job - # below makes it public. An existing release keeps its state. - if ! gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - gh release create "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ - --verify-tag --draft --title "OpenWritr ${RELEASE_VERSION}" \ - --notes-file release-candidate/release-notes.md - elif [[ "$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ - --json isDraft --jq .isDraft)" == "true" ]]; then - gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ - --title "OpenWritr ${RELEASE_VERSION}" \ - --notes-file release-candidate/release-notes.md - else - # Replacing the assets of a public release would expose untested - # files, and a failing smoke test could not take them back. - echo "Release $RELEASE_TAG is already public; refusing to replace its assets. Publish a new version instead." >&2 - exit 1 - fi - bash scripts/verify_release_asset_contract.sh \ - subset "$RELEASE_TAG" "$GITHUB_REPOSITORY" "${asset_names[@]}" - gh release upload "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \ - "${assets[@]}" --clobber - bash scripts/verify_release_asset_contract.sh \ - exact "$RELEASE_TAG" "$GITHUB_REPOSITORY" "${asset_names[@]}" - - # R05: install the verified workflow artifact attached to the draft and - # exercise it, as a consumer would, BEFORE the release becomes public. - # A failure leaves the draft unpublished. - smoke-test: - name: Smoke-test the release before publishing - needs: [sign-and-notarize, create-draft] - permissions: - contents: read - uses: ./.github/workflows/smoke-test.yml - with: - tag: ${{ needs.sign-and-notarize.outputs.tag }} - artifact_name: ${{ needs.sign-and-notarize.outputs.artifact_name }} - - publish: - name: Publish the release - needs: [sign-and-notarize, create-draft, smoke-test] - runs-on: ubuntu-24.04 - timeout-minutes: 10 - permissions: - contents: write - steps: - - name: Checkout tag verification helper - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.sign-and-notarize.outputs.triggering_sha }} - persist-credentials: false - sparse-checkout: | - scripts/verify_release_asset_contract.sh - scripts/verify_remote_release_tag.sh - sparse-checkout-cone-mode: false - - - name: Make the tested draft public - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ needs.sign-and-notarize.outputs.tag }} - RELEASE_VERSION: ${{ needs.sign-and-notarize.outputs.version }} - TRIGGERING_SHA: ${{ needs.sign-and-notarize.outputs.triggering_sha }} - run: | - set -euo pipefail - bash scripts/verify_remote_release_tag.sh \ - "$RELEASE_TAG" "$TRIGGERING_SHA" "$GITHUB_REPOSITORY" - asset_names=( - "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.zip" - "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.zip.sha256" - "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.dmg" - "OpenWritr-v${RELEASE_VERSION}-macOS-arm64.dmg.sha256" - "OpenWritr-${RELEASE_VERSION}.dmg" - ) - bash scripts/verify_release_asset_contract.sh \ - exact "$RELEASE_TAG" "$GITHUB_REPOSITORY" "${asset_names[@]}" - gh release edit "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --draft=false - if [[ "$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)" != "false" ]]; then - echo "Release $RELEASE_TAG is still a draft." >&2 - exit 1 - fi - - - name: Verify the public download is the file that was tested - env: - RELEASE_TAG: ${{ needs.sign-and-notarize.outputs.tag }} - TESTED_SHA256: ${{ needs.smoke-test.outputs.dmg_sha256 }} - run: | - set -euo pipefail - if [[ -z "$TESTED_SHA256" ]]; then - echo "The smoke test did not report the digest it tested." >&2 - exit 1 - fi - base="https://github.com/${GITHUB_REPOSITORY}/releases/download/${RELEASE_TAG}" - name="OpenWritr-${RELEASE_TAG}-macOS-arm64.dmg" - mkdir public && cd public - curl --fail --silent --show-error --location --retry 5 --retry-delay 5 -O "$base/$name" -O "$base/$name.sha256" - shasum -a 256 -c "$name.sha256" - public_sha="$(shasum -a 256 "$name" | cut -d' ' -f1)" - if [[ "$public_sha" != "$TESTED_SHA256" ]]; then - echo "The public DMG ($public_sha) is not the file the smoke test ran ($TESTED_SHA256)." >&2 - exit 1 - fi diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 417ee52..c2530c5 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -1,36 +1,21 @@ name: Release smoke test +run-name: Smoke-test OpenWritr ${{ inputs.tag }} # Installs the release's disk image the way a consumer would and exercises its # core function (speech-to-text) through the app's non-interactive --self-test # entry point. The job summary is the dated record for criterion R05. # -# release.yml runs it against the verified workflow artifact attached to the -# DRAFT release, before the draft is made public, so a failing build is never -# offered to anyone. It also runs on demand for any published tag. The publish -# job re-verifies the public download against the tested artifact's checksum. +# The maintainer's secretless broker handoff runs this against a DRAFT release +# before making it public. It can also be run again for a published tag. # Releases before the one that introduced --self-test fail the check below. on: workflow_dispatch: inputs: tag: - description: Published release tag to test, for example v1.6.5 + description: Draft or published release tag to test, for example v1.6.5 required: true type: string - workflow_call: - inputs: - tag: - description: Release tag represented by the workflow artifact - required: true - type: string - artifact_name: - description: Workflow artifact containing the verified release files - required: true - type: string - outputs: - dmg_sha256: - description: SHA-256 of the DMG that was installed and tested - value: ${{ jobs.smoke.outputs.dmg_sha256 }} permissions: contents: read @@ -40,8 +25,6 @@ jobs: name: Install and transcribe runs-on: macos-15 timeout-minutes: 30 - outputs: - dmg_sha256: ${{ steps.verify-download.outputs.dmg_sha256 }} steps: - name: Validate release tag shell: bash @@ -54,15 +37,7 @@ jobs: exit 1 fi - - name: Download verified workflow artifact - if: inputs.artifact_name != '' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: ${{ inputs.artifact_name }} - path: dl - - - name: Download published release DMG and checksum - if: inputs.artifact_name == '' + - name: Download draft or published release DMG and checksum shell: bash env: GH_TOKEN: ${{ github.token }} @@ -89,7 +64,6 @@ jobs: fi (cd dl && shasum -a 256 -c "$(basename "$checksum")") echo "DMG_PATH=$dmg" >> "$GITHUB_ENV" - echo "dmg_sha256=$(shasum -a 256 "$dmg" | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" - name: Install to /Applications shell: bash @@ -108,6 +82,13 @@ jobs: set -euo pipefail app=/Applications/OpenWritr.app codesign --verify --deep --strict --verbose=2 "$app" + signature="$(codesign -dv --verbose=4 "$app" 2>&1)" + team_id="$(printf '%s\n' "$signature" | awk -F= '/^TeamIdentifier=/ { print $2; exit }')" + identifier="$(plutil -extract CFBundleIdentifier raw "$app/Contents/Info.plist")" + if [[ "$team_id" != "G69Z5BNY97" || "$identifier" != "com.openwritr.app" ]]; then + echo "Unexpected release identity: Team $team_id, bundle $identifier." >&2 + exit 1 + fi spctl --assess --type execute --verbose=2 "$app" xcrun stapler validate "$app" diff --git a/.gitignore b/.gitignore index 894eac1..d17bf06 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ .artifacts/ .swiftpm/ DerivedData/ -.release.env dist/ *.dmg *.dmg.sha256 diff --git a/.release.env.example b/.release.env.example deleted file mode 100644 index fdd8490..0000000 --- a/.release.env.example +++ /dev/null @@ -1,3 +0,0 @@ -TEAM_ID="G69Z5BNY97" -CODE_SIGN_IDENTITY="Developer ID Application: Torsten Mahr (G69Z5BNY97)" -NOTARY_PROFILE="OpenWritr" diff --git a/AGENTS.md b/AGENTS.md index b3eeaa6..df3b47a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ OpenWritr is a macOS menu bar app (`LSUIElement`) for push-to-talk voice-to-text built with Swift Package Manager for macOS 14+ on Apple Silicon. End users install the signed, notarized DMG/ZIP from GitHub Releases and receive updates in place through the app itself, so a bad release reaches every installed copy within about -a day. Changes to `UpdateManager`, the release workflow, or signing can strand +a day. Changes to `UpdateManager`, the broker profile/publication handoff, or signing can strand users on an old version. ## What this repository is not @@ -29,7 +29,7 @@ transcript text to the provider the user chose. | `Sources/ObjCExceptionCatcher/` | Small Objective-C shim so Swift can catch `NSException`. | | `Resources/AppIcon.icns` | The app icon copied into the bundle. | | `Info.plist` | Bundle identity: name, version, description, copyright, licence, repository and issue URLs. `Package.swift` has no fields for these, so they live here; the release build overrides the version from the tag. Extended by `scripts/build-app.sh`. | -| `scripts/` | Build, sign, notarize, DMG, release, and model-evaluation scripts. | +| `scripts/` | Local diagnostic build/DMG tools, broker release verification/publication handoff, and model-evaluation scripts. | | `eval/cleanup-cases.json` | Synthetic cleanup-model benchmark cases. Never add private dictation. | | `docs/` | GitHub Pages site (`index.html` and assets), served from `main` `/docs`. | | `plan/` | Working implementation plans. | @@ -59,7 +59,7 @@ Concurrency model: `AppViewModel` is `@MainActor`. `AudioEngine` is `@unchecked Preferences live in `UserDefaults` (no separate plist). Custom cleanup prompts use a versioned per-provider/model store so bundled tuned defaults can change without overwriting user text. -`scripts/build-app.sh` signs with a Developer ID Application or Apple Development certificate found in the local keychain (or named in `OPENWRITR_SIGNING_IDENTITY`) and exits with an error if there is none; it creates no certificate. Ad-hoc signatures are refused, because macOS would reset the app's permissions. +`scripts/build-app.sh` is a local diagnostic build. It signs with a Developer ID Application or Apple Development certificate found in the local keychain (or named in `OPENWRITR_SIGNING_IDENTITY`) and exits with an error if there is none; it creates no certificate. Ad-hoc signatures are refused, because macOS would reset the app's permissions. Distributable builds do not use this script or any OpenWritr workflow: they are assembled, signed, notarized, and packaged by `trsdn/macos-notarization-broker` profile `openwritr`. ## Enhanced Mode @@ -74,10 +74,10 @@ Enhanced activation has two modes: on demand (`Shift + hotkey`) and always-enhan OpenWritr is distributed outside the Mac App Store. `UpdateManager` (AppUpdater 4.x) checks `trsdn/OpenWritr` GitHub Releases for a newer, Developer ID-signed DMG and installs it in place. - Automatic checks run roughly every 24 hours (Settings → Updates, on by default). A manual check is in the menu bar and Settings. -- Asset naming: the release workflow publishes an extra DMG named `OpenWritr-{semver}.dmg` (no `v` prefix, no arch suffix) beside the `OpenWritr-v{version}-macOS-arm64.{dmg,zip}` assets. AppUpdater looks for this exact name. +- Asset naming: the broker profile creates an extra DMG named `OpenWritr-{semver}.dmg` (no `v` prefix, no arch suffix) as a byte-identical copy of `OpenWritr-v{version}-macOS-arm64.dmg`. AppUpdater looks for this exact name. - Verification: AppUpdater checks the downloaded DMG's Developer ID identity, Team ID, and bundle identifier against the installed app. - **No attestation policy — do not add one back** (#31). AppUpdater accepts only a `refs/heads/…` source ref, but releases run on tag pushes, so provenance names `refs/tags/vX.Y.Z`. It also loads its Sigstore trust roots through `Bundle.module`, which for a `swift build` product only looks at the `.app` root and the CI machine's `.build` path, so verification hits `fatalError` in a shipped app. Re-enabling it needs an upstream AppUpdater fix and releases dispatched from `main`. -- **Never attest the update DMG.** 1.6.0 shipped with the policy. It reaches the crashing code only if GitHub has an attestation for the new DMG's digest; without one it rejects the update cleanly. 1.6.0 users have to update manually once. +- **Never attest either OpenWritr DMG.** The updater alias is byte-identical to the versioned DMG, so an attestation for either filename covers the same digest. 1.6.0 shipped with the policy and reaches the crashing code only if GitHub has an attestation for the new DMG's digest; without one it rejects the update cleanly. The broker must attest only the OpenWritr ZIP. 1.6.0 users have to update manually once. - `scripts/build-app.sh` still copies `AppUpdater_AppUpdater.bundle` into `Contents/Resources/`. It is unused without an attestation policy but keeps the layout AppUpdater documents. - Quiescing: before installing, `UpdateManager` calls `AppViewModel.quiesceForUpdateInstall()` so a swap-and-relaunch cannot interrupt an in-flight capture. @@ -85,7 +85,7 @@ OpenWritr is distributed outside the Mac App Store. `UpdateManager` (AppUpdater Anything not listed here is hand-maintained. -- Generated, never hand-edit: `.build/` (SwiftPM output and the built `.app`), `dist/` and `.artifacts/` (release and evaluation output), `*.dmg` and `*.dmg.sha256`. All are git-ignored; regenerate with `swift build -c release`, `scripts/build-app.sh`, or the release scripts. +- Generated, never hand-edit: `.build/` (SwiftPM output and the built `.app`), `dist/` and `.artifacts/` (local build, broker download, and evaluation output), `*.dmg` and `*.dmg.sha256`. All are git-ignored; regenerate with `swift build -c release`, `scripts/build-app.sh`, or the broker request. - Generated by `gh aw compile`, never hand-edit: `.github/workflows/*.lock.yml` and `.github/aw/actions-lock.json`. Edit the matching agentic workflow Markdown file and recompile it instead. - Machine-owned: `Package.resolved`. Change it only by updating `Package.swift` or by merging a Dependabot PR. - Bundled, edit deliberately: `Sources/OpenWritr/Resources/cleanup-prompt-profiles.json`. Editing it changes shipped prompt defaults for every user. @@ -108,7 +108,7 @@ cp -R .build/release/OpenWritr.app /Applications/ open /Applications/OpenWritr.app ``` -The build script needs a Developer ID Application or Apple Development certificate in the local keychain. The app asks for Microphone and Accessibility permission on first use. +The local diagnostic build script needs a Developer ID Application or Apple Development certificate in the local keychain. The app asks for Microphone and Accessibility permission on first use. Release signing and notarization happen only in the broker. ## Validate before proposing a change @@ -132,15 +132,16 @@ swift test full commit SHAs with readable version comments. Update generated agentic workflow locks only through `gh aw compile`. - Release identity comes from `Info.plist` (`CFBundleShortVersionString` and `CFBundleVersion`). Bump both in a `chore(release): bump version to X.Y.Z` change before tagging. -- User-facing changes get an entry in `CHANGELOG.md` (`## [x.y.z] — date`). The release workflow publishes that section as the release notes and fails when it is missing or empty, or when Info.plist disagrees with the tag. +- User-facing changes get an entry in `CHANGELOG.md` (`## [x.y.z] — date`). The secretless publication handoff publishes that section as the release notes and fails when it is missing or empty. The broker verifies the tagged bundle version. - Commit messages use Conventional Commits (`fix(settings): …`, `chore(release): …`). ## Do not do these - Do not rewrite history, force push, or delete branches. `main` blocks force pushes and deletion and requires the `Secret Scan` check. -- Do not commit secrets, tokens, credentials, certificates, or personal data. `.release.env` is git-ignored; `.release.env.example` is the template. -- Do not publish a release, create or move tags, dispatch the release workflow, or change repository settings. The maintainer (`@trsdn`) does this. -- Do not add a build-attestation policy to `UpdateManager` or attest the update DMG (see In-app updates). +- Do not commit secrets, tokens, credentials, certificates, notary profiles, or personal data. +- Do not publish a release, create or move tags, dispatch the notarization broker, run the publication handoff, or change repository settings. The explicitly authorized maintainer (`@trsdn`, numeric actor ID `24534196`) does this. +- Do not add a build-attestation policy to `UpdateManager`, and do not attest either OpenWritr DMG (see In-app updates). +- Do not add Apple credentials, certificates, notary profiles, release environments, or credential-reading workflows to OpenWritr. The broker is the only home for release credentials. - Do not add private dictation or real transcripts to `eval/cleanup-cases.json`; synthetic or explicitly approved text only. - Do not run destructive commands against the user's machine or data: no `defaults delete com.openwritr.app`, no removal of `~/Library` state, no `tccutil reset`, no `security delete-keychain` outside `.build/`. - Do not hand-edit the generated paths listed above. @@ -148,17 +149,18 @@ swift test ## Credentials and revocation -Release credentials are held as secrets in the GitHub `release` environment and -are never in the tree. Only the signing/notarization job uses that environment. -If one is exposed, revoke it at its source first, then update the environment -secret. - -| Credential | Where it lives | If exposed | -|---|---|---| -| `MACOS_CERTIFICATE`, `MACOS_CERTIFICATE_PWD` (Developer ID Application `.p12`) | GitHub `release` environment secrets | Revoke the certificate in the Apple Developer portal, issue a new one, re-export the `.p12`, update both secrets. Maintainer only. | -| `APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_APP_PASSWORD` | GitHub `release` environment secrets | Revoke the app-specific password at appleid.apple.com, create a new one, update `APPLE_APP_PASSWORD`. Maintainer only. | -| Local notary profile (`xcrun notarytool store-credentials`) | The maintainer's login keychain | Revoke the app-specific password as above and store the profile again. | -| User-entered provider API keys | The user's macOS Keychain (`KeychainStore`) | The user revokes the key with the provider and enters a new one in Settings. The repository holds none. | +OpenWritr has no release credential. `MACOS_CERTIFICATE`, +`MACOS_CERTIFICATE_PWD`, `APPLE_ID`, `APPLE_TEAM_ID`, and +`APPLE_APP_PASSWORD` live only in the broker's protected `macos-signing` +environment. Those five names must not exist as OpenWritr repository or +environment secrets. The broker's security policy owns their rotation and +revocation procedure. + +A local signing identity may exist in a maintainer's login keychain for +diagnostic builds, but OpenWritr scripts never export, upload, or configure it +and no local notary profile is part of the release path. User-entered provider +API keys remain in the user's macOS Keychain (`KeychainStore`); if exposed, the +user revokes the key with the provider and enters a new one in Settings. ## Attribution diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db42f7..35e3483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ All notable changes to OpenWritr will be documented in this file. Each release's notes on GitHub are generated from its section here, and the -release workflow fails when the section for a tag is missing or empty. +broker publication handoff fails when the section for a tag is missing or empty. ## [1.6.6] — 2026-09-22 diff --git a/README.md b/README.md index 97b12eb..8648269 100644 --- a/README.md +++ b/README.md @@ -86,8 +86,14 @@ The default comparison covers Apple Intelligence, Luna, Gemini Flash, MAI Flash, ### Signed DMG release -The release flow builds a Developer ID signed app, notarizes and staples the app bundle, packages a -ZIP from that notarized app, then creates and notarizes a DMG. GitHub Releases for `v*` tags receive: +Distributable builds come from the public +[`trsdn/macos-notarization-broker`](https://github.com/trsdn/macos-notarization-broker) +profile `openwritr`. The broker resolves an immutable tag, builds without +secrets, validates on a fresh runner, then signs, notarizes, staples, and +packages with broker-owned code and credentials. OpenWritr has no Apple +certificate or notary secret, and its workflows never sign or notarize. + +GitHub Releases receive exactly: - `OpenWritr-v{version}-macOS-arm64.zip` - `OpenWritr-v{version}-macOS-arm64.zip.sha256` @@ -96,24 +102,22 @@ ZIP from that notarized app, then creates and notarizes a DMG. GitHub Releases f - `OpenWritr-{version}.dmg` (the same signed/notarized DMG bytes under the exact name required by [AppUpdater](#in-app-updates)) -The signing and notarization job reads its five credentials only from the -GitHub `release` environment. The required secret names and revocation steps are -listed in [AGENTS.md](AGENTS.md#credentials-and-revocation). - -For local releases, copy the example environment and store a notary profile once: +The maintainer requests the broker build from the broker checkout: ```sh -cp .release.env.example .release.env -xcrun notarytool store-credentials OpenWritr \ - --apple-id "your@email.com" \ - --team-id "G69Z5BNY97" \ - --password "app-specific-password" - -scripts/release_macos.sh +scripts/request.sh openwritr vX.Y.Z /path/to/OpenWritr/.artifacts/broker-release ``` -Important: if you distribute a ZIP, notarize and staple the `.app` before creating the archive. A -stapled DMG ticket alone does not protect ZIP distribution. +Then OpenWritr's secretless publication handoff creates a draft, runs the +read-only transcription smoke test against that draft, and publishes only +after the tag, checksums, exact five-asset contract, and smoke result pass. +See [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md) for the maintainer-only +procedure and authorization boundary. + +The broker attests the OpenWritr ZIP only. It deliberately does **not** attest +either DMG, because the AppUpdater alias and versioned DMG have the same digest +and an attestation for that digest can crash OpenWritr 1.6.0's updater path +(see [#31](https://github.com/trsdn/OpenWritr/issues/31)). ### In-app updates @@ -122,7 +126,7 @@ OpenWritr checks `trsdn/OpenWritr` GitHub Releases for newer, Developer ID-signe - Automatic checks run roughly every 24 hours (toggle: **Settings → Updates**); a manual check is also available from the menu bar. - Before installing, AppUpdater checks that the downloaded app has the same Developer ID Team ID, signing identifier and bundle identifier as the installed app. Nothing is installed from an unsigned or mismatched build. - **OpenWritr 1.6.0 cannot update itself.** It was built to require GitHub Artifact Attestation, which does not work with the current release pipeline (see [#31](https://github.com/trsdn/OpenWritr/issues/31)). It reports the update check as failed. Install the next release manually from the Releases page; later versions update themselves. -- The release workflow publishes an extra `OpenWritr-{version}.dmg` asset specifically for this update check, alongside the existing versioned ZIP/DMG downloads above. +- The broker publication handoff publishes an extra `OpenWritr-{version}.dmg` asset specifically for this update check, alongside the existing versioned ZIP/DMG downloads above. ## Privacy @@ -174,7 +178,10 @@ Releases follow [Semantic Versioning](https://semver.org): patch releases fix bu ## Verifying a download -Releases are built by the [release workflow](.github/workflows/release.yml), signed with a Developer ID certificate (Team ID `G69Z5BNY97`), and notarized by Apple. You can check that yourself: +Releases are built by the +[notarization broker](https://github.com/trsdn/macos-notarization-broker), +signed with a Developer ID certificate (Team ID `G69Z5BNY97`), and notarized +by Apple. You can check that yourself: ```sh shasum -a 256 -c OpenWritr-vX.Y.Z-macOS-arm64.zip.sha256 @@ -183,7 +190,13 @@ spctl --assess --type execute --verbose /Applications/OpenWritr.app # expect: xcrun stapler validate /Applications/OpenWritr.app ``` -This proves the app was signed by that Team ID and not altered afterwards. It does not prove which source commit it was built from: OpenWritr deliberately publishes no GitHub build attestation (see [#31](https://github.com/trsdn/OpenWritr/issues/31)). Third-party licences are bundled in `OpenWritr.app/Contents/Resources/Licenses/` and listed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). +This proves the app was signed by that Team ID and not altered afterwards. +The broker download also carries provenance naming the immutable OpenWritr +source commit. The ZIP may have GitHub build provenance from the broker, but +the DMGs deliberately do not (see [#31](https://github.com/trsdn/OpenWritr/issues/31)). +Third-party licences are bundled in +`OpenWritr.app/Contents/Resources/Licenses/` and listed in +[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). ## Support and security diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 9002028..30c700b 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -1,34 +1,28 @@ # OpenWritr Release Checklist -The tag-triggered GitHub Actions workflow is the canonical release path. The -maintainer prepares and tags the release; the workflow builds, signs, notarizes, -creates the draft, smoke-tests the verified workflow artifact attached to it, -and publishes it. +Distributable builds are produced by the public +[`trsdn/macos-notarization-broker`](https://github.com/trsdn/macos-notarization-broker) +profile `openwritr`. OpenWritr has no Apple certificate or notarization secret, +and no OpenWritr workflow builds, signs, or notarizes a release. -## One-time repository setup +Only the maintainer `@trsdn` (GitHub numeric actor ID `24534196`) may create a +release tag, dispatch the broker, or run the publication handoff. Agents and +contributors prepare pull requests only. -The maintainer must create a GitHub environment named `release`, restrict its -deployment branches and tags to selected tags matching `v*`, and configure these -environment secrets: +This architecture depends on +[`trsdn/macos-notarization-broker#69`](https://github.com/trsdn/macos-notarization-broker/pull/69), +which aligns profile `openwritr`, creates the AppUpdater alias, and excludes +both OpenWritr DMG digests from attestation. Merge that broker pull request +before merging or using this release path. -- `MACOS_CERTIFICATE` -- `MACOS_CERTIFICATE_PWD` -- `APPLE_ID` -- `APPLE_TEAM_ID` -- `APPLE_APP_PASSWORD` - -These values must not remain repository-level Actions secrets after the -environment migration is verified. The environment and secret migration are -repository settings; the workflow cannot create or migrate them. - -## 1. Maintainer: prepare the release +## 1. Prepare and merge the release - [ ] Work on a pull-request branch; do not release unreviewed local changes. -- [ ] Set both `CFBundleShortVersionString` and `CFBundleVersion` in `Info.plist` - to `x.y.z`. +- [ ] Set both `CFBundleShortVersionString` and `CFBundleVersion` in + `Info.plist` to `x.y.z`. - [ ] Add a non-empty `## [x.y.z] — YYYY-MM-DD` section to `CHANGELOG.md`. - [ ] Leave no release entries under an `Unreleased` heading. -- [ ] Run the required validation: +- [ ] Run: ```sh swift build -c release -Xswiftc -warnings-as-errors @@ -36,107 +30,125 @@ repository settings; the workflow cannot create or migrate them. swift test ``` -- [ ] Merge the release-preparation pull request and confirm the intended commit - is on `main`. +- [ ] Merge the release-preparation pull request and confirm the intended + commit is on `main`. -## 2. Maintainer: create the release tag +## 2. Create the immutable tag -- [ ] Create and push `vx.y.z` at the prepared `main` commit. This explicit tag - push is the release trigger. -- [ ] Confirm the **Release macOS** workflow started for that tag. +- [ ] Create and push `vx.y.z` at the prepared `main` commit. +- [ ] Do not move or reuse a release tag. The broker and publication handoff + both resolve annotated or lightweight tags to the immutable commit and + fail if the tag moves. -Do not build or upload release assets manually during the normal path. Do not -dispatch the workflow for a new release instead of pushing its tag. +Pushing the tag does not run a release workflow in OpenWritr. This is +intentional: source-repository automation has no signing secret and no token +that can access broker secrets. -## 3. Workflow: build and publish +## 3. Request the broker build -The workflow performs these actions without maintainer intervention: +Use a clean checkout of `trsdn/macos-notarization-broker` at its current +`origin/main`. The canonical request is: -1. Validates the triggering tag and commit, `Info.plist`, and changelog entry. -2. Uses the `release` environment to build, Developer ID-sign, notarize, staple, - and verify the app and disk image. -3. Resolves the live remote tag again, requires it still points to the triggering - commit, and passes the verified files to a separate job that creates or - updates a **draft** GitHub release. A new draft may be empty; a rerun may - contain only the five expected asset names. Any unexpected stale asset fails - the workflow instead of being published. -4. Downloads the same immutable workflow artifact without release-write access, - installs its DMG, verifies Gatekeeper and notarization, and runs the - transcription smoke test. -5. After the smoke test, resolves the live remote tag again and requires it - still points to the triggering commit and rechecks the exact five-asset set - before publishing the draft, then verifies the public DMG is the tested file. +```sh +cd /path/to/macos-notarization-broker +scripts/request.sh openwritr vx.y.z /path/to/OpenWritr/.artifacts/broker-release +``` -The release contains exactly these five public assets: +Do **not** add `--publish`. The broker command authorizes the fixed maintainer, +resolves the tag to a full commit, builds without secrets, validates on a fresh +runner, signs and notarizes with broker-owned code, downloads only the +correlated workflow artifact, and verifies `provenance.json` plus every digest. + +The broker profile must produce: - `OpenWritr-vx.y.z-macOS-arm64.zip` - `OpenWritr-vx.y.z-macOS-arm64.zip.sha256` - `OpenWritr-vx.y.z-macOS-arm64.dmg` - `OpenWritr-vx.y.z-macOS-arm64.dmg.sha256` -- `OpenWritr-x.y.z.dmg` — the same notarized DMG bytes under the exact name - required by AppUpdater - -Release notes come from the matching `CHANGELOG.md` section. Do not write or -replace them manually. - -**Never attest `OpenWritr-x.y.z.dmg`.** OpenWritr intentionally has no updater -attestation policy; restoring one or attesting the update DMG can strand or crash -installed clients (see #31). +- `OpenWritr-x.y.z.dmg` and its broker checksum +- `provenance.json` and `preflight-manifest.json` -## 4. Maintainer: monitor and recover +The updater alias is a byte-identical broker `copy_of` of the versioned DMG. +The broker may attest the ZIP, but it must not attest either OpenWritr DMG: +both DMG names have the same digest, and any attestation for that digest can +crash the updater path in OpenWritr 1.6.0 (see #31). -- [ ] Confirm **Build signed and notarized macOS artifacts** passed. -- [ ] Confirm **Create or update the draft release** passed. -- [ ] Confirm **Smoke-test the release before publishing** passed. -- [ ] Confirm **Publish the release** passed. Its smoke-test job summary is the - `R05` record described in - [docs/release-smoke-tests.md](docs/release-smoke-tests.md). -- [ ] Confirm the release page is public and lists all five exact asset names. +## 4. Create the draft, smoke-test, and publish -If signing, notarization, networking, or a runner fails transiently, rerun the -existing tag with `workflow_dispatch`, selecting that `vx.y.z` tag as the -workflow ref. For example: +The broker prints the verified artifact directory. From a clean OpenWritr +checkout at the release tag or current `main`, run: ```sh -gh workflow run release.yml --ref vx.y.z +scripts/publish_broker_release.sh \ + vx.y.z \ + .artifacts/broker-release/openwritr-x.y.z-req-REQUEST_ID ``` -Using the tag as the workflow ref is required by the `release` environment's -`v*` deployment restriction. There is no independent version input: the workflow -derives the release identity from the triggering tag, checks out its fully -qualified `refs/tags/vx.y.z` ref, and verifies that `HEAD` is that tag's commit. -The rerun rebuilds the immutable tagged commit and may replace assets only while -the release remains a draft. +This secretless handoff: + +1. requires the authorized maintainer's numeric GitHub identity; +2. resolves the broker run and artifact recorded in the supplied provenance, + requires the fixed broker repository/workflow/actor, successful `main` run, + commit and attempt, then redownloads that artifact by immutable artifact ID + and verifies GitHub's SHA-256 for the artifact archive; +3. validates broker provenance, source repository ID, tag, full commit SHA, + profile digest, signed bundle/team identity, artifact names, checksums, + preflight identity, the byte-identical AppUpdater alias, and a broker + attestation manifest containing the ZIP only and neither DMG; +4. resolves the live remote tag and requires the same commit; +5. extracts release notes from the tagged `CHANGELOG.md` section; +6. requires that no release or draft already exists, atomically creates a new + **draft** release, and uploads exactly the five public assets without + clobbering; +7. downloads all five draft assets again and requires byte equality with the + authenticated broker artifact; +8. dispatches the read-only `Release smoke test` workflow against that draft + and waits for it to pass; +9. rechecks the tag and draft state and redownloads all five assets before + publication; and +10. publishes the draft, then redownloads all five public assets and requires + byte equality with the authenticated broker artifact. + +Any failure before publication leaves a draft. To retry the same immutable tag, +the maintainer must first delete that unpublished draft, then start a new +handoff; the script never resumes, updates, or clobbers an existing release. +This is the per-tag serialization boundary. If the final post-publication +verification reports an error, the release is already public and must be +treated as a release incident; do not replace its assets. Corrections require a +new version and tag. + +The public release contains exactly: -The workflow refuses to overwrite an already-public release. If code, scripts, -metadata, release notes, or assets need a fix, prepare and tag a **new version**. -Never move or reuse the published tag. A failed draft may be deleted by the -maintainer before creating that new version. +- `OpenWritr-vx.y.z-macOS-arm64.zip` +- `OpenWritr-vx.y.z-macOS-arm64.zip.sha256` +- `OpenWritr-vx.y.z-macOS-arm64.dmg` +- `OpenWritr-vx.y.z-macOS-arm64.dmg.sha256` +- `OpenWritr-x.y.z.dmg` -## 5. Optional local rehearsal or recovery +`provenance.json`, `preflight-manifest.json`, and the updater alias's redundant +checksum remain in the verified broker download; they are not public release +assets because OpenWritr's established release contract is exactly five files. -Local release commands are optional diagnostics, not the canonical release -procedure and not a substitute for the tag-triggered workflow. They do not -create or publish a GitHub release. +## 5. Verify the public release -With a local Developer ID identity and notary profile configured: +- [ ] Confirm the broker run passed, including its protected sign job. +- [ ] Confirm the correlated smoke-test run passed and its job summary records + the installed version, Gatekeeper/notarization checks, and transcription. +- [ ] Confirm the release is public and has exactly the five asset names above. +- [ ] Confirm the primary public DMG digest matches the broker download. -```sh -cp .release.env.example .release.env -version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' Info.plist)" -scripts/release_macos.sh "$version" -``` +Release notes come only from `CHANGELOG.md`. Never write replacement notes by +hand, never upload locally built files, and never attest either OpenWritr DMG. + +## Local diagnostic build -The local script uses the versioned asset base -`dist/OpenWritr-v${version}-macOS-arm64`. Verify those local outputs directly: +Local tools are for development checks only: ```sh -xcrun stapler validate .build/release/OpenWritr.app -spctl --assess --type execute --verbose=2 .build/release/OpenWritr.app -xcrun stapler validate "dist/OpenWritr-v${version}-macOS-arm64.dmg" -spctl --assess --type open --context context:primary-signature \ - --verbose=2 "dist/OpenWritr-v${version}-macOS-arm64.dmg" +scripts/build-app.sh +scripts/make_dmg.sh ``` -Do not upload locally produced files over a public release. Any recovered -release still goes through a new version and the canonical workflow. +They use a signing identity already present in the local keychain and never +configure, export, upload, or notarize with Apple credentials. Their output is +not a release candidate and must not be uploaded to GitHub Releases. diff --git a/Sources/OpenWritr/UpdateManager.swift b/Sources/OpenWritr/UpdateManager.swift index 064bdd8..db50484 100644 --- a/Sources/OpenWritr/UpdateManager.swift +++ b/Sources/OpenWritr/UpdateManager.swift @@ -52,9 +52,11 @@ final class UpdateManager { /// No `GitHubAttestationPolicy`, for two reasons (see #31): /// - /// - AppUpdater only accepts a branch ref (`refs/heads/…`) as the attested source, - /// and releases are built by a tag push, whose provenance names `refs/tags/vX.Y.Z`. - /// Every release would be rejected. + /// - Release signing runs in `trsdn/macos-notarization-broker`, not this source + /// repository, so broker provenance cannot satisfy a source-repository policy. + /// The broker also deliberately never attests either OpenWritr DMG: the updater + /// alias and versioned DMG share one digest, and 1.6.0 can crash if that digest + /// has an attestation. /// - It loads its Sigstore trust roots through SwiftPM's `Bundle.module`, which, in a /// `swift build` product, only looks at the `.app` root and the build machine's /// `.build` path, never at `Contents/Resources`. Verifying an attestation therefore diff --git a/docs/release-smoke-tests.md b/docs/release-smoke-tests.md index 1aa6753..e67a774 100644 --- a/docs/release-smoke-tests.md +++ b/docs/release-smoke-tests.md @@ -1,47 +1,60 @@ # Release smoke tests Criterion `R05`: the release artifact is installed as a consumer receives it -and its core function is exercised. This repository does it automatically, so no -maintainer has to. +and its core function is exercised before publication. ## How -[`smoke-test.yml`](../.github/workflows/smoke-test.yml) runs in every release -(called from `release.yml`) against the verified workflow artifact that was -attached to the **draft** release, before it is made public. It also runs on -demand for any published tag (`Actions → Release smoke test → Run workflow`). It: +The explicitly authorized maintainer first downloads a verified notarized +artifact with the public broker's canonical command: -1. downloads the DMG and checksum from the current workflow artifact during a - release run, or from a published GitHub release during an on-demand run, and - verifies the checksum; +```sh +scripts/request.sh openwritr vX.Y.Z /path/to/OpenWritr/.artifacts/broker-release +``` + +OpenWritr's `scripts/publish_broker_release.sh` uses the supplied provenance +only to locate the broker run. It verifies the fixed broker +repository/workflow/actor, successful run commit and attempt, redownloads the +artifact by immutable artifact ID, checks GitHub's artifact SHA-256, and then +validates the source/profile/signing provenance and file digests. It refuses to +run if any release or draft already exists for the tag, then creates a new +single-use draft with the exact five public assets and dispatches +[`smoke-test.yml`](../.github/workflows/smoke-test.yml). The workflow has only +`contents: read`; it can download the authenticated draft but cannot edit or +publish it. + +The workflow: + +1. downloads the versioned DMG and checksum from the draft release and verifies + the checksum; 2. installs the app to `/Applications`; -3. checks the signature, Gatekeeper assessment, and notarization ticket; +3. checks the signature, Gatekeeper assessment, and notarization ticket; and 4. synthesizes a spoken phrase, runs the installed app with `--self-test`, and requires `hello world smoke test` in the transcript. `--self-test` loads the shipped speech model and transcribes the file through the same code path as a recording, without a microphone (`Sources/OpenWritr/SelfTest.swift`). -After it passes, `release.yml`'s `publish` job re-resolves the remote tag and -requires it still points to the commit that triggered the release before making -the draft public. It then downloads the DMG through its public URL to verify it -against its checksum. If the smoke test fails or the tag moved, the release stays -a draft, so nobody, including the in-app updater, is offered an unverified build. +The maintainer-side handoff downloads and byte-compares all five draft assets +before the smoke test, waits for the correlated workflow run, then re-resolves +the immutable tag and redownloads all five assets before publication. After +publishing, it checks the exact five-name contract and compares all five public +assets again. A failure before publication leaves the single-use draft in +place; the maintainer deletes that unpublished draft before a fresh retry. -The smoke-test job has only `contents: read`. It never needs permission to -create, edit, upload to, or publish a GitHub release. +This preserves smoke-before-publication without giving OpenWritr a source-side +token that can access broker secrets. Broker signing remains a separate, +manually authorized operation in `trsdn/macos-notarization-broker`. -The dated record is the job summary of each run: version, asset, macOS, what was -exercised, the transcript, and the result. Find it under the workflow run for the -release tag. +The dated record is the smoke-test job summary: version, asset, macOS, what was +exercised, transcript, and result. ## What it does not cover The hotkey, microphone capture, and pasting need a person at a logged-in desktop -and cannot run on a hosted runner. They are the parts the unit tests and the -per-change manual check in the pull request template are for. - -## Status +and cannot run on a hosted runner. They are covered by unit tests and the +per-change manual check in the pull request template. -The first run was for `v1.6.5` (run 35534329100), which published first and tested -afterwards. The draft-then-publish order applies from the next release; until a -release has gone through it, that order is untested. +The broker may attest OpenWritr's ZIP, but it must never attest either DMG. The +AppUpdater alias is byte-identical to the primary DMG, so both names share one +digest; an attestation for either would violate the updater safety rule from +issue #31. diff --git a/docs/self-assessment.md b/docs/self-assessment.md index ad0780e..24e7d2d 100644 --- a/docs/self-assessment.md +++ b/docs/self-assessment.md @@ -51,7 +51,7 @@ Automation Availability does not apply: hosted runners are available and used. | B01 | pass | Description says what it is: native macOS menu bar app for push-to-talk voice-to-text, local Parakeet transcription, optional cleanup. | | B02 | pass | README states purpose, status ("actively maintained"), install and usage, and links (site, download, changelog, licence). Audience is stated through purpose and the Requirements section (macOS 14+, Apple Silicon). | | B03 | pass | `LICENSE` is MIT, detected by GitHub. | -| B04 | pass | `.gitignore` covers `.build/`, `.swiftpm/`, `DerivedData/`, `dist/`, `.artifacts/`, `.release.env`, disk images, Xcode output. Searched the tree for key, token and private-key patterns: only variable initialisation in `scripts/setup_notarization.sh` and `openssl rand` in the workflow. `.release.env.example` holds a Team ID and identity name, not a secret. The committed badge SVG is generated output the record documents. | +| B04 | pass | `.gitignore` covers `.build/`, `.swiftpm/`, `DerivedData/`, `dist/`, `.artifacts/`, disk images, and Xcode output. OpenWritr contains no Apple credential setup script, release environment, certificate import, or notarization secret surface; those exist only in the public broker. The committed badge SVG is generated output the record documents. | | B05 | pass | `AGENTS.md` documents `swift build -c release -Xswiftc -warnings-as-errors` and `swift test`. The latest `CI` run on `main` (push, `34fdc90`) is green. The assessor also ran both commands from a clean copy and both exited 0. | | B06 | pass | Merge commits and rebase merges are disabled, only squash is on; `main` is protected with required checks; README states squash merges. No open critical Dependabot alert, no open secret-scanning alert, code scanning not enabled. | | B07 | pass | `Package.swift` declares `swift-tools-version: 6.0`, `.macOS(.v14)` and both dependencies; `Package.resolved` pins them; README states macOS 14+ and Apple Silicon. | @@ -60,8 +60,8 @@ Automation Availability does not apply: hosted runners are available and used. | B10 | pass | README status sentence; `.github/CODEOWNERS` names `@trsdn`; the account owns the repository. | | B11 | pass | This record, dated 2026-09-20. | | B12 | pass | Topic `trsdn-standard` is present. | -| B13 | pass | Each fact has one home. Build, run, and validation commands: `AGENTS.md` (the README and the site link to it). Release secrets and revocation: `AGENTS.md` (the README links). Requirements: the README (`AGENTS.md` links). The two earlier disagreements are fixed. | -| B14 | pass | `AGENTS.md` "Credentials and revocation" names each credential class, where it lives and who replaces it. | +| B13 | pass | Each fact has one home. Build, run, and validation commands: `AGENTS.md` (the README and the site link to it). The broker-owned release credential boundary and prohibition on OpenWritr secrets: `AGENTS.md`; the maintainer release procedure: `RELEASE_CHECKLIST.md`. Requirements: the README (`AGENTS.md` links). | +| B14 | pass | `AGENTS.md` states that OpenWritr owns no release credentials, names the five obsolete OpenWritr secret names, and points revocation/rotation to the broker security policy. | | B15 | pass | `THIRD_PARTY_NOTICES.md` lists the three linked packages and how the obligations are met. The v1.6.4 artifact does not yet carry the licence texts the notice promises; that is recorded under `I03`. | | B16 | pass | Branch protection on `main`: `allow_force_pushes` false, `allow_deletions` false. | @@ -97,8 +97,8 @@ Automation Availability does not apply: hosted runners are available and used. | S08 | pass | `.github/dependabot.yml` covers `github-actions` and `swift`, weekly; single maintainer owns triage. | | S09 | pass | `main` protection requires `Secret Scan` and `Build and test`, strict; both checks exist and run. | | S10 | pass | `AGENTS.md` names components and constraints (update attestation must not be added, asset naming AppUpdater needs, `Package.resolved` machine-owned, release identity from `Info.plist`). | -| S11 | pass | All four workflows declare `permissions` (`contents: read`, and `contents: write` for `release.yml`, which creates releases, and `stats.yml`, which commits the card). | -| S12 | pass | `actions/checkout@v7` and `actions/upload-artifact@v7` are GitHub-published, major tags; `trsdn/.github/.github/workflows/repo-stats.yml@main` is within the same account. | +| S11 | pass | Every workflow declares permissions. All application/review/smoke workflows are read-only; only `stats.yml` writes, narrowly to the generated stats branch. Release mutation happens in an explicitly authorized maintainer-side script with the maintainer's `gh` credentials, not an Actions token. | +| S12 | pass | Hand-maintained workflow actions are pinned to full commit SHAs, and the shared conformance/stats workflows are pinned to reviewed commits. | | S13 | na | No workflow uses `pull_request_target` or `workflow_run` (all four workflows read). | ## Deployable @@ -120,12 +120,12 @@ Assessed on the latest release, `v1.6.4` (2026-09-19), assets `OpenWritr-v1.6.4- |---|---|---| | R01 | pass | Name, version, description, copyright, licence, and repository and issue URLs all have a home in `Info.plist`, and `AGENTS.md` states why (`Package.swift` has no fields for them). The published `v1.6.5` bundle carries them, and they agree with the GitHub metadata. | | R02 | pass | README "Versioning and compatibility" names SemVer and states what each kind of release means. | -| R03 | pass | Tag `v1.6.4` points at commit `a15b547`; `release.yml` triggers on `v*` tags and the run for that tag succeeded. | +| R03 | pass | Existing release tags are immutable. New releases use the public broker's manually authorized `openwritr` request; both the broker and the OpenWritr publication handoff resolve the tag to a full commit and fail if it moves. | | R04 | pass | Tag `v1.6.4`, bundle `CFBundleShortVersionString` 1.6.4 (read from the download), title "OpenWritr 1.6.4". | -| R05 | pass | `smoke-test.yml` ran for `v1.6.5` in the release run (which published first; since #56 it runs against the draft and publishing depends on it) (Actions run 35534329100, job `Smoke-test the published release`, conclusion success): it downloaded the published DMG, verified its checksum, installed it, checked Gatekeeper and notarization, and transcribed a synthesized phrase through `--self-test`, without anyone operating the product. The job summary is the dated record. | +| R05 | pass | `publish_broker_release.sh` binds the candidate to a successful fixed-identity broker run and GitHub artifact digest. It refuses any pre-existing release/draft, creates a single-use exact five-asset draft without clobbering, and byte-compares all five downloaded draft assets with the authenticated broker output. `smoke-test.yml` verifies checksum, bundle identifier, Developer ID Team, Gatekeeper acceptance and notarization, then transcribes a synthesized phrase through `--self-test` without an operator. The handoff waits for the correlated run, rechecks the immutable tag and all five draft bytes, publishes, and verifies the exact public contract plus all five bytes again. | | R06 | pass | Release notes: "### Fixed - Brought the Settings window to the front ... (#42)", specific, nothing breaking to warn about. | -| R07 | pass | Release body equals the 1.6.4 changelog entry plus a "Full changelog" link; the entry exists and is not empty. `release.yml` on `main` gates on it and passes it as the notes. | -| R08 | pass | Developer ID signature (Team `G69Z5BNY97`) and stapled notarization verified on the download (`codesign` valid, `spctl` "accepted, source=Notarized Developer ID", `stapler validate` worked); README "Verifying a download" gives the commands and says the attestation is deliberately not published (#31) and that this does not prove the source commit. `gh attestation verify` finds none, as stated. | +| R07 | pass | Release notes come from the tagged `CHANGELOG.md` section. The publication handoff fails when the entry is missing, empty, or still under `Unreleased`; it never accepts freestanding notes. | +| R08 | pass | Developer ID signature (Team `G69Z5BNY97`) and stapled notarization are verified by the broker and smoke test. Broker provenance records the immutable source commit. The broker may attest the ZIP only and statically excludes both OpenWritr DMGs, because their shared digest must have no attestation (#31). | ## Product Identity @@ -138,7 +138,7 @@ Read from the downloaded `v1.6.4` ZIP (`OpenWritr.app/Contents/Info.plist`). | I03 | pass | The published `v1.6.5` bundle carries `NSHumanReadableCopyright`, `OpenWritrLicense` (`MIT`), and `Contents/Resources/Licenses/` with the OpenWritr licence, the dependency licences, and `THIRD_PARTY_NOTICES.md`. | | I04 | pass | Source read, app not operated: `AboutView.swift` shows "Version X", and links the repository, "Report an Issue" and the licence. | | I05 | pass | `CFBundleIconFile` = `AppIcon`, `AppIcon.icns` in the bundle; the site's `icon.svg`, `icon-192.png`, `apple-touch-icon.png` and `favicon.ico` show the same icon (compared visually). No store listing exists. | -| I06 | pass | `release.yml` passes `OPENWRITR_VERSION` from the tag and `build-app.sh` writes it into the bundle after a gate checks `Info.plist` against the tag; the other identity values are constants in `Info.plist`, one source-controlled place the build copies into the artifact. | +| I06 | pass | The broker resolves the immutable tag, requires the source `Info.plist` version to match through its `openwritr` adapter, and stamps the release bundle from that request. Other identity values remain constants in the source-controlled plist and reviewed broker profile. | ## Documentation @@ -172,7 +172,7 @@ Source read only (`docs/index.html`); the page was not rendered, except that the |---|---|---| | G01 | pass | `AGENTS.md` at the root. | | G02 | pass | Purpose, layout and commands are stated, and validation is named as the one to use. The command was run by the assessor from a clean copy and succeeded. Build and run commands are stated (`scripts/build-app.sh`, `open`). | -| G03 | pass | "Do not do these" covers history rewriting, force pushes, secrets, releases (no tags, no dispatching the release workflow), and data-destructive commands (`defaults delete`, `tccutil reset`, keychain deletion). Deployments were treated as not applicable: nothing is deployed. | +| G03 | pass | "Do not do these" covers history rewriting, force pushes, secrets, releases (no tags, broker dispatch, or publication handoff), and data-destructive commands (`defaults delete`, `tccutil reset`, keychain deletion). Deployments were treated as not applicable: nothing is deployed. | | G04 | pass | `CLAUDE.md` imports `@AGENTS.md` and adds nothing. `.github/copilot-instructions.md` points at `AGENTS.md` and repeats its validation command; the repetition agrees, so it is not divergence under 1.15.0. | | G05 | pass | `AGENTS.md` names the validation command; CI runs it green on `main`. | | G06 | pass | `AGENTS.md` "Generated, vendored, and machine-owned paths" lists `.build/`, `dist/`, `.artifacts/`, disk images and `Package.resolved`; these are also in `.gitignore`. | diff --git a/scripts/build-app.sh b/scripts/build-app.sh index 4995911..0cc4d5e 100755 --- a/scripts/build-app.sh +++ b/scripts/build-app.sh @@ -3,19 +3,11 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -ENV_FILE="${RELEASE_ENV_FILE:-$PROJECT_DIR/.release.env}" BUILD_DIR="$PROJECT_DIR/.build/release" APP="$BUILD_DIR/OpenWritr.app" DEFAULT_BUNDLE_ID="com.openwritr.app" PREFERRED_IDENTITY="${OPENWRITR_SIGNING_IDENTITY:-${CODE_SIGN_IDENTITY:-}}" -if [[ -f "$ENV_FILE" ]]; then - set -a - . "$ENV_FILE" - set +a - PREFERRED_IDENTITY="${OPENWRITR_SIGNING_IDENTITY:-${CODE_SIGN_IDENTITY:-$PREFERRED_IDENTITY}}" -fi - find_signing_identity() { if [[ -n "$PREFERRED_IDENTITY" ]]; then security find-identity -v -p codesigning 2>/dev/null \ @@ -96,8 +88,8 @@ p['CFBundlePackageType'] = 'APPL' p['CFBundleDisplayName'] = 'OpenWritr' p['NSHighResolutionCapable'] = True p['LSMinimumSystemVersion'] = '14.0' -# The release workflow passes the version parsed from the tag, so the bundle -# identity is derived from the tag rather than maintained by hand. +# The broker passes the version parsed from the immutable tag for release +# builds. Local diagnostic builds normally use the checked-in version. version = os.environ.get('OPENWRITR_VERSION') if version: p['CFBundleShortVersionString'] = version diff --git a/scripts/make_dmg.sh b/scripts/make_dmg.sh index cd6f042..e8c2038 100755 --- a/scripts/make_dmg.sh +++ b/scripts/make_dmg.sh @@ -3,13 +3,6 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -ENV_FILE="${RELEASE_ENV_FILE:-$PROJECT_DIR/.release.env}" - -if [[ -f "$ENV_FILE" ]]; then - set -a - . "$ENV_FILE" - set +a -fi APP_NAME="OpenWritr" APP_PATH="${APP_PATH:-$PROJECT_DIR/.build/release/$APP_NAME.app}" diff --git a/scripts/notarize_app.sh b/scripts/notarize_app.sh deleted file mode 100755 index e267af1..0000000 --- a/scripts/notarize_app.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -ENV_FILE="${RELEASE_ENV_FILE:-$PROJECT_DIR/.release.env}" - -if [[ -f "$ENV_FILE" ]]; then - set -a - . "$ENV_FILE" - set +a -fi - -APP_PATH="${APP_PATH:-$PROJECT_DIR/.build/release/OpenWritr.app}" -ZIP_PATH="${ZIP_PATH:-$PROJECT_DIR/dist/OpenWritr-macos.zip}" -SUBMISSION_ROOT="${NOTARY_SUBMISSION_DIR:-$PROJECT_DIR/.build/notary-submissions}" -SUBMISSION_DIR="$SUBMISSION_ROOT/$$-${RANDOM}" -SUBMISSION_ZIP="$SUBMISSION_DIR/OpenWritr.zip" - -cleanup() { - rm -f -- "$SUBMISSION_ZIP" - rmdir -- "$SUBMISSION_DIR" 2>/dev/null || true -} -trap cleanup EXIT -trap 'exit 129' HUP -trap 'exit 130' INT -trap 'exit 143' TERM - -if [[ ! -d "$APP_PATH" ]]; then - echo "App bundle not found at $APP_PATH" - exit 1 -fi - -rm -f -- "$ZIP_PATH" "$ZIP_PATH.sha256" -mkdir -p "$(dirname "$ZIP_PATH")" "$SUBMISSION_DIR" -codesign --verify --deep --strict --verbose=2 "$APP_PATH" -ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$SUBMISSION_ZIP" - -if [[ -n "${NOTARY_PROFILE:-}" ]]; then - xcrun notarytool submit "$SUBMISSION_ZIP" --keychain-profile "$NOTARY_PROFILE" --wait -else - missing=() - for variable in APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD; do - if [[ -z "${!variable:-}" ]]; then - missing+=("$variable") - fi - done - - if [[ "${#missing[@]}" -gt 0 ]]; then - echo "Set NOTARY_PROFILE or provide APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD." - exit 1 - fi - - set +x - xcrun notarytool submit "$SUBMISSION_ZIP" \ - --apple-id "$APPLE_ID" \ - --team-id "$APPLE_TEAM_ID" \ - --password "$APPLE_APP_PASSWORD" \ - --wait -fi - -xcrun stapler staple "$APP_PATH" -xcrun stapler validate "$APP_PATH" -codesign --verify --deep --strict --verbose=2 "$APP_PATH" -spctl --assess --type execute --verbose=2 "$APP_PATH" - -ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "$ZIP_PATH" -( - cd "$(dirname "$ZIP_PATH")" - zip_name="$(basename "$ZIP_PATH")" - shasum -a 256 "$zip_name" > "$zip_name.sha256" - shasum -a 256 -c "$zip_name.sha256" -) - -echo "App notarization complete: $APP_PATH" -echo "ZIP created: $ZIP_PATH" -echo "Checksum created: $ZIP_PATH.sha256" \ No newline at end of file diff --git a/scripts/notarize_dmg.sh b/scripts/notarize_dmg.sh deleted file mode 100755 index a5422b8..0000000 --- a/scripts/notarize_dmg.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -ENV_FILE="${RELEASE_ENV_FILE:-$PROJECT_DIR/.release.env}" - -if [[ -f "$ENV_FILE" ]]; then - set -a - . "$ENV_FILE" - set +a -fi - -DMG_PATH="${DMG_PATH:-$PROJECT_DIR/dist/OpenWritr-macos.dmg}" - -if [[ ! -f "$DMG_PATH" ]]; then - echo "DMG not found at $DMG_PATH" - exit 1 -fi - -rm -f -- "$DMG_PATH.sha256" -codesign --verify --strict --verbose=2 "$DMG_PATH" - -if [[ -n "${NOTARY_PROFILE:-}" ]]; then - xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_PROFILE" --wait -else - missing=() - for variable in APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD; do - if [[ -z "${!variable:-}" ]]; then - missing+=("$variable") - fi - done - - if [[ "${#missing[@]}" -gt 0 ]]; then - echo "Set NOTARY_PROFILE or provide APPLE_ID APPLE_TEAM_ID APPLE_APP_PASSWORD." - exit 1 - fi - - set +x - xcrun notarytool submit "$DMG_PATH" \ - --apple-id "$APPLE_ID" \ - --team-id "$APPLE_TEAM_ID" \ - --password "$APPLE_APP_PASSWORD" \ - --wait -fi - -xcrun stapler staple "$DMG_PATH" -xcrun stapler validate "$DMG_PATH" -spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG_PATH" -hdiutil verify "$DMG_PATH" -( - cd "$(dirname "$DMG_PATH")" - dmg_name="$(basename "$DMG_PATH")" - shasum -a 256 "$dmg_name" > "$dmg_name.sha256" - shasum -a 256 -c "$dmg_name.sha256" -) - -echo "DMG notarization complete: $DMG_PATH" -echo "Checksum created: $DMG_PATH.sha256" diff --git a/scripts/publish_broker_release.sh b/scripts/publish_broker_release.sh new file mode 100755 index 0000000..f18bb35 --- /dev/null +++ b/scripts/publish_broker_release.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPOSITORY="trsdn/OpenWritr" +AUTHORIZED_ACTOR_ID="24534196" +BROKER_REPOSITORY="trsdn/macos-notarization-broker" +BROKER_REPOSITORY_ID="1315404585" +BROKER_WORKFLOW_ID="322509883" + +tag="${1:-}" +artifact_dir="${2:-}" +if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ || -z "$artifact_dir" ]]; then + echo "Usage: $0 vX.Y.Z BROKER_ARTIFACT_DIRECTORY" >&2 + exit 2 +fi + +for tool in cmp gh python3 shasum; do + command -v "$tool" >/dev/null 2>&1 || { + echo "Required tool not found: $tool" >&2 + exit 1 + } +done +gh auth status >/dev/null +actor_id="$(gh api user --jq '.id')" +if [[ "$actor_id" != "$AUTHORIZED_ACTOR_ID" ]]; then + echo "Only the authorized OpenWritr maintainer may create or publish releases." >&2 + exit 1 +fi + +version="${tag#v}" +artifact_dir="$(cd "$artifact_dir" && pwd)" +read -r broker_run_id broker_run_attempt broker_commit request_id < <( + python3 - "$artifact_dir/provenance.json" <<'PY' +import json +import pathlib +import re +import sys + +path = pathlib.Path(sys.argv[1]) +if path.is_symlink() or not path.is_file(): + raise SystemExit("Supplied broker directory has no regular provenance.json") +provenance = json.loads(path.read_text(encoding="utf-8")) +broker = provenance.get("broker", {}) +values = ( + str(broker.get("run_id", "")), + str(broker.get("run_attempt", "")), + str(broker.get("commit_sha", "")), + str(provenance.get("request_id", "")), +) +patterns = (r"[0-9]+", r"[1-9][0-9]*", r"[0-9a-f]{40}", r"[A-Za-z0-9._-]{1,80}") +if any(re.fullmatch(pattern, value) is None for pattern, value in zip(patterns, values)): + raise SystemExit("Supplied broker provenance has invalid run metadata") +print("\t".join(values)) +PY +) + +read -r run_repository_id run_workflow_id run_event run_branch run_sha \ + run_attempt run_actor_id run_status run_conclusion < <( + gh api "repos/$BROKER_REPOSITORY/actions/runs/$broker_run_id" \ + --jq '[.repository.id, .workflow_id, .event, .head_branch, .head_sha, .run_attempt, .actor.id, .status, .conclusion] | @tsv' +) +if [[ "$run_repository_id" != "$BROKER_REPOSITORY_ID" || + "$run_workflow_id" != "$BROKER_WORKFLOW_ID" || + "$run_event" != "workflow_dispatch" || + "$run_branch" != "main" || + "$run_sha" != "$broker_commit" || + "$run_attempt" != "$broker_run_attempt" || + "$run_actor_id" != "$AUTHORIZED_ACTOR_ID" || + "$run_status" != "completed" || + "$run_conclusion" != "success" ]]; then + echo "Broker workflow run identity or conclusion does not match the provenance." >&2 + exit 1 +fi + +artifact_name="openwritr-${version}-${request_id}" +artifact_rows="$( + gh api "repos/$BROKER_REPOSITORY/actions/runs/$broker_run_id/artifacts" \ + --jq ".artifacts[] | select(.name == \"$artifact_name\") | [.id, .digest, .expired] | @tsv" +)" +artifact_count="$(awk 'NF { count += 1 } END { print count + 0 }' <<< "$artifact_rows")" +if [[ "$artifact_count" != "1" ]]; then + echo "Broker run does not contain exactly one correlated artifact named $artifact_name." >&2 + exit 1 +fi +read -r artifact_id artifact_digest artifact_expired <<< "$artifact_rows" +if [[ ! "$artifact_id" =~ ^[0-9]+$ || + ! "$artifact_digest" =~ ^sha256:[0-9a-f]{64}$ || + "$artifact_expired" != "false" ]]; then + echo "Broker workflow artifact is invalid, expired, or missing its GitHub digest." >&2 + exit 1 +fi + +work_dir="$(mktemp -d)" +notes_file="$work_dir/release-notes.md" +artifact_zip="$work_dir/broker-artifact.zip" +verified_dir="$work_dir/verified" +cleanup() { + rm -rf -- "$work_dir" +} +trap cleanup EXIT + +gh api "repos/$BROKER_REPOSITORY/actions/artifacts/$artifact_id/zip" > "$artifact_zip" +downloaded_digest="$(shasum -a 256 "$artifact_zip" | cut -d' ' -f1)" +if [[ "$downloaded_digest" != "${artifact_digest#sha256:}" ]]; then + echo "Downloaded broker artifact digest does not match GitHub's immutable artifact record." >&2 + exit 1 +fi +python3 - "$artifact_zip" "$verified_dir" <<'PY' +import pathlib +import stat +import sys +import zipfile + +archive = pathlib.Path(sys.argv[1]) +destination = pathlib.Path(sys.argv[2]) +destination.mkdir() +with zipfile.ZipFile(archive) as bundle: + for entry in bundle.infolist(): + path = pathlib.PurePosixPath(entry.filename) + mode = entry.external_attr >> 16 + if path.is_absolute() or ".." in path.parts or stat.S_ISLNK(mode): + raise SystemExit(f"Unsafe path in broker artifact: {entry.filename}") + bundle.extractall(destination) +PY + +artifact_dir="$verified_dir" +source_sha="$(python3 "$SCRIPT_DIR/verify_broker_artifacts.py" "$artifact_dir" "$tag")" +bash "$SCRIPT_DIR/verify_remote_release_tag.sh" "$tag" "$source_sha" "$REPOSITORY" + +asset_base="OpenWritr-v${version}-macOS-arm64" +asset_names=( + "$asset_base.zip" + "$asset_base.zip.sha256" + "$asset_base.dmg" + "$asset_base.dmg.sha256" + "OpenWritr-${version}.dmg" +) +assets=() +for name in "${asset_names[@]}"; do + path="$artifact_dir/$name" + [[ -f "$path" && ! -L "$path" ]] || { + echo "Expected release file is missing or unsafe: $path" >&2 + exit 1 + } + assets+=("$path") +done + +verify_release_bytes() { + local stage="$1" + local download_dir="$work_dir/release-$stage" + rm -rf -- "$download_dir" + mkdir -p "$download_dir" + gh release download "$tag" --repo "$REPOSITORY" --dir "$download_dir" \ + --pattern "$asset_base.zip" \ + --pattern "$asset_base.zip.sha256" \ + --pattern "$asset_base.dmg" \ + --pattern "$asset_base.dmg.sha256" \ + --pattern "OpenWritr-${version}.dmg" + for name in "${asset_names[@]}"; do + if ! cmp -s "$artifact_dir/$name" "$download_dir/$name"; then + echo "$stage release asset differs from the authenticated broker artifact: $name" >&2 + exit 1 + fi + done +} + +changelog="$( + gh api "repos/$REPOSITORY/contents/CHANGELOG.md?ref=$tag" \ + -H "Accept: application/vnd.github.raw" +)" +held="$( + printf '%s\n' "$changelog" | awk ' + tolower($0) ~ /^##[ \t]+\[?unreleased\]?/ { capture = 1; next } + capture && /^## / { exit } + capture { print } + ' | tr -d '[:space:]' +)" +if [[ -n "$held" ]]; then + echo "CHANGELOG.md still holds entries under Unreleased; promote them into $version." >&2 + exit 1 +fi +notes="$( + printf '%s\n' "$changelog" | awk -v version="$version" ' + BEGIN { gsub(/\./, "\\.", version) } + $0 ~ "^## \\[?" version "\\]?([ \t]|$)" { capture = 1; next } + capture && /^## / { exit } + capture { print } + ' +)" +if [[ -z "${notes//[[:space:]]/}" ]]; then + echo "CHANGELOG.md has no non-empty entry for $version." >&2 + exit 1 +fi +printf '%s\n' "$notes" > "$notes_file" + +if gh release view "$tag" --repo "$REPOSITORY" >/dev/null 2>&1; then + echo "Release or draft $tag already exists; refusing an overlapping or resumed handoff." >&2 + echo "For a failed unpublished attempt, the maintainer must delete the draft before starting again." >&2 + exit 1 +fi +gh release create "$tag" --repo "$REPOSITORY" \ + --verify-tag --draft --title "OpenWritr $version" --notes-file "$notes_file" + +bash "$SCRIPT_DIR/verify_release_asset_contract.sh" \ + subset "$tag" "$REPOSITORY" "${asset_names[@]}" +gh release upload "$tag" --repo "$REPOSITORY" "${assets[@]}" +bash "$SCRIPT_DIR/verify_release_asset_contract.sh" \ + exact "$tag" "$REPOSITORY" "${asset_names[@]}" +verify_release_bytes "draft-before-smoke" + +existing_runs="$( + gh run list --repo "$REPOSITORY" --workflow smoke-test.yml \ + --event workflow_dispatch --limit 30 --json databaseId --jq '.[].databaseId' +)" +gh workflow run smoke-test.yml --repo "$REPOSITORY" --ref main --field "tag=$tag" + +run_id="" +for _ in $(seq 1 30); do + run_id="$( + gh run list --repo "$REPOSITORY" --workflow smoke-test.yml \ + --event workflow_dispatch --branch main --limit 30 \ + --json databaseId,displayTitle \ + --jq ".[] | select(.displayTitle == \"Smoke-test OpenWritr $tag\") | .databaseId" | + while IFS= read -r candidate; do + if ! grep -Fxq "$candidate" <<< "$existing_runs"; then + printf '%s\n' "$candidate" + break + fi + done + )" + [[ -n "$run_id" ]] && break + sleep 2 +done +if [[ -z "$run_id" ]]; then + echo "Could not correlate the smoke-test workflow run for $tag; the draft remains unpublished." >&2 + exit 1 +fi +gh run watch "$run_id" --repo "$REPOSITORY" --exit-status + +bash "$SCRIPT_DIR/verify_remote_release_tag.sh" "$tag" "$source_sha" "$REPOSITORY" +bash "$SCRIPT_DIR/verify_release_asset_contract.sh" \ + exact "$tag" "$REPOSITORY" "${asset_names[@]}" +verify_release_bytes "draft-after-smoke" +if [[ "$(gh release view "$tag" --repo "$REPOSITORY" --json isDraft --jq .isDraft)" != "true" ]]; then + echo "Release $tag stopped being a draft before publication." >&2 + exit 1 +fi + +gh release edit "$tag" --repo "$REPOSITORY" --draft=false +if [[ "$(gh release view "$tag" --repo "$REPOSITORY" --json isDraft --jq .isDraft)" != "false" ]]; then + echo "Release $tag is still a draft." >&2 + exit 1 +fi +bash "$SCRIPT_DIR/verify_release_asset_contract.sh" \ + exact "$tag" "$REPOSITORY" "${asset_names[@]}" +verify_release_bytes "published" + +echo "Published https://github.com/$REPOSITORY/releases/tag/$tag after smoke run $run_id passed." diff --git a/scripts/release_macos.sh b/scripts/release_macos.sh deleted file mode 100755 index ec1fea4..0000000 --- a/scripts/release_macos.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -ENV_FILE="${RELEASE_ENV_FILE:-$PROJECT_DIR/.release.env}" - -if [[ -f "$ENV_FILE" ]]; then - set -a - . "$ENV_FILE" - set +a -fi - -if [[ "$#" -gt 1 ]]; then - echo "Usage: $0 [v]VERSION" >&2 - exit 1 -fi - -version_input="${1:-${RELEASE_VERSION:-}}" -if [[ -z "$version_input" ]]; then - version_input="$(git -C "$PROJECT_DIR" describe --tags --exact-match HEAD 2>/dev/null || true)" -fi -if [[ -z "$version_input" ]]; then - version_input="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$PROJECT_DIR/Info.plist")" -fi - -version="${version_input#v}" -if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then - echo "Invalid release version: $version_input" >&2 - echo "Usage: $0 [v]VERSION" >&2 - exit 1 -fi - -DIST_DIR="$PROJECT_DIR/dist" -ASSET_BASE="$DIST_DIR/OpenWritr-v${version}-macOS-arm64" -DMG_PATH="$ASSET_BASE.dmg" -APP_PATH="${APP_PATH:-$PROJECT_DIR/.build/release/OpenWritr.app}" -ZIP_PATH="$ASSET_BASE.zip" -CAN_NOTARIZE=false - -if [[ -n "${NOTARY_PROFILE:-}" || ( -n "${APPLE_ID:-}" && -n "${APPLE_TEAM_ID:-}" && -n "${APPLE_APP_PASSWORD:-}" ) ]]; then - CAN_NOTARIZE=true -fi - -mkdir -p "$DIST_DIR" -rm -f -- "$ZIP_PATH" "$ZIP_PATH.sha256" "$DMG_PATH" "$DMG_PATH.sha256" - -"$SCRIPT_DIR/build-app.sh" - -if [[ "$CAN_NOTARIZE" == true ]]; then - APP_PATH="$APP_PATH" ZIP_PATH="$ZIP_PATH" "$SCRIPT_DIR/notarize_app.sh" -else - echo "Skipping app notarization and ZIP packaging because neither NOTARY_PROFILE nor APPLE_ID, APPLE_TEAM_ID, and APPLE_APP_PASSWORD are set." -fi - -APP_PATH="$APP_PATH" DMG_PATH="$DMG_PATH" REQUIRE_NOTARIZED_APP="$CAN_NOTARIZE" "$SCRIPT_DIR/make_dmg.sh" - -if [[ "$CAN_NOTARIZE" == true ]]; then - DMG_PATH="$DMG_PATH" "$SCRIPT_DIR/notarize_dmg.sh" -else - echo "Skipping DMG notarization because neither NOTARY_PROFILE nor APPLE_ID, APPLE_TEAM_ID, and APPLE_APP_PASSWORD are set." - ( - cd "$DIST_DIR" - dmg_name="$(basename "$DMG_PATH")" - shasum -a 256 "$dmg_name" > "$dmg_name.sha256" - shasum -a 256 -c "$dmg_name.sha256" - ) -fi - -codesign --verify --deep --strict --verbose=2 "$APP_PATH" -hdiutil verify "$DMG_PATH" - -expected_artifacts=("$DMG_PATH" "$DMG_PATH.sha256") -if [[ "$CAN_NOTARIZE" == true ]]; then - xcrun stapler validate "$APP_PATH" - spctl --assess --type execute --verbose=2 "$APP_PATH" - xcrun stapler validate "$DMG_PATH" - spctl --assess --type open --context context:primary-signature --verbose=2 "$DMG_PATH" - expected_artifacts+=("$ZIP_PATH" "$ZIP_PATH.sha256") -fi - -for artifact in "${expected_artifacts[@]}"; do - if [[ ! -f "$artifact" ]]; then - echo "Expected release artifact is missing: $artifact" >&2 - exit 1 - fi -done - -( - cd "$DIST_DIR" - shasum -a 256 -c "$(basename "$DMG_PATH").sha256" - if [[ "$CAN_NOTARIZE" == true ]]; then - shasum -a 256 -c "$(basename "$ZIP_PATH").sha256" - fi -) - -echo "Release artifacts for v${version} are in $DIST_DIR/." diff --git a/scripts/setup_notarization.sh b/scripts/setup_notarization.sh deleted file mode 100755 index aafb528..0000000 --- a/scripts/setup_notarization.sh +++ /dev/null @@ -1,660 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -set +x - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_DIR="$(dirname "$SCRIPT_DIR")" -RELEASE_ENV_FILE="$PROJECT_DIR/.release.env" - -repository="${OPENWRITR_REPOSITORY:-trsdn/OpenWritr}" -release_environment="${OPENWRITR_RELEASE_ENVIRONMENT:-release}" -team_id="${APPLE_TEAM_ID:-G69Z5BNY97}" -profile="${NOTARY_PROFILE:-OpenWritr}" - -apple_id="" -apple_app_password="" -apple_app_password_confirmation="" -dialog_result="" -secret_names="" -gui_mode=false - -cleanup_secrets() { - apple_id="" - apple_app_password="" - apple_app_password_confirmation="" - dialog_result="" - unset apple_id apple_app_password apple_app_password_confirmation dialog_result APPLE_APP_PASSWORD -} - -handle_signal() { - local status="$1" - cleanup_secrets - trap - EXIT HUP INT TERM - exit "$status" -} - -trap cleanup_secrets EXIT -trap 'handle_signal 129' HUP -trap 'handle_signal 130' INT -trap 'handle_signal 143' TERM - -unset APPLE_APP_PASSWORD -export GH_PROMPT_DISABLED=1 -unset GH_DEBUG - -usage() { - cat <<'EOF' -Usage: scripts/setup_notarization.sh [options] - -Configure release-environment notarization secrets and a local notarytool profile. - -Options: - --repo OWNER/REPO GitHub repository (default: trsdn/OpenWritr) - --environment NAME GitHub environment (default: release) - --team-id ID Apple Developer team ID (default: G69Z5BNY97) - --profile NAME Local notarytool profile (default: OpenWritr) - --gui Read credentials from secure macOS dialogs (no TTY needed) - -h, --help Show this help - -Environment overrides: - OPENWRITR_REPOSITORY, OPENWRITR_RELEASE_ENVIRONMENT, APPLE_TEAM_ID, - NOTARY_PROFILE - -By default, credentials are read from an interactive terminal. With --gui, -the Apple ID and hidden app-specific password are read from macOS dialogs. -EOF -} - -die() { - printf 'Error: %s\n' "$1" >&2 - exit 1 -} - -while [[ "$#" -gt 0 ]]; do - case "$1" in - --repo) - [[ "$#" -ge 2 && -n "$2" ]] || die "--repo requires OWNER/REPO." - repository="$2" - shift 2 - ;; - --repo=*) - repository="${1#*=}" - shift - ;; - --environment) - [[ "$#" -ge 2 && -n "$2" ]] || die "--environment requires a name." - release_environment="$2" - shift 2 - ;; - --environment=*) - release_environment="${1#*=}" - shift - ;; - --team-id) - [[ "$#" -ge 2 && -n "$2" ]] || die "--team-id requires a value." - team_id="$2" - shift 2 - ;; - --team-id=*) - team_id="${1#*=}" - shift - ;; - --profile) - [[ "$#" -ge 2 && -n "$2" ]] || die "--profile requires a value." - profile="$2" - shift 2 - ;; - --profile=*) - profile="${1#*=}" - shift - ;; - --gui) - gui_mode=true - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - die "Unknown option: $1" - ;; - esac -done - -[[ "$repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] \ - || die "Repository must use the OWNER/REPO format." -[[ "$release_environment" =~ ^[A-Za-z0-9._-]+$ ]] \ - || die "Environment may contain only letters, digits, dots, underscores, and hyphens." -[[ "$team_id" =~ ^[A-Z0-9]{10}$ ]] \ - || die "Apple Developer team ID must be 10 uppercase letters or digits." -[[ "$profile" =~ ^[A-Za-z0-9._-]+$ ]] \ - || die "Profile may contain only letters, digits, dots, underscores, and hyphens." - -if [[ "$gui_mode" != true ]] && [[ ! -t 0 || ! -t 2 ]]; then - die "An interactive terminal is required; use --gui from an active macOS GUI session for a non-TTY runner." -fi - -for tool in gh xcrun security git; do - command -v "$tool" >/dev/null 2>&1 || die "Required tool not found: $tool" -done - -if [[ "$gui_mode" == true ]]; then - [[ "$(uname -s)" == "Darwin" ]] || die "--gui requires macOS." - command -v osascript >/dev/null 2>&1 || die "Required tool not found: osascript" - command -v python3 >/dev/null 2>&1 || die "Required tool not found: python3" - launchctl print "gui/$(id -u)" >/dev/null 2>&1 \ - || die "--gui requires an active macOS GUI login session." - - if ! dialog_result="$( - osascript 2>/dev/null <<'APPLESCRIPT' -try - set dialogResponse to display dialog "OpenWritr will request notarization credentials in secure dialogs." with title "OpenWritr Notarization Setup" buttons {"Cancel", "Continue"} default button "Continue" cancel button "Cancel" - return button returned of dialogResponse -on error number -128 - return "CANCEL" -end try -APPLESCRIPT - )"; then - die "osascript could not display dialogs in the active GUI session." - fi - case "$dialog_result" in - Continue) - dialog_result="" - ;; - CANCEL) - printf 'Credential setup canceled.\n' >&2 - exit 130 - ;; - *) - die "osascript returned an unexpected dialog result." - ;; - esac -fi - -xcrun --find notarytool >/dev/null 2>&1 || die "notarytool is not available through xcrun." -gh auth status >/dev/null 2>&1 || die "GitHub CLI authentication is required; run 'gh auth login'." -gh api "repos/$repository/environments/$release_environment" >/dev/null 2>&1 \ - || die "GitHub environment '$release_environment' does not exist in $repository." - -if [[ -L "$RELEASE_ENV_FILE" ]]; then - die ".release.env must not be a symbolic link." -fi -git -C "$PROJECT_DIR" check-ignore -q -- .release.env \ - || die ".release.env is not ignored by Git." - -load_secret_names() { - if ! secret_names="$( - gh secret list \ - --repo "$repository" \ - --env "$release_environment" \ - --json name \ - --jq '.[].name' - )"; then - die "Unable to list secrets for environment '$release_environment' in $repository." - fi -} - -secret_exists() { - local expected="$1" - local existing - - while IFS= read -r existing; do - [[ "$existing" == "$expected" ]] && return 0 - done <<< "$secret_names" - return 1 -} - -load_secret_names -missing_certificate_secrets=() -for secret_name in MACOS_CERTIFICATE MACOS_CERTIFICATE_PWD; do - if ! secret_exists "$secret_name"; then - missing_certificate_secrets+=("$secret_name") - fi -done - -if [[ "${#missing_certificate_secrets[@]}" -gt 0 ]]; then - printf 'Error: Required certificate secret(s) missing in %s environment %s: %s\n' \ - "$repository" "$release_environment" "${missing_certificate_secrets[*]}" >&2 - printf 'Configure the existing Developer ID certificate separately; this script never exports private keys.\n' >&2 - exit 1 -fi - -if [[ "$gui_mode" == true ]]; then - if ! dialog_result="$( - osascript 2>/dev/null <<'APPLESCRIPT' -try - set dialogResponse to display dialog "Apple ID:" default answer "" with title "OpenWritr Notarization Setup" buttons {"Cancel", "Continue"} default button "Continue" cancel button "Cancel" - return "VALUE" & linefeed & (text returned of dialogResponse) -on error number -128 - return "CANCEL" -end try -APPLESCRIPT - )"; then - die "Unable to display the Apple ID dialog." - fi - case "$dialog_result" in - VALUE) - apple_id="" - ;; - VALUE$'\n'*) - apple_id="${dialog_result#*$'\n'}" - ;; - CANCEL) - printf 'Credential setup canceled.\n' >&2 - exit 130 - ;; - *) - die "Unable to read the Apple ID from the dialog." - ;; - esac - dialog_result="" - [[ -n "$apple_id" ]] || die "Apple ID cannot be empty." - - if ! dialog_result="$( - osascript 2>/dev/null <<'APPLESCRIPT' -try - set dialogResponse to display dialog "App-specific password:" default answer "" with title "OpenWritr Notarization Setup" buttons {"Cancel", "Continue"} default button "Continue" cancel button "Cancel" with hidden answer - return "VALUE" & linefeed & (text returned of dialogResponse) -on error number -128 - return "CANCEL" -end try -APPLESCRIPT - )"; then - die "Unable to display the app-specific password dialog." - fi - case "$dialog_result" in - VALUE) - apple_app_password="" - ;; - VALUE$'\n'*) - apple_app_password="${dialog_result#*$'\n'}" - ;; - CANCEL) - printf 'Credential setup canceled.\n' >&2 - exit 130 - ;; - *) - die "Unable to read the app-specific password from the dialog." - ;; - esac - dialog_result="" - - if ! dialog_result="$( - osascript 2>/dev/null <<'APPLESCRIPT' -try - set dialogResponse to display dialog "Confirm app-specific password:" default answer "" with title "OpenWritr Notarization Setup" buttons {"Cancel", "Continue"} default button "Continue" cancel button "Cancel" with hidden answer - return "VALUE" & linefeed & (text returned of dialogResponse) -on error number -128 - return "CANCEL" -end try -APPLESCRIPT - )"; then - die "Unable to display the app-specific password confirmation dialog." - fi - case "$dialog_result" in - VALUE) - apple_app_password_confirmation="" - ;; - VALUE$'\n'*) - apple_app_password_confirmation="${dialog_result#*$'\n'}" - ;; - CANCEL) - printf 'Credential setup canceled.\n' >&2 - exit 130 - ;; - *) - die "Unable to read the app-specific password confirmation from the dialog." - ;; - esac - dialog_result="" -else - printf 'Apple ID: ' >&2 - if ! IFS= read -r apple_id; then - die "Unable to read Apple ID." - fi - [[ -n "$apple_id" ]] || die "Apple ID cannot be empty." - - printf 'App-specific password: ' >&2 - if ! IFS= read -r -s apple_app_password; then - printf '\n' >&2 - die "Unable to read app-specific password." - fi - printf '\nConfirm app-specific password: ' >&2 - if ! IFS= read -r -s apple_app_password_confirmation; then - printf '\n' >&2 - die "Unable to confirm app-specific password." - fi - printf '\n' >&2 -fi - -[[ -n "$apple_app_password" ]] || die "App-specific password cannot be empty." -[[ "$apple_app_password" == "$apple_app_password_confirmation" ]] \ - || die "App-specific passwords do not match." -apple_app_password_confirmation="" -unset apple_app_password_confirmation - -if ! printf '%s' "$apple_id" \ - | gh secret set APPLE_ID --repo "$repository" --env "$release_environment" >/dev/null; then - die "Failed to set APPLE_ID in environment '$release_environment'." -fi -if ! printf '%s' "$team_id" \ - | gh secret set APPLE_TEAM_ID --repo "$repository" --env "$release_environment" >/dev/null; then - die "Failed to set APPLE_TEAM_ID in environment '$release_environment'." -fi -if ! printf '%s' "$apple_app_password" \ - | gh secret set APPLE_APP_PASSWORD --repo "$repository" --env "$release_environment" >/dev/null; then - apple_app_password="" - unset apple_app_password - die "Failed to set APPLE_APP_PASSWORD in environment '$release_environment'." -fi - -run_notarytool_gui() { - python3 -c "$(cat <<'PYTHON' -import errno -import fcntl -import os -import re -import select -import signal -import sys -import termios -import time - -TIMEOUT_SECONDS = 120.0 -PROMPT_PATTERN = re.compile( - rb"(?i)(?:^|[\r\n])\s*(?:enter\s+)?(?:(?:an?|the|your)\s+)?" - rb"(?:app(?:lication)?[- ]specific\s+)?password" - rb"(?:\s+for\s+[^\r\n:]+)?\s*:\s*$" -) -ANSI_PATTERN = re.compile(rb"\x1b\[[0-?]*[ -/]*[@-~]") - - -class DriverSignal(Exception): - def __init__(self, signum): - self.signum = signum - - -def wipe(value): - for index in range(len(value)): - value[index] = 0 - - -def disable_echo(fd): - attributes = termios.tcgetattr(fd) - attributes[3] &= ~(termios.ECHO | termios.ECHONL) - termios.tcsetattr(fd, termios.TCSANOW, attributes) - - -def write_all(fd, value): - view = memoryview(value) - offset = 0 - while offset < len(view): - written = os.write(fd, view[offset:]) - if written == 0: - raise OSError("PTY write returned no bytes") - offset += written - - -def status_to_exit_code(status): - if os.WIFEXITED(status): - return os.WEXITSTATUS(status) - if os.WIFSIGNALED(status): - return 128 + os.WTERMSIG(status) - return 1 - - -def terminate_and_reap(pid): - try: - os.killpg(pid, signal.SIGTERM) - except ProcessLookupError: - try: - os.kill(pid, signal.SIGTERM) - except ProcessLookupError: - pass - - deadline = time.monotonic() + 2.0 - while time.monotonic() < deadline: - try: - waited_pid, status = os.waitpid(pid, os.WNOHANG) - except ChildProcessError: - return None - if waited_pid == pid: - return status - time.sleep(0.05) - - try: - os.killpg(pid, signal.SIGKILL) - except ProcessLookupError: - try: - os.kill(pid, signal.SIGKILL) - except ProcessLookupError: - pass - try: - return os.waitpid(pid, 0)[1] - except ChildProcessError: - return None - - -def read_password(password): - while True: - chunk = os.read(0, 4096) - if not chunk: - return - password.extend(chunk) - - -def run(): - if len(sys.argv) != 4: - sys.stderr.write("Credential driver received invalid non-secret arguments.\n") - return 2 - - profile, apple_id, team_id = sys.argv[1:] - password = bytearray() - master_fd = None - slave_fd = None - child_pid = None - child_status = None - prompt_buffer = bytearray() - password_sent = False - - def handle_signal(signum, _frame): - raise DriverSignal(signum) - - for signum in (signal.SIGHUP, signal.SIGINT, signal.SIGTERM): - signal.signal(signum, handle_signal) - - try: - read_password(password) - if not password: - sys.stderr.write("Credential driver received an empty password.\n") - return 2 - - master_fd, slave_fd = os.openpty() - disable_echo(slave_fd) - child_pid = os.fork() - if child_pid == 0: - try: - os.setsid() - fcntl.ioctl(slave_fd, termios.TIOCSCTTY, 0) - os.dup2(slave_fd, 0) - os.dup2(slave_fd, 1) - os.dup2(slave_fd, 2) - if master_fd > 2: - os.close(master_fd) - if slave_fd > 2: - os.close(slave_fd) - os.execvp( - "xcrun", - [ - "xcrun", - "notarytool", - "store-credentials", - profile, - "--apple-id", - apple_id, - "--team-id", - team_id, - ], - ) - except BaseException: - os._exit(127) - - os.close(slave_fd) - slave_fd = None - deadline = time.monotonic() + TIMEOUT_SECONDS - - while child_status is None: - remaining = deadline - time.monotonic() - if remaining <= 0: - child_status = terminate_and_reap(child_pid) - child_pid = None - sys.stderr.write("notarytool credential setup timed out.\n") - return 124 - - readable, _, _ = select.select([master_fd], [], [], min(0.25, remaining)) - if readable: - try: - chunk = os.read(master_fd, 4096) - except OSError as error: - if error.errno != errno.EIO: - raise - chunk = b"" - - if chunk and not password_sent: - prompt_buffer.extend(chunk) - if len(prompt_buffer) > 8192: - del prompt_buffer[:-8192] - clean_output = ANSI_PATTERN.sub(b"", bytes(prompt_buffer)) - if PROMPT_PATTERN.search(clean_output): - disable_echo(master_fd) - write_all(master_fd, password) - write_all(master_fd, b"\n") - wipe(password) - password_sent = True - wipe(prompt_buffer) - prompt_buffer.clear() - - waited_pid, status = os.waitpid(child_pid, os.WNOHANG) - if waited_pid == child_pid: - child_status = status - child_pid = None - - if not password_sent: - sys.stderr.write("notarytool exited before requesting the password.\n") - exit_code = status_to_exit_code(child_status) - return exit_code if exit_code != 0 else 1 - - exit_code = status_to_exit_code(child_status) - if exit_code != 0: - sys.stderr.write("notarytool did not store and validate the Keychain profile.\n") - return exit_code - except DriverSignal as error: - for signum in (signal.SIGHUP, signal.SIGINT, signal.SIGTERM): - signal.signal(signum, signal.SIG_IGN) - if child_pid is not None: - child_status = terminate_and_reap(child_pid) - child_pid = None - return 128 + error.signum - except BaseException: - sys.stderr.write("Credential driver failed before notarization credentials were stored.\n") - return 1 - finally: - wipe(password) - wipe(prompt_buffer) - if child_pid is not None: - terminate_and_reap(child_pid) - if slave_fd is not None: - os.close(slave_fd) - if master_fd is not None: - os.close(master_fd) - - -sys.exit(run()) -PYTHON - )" "$profile" "$apple_id" "$team_id" -} - -if [[ "$gui_mode" == true ]]; then - notarytool_status=0 - printf '%s' "$apple_app_password" | run_notarytool_gui || notarytool_status=$? - apple_app_password="" - unset apple_app_password - if [[ "$notarytool_status" -ne 0 ]]; then - printf 'Error: notarytool credential setup failed.\n' >&2 - exit "$notarytool_status" - fi -else - apple_app_password="" - unset apple_app_password - printf '\nnotarytool will prompt securely for the app-specific password.\n' >&2 - printf 'Enter the same password again; this script does not pass it to notarytool.\n' >&2 - if ! xcrun notarytool store-credentials "$profile" \ - --apple-id "$apple_id" \ - --team-id "$team_id"; then - die "notarytool did not store and validate the Keychain profile." - fi -fi -unset apple_id - -load_secret_names -missing_required_secrets=() -for secret_name in \ - MACOS_CERTIFICATE \ - MACOS_CERTIFICATE_PWD \ - APPLE_ID \ - APPLE_TEAM_ID \ - APPLE_APP_PASSWORD; do - if ! secret_exists "$secret_name"; then - missing_required_secrets+=("$secret_name") - fi -done - -if [[ "${#missing_required_secrets[@]}" -gt 0 ]]; then - printf 'Error: Required secret name(s) not found in environment %s: %s\n' \ - "$release_environment" "${missing_required_secrets[*]}" >&2 - exit 1 -fi - -if ! xcrun notarytool history --keychain-profile "$profile" >/dev/null 2>&1; then - die "The stored notarytool Keychain profile could not access submission history." -fi - -find_signing_identity() { - local line - local fingerprint - - while IFS= read -r line; do - if [[ "$line" == *"Developer ID Application:"* && "$line" == *"($team_id)"* ]]; then - read -r _ fingerprint _ <<< "$line" - if [[ "$fingerprint" =~ ^[[:xdigit:]]{40}$ ]]; then - printf '%s' "$fingerprint" - return 0 - fi - fi - done < <(security find-identity -v -p codesigning 2>/dev/null) - return 1 -} - -signing_identity="$(find_signing_identity || true)" - -if [[ -e "$RELEASE_ENV_FILE" ]]; then - chmod 600 "$RELEASE_ENV_FILE" -fi -umask 077 -{ - printf 'NOTARY_PROFILE=%s\n' "$profile" - if [[ -n "$signing_identity" ]]; then - printf 'CODE_SIGN_IDENTITY=%s\n' "$signing_identity" - fi -} > "$RELEASE_ENV_FILE" -chmod 600 "$RELEASE_ENV_FILE" - -if [[ -z "$signing_identity" ]]; then - printf 'Warning: No local Developer ID Application identity for team %s was found.\n' "$team_id" >&2 - printf '.release.env contains only the validated notary profile.\n' >&2 -fi - -printf 'Notarization credentials configured successfully for %s environment %s.\n' \ - "$repository" "$release_environment" -printf 'Local configuration written to %s with mode 600.\n' "$RELEASE_ENV_FILE" diff --git a/scripts/test_verify_broker_artifacts.py b/scripts/test_verify_broker_artifacts.py new file mode 100644 index 0000000..cc0b5d1 --- /dev/null +++ b/scripts/test_verify_broker_artifacts.py @@ -0,0 +1,125 @@ +import hashlib +import json +import pathlib +import tempfile +import unittest + +import verify_broker_artifacts + + +class VerifyBrokerArtifactsTests(unittest.TestCase): + def create_fixture(self, root: pathlib.Path, tag: str = "v1.2.3") -> None: + version = tag.removeprefix("v") + names = [ + f"OpenWritr-v{version}-macOS-arm64.zip", + f"OpenWritr-v{version}-macOS-arm64.dmg", + f"OpenWritr-{version}.dmg", + ] + contents = { + names[0]: b"zip", + names[1]: b"dmg", + names[2]: b"dmg", + } + artifacts = [] + for name in names: + path = root / name + path.write_bytes(contents[name]) + digest = hashlib.sha256(contents[name]).hexdigest() + checksum = f"{name}.sha256" + (root / checksum).write_text(f"{digest} {name}\n", encoding="utf-8") + artifacts.append( + { + "name": name, + "checksum": checksum, + "sha256": digest, + "attest": name.endswith(".zip"), + } + ) + zip_name = names[0] + zip_digest = hashlib.sha256(contents[zip_name]).hexdigest() + (root / "attestation-subjects.sha256").write_text( + f"{zip_digest} {zip_name}\n", + encoding="utf-8", + ) + profile_digest = "b" * 64 + (root / "preflight-manifest.json").write_text( + json.dumps( + { + "profile": "openwritr", + "version": version, + "profile_digest": profile_digest, + } + ), + encoding="utf-8", + ) + (root / "provenance.json").write_text( + json.dumps( + { + "profile": "openwritr", + "profile_digest": profile_digest, + "request_id": "req-test", + "version": version, + "broker": { + "repository": "trsdn/macos-notarization-broker", + "commit_sha": "c" * 40, + "run_id": "12345", + "run_attempt": "1", + }, + "source": { + "repository": "trsdn/OpenWritr", + "repository_id": 1165782217, + "tag": tag, + "ref_target_sha": "a" * 40, + "tag_object_sha": None, + "commit_sha": "a" * 40, + }, + "signed_application": { + "bundle_identifier": "com.openwritr.app", + "team_id": "G69Z5BNY97", + }, + "artifacts": artifacts, + } + ), + encoding="utf-8", + ) + + def test_accepts_exact_openwritr_contract(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + self.create_fixture(root) + self.assertEqual(verify_broker_artifacts.verify_artifacts(root, "v1.2.3"), "a" * 40) + + def test_rejects_non_identical_updater_alias(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + self.create_fixture(root) + alias = root / "OpenWritr-1.2.3.dmg" + alias.write_bytes(b"different") + with self.assertRaisesRegex(ValueError, "digest mismatch"): + verify_broker_artifacts.verify_artifacts(root, "v1.2.3") + + def test_rejects_wrong_developer_id_team(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + self.create_fixture(root) + provenance_path = root / "provenance.json" + provenance = json.loads(provenance_path.read_text(encoding="utf-8")) + provenance["signed_application"]["team_id"] = "ATTACKER00" + provenance_path.write_text(json.dumps(provenance), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "Developer ID team"): + verify_broker_artifacts.verify_artifacts(root, "v1.2.3") + + def test_rejects_dmg_attestation(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + self.create_fixture(root) + provenance_path = root / "provenance.json" + provenance = json.loads(provenance_path.read_text(encoding="utf-8")) + provenance["artifacts"][1]["attest"] = True + provenance_path.write_text(json.dumps(provenance), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "attestation policy"): + verify_broker_artifacts.verify_artifacts(root, "v1.2.3") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/verify_broker_artifacts.py b/scripts/verify_broker_artifacts.py new file mode 100755 index 0000000..5c420b8 --- /dev/null +++ b/scripts/verify_broker_artifacts.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 + +import argparse +import hashlib +import json +import pathlib +import re + + +REPOSITORY = "trsdn/OpenWritr" +REPOSITORY_ID = 1165782217 +BROKER_REPOSITORY = "trsdn/macos-notarization-broker" +BUNDLE_IDENTIFIER = "com.openwritr.app" +TEAM_ID = "G69Z5BNY97" + + +def sha256(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def verify_artifacts(root: pathlib.Path, tag: str) -> str: + if not re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?", tag): + raise ValueError(f"Tag must be an exact version tag such as v1.2.3: {tag}") + if not root.is_dir(): + raise ValueError(f"Broker artifact directory does not exist: {root}") + + version = tag.removeprefix("v") + provenance_path = root / "provenance.json" + manifest_path = root / "preflight-manifest.json" + if not provenance_path.is_file() or provenance_path.is_symlink(): + raise ValueError("Broker artifact is missing a regular provenance.json") + if not manifest_path.is_file() or manifest_path.is_symlink(): + raise ValueError("Broker artifact is missing a regular preflight-manifest.json") + + provenance = json.loads(provenance_path.read_text(encoding="utf-8")) + if provenance.get("profile") != "openwritr": + raise ValueError("Broker provenance profile is not openwritr") + if provenance.get("version") != version: + raise ValueError("Broker provenance names the wrong version") + profile_digest = provenance.get("profile_digest", "") + if not re.fullmatch(r"[0-9a-f]{64}", profile_digest): + raise ValueError("Broker provenance has no valid profile digest") + request_id = provenance.get("request_id", "") + if not re.fullmatch(r"[A-Za-z0-9._-]{1,80}", request_id): + raise ValueError("Broker provenance has no valid request ID") + + broker = provenance.get("broker", {}) + if broker.get("repository") != BROKER_REPOSITORY: + raise ValueError("Broker provenance names the wrong broker repository") + if not re.fullmatch(r"[0-9a-f]{40}", broker.get("commit_sha", "")): + raise ValueError("Broker provenance has no immutable broker commit") + if not str(broker.get("run_id", "")).isdigit(): + raise ValueError("Broker provenance has no valid workflow run ID") + if not str(broker.get("run_attempt", "")).isdigit() or int(broker["run_attempt"]) < 1: + raise ValueError("Broker provenance has no valid workflow run attempt") + + source = provenance.get("source", {}) + if source.get("repository") != REPOSITORY: + raise ValueError("Broker provenance names the wrong source repository") + if source.get("repository_id") != REPOSITORY_ID: + raise ValueError("Broker provenance names the wrong source repository ID") + if source.get("tag") != tag: + raise ValueError("Broker provenance names the wrong source tag") + commit_sha = source.get("commit_sha", "") + if not re.fullmatch(r"[0-9a-f]{40}", commit_sha): + raise ValueError("Broker provenance has no immutable source commit") + if not re.fullmatch(r"[0-9a-f]{40}", source.get("ref_target_sha", "")): + raise ValueError("Broker provenance has no immutable source ref target") + tag_object_sha = source.get("tag_object_sha") + if tag_object_sha is not None and not re.fullmatch(r"[0-9a-f]{40}", tag_object_sha): + raise ValueError("Broker provenance has an invalid tag object SHA") + + signed_application = provenance.get("signed_application", {}) + if signed_application.get("bundle_identifier") != BUNDLE_IDENTIFIER: + raise ValueError("Broker provenance names the wrong bundle identifier") + if signed_application.get("team_id") != TEAM_ID: + raise ValueError("Broker provenance names the wrong Developer ID team") + + preflight = json.loads(manifest_path.read_text(encoding="utf-8")) + if ( + preflight.get("profile") != "openwritr" + or preflight.get("version") != version + or preflight.get("profile_digest") != profile_digest + ): + raise ValueError("Preflight manifest does not match the broker provenance") + + expected = { + f"OpenWritr-v{version}-macOS-arm64.zip", + f"OpenWritr-v{version}-macOS-arm64.dmg", + f"OpenWritr-{version}.dmg", + } + expected_attestation = {f"OpenWritr-v{version}-macOS-arm64.zip"} + artifacts = provenance.get("artifacts") + if not isinstance(artifacts, list): + raise ValueError("Broker provenance has no artifact list") + by_name = {artifact.get("name"): artifact for artifact in artifacts} + if len(artifacts) != len(expected) or set(by_name) != expected: + raise ValueError( + "Broker provenance artifact contract differs from OpenWritr's expected ZIP, DMG, and updater alias" + ) + + for name, artifact in by_name.items(): + if artifact.get("attest") != (name in expected_attestation): + raise ValueError(f"Broker attestation policy is unsafe for OpenWritr: {name}") + path = root / name + checksum_name = artifact.get("checksum") + if ( + not isinstance(checksum_name, str) + or pathlib.PurePath(checksum_name).name != checksum_name + ): + raise ValueError(f"Broker checksum name is unsafe: {checksum_name}") + checksum_path = root / str(checksum_name) + if ( + not path.is_file() + or path.is_symlink() + or not checksum_path.is_file() + or checksum_path.is_symlink() + ): + raise ValueError(f"Broker artifact or checksum is missing or unsafe: {name}") + digest = sha256(path) + if digest != artifact.get("sha256"): + raise ValueError(f"Broker provenance digest mismatch: {name}") + checksum_fields = checksum_path.read_text(encoding="utf-8").strip().split() + if len(checksum_fields) != 2 or checksum_fields[0] != digest: + raise ValueError(f"Broker checksum content mismatch: {checksum_path.name}") + if checksum_fields[1].lstrip("*") != name: + raise ValueError(f"Broker checksum names the wrong file: {checksum_path.name}") + + primary = root / f"OpenWritr-v{version}-macOS-arm64.dmg" + updater = root / f"OpenWritr-{version}.dmg" + if sha256(primary) != sha256(updater): + raise ValueError("AppUpdater alias is not byte-identical to the versioned DMG") + + attestation_manifest = root / "attestation-subjects.sha256" + if not attestation_manifest.is_file() or attestation_manifest.is_symlink(): + raise ValueError("Broker artifact is missing a regular attestation subject manifest") + expected_zip = root / f"OpenWritr-v{version}-macOS-arm64.zip" + expected_line = f"{sha256(expected_zip)} {expected_zip.name}" + lines = attestation_manifest.read_text(encoding="utf-8").splitlines() + if lines != [expected_line]: + raise ValueError("Broker attestation subject manifest must contain only the OpenWritr ZIP") + + return commit_sha + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("artifact_directory", type=pathlib.Path) + parser.add_argument("tag") + args = parser.parse_args() + print(verify_artifacts(args.artifact_directory, args.tag)) + + +if __name__ == "__main__": + main() From c7c78c116158f882c1f50dc48cbb42919cef3a0d Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 21:37:38 +0200 Subject: [PATCH 2/6] fix(release): harden draft smoke dispatch Bind draft smoke runs to trusted main, authenticated artifact digests, and a unique nonce. Extract changelog notes with literal version matching so SemVer build metadata is handled safely. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- .github/workflows/ci.yml | 6 ++- .github/workflows/smoke-test.yml | 62 ++++++++++++++++++++++++--- AGENTS.md | 5 +++ README.md | 2 +- RELEASE_CHECKLIST.md | 9 ++-- docs/release-smoke-tests.md | 30 ++++++++----- docs/self-assessment.md | 6 +-- scripts/extract_release_notes.py | 59 ++++++++++++++++++++++++++ scripts/publish_broker_release.sh | 70 +++++++++++++------------------ scripts/test_release_notes.py | 53 +++++++++++++++++++++++ 10 files changed, 237 insertions(+), 65 deletions(-) create mode 100644 scripts/extract_release_notes.py create mode 100644 scripts/test_release_notes.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c66555..4340218 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,5 +38,7 @@ jobs: - name: Validate release tooling run: | bash -n scripts/*.sh - PYTHONPATH=scripts python3 -m unittest scripts/test_verify_broker_artifacts.py - python3 -m py_compile scripts/verify_broker_artifacts.py + PYTHONPATH=scripts python3 -m unittest discover -s scripts -p 'test_*.py' + python3 -m py_compile \ + scripts/extract_release_notes.py \ + scripts/verify_broker_artifacts.py diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index c2530c5..ad2af46 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -1,5 +1,5 @@ name: Release smoke test -run-name: Smoke-test OpenWritr ${{ inputs.tag }} +run-name: Smoke-test OpenWritr ${{ inputs.tag }} (${{ inputs.nonce }}) # Installs the release's disk image the way a consumer would and exercises its # core function (speech-to-text) through the app's non-interactive --self-test @@ -16,26 +16,66 @@ on: description: Draft or published release tag to test, for example v1.6.5 required: true type: string + expected_dmg_sha256: + description: SHA-256 of the exact versioned DMG in the draft + required: true + type: string + expected_checksum_sha256: + description: SHA-256 of the exact DMG checksum file in the draft + required: true + type: string + nonce: + description: Unique request correlation nonce + required: true + type: string -permissions: - contents: read +permissions: {} jobs: smoke: name: Install and transcribe runs-on: macos-15 timeout-minutes: 30 + # GitHub exposes draft releases only to identities with push-level access. + # This checkout-free job needs contents: write solely to download the + # maintainer-created draft. It never creates, edits, uploads, or publishes + # a release, and dispatch is bound to trusted main plus immutable digests. + permissions: + contents: write steps: - - name: Validate release tag + - name: Authorize trusted dispatch and immutable inputs shell: bash env: + ACTOR_ID: ${{ github.actor_id }} + EVENT_NAME: ${{ github.event_name }} + EXPECTED_CHECKSUM_SHA256: ${{ inputs.expected_checksum_sha256 }} + EXPECTED_DMG_SHA256: ${{ inputs.expected_dmg_sha256 }} + NONCE: ${{ inputs.nonce }} + REF: ${{ github.ref }} + REPOSITORY_ID: ${{ github.repository_id }} TAG: ${{ inputs.tag }} run: | set -euo pipefail + if [[ "$EVENT_NAME" != "workflow_dispatch" || + "$REF" != "refs/heads/main" || + "$ACTOR_ID" != "24534196" || + "$REPOSITORY_ID" != "1165782217" ]]; then + echo "Release smoke tests require an authorized dispatch from trusted main." >&2 + exit 1 + fi if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then echo "Tag must look like v1.2.3: $TAG" >&2 exit 1 fi + if [[ ! "$EXPECTED_DMG_SHA256" =~ ^[0-9a-f]{64}$ || + ! "$EXPECTED_CHECKSUM_SHA256" =~ ^[0-9a-f]{64}$ ]]; then + echo "Expected release digests must be lowercase SHA-256 values." >&2 + exit 1 + fi + if [[ ! "$NONCE" =~ ^smoke-[0-9a-f]{32}$ ]]; then + echo "Smoke request nonce is invalid." >&2 + exit 1 + fi - name: Download draft or published release DMG and checksum shell: bash @@ -44,15 +84,18 @@ jobs: TAG: ${{ inputs.tag }} run: | set -euo pipefail + version="${TAG#v}" mkdir -p dl gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir dl \ - --pattern "OpenWritr-v*-macOS-arm64.dmg" \ - --pattern "OpenWritr-v*-macOS-arm64.dmg.sha256" + --pattern "OpenWritr-v${version}-macOS-arm64.dmg" \ + --pattern "OpenWritr-v${version}-macOS-arm64.dmg.sha256" - name: Verify downloaded DMG id: verify-download shell: bash env: + EXPECTED_CHECKSUM_SHA256: ${{ inputs.expected_checksum_sha256 }} + EXPECTED_DMG_SHA256: ${{ inputs.expected_dmg_sha256 }} TAG: ${{ inputs.tag }} run: | set -euo pipefail @@ -62,6 +105,13 @@ jobs: echo "Expected smoke-test DMG or checksum is missing for $TAG." >&2 exit 1 fi + dmg_sha256="$(shasum -a 256 "$dmg" | cut -d' ' -f1)" + checksum_sha256="$(shasum -a 256 "$checksum" | cut -d' ' -f1)" + if [[ "$dmg_sha256" != "$EXPECTED_DMG_SHA256" || + "$checksum_sha256" != "$EXPECTED_CHECKSUM_SHA256" ]]; then + echo "Draft release bytes do not match the authenticated broker candidate." >&2 + exit 1 + fi (cd dl && shasum -a 256 -c "$(basename "$checksum")") echo "DMG_PATH=$dmg" >> "$GITHUB_ENV" diff --git a/AGENTS.md b/AGENTS.md index df3b47a..0fb6f8f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -131,6 +131,11 @@ swift test - Pin external actions and reusable workflows in hand-maintained workflows to full commit SHAs with readable version comments. Update generated agentic workflow locks only through `gh aw compile`. +- The checkout-free release smoke job is the only release workflow with + `contents: write`, because GitHub requires push-level access to download + draft assets. It must run from trusted `main`, remain bound to the fixed + maintainer/repository IDs plus exact digests and a unique nonce, and must + never mutate a release. - Release identity comes from `Info.plist` (`CFBundleShortVersionString` and `CFBundleVersion`). Bump both in a `chore(release): bump version to X.Y.Z` change before tagging. - User-facing changes get an entry in `CHANGELOG.md` (`## [x.y.z] — date`). The secretless publication handoff publishes that section as the release notes and fails when it is missing or empty. The broker verifies the tagged bundle version. - Commit messages use Conventional Commits (`fix(settings): …`, `chore(release): …`). diff --git a/README.md b/README.md index 8648269..e4608da 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ scripts/request.sh openwritr vX.Y.Z /path/to/OpenWritr/.artifacts/broker-release ``` Then OpenWritr's secretless publication handoff creates a draft, runs the -read-only transcription smoke test against that draft, and publishes only +checkout-free transcription smoke test against that draft, and publishes only after the tag, checksums, exact five-asset contract, and smoke result pass. See [RELEASE_CHECKLIST.md](RELEASE_CHECKLIST.md) for the maintainer-only procedure and authorization boundary. diff --git a/RELEASE_CHECKLIST.md b/RELEASE_CHECKLIST.md index 30c700b..6971e74 100644 --- a/RELEASE_CHECKLIST.md +++ b/RELEASE_CHECKLIST.md @@ -102,8 +102,11 @@ This secretless handoff: clobbering; 7. downloads all five draft assets again and requires byte equality with the authenticated broker artifact; -8. dispatches the read-only `Release smoke test` workflow against that draft - and waits for it to pass; +8. dispatches `Release smoke test` from trusted `main` with a unique nonce and + the authenticated DMG/checksum digests, correlates that exact run, and waits + for it to pass. The checkout-free smoke job has only `contents: write` + because GitHub requires push-level access to read draft release assets; it + never mutates the release; 9. rechecks the tag and draft state and redownloads all five assets before publication; and 10. publishes the draft, then redownloads all five public assets and requires @@ -135,7 +138,7 @@ assets because OpenWritr's established release contract is exactly five files. - [ ] Confirm the correlated smoke-test run passed and its job summary records the installed version, Gatekeeper/notarization checks, and transcription. - [ ] Confirm the release is public and has exactly the five asset names above. -- [ ] Confirm the primary public DMG digest matches the broker download. +- [ ] Confirm all five public asset bytes match the broker download. Release notes come only from `CHANGELOG.md`. Never write replacement notes by hand, never upload locally built files, and never attest either OpenWritr DMG. diff --git a/docs/release-smoke-tests.md b/docs/release-smoke-tests.md index e67a774..c4c32c7 100644 --- a/docs/release-smoke-tests.md +++ b/docs/release-smoke-tests.md @@ -19,14 +19,23 @@ artifact by immutable artifact ID, checks GitHub's artifact SHA-256, and then validates the source/profile/signing provenance and file digests. It refuses to run if any release or draft already exists for the tag, then creates a new single-use draft with the exact five public assets and dispatches -[`smoke-test.yml`](../.github/workflows/smoke-test.yml). The workflow has only -`contents: read`; it can download the authenticated draft but cannot edit or -publish it. +[`smoke-test.yml`](../.github/workflows/smoke-test.yml) explicitly from +`main`, never from the release tag. The dispatch carries the immutable tag, +exact DMG and checksum-file digests, and a unique nonce used in the workflow +run name and run correlation. + +GitHub permits draft-release downloads only to tokens with push-level +repository access. The workflow therefore grants `contents: write` to the +single checkout-free smoke job and nowhere else. That job is fixed to the +repository and maintainer numeric IDs and `refs/heads/main`; it does not check +out or execute tagged source and never creates, edits, uploads, or publishes a +release. The permission exists solely to read the maintainer-created draft. The workflow: -1. downloads the versioned DMG and checksum from the draft release and verifies - the checksum; +1. downloads the exact versioned DMG and checksum from the draft release, + requires both file digests to equal the authenticated dispatch inputs, and + verifies the checksum contents; 2. installs the app to `/Applications`; 3. checks the signature, Gatekeeper assessment, and notarization ticket; and 4. synthesizes a spoken phrase, runs the installed app with `--self-test`, and @@ -35,11 +44,12 @@ The workflow: recording, without a microphone (`Sources/OpenWritr/SelfTest.swift`). The maintainer-side handoff downloads and byte-compares all five draft assets -before the smoke test, waits for the correlated workflow run, then re-resolves -the immutable tag and redownloads all five assets before publication. After -publishing, it checks the exact five-name contract and compares all five public -assets again. A failure before publication leaves the single-use draft in -place; the maintainer deletes that unpublished draft before a fresh retry. +before the smoke test, waits for the exact nonce-bound workflow run, then +re-resolves the immutable tag and redownloads all five assets before +publication. After publishing, it checks the exact five-name contract and +compares all five public assets again. A failure before publication leaves the +single-use draft in place; the maintainer deletes that unpublished draft before +a fresh retry. This preserves smoke-before-publication without giving OpenWritr a source-side token that can access broker secrets. Broker signing remains a separate, diff --git a/docs/self-assessment.md b/docs/self-assessment.md index 24e7d2d..ad73efc 100644 --- a/docs/self-assessment.md +++ b/docs/self-assessment.md @@ -97,9 +97,9 @@ Automation Availability does not apply: hosted runners are available and used. | S08 | pass | `.github/dependabot.yml` covers `github-actions` and `swift`, weekly; single maintainer owns triage. | | S09 | pass | `main` protection requires `Secret Scan` and `Build and test`, strict; both checks exist and run. | | S10 | pass | `AGENTS.md` names components and constraints (update attestation must not be added, asset naming AppUpdater needs, `Package.resolved` machine-owned, release identity from `Info.plist`). | -| S11 | pass | Every workflow declares permissions. All application/review/smoke workflows are read-only; only `stats.yml` writes, narrowly to the generated stats branch. Release mutation happens in an explicitly authorized maintainer-side script with the maintainer's `gh` credentials, not an Actions token. | +| S11 | pass | Every workflow declares permissions. Release mutation happens only in the explicitly authorized maintainer-side script with the maintainer's `gh` credentials. The checkout-free smoke job is the sole release-related workflow exception: it has job-scoped `contents: write` because GitHub requires push-level access to read draft release assets, is fixed to the numeric repository/maintainer identities and `refs/heads/main`, receives exact digests plus a nonce, and never creates, edits, uploads, or publishes a release. `stats.yml` separately writes only to the generated stats branch. | | S12 | pass | Hand-maintained workflow actions are pinned to full commit SHAs, and the shared conformance/stats workflows are pinned to reviewed commits. | -| S13 | na | No workflow uses `pull_request_target` or `workflow_run` (all four workflows read). | +| S13 | na | No workflow uses `pull_request_target` or `workflow_run`. | ## Deployable @@ -122,7 +122,7 @@ Assessed on the latest release, `v1.6.4` (2026-09-19), assets `OpenWritr-v1.6.4- | R02 | pass | README "Versioning and compatibility" names SemVer and states what each kind of release means. | | R03 | pass | Existing release tags are immutable. New releases use the public broker's manually authorized `openwritr` request; both the broker and the OpenWritr publication handoff resolve the tag to a full commit and fail if it moves. | | R04 | pass | Tag `v1.6.4`, bundle `CFBundleShortVersionString` 1.6.4 (read from the download), title "OpenWritr 1.6.4". | -| R05 | pass | `publish_broker_release.sh` binds the candidate to a successful fixed-identity broker run and GitHub artifact digest. It refuses any pre-existing release/draft, creates a single-use exact five-asset draft without clobbering, and byte-compares all five downloaded draft assets with the authenticated broker output. `smoke-test.yml` verifies checksum, bundle identifier, Developer ID Team, Gatekeeper acceptance and notarization, then transcribes a synthesized phrase through `--self-test` without an operator. The handoff waits for the correlated run, rechecks the immutable tag and all five draft bytes, publishes, and verifies the exact public contract plus all five bytes again. | +| R05 | pass | `publish_broker_release.sh` binds the candidate to a successful fixed-identity broker run and GitHub artifact digest. It refuses any pre-existing release/draft, creates a single-use exact five-asset draft without clobbering, and byte-compares all five downloaded draft assets with the authenticated broker output. It dispatches the checkout-free smoke workflow from trusted `main` with exact DMG/checksum digests and a unique nonce, then correlates that exact run. `smoke-test.yml` verifies those bytes, bundle identifier, Developer ID Team, Gatekeeper acceptance and notarization, then transcribes a synthesized phrase through `--self-test` without an operator. The handoff rechecks the immutable tag and all five draft bytes, publishes, and verifies the exact public contract plus all five bytes again. | | R06 | pass | Release notes: "### Fixed - Brought the Settings window to the front ... (#42)", specific, nothing breaking to warn about. | | R07 | pass | Release notes come from the tagged `CHANGELOG.md` section. The publication handoff fails when the entry is missing, empty, or still under `Unreleased`; it never accepts freestanding notes. | | R08 | pass | Developer ID signature (Team `G69Z5BNY97`) and stapled notarization are verified by the broker and smoke test. Broker provenance records the immutable source commit. The broker may attest the ZIP only and statically excludes both OpenWritr DMGs, because their shared digest must have no attestation (#31). | diff --git a/scripts/extract_release_notes.py b/scripts/extract_release_notes.py new file mode 100644 index 0000000..d65dddb --- /dev/null +++ b/scripts/extract_release_notes.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +import argparse +import pathlib +import re + + +def extract_release_notes(changelog: str, version: str) -> str: + unreleased = re.compile(r"^##\s+\[?unreleased\]?(?:\s|$)", re.IGNORECASE) + release = re.compile(rf"^##\s+\[?{re.escape(version)}\]?(?:\s|$)") + + lines = changelog.splitlines() + unreleased_lines: list[str] = [] + release_lines: list[str] = [] + section: str | None = None + + for line in lines: + if line.startswith("## "): + if unreleased.match(line): + section = "unreleased" + elif release.match(line): + section = "release" + else: + section = None + continue + if section == "unreleased": + unreleased_lines.append(line) + elif section == "release": + release_lines.append(line) + + if any(line.strip() for line in unreleased_lines): + raise ValueError( + f"CHANGELOG.md still holds entries under Unreleased; promote them into {version}." + ) + + notes = "\n".join(release_lines).strip() + if not notes: + raise ValueError(f"CHANGELOG.md has no non-empty entry for {version}.") + return f"{notes}\n" + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("changelog", type=pathlib.Path) + parser.add_argument("version") + args = parser.parse_args() + + try: + notes = extract_release_notes( + args.changelog.read_text(encoding="utf-8"), + args.version, + ) + except ValueError as error: + parser.error(str(error)) + print(notes, end="") + + +if __name__ == "__main__": + main() diff --git a/scripts/publish_broker_release.sh b/scripts/publish_broker_release.sh index f18bb35..b8ba05e 100755 --- a/scripts/publish_broker_release.sh +++ b/scripts/publish_broker_release.sh @@ -93,6 +93,7 @@ fi work_dir="$(mktemp -d)" notes_file="$work_dir/release-notes.md" +changelog_file="$work_dir/CHANGELOG.md" artifact_zip="$work_dir/broker-artifact.zip" verified_dir="$work_dir/verified" cleanup() { @@ -165,34 +166,11 @@ verify_release_bytes() { done } -changelog="$( - gh api "repos/$REPOSITORY/contents/CHANGELOG.md?ref=$tag" \ - -H "Accept: application/vnd.github.raw" -)" -held="$( - printf '%s\n' "$changelog" | awk ' - tolower($0) ~ /^##[ \t]+\[?unreleased\]?/ { capture = 1; next } - capture && /^## / { exit } - capture { print } - ' | tr -d '[:space:]' -)" -if [[ -n "$held" ]]; then - echo "CHANGELOG.md still holds entries under Unreleased; promote them into $version." >&2 - exit 1 -fi -notes="$( - printf '%s\n' "$changelog" | awk -v version="$version" ' - BEGIN { gsub(/\./, "\\.", version) } - $0 ~ "^## \\[?" version "\\]?([ \t]|$)" { capture = 1; next } - capture && /^## / { exit } - capture { print } - ' -)" -if [[ -z "${notes//[[:space:]]/}" ]]; then - echo "CHANGELOG.md has no non-empty entry for $version." >&2 - exit 1 -fi -printf '%s\n' "$notes" > "$notes_file" +gh api --method GET "repos/$REPOSITORY/contents/CHANGELOG.md" \ + --raw-field "ref=$tag" \ + -H "Accept: application/vnd.github.raw" > "$changelog_file" +python3 "$SCRIPT_DIR/extract_release_notes.py" \ + "$changelog_file" "$version" > "$notes_file" if gh release view "$tag" --repo "$REPOSITORY" >/dev/null 2>&1; then echo "Release or draft $tag already exists; refusing an overlapping or resumed handoff." >&2 @@ -209,11 +187,17 @@ bash "$SCRIPT_DIR/verify_release_asset_contract.sh" \ exact "$tag" "$REPOSITORY" "${asset_names[@]}" verify_release_bytes "draft-before-smoke" -existing_runs="$( - gh run list --repo "$REPOSITORY" --workflow smoke-test.yml \ - --event workflow_dispatch --limit 30 --json databaseId --jq '.[].databaseId' +smoke_nonce="$(python3 -c 'import uuid; print(\"smoke-\" + uuid.uuid4().hex)')" +expected_dmg_sha256="$(shasum -a 256 "$artifact_dir/$asset_base.dmg" | cut -d' ' -f1)" +expected_checksum_sha256="$( + shasum -a 256 "$artifact_dir/$asset_base.dmg.sha256" | cut -d' ' -f1 )" -gh workflow run smoke-test.yml --repo "$REPOSITORY" --ref main --field "tag=$tag" +expected_smoke_title="Smoke-test OpenWritr $tag ($smoke_nonce)" +gh workflow run smoke-test.yml --repo "$REPOSITORY" --ref main \ + --field "tag=$tag" \ + --field "expected_dmg_sha256=$expected_dmg_sha256" \ + --field "expected_checksum_sha256=$expected_checksum_sha256" \ + --field "nonce=$smoke_nonce" run_id="" for _ in $(seq 1 30); do @@ -221,19 +205,25 @@ for _ in $(seq 1 30); do gh run list --repo "$REPOSITORY" --workflow smoke-test.yml \ --event workflow_dispatch --branch main --limit 30 \ --json databaseId,displayTitle \ - --jq ".[] | select(.displayTitle == \"Smoke-test OpenWritr $tag\") | .databaseId" | - while IFS= read -r candidate; do - if ! grep -Fxq "$candidate" <<< "$existing_runs"; then - printf '%s\n' "$candidate" - break - fi - done + --jq "[.[] | select(.displayTitle == \"$expected_smoke_title\")][0].databaseId // empty" )" [[ -n "$run_id" ]] && break sleep 2 done if [[ -z "$run_id" ]]; then - echo "Could not correlate the smoke-test workflow run for $tag; the draft remains unpublished." >&2 + echo "Could not correlate smoke request $smoke_nonce for $tag; the draft remains unpublished." >&2 + exit 1 +fi +read -r smoke_event smoke_branch smoke_actor_id smoke_repository_id smoke_title < <( + gh api "repos/$REPOSITORY/actions/runs/$run_id" \ + --jq '[.event, .head_branch, .actor.id, .repository.id, .display_title] | @tsv' +) +if [[ "$smoke_event" != "workflow_dispatch" || + "$smoke_branch" != "main" || + "$smoke_actor_id" != "$AUTHORIZED_ACTOR_ID" || + "$smoke_repository_id" != "1165782217" || + "$smoke_title" != "$expected_smoke_title" ]]; then + echo "Correlated smoke run does not match the authorized nonce-bound dispatch." >&2 exit 1 fi gh run watch "$run_id" --repo "$REPOSITORY" --exit-status diff --git a/scripts/test_release_notes.py b/scripts/test_release_notes.py new file mode 100644 index 0000000..89a0076 --- /dev/null +++ b/scripts/test_release_notes.py @@ -0,0 +1,53 @@ +import unittest + +import extract_release_notes + + +class ExtractReleaseNotesTests(unittest.TestCase): + def test_extracts_build_metadata_version_literally(self) -> None: + changelog = """# Changelog + +## [1.2.3+build.1] — 2026-09-22 + +### Fixed +- Preserved build metadata. + +## [1.2.3buildX1] — 2026-09-21 + +- Must not match regex metacharacters. +""" + self.assertEqual( + extract_release_notes.extract_release_notes( + changelog, + "1.2.3+build.1", + ), + "### Fixed\n- Preserved build metadata.\n", + ) + + def test_rejects_entries_left_under_unreleased(self) -> None: + changelog = """# Changelog + +## Unreleased + +- Not promoted. + +## [1.2.3+build.1] + +- Release notes. +""" + with self.assertRaisesRegex(ValueError, "still holds entries"): + extract_release_notes.extract_release_notes( + changelog, + "1.2.3+build.1", + ) + + def test_rejects_missing_release_section(self) -> None: + with self.assertRaisesRegex(ValueError, "no non-empty entry"): + extract_release_notes.extract_release_notes( + "# Changelog\n\n## [1.2.4]\n\n- Other release.\n", + "1.2.3+build.1", + ) + + +if __name__ == "__main__": + unittest.main() From 4ea06f2a23954a1cf4e9bb6c57c3bf9b72afc40d Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 21:50:32 +0200 Subject: [PATCH 3/6] fix(release): correct smoke nonce generation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- .github/workflows/ci.yml | 1 + scripts/publish_broker_release.sh | 2 +- scripts/test_publish_broker_release.sh | 20 ++++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100755 scripts/test_publish_broker_release.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4340218..4e16c3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: - name: Validate release tooling run: | bash -n scripts/*.sh + bash scripts/test_publish_broker_release.sh PYTHONPATH=scripts python3 -m unittest discover -s scripts -p 'test_*.py' python3 -m py_compile \ scripts/extract_release_notes.py \ diff --git a/scripts/publish_broker_release.sh b/scripts/publish_broker_release.sh index b8ba05e..ae24f65 100755 --- a/scripts/publish_broker_release.sh +++ b/scripts/publish_broker_release.sh @@ -187,7 +187,7 @@ bash "$SCRIPT_DIR/verify_release_asset_contract.sh" \ exact "$tag" "$REPOSITORY" "${asset_names[@]}" verify_release_bytes "draft-before-smoke" -smoke_nonce="$(python3 -c 'import uuid; print(\"smoke-\" + uuid.uuid4().hex)')" +smoke_nonce="$(python3 -c 'import uuid; print("smoke-" + uuid.uuid4().hex)')" expected_dmg_sha256="$(shasum -a 256 "$artifact_dir/$asset_base.dmg" | cut -d' ' -f1)" expected_checksum_sha256="$( shasum -a 256 "$artifact_dir/$asset_base.dmg.sha256" | cut -d' ' -f1 diff --git a/scripts/test_publish_broker_release.sh b/scripts/test_publish_broker_release.sh new file mode 100755 index 0000000..b768dd9 --- /dev/null +++ b/scripts/test_publish_broker_release.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +publication_script="$SCRIPT_DIR/publish_broker_release.sh" + +assignment_count="$(grep -c '^smoke_nonce=' "$publication_script")" +if [[ "$assignment_count" -ne 1 ]]; then + echo "Expected exactly one smoke_nonce assignment, found $assignment_count." >&2 + exit 1 +fi + +assignment_line="$(grep '^smoke_nonce=' "$publication_script")" +eval "$assignment_line" + +if [[ ! "$smoke_nonce" =~ ^smoke-[0-9a-f]{32}$ ]]; then + echo "Invalid smoke nonce: $smoke_nonce" >&2 + exit 1 +fi From e8dbcbde741fd4a74145e76df29e0b7df877f3c0 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 21:59:03 +0200 Subject: [PATCH 4/6] fix(release): bind canonical checksum files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- scripts/test_verify_broker_artifacts.py | 17 +++++++++++++++++ scripts/verify_broker_artifacts.py | 6 ++++++ 2 files changed, 23 insertions(+) diff --git a/scripts/test_verify_broker_artifacts.py b/scripts/test_verify_broker_artifacts.py index cc0b5d1..ac63e6f 100644 --- a/scripts/test_verify_broker_artifacts.py +++ b/scripts/test_verify_broker_artifacts.py @@ -109,6 +109,23 @@ def test_rejects_wrong_developer_id_team(self) -> None: with self.assertRaisesRegex(ValueError, "Developer ID team"): verify_broker_artifacts.verify_artifacts(root, "v1.2.3") + def test_rejects_noncanonical_checksum_name(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + self.create_fixture(root) + provenance_path = root / "provenance.json" + provenance = json.loads(provenance_path.read_text(encoding="utf-8")) + artifact = provenance["artifacts"][0] + alternate_checksum = "alternate-safe-name.sha256" + (root / alternate_checksum).write_text( + f"{artifact['sha256']} {artifact['name']}\n", + encoding="utf-8", + ) + artifact["checksum"] = alternate_checksum + provenance_path.write_text(json.dumps(provenance), encoding="utf-8") + with self.assertRaisesRegex(ValueError, "checksum name is not canonical"): + verify_broker_artifacts.verify_artifacts(root, "v1.2.3") + def test_rejects_dmg_attestation(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) diff --git a/scripts/verify_broker_artifacts.py b/scripts/verify_broker_artifacts.py index 5c420b8..de83d40 100755 --- a/scripts/verify_broker_artifacts.py +++ b/scripts/verify_broker_artifacts.py @@ -113,6 +113,12 @@ def verify_artifacts(root: pathlib.Path, tag: str) -> str: or pathlib.PurePath(checksum_name).name != checksum_name ): raise ValueError(f"Broker checksum name is unsafe: {checksum_name}") + expected_checksum_name = f"{name}.sha256" + if checksum_name != expected_checksum_name: + raise ValueError( + f"Broker checksum name is not canonical for {name}: " + f"expected {expected_checksum_name}, got {checksum_name}" + ) checksum_path = root / str(checksum_name) if ( not path.is_file() From 40f96f53c891c9260c455862c45030b61280748a Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 22:48:49 +0200 Subject: [PATCH 5/6] fix(release): reject malformed checksum entries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- scripts/test_verify_broker_artifacts.py | 16 ++++++++++++++++ scripts/verify_broker_artifacts.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/scripts/test_verify_broker_artifacts.py b/scripts/test_verify_broker_artifacts.py index ac63e6f..f8ce732 100644 --- a/scripts/test_verify_broker_artifacts.py +++ b/scripts/test_verify_broker_artifacts.py @@ -126,6 +126,22 @@ def test_rejects_noncanonical_checksum_name(self) -> None: with self.assertRaisesRegex(ValueError, "checksum name is not canonical"): verify_broker_artifacts.verify_artifacts(root, "v1.2.3") + def test_rejects_multiple_checksum_filename_markers(self) -> None: + with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + self.create_fixture(root) + provenance = json.loads( + (root / "provenance.json").read_text(encoding="utf-8") + ) + artifact = provenance["artifacts"][0] + checksum_path = root / artifact["checksum"] + checksum_path.write_text( + f"{artifact['sha256']} **{artifact['name']}\n", + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "checksum names the wrong file"): + verify_broker_artifacts.verify_artifacts(root, "v1.2.3") + def test_rejects_dmg_attestation(self) -> None: with tempfile.TemporaryDirectory() as temporary: root = pathlib.Path(temporary) diff --git a/scripts/verify_broker_artifacts.py b/scripts/verify_broker_artifacts.py index de83d40..52ed6f8 100755 --- a/scripts/verify_broker_artifacts.py +++ b/scripts/verify_broker_artifacts.py @@ -133,7 +133,7 @@ def verify_artifacts(root: pathlib.Path, tag: str) -> str: checksum_fields = checksum_path.read_text(encoding="utf-8").strip().split() if len(checksum_fields) != 2 or checksum_fields[0] != digest: raise ValueError(f"Broker checksum content mismatch: {checksum_path.name}") - if checksum_fields[1].lstrip("*") != name: + if checksum_fields[1] not in {name, f"*{name}"}: raise ValueError(f"Broker checksum names the wrong file: {checksum_path.name}") primary = root / f"OpenWritr-v{version}-macOS-arm64.dmg" From a5745248783d3022b313bca05e6f914624b43a85 Mon Sep 17 00:00:00 2001 From: trsdn Date: Tue, 22 Sep 2026 23:22:55 +0200 Subject: [PATCH 6/6] test(release): avoid evaluating publication source Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4ea0580f-655c-457e-9b12-dd7e8dc1dfe5 --- scripts/test_publish_broker_release.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/test_publish_broker_release.sh b/scripts/test_publish_broker_release.sh index b768dd9..9af050d 100755 --- a/scripts/test_publish_broker_release.sh +++ b/scripts/test_publish_broker_release.sh @@ -12,8 +12,13 @@ if [[ "$assignment_count" -ne 1 ]]; then fi assignment_line="$(grep '^smoke_nonce=' "$publication_script")" -eval "$assignment_line" +expected_assignment='smoke_nonce="$(python3 -c '\''import uuid; print("smoke-" + uuid.uuid4().hex)'\'')"' +if [[ "$assignment_line" != "$expected_assignment" ]]; then + echo "Unexpected smoke_nonce assignment: $assignment_line" >&2 + exit 1 +fi +smoke_nonce="$(python3 -c 'import uuid; print("smoke-" + uuid.uuid4().hex)')" if [[ ! "$smoke_nonce" =~ ^smoke-[0-9a-f]{32}$ ]]; then echo "Invalid smoke nonce: $smoke_nonce" >&2 exit 1