diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 565f713..bec50c3 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -14,6 +14,8 @@ jobs: timeout-minutes: 45 steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - name: Test release signing helper + run: ./scripts/test-sign-latest-alpha.sh - uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin diff --git a/README.md b/README.md index dd931e9..c6ef164 100644 --- a/README.md +++ b/README.md @@ -140,9 +140,11 @@ With an emulator or device running: The debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`. -Release signing is deliberately local and secret-free in Git. See the +Release signing is deliberately local and secret-free in Git. Once the current +`main` commit passes CI, `./scripts/sign-latest-alpha.sh` downloads and verifies +its exact candidate before prompting locally for the signing passwords. See the [release-signing guide](docs/development/release-signing.md) to create and back -up the long-lived key, produce a verified signed APK, and generate its checksum. +up the long-lived key and verify the signed APK. The [technical-alpha guide](docs/alpha-testing.md) defines installation, update, test, feedback, diagnostics, and metrics boundaries. diff --git a/docs/development/release-signing.md b/docs/development/release-signing.md index 3738b9e..308cc04 100644 --- a/docs/development/release-signing.md +++ b/docs/development/release-signing.md @@ -161,18 +161,50 @@ validating the Gradle wrapper, checking the wrapper distribution, and enforcing Gradle dependency checksums. Review changes to those pins and checksums as supply-chain changes before signing a new candidate. -To sign the exact candidate produced by CI, first identify the successful -`Android` run for the intended commit: +### Preferred local path + +From a clean checkout of current `main`, run: ```bash +git switch main git pull --ff-only +./scripts/sign-latest-alpha.sh +``` + +The script finds the successful `Android` push run for the exact checked-out +commit. It refuses tracked local changes, a stale checkout, a failed run, a run +from another branch, or a mismatched commit. It downloads the uniquely named CI +artifact into an owner-only temporary directory and passes it to the existing +signer, which performs the checksum, build-metadata, package, version, alignment, +and certificate checks. The temporary download is removed on exit. + +The standard keystore location from this guide is used automatically: +`$HOME/.local/share/threadline/signing/threadline-release.p12`. Override the +non-secret path or alias only when needed: + +```bash +export THREADLINE_RELEASE_STORE_FILE=/path/to/threadline-release.p12 +export THREADLINE_RELEASE_KEY_ALIAS=threadline-release +./scripts/sign-latest-alpha.sh +unset THREADLINE_RELEASE_STORE_FILE THREADLINE_RELEASE_KEY_ALIAS +``` + +Both passwords are still entered interactively without echoing. They are passed +to `apksigner` through its process environment, cleared when the signer exits, +and never written to a file or command-line argument. + +### Manual candidate selection + +For troubleshooting or deliberate selection of an older successful run, first +confirm that the run succeeded and its `headSha` equals the local `HEAD`: + +```bash git rev-parse HEAD gh run list --workflow Android --branch main --limit 5 gh run view RUN_ID --json conclusion,headSha,url ``` -Confirm that `conclusion` is `success` and `headSha` equals the local `HEAD`. -Then download and verify the artifact: +Then download the exact artifact: ```bash threadline_candidate_dir=$(mktemp -d) @@ -203,8 +235,8 @@ rm -r -- "$threadline_candidate_dir" unset threadline_candidate_apk threadline_candidate_dir threadline_candidate_name ``` -The helper verifies the candidate checksum, source commit, application ID, and -version before asking for signing passwords. Omitting the APK argument keeps the +The signing helper verifies the candidate checksum, source commit, application +ID, and version before asking for passwords. Omitting the APK argument keeps the original local-build path and runs `assembleRelease` before signing. For non-interactive automation, the helper also accepts diff --git a/scripts/sign-latest-alpha.sh b/scripts/sign-latest-alpha.sh new file mode 100755 index 0000000..bf1f1cd --- /dev/null +++ b/scripts/sign-latest-alpha.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) +repository_dir=$(CDPATH= cd -- "$script_dir/.." && pwd -P) +# shellcheck source=project-metadata.sh +THREADLINE_METADATA_REPOSITORY_DIR=$repository_dir +. "$script_dir/project-metadata.sh" +unset THREADLINE_METADATA_REPOSITORY_DIR + +if (( $# != 0 )); then + echo "Usage: $0" >&2 + exit 64 +fi + +if ! command -v gh >/dev/null 2>&1; then + echo "GitHub CLI is required. Install gh and authenticate before signing." >&2 + exit 1 +fi +if ! gh auth status >/dev/null 2>&1; then + echo "GitHub CLI is not authenticated. Run: gh auth login" >&2 + exit 1 +fi +if ! git -C "$repository_dir" diff --quiet || + ! git -C "$repository_dir" diff --cached --quiet; then + echo "Refusing to sign from a checkout with tracked changes." >&2 + exit 1 +fi + +current_commit=$(git -C "$repository_dir" rev-parse HEAD) +main_commit=$(gh api 'repos/{owner}/{repo}/commits/main' --jq .sha) +if [[ $current_commit != "$main_commit" ]]; then + echo "This checkout is not current main." >&2 + echo "Switch to main and pull with --ff-only before signing." >&2 + exit 1 +fi + +run_id=$( + gh run list \ + --workflow Android \ + --branch main \ + --commit "$current_commit" \ + --event push \ + --status success \ + --limit 1 \ + --json databaseId \ + --jq '.[0].databaseId // empty' +) +if [[ -z $run_id ]]; then + echo "No successful main-branch Android push run exists for $current_commit." >&2 + echo "Wait for the required Android workflow to pass before signing." >&2 + exit 1 +fi + +IFS=$'\t' read -r \ + run_workflow run_conclusion run_event run_branch run_commit run_url < <( + gh run view "$run_id" \ + --json workflowName,conclusion,event,headBranch,headSha,url \ + --jq '[.workflowName, .conclusion, .event, .headBranch, .headSha, .url] | @tsv' + ) +if [[ $run_workflow != Android ]] || + [[ $run_conclusion != success ]] || + [[ $run_event != push ]] || + [[ $run_branch != main ]] || + [[ $run_commit != "$current_commit" ]]; then + echo "GitHub run $run_id does not match this checkout's successful main build." >&2 + exit 1 +fi + +candidate_dir=$(mktemp -d "${TMPDIR:-/tmp}/threadline-alpha.XXXXXXXX") +cleanup() { + rm -r -- "$candidate_dir" +} +trap cleanup EXIT + +candidate_name="threadline-${THREADLINE_VERSION_NAME}-UNSIGNED-${current_commit:0:12}" +candidate_apk="$candidate_dir/threadline-${THREADLINE_VERSION_NAME}-UNSIGNED.apk" + +echo "Downloading verified candidate from: $run_url" +gh run download "$run_id" \ + --name "$candidate_name" \ + --dir "$candidate_dir" + +if [[ ! -f $candidate_apk ]]; then + echo "Downloaded artifact did not contain: $(basename -- "$candidate_apk")" >&2 + exit 1 +fi + +THREADLINE_RELEASE_STORE_FILE=${THREADLINE_RELEASE_STORE_FILE:-${XDG_DATA_HOME:-$HOME/.local/share}/threadline/signing/threadline-release.p12} +THREADLINE_RELEASE_KEY_ALIAS=${THREADLINE_RELEASE_KEY_ALIAS:-threadline-release} +export THREADLINE_RELEASE_STORE_FILE THREADLINE_RELEASE_KEY_ALIAS + +"$script_dir/build-signed-alpha.sh" "$candidate_apk" diff --git a/scripts/test-sign-latest-alpha.sh b/scripts/test-sign-latest-alpha.sh new file mode 100755 index 0000000..56884fd --- /dev/null +++ b/scripts/test-sign-latest-alpha.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P) +repository_dir=$(CDPATH= cd -- "$script_dir/.." && pwd -P) +test_root=$(mktemp -d "${TMPDIR:-/tmp}/threadline-sign-test.XXXXXXXX") +trap 'rm -r -- "$test_root"' EXIT + +test_repository=$test_root/repository +test_bin=$test_root/bin +test_home=$test_root/home +signer_log=$test_root/signer.log +mkdir -p "$test_repository/scripts" "$test_bin" "$test_home" +cp "$script_dir/sign-latest-alpha.sh" "$test_repository/scripts/" +cp "$script_dir/project-metadata.sh" "$test_repository/scripts/" + +cat > "$test_repository/gradle.properties" <<'EOF' +threadline.releaseApplicationId=io.github.r055le.threadline +threadline.versionCode=10005 +threadline.versionName=0.1.0-alpha.5 +EOF + +cat > "$test_repository/scripts/build-signed-alpha.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +[[ -f $1 ]] +{ + printf 'candidate=%s\n' "$1" + printf 'store=%s\n' "$THREADLINE_RELEASE_STORE_FILE" + printf 'alias=%s\n' "$THREADLINE_RELEASE_KEY_ALIAS" +} > "$THREADLINE_TEST_SIGNER_LOG" +EOF +chmod +x "$test_repository/scripts/build-signed-alpha.sh" + +cat > "$test_bin/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +case "${1:-} ${2:-}" in + "auth status") + exit 0 + ;; + "api repos/{owner}/{repo}/commits/main") + printf '%s\n' "${THREADLINE_TEST_MAIN_COMMIT:-$THREADLINE_TEST_CURRENT_COMMIT}" + ;; + "run list") + [[ ${THREADLINE_TEST_NO_RUN:-0} == 1 ]] || printf '9001\n' + ;; + "run view") + run_commit=${THREADLINE_TEST_RUN_COMMIT:-$THREADLINE_TEST_CURRENT_COMMIT} + printf 'Android\tsuccess\tpush\tmain\t%s\thttps://example.invalid/run/9001\n' \ + "$run_commit" + ;; + "run download") + shift 2 + artifact_name= + destination= + while (( $# > 0 )); do + case "$1" in + --name) + artifact_name=$2 + shift 2 + ;; + --dir) + destination=$2 + shift 2 + ;; + *) + shift + ;; + esac + done + expected_name="threadline-0.1.0-alpha.5-UNSIGNED-${THREADLINE_TEST_CURRENT_COMMIT:0:12}" + [[ $artifact_name == "$expected_name" ]] + printf 'unsigned fixture\n' > "$destination/threadline-0.1.0-alpha.5-UNSIGNED.apk" + ;; + *) + echo "Unexpected gh invocation: $*" >&2 + exit 1 + ;; +esac +EOF +chmod +x "$test_bin/gh" + +git -C "$test_repository" init --quiet --initial-branch=main +git -C "$test_repository" config user.email threadline-test@example.invalid +git -C "$test_repository" config user.name "Threadline test" +git -C "$test_repository" add . +git -C "$test_repository" commit --quiet -m fixture +test_commit=$(git -C "$test_repository" rev-parse HEAD) + +PATH="$test_bin:$PATH" \ +HOME=$test_home \ +THREADLINE_TEST_CURRENT_COMMIT=$test_commit \ +THREADLINE_TEST_SIGNER_LOG=$signer_log \ + "$test_repository/scripts/sign-latest-alpha.sh" + +grep -F "store=$test_home/.local/share/threadline/signing/threadline-release.p12" \ + "$signer_log" >/dev/null +grep -F "alias=threadline-release" "$signer_log" >/dev/null +candidate_path=$(sed -n 's/^candidate=//p' "$signer_log") +[[ -n $candidate_path && ! -e $(dirname -- "$candidate_path") ]] + +rm -f "$signer_log" +if PATH="$test_bin:$PATH" \ + HOME=$test_home \ + THREADLINE_TEST_CURRENT_COMMIT=$test_commit \ + THREADLINE_TEST_MAIN_COMMIT=0000000000000000000000000000000000000000 \ + THREADLINE_TEST_SIGNER_LOG=$signer_log \ + "$test_repository/scripts/sign-latest-alpha.sh" 2>/dev/null; then + echo "Stale checkout was accepted." >&2 + exit 1 +fi +[[ ! -e $signer_log ]] + +if PATH="$test_bin:$PATH" \ + HOME=$test_home \ + THREADLINE_TEST_CURRENT_COMMIT=$test_commit \ + THREADLINE_TEST_RUN_COMMIT=0000000000000000000000000000000000000000 \ + THREADLINE_TEST_SIGNER_LOG=$signer_log \ + "$test_repository/scripts/sign-latest-alpha.sh" 2>/dev/null; then + echo "Mismatched run commit was accepted." >&2 + exit 1 +fi +[[ ! -e $signer_log ]] + +if PATH="$test_bin:$PATH" \ + HOME=$test_home \ + THREADLINE_TEST_CURRENT_COMMIT=$test_commit \ + THREADLINE_TEST_NO_RUN=1 \ + THREADLINE_TEST_SIGNER_LOG=$signer_log \ + "$test_repository/scripts/sign-latest-alpha.sh" 2>/dev/null; then + echo "Missing successful run was accepted." >&2 + exit 1 +fi +[[ ! -e $signer_log ]] + +printf 'sign-latest-alpha tests passed\n'