Skip to content

Add tag-triggered CI release pipeline for build provenance #6

Add tag-triggered CI release pipeline for build provenance

Add tag-triggered CI release pipeline for build provenance #6

Workflow file for this run

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