diff --git a/.github/actions/ci-gate/action.yml b/.github/actions/ci-gate/action.yml index 861b8c13..08254644 100644 --- a/.github/actions/ci-gate/action.yml +++ b/.github/actions/ci-gate/action.yml @@ -69,13 +69,21 @@ inputs: required: false default: "1" apple-developer-id-p12-base64: - description: Base64-encoded Developer ID certificate. + description: Base64-encoded Developer ID certificate. Pass it when this build signs with Developer ID. required: false default: "" apple-developer-id-p12-password: description: Password for the Developer ID certificate. required: false default: "" + apple-distribution-p12-base64: + description: Base64-encoded Apple Distribution certificate. Pass it when this build runs where development provisioning cannot work, so only App Store profiles sign. + required: false + default: "" + apple-distribution-p12-password: + description: Password for the Apple Distribution certificate. + required: false + default: "" apple-developer-id-profile-base64: description: Base64-encoded Developer ID provisioning profile. required: false @@ -174,8 +182,10 @@ runs: SWIFT_MK_HELPER_ROOT: ${{ github.repository == 'agoodkind/swift-makefile' && '.' || '.swift-makefile' }} IMPORT_SIGNING_CERT: ${{ inputs.import-signing-cert }} INSTALL_PROVISIONING_PROFILE: ${{ inputs.install-provisioning-profile }} - HAS_SIGNING_CERT: ${{ inputs.apple-developer-id-p12-base64 != '' }} - HAS_SIGNING_PASSWORD: ${{ inputs.apple-developer-id-p12-password != '' }} + HAS_DEVELOPER_ID_CERT: ${{ inputs.apple-developer-id-p12-base64 != '' }} + HAS_DEVELOPER_ID_PASSWORD: ${{ inputs.apple-developer-id-p12-password != '' }} + HAS_DISTRIBUTION_CERT: ${{ inputs.apple-distribution-p12-base64 != '' }} + HAS_DISTRIBUTION_PASSWORD: ${{ inputs.apple-distribution-p12-password != '' }} HAS_PROVISIONING_PROFILE: ${{ inputs.apple-developer-id-profile-base64 != '' }} run: swift "${SWIFT_MK_HELPER_ROOT}/.github/actions/workflow-helper/workflow-helper.swift" validate-signing-inputs @@ -184,8 +194,10 @@ runs: if: ${{ inputs.run != 'false' && (inputs.gate != 'extra-targets' || steps.resolve.outputs.count != '0') && inputs.import-signing-cert == 'true' && github.repository == 'agoodkind/swift-makefile' }} uses: ./.github/actions/import-signing-cert with: - p12-base64: ${{ inputs.apple-developer-id-p12-base64 }} - p12-password: ${{ inputs.apple-developer-id-p12-password }} + developer-id-p12-base64: ${{ inputs.apple-developer-id-p12-base64 }} + developer-id-p12-password: ${{ inputs.apple-developer-id-p12-password }} + distribution-p12-base64: ${{ inputs.apple-distribution-p12-base64 }} + distribution-p12-password: ${{ inputs.apple-distribution-p12-password }} identity-name: ${{ inputs.signing-identity-name }} - name: Install signing certificate @@ -193,8 +205,10 @@ runs: if: ${{ inputs.run != 'false' && (inputs.gate != 'extra-targets' || steps.resolve.outputs.count != '0') && inputs.import-signing-cert == 'true' && github.repository != 'agoodkind/swift-makefile' }} uses: agoodkind/swift-makefile/.github/actions/import-signing-cert@main with: - p12-base64: ${{ inputs.apple-developer-id-p12-base64 }} - p12-password: ${{ inputs.apple-developer-id-p12-password }} + developer-id-p12-base64: ${{ inputs.apple-developer-id-p12-base64 }} + developer-id-p12-password: ${{ inputs.apple-developer-id-p12-password }} + distribution-p12-base64: ${{ inputs.apple-distribution-p12-base64 }} + distribution-p12-password: ${{ inputs.apple-distribution-p12-password }} identity-name: ${{ inputs.signing-identity-name }} - name: Install provisioning profile (swift-makefile) diff --git a/.github/actions/import-signing-cert/action.yml b/.github/actions/import-signing-cert/action.yml index 8fbab7f0..0b976aeb 100644 --- a/.github/actions/import-signing-cert/action.yml +++ b/.github/actions/import-signing-cert/action.yml @@ -1,15 +1,25 @@ -name: Import Developer ID signing certificate -description: Import the Developer ID Application certificate with apple-actions/import-codesign-certs and resolve its codesigning identity SHA-1. +name: Import signing certificates +description: Import the signing certificates a build needs into one keychain and resolve the codesigning identity SHA-1 for the identity the caller names. inputs: - p12-base64: - description: Base64-encoded Developer ID Application .p12. - required: true - p12-password: - description: Import password for the .p12. - required: true + developer-id-p12-base64: + description: Base64-encoded Developer ID Application .p12. Pass it when the build signs anything with Developer ID. + required: false + default: "" + developer-id-p12-password: + description: Import password for the Developer ID .p12. + required: false + default: "" + distribution-p12-base64: + description: Base64-encoded Apple Distribution .p12. Pass it when the build signs on a machine that is not a registered device, where only App Store profiles work. + required: false + default: "" + distribution-p12-password: + description: Import password for the Apple Distribution .p12. + required: false + default: "" identity-name: - description: 'Full identity name to resolve, e.g. "Developer ID Application: Name (TEAMID)".' + description: 'Full identity name to resolve, e.g. "Developer ID Application: Name (TEAMID)" or "Apple Distribution: Name (TEAMID)". It decides which imported certificate signs.' required: true outputs: @@ -49,17 +59,82 @@ runs: printf 'path_noext=%s\n' "$keychain_base" >> "$GITHUB_OUTPUT" printf 'path=%s\n' "$keychain_path" >> "$GITHUB_OUTPUT" + - name: Require at least one certificate + shell: bash + env: + DEVELOPER_ID_P12: ${{ inputs.developer-id-p12-base64 }} + DISTRIBUTION_P12: ${{ inputs.distribution-p12-base64 }} + run: | + set -euo pipefail + + if [[ -z "$DEVELOPER_ID_P12" && -z "$DISTRIBUTION_P12" ]]; then + echo "signing was requested but neither a Developer ID nor an Apple Distribution certificate was passed" >&2 + exit 1 + fi + - name: Remove stale signing keychain for this runner shell: bash env: KEYCHAIN_PATH: ${{ steps.keychain.outputs.path }} run: security delete-keychain "$KEYCHAIN_PATH" 2>/dev/null || true - - uses: apple-actions/import-codesign-certs@v3 + # The keychain is created here rather than by an import step so that each + # import is guarded only by whether its own certificate was passed. Letting + # the first import create it would make the steps order-dependent: whichever + # ran first would have to create, and the other would have to not, which + # cannot be expressed when either may be absent. + - name: Create the signing keychain + id: create-keychain + shell: bash + env: + KEYCHAIN_NOEXT: ${{ steps.keychain.outputs.path_noext }} + KEYCHAIN_PATH: ${{ steps.keychain.outputs.path }} + run: | + set -euo pipefail + + # One command, no pipe. Reading /dev/urandom through a pipe into a + # byte-counting reader makes the reader exit first, which kills the writer + # with a broken pipe; under `pipefail` that fails, and on a runner it hung + # instead. openssl reads its own randomness and stops on its own. + keychain_password="$(openssl rand -hex 24)" + # Masked before it reaches any later step's environment, so no log can + # echo it even if a step runs with the shell tracing on. + echo "::add-mask::${keychain_password}" + + # Create with the .keychain suffix, which is what apple-actions passes. + # Under ~/Library/Keychains the Security framework appends -db to whatever + # path it is given, so this produces the .keychain-db file every later + # command names. Passing the extensionless path instead produces a file + # ending in a bare -db, and each later command then names a keychain that + # does not exist. + security create-keychain -p "$keychain_password" "${KEYCHAIN_NOEXT}.keychain" + # Keep it unlocked for the job: the default is a six-hour idle relock, + # which a long build can cross, and a relocked keychain fails signing + # with a prompt nobody can answer. + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$keychain_password" "$KEYCHAIN_PATH" + + printf 'password=%s\n' "$keychain_password" >> "$GITHUB_OUTPUT" + + - name: Import the Developer ID certificate + if: ${{ inputs.developer-id-p12-base64 != '' }} + uses: apple-actions/import-codesign-certs@v3 + with: + p12-file-base64: ${{ inputs.developer-id-p12-base64 }} + p12-password: ${{ inputs.developer-id-p12-password }} + keychain: ${{ steps.keychain.outputs.path_noext }} + create-keychain: "false" + keychain-password: ${{ steps.create-keychain.outputs.password }} + + - name: Import the Apple Distribution certificate + if: ${{ inputs.distribution-p12-base64 != '' }} + uses: apple-actions/import-codesign-certs@v3 with: - p12-file-base64: ${{ inputs.p12-base64 }} - p12-password: ${{ inputs.p12-password }} + p12-file-base64: ${{ inputs.distribution-p12-base64 }} + p12-password: ${{ inputs.distribution-p12-password }} keychain: ${{ steps.keychain.outputs.path_noext }} + create-keychain: "false" + keychain-password: ${{ steps.create-keychain.outputs.password }} - name: Scope the user keychain search list to the signing keychain shell: bash @@ -78,7 +153,7 @@ runs: # "No certificate for team". Set the list explicitly to the signing # keychain plus the System keychain: the signing keychain supplies the # identity and the System keychain supplies the trust anchors that - # validate the Developer ID chain. + # validate the certificate chain. security list-keychains -d user -s "$KEYCHAIN_PATH" /Library/Keychains/System.keychain - name: Resolve identity SHA-1 @@ -99,7 +174,12 @@ runs: )" if [[ -z "$identity_sha1" ]]; then - echo "Developer ID Application identity '$IDENTITY_NAME' not found in $KEYCHAIN_PATH" >&2 + echo "signing identity '$IDENTITY_NAME' not found in $KEYCHAIN_PATH" >&2 + echo "identities that were imported:" >&2 + # Names only. This is what tells a reader whether the wrong + # certificate was supplied rather than the right one failing to + # validate, which is otherwise indistinguishable from this failure. + security find-identity -p codesigning "$KEYCHAIN_PATH" >&2 exit 1 fi diff --git a/.github/actions/workflow-helper/workflow-helper.swift b/.github/actions/workflow-helper/workflow-helper.swift index 8f9a7795..047d66fb 100644 --- a/.github/actions/workflow-helper/workflow-helper.swift +++ b/.github/actions/workflow-helper/workflow-helper.swift @@ -23,6 +23,7 @@ private enum WorkflowHelperError: LocalizedError { case invalidJSONShape(label: String) case invalidJSONElement(label: String) case failedCommand(command: String, exitStatus: Int32) + case missingSigningCertificate case missingSigningSecret(String) var errorDescription: String? { @@ -41,6 +42,14 @@ private enum WorkflowHelperError: LocalizedError { return "\(label) must contain only strings" case let .failedCommand(command, exitStatus): return "\(command) exited with status \(exitStatus)" + case .missingSigningCertificate: + return + "workflow-helper: signed CI needs a certificate, and neither " + + "APPLE_DEVELOPER_ID_P12_BASE64 nor APPLE_DISTRIBUTION_P12_BASE64 is set. " + + "Set the one this repository signs CI with: Apple Distribution when the " + + "runner is not a registered device, Developer ID otherwise. " + + "If this fails in a Dependabot run, check that the same secret name is " + + "available as a Dependabot secret" case let .missingSigningSecret(name): return "workflow-helper: \(name) is required for signed CI; " @@ -142,20 +151,43 @@ private func requireSigningSecret(_ present: Bool, name: String) throws { private func validateSigningInputs(environment: Environment) throws { if environment.bool("IMPORT_SIGNING_CERT") { + try requireSigningCertificate(environment: environment) + } + + if environment.bool("INSTALL_PROVISIONING_PROFILE") { try requireSigningSecret( - environment.bool("HAS_SIGNING_CERT"), - name: "APPLE_DEVELOPER_ID_P12_BASE64" + environment.bool("HAS_PROVISIONING_PROFILE"), + name: "APPLE_DEVELOPER_ID_PROFILE_BASE64" ) + } +} + +/// A signed build needs at least one certificate, and each certificate it does +/// supply needs its password. +/// +/// Which certificate is right depends on where the build runs. A machine that is +/// not a registered device cannot use development provisioning, so it signs with +/// Apple Distribution and App Store profiles; a build producing something a person +/// downloads signs with Developer ID. A repository that does both supplies both, +/// and `signing-identity-name` decides which one a given build uses. So this +/// refuses only the case where neither was supplied, rather than naming one. +private func requireSigningCertificate(environment: Environment) throws { + let hasDeveloperID = environment.bool("HAS_DEVELOPER_ID_CERT") + let hasDistribution = environment.bool("HAS_DISTRIBUTION_CERT") + + guard hasDeveloperID || hasDistribution else { + throw WorkflowHelperError.missingSigningCertificate + } + if hasDeveloperID { try requireSigningSecret( - environment.bool("HAS_SIGNING_PASSWORD"), + environment.bool("HAS_DEVELOPER_ID_PASSWORD"), name: "APPLE_DEVELOPER_ID_P12_PASSWORD" ) } - - if environment.bool("INSTALL_PROVISIONING_PROFILE") { + if hasDistribution { try requireSigningSecret( - environment.bool("HAS_PROVISIONING_PROFILE"), - name: "APPLE_DEVELOPER_ID_PROFILE_BASE64" + environment.bool("HAS_DISTRIBUTION_PASSWORD"), + name: "APPLE_DISTRIBUTION_P12_PASSWORD" ) } } diff --git a/.github/workflows/_ci.yml b/.github/workflows/_ci.yml index c9762ed9..bc2654e4 100644 --- a/.github/workflows/_ci.yml +++ b/.github/workflows/_ci.yml @@ -92,10 +92,24 @@ on: type: string default: agk-local-macos-26 secrets: + # The two certificates a build can sign with. Set whichever ones this + # repository actually signs with; `signing-identity-name` decides which one + # a given build uses, so passing both is normal for a repository that signs + # its CI and its release differently. + # + # A repository whose CI runner is not a registered device cannot use + # development provisioning, so its CI signs with Apple Distribution and App + # Store profiles, which carry no device list. It still releases with + # Developer ID, because a downloadable app cannot be App Store signed. Such a + # repository sets both. APPLE_DEVELOPER_ID_P12_BASE64: required: false APPLE_DEVELOPER_ID_P12_PASSWORD: required: false + APPLE_DISTRIBUTION_P12_BASE64: + required: false + APPLE_DISTRIBUTION_P12_PASSWORD: + required: false APPLE_DEVELOPER_ID_PROFILE_BASE64: required: false # App Store Connect API key for iOS automatic signing @@ -353,6 +367,8 @@ jobs: fetch-depth: ${{ inputs.fetch-depth }} apple-developer-id-p12-base64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }} apple-developer-id-p12-password: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} + apple-distribution-p12-base64: ${{ secrets.APPLE_DISTRIBUTION_P12_BASE64 }} + apple-distribution-p12-password: ${{ secrets.APPLE_DISTRIBUTION_P12_PASSWORD }} apple-developer-id-profile-base64: ${{ secrets.APPLE_DEVELOPER_ID_PROFILE_BASE64 }} apple-notary-key-base64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }} apple-notary-key-id: ${{ secrets.APPLE_NOTARY_KEY_ID }} @@ -400,6 +416,8 @@ jobs: fetch-depth: ${{ inputs.fetch-depth }} apple-developer-id-p12-base64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }} apple-developer-id-p12-password: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} + apple-distribution-p12-base64: ${{ secrets.APPLE_DISTRIBUTION_P12_BASE64 }} + apple-distribution-p12-password: ${{ secrets.APPLE_DISTRIBUTION_P12_PASSWORD }} apple-developer-id-profile-base64: ${{ secrets.APPLE_DEVELOPER_ID_PROFILE_BASE64 }} apple-notary-key-base64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }} apple-notary-key-id: ${{ secrets.APPLE_NOTARY_KEY_ID }} @@ -526,6 +544,8 @@ jobs: fetch-depth: ${{ inputs.fetch-depth }} apple-developer-id-p12-base64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }} apple-developer-id-p12-password: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} + apple-distribution-p12-base64: ${{ secrets.APPLE_DISTRIBUTION_P12_BASE64 }} + apple-distribution-p12-password: ${{ secrets.APPLE_DISTRIBUTION_P12_PASSWORD }} apple-developer-id-profile-base64: ${{ secrets.APPLE_DEVELOPER_ID_PROFILE_BASE64 }} apple-notary-key-base64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }} apple-notary-key-id: ${{ secrets.APPLE_NOTARY_KEY_ID }} @@ -573,6 +593,8 @@ jobs: fetch-depth: ${{ inputs.fetch-depth }} apple-developer-id-p12-base64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }} apple-developer-id-p12-password: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} + apple-distribution-p12-base64: ${{ secrets.APPLE_DISTRIBUTION_P12_BASE64 }} + apple-distribution-p12-password: ${{ secrets.APPLE_DISTRIBUTION_P12_PASSWORD }} apple-developer-id-profile-base64: ${{ secrets.APPLE_DEVELOPER_ID_PROFILE_BASE64 }} apple-notary-key-base64: ${{ secrets.APPLE_NOTARY_KEY_BASE64 }} apple-notary-key-id: ${{ secrets.APPLE_NOTARY_KEY_ID }} diff --git a/.github/workflows/_release.yml b/.github/workflows/_release.yml index 0ecf2c00..5dd52f45 100644 --- a/.github/workflows/_release.yml +++ b/.github/workflows/_release.yml @@ -554,13 +554,31 @@ jobs: SWIFT_MK_HELPER_ROOT: ${{ github.repository == 'agoodkind/swift-makefile' && '.' || '.swift-makefile' }} run: bash "${SWIFT_MK_HELPER_ROOT}/.github/actions/ci-diagnostics/start.sh" "${RUNNER_TEMP}/ci-diagnostics" "${SWIFT_MK_HELPER_ROOT}" + # A release signs with Developer ID, whatever a repository's CI signs with, + # because an App Store certificate cannot sign something a person downloads. + # + # Two steps for one action, the same split the CI gate uses. This repository + # runs the copy in the branch under test, so a change to the action is + # exercised by the release dry run that ships it; every other repository runs + # the released copy. Referencing only @main would have this repository test + # the previous action against the current workflow, which fails whenever the + # two change together. + - name: Install signing certificate (swift-makefile) + id: cert-local + if: ${{ env.HAS_SIGNING == 'true' && github.repository == 'agoodkind/swift-makefile' }} + uses: ./.github/actions/import-signing-cert + with: + developer-id-p12-base64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }} + developer-id-p12-password: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} + identity-name: ${{ inputs.signing-identity-name }} + - name: Install signing certificate - id: cert - if: env.HAS_SIGNING == 'true' + id: cert-remote + if: ${{ env.HAS_SIGNING == 'true' && github.repository != 'agoodkind/swift-makefile' }} uses: agoodkind/swift-makefile/.github/actions/import-signing-cert@main with: - p12-base64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }} - p12-password: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} + developer-id-p12-base64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }} + developer-id-p12-password: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} identity-name: ${{ inputs.signing-identity-name }} - name: Install provisioning profile @@ -579,8 +597,8 @@ jobs: shell: bash env: SWIFT_MK_HELPER_ROOT: ${{ github.repository == 'agoodkind/swift-makefile' && '.' || '.swift-makefile' }} - SIGNING_KEYCHAIN: ${{ steps.cert.outputs.keychain }} - SIGNING_IDENTITY_SHA1: ${{ steps.cert.outputs.identity-sha1 }} + SIGNING_KEYCHAIN: ${{ steps.cert-local.outputs.keychain || steps.cert-remote.outputs.keychain }} + SIGNING_IDENTITY_SHA1: ${{ steps.cert-local.outputs.identity-sha1 || steps.cert-remote.outputs.identity-sha1 }} run: | bash "${SWIFT_MK_HELPER_ROOT}/.github/actions/ci-diagnostics/capture-signing.sh" \ "${RUNNER_TEMP}/ci-diagnostics" "${SIGNING_KEYCHAIN}" "${SIGNING_IDENTITY_SHA1}" @@ -593,8 +611,8 @@ jobs: - name: Build release artifacts shell: bash env: - CERT_SHA1: ${{ steps.cert.outputs.identity-sha1 }} - CODE_SIGN_KEYCHAIN: ${{ steps.cert.outputs.keychain }} + CERT_SHA1: ${{ steps.cert-local.outputs.identity-sha1 || steps.cert-remote.outputs.identity-sha1 }} + CODE_SIGN_KEYCHAIN: ${{ steps.cert-local.outputs.keychain || steps.cert-remote.outputs.keychain }} TEAM_ID: ${{ inputs.apple-team-id }} PROVISIONING_PROFILE_SPECIFIER: ${{ steps.profile.outputs.profile-specifier }} RELEASE_TAG: ${{ needs.meta.outputs.tag }}