Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
44 changes: 38 additions & 6 deletions docs/development/release-signing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
93 changes: 93 additions & 0 deletions scripts/sign-latest-alpha.sh
Original file line number Diff line number Diff line change
@@ -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"
138 changes: 138 additions & 0 deletions scripts/test-sign-latest-alpha.sh
Original file line number Diff line number Diff line change
@@ -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'