Skip to content

Publish Release

Publish Release #45

Workflow file for this run

name: Publish Release
on:
workflow_dispatch:
inputs:
tag:
description: Existing release tag to publish
required: true
type: string
identity_migration_phase:
description: Apple identity migration mode for this release
required: true
default: disabled
type: choice
options:
- disabled
- legacy-preparer
concurrency:
group: release-draft-creation
queue: max
permissions:
contents: read
jobs:
validate-ref:
name: Validate Approved Release Ref
if: github.ref == 'refs/heads/main'
runs-on: macos-26
outputs:
commit: ${{ steps.release-ref.outputs.commit }}
tooling-commit: ${{ steps.release-ref.outputs.tooling-commit }}
steps:
- name: Check out trusted release tooling
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
ref: main
fetch-depth: 0
persist-credentials: false
- name: Require a tag reachable from protected main
id: release-ref
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
echo "commit=$(./Scripts/verify_release_ref.sh "$RELEASE_TAG")" >> "$GITHUB_OUTPUT"
echo "tooling-commit=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
stage:
name: Build Approved Source Without Secrets
needs: validate-ref
if: github.ref == 'refs/heads/main'
runs-on: macos-26
permissions:
contents: read
steps:
- name: Check out trusted release tooling
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
ref: ${{ needs.validate-ref.outputs.tooling-commit }}
path: trusted-control-plane
fetch-depth: 0
persist-credentials: false
- name: Check out approved tagged source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
ref: ${{ needs.validate-ref.outputs.commit }}
path: release-source
fetch-depth: 0
persist-credentials: false
- name: Resolve Stable release identity context
env:
REQUESTED_IDENTITY_MIGRATION_PHASE: ${{ inputs.identity_migration_phase }}
run: |
set -euo pipefail
python3 trusted-control-plane/Scripts/stable_rollout.py workflow-guard \
--declaration release-source/release-rollout.json \
--policy trusted-control-plane/Scripts/apple_identity_policy.json
python3 trusted-control-plane/Scripts/stable_rollout.py packaging-context \
--declaration release-source/release-rollout.json \
--policy trusted-control-plane/Scripts/apple_identity_policy.json \
--version-env release-source/version.env \
--expected-migration-phase "$REQUESTED_IDENTITY_MIGRATION_PHASE" \
--github-env "$GITHUB_ENV" \
--github-summary "$GITHUB_STEP_SUMMARY"
- name: Build and stage approved source
env:
GH_TOKEN: ${{ github.token }}
RELEASE_COMMIT: ${{ needs.validate-ref.outputs.commit }}
RELEASE_TAG: ${{ inputs.tag }}
SOURCE_GITHUB_REPOSITORY: ${{ github.repository }}
REPOPROMPT_CONTROL_PLANE_SCRIPTS_DIR: ${{ github.workspace }}/trusted-control-plane/Scripts
REPOPROMPT_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/release-source
# Link the Sentry SDK into the official release binary. The protected DSN is
# injected later in the signing job, so the secret-free stage stays inert.
REPOPROMPT_ENABLE_SENTRY: "1"
run: ./trusted-control-plane/Scripts/release.sh stage-publish
- name: Upload staged release source
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: RepoPrompt-CE-staged-release
path: |
release-source/dist/*-stage.zip
release-source/dist/*-stage.zip.sha256
if-no-files-found: error
retention-days: 1
publish:
name: Sign, Notarize, and Create Draft
needs:
- validate-ref
- stage
if: github.ref == 'refs/heads/main'
environment: release
runs-on: macos-26
permissions:
contents: write
steps:
- name: Check out trusted release tooling
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
ref: ${{ needs.validate-ref.outputs.tooling-commit }}
path: trusted-control-plane
fetch-depth: 0
persist-credentials: false
- name: Download staged release source
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: RepoPrompt-CE-staged-release
path: staged-release
- name: Check out approved source as data
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
ref: ${{ needs.validate-ref.outputs.commit }}
path: approved-source
fetch-depth: 1
persist-credentials: false
- name: Resolve Stable release identity context
env:
REQUESTED_IDENTITY_MIGRATION_PHASE: ${{ inputs.identity_migration_phase }}
run: |
set -euo pipefail
python3 trusted-control-plane/Scripts/stable_rollout.py workflow-guard \
--declaration approved-source/release-rollout.json \
--policy trusted-control-plane/Scripts/apple_identity_policy.json
python3 trusted-control-plane/Scripts/stable_rollout.py packaging-context \
--declaration approved-source/release-rollout.json \
--policy trusted-control-plane/Scripts/apple_identity_policy.json \
--version-env approved-source/version.env \
--expected-migration-phase "$REQUESTED_IDENTITY_MIGRATION_PHASE" \
--github-env "$GITHUB_ENV" \
--github-summary "$GITHUB_STEP_SUMMARY"
- name: Verify and expand staged release source
env:
RELEASE_COMMIT: ${{ needs.validate-ref.outputs.commit }}
run: |
set -euo pipefail
cd staged-release
shopt -s nullglob
archives=(./*-stage.zip)
checksum_files=(./*-stage.zip.sha256)
[[ "${#archives[@]}" == "1" ]] || { echo "Expected exactly one staged ZIP" >&2; exit 1; }
[[ "${#checksum_files[@]}" == "1" ]] || { echo "Expected exactly one staged ZIP checksum" >&2; exit 1; }
archive="${archives[0]}"
[[ "${checksum_files[0]}" == "$archive.sha256" ]] || { echo "Staged ZIP checksum name mismatch" >&2; exit 1; }
shasum -a 256 -c "${checksum_files[0]}"
../trusted-control-plane/Scripts/extract_staged_release.py "$archive" ../release-source RepoPrompt
REPOPROMPT_RELEASE_SOURCE_ROOT=../release-source \
REPOPROMPT_APPROVED_SOURCE_ROOT=../approved-source \
../trusted-control-plane/Scripts/validate_staged_release.sh
- name: Import Developer ID certificate
env:
CERTIFICATE_P12_BASE64: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_BASE64 }}
CERTIFICATE_P12_PASSWORD: ${{ secrets.DEVELOPER_ID_APPLICATION_P12_PASSWORD }}
CI_KEYCHAIN_PASSWORD: ${{ secrets.CI_KEYCHAIN_PASSWORD }}
run: |
set -euo pipefail
umask 077
KEYCHAIN_PATH="$RUNNER_TEMP/repoprompt-release.keychain-db"
CERTIFICATE_PATH="$RUNNER_TEMP/repoprompt-release.p12"
keychain_created=0
cleanup_certificate_and_failed_keychain() {
status=$?
rm -f "$CERTIFICATE_PATH"
if (( status != 0 )) && (( keychain_created )); then
security delete-keychain "$KEYCHAIN_PATH" || true
fi
}
trap cleanup_certificate_and_failed_keychain EXIT
: "${EXPECTED_SIGN_IDENTITY:?Apple application identity is missing from the reviewed policy}"
printf '%s' "$CERTIFICATE_P12_BASE64" | base64 --decode > "$CERTIFICATE_PATH"
security create-keychain -p "$CI_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
keychain_created=1
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "$CI_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security import "$CERTIFICATE_PATH" -k "$KEYCHAIN_PATH" -P "$CERTIFICATE_P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$CI_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security list-keychains -d user -s "$KEYCHAIN_PATH"
security find-identity -v -p codesigning "$KEYCHAIN_PATH" |
grep -F "\"$EXPECTED_SIGN_IDENTITY\"" >/dev/null || {
echo "Expected application identity is not present and usable in the ephemeral keychain" >&2
exit 1
}
printf 'KEYCHAIN_PATH=%s\n' "$KEYCHAIN_PATH" >> "$GITHUB_ENV"
printf 'SIGN_IDENTITY=%s\n' "$EXPECTED_SIGN_IDENTITY" >> "$GITHUB_ENV"
- name: Prepare successor identity migration anchor
if: inputs.identity_migration_phase == 'legacy-preparer'
env:
SUCCESSOR_CERTIFICATE_P12_BASE64: ${{ secrets.SUCCESSOR_DEVELOPER_ID_APPLICATION_P12_BASE64 }}
SUCCESSOR_CERTIFICATE_P12_PASSWORD: ${{ secrets.SUCCESSOR_DEVELOPER_ID_APPLICATION_P12_PASSWORD }}
CI_KEYCHAIN_PASSWORD: ${{ secrets.CI_KEYCHAIN_PASSWORD }}
run: |
set -euo pipefail
umask 077
: "${SUCCESSOR_CERTIFICATE_P12_BASE64:?Successor Developer ID Application certificate is not configured}"
: "${SUCCESSOR_CERTIFICATE_P12_PASSWORD:?Successor Developer ID Application password is not configured}"
expected_identity="$EXPECTED_MIGRATION_ANCHOR_SIGN_IDENTITY"
[[ -n "$expected_identity" ]] || {
echo "Migration-anchor signing identity is missing from the reviewed policy" >&2
exit 1
}
successor_certificate="$RUNNER_TEMP/repoprompt-release-successor.p12"
anchor_source="trusted-control-plane/Scripts/identity_migration_anchor.c"
anchor="$RUNNER_TEMP/repoprompt-successor-identity-anchor"
[[ -f "$anchor_source" ]] || {
echo "Missing trusted successor identity anchor source: $anchor_source" >&2
exit 1
}
xcrun clang -arch arm64 -arch x86_64 -Os -Wl,-dead_strip -o "$anchor" "$anchor_source"
[[ -f "$anchor" && -x "$anchor" ]] || {
echo "Failed to build successor identity anchor" >&2
exit 1
}
printf '%s' "$SUCCESSOR_CERTIFICATE_P12_BASE64" | base64 --decode > "$successor_certificate"
security import "$successor_certificate" -k "$KEYCHAIN_PATH" \
-P "$SUCCESSOR_CERTIFICATE_P12_PASSWORD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: \
-s -k "$CI_KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH"
security find-identity -v -p codesigning "$KEYCHAIN_PATH" |
grep -F "\"$expected_identity\"" >/dev/null || {
echo "Expected successor application identity is not present and usable in the ephemeral keychain" >&2
exit 1
}
codesign --force --sign "$expected_identity" --keychain "$KEYCHAIN_PATH" \
--identifier "$EXPECTED_MIGRATION_ANCHOR_BUNDLE_ID" \
--timestamp --options runtime "$anchor"
codesign --verify --strict --verbose=2 \
-R="$EXPECTED_MIGRATION_ANCHOR_REQUIREMENT" "$anchor"
signature_details="$(codesign -dv --verbose=4 "$anchor" 2>&1)"
grep -Fx "Identifier=$EXPECTED_MIGRATION_ANCHOR_BUNDLE_ID" \
<<< "$signature_details" >/dev/null
grep -Fx "TeamIdentifier=$EXPECTED_MIGRATION_ANCHOR_TEAM_ID" \
<<< "$signature_details" >/dev/null
printf 'REPOPROMPT_IDENTITY_MIGRATION_ANCHOR=%s\n' "$anchor" >> "$GITHUB_ENV"
- name: Prepare provisioning profile and notarization key
env:
PROVISIONING_PROFILE_BASE64: ${{ secrets.REPOPROMPT_CE_PROVISIONING_PROFILE_BASE64 }}
NOTARYTOOL_PRIVATE_KEY_BASE64: ${{ secrets.NOTARYTOOL_PRIVATE_KEY_BASE64 }}
run: |
set -euo pipefail
umask 077
SECRETS_DIR="$RUNNER_TEMP/repoprompt-release-secrets"
mkdir -p "$SECRETS_DIR"
printf '%s' "$PROVISIONING_PROFILE_BASE64" | base64 --decode > "$SECRETS_DIR/repoprompt-ce.provisionprofile"
printf '%s' "$NOTARYTOOL_PRIVATE_KEY_BASE64" | base64 --decode > "$SECRETS_DIR/notarytool-private-key.p8"
echo "REPOPROMPT_PROVISIONING_PROFILE=$SECRETS_DIR/repoprompt-ce.provisionprofile" >> "$GITHUB_ENV"
echo "NOTARYTOOL_PRIVATE_KEY=$SECRETS_DIR/notarytool-private-key.p8" >> "$GITHUB_ENV"
- name: Install Sentry CLI when symbol upload is configured
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: |
set -euo pipefail
if [[ -n "${SENTRY_AUTH_TOKEN:-}" ]] && ! command -v sentry-cli >/dev/null 2>&1; then
brew install getsentry/tools/sentry-cli
fi
- name: Prepare Sentry symbol upload token file
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
run: |
set -euo pipefail
if [[ -n "${SENTRY_AUTH_TOKEN:-}" ]]; then
umask 077
SENTRY_TOKEN_FILE="$RUNNER_TEMP/repoprompt-release-secrets/sentry-auth-token"
printf '%s' "$SENTRY_AUTH_TOKEN" > "$SENTRY_TOKEN_FILE"
echo "REPOPROMPT_SENTRY_AUTH_TOKEN_FILE=$SENTRY_TOKEN_FILE" >> "$GITHUB_ENV"
fi
- name: Sign, notarize, and create draft release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_COMMIT: ${{ needs.validate-ref.outputs.commit }}
RELEASE_TAG: ${{ inputs.tag }}
SOURCE_GITHUB_REPOSITORY: ${{ github.repository }}
REPOPROMPT_CONTROL_PLANE_SCRIPTS_DIR: ${{ github.workspace }}/trusted-control-plane/Scripts
REPOPROMPT_GIT_ROOT: ${{ github.workspace }}/trusted-control-plane
REPOPROMPT_RELEASE_SOURCE_ROOT: ${{ github.workspace }}/release-source
REPOPROMPT_APPROVED_SOURCE_ROOT: ${{ github.workspace }}/approved-source
REPOPROMPT_REQUIRE_HEAD_MATCH: 0
REPOPROMPT_ENABLE_SENTRY: "1"
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
NOTARYTOOL_KEY_ID: ${{ secrets.NOTARYTOOL_KEY_ID }}
NOTARYTOOL_ISSUER_ID: ${{ secrets.NOTARYTOOL_ISSUER_ID }}
# Protected release environment secret. sign_staged_release.sh bakes it
# into the signed bundle's Info.plist as RepoPromptSentryDSN. Optional: if
# unset, telemetry stays off (fail-safe).
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
REPOPROMPT_SENTRY_ORG: ${{ vars.SENTRY_ORG }}
REPOPROMPT_SENTRY_PROJECT: ${{ vars.SENTRY_PROJECT }}
run: ./trusted-control-plane/Scripts/release.sh publish-staged
- name: Remove ephemeral keychain
if: always()
run: |
KEYCHAIN_PATH="$RUNNER_TEMP/repoprompt-release.keychain-db"
CERTIFICATE_PATH="$RUNNER_TEMP/repoprompt-release.p12"
security delete-keychain "$KEYCHAIN_PATH" || true
rm -f "$CERTIFICATE_PATH"
rm -f "$RUNNER_TEMP/repoprompt-release-successor.p12"
rm -f "$RUNNER_TEMP/repoprompt-successor-identity-anchor"
rm -rf "$RUNNER_TEMP/repoprompt-release-secrets"
- name: Upload signed release ZIP for secret-free smoke
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: RepoPrompt-CE-signed-release-zip
path: |
release-source/dist/*.zip
release-source/dist/SHA256SUMS
release-source/dist/*-artifact-manifest.json
if-no-files-found: error
retention-days: 1
smoke-signed-helper:
name: Smoke Signed Helper Without Release Secrets
needs:
- validate-ref
- publish
if: github.ref == 'refs/heads/main'
runs-on: macos-26
permissions:
contents: read
steps:
- name: Check out trusted release tooling
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
ref: ${{ needs.validate-ref.outputs.tooling-commit }}
path: trusted-control-plane
fetch-depth: 1
persist-credentials: false
- name: Download signed release ZIP
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: RepoPrompt-CE-signed-release-zip
path: signed-release
- name: Extract and statically validate signed helper layout
run: |
set -euo pipefail
shopt -s nullglob
archives=(signed-release/*.zip)
checksum_manifests=(signed-release/*SHA256SUMS)
artifact_manifests=(signed-release/*-artifact-manifest.json)
[[ "${#archives[@]}" == "1" ]] || { echo "Expected exactly one signed ZIP" >&2; exit 1; }
[[ "${#checksum_manifests[@]}" == "1" ]] || { echo "Expected exactly one signed ZIP checksum manifest" >&2; exit 1; }
[[ "${#artifact_manifests[@]}" == "1" ]] || { echo "Expected exactly one signed app artifact manifest" >&2; exit 1; }
archive="${archives[0]}"
archive_name="$(basename "$archive")"
archive_checksum_entry="$RUNNER_TEMP/signed-release-zip.sha256"
awk -v archive_name="$archive_name" '$2 == archive_name { print }' "${checksum_manifests[0]}" > "$archive_checksum_entry"
[[ "$(wc -l < "$archive_checksum_entry" | tr -d ' ')" == "1" ]] || { echo "Expected exactly one signed ZIP checksum entry" >&2; exit 1; }
(cd signed-release && shasum -a 256 -c "$archive_checksum_entry")
mkdir extracted
ditto -x -k "$archive" extracted
./trusted-control-plane/Scripts/validate_embedded_mcp_helper_layout.sh \
"extracted/RepoPrompt CE.app" \
"Signed draft MCP helper layout"
./trusted-control-plane/Scripts/validate_app_architectures.sh \
"extracted/RepoPrompt CE.app" \
"arm64,x86_64" \
"Signed draft app"
./trusted-control-plane/Scripts/write_app_artifact_manifest.py verify \
--app "extracted/RepoPrompt CE.app" \
--manifest "${artifact_manifests[0]}" \
--expected-architectures "arm64,x86_64"
echo "ARTIFACT_MANIFEST_PATH=${artifact_manifests[0]}" >> "$GITHUB_ENV"
- name: Execute signed helper and packaged roundtrip without inherited secrets
env:
REPOPROMPT_PACKAGED_SMOKE_TIMEOUT: "240"
REPOPROMPT_PACKAGED_SMOKE_HELPER_TIMEOUT: "60"
run: |
env -i \
PATH=/usr/bin:/bin:/usr/sbin:/sbin \
HOME="$HOME" \
TMPDIR="$RUNNER_TEMP" \
USER="$USER" \
LOGNAME="$LOGNAME" \
./trusted-control-plane/Scripts/smoke_embedded_mcp_helper.sh \
"extracted/RepoPrompt CE.app" \
"Signed draft MCP helper"
env -i \
PATH=/usr/bin:/bin:/usr/sbin:/sbin \
HOME="$HOME" \
TMPDIR="$RUNNER_TEMP" \
USER="$USER" \
LOGNAME="$LOGNAME" \
REPOPROMPT_PACKAGED_SMOKE_TIMEOUT="$REPOPROMPT_PACKAGED_SMOKE_TIMEOUT" \
REPOPROMPT_PACKAGED_SMOKE_HELPER_TIMEOUT="$REPOPROMPT_PACKAGED_SMOKE_HELPER_TIMEOUT" \
./trusted-control-plane/Scripts/smoke_packaged_mcp_roundtrip.sh \
"extracted/RepoPrompt CE.app" \
"Signed draft packaged app" \
"$ARTIFACT_MANIFEST_PATH"