Add tag-triggered CI release pipeline for build provenance #6
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Release | |
| on: | |
| push: | |
| tags: | |
| - "v*" | |
| # One release build per tag; never cancel a release mid-flight. | |
| concurrency: | |
| group: release-${{ github.ref }} | |
| cancel-in-progress: false | |
| permissions: | |
| contents: write | |
| id-token: write | |
| attestations: write | |
| env: | |
| WORKSPACE: Cosign.xcworkspace | |
| SCHEME: Cosign | |
| APP_NAME: Cosign | |
| BUILD_CLAIM_KEY_ID: cosign-release-2026-06 | |
| COSIGN_REPOSITORY: hackshare/cosign | |
| jobs: | |
| release: | |
| name: Build, sign, upload, publish | |
| runs-on: macos-26 | |
| environment: ios-release | |
| steps: | |
| - name: Checkout release tag | |
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | |
| with: | |
| fetch-depth: 0 | |
| - name: Verify tag is annotated, signed by the release signer, and matches the event commit | |
| env: | |
| TAG_NAME: ${{ github.ref_name }} | |
| EVENT_SHA: ${{ github.sha }} | |
| GH_TOKEN: ${{ github.token }} | |
| RELEASE_SIGNER_SHA256: 736128e8f88bd12487727c398cdb71efd0195b4447e785c860607feeb004acd6 | |
| run: | | |
| set -euo pipefail | |
| git fetch --tags --force | |
| TAG_TYPE="$(git cat-file -t "refs/tags/${TAG_NAME}")" | |
| if [[ "${TAG_TYPE}" != "tag" ]]; then | |
| echo "Release tag must be an annotated tag, got: ${TAG_TYPE}" >&2 | |
| exit 1 | |
| fi | |
| # GitHub verifies the tag's signature against the account's registered | |
| # signing keys (the same check behind the "Verified" badge), so we don't | |
| # ship a pinned key and key rotation is picked up automatically. | |
| TAG_SHA="$(git rev-parse "${TAG_NAME}")" | |
| TAG_JSON="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${TAG_SHA}")" | |
| VERIFIED="$(jq -r '.verification.verified' <<<"${TAG_JSON}")" | |
| REASON="$(jq -r '.verification.reason' <<<"${TAG_JSON}")" | |
| SIGNER="$(jq -r '.tagger.email' <<<"${TAG_JSON}")" | |
| SIGNER_SHA="$(printf '%s' "${SIGNER}" | shasum -a 256 | cut -d' ' -f1)" | |
| echo "Signature verified=${VERIFIED} (${REASON})" | |
| if [[ "${VERIFIED}" != "true" ]]; then | |
| echo "Release tag signature was not verified by GitHub (reason: ${REASON})" >&2 | |
| exit 1 | |
| fi | |
| # GitHub reports verified=true only when the signing key belongs to an | |
| # account whose verified email matches the tagger identity, so matching the | |
| # signer binds the release to that account cryptographically. The signer is | |
| # pinned as a SHA-256 to keep it out of the source and public run logs; the | |
| # hash only selects the account. | |
| if [[ "${SIGNER_SHA}" != "${RELEASE_SIGNER_SHA256}" ]]; then | |
| echo "Release tag signer is not the trusted release signer" >&2 | |
| exit 1 | |
| fi | |
| TAG_COMMIT="$(git rev-parse "${TAG_NAME}^{}")" | |
| if [[ "${TAG_COMMIT}" != "${EVENT_SHA}" ]]; then | |
| echo "Tag commit does not match the event SHA" >&2 | |
| echo " tag commit: ${TAG_COMMIT}" >&2 | |
| echo " event sha: ${EVENT_SHA}" >&2 | |
| exit 1 | |
| fi | |
| - name: Parse version and build from the tag | |
| env: | |
| TAG_NAME: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| if [[ ! "${TAG_NAME}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?\+[0-9]+$ ]]; then | |
| echo "Tag must look like v<version>+<build>, got: ${TAG_NAME}" >&2 | |
| exit 1 | |
| fi | |
| APP_VERSION="${TAG_NAME#v}" | |
| APP_VERSION="${APP_VERSION%%+*}" | |
| APP_BUILD="${TAG_NAME##*+}" | |
| echo "APP_VERSION=${APP_VERSION}" >> "$GITHUB_ENV" | |
| echo "APP_BUILD=${APP_BUILD}" >> "$GITHUB_ENV" | |
| - name: Install Rust toolchain | |
| uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable | |
| with: | |
| targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios | |
| - name: Build Rust XCFramework | |
| env: | |
| DEVICE_ONLY: "1" | |
| run: ./scripts/build-xcframework.sh | |
| - name: Install Tuist | |
| run: brew install tuist | |
| - name: Generate Xcode project | |
| run: tuist generate --no-open | |
| - name: Fail if the checkout was mutated by dependency resolution | |
| run: | | |
| set -euo pipefail | |
| if [[ -n "$(git status --porcelain)" ]]; then | |
| echo "Working tree is dirty after project generation / package resolution" >&2 | |
| git status --short >&2 | |
| exit 1 | |
| fi | |
| - name: Generate and sign the BuildClaim | |
| env: | |
| TAG_NAME: ${{ github.ref_name }} | |
| GITHUB_REPOSITORY: ${{ github.repository }} | |
| BUILD_CLAIM_PRIVATE_KEY_B64: ${{ secrets.BUILD_CLAIM_PRIVATE_KEY_B64 }} | |
| run: | | |
| set -euo pipefail | |
| BUILD_CLAIM_DIR="$RUNNER_TEMP/build-claim" | |
| rm -rf "$BUILD_CLAIM_DIR"; mkdir -p "$BUILD_CLAIM_DIR" | |
| echo "BUILD_CLAIM_DIR=${BUILD_CLAIM_DIR}" >> "$GITHUB_ENV" | |
| APP_VERSION="$APP_VERSION" APP_BUILD="$APP_BUILD" \ | |
| BUILD_CLAIM_KEY_ID="$BUILD_CLAIM_KEY_ID" \ | |
| COSIGN_TAG="$TAG_NAME" \ | |
| COSIGN_REPOSITORY="$COSIGN_REPOSITORY" \ | |
| python3 ci/create_build_claim.py "$BUILD_CLAIM_DIR/BuildClaim.json" | |
| xcrun swiftc Tools/SignBuildClaim.swift -o "$RUNNER_TEMP/sign-build-claim" | |
| "$RUNNER_TEMP/sign-build-claim" \ | |
| "$BUILD_CLAIM_DIR/BuildClaim.json" \ | |
| "$BUILD_CLAIM_DIR/BuildClaim.sig" | |
| shasum -a 256 "$BUILD_CLAIM_DIR/BuildClaim.json" | |
| - name: Materialize the App Store Connect API key | |
| env: | |
| ASC_KEY_P8_B64: ${{ secrets.ASC_KEY_P8_B64 }} | |
| ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} | |
| run: | | |
| set -euo pipefail | |
| KEY_DIR="$HOME/.appstoreconnect/private_keys" | |
| mkdir -p "$KEY_DIR" | |
| KEY_PATH="$KEY_DIR/AuthKey_${ASC_KEY_ID}.p8" | |
| printf '%s' "$ASC_KEY_P8_B64" | base64 -d > "$KEY_PATH" | |
| chmod 600 "$KEY_PATH" | |
| echo "ASC_KEY_PATH=${KEY_PATH}" >> "$GITHUB_ENV" | |
| - name: Import the Apple Distribution signing certificate | |
| env: | |
| DIST_CERT_P12_B64: ${{ secrets.DIST_CERT_P12_B64 }} | |
| DIST_CERT_P12_PASSWORD: ${{ secrets.DIST_CERT_P12_PASSWORD }} | |
| run: | | |
| set -euo pipefail | |
| KEYCHAIN="$RUNNER_TEMP/signing.keychain-db" | |
| KEYCHAIN_PW="$(openssl rand -base64 24)" | |
| security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" | |
| security set-keychain-settings -lut 21600 "$KEYCHAIN" | |
| security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" | |
| CERT_PATH="$RUNNER_TEMP/dist-cert.p12" | |
| printf '%s' "$DIST_CERT_P12_B64" | base64 -d > "$CERT_PATH" | |
| security import "$CERT_PATH" -k "$KEYCHAIN" -P "$DIST_CERT_P12_PASSWORD" \ | |
| -T /usr/bin/codesign -T /usr/bin/xcodebuild | |
| security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PW" "$KEYCHAIN" >/dev/null | |
| # Prepend our keychain to the search list so xcodebuild finds the cert. | |
| security list-keychains -d user -s "$KEYCHAIN" \ | |
| $(security list-keychains -d user | sed 's/[" ]//g') | |
| rm -f "$CERT_PATH" | |
| - name: Archive with the embedded BuildClaim (signed) | |
| env: | |
| ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} | |
| ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} | |
| run: | | |
| set -euo pipefail | |
| ARCHIVE_PATH="$RUNNER_TEMP/${APP_NAME}.xcarchive" | |
| echo "ARCHIVE_PATH=${ARCHIVE_PATH}" >> "$GITHUB_ENV" | |
| rm -rf "$ARCHIVE_PATH" | |
| xcodebuild archive \ | |
| -workspace "$WORKSPACE" \ | |
| -scheme "$SCHEME" \ | |
| -configuration Release \ | |
| -destination 'generic/platform=iOS' \ | |
| -archivePath "$ARCHIVE_PATH" \ | |
| -allowProvisioningUpdates \ | |
| -authenticationKeyPath "$ASC_KEY_PATH" \ | |
| -authenticationKeyID "$ASC_KEY_ID" \ | |
| -authenticationKeyIssuerID "$ASC_ISSUER_ID" \ | |
| EMBED_BUILD_CLAIM=YES \ | |
| BUILD_CLAIM_DIR="$BUILD_CLAIM_DIR" \ | |
| MARKETING_VERSION="$APP_VERSION" \ | |
| CURRENT_PROJECT_VERSION="$APP_BUILD" | |
| - name: Assert the claim is sealed and the bundle signature is valid | |
| run: | | |
| set -euo pipefail | |
| APP_PATH="$ARCHIVE_PATH/Products/Applications/${APP_NAME}.app" | |
| cmp "$BUILD_CLAIM_DIR/BuildClaim.json" "$APP_PATH/BuildClaim.json" | |
| cmp "$BUILD_CLAIM_DIR/BuildClaim.sig" "$APP_PATH/BuildClaim.sig" | |
| codesign --verify --deep --strict --verbose=4 "$APP_PATH" | |
| - name: Install the App Store provisioning profile | |
| env: | |
| PROVISIONING_PROFILE_B64: ${{ secrets.PROVISIONING_PROFILE_B64 }} | |
| run: | | |
| set -euo pipefail | |
| PROFILES_DIR="$HOME/Library/MobileDevice/Provisioning Profiles" | |
| mkdir -p "$PROFILES_DIR" | |
| printf '%s' "$PROVISIONING_PROFILE_B64" | base64 -d \ | |
| > "$PROFILES_DIR/cosign-ci.mobileprovision" | |
| - name: Export the IPA | |
| run: | | |
| set -euo pipefail | |
| EXPORT_DIR="$RUNNER_TEMP/export" | |
| rm -rf "$EXPORT_DIR"; mkdir -p "$EXPORT_DIR" | |
| # Manual signing (ci/export-options.plist) with the imported distribution | |
| # cert + the installed App Store profile -- no cloud/automatic signing. | |
| xcodebuild -exportArchive \ | |
| -archivePath "$ARCHIVE_PATH" \ | |
| -exportPath "$EXPORT_DIR" \ | |
| -exportOptionsPlist ci/export-options.plist | |
| IPA_PATH="$(find "$EXPORT_DIR" -maxdepth 1 -type f -name '*.ipa' -print -quit)" | |
| [ -n "$IPA_PATH" ] || { echo "No IPA exported" >&2; exit 1; } | |
| echo "IPA_PATH=${IPA_PATH}" >> "$GITHUB_ENV" | |
| shasum -a 256 "$IPA_PATH" | |
| - name: Build the public verification artifact | |
| env: | |
| TAG_NAME: ${{ github.ref_name }} | |
| run: | | |
| set -euo pipefail | |
| RELEASE_DIR="$RUNNER_TEMP/release" | |
| STAGE_DIR="$RUNNER_TEMP/verification-stage" | |
| rm -rf "$RELEASE_DIR" "$STAGE_DIR" | |
| mkdir -p "$RELEASE_DIR" "$STAGE_DIR" | |
| echo "RELEASE_DIR=${RELEASE_DIR}" >> "$GITHUB_ENV" | |
| cp "$BUILD_CLAIM_DIR/BuildClaim.json" "$RELEASE_DIR/" | |
| cp "$BUILD_CLAIM_DIR/BuildClaim.sig" "$RELEASE_DIR/" | |
| ditto "$ARCHIVE_PATH" "$STAGE_DIR/${APP_NAME}.xcarchive" | |
| cp "$BUILD_CLAIM_DIR/BuildClaim.json" "$STAGE_DIR/" | |
| cp "$BUILD_CLAIM_DIR/BuildClaim.sig" "$STAGE_DIR/" | |
| VERIFICATION_ARTIFACT="$RELEASE_DIR/${APP_NAME}-${TAG_NAME}-verification.zip" | |
| ditto -c -k --keepParent "$STAGE_DIR" "$VERIFICATION_ARTIFACT" | |
| echo "VERIFICATION_ARTIFACT=${VERIFICATION_ARTIFACT}" >> "$GITHUB_ENV" | |
| shasum -a 256 "$VERIFICATION_ARTIFACT" | |
| - name: Upload the exact IPA to TestFlight | |
| env: | |
| ASC_KEY_ID: ${{ secrets.ASC_KEY_ID }} | |
| ASC_ISSUER_ID: ${{ secrets.ASC_ISSUER_ID }} | |
| run: | | |
| set -euo pipefail | |
| UPLOADED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)" | |
| echo "UPLOADED_AT=${UPLOADED_AT}" >> "$GITHUB_ENV" | |
| xcrun altool --upload-app \ | |
| --type ios \ | |
| --file "$IPA_PATH" \ | |
| --apiKey "$ASC_KEY_ID" \ | |
| --apiIssuer "$ASC_ISSUER_ID" | |
| - name: Generate and sign the submission receipt | |
| env: | |
| TAG_NAME: ${{ github.ref_name }} | |
| GITHUB_REPOSITORY: ${{ github.repository }} | |
| EVENT_SHA: ${{ github.sha }} | |
| RUN_ID: ${{ github.run_id }} | |
| RUN_ATTEMPT: ${{ github.run_attempt }} | |
| SUBMISSION_PRIVATE_KEY_B64: ${{ secrets.SUBMISSION_PRIVATE_KEY_B64 }} | |
| run: | | |
| set -euo pipefail | |
| CLAIM_SHA256="$(shasum -a 256 "$BUILD_CLAIM_DIR/BuildClaim.json" | awk '{print $1}')" | |
| IPA_SHA256="$(shasum -a 256 "$IPA_PATH" | awk '{print $1}')" | |
| VERIFY_SHA256="$(shasum -a 256 "$VERIFICATION_ARTIFACT" | awk '{print $1}')" | |
| python3 ci/create_submission_receipt.py \ | |
| --out "$RELEASE_DIR/TestFlightSubmission.json" \ | |
| --key-id "$BUILD_CLAIM_KEY_ID" \ | |
| --repository "$GITHUB_REPOSITORY" \ | |
| --tag "$TAG_NAME" \ | |
| --commit "$EVENT_SHA" \ | |
| --version "$APP_VERSION" \ | |
| --build "$APP_BUILD" \ | |
| --claim-sha256 "sha256:${CLAIM_SHA256}" \ | |
| --ipa-sha256 "sha256:${IPA_SHA256}" \ | |
| --verification-sha256 "sha256:${VERIFY_SHA256}" \ | |
| --run-id "$RUN_ID" \ | |
| --run-attempt "$RUN_ATTEMPT" \ | |
| --uploaded-at "$UPLOADED_AT" | |
| xcrun swiftc Tools/SignJsonFile.swift -o "$RUNNER_TEMP/sign-json-file" | |
| SIGNING_PRIVATE_KEY_B64="$SUBMISSION_PRIVATE_KEY_B64" \ | |
| "$RUNNER_TEMP/sign-json-file" \ | |
| "$RELEASE_DIR/TestFlightSubmission.json" \ | |
| "$RELEASE_DIR/TestFlightSubmission.sig" | |
| - name: Write SHA256SUMS | |
| run: | | |
| set -euo pipefail | |
| ( | |
| cd "$RELEASE_DIR" | |
| shasum -a 256 \ | |
| BuildClaim.json \ | |
| BuildClaim.sig \ | |
| TestFlightSubmission.json \ | |
| TestFlightSubmission.sig \ | |
| "$(basename "$VERIFICATION_ARTIFACT")" \ | |
| > SHA256SUMS | |
| ) | |
| cat "$RELEASE_DIR/SHA256SUMS" | |
| - name: Attest the verification artifact | |
| uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 | |
| with: | |
| subject-path: ${{ env.VERIFICATION_ARTIFACT }} | |
| - name: Publish the GitHub Release | |
| env: | |
| TAG_NAME: ${{ github.ref_name }} | |
| GH_TOKEN: ${{ github.token }} | |
| run: | | |
| set -euo pipefail | |
| gh release create "$TAG_NAME" \ | |
| --verify-tag \ | |
| --draft \ | |
| --title "$TAG_NAME" \ | |
| --notes "Signed release build $TAG_NAME. The app shows this build's identity under Settings, Build verification; match the SHA-256 of BuildClaim.json here against the fingerprint the app shows. Attached: BuildClaim.{json,sig}, a signed submission receipt, an attested verification artifact, and SHA256SUMS." \ | |
| "$RELEASE_DIR/BuildClaim.json" \ | |
| "$RELEASE_DIR/BuildClaim.sig" \ | |
| "$RELEASE_DIR/TestFlightSubmission.json" \ | |
| "$RELEASE_DIR/TestFlightSubmission.sig" \ | |
| "$RELEASE_DIR/SHA256SUMS" \ | |
| "$VERIFICATION_ARTIFACT" | |
| gh release edit "$TAG_NAME" --draft=false |